<!-- SOLID CITY · part 3101 -->
r g = new T.BufferGeometry();
    g.setAttribute('position', new T.Float32BufferAttribute(this.pos, 3));
    g.setAttribute('normal', new T.Float32BufferAttribute(this.nor, 3));
    g.setAttribute('color', new T.Float32BufferAttribute(this.col, 3));
    g.setIndex(this.idx);
    var m = new T.Mesh(g, mat); m.frustumCulled = false; return m;
  };
  var solid = new Batch(), glow = new Batch(), glassB = new Batch();
  function box(b, w, h, d, m, color) { b.add(new T.BoxGeometry(w, h, d), m, color); }
  function cyl(b, rt, rb, h, s, m, color) { b.add(new T.CylinderGeometry(rt, rb, h, s), m, color); }
  function sph(b, r, m, color, sx, sy, sz) { b.add(new T.SphereGeometry(r, 10, 7), m, color); }
  function cone(b, r, h, s, m, color) { b.add(new T.ConeGeometry(r, h, s), m, color); }

  /* red and black marble — one canvas, veined by hand */
  function marbleTex(redBase) {
    var cv = document.createElement('canvas'); cv.width = 256; cv.height = 256;
    var g = cv.getContext('2d');
    g.fillStyle = redBase ? '#57101b' : '#0d0b0e'; g.fillRect(0, 0, 256, 256);
    for (var i = 0; i < 46; i++) {
      g.strokeStyle = redBase ? 'rgba(255,120,120,' + (0.05 + R() * 0.16) + ')' : 'rgba(200,30,50,' + (0.08 + R() * 0.2) + ')';
      g.lineWidth = 0.6 + R() * 1.8; g.beginPath();
      var x = R() * 256, y = R() * 256; g.moveTo(x, y);
      for (var k = 0; k < 4; k++) { x += (R() - 0.5) * 90; y += (R() - 0.5) * 90; g.lineTo(x, y); }
      g.stroke();
    }
    for (var j = 0; j < 26; j++) { g.fillStyle = 'rgba(240,220,190,' + (0.03 + R() * 0.06) + ')'; g.fillRect(R() * 256, R() * 256, 1.4, 1.4); }
    var t = new T.CanvasTexture(cv); t.encoding = T.sRGBEncoding; t.wrapS = t.wrapT = T.RepeatWrapping;
    return t;
  }
  var marbleBlack = marbleTex(false), marbleRed = marbleTex(true);
  /* THE TWO CLOTHS (the King's word, 2026-10-14): "instead
   * of using color use the images to create the effects" —
   * the lava-glow marble is a real AI-struck image, woven at
   * two qualities: the high cloth for strong phones, the low
   * for phones that struggle, so everybody gets to play. The
   * placer chooses the tier and hands the texture in; the
   * cloth breathes (update) — its seams drift and its light
   * pulses, slowly, like cooling fire. */
  var lavaTex = opts.texLava || null, lavaMats = [], lavaClones = [];
  /* THE FOLD (the King's word, 2026-10-14): the image is
   * rayscanned into a heightfield — stone faces stand
   * CONVEX, lava seams sink CONCAVE — and objects are baked
   * OUT of the image: their vertices pushed along their
   * normals by the scan, then flat-shaded into crystal.
   * Relief by tier: a derived normal map for the high
   * cloth, a bump map for the low. All baked at build;
   * nothing per frame but the breathing. */
  var HEIGHT = opts.texHeight || null;
  function imgRaw(u, v) {
    var w = HEIGHT.w, h = HEIGHT.h, d = HEIGHT.data;
    var st = d.length >= w * h * 4 ? 4 : 3;
    var x = ((Math.floor(u * w) % w) + w) % w, y = ((Math.floor((1 - v) * h) % h) + h) % h;
    var i = (y * w + x) * st, r = d[i], g = d[i + 1], b = d[i + 2];
    var lum = (0.35 * r + 0.5 * g + 0.15 * b) / 255;
    var vein = Math.max(0, r - b) / 160; if (vein > 1) vein = 1;
    /* the fold is NORMALIZED to the image's own histogram
     * (measured on first use): the scan's 5th–95th luminance
     * percentiles are stretched across the relief, so the
     * brightest stone always stands convex and the darkest
     * pits sit below the face — whatever image is struck,
     * the fold fits it. The lava seams still sink deepest:
     * their vein signal outweighs their light. */
    if (!HEIGHT._norm) {
      var lums = [];
      for (var gy = 0; gy < 128; gy++) for (var gx = 0; gx < 128; gx++) {
        var gi = ((gy * HEIGHT.h / 128 | 0) * HEIGHT.w + (gx * HEIGHT.w / 128 | 0)) * (HEIGHT.data.length >= HEIGHT.w * HEIGHT.h * 4 ? 4 : 3);
        lums.push((0.35 * HEIGHT.data[gi] + 0.5 * HEIGHT.data[gi + 1] + 0.15 * HEIGHT.data[gi + 2]) / 255);
      }
      lums.sort(function (a, b) { return a - b; });
      HEIGHT._norm = { lo: lums[Math.floor(lums.length * 0.05)], hi: lums[Math.floor(lums.length * 0.95)] };
      if (HEIGHT._norm.hi - HEIGHT._norm.lo < 0.05) HEIGHT._norm.hi = HEIGHT._norm.lo + 0.05;
    }
    var ln = (lum - HEIGHT._norm.lo) / (HEIGHT._norm.hi - HEIGHT._norm.lo);
    ln = ln < 0 ? 0 : ln > 1 ? 1 : ln;
    var hh = (ln - 0.30) * 2.4 - vein * 1.5;
    return hh < -1.7 ? -1.7 : hh > 1.8 ? 1.8 : hh;
  }
  /* the heightfield made PERIODIC before it is trusted: a
   * cylinder's seam must close — the last stretch of each
   * axis is cross-faded into its first, so the folded
   * surface is one unbroken manifold, no step at the wrap.
   * (The geometry studied properly, per the King's word:
   * master-class or nothing.) */
  function imgH(u, v) {
    if (!HEIGHT) return 0;
    var fu = u - Math.floor(u), fv = v - Math.floor(v), h0 = imgRaw(fu, fv);
    if (fu > 0.85) { var wu