<!-- SOLID CITY · part 3159 -->
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 x = ((Math.floor(u * w) % w) + w) % w, y = ((Math.floor((1 - v) * h) % h) + h) % h;
    var i = (y * w + x) * 4, 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;
    /* calibrated to the image's own distribution (the scan
     * measured, not guessed): the black pits sit just below
     * the face, the polished stone and red swirls stand
     * convex above it, and the lava seams — the brightest
     * thing in the picture — sink deepest of all, because
     * their vein signal outweighs their light */
    var hh = (lum - 0.16) * 2.2 - vein * 1.6;
    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 = (fu - 0.85) / 0.15; h0 = h0 * (1 - wu) + imgRaw(fu - 1, fv) * wu; }
    if (fv > 0.85) { var wv = (fv - 0.85) / 0.15; h0 = h0 * (1 - wv) + imgRaw(fu, fv - 1) * wv; }
    return h0;
  }
  function reliefTexture(kind, size) {
    if (!HEIGHT) return null;
    var data = new Uint8Array(size * size * 4);
    for (var y = 0; y < size; y++) for (var x = 0; x < size; x++) {
      var i = (y * size + x) * 4;
      if (kind === 'bump') { var hv = Math.max(0, Math.min(255, Math.round((imgH(x / size, y / size) + 1.6) / 3.4 * 255))); data[i] = data[i + 1] = data[i + 2] = hv; data[i + 3] = 255; }
      else {
        var s = 1.7;
        var dx = (imgH((x - 1) / size, y / size) - imgH((x + 1) / size, y / size)) * s;
        var dy = (imgH(x / size, (y - 1) / size) - imgH(x / size, (y + 1) / size)) * s;
        var inv = 1 / Math.sqrt(dx * dx + dy * dy + 1);
        data[i] = Math.round((dx * inv * 0.5 + 0.5) * 255); data[i + 1] = Math.round((dy * inv * 0.5 + 0.5) * 255); data[i + 2] = Math.round((inv * 0.5 + 0.5) * 255); data[i + 3] = 255;
      }
    }
    var t = new T.DataTexture(data, size, size); t.needsUpdate = true; t.wrapS = t.wrapT = T.RepeatWrapping;
    return t;
  }
  var NRM = reliefTexture('normal', 256), BMP = reliefTexture('bump', 128);
  var molded = { baked: false, maxRise: 0, maxSink: 0 };
  function displace(geo, rx, ry, depth) {
    if (!HEIGHT || !geo.attributes.uv || !geo.attributes.normal) return geo;
    var p = geo.attributes.position, n = geo.attributes.normal, uv = geo.attributes.uv;
    for (var i = 0; i < p.count; i++) {
      var d = imgH(uv.getX(i) * rx, uv.getY(i) * ry) * depth;
      if (d > molded.maxRise) molded.maxRise = d;
      if (d < molded.maxSink) molded.maxSink = d;
      p.setXYZ(i, p.getX(i) + n.getX(i) * d, p.getY(i) + n.getY(i) * d, p.getZ(i) + n.getZ(i) * d);
    }
    geo.computeVertexNormals(); molded