<!-- SOLID CITY · part 1208 -->
e, k), t1 = edgeIdx(e, k + 1), s0 = sk[e] + k, s1 = s0 + 1;
    idx.push(t0, t1, s0, t1, s1, s0, t0, s0, t1, t1, s0, s1); // both windings (cheap, avoids cull holes)
  }
  var g = new T.BufferGeometry();
  g.setAttribute('position', new T.BufferAttribute(pos, 3));
  g.setAttribute('normal', new T.BufferAttribute(nor, 3));
  g.setAttribute('color', new T.BufferAttribute(col, 3));
  g.setIndex(idx);
  g.boundingSphere = new T.Sphere(new T.Vector3(0, 0, 0), size * 0.75 + 4500);
  var m = new T.Mesh(g, TERRAIN_MAT);
  m.position.set(cx, 0, cz); m.matrixAutoUpdate = false; m.updateMatrix(); m.visible = false;
  world.add(m);
  return m;
}
function getChunk(cx, cz, size) {
  var k = nodeKey(cx, cz, size), c = chunks.get(k);
  return c;
}
var wanted = new Set(), visibleNow = [];
function lodSelect(cam) {   // cam: absolute {x, y, z}
  var out = [], queue = [];
  function ready(node) { var c = getChunk(node.cx, node.cz, node.size); return c && c.built; }
  function need(node) {
    var k = nodeKey(node.cx, node.cz, node.size);
    if (!chunks.has(k)) { chunks.set(k, { built: false, mesh: null, last: 0 }); buildQueue.push(node); }
    return chunks.get(k);
  }
  function shouldSplit(node) {
    if (node.size <= MIN_SIZE) return false;
    var dx = Math.max(Math.abs(cam.x - node.cx) - node.size / 2, 0), dz = Math.max(Math.abs(cam.z - node.cz) - node.size / 2, 0);
    var dy = Math.max(cam.y - 1200, 0) * 0.6;
    var d = Math.sqrt(dx * dx + dz * dz + dy * dy);
    if (node.size > 40000 && Math.abs(node.cx - ROOT.cx) + Math.abs(node.cz - ROOT.cz) > 1 && d > 60000) return false;
    return d < node.size * SPLIT_K * (S.mode === 'walk' ? 0.36 : 0.5);
  }
  function kids(node) { var q = node.size / 4, s = node.size / 2; return [{ cx: node.cx - q, cz: node.cz - q, size: s }, { cx: node.cx + q, cz: node.cz - q, size: s }, { cx: node.cx - q, cz: node.cz + q, size: s }, { cx: node.cx + q, cz: node.cz + q, size: s }]; }
  // returns true if the subtree can be drawn completely
  function walk(node, depth) {
    if (shouldSplit(node)) {
      var ks = kids(node), sub = [], ok = true;
      for (var i = 0; i < 4; i++) { var r = []; if (!collect(ks[i], r)) ok = false; sub.push(r); }
      if (ok) { for (i = 0; i < 4; i++) for (var j = 0; j < sub[i].length; j++) out.push(sub[i][j]); return true; }
      var self = need(node); if (self.built) { out.push(self); return true; }
      return false;
    }
    var c = need(node); if (c.built) { out.push(c); return true; }
    return false;
  }
  function collect(node, arr) {
    var save = out; out = arr; var ok = walk(node); out = save; return ok;
  }
  walk(ROOT, 0);
  return out;
}
var frameNo = 0;
function updateTerrain(cam, budgetMs) {
  frameNo++;
  var sel = lodSelect(cam);
  for (var i = 0; i < visibleNow.length; i++) if (visibleNow[i].mesh) visibleNow[i].mesh.visible = false;
  for (i = 0; i < sel.length; i++) { if (sel[i].mesh) sel[i].mesh.visible = true; sel[i].last = frameNo; }
  visibleNow = sel;
  // build nearest-first within a time budget
  if (buildQueue.length) {
    buildQueue.sort(function (a, b) { return (a.size - b.size) * 0 + (Math.hypot(a.cx - cam.x, a.cz - cam.z) / a.size) - (Math.hypot(b.cx - cam.x, b.cz - cam.z) / b.size); });
    var t0 = performance.now(), built = 0;
    while (buildQueue.length && (built < 1 || performance.now() - t0 < budgetMs)) {
      var nd = buildQueue.shift(), c = chunks.get(nodeKey(nd.cx, nd.cz, nd.size));
      if (!c || c.built) continue;
      c.mesh = buildChunk(nd.cx, nd.cz, nd.size); c.built = true; built++;
    }
  }
  // evict stale chunks
  if (frameNo % 120 === 0 && chunks.size > 420) {
    chunks.forEach(function (c, k) {
      if (c.built && frameNo - c.last > 600 && chunks.size > 300) { if (c.mesh) { world.remove(c.mesh); c.mesh.geometry.dispose(); } chunks.delete(k); }
    });
    buildQueue = buildQueue.filter(function (n) { return chunks.has(nodeKey(n.cx, n.cz, n.size)); });
  }
}
function terrainStats() { var t = 0, v = 0; for (var i = 0; i < visibleNow.length; i++) if (visibleNow[i].mesh) { v++; t += visibleNow[i].mesh.geometry.index.count / 3; } return { chunks: v, tris: t, cached: chunks.size, queue: buildQueue.length }; }

/* ---------------- ocean disc (follows the camera, curved) ---------------- */
var ocean = (function () {
  var rings = [0], rr = 40; while (rr < 900000) { rings.push(rr); rr *= 1.22; }
  var segs = 96, pos = [], idx = [];
  for (var r = 0; r < rings.length; r++) for (var s = 0; s <= segs; s++) { var a = s / segs * Math.PI * 2; pos.push(Math.cos(a) * rings[r], 0, Math.sin(a) * rings[r]); }
  for (r = 0; r < rings.length - 1; r++) for (s = 0; s < segs; s++) { var a0 = r * (segs + 1) + s, b0 = a0 + 1, c0 = a0 + segs + 1, d0 = c0 + 1; idx.push(a0, b0, c0, b0, d0, c0); }
  var g = new T.BufferGeometry(); g.setAttribute('position', new T.Float32BufferAttribute(pos, 3)); g.setIndex(idx); g.computeVertexNormals();
  var m = new T.Mesh(g, curve(new T.MeshPhongMaterial({ c