<!-- PLAYA 2.0'S WORLD · part 9 -->
eo.clone()); g.applyMatrix4(m);
    if (!buckets.has(mat)) buckets.set(mat, []);
    buckets.get(mat).push(g); geo.dispose();
  };
  const box = (mat, w, h, dd, x, y, z, rx, ry, rz) => add(mat, new THREE.BoxGeometry(d(w), d(h), d(dd)), x, y, z, rx, ry, rz);
  const cyl = (mat, rt, rb, h, seg, x, y, z, rx, ry, rz) => add(mat, new THREE.CylinderGeometry(d(rt * 2) / 2, d(rb * 2) / 2, d(h), seg || 12), x, y, z, rx, ry, rz);
  // cable/beam between two points (diameter >= 0.13)
  const rod = (mat, r, a, b, seg = 6) => {
    const A = new THREE.Vector3(...a), B = new THREE.Vector3(...b), len = A.distanceTo(B);
    const g = new THREE.CylinderGeometry(d(r * 2) / 2, r, d(len), seg);
    const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), B.clone().sub(A).normalize());
    const m = new THREE.Matrix4().compose(A.clone().add(B).multiplyScalar(0.5), q, new THREE.Vector3(1, 1, 1));
    const ng = g.toNonIndexed(); ng.applyMatrix4(m); g.dispose();
    if (!buckets.has(mat)) buckets.set(mat, []); buckets.get(mat).push(ng);
  };
  const torus = (mat, R, r, x, y, z, rx = Math.PI / 2, ry = 0, rz = 0) => add(mat, new THREE.TorusGeometry(d(R * 2) / 2, d(r * 2) / 2, 8, 32), x, y, z, rx, ry, rz);
  const build = () => {
    for (const [mat, list] of buckets) {
      let n = 0; list.forEach(g => n += g.attributes.position.count);
      const pos = new Float32Array(n * 3), nor = new Float32Array(n * 3); let o = 0;
      list.forEach(g => { pos.set(g.attributes.position.array, o * 3); nor.set(g.attributes.normal.array, o * 3); o += g.attributes.position.count; g.dispose(); });
      const geo = new THREE.BufferGeometry();
      geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
      geo.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
      geo.computeBoundingSphere();
      const mesh = new THREE.Mesh(geo, mat); mesh.castShadow = mesh.receiveShadow = true; group.add(mesh);
    }
    buckets.clear();
  };
  return { d, add, box, cyl, rod, torus, build };
}
function mats(THREE, opts) {
  const red = opts.red ?? 0xc8102e, black = opts.black ?? 0x0b0b0d;
  return {
    black: new THREE.MeshStandardMaterial({ color: black, metalness: 0.6, roughness: 0.35 }),
    graphite: new THREE.MeshStandardMaterial({ color: 0x23232a, metalness: 0.4, roughness: 0.55 }),
    red: new THREE.MeshStandardMaterial({ color: red, metalness: 0.3, roughness: 0.4 }),
    glow: new THREE.MeshStandardMaterial({ color: red, emissive: red, emissiveIntensity: opts.glow ?? 1.6, roughness: 0.3 }),
    stone: new THREE.MeshStandardMaterial({ color: 0x2c2c30, roughness: 0.85 }),
  };
}
// ---- ctx-aware placement (new8 core loader passes opts.ctx). Without ctx: legacy behaviour. ----
function layoutInfo(L = {}) {
  const P = L.P ?? 40, H = L.HALF ?? 15, LIM = L.LIM ?? 190, RZ0 = L.RZ0 ?? 106, RZ1 = L.RZ1 ?? 134, BR = L.BRIDGES || [];
  const N = Math.floor((LIM - H) / P), blocks = [];
  for (let i = -N; i <= N; i++) for (let j = -N; j <= N; j++) {
    const x = i * P, z = j * P; if (z > RZ0 - H && z < RZ1 + H) continue; // river row has no block
    blocks.push({ i, j, type: !i && !j ? 'plaza' : Math.max(Math.abs(i), Math.abs(j)) <= 1 ? 'tower' : 'block', x0: x - H, x1: x + H, z0: z - H, z1: z + H });
  }
  const edge = N * P + H + 0.8, band = { c: (edge + LIM) / 2, hw: (LIM - edge) / 2 }; // open ring between outer blocks (+lamps) and the walk limit
  return { P, H, LIM, RZ0, RZ1, BR, N, blocks, band, road: (P - 2 * H) / 2 - 0.6 };
}
const hit = (a, b, m = 0) => a.x0 < b.x1 + m && a.x1 > b.x0 - m && a.z0 < b.z1 + m && a.z1 > b.z0 - m;
const rectAt = (x, z, hx, hz) => ({ x0: x - hx, x1: x + hx, z0: z - hz, z1: z + hz });
function spotFree(ctx, I, r, water) {
  if (I.blocks.some(b => hit(r, b, 0.5))) return false;
  if (!water && r.z1 > I.RZ0 - 2 && r.z0 < I.RZ1 + 2) return false;
  if (r.x0 < -I.LIM || r.x1 > I.LIM || r.z0 < -I.LIM || r.z1 > I.LIM) return false;
  return !(ctx.__new8Claims || []).some(c => hit(r, c, 1));
}
// kind 'land': half-extents hx,hz must fit the open outer ring (south side skipped: bus route A runs there).
// kind 'bridge': spans the river along a road line without an existing core bridge (rotated 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,