<!-- NEW8 — EMBER CITY STRUCTURES · part 2 -->
d to run N-S).
function pickSpot(ctx, opts, kind, hx, hz, pref = 0) {
  const I = layoutInfo(ctx.layout); let cands = [];
  if (kind === 'bridge') {
    const zc = (I.RZ0 + I.RZ1) / 2;
    for (let k = -I.N; k < I.N; k++) { const x = (k + 0.5) * I.P; if (I.BR.some(b => Math.abs(b - x) < 12)) continue; cands.push({ x, z: zc, ry: Math.PI / 2, r: rectAt(x, zc, hz, hx) }); }
  } else {
    const c = I.band.c;
    for (let t = -I.N * I.P; t <= I.N * I.P; t += I.P / 2) cands.push({ x: t, z: -c }, { x: c, z: t }, { x: -c, z: t });
    cands = cands.map(o => ({ ...o, ry: 0, r: rectAt(o.x, o.z, hx, hz) }));
  }
  let chosen = null;
  if (opts.position) { const [x, , z] = opts.position, ry = opts.rotationY || 0, sw = Math.abs(Math.sin(ry)) > 0.5; const r = sw ? rectAt(x, z, hz, hx) : rectAt(x, z, hx, hz); if (spotFree(ctx, I, r, kind === 'bridge')) chosen = { x, z, ry, r }; else console.warn('[new8 module] position', opts.position, 'overlaps the core layout; picking an open spot'); }
  const fits = kind === 'bridge' ? Math.max(hz, 0) <= I.road : Math.max(hx, hz) <= I.band.hw;
  if (!chosen && fits) for (let n = 0; n < cands.length; n++) { const o = cands[(pref + n) % cands.length]; if (spotFree(ctx, I, o.r, kind === 'bridge')) { chosen = o; break; } }
  if (!chosen) throw new Error('no open spot for ' + kind + ' footprint ' + hx + 'x' + hz);
  (ctx.__new8Claims ||= []).push(chosen.r); return chosen;
}
// place group: with ctx -> open spot + ctx.groundY + colliders; without ctx -> opts.position as before.
function place(scene, group, opts, foot) {
  const ctx = opts.ctx, s = opts.scale ?? 1; if (s < MIN) throw new Error('scale below 0.13'); group.scale.setScalar(s);
  if (ctx && ctx.layout) {
    const sp = pickSpot(ctx, opts, foot.kind, foot.hx * s, foot.hz * s, opts.spot ?? foot.pref ?? 0);
    const gy = typeof ctx.groundY === 'function' ? ctx.groundY(sp.x, sp.z) : 0;
    group.position.set(sp.x, gy, sp.z); group.rotation.y = sp.ry;
  } else {
    const p = opts.position || [0, 0, 0]; group.position.set(p[0], p[1], p[2]); group.rotation.y = opts.rotationY || 0;
  }
  scene.add(group);
  const cols = [];
  if (ctx && typeof ctx.addCollider === 'function') {
    scene.updateMatrixWorld(true); const m = group.matrixWorld, THREE_V = group.position.constructor;
    for (const [x0, x1, z0, z1, h] of foot.cols || []) {
      let a = Infinity, b = -Infinity, c = Infinity, d = -Infinity;
      for (const [x, z] of [[x0, z0], [x1, z0], [x0, z1], [x1, z1]]) { const v = new THREE_V(x, 0, z).applyMatrix4(m); a = Math.min(a, v.x); b = Math.max(b, v.x); c = Math.min(c, v.z); d = Math.max(d, v.z); }
      const top = group.position.y + h * s; ctx.addCollider(a, b, c, d, top); cols.push([a, b, c, d, top]);
    }
  }
  group.userData.colliders = cols; group.userData.footprint = foot.kind; return group;
}

// Walkable deck for the new8 core (ctx): core groundY = spot y + dh on the deck, ramping over the last RAMP units
// down to street level (0.03) at each end. Walking surface (graphite top) sits exactly at dh; ramps replace end abutments.
const RAMP = 5, STREET = -0.1; // local y of street at deck ends (0.03 street - 0.13 spot ground)
function rampBox(k, mat, w, t, d, xEnd, z, dh, yTopOff = 0) { // slab whose top runs from (xEnd - sign*RAMP, dh) to (xEnd, STREET)
  const sg = Math.sign(xEnd), x0 = xEnd - sg * RAMP, dy = dh - STREET, len = Math.hypot(RAMP, dy), a = Math.atan2(dy, RAMP);
  const rz = sg > 0 ? -a : a, nx = -Math.sin(rz), ny = Math.cos(rz);
  const mx = (x0 + xEnd) / 2, my = (dh + STREET) / 2 + yTopOff;
  k.box(mat, len, t, d, mx - nx * t / 2, my - ny * t / 2, z, 0, 0, rz);
}
function walkDeck(k, M, opts, L, W, dh, gW, railX, railH) {
  if (!opts.ctx) return false;
  const Lw = L - 2 * RAMP;
  k.box(M.black, Lw, 0.5, W, 0, dh - 0.14 - 0.25, 0);
  k.box(M.graphite, Lw, 0.14, gW, 0, dh - 0.07, 0);
  for (const xe of [-L / 2, L / 2]) {
    rampBox(k, M.black, W, 0.5, W, xe, 0, dh);
    for (const sd of [-1, 1]) rampBox(k, M.black, 0.2, railH, 0.2, xe, sd * railX, dh, railH); // sloped railing on ramp
  }
  return true;
}

// Suspension bridge: two red portal towers, parabolic main cables, vertical hangers.
export function createSuspensionBridge(THREE, scene, opts = {}) {
  const g = new THREE.Group(); g.name = 'SuspensionBridge';
  const M = mats(THREE, opts), k = kit(THREE, g);
  const L = opts.length ?? 60, W = opts.width ?? 6, dh = opts.deckHeight ?? 3, th = opts.towerHeight ?? 16;
  const tx = L * 0.3;
  const walk = walkDeck(k, M, opts, L, W, dh, W - 1.4, W / 2 - 0.1, 0.6), Ld = walk ? L - 2 * RAMP : L, ys = walk ? -0.14 : 0;
  if (!walk) { k.box(M.black, L, 0.5, W, 0, dh - 0.25, 0); k.box(M.graphite, L, 0.14, W - 1.4, 0, dh + 0.07, 0); }
  [-1, 1].forEach(s => { k.box(M.glow, Ld, 0.14, 0.14, 0, dh + 0.05 + ys, s * (W / 2 - 0.2)); k.box(M.black, Ld, 0.6, 0.2, 0, dh + 0.3, s * (W / 2 - 0.1)); });
  for (const x of [-tx, tx]) {
    for (const s of [-1, 1]) k.box(M.red, 0.9