<!-- SOLID CITY · part 67 -->
= Math.max(1, Math.ceil(d / step)); for (var k = 1; k <= n; k++) out.push({ x: a.x + (b.x - a.x) * k / n, z: a.z + (b.z - a.z) * k / n }); }
  return out;
}
function normals(p) {
  return p.map(function (q, i) {
    var a = p[Math.max(0, i - 1)], b = p[Math.min(p.length - 1, i + 1)], dx = b.x - a.x, dz = b.z - a.z, l = Math.hypot(dx, dz) || 1; return { x: -dz / l, z: dx / l };
  });
}
function strip(M, p, nr, off, hw, y) {   // a ribbon along polyline p, centred `off` m to the side, half-width hw
  var pos = [];
  for (var i = 1; i < p.length; i++) {
    var a = p[i - 1], b = p[i], na = nr[i - 1], nb = nr[i];
    var aL = [a.x + na.x * (off - hw), a.z + na.z * (off - hw)], aR = [a.x + na.x * (off + hw), a.z + na.z * (off + hw)];
    var bL = [b.x + nb.x * (off - hw), b.z + nb.z * (off - hw)], bR = [b.x + nb.x * (off + hw), b.z + nb.z * (off + hw)];
    pos.push(aL[0], y, aL[1], bL[0], y, bL[1], aR[0], y, aR[1], aR[0], y, aR[1], bL[0], y, bL[1], bR[0], y, bR[1]);
  }
  M.raw(pos);
}
function dashes(M, p, hw, y) {   // red centre dashes: 3 m on, 6 m off
  var pos = [], acc = 0;
  for (var i = 1; i < p.length; i++) {
    var a = p[i - 1], b = p[i], d = Math.hypot(b.x - a.x, b.z - a.z), ux = (b.x - a.x) / (d || 1), uz = (b.z - a.z) / (d || 1), nx = -uz * hw, nz = ux * hw;
    for (var s = (9 - acc % 9) % 9; s + 3 <= d; s += 9) {
      var x0 = a.x + ux * s, z0 = a.z + uz * s, x1 = x0 + ux * 3, z1 = z0 + uz * 3;
      pos.push(x0 - nx, y, z0 - nz, x1 - nx, y, z1 - nz, x0 + nx, y, z0 + nz, x0 + nx, y, z0 + nz, x1 - nx, y, z1 - nz, x1 + nx, y, z1 + nz);
    }
    acc += d;
  }
  if (pos.length) M.raw(pos);
}

/* ======================= sign atlas (one texture, one draw call) ======================= */
var ATL = { cols: 4, rows: 12, w: 1024, h: 1536, n: 0, cv: null, g: null, tex: null };
function atlasCell(line1, line2, arrow) {
  if (!ATL.cv) { ATL.cv = document.createElement('canvas'); ATL.cv.width = ATL.w; ATL.cv.height = ATL.h; ATL.g = ATL.cv.getContext('2d'); }
  var i = ATL.n++, cw = ATL.w / ATL.cols, ch = ATL.h / ATL.rows, x = (i % ATL.cols) * cw, y = Math.floor(i / ATL.cols) * ch, g = ATL.g;
  g.fillStyle = '#0b0709'; g.fillRect(x, y, cw, ch); g.strokeStyle = '#c99a3a'; g.lineWidth = 5; g.strokeRect(x + 4, y + 4, cw - 8, ch - 8);
  g.fillStyle = '#ff2a40'; g.fillRect(x + 10, y + 10, 8, ch - 20);
  g.textBaseline = 'middle'; g.fillStyle = '#f6dc8e'; g.font = '800 26px system-ui, sans-serif';
  var t1 = (arrow ? arrow + ' ' : '') + line1; while (g.measureText(t1).width > cw - 36 && g.font !== '800 16px system-ui, sans-serif') g.font = g.font.replace(/\d+px/, function (m) { return (parseInt(m, 10) - 2) + 'px'; });
  g.fillText(t1, x + 26, y + ch * 0.38);
  g.fillStyle = '#e9dcc2'; g.font = '600 19px system-ui, sans-serif'; g.fillText(line2 || '', x + 26, y + ch * 0.72);
  return { u0: (x) / ATL.w, u1: (x + cw) / ATL.w, v0: 1 - (y + ch) / ATL.h, v1: 1 - y / ATL.h };
}
function signBoard(M, cell, x, z, rotY, w, h, y) {   // two-sided board facing rotY, uv from the atlas
  var c = Math.cos(rotY), s = Math.sin(rotY), hx = c * w / 2, hz = -s * w / 2, pos = [], uv = [];
  var L = [x - hx, z - hz], R = [x + hx, z + hz], y0 = y, y1 = y + h;
  // front (normal along (s, c))
  pos.push(L[0], y0, L[1], R[0], y0, R[1], L[0], y1, L[1], L[0], y1, L[1], R[0], y0, R[1], R[0], y1, R[1]);
  uv.push(cell.u0, cell.v0, cell.u1, cell.v0, cell.u0, cell.v1, cell.u0, cell.v1, cell.u1, cell.v0, cell.u1, cell.v1);
  // back (mirrored so it reads correctly from behind)
  pos.push(R[0], y0, R[1], L[0], y0, L[1], R[0], y1, R[1], R[0], y1, R[1], L[0], y0, L[1], L[0], y1, L[1]);
  uv.push(cell.u0, cell.v0, cell.u1, cell.v0, cell.u0, cell.v1, cell.u0, cell.v1, cell.u1, cell.v0, cell.u1, cell.v1);
  M.raw(pos, uv);
}

/* ======================= materials ======================= */
var MAT = null;
function mats() {
  if (MAT) return MAT;
  MAT = {
    asphalt: curve(new T.MeshLambertMaterial({ color: 0x2e252c, side: T.DoubleSide, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 })),
    gold: curve(new T.MeshLambertMaterial({ color: 0xd8a944, emissive: 0x4a3008, side: T.DoubleSide })),
    red: curve(new T.MeshBasicMaterial({ color: 0xff2a40, side: T.DoubleSide })),
    dark: curve(new T.MeshLambertMaterial({ color: 0x15101a })),
    stone: curve(new T.MeshLambertMaterial({ color: 0x2a2026 })),
    glow: curve(new T.MeshBasicMaterial({ color: 0xffd98a })),
    glass: curve(new T.MeshLambertMaterial({ color: 0x3a0c12, emissive: 0x5a0a14 }))
  };
  return MAT;
}

/* ======================= roads, lamps, signs, pads, arch, far proxies ======================= */
var ROADS = [];   // {id, pts:[{x,z}], kind:'inner'|'radial'|'ring'}
function planRoads() {
  CAMPUS.forEach(function (c) {
    var a = ANCHOR[c.id], p = null;
    try { p = window.CityHyper && CityHyper.findPath ? CityHyper.findPath({ x: a.x, z: a.z }, { x: c.gate.x, z: c.gate.z }) : null; } catch (e) { p = null; }
    ROADS.push({ id: c