blob_and_ornament_v1

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/9cfba6b3fe0bfe9d/" sandbox="allow-scripts allow-forms allow-modals allow-downloads" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>BLOB AND ORNAMENT v1.0</title>
<style>
:root{--bg:#ececec;--ink:#151515;--mute:#8e8e8e;--line:#e6e6e6}
*{box-sizing:border-box;margin:0;padding:0}
[hidden]{display:none!important}
html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--ink);
  font:500 9px/1.3 -apple-system,BlinkMacSystemFont,"Helvetica Neue",Helvetica,Arial,sans-serif;
  letter-spacing:.12em;text-transform:uppercase;user-select:none;-webkit-user-select:none}
#view{position:fixed;inset:0}
canvas{display:block;width:100%;height:100%;touch-action:none;outline:none}

.panel{position:fixed;background:#fff;border-radius:6px;padding:10px 11px;
  box-shadow:0 1px 2px rgba(0,0,0,.04),0 6px 24px rgba(0,0,0,.05)}
#brand{position:fixed;left:16px;top:14px;pointer-events:none}
#brand b{display:block;font-weight:600;letter-spacing:.2em}
#brand span{display:block;color:var(--mute);margin-top:4px;font-variant-numeric:tabular-nums}
#hint{position:fixed;left:50%;top:14px;transform:translateX(-50%);color:var(--mute);pointer-events:none;white-space:nowrap}

#views{left:50%;top:14px;transform:translateX(-50%);display:flex;gap:4px;padding:5px}
#views button{padding:5px 8px;color:var(--mute)}
#views button.on{color:var(--ink)}
#add{left:14px;top:50%;transform:translateY(-50%);width:116px;display:flex;flex-direction:column;gap:5px}
#side{right:14px;top:14px;width:200px;max-height:calc(100% - 28px);overflow:auto}
#bar{left:50%;bottom:16px;transform:translateX(-50%);display:flex;gap:5px;padding:6px}
#modes{left:14px;top:58px;display:flex;gap:4px;padding:5px}
#modes button{padding:6px 10px;color:var(--mute)}
#modes button.on{color:var(--ink);border-color:var(--ink)}
#pal{left:14px;top:112px;width:132px;display:flex;flex-direction:column;gap:4px}
#pal button{color:var(--mute);justify-content:flex-start}
#pal button.on{color:var(--ink);border-color:var(--ink)}
#pal button svg{stroke:currentColor}
#pal .note{color:var(--mute);margin-top:6px;line-height:1.5;letter-spacing:.08em}
.seg3{display:flex;gap:4px}.seg3 button{flex:1;justify-content:center;padding:6px 2px;color:var(--mute)}.seg3 button.on{color:var(--ink)}
.seg4{display:grid;grid-template-columns:1fr 1fr;gap:4px}.seg4 button{justify-content:center;padding:6px 2px;color:var(--mute)}.seg4 button.on{color:var(--ink)}
#progress{position:fixed;left:0;top:0;height:2px;background:var(--ink);width:0;pointer-events:none;transition:width .1s}

.h{color:var(--mute);margin-bottom:9px;letter-spacing:.18em}
.sec{padding-bottom:12px;margin-bottom:12px;border-bottom:1px solid var(--line)}
.sec:last-child{border:0;margin:0;padding-bottom:0}
.row{display:flex;justify-content:space-between;align-items:center;margin:0 0 3px}
.row b{font-weight:500;color:var(--mute);font-variant-numeric:tabular-nums}
.gap{height:9px}

button{font:inherit;letter-spacing:inherit;text-transform:inherit;color:var(--ink);background:#fff;
  border:1px solid var(--line);border-radius:4px;padding:7px 9px;cursor:pointer;
  display:flex;align-items:center;gap:8px;transition:background .12s,border-color .12s,color .12s}
button:hover{background:#f6f6f6;border-color:#d6d6d6}
button:active{background:#efefef}
button.on{border-color:var(--ink)}
button:disabled{color:#c2c2c2;cursor:default;background:#fff;border-color:var(--line)}
button.del{color:#c2453b}
button svg{width:14px;height:14px;flex:none;fill:none;stroke:var(--ink);stroke-width:1.2}
input[type=color]{-webkit-appearance:none;appearance:none;width:38px;height:14px;border:1px solid #d8d8d8;border-radius:3px;padding:0;background:none;cursor:pointer}
input[type=color]::-webkit-color-swatch-wrapper{padding:0}
input[type=color]::-webkit-color-swatch{border:0;border-radius:2px}
input[type=color]::-moz-color-swatch{border:0;border-radius:2px}
.seg{display:flex;gap:4px}
.seg button{flex:1;justify-content:center;padding:6px 4px;color:var(--mute)}
.seg button.on{color:var(--ink)}
.full{width:100%;justify-content:center}
.stack{display:flex;flex-direction:column;gap:5px}
#status,#status2{color:var(--mute);margin-top:9px;min-height:11px;font-variant-numeric:tabular-nums}

input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:14px;background:transparent;margin:0 0 4px;cursor:pointer}
input[type=range]::-webkit-slider-runnable-track{height:1px;background:#d8d8d8}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:9px;height:9px;border-radius:50%;background:#fff;border:1px solid var(--ink);margin-top:-4px}
input[type=range]::-moz-range-track{height:1px;background:#d8d8d8}
input[type=range]::-moz-range-thumb{width:7px;height:7px;border-radius:50%;background:#fff;border:1px solid var(--ink)}
</style>
<script type="importmap">
{"imports":{
  "three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
  "three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}}
</script>
</head>
<body>

<div id="view"><canvas id="c"></canvas></div>

<div id="brand"><b>BLOB AND ORNAMENT v1.0</b><span id="stats">—</span></div>

<div class="panel" id="views">
  <button data-view="top">TOP</button><button data-view="bottom">BOTTOM</button>
  <button data-view="front">FRONT</button><button data-view="back">BACK</button>
  <button data-view="left">LEFT</button><button data-view="right">RIGHT</button>
</div>

<div id="progress"></div>
<div class="panel" id="modes">
  <button id="spForm" class="on">FORM</button><button id="spOrn">ORNAMENT</button>
</div>

<div class="panel" id="pal" hidden>
  <div class="h">STYLE · MIX</div>
  <div id="palList" class="stack" style="gap:4px"></div>
  <div class="note">MEHRERE STILE<br>KOMBINIERBAR</div>
</div>

<div class="panel" id="add">
  <div class="h">LAYER</div>
  <button id="modePos" class="on"><svg viewBox="0 0 14 14"><circle cx="7" cy="7" r="4.6" fill="#fff"/></svg>POSITIVE</button>
  <button id="modeNeg"><svg viewBox="0 0 14 14"><circle cx="7" cy="7" r="4.6" fill="#151515"/></svg>NEGATIVE</button>
  <div class="h" style="margin:7px 0 4px">ADD</div>
  <button data-add="sphere"><svg viewBox="0 0 14 14"><circle cx="7" cy="7" r="5.2"/></svg>SPHERE</button>
  <button data-add="cube"><svg viewBox="0 0 14 14"><rect x="2" y="2" width="10" height="10" rx="3"/></svg>CUBE</button>
  <button data-add="capsule"><svg viewBox="0 0 14 14"><rect x="4.4" y="1.4" width="5.2" height="11.2" rx="2.6"/></svg>CAPSULE</button>
  <button data-add="spline"><svg viewBox="0 0 14 14"><path d="M1.5 10.5C4 10.5 4 3.5 7 3.5S10 10.5 12.5 10.5"/></svg>SPLINE</button>
</div>

<div class="panel" id="side">
  <div class="sec formOnly">
    <div class="h">SETUP</div>
    <div class="row"><span>BLEND</span><b id="vBlend"></b></div>
    <input id="sBlend" type="range" min="0" max="0.8" step="0.01" value="0.2">
    <div class="row"><span>BLEND SHAPE · ROUND → ANGULAR</span><b id="vFall"></b></div>
    <input id="sFall" type="range" min="0" max="1" step="0.01" value="0.5">
    <div class="row"><span>PREVIEW RES</span><b id="vPrev"></b></div>
    <input id="sPrev" type="range" min="6" max="40" step="1" value="14">
    <div class="row"><span>SHADING</span></div>
    <div class="seg"><button id="shSmooth" class="on">SMOOTH</button><button id="shFlat">RAW</button></div>
    <div class="gap"></div>
    <div class="row"><span id="symLbl">SYMMETRY</span></div>
    <div class="seg"><button id="symX">X</button><button id="symY">Y</button><button id="symZ">Z</button></div>
    <div class="gap"></div>
    <div class="row"><span>GRID</span></div>
    <div class="seg"><button id="gridOn" class="on">ON</button><button id="gridOff">OFF</button></div>
    <div class="gap"></div>
    <div class="row"><span>OUTLINES</span></div>
    <div class="seg"><button id="outOn" class="on">ON</button><button id="outOff">OFF</button></div>
  </div>

  <div class="sec">
    <div class="h">MATERIAL</div>
    <div class="row" style="margin-bottom:9px"><span>COLOR</span><input id="mColor" type="color" value="#dcdcdd"></div>
    <div class="row"><span>SMOOTHNESS</span><b id="vSm"></b></div>
    <input id="sSm" type="range" min="0" max="1" step="0.01" value="0.38">
    <div class="row"><span>METALLIC</span><b id="vMe"></b></div>
    <input id="sMe" type="range" min="0" max="1" step="0.01" value="0">
    <div class="row"><span>TRANSLUCENCE</span><b id="vTr"></b></div>
    <input id="sTr" type="range" min="0" max="1" step="0.01" value="0">
  </div>

  <div class="sec ornOnly" hidden>
    <div class="h">GROWTH</div>
    <div class="row"><span>DENSITY · PACKING</span><b id="vOd"></b></div>
    <input id="sOd" type="range" min="0.3" max="3" step="0.01" value="1">
    <div class="row"><span>SIZE · LANE WIDTH</span><b id="vOs"></b></div>
    <input id="sOs" type="range" min="0.25" max="3" step="0.01" value="1">
    <div class="row"><span>SCALE RANGE · LOG</span><b id="vOm"></b></div>
    <input id="sOm" type="range" min="0" max="1" step="0.01" value="0.5">
    <div class="row"><span>RELIEF</span><b id="vOr"></b></div>
    <input id="sOr" type="range" min="0.3" max="2.2" step="0.01" value="1">
    <div class="row"><span>VOLUTES</span><b id="vOc"></b></div>
    <input id="sOc" type="range" min="0" max="1" step="0.01" value="0.5">
    <div class="row"><span>TWIST</span><b id="vOt"></b></div>
    <input id="sOt" type="range" min="0" max="1" step="0.01" value="0.5">
    <div class="row"><span>NOISE</span><b id="vOh"></b></div>
    <input id="sOh" type="range" min="0" max="1" step="0.01" value="0.35">
    <div class="row"><span>LANE LENGTH</span><b id="vOl"></b></div>
    <input id="sOl" type="range" min="0.2" max="3" step="0.01" value="1">
    <div class="row"><span>VORONOI CELL SIZE</span><b id="vOv"></b></div>
    <input id="sOv" type="range" min="0.2" max="1.6" step="0.01" value="0.6">
  </div>

  <div class="sec ornOnly" hidden>
    <div class="h">FLOW FIELD</div>
    <div class="row"><span>FIELD FOLLOW</span><b id="vOf"></b></div>
    <input id="sOf" type="range" min="0" max="1" step="0.01" value="0.85">
    <div class="row"><span>NOISE ↔ FORM</span><b id="vOn"></b></div>
    <input id="sOn" type="range" min="0" max="1" step="0.01" value="0.25">
    <div class="row"><span>FORM DIRECTION</span></div>
    <div class="seg4"><button id="flAlong" class="on">ALONG</button><button id="flAround">AROUND</button><button id="flUp">UP</button><button id="flFree">NOISE</button></div>
    <div class="gap"></div>
    <div class="row"><span>DRAW BY HAND</span></div>
    <div class="seg3"><button id="fDir">DIRECT</button><button id="fSwl">SWIRL ⟲</button><button id="fSwr">SWIRL ⟳</button></div>
    <div class="gap"></div>
    <div class="seg3"><button id="fErase">ERASE</button><button id="fClear">CLEAR</button><button id="fShow">SHOW</button></div>
    <div class="gap"></div>
    <div class="row"><span>BRUSH SIZE</span><b id="vBr2"></b></div>
    <input id="sBr2" type="range" min="0.03" max="0.5" step="0.005" value="0.12">
  </div>

  <div class="sec ornOnly" hidden>
    <div class="h">PLACEMENT</div>
    <div class="row"><span>SYMMETRY</span></div>
    <div class="seg"><button id="oSymX">X</button><button id="oSymY">Y</button><button id="oSymZ">Z</button></div>
    <div class="gap"></div>
    <div class="row"><span>DRAWN CURVES AS GUIDES</span></div>
    <div class="seg"><button id="gdOn" class="on">ON</button><button id="gdOff">OFF</button></div>
    <div class="gap"></div>
    <div class="row"><span>ZONE</span></div>
    <div class="seg"><button id="znAll" class="on">EVERYWHERE</button><button id="znPaint">PAINTED</button></div>
    <div id="paintRows" hidden>
      <div class="gap"></div>
      <div class="seg3"><button id="tBrush">BRUSH</button><button id="tErase">ERASE</button><button id="tClear">CLEAR</button></div>
      <div class="gap"></div>
      <div class="row"><span>BRUSH SIZE</span><b id="vBr"></b></div>
      <input id="sBr" type="range" min="0.03" max="0.5" step="0.005" value="0.12">
    </div>
  </div>

  <div class="sec ornOnly" hidden>
    <div class="h">ORNAMENT MATERIAL</div>
    <div class="row"><span>BASE FORM</span></div>
    <div class="seg"><button id="bdOn" class="on">SHOW</button><button id="bdOff">HIDE</button></div>
    <div class="gap"></div>
    <div class="row"><span>COLOR</span><input id="oColor" type="color" value="#dcdcdd"></div>
    <div class="row"><span>SEED</span><b id="vSeed"></b></div>
  </div>

  <div class="sec" id="selSec" hidden>
    <div class="h" id="selTitle">SELECTED</div>
    <div class="row"><span>RADIUS</span><b id="vR"></b></div>
    <input id="sR" type="range" min="0.03" max="1.2" step="0.005" value="0.3">
    <div id="rowRound" hidden>
      <div class="row"><span>ROUNDNESS</span><b id="vRound"></b></div>
      <input id="sRound" type="range" min="0" max="1" step="0.01" value="0.35">
    </div>
    <div id="rowField" hidden style="margin-top:6px">
      <div class="row"><span>FORCE FIELD · EXPERIMENTAL</span></div>
      <div class="seg"><button id="ffSolid" class="on">SOLID</button><button id="ffAttr">ATTRACT</button><button id="ffRep">REPEL</button></div>
      <div id="rowFstr" hidden>
        <div class="row"><span>STRENGTH</span><b id="vFstr"></b></div>
        <input id="sFstr" type="range" min="0" max="1" step="0.01" value="0.6">
      </div>
    </div>
    <div id="rowSpl" hidden style="margin-top:6px"><button class="full" id="btnAuto">AUTO-SMOOTH HANDLES</button></div>
    <div id="rowLen" hidden>
      <div class="row"><span>LENGTH</span><b id="vLen"></b></div>
      <input id="sLen" type="range" min="0" max="1.5" step="0.01" value="0.4">
    </div>
  </div>

  <div class="sec">
    <div class="h">PROJECT</div>
    <div class="seg"><button id="pSave">SAVE</button><button id="pLoad">LOAD</button></div>
    <div class="stack">
      <button class="full" id="pExp">EXPORT .JSON</button>
      <button class="full" id="pImp">IMPORT .JSON</button>
    </div>
    <input id="pFile" type="file" accept=".json,application/json" hidden>
  </div>

  <div class="sec ornOnly" hidden>
    <div class="h">EXPORT</div>
    <div class="row"><span>BODY RES</span><b id="vRend2"></b></div>
    <input id="sRend2" type="range" min="24" max="160" step="2" value="64">
    <div class="stack">
      <button class="full" id="btnExpAll">.GLB · BODY + ORNAMENT</button>
      <button class="full" id="btnExpOrn">.GLB · ORNAMENT ONLY</button>
    </div>
    <div id="status2">—</div>
  </div>

  <div class="sec formOnly">
    <div class="h">RENDER</div>
    <div class="row"><span>RENDER RES</span><b id="vRend"></b></div>
    <input id="sRend" type="range" min="24" max="160" step="2" value="64">
    <div class="stack">
      <button class="full" id="btnRender">RENDER MESH</button>
      <button class="full" id="btnGlb" disabled>DOWNLOAD .GLB</button>
    </div>
    <div id="status">READY</div>
  </div>
</div>

<div class="panel" id="bar" hidden>
  <div id="barSel" style="display:flex;gap:5px">
    <button data-mode="move">MOVE</button>
    <button data-mode="rotate">ROTATE</button>
    <button data-mode="scale">SCALE</button>
    <button class="del" id="btnDel">DELETE</button>
  </div>
  <div id="barOrn" style="display:flex;gap:5px" hidden>
    <button id="btnGrow">GROW</button>
    <button id="btnReroll">RE-ROLL</button>
    <button class="del" id="btnClearOrn">CLEAR</button>
  </div>
  <div id="barDraw" style="display:flex;gap:5px" hidden>
    <button id="btnFinish">FINISH <span id="drawN" style="color:#8e8e8e"></span></button>
    <button id="btnCancel">CANCEL</button>
  </div>
</div>

<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';

const V3 = THREE.Vector3;
const $ = id => document.getElementById(id);
const BIG = 1e4;
const TYPES = ['sphere', 'cube', 'capsule', 'spline'];
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));

/* ============================================================
   STATE
============================================================ */
const S = {
  blobs: [], sel: null, mode: 'move',
  blend: 0.2, prevRes: 14, rendRes: 64, flat: false,
  dirty: true, hqFresh: false, shift: false, drawing: null,
  sym: [false, false, false],   // symmetry state used as default for new blobs (= last toggled state)
  outline: true,                // subtle outline shells around every blob
  mat: { color: '#dcdcdd', smooth: 0.38, metal: 0, trans: 0 },   // material of the whole blob
  fall: 0.5,                    // blend shape: 0 = very round, 0.5 = standard, 1 = angular (chamfer)
  neg: false,                   // active layer: false = positive, true = negative (cutting) blobs
  clip: null, pasteN: 0, node: null,   // node = selected spline node / handle {kind,i}
};
let uid = 1;
// ornament mode state (see ORNAMENT MODE section)
const O = {
  on: false, formStamp: 0, genStamp: -1,
  styles: { flow: true, cartilage: true, cable: true, chain: true, spine: true, beads: true, bundle: true, acanthus: true, laurel: true, thorns: true, bones: true, ribs: true, scroll: true, coil: true },
  density: 1, size: 1, mix: 0.5, relief: 1, curl: 0.5, twist: 0.5, chaos: 0.35, cells: 0.6,
  length: 1, body: true, fol: 0.85, fnoise: 0.25, fstrokes: [], ftool: null, showFlow: false,
  flow: 'along', sym: [false, false, false], guides: true,
  zone: 'all', tool: null, brush: 0.12, stamps: [], painting: false,
  seed: 7, color: '#dcdcdd', follow: true, job: null,
};

function makeBlob(type, pos) {
  const b = { id: uid++, type, pos: pos.clone(), quat: new THREE.Quaternion(),
              scale: new V3(1, 1, 1), r: 0.3, len: 0.4, round: 0.35, pts: null, samples: null,
              sym: S.sym.slice(), neg: S.neg, field: 0, fstr: 0.6 };
  if (type === 'sphere')  b.r = 0.4;
  if (type === 'cube')    { b.r = 0.32; b.round = 0.4; }
  if (type === 'capsule') { b.r = 0.2;  b.len = 0.4; }
  if (type === 'spline')  b.r = 0.1;
  return b;
}
// splines are cubic Bezier chains: every node has an in- and out-handle (offsets, local space)
const SEG = 8;
function autoHandles(b) {
  const n = b.pts.length; b.hin = []; b.hout = [];
  for (let i = 0; i < n; i++) {
    const p = b.pts[i], prev = b.pts[i - 1], next = b.pts[i + 1];
    let o;
    if (prev && next) o = next.clone().sub(prev).divideScalar(6);
    else if (next) o = next.clone().sub(p).divideScalar(3);
    else o = p.clone().sub(prev).divideScalar(3);
    b.hout.push(o.clone()); b.hin.push(o.clone().negate());
  }
}
function updateSpline(b) {
  const s = [], n = b.pts.length;
  for (let i = 0; i < n - 1; i++) {
    const P0 = b.pts[i], P3 = b.pts[i + 1];
    const P1 = P0.clone().add(b.hout[i]), P2 = P3.clone().add(b.hin[i + 1]);
    for (let j = 0; j < SEG; j++) {
      const t = j / SEG, u = 1 - t;
      s.push(new V3().addScaledVector(P0, u * u * u).addScaledVector(P1, 3 * u * u * t)
                     .addScaledVector(P2, 3 * u * t * t).addScaledVector(P3, t * t * t));
    }
  }
  s.push(b.pts[n - 1].clone());
  b.samples = s;
}
function cloneBlob(b) {
  const n = makeBlob(b.type, b.pos);
  n.quat.copy(b.quat); n.scale.copy(b.scale); n.r = b.r; n.len = b.len; n.round = b.round;
  n.sym = b.sym.slice(); n.neg = b.neg; n.field = b.field; n.fstr = b.fstr;
  if (b.pts) {
    n.pts = b.pts.map(p => p.clone()); n.hin = b.hin.map(p => p.clone()); n.hout = b.hout.map(p => p.clone());
    updateSpline(n);
  }
  return n;
}

/* ============================================================
   RENDERER / SCENE
============================================================ */
const canvas = $('c'), view = $('view');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xececec);
const persp = new THREE.PerspectiveCamera(40, 1, 0.05, 100);
persp.position.set(2.3, 1.7, 3.3);
scene.add(persp);
const ortho = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.01, 1000);   // used by the orthographic helper views
scene.add(ortho);
let camera = persp;                                // the active camera (persp or ortho)

const controls = new OrbitControls(camera, canvas);
controls.target.set(0, 0.5, 0);
controls.enableDamping = true; controls.dampingFactor = 0.12;
controls.update();

// sculpt lighting: lights ride with the camera, so the form is always lit from "the viewer's" side
scene.add(new THREE.HemisphereLight(0xffffff, 0xc4c4ca, 0.45));
// soft studio environment: needed so metallic / glossy / translucent materials have something to reflect
scene.environment = new THREE.PMREMGenerator(renderer).fromScene(new RoomEnvironment(), 0.04).texture;
scene.environmentIntensity = 0.3;
const lightRig = new THREE.Object3D(); scene.add(lightRig);   // follows whichever camera is active
function camLight(color, intensity, pos, tgt) {
  const l = new THREE.DirectionalLight(color, intensity);
  l.position.set(...pos);
  const t = new THREE.Object3D(); t.position.set(...tgt);
  lightRig.add(l); lightRig.add(t); l.target = t;
}
camLight(0xffffff, 1.6, [-2.5, 3.5, 2.0], [0, 0, -4.5]);   // key, upper left
camLight(0xe4eaff, 0.65, [3.0, 0.4, 2.0], [0, 0, -4.5]);   // soft cool fill, right
camLight(0xffffff, 0.9, [1.5, 2.5, -12], [0, 0, -4.5]);    // rim from behind

const grid = new THREE.GridHelper(8, 32, 0xd0d0d0, 0xdcdcdc);
grid.material.transparent = true; grid.material.opacity = 0.8;
scene.add(grid);

const matPrev = new THREE.MeshPhysicalMaterial({ color: 0xffffff, vertexColors: true });
const matHQ   = new THREE.MeshPhysicalMaterial({ color: 0xffffff });
const matOB   = new THREE.MeshPhysicalMaterial({ color: 0xffffff, vertexColors: true });   // ornament mode: body surface (tint = painted zone)
const matOrn  = new THREE.MeshPhysicalMaterial({ color: 0xffffff });                       // grown ornaments
function applyMaterial() {                       // one material for the whole blob (preview + render)
  const m = S.mat;
  for (const mt of [matPrev, matHQ, matOB, matOrn]) {
    mt.color.set(m.color); mt.roughness = 1 - m.smooth; mt.metalness = m.metal;
    mt.transmission = m.trans; mt.thickness = 0.6; mt.ior = 1.4;
    mt.attenuationColor.set(m.color); mt.attenuationDistance = 1.5;
    mt.needsUpdate = true;
  }
  const oc = O.follow ? m.color : O.color;
  matOrn.color.set(oc); matOrn.attenuationColor.set(oc);
  applyLayerLook();
}
const prevMesh = new THREE.Mesh(new THREE.BufferGeometry(), matPrev);
scene.add(prevMesh);
let hqMesh = null, hqGeo = null, prevOwner = null, lastTris = 0, lastMs = 0;

/* ============================================================
   SDF
============================================================ */
// compiles all blobs + their mirror copies for the active symmetry planes (world x=0 / y=0 / z=0)
function compile(blobs) {
  const out = [];
  blobs.forEach((b, i) => {
    const c0 = compileOne(b); c0.src = i; out.push(c0);
    for (let m = 1; m < 8; m++) {
      if (((m & 1) && !b.sym[0]) || ((m & 2) && !b.sym[1]) || ((m & 4) && !b.sym[2])) continue;
      out.push(mirrorC(c0, (m & 1) ? -1 : 1, (m & 2) ? -1 : 1, (m & 4) ? -1 : 1));
    }
  });
  return out;
}
function mirrorC(c, sx, sy, sz) {
  const d = Object.assign({}, c);
  if (c.t === 3) {
    const w = new Float32Array(c.wpts.length);
    for (let i = 0; i < w.length; i += 3) { w[i] = c.wpts[i] * sx; w[i + 1] = c.wpts[i + 1] * sy; w[i + 2] = c.wpts[i + 2] * sz; }
    d.wpts = w;
  } else {
    d.px = c.px * sx; d.py = c.py * sy; d.pz = c.pz * sz;          // rotation R' = M R M
    d.m1 = c.m1 * sx * sy; d.m2 = c.m2 * sx * sz; d.m3 = c.m3 * sy * sx;
    d.m5 = c.m5 * sy * sz; d.m6 = c.m6 * sz * sx; d.m7 = c.m7 * sz * sy;
  }
  [d.x0, d.x1] = sx < 0 ? [-c.x1, -c.x0] : [c.x0, c.x1];
  [d.y0, d.y1] = sy < 0 ? [-c.y1, -c.y0] : [c.y0, c.y1];
  [d.z0, d.z1] = sz < 0 ? [-c.z1, -c.z0] : [c.z0, c.z1];
  return d;
}
function compileOne(b) {
  {
    const c = { t: TYPES.indexOf(b.type), neg: !!b.neg, field: (FORCE_FIELDS && b.type !== 'spline') ? (b.field | 0) : 0 };
    const sx = b.scale.x, sy = b.scale.y, sz = b.scale.z;
    if (c.t === 3) {
      const n = b.samples.length, w = new Float32Array(n * 3), v = new V3();
      let x0 = 1e9, y0 = 1e9, z0 = 1e9, x1 = -1e9, y1 = -1e9, z1 = -1e9;
      for (let i = 0; i < n; i++) {
        v.copy(b.samples[i]).multiply(b.scale).applyQuaternion(b.quat).add(b.pos);
        w[i * 3] = v.x; w[i * 3 + 1] = v.y; w[i * 3 + 2] = v.z;
        x0 = Math.min(x0, v.x); y0 = Math.min(y0, v.y); z0 = Math.min(z0, v.z);
        x1 = Math.max(x1, v.x); y1 = Math.max(y1, v.y); z1 = Math.max(z1, v.z);
      }
      c.wpts = w; c.rw = b.r * (sx + sy + sz) / 3;
      c.x0 = x0 - c.rw; c.y0 = y0 - c.rw; c.z0 = z0 - c.rw;
      c.x1 = x1 + c.rw; c.y1 = y1 + c.rw; c.z1 = z1 + c.rw;
    } else {
      const e = new THREE.Matrix4().makeRotationFromQuaternion(b.quat).elements;
      c.px = b.pos.x; c.py = b.pos.y; c.pz = b.pos.z;
      c.m0 = e[0]; c.m1 = e[1]; c.m2 = e[2]; c.m3 = e[4]; c.m4 = e[5]; c.m5 = e[6]; c.m6 = e[8]; c.m7 = e[9]; c.m8 = e[10];
      c.isx = 1 / sx; c.isy = 1 / sy; c.isz = 1 / sz;
      c.ms = Math.min(sx, sy, sz);
      c.r = b.r; c.len = b.len; c.rr = Math.min(b.r * b.round, b.r * 0.98);
      c.s = c.field * b.fstr * (c.field > 0 ? 1.5 : 0.9);     // signed field strength (repel stays < 1 so the mapping cannot invert)
      let R = c.t === 0 ? b.r : c.t === 1 ? b.r * 1.75 : b.len + b.r;
      R *= Math.max(sx, sy, sz);
      c.x0 = c.px - R; c.y0 = c.py - R; c.z0 = c.pz - R;
      c.x1 = c.px + R; c.y1 = c.py + R; c.z1 = c.pz + R;
    }
    return c;
  }
}

function blobDist(c, x, y, z) {
  if (c.t === 3) {
    const w = c.wpts; let best = 1e18;
    for (let i = 0; i < w.length - 3; i += 3) {
      const ax = w[i], ay = w[i + 1], az = w[i + 2];
      const bx = w[i + 3] - ax, by = w[i + 4] - ay, bz = w[i + 5] - az;
      const px = x - ax, py = y - ay, pz = z - az;
      let t = (px * bx + py * by + pz * bz) / (bx * bx + by * by + bz * bz + 1e-12);
      t = t < 0 ? 0 : t > 1 ? 1 : t;
      const dx = px - bx * t, dy = py - by * t, dz = pz - bz * t;
      const d2 = dx * dx + dy * dy + dz * dz;
      if (d2 < best) best = d2;
    }
    return Math.sqrt(best) - c.rw;
  }
  const dx = x - c.px, dy = y - c.py, dz = z - c.pz;
  const qx = (c.m0 * dx + c.m1 * dy + c.m2 * dz) * c.isx;
  const qy = (c.m3 * dx + c.m4 * dy + c.m5 * dz) * c.isy;
  const qz = (c.m6 * dx + c.m7 * dy + c.m8 * dz) * c.isz;
  let d;
  if (c.t === 0) d = Math.sqrt(qx * qx + qy * qy + qz * qz) - c.r;
  else if (c.t === 1) {
    const hb = c.r - c.rr;
    const ax = Math.abs(qx) - hb, ay = Math.abs(qy) - hb, az = Math.abs(qz) - hb;
    const ox = ax > 0 ? ax : 0, oy = ay > 0 ? ay : 0, oz = az > 0 ? az : 0;
    d = Math.sqrt(ox * ox + oy * oy + oz * oz) + Math.min(Math.max(ax, ay, az), 0) - c.rr;
  } else {
    const t = Math.max(-c.len, Math.min(c.len, qy)), ey = qy - t;
    d = Math.sqrt(qx * qx + ey * ey + qz * qz) - c.r;
  }
  return d * c.ms;
}

// smooth minimum with adjustable blend shape (S.fall, 0..1):
//   0    very round  - quadratic blend mixed with a wide exponential tail
//   0.5  standard    - classic quadratic polynomial smooth-min
//   1    angular     - quadratic mixed into a 45-degree chamfer (hard crease, flat bevel)
// Every variant is "min(a,b) minus a bulge", so it stays a plain, cheap metaball blend.
function roundW() { return Math.max(0, 1 - 2 * S.fall); }     // weight of the round end (0..1)
function smin(a, b, k) {
  if (k <= 0) return a < b ? a : b;
  const m = a < b ? a : b, d = Math.abs(a - b), t = S.fall;
  const q = d < k ? (k - d) / k : 0, quad = k * q * q * 0.25;
  let c;
  if (t <= 0.5) {
    const u = 1 - 2 * t;
    c = (1 - u) * quad;
    if (u > 0) { const ke = k * (0.36 + 0.3 * u); if (d < 4 * ke) c += u * ke * Math.log(1 + Math.exp(-d / ke)); }
  } else {
    const v = 2 * t - 1;
    c = (1 - v) * quad;
    const ch = (a + b - 0.6 * k) * 0.70710678;                  // chamfer: flat 45-degree bevel between the shapes
    if (ch < m) c += v * (m - ch);
  }
  return m - c;
}

/* ============================================================
   FORCE FIELDS  (EXPERIMENTAL)
   A sphere / cube / capsule can be turned from SOLID into a field (b.field = +1 attract, -1 repel).
   Its own volume is the area of effect; the geometry of its own layer is domain-warped inside it:
     p' = p + s * w * (p - core)      w = 0 at the membrane (surface), smoothly rising to 1 at the core
   attract (s>0) samples further out  -> surfaces get pulled towards the shape
   repel   (s<0) samples further in   -> surfaces get pushed away from it
   To remove the feature: set FORCE_FIELDS = false (everything below + the ROLE rows become inert),
   or delete this section, warpPoint()/warpMargin() calls in polygonize(), tintField() and the field mesh.
============================================================ */
const FORCE_FIELDS = true;
const WP = new Float64Array(3);
function warpMargin(fs) {
  for (const f of fs) if (f.D === undefined) f.D = Math.max(1e-3, -blobDist(f, f.px, f.py, f.pz));   // depth of the core below the surface
  return fs.reduce((s, f) => s + Math.abs(f.s) * (f.x1 - f.x0) / 2, 0);
}
function warpPoint(fs, x, y, z) {
  let dx = 0, dy = 0, dz = 0;
  for (const f of fs) {
    const d = blobDist(f, x, y, z);
    if (d >= 0) continue;                                  // only inside the shape: nothing happens outside the membrane
    const t = Math.min(1, -d / f.D), w = t * t * (3 - 2 * t);
    let qx = f.px, qy = f.py, qz = f.pz;
    if (f.t === 2) {                                       // capsule: core is the nearest point on its axis
      const ly = (f.m3 * (x - f.px) + f.m4 * (y - f.py) + f.m5 * (z - f.pz)) * f.isy;
      const t2 = Math.max(-f.len, Math.min(f.len, ly)) / f.isy;
      qx += f.m3 * t2; qy += f.m4 * t2; qz += f.m5 * t2;
    }
    const s = f.s * w;
    dx += (x - qx) * s; dy += (y - qy) * s; dz += (z - qz) * s;
  }
  WP[0] = x + dx; WP[1] = y + dy; WP[2] = z + dz;
}

function ownerAt(cs, x, y, z) {
  let best = 1e18, bi = 0;
  for (let i = 0; i < cs.length; i++) {
    const d = blobDist(cs[i], x, y, z);
    if (d < best) { best = d; bi = i; }
  }
  return cs[bi].src;
}

/* ============================================================
   POLYGONIZER  (smooth-union field -> naive surface nets)
============================================================ */
const CX = [0, 1, 0, 1, 0, 1, 0, 1], CY = [0, 0, 1, 1, 0, 0, 1, 1], CZ = [0, 0, 0, 0, 1, 1, 1, 1];
const EA = [0, 2, 4, 6, 0, 1, 4, 5, 0, 1, 2, 3], EB = [1, 3, 5, 7, 2, 3, 6, 7, 4, 5, 6, 7];

function polygonize(cs, k, h0, maxPts, wantOwner) {
  let x0 = 1e9, y0 = 1e9, z0 = 1e9, x1 = -1e9, y1 = -1e9, z1 = -1e9;
  const posCs = cs.filter(c => !c.neg && !c.field);
  if (!posCs.length) return null;                      // nothing solid to build (negatives only carve)
  const fp = cs.filter(c => c.field && !c.neg), fn = cs.filter(c => c.field && c.neg);   // force fields per layer
  const wmP = warpMargin(fp), wmN = warpMargin(fn);
  const kr = k * (1 + 2 * roundW());                      // reach of the blend kernel
  for (const c of posCs) {                              // grid only covers the solid blobs
    x0 = Math.min(x0, c.x0); y0 = Math.min(y0, c.y0); z0 = Math.min(z0, c.z0);
    x1 = Math.max(x1, c.x1); y1 = Math.max(y1, c.y1); z1 = Math.max(z1, c.z1);
  }
  x0 -= wmP; y0 -= wmP; z0 -= wmP; x1 += wmP; y1 += wmP; z1 += wmP;
  let H = h0, ox, oy, oz, nx, ny, nz;
  for (let it = 0; it < 80; it++) {
    const pad = kr + 4 * H;
    ox = Math.floor((x0 - pad) / H) * H; oy = Math.floor((y0 - pad) / H) * H; oz = Math.floor((z0 - pad) / H) * H;
    nx = Math.ceil((x1 + pad - ox) / H) + 1; ny = Math.ceil((y1 + pad - oy) / H) + 1; nz = Math.ceil((z1 + pad - oz) / H) + 1;
    if (nx * ny * nz <= maxPts) break;
    H *= 1.06;
  }
  const F = new Float32Array(nx * ny * nz).fill(BIG);
  const m = kr + 3 * H;
  const rng = (lo, hi, o, n, e) => [Math.max(0, Math.floor((lo - m - e - o) / H)), Math.min(n - 1, Math.ceil((hi + m + e - o) / H))];

  /* --- field fill --- */
  // solids first (smooth union), then negatives carve them out (smooth subtraction)
  for (const c of [...posCs, ...cs.filter(c => c.neg && !c.field)]) {
    const neg = !!c.neg, fs = neg ? fn : fp, wm = neg ? wmN : wmP;
    const [ia, ib] = rng(c.x0, c.x1, ox, nx, wm), [ja, jb] = rng(c.y0, c.y1, oy, ny, wm), [ka, kb] = rng(c.z0, c.z1, oz, nz, wm);
    if (fs.length) {
      // experimental: this layer is warped by force fields -> evaluate the blob at displaced sample points
      for (let kz = ka; kz <= kb; kz++) {
        const z = oz + kz * H;
        for (let jy = ja; jy <= jb; jy++) {
          const y = oy + jy * H;
          let p = ia + nx * (jy + ny * kz);
          for (let ix = ia; ix <= ib; ix++, p++) {
            warpPoint(fs, ox + ix * H, y, z);
            const d = blobDist(c, WP[0], WP[1], WP[2]);
            F[p] = neg ? -smin(-F[p], d, k) : smin(F[p], d, k);
          }
        }
      }
    } else if (c.t !== 3) {
      for (let kz = ka; kz <= kb; kz++) {
        const z = oz + kz * H;
        for (let jy = ja; jy <= jb; jy++) {
          const y = oy + jy * H;
          let p = ia + nx * (jy + ny * kz);
          for (let ix = ia; ix <= ib; ix++, p++) {
            const d = blobDist(c, ox + ix * H, y, z);
            F[p] = neg ? -smin(-F[p], d, k) : smin(F[p], d, k);
          }
        }
      }
    } else {
      // splines: rasterise segment by segment into a scratch volume (plain min), then smooth-merge
      const sx = ib - ia + 1, sy = jb - ja + 1, sz = kb - ka + 1;
      const sc = new Float32Array(sx * sy * sz).fill(BIG);
      const w = c.wpts, mm = c.rw + m;
      for (let s = 0; s < w.length - 3; s += 3) {
        const ax = w[s], ay = w[s + 1], az = w[s + 2];
        const bx = w[s + 3] - ax, by = w[s + 4] - ay, bz = w[s + 5] - az;
        const L2 = bx * bx + by * by + bz * bz + 1e-12;
        const qa = Math.max(ia, Math.floor((Math.min(ax, ax + bx) - mm - ox) / H)), qb = Math.min(ib, Math.ceil((Math.max(ax, ax + bx) + mm - ox) / H));
        const ra = Math.max(ja, Math.floor((Math.min(ay, ay + by) - mm - oy) / H)), rb = Math.min(jb, Math.ceil((Math.max(ay, ay + by) + mm - oy) / H));
        const ta = Math.max(ka, Math.floor((Math.min(az, az + bz) - mm - oz) / H)), tb = Math.min(kb, Math.ceil((Math.max(az, az + bz) + mm - oz) / H));
        for (let kz = ta; kz <= tb; kz++) {
          const pz = oz + kz * H - az;
          for (let jy = ra; jy <= rb; jy++) {
            const py = oy + jy * H - ay;
            let q = (qa - ia) + sx * ((jy - ja) + sy * (kz - ka));
            for (let ix = qa; ix <= qb; ix++, q++) {
              const px = ox + ix * H - ax;
              let t = (px * bx + py * by + pz * bz) / L2; t = t < 0 ? 0 : t > 1 ? 1 : t;
              const dx = px - bx * t, dy = py - by * t, dz = pz - bz * t;
              const d = Math.sqrt(dx * dx + dy * dy + dz * dz) - c.rw;
              if (d < sc[q]) sc[q] = d;
            }
          }
        }
      }
      let q = 0;
      for (let kz = ka; kz <= kb; kz++) for (let jy = ja; jy <= jb; jy++) {
        let p = ia + nx * (jy + ny * kz);
        for (let ix = ia; ix <= ib; ix++, p++, q++) {
          const v = sc[q];
          if (v < BIG * 0.5) F[p] = neg ? -smin(-F[p], v, k) : smin(F[p], v, k);
        }
      }
    }
  }

  /* --- surface nets --- */
  const cx = nx - 1, cy = ny - 1, cz = nz - 1;
  const cell = new Int32Array(cx * cy * cz).fill(-1);
  let vpos = new Float32Array(3 * 4096), vc = 0;
  let idx = new Uint32Array(3 * 8192), ic = 0;
  const fv = new Float32Array(8), sz2 = nx * ny;

  for (let kz = 0; kz < cz; kz++) for (let jy = 0; jy < cy; jy++) {
    let p = nx * (jy + ny * kz), cb = cx * (jy + cy * kz);
    for (let ix = 0; ix < cx; ix++, p++, cb++) {
      fv[0] = F[p]; fv[1] = F[p + 1]; fv[2] = F[p + nx]; fv[3] = F[p + nx + 1];
      fv[4] = F[p + sz2]; fv[5] = F[p + sz2 + 1]; fv[6] = F[p + sz2 + nx]; fv[7] = F[p + sz2 + nx + 1];
      let neg = 0;
      for (let i = 0; i < 8; i++) if (fv[i] < 0) neg++;
      if (neg === 0 || neg === 8) continue;
      let n = 0, ax = 0, ay = 0, az = 0;
      for (let e = 0; e < 12; e++) {
        const a = EA[e], b = EB[e], fa = fv[a], fb = fv[b];
        if ((fa < 0) !== (fb < 0)) {
          const t = fa / (fa - fb);
          ax += CX[a] + (CX[b] - CX[a]) * t; ay += CY[a] + (CY[b] - CY[a]) * t; az += CZ[a] + (CZ[b] - CZ[a]) * t; n++;
        }
      }
      if (vc * 3 + 3 > vpos.length) { const nv = new Float32Array(vpos.length * 2); nv.set(vpos); vpos = nv; }
      vpos[vc * 3] = ox + (ix + ax / n) * H; vpos[vc * 3 + 1] = oy + (jy + ay / n) * H; vpos[vc * 3 + 2] = oz + (kz + az / n) * H;
      cell[cb] = vc++;
    }
  }

  const quad = (c0, c1, c2, c3, flip) => {
    const a = cell[c0], b = cell[c1], c = cell[c2], d = cell[c3];
    if ((a | b | c | d) < 0) return;
    if (ic + 6 > idx.length) { const ni = new Uint32Array(idx.length * 2); ni.set(idx); idx = ni; }
    if (!flip) { idx[ic++] = a; idx[ic++] = b; idx[ic++] = c; idx[ic++] = a; idx[ic++] = c; idx[ic++] = d; }
    else       { idx[ic++] = a; idx[ic++] = c; idx[ic++] = b; idx[ic++] = a; idx[ic++] = d; idx[ic++] = c; }
  };
  const sCy = cx, sCz = cx * cy;
  for (let kz = 0; kz < cz; kz++) for (let jy = 0; jy < cy; jy++) {
    let p = nx * (jy + ny * kz), cb = cx * (jy + cy * kz);
    for (let ix = 0; ix < cx; ix++, p++, cb++) {
      const f0 = F[p], in0 = f0 < 0;
      if (jy > 0 && kz > 0 && (F[p + 1] < 0) !== in0)       quad(cb - sCy - sCz, cb - sCz, cb, cb - sCy, !in0);          // x edge (u=y,v=z)
      if (ix > 0 && kz > 0 && (F[p + nx] < 0) !== in0)      quad(cb - sCz - 1, cb - 1, cb, cb - sCz, !in0);              // y edge (u=z,v=x)
      if (ix > 0 && jy > 0 && (F[p + sz2] < 0) !== in0)     quad(cb - 1 - sCy, cb - sCy, cb, cb - 1, !in0);              // z edge (u=x,v=y)
    }
  }

  /* --- smooth normals from the field gradient (trilinear over grid-point gradients) --- */
  const pos = vpos.slice(0, vc * 3), nor = new Float32Array(vc * 3);
  const g = new Float32Array(24);
  const gAt = (i, j, k, o) => {
    i = clamp(i, 1, nx - 2); j = clamp(j, 1, ny - 2); k = clamp(k, 1, nz - 2);
    const p = i + nx * (j + ny * k);
    g[o] = F[p + 1] - F[p - 1]; g[o + 1] = F[p + nx] - F[p - nx]; g[o + 2] = F[p + sz2] - F[p - sz2];
  };
  for (let v = 0; v < vc; v++) {
    const gx = (pos[v * 3] - ox) / H, gy = (pos[v * 3 + 1] - oy) / H, gz = (pos[v * 3 + 2] - oz) / H;
    const i = Math.floor(gx), j = Math.floor(gy), k = Math.floor(gz);
    const fx = gx - i, fy = gy - j, fz = gz - k;
    for (let c = 0; c < 8; c++) gAt(i + CX[c], j + CY[c], k + CZ[c], c * 3);
    let a = 0, b = 0, d = 0;
    for (let c = 0; c < 8; c++) {
      const w = (CX[c] ? fx : 1 - fx) * (CY[c] ? fy : 1 - fy) * (CZ[c] ? fz : 1 - fz);
      a += g[c * 3] * w; b += g[c * 3 + 1] * w; d += g[c * 3 + 2] * w;
    }
    const l = Math.hypot(a, b, d) || 1;
    nor[v * 3] = a / l; nor[v * 3 + 1] = b / l; nor[v * 3 + 2] = d / l;
  }

  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  geo.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
  geo.setIndex(new THREE.BufferAttribute(idx.slice(0, ic), 1));
  geo.computeBoundingSphere();

  let owner = null;
  if (wantOwner) {
    owner = new Int16Array(vc);
    for (let v = 0; v < vc; v++) owner[v] = ownerAt(posCs, pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2]);
  }
  return { geo, owner, H, tris: ic / 3, verts: vc, grid: { F, ox, oy, oz, nx, ny, nz, H } };
}

/* ============================================================
   PREVIEW MESH
============================================================ */
const TB = [1, 1, 1], TS = [0.55, 0.68, 1.0];                // positive: base / selected tint (multiplies the material colour)
const TBN = [0.04, 0.04, 0.045], TSN = [0.24, 0.30, 0.50];   // negative: black / selected tint

// positives = carved result (white); negatives are drawn separately as black "tool" shapes
const matNeg = new THREE.MeshStandardMaterial({ color: 0xffffff, vertexColors: true, roughness: 0.45, metalness: 0 });
const negMesh = new THREE.Mesh(new THREE.BufferGeometry(), matNeg);
scene.add(negMesh);
let negOwner = null;
// force-field shapes (experimental): translucent tinted volumes, alpha per vertex
const matField = new THREE.MeshStandardMaterial({ color: 0xffffff, vertexColors: true, transparent: true, depthWrite: false, roughness: 0.7, metalness: 0 });
const fieldMesh = new THREE.Mesh(new THREE.BufferGeometry(), matField);
fieldMesh.renderOrder = 3; scene.add(fieldMesh);
let fieldOwner = null;

// active layer is opaque + editable, the other one is a faint, locked ghost
function applyLayerLook() {
  const n = S.neg;
  matPrev.transparent = n; matPrev.opacity = n ? 0.16 : 1; matPrev.depthWrite = !n;
  matPrev.transmission = n ? 0 : S.mat.trans;
  matNeg.transparent = !n; matNeg.opacity = n ? 1 : 0.16; matNeg.depthWrite = n;
  matPrev.needsUpdate = matNeg.needsUpdate = true;
}
function setGeo(mesh, geo) { const old = mesh.geometry; mesh.geometry = geo; old.dispose(); }

function remeshPreview() {
  const t0 = performance.now();
  const cs = compile(S.blobs);
  const negCs = cs.filter(c => c.neg).map(c => Object.assign({}, c, { neg: false }));
  const rp = polygonize(cs, S.blend, 1 / S.prevRes, 1.2e6, true);
  const rn = negCs.length ? polygonize(negCs, S.blend, 1 / S.prevRes, 1.2e6, true) : null;
  [[rp, prevMesh, false], [rn, negMesh, true]].forEach(([r, mesh, isNeg]) => {
    if (!r) { setGeo(mesh, new THREE.BufferGeometry()); if (isNeg) negOwner = null; else prevOwner = null; return; }
    r.geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(r.verts * 3), 3));
    setGeo(mesh, r.geo);
    if (isNeg) negOwner = r.owner; else prevOwner = r.owner;
  });
  const fcs = cs.filter(c => c.field).map(c => Object.assign({}, c, { neg: false, field: 0 }));
  const rf = fcs.length ? polygonize(fcs, 0, 1 / S.prevRes, 6e5, true) : null;
  if (rf) { rf.geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(rf.verts * 4), 4)); setGeo(fieldMesh, rf.geo); fieldOwner = rf.owner; }
  else { setGeo(fieldMesh, new THREE.BufferGeometry()); fieldOwner = null; }
  lastTris = (rp ? rp.tris : 0) + (rn ? rn.tris : 0);
  applyTint();
  lastMs = performance.now() - t0;
}
// attract = blue, repel = orange; alpha: selected > active layer > ghost layer
function tintField() {
  const col = fieldMesh.geometry.attributes.color;
  if (!col || !fieldOwner) return;
  const a = col.array;
  for (let i = 0; i < fieldOwner.length; i++) {
    const b = S.blobs[fieldOwner[i]];
    const c = b.field > 0 ? [0.42, 0.6, 1.0] : [1.0, 0.55, 0.32];
    const al = b === S.sel ? 0.5 : (!!b.neg === S.neg ? 0.26 : 0.06);
    a[i * 4] = c[0]; a[i * 4 + 1] = c[1]; a[i * 4 + 2] = c[2]; a[i * 4 + 3] = al;
  }
  col.needsUpdate = true;
}
function tintMesh(mesh, owner, base, sel, si) {
  const col = mesh.geometry.attributes.color;
  if (!col || !owner) return;
  const a = col.array;
  for (let i = 0; i < owner.length; i++) {
    const t = owner[i] === si ? sel : base;
    a[i * 3] = t[0]; a[i * 3 + 1] = t[1]; a[i * 3 + 2] = t[2];
  }
  col.needsUpdate = true;
}
function applyTint() {
  const si = S.sel ? S.blobs.indexOf(S.sel) : -1;
  tintMesh(prevMesh, prevOwner, TB, TS, si);
  tintMesh(negMesh, negOwner, TBN, TSN, si);
  tintField();
}
function markDirty() {
  O.formStamp++;
  S.dirty = true; S.hqFresh = false;
  if (hqMesh) hqMesh.visible = false;
  prevMesh.visible = true; negMesh.visible = true; fieldMesh.visible = true;
  if (hqGeo) setStatus('OUT OF DATE — RENDER AGAIN');
}

/* ============================================================
   OUTLINES  (subtle line shells, always a bit larger than the blob)
============================================================ */
const outMats = {
  act:   new THREE.LineBasicMaterial({ color: 0x151515, transparent: true, opacity: 0.2,  depthWrite: false }),
  ghost: new THREE.LineBasicMaterial({ color: 0x151515, transparent: true, opacity: 0.06, depthWrite: false }),
  sel:   new THREE.LineBasicMaterial({ color: 0x5a78d6, transparent: true, opacity: 0.8,  depthWrite: false }),
};
const outLines = {};
for (const k in outMats) {
  outLines[k] = new THREE.LineSegments(new THREE.BufferGeometry(), outMats[k]);
  outLines[k].frustumCulled = false; outLines[k].renderOrder = 5; scene.add(outLines[k]);
}
function circ(c, U, W, rad, n, a0 = 0, a1 = Math.PI * 2) {
  const p = [];
  for (let i = 0; i <= n; i++) { const a = a0 + (a1 - a0) * i / n; p.push(c.clone().addScaledVector(U, Math.cos(a) * rad).addScaledVector(W, Math.sin(a) * rad)); }
  return p;
}
function outlineOf(b) {
  const out = [], X = new V3(1, 0, 0), Y = new V3(0, 1, 0), Z = new V3(0, 0, 1), O = new V3();
  const pushSeg = (a, c) => out.push(a.x, a.y, a.z, c.x, c.y, c.z);
  const polyW = pts => { for (let i = 1; i < pts.length; i++) pushSeg(pts[i - 1], pts[i]); };   // world points
  const poly = pts => polyW(pts.map(p => toWorld(b, p)));                                       // local points
  if (b.type === 'sphere') {
    const R = b.r * 1.1 + 0.015;
    poly(circ(O, X, Y, R, 48)); poly(circ(O, Y, Z, R, 48)); poly(circ(O, X, Z, R, 48));
  } else if (b.type === 'cube') {
    const h = b.r * 1.08 + 0.015, c = [];
    for (let i = 0; i < 8; i++) c.push(new V3(i & 1 ? h : -h, i & 2 ? h : -h, i & 4 ? h : -h));
    for (let i = 0; i < 8; i++) for (const bit of [1, 2, 4]) if (!(i & bit)) poly([c[i], c[i | bit]]);
  } else if (b.type === 'capsule') {
    const R = b.r * 1.1 + 0.015, L = b.len;
    poly(circ(new V3(0, L, 0), X, Z, R, 40)); poly(circ(new V3(0, -L, 0), X, Z, R, 40));
    for (const U of [X, Z]) {
      const top = circ(new V3(0, L, 0), U, Y, R, 24, 0, Math.PI), bot = circ(new V3(0, -L, 0), U, Y, R, 24, Math.PI, Math.PI * 2);
      poly(top.concat(bot, [top[0]]));
    }
  } else {
    const P = b.samples.map(p => toWorld(b, p)), n = P.length;
    const rw = b.r * (b.scale.x + b.scale.y + b.scale.z) / 3 * 1.1 + 0.015;
    const fr = []; let nrm = null, t = new V3(0, 1, 0);
    for (let i = 0; i < n; i++) {                                // parallel-transport frames along the curve
      const tt = P[Math.min(i + 1, n - 1)].clone().sub(P[Math.max(i - 1, 0)]);
      if (tt.lengthSq() > 1e-12) t = tt.normalize();
      if (!nrm) nrm = (Math.abs(t.y) < 0.9 ? Y : X).clone();
      nrm = nrm.sub(t.clone().multiplyScalar(nrm.dot(t))).normalize();
      fr.push([nrm.clone(), new V3().crossVectors(t, nrm)]);
    }
    for (let a = 0; a < 4; a++) {                                // 4 longitudinal lines
      const ang = a * Math.PI / 2, cs = Math.cos(ang) * rw, sn = Math.sin(ang) * rw;
      polyW(P.map((p, i) => p.clone().addScaledVector(fr[i][0], cs).addScaledVector(fr[i][1], sn)));
    }
    for (let i = 0; i < n; i += 4) polyW(circ(P[i], fr[i][0], fr[i][1], rw, 20));   // rings
    if ((n - 1) % 4) polyW(circ(P[n - 1], fr[n - 1][0], fr[n - 1][1], rw, 20));
  }
  const base = out.slice();                                      // symmetry copies are just reflected lines
  for (let m = 1; m < 8; m++) {
    if (((m & 1) && !b.sym[0]) || ((m & 2) && !b.sym[1]) || ((m & 4) && !b.sym[2])) continue;
    const sx = (m & 1) ? -1 : 1, sy = (m & 2) ? -1 : 1, sz = (m & 4) ? -1 : 1;
    for (let i = 0; i < base.length; i += 3) out.push(base[i] * sx, base[i + 1] * sy, base[i + 2] * sz);
  }
  return out;
}
function rebuildOutlines() {
  const arr = { act: [], ghost: [], sel: [] };
  S.blobs.forEach(b => {
    const k = b === S.sel ? 'sel' : (!!b.neg === S.neg ? 'act' : 'ghost');
    arr[k] = arr[k].concat(outlineOf(b));
  });
  for (const k in outLines) {
    const g = new THREE.BufferGeometry();
    g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(arr[k]), 3));
    outLines[k].geometry.dispose(); outLines[k].geometry = g; outLines[k].visible = S.outline;
  }
}

/* ============================================================
   GIZMOS
============================================================ */
const AX = [new V3(1, 0, 0), new V3(0, 1, 0), new V3(0, 0, 1)];
const COL = [0xe5484d, 0x30a46c, 0x3e63dd];
const Y_UP = new V3(0, 1, 0), Z_UP = new V3(0, 0, 1);
const gizmo = new THREE.Group(); gizmo.visible = false; scene.add(gizmo);
const hitMat = new THREE.MeshBasicMaterial({ visible: false });
const axisMats = COL.map(c => new THREE.MeshBasicMaterial({ color: c, depthTest: false, transparent: true, opacity: 0.8 }));
const centerMat = new THREE.MeshBasicMaterial({ color: 0x9a9a9a, depthTest: false, transparent: true, opacity: 0.9 });
const gm = { move: new THREE.Group(), rotate: new THREE.Group(), scale: new THREE.Group() };
for (const k in gm) gizmo.add(gm[k]);

function tag(mesh, kind, axis) { mesh.userData = { kind, axis }; mesh.renderOrder = 999; return mesh; }
for (let i = 0; i < 3; i++) {
  // move arrow
  let g = new THREE.Group(); g.quaternion.setFromUnitVectors(Y_UP, AX[i]);
  let s = tag(new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.72, 6), axisMats[i]), 'move', i); s.position.y = 0.36;
  let c = tag(new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.2, 14), axisMats[i]), 'move', i); c.position.y = 0.82;
  let h = tag(new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, 1, 6), hitMat), 'move', i); h.position.y = 0.5;
  g.add(s, c, h); gm.move.add(g);
  // rotate ring
  g = new THREE.Group(); g.quaternion.setFromUnitVectors(Z_UP, AX[i]);
  g.add(tag(new THREE.Mesh(new THREE.TorusGeometry(0.85, 0.011, 6, 80), axisMats[i]), 'rotate', i));
  g.add(tag(new THREE.Mesh(new THREE.TorusGeometry(0.85, 0.06, 6, 40), hitMat), 'rotate', i));
  gm.rotate.add(g);
  // scale handle
  g = new THREE.Group(); g.quaternion.setFromUnitVectors(Y_UP, AX[i]);
  s = tag(new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.74, 6), axisMats[i]), 'scale', i); s.position.y = 0.37;
  c = tag(new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.1), axisMats[i]), 'scale', i); c.position.y = 0.82;
  h = tag(new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, 0.7, 6), hitMat), 'scale', i); h.position.y = 0.65;
  g.add(s, c, h); gm.scale.add(g);
}
gm.scale.add(tag(new THREE.Mesh(new THREE.BoxGeometry(0.17, 0.17, 0.17), centerMat), 'scale', 'c'));

// camera-facing radius ring (visible while Shift is held)
const ringPts = []; for (let i = 0; i < 64; i++) { const a = i / 64 * Math.PI * 2; ringPts.push(new V3(Math.cos(a), Math.sin(a), 0)); }
const ring = new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(ringPts),
  new THREE.LineBasicMaterial({ color: 0x151515, transparent: true, opacity: 0.55, depthTest: false }));
ring.renderOrder = 998; ring.visible = false; scene.add(ring);

// round, outlined dots: spline control points + drawing preview
const dotTex = (() => {
  const c = document.createElement('canvas'); c.width = c.height = 64; const g = c.getContext('2d');
  g.fillStyle = '#2a2a2a'; g.beginPath(); g.arc(32, 32, 30, 0, 7); g.fill();
  g.fillStyle = '#fff'; g.beginPath(); g.arc(32, 32, 22, 0, 7); g.fill();
  const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t;
})();
const dotMat = new THREE.PointsMaterial({ size: 11, sizeAttenuation: false, map: dotTex, alphaTest: 0.5, transparent: true, depthTest: false });
function makeDots(n) {
  const attr = new THREE.BufferAttribute(new Float32Array(n * 3), 3); attr.setUsage(THREE.DynamicDrawUsage);
  const geo = new THREE.BufferGeometry(); geo.setAttribute('position', attr); geo.setDrawRange(0, 0);
  const pts = new THREE.Points(geo, dotMat); pts.frustumCulled = false; pts.renderOrder = 998; pts.visible = false; scene.add(pts);
  return { pts, geo, attr };
}
const glowTex = (() => {
  const c = document.createElement('canvas'); c.width = c.height = 64; const g = c.getContext('2d');
  const gr = g.createRadialGradient(32, 32, 2, 32, 32, 31);
  gr.addColorStop(0, 'rgba(255,255,255,1)'); gr.addColorStop(0.45, 'rgba(255,255,255,0.55)'); gr.addColorStop(1, 'rgba(255,255,255,0)');
  g.fillStyle = gr; g.fillRect(0, 0, 64, 64);
  const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t;
})();
const glowDots = makeDots(1);              // hover glow on a spline node / handle
glowDots.pts.material = new THREE.PointsMaterial({ size: 30, sizeAttenuation: false, map: glowTex, color: 0x4f6df5, transparent: true, opacity: 0.7, depthTest: false });
glowDots.pts.renderOrder = 996;
const selDots = makeDots(1);               // marker for the selected node / handle
selDots.pts.material = dotMat.clone(); selDots.pts.material.size = 14; selDots.pts.material.color.set(0x4f6df5);
selDots.pts.renderOrder = 999;
let hoverNode = null;
const handleDots = makeDots(160);          // spline nodes
const MAXD = 150;
const bezDots = makeDots(320);             // bezier handles: smaller, tinted
bezDots.pts.material = dotMat.clone(); bezDots.pts.material.size = 8; bezDots.pts.material.color.set(0xa9bcff);
const bezLineAttr = new THREE.BufferAttribute(new Float32Array(640 * 3), 3); bezLineAttr.setUsage(THREE.DynamicDrawUsage);
const bezLines = new THREE.LineSegments(new THREE.BufferGeometry().setAttribute('position', bezLineAttr),
  new THREE.LineBasicMaterial({ color: 0x6b86e8, transparent: true, opacity: 0.7, depthTest: false }));
bezLines.frustumCulled = false; bezLines.renderOrder = 997; bezLines.visible = false; bezLines.geometry.setDrawRange(0, 0); scene.add(bezLines);
const draw = makeDots(MAXD + 2);
const drawLine = new THREE.Line(new THREE.BufferGeometry().setAttribute('position', draw.attr),
  new THREE.LineBasicMaterial({ color: 0x151515, transparent: true, opacity: 0.6, depthTest: false }));
drawLine.frustumCulled = false; drawLine.renderOrder = 997; drawLine.visible = false; drawLine.geometry.setDrawRange(0, 0); scene.add(drawLine);

function toWorld(b, v) { return v.clone().multiply(b.scale).applyQuaternion(b.quat).add(b.pos); }
function toLocal(b, w) { return w.clone().sub(b.pos).applyQuaternion(b.quat.clone().invert()).divide(b.scale); }
function splineCtrl(b) {
  return {
    nodes: b.pts.map(p => toWorld(b, p)),
    ins: b.pts.map((p, i) => toWorld(b, p.clone().add(b.hin[i]))),
    outs: b.pts.map((p, i) => toWorld(b, p.clone().add(b.hout[i]))),
  };
}

// distance used to size gizmos: in the orthographic view the equivalent perspective distance
function viewDist(p) {
  if (!camera.isOrthographicCamera) return camera.position.distanceTo(p);
  return (camera.top - camera.bottom) / camera.zoom / (2 * Math.tan(THREE.MathUtils.degToRad(persp.fov / 2)));
}

/* ---------- orthographic helper views (Blender-like) ---------- */
// TOP / BOTTOM / FRONT / BACK / LEFT / RIGHT look straight along an axis with an orthographic camera.
// As soon as the camera is rotated, the view falls back to the normal perspective camera.
const VIEWS = { top: [0, 1, 0], bottom: [0, -1, 0], front: [0, 0, 1], back: [0, 0, -1], right: [1, 0, 0], left: [-1, 0, 0] };
let snapQ = null, curView = null;
function useCamera(c) { camera = c; controls.object = c; }
function setView(name) {
  controls.enableDamping = false; controls.update(); controls.enableDamping = true;   // flush leftover orbit inertia so it cannot rotate the snapped view
  const dir = new V3(...VIEWS[name]), dist = viewDist(controls.target);
  if (camera !== ortho) {                           // size the ortho frustum so the target keeps its on-screen size
    const h = dist * 2 * Math.tan(THREE.MathUtils.degToRad(persp.fov / 2)), a = innerWidth / innerHeight;
    ortho.left = -h / 2 * a; ortho.right = h / 2 * a; ortho.top = h / 2; ortho.bottom = -h / 2; ortho.zoom = 1;
    ortho.updateProjectionMatrix();
  }
  const d0 = camera === ortho ? ortho.position.distanceTo(controls.target) : dist;
  ortho.position.copy(controls.target).addScaledVector(dir, d0);
  useCamera(ortho);
  controls.update();
  snapQ = ortho.quaternion.clone(); curView = name; syncViewUI();
}
function leavePerspective() {                       // ortho -> perspective, keeping the current look direction and size
  const dist = viewDist(controls.target), dir = new V3().subVectors(ortho.position, controls.target).normalize();
  persp.position.copy(controls.target).addScaledVector(dir, dist);
  useCamera(persp); controls.update();
  curView = null; snapQ = null; syncViewUI();
}
function syncViewUI() { document.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('on', b.dataset.view === curView)); }

let hover = null;
function updateGizmo() {
  const b = S.sel;
  const show = !!b && !S.drawing;
  gizmo.visible = show && !S.shift;
  for (const k in gm) gm[k].visible = (k === S.mode);
  if (show) {
    gizmo.position.copy(b.pos);
    gizmo.scale.setScalar(viewDist(b.pos) * 0.15);
    gizmo.quaternion.copy(S.mode === 'scale' ? b.quat : new THREE.Quaternion());
  }
  axisMats.forEach((m, i) => m.opacity = hover && hover.axis === i ? 1 : 0.78);
  centerMat.opacity = hover && hover.axis === 'c' ? 1 : 0.85;

  ring.visible = show && S.shift;
  if (ring.visible) {
    ring.position.copy(b.pos); ring.quaternion.copy(camera.quaternion);
    ring.scale.setScalar(Math.max(0.01, b.r * (b.scale.x + b.scale.y + b.scale.z) / 3));
  }
  // spline control points
  const showPts = show && b.type === 'spline';
  handleDots.pts.visible = bezDots.pts.visible = bezLines.visible = showPts;
  if (showPts) {
    const { nodes, ins, outs } = splineCtrl(b), n = nodes.length, a = handleDots.attr.array;
    nodes.forEach((p, i) => { a[i * 3] = p.x; a[i * 3 + 1] = p.y; a[i * 3 + 2] = p.z; });
    handleDots.attr.needsUpdate = true; handleDots.geo.setDrawRange(0, n);
    const ba = bezDots.attr.array, la = bezLineAttr.array; let k = 0;
    const put = (i, h) => {
      ba[k * 3] = h.x; ba[k * 3 + 1] = h.y; ba[k * 3 + 2] = h.z;
      la[k * 6] = nodes[i].x; la[k * 6 + 1] = nodes[i].y; la[k * 6 + 2] = nodes[i].z;
      la[k * 6 + 3] = h.x; la[k * 6 + 4] = h.y; la[k * 6 + 5] = h.z; k++;
    };
    for (let i = 0; i < n; i++) { if (i > 0) put(i, ins[i]); if (i < n - 1) put(i, outs[i]); }
    bezDots.attr.needsUpdate = true; bezLineAttr.needsUpdate = true;
    bezDots.geo.setDrawRange(0, k); bezLines.geometry.setDrawRange(0, k * 2);
    // hover glow + selected-node marker
    const at = h => h && (h.kind === 'node' ? nodes[h.i] : h.kind === 'hin' ? ins[h.i] : outs[h.i]);
    const hp = at(hoverNode), sp = at(S.node);
    glowDots.pts.visible = !!hp; selDots.pts.visible = !!sp;
    if (hp) { glowDots.attr.array.set([hp.x, hp.y, hp.z]); glowDots.attr.needsUpdate = true; glowDots.geo.setDrawRange(0, 1); }
    if (sp) { selDots.attr.array.set([sp.x, sp.y, sp.z]); selDots.attr.needsUpdate = true; selDots.geo.setDrawRange(0, 1); }
  } else { glowDots.pts.visible = false; selDots.pts.visible = false; }
}

/* ============================================================
   INTERACTION
============================================================ */
const raycaster = new THREE.Raycaster();
const ndcv = new THREE.Vector2();
function ndc(e) {
  const r = canvas.getBoundingClientRect();
  return ndcv.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
}
function setRay(e) { raycaster.setFromCamera(ndc(e), camera); return raycaster.ray; }

function axisParam(ray, O, D) {
  const w0 = O.clone().sub(ray.origin);
  const b = D.dot(ray.direction), d = D.dot(w0), e = ray.direction.dot(w0);
  const den = 1 - b * b;
  return den < 1e-6 ? 0 : (b * e - d) / den;
}

function hitGizmo(e) {
  if (!S.sel || !gizmo.visible) return null;
  setRay(e);
  const hits = raycaster.intersectObjects(gm[S.mode].children, true).filter(h => h.object.userData.kind);
  if (!hits.length) return null;
  const c = hits.find(h => h.object.userData.axis === 'c');       // the centre cube (uniform scale) wins over the axis handles
  return (c || hits[0]).object.userData;
}
function hitSplineHandle(e) {
  const b = S.sel;
  if (!b || b.type !== 'spline' || S.drawing) return null;
  const r = canvas.getBoundingClientRect(), mx = e.clientX - r.left, my = e.clientY - r.top;
  const { nodes, ins, outs } = splineCtrl(b), n = nodes.length;
  let best = null, bd = 150;
  const test = (p, kind, i, bias) => {
    const q = p.clone().project(camera);
    const d = ((q.x * .5 + .5) * r.width - mx) ** 2 + ((-q.y * .5 + .5) * r.height - my) ** 2 + bias;
    if (d < bd) { bd = d; best = { kind, i }; }
  };
  for (let i = 0; i < n; i++) {
    test(nodes[i], 'node', i, 0);
    if (i > 0) test(ins[i], 'hin', i, 20);
    if (i < n - 1) test(outs[i], 'hout', i, 20);
  }
  return best;
}
function deleteNode(b, i) {
  if (b.pts.length <= 2) return;
  b.pts.splice(i, 1); b.hin.splice(i, 1); b.hout.splice(i, 1);
  updateSpline(b); markDirty();
}
function insertNodeAt(e) {                                   // double-click on the curve: split the bezier segment
  const b = S.sel;
  if (!b || b.type !== 'spline' || b.pts.length >= 150 || hitSplineHandle(e)) return;
  const r = canvas.getBoundingClientRect(), mx = e.clientX - r.left, my = e.clientY - r.top;
  let best = -1, bd = 225;
  b.samples.forEach((s, i) => {
    const q = toWorld(b, s).project(camera);
    const d = ((q.x * .5 + .5) * r.width - mx) ** 2 + ((-q.y * .5 + .5) * r.height - my) ** 2;
    if (d < bd) { bd = d; best = i; }
  });
  if (best < 0) return;
  const nseg = b.pts.length - 1, seg = Math.min(nseg - 1, Math.floor(best / SEG));
  const t = clamp((best - seg * SEG) / SEG, 0.15, 0.85);
  const P0 = b.pts[seg], P3 = b.pts[seg + 1], P1 = P0.clone().add(b.hout[seg]), P2 = P3.clone().add(b.hin[seg + 1]);
  const lp = (a, c) => a.clone().lerp(c, t);
  const Q0 = lp(P0, P1), Q1 = lp(P1, P2), Q2 = lp(P2, P3), R0 = lp(Q0, Q1), R1 = lp(Q1, Q2), Sp = lp(R0, R1);
  b.hout[seg] = Q0.clone().sub(P0); b.hin[seg + 1] = Q2.clone().sub(P3);
  b.pts.splice(seg + 1, 0, Sp); b.hin.splice(seg + 1, 0, R0.clone().sub(Sp)); b.hout.splice(seg + 1, 0, R1.clone().sub(Sp));
  updateSpline(b); markDirty();
}
function pasteBlob() {
  S.pasteN++;
  const b = cloneBlob(S.clip);
  b.neg = S.neg;                                  // pasting assigns the blob to the active layer
  b.pos.addScaledVector(new V3().setFromMatrixColumn(camera.matrixWorld, 0), 0.25 * S.pasteN);
  S.blobs.push(b); select(b); markDirty();
}
function pickBlob(e) {
  const mesh = layerMesh();                       // only the active layer can be picked
  if (!mesh.geometry.attributes.position) return null;
  setRay(e);
  const h = raycaster.intersectObject(mesh, false)[0];
  if (!h) return null;
  const cs = compile(S.blobs).filter(c => c.neg === S.neg && !c.field);
  return cs.length ? S.blobs[ownerAt(cs, h.point.x, h.point.y, h.point.z)] : null;
}
function pickField(e) {                           // force-field shells are pickable (active layer only)
  if (!FORCE_FIELDS || !fieldMesh.geometry.attributes.position) return null;
  setRay(e);
  const h = raycaster.intersectObject(fieldMesh, false)[0];
  if (!h) return null;
  const fc = compile(S.blobs).filter(c => c.field);
  if (!fc.length) return null;
  const b = S.blobs[ownerAt(fc.map(c => ({ ...c, neg: false, field: 0 })), h.point.x, h.point.y, h.point.z)];
  return b && !!b.neg === S.neg ? b : null;
}
function layerMesh() { return S.neg ? negMesh : prevMesh; }

let drag = null, downPos = null;

view.addEventListener('pointerdown', e => {
  if (e.button !== 0) return;
  if (O.on) { ornDown(e); return; }
  if (S.drawing) { downPos = { x: e.clientX, y: e.clientY }; return; }   // drawing: points are placed on click only; drag orbits
  const b = S.sel;
  if (b) {
    if (e.shiftKey) {                                         // SHIFT + drag = radius
      drag = { kind: 'radius', b, r0: b.r, y0: e.clientY };
      e.stopPropagation(); return;
    }
    const hp = hitSplineHandle(e);
    if (hp) {
      if (hp.kind === 'node' && e.altKey) { S.node = null; deleteNode(b, hp.i); e.stopPropagation(); return; }   // ALT + click node = delete
      S.node = { kind: hp.kind, i: hp.i }; hoverNode = S.node; updateGizmo();
      drag = { kind: hp.kind, b, i: hp.i }; e.stopPropagation(); return;
    }
    const hg = hitGizmo(e);
    if (hg) {
      const ray = setRay(e), d = { kind: hg.kind, axis: hg.axis, b, pos0: b.pos.clone(), quat0: b.quat.clone(), scale0: b.scale.clone(), y0: e.clientY };
      if (hg.kind === 'move') { d.D = AX[hg.axis]; d.t0 = axisParam(ray, d.pos0, d.D); }
      if (hg.kind === 'rotate') {
        const a = hg.axis, u = AX[(a + 1) % 3], v = AX[(a + 2) % 3];
        d.A = AX[a]; d.u = u; d.v = v;
        d.plane = new THREE.Plane().setFromNormalAndCoplanarPoint(d.A, d.pos0);
        const p = ray.intersectPlane(d.plane, new V3());
        if (!p) return;
        p.sub(d.pos0); d.a0 = Math.atan2(p.dot(v), p.dot(u));
      }
      if (hg.kind === 'scale' && hg.axis !== 'c') {
        d.D = AX[hg.axis].clone().applyQuaternion(b.quat);
        const gs = viewDist(b.pos) * 0.15;
        d.t0 = axisParam(ray, d.pos0, d.D);
        if (Math.abs(d.t0) < 0.25 * gs) d.t0 = 0.25 * gs * (d.t0 < 0 ? -1 : 1);
      }
      drag = d; e.stopPropagation(); return;
    }
  }
  downPos = { x: e.clientX, y: e.clientY };
}, true);

window.addEventListener('pointermove', e => {
  if (O.on) { ornMove(e); return; }
  if (S.drawing) { drawMove(e); return; }
  if (drag && (drag.kind === 'node' || drag.kind === 'hin' || drag.kind === 'hout')) hoverNode = { kind: drag.kind, i: drag.i };
  if (!drag) {
    // hover feedback
    if (e.target === canvas && S.sel && !S.shift) {
      const hg = hitGizmo(e), hp = hitSplineHandle(e);
      hover = hg; canvas.style.cursor = (hg || hp) ? 'pointer' : '';
      const nh = hp ? { kind: hp.kind, i: hp.i } : null;
      if ((nh && nh.kind + nh.i) !== (hoverNode && hoverNode.kind + hoverNode.i)) { hoverNode = nh; updateGizmo(); }
    } else { hover = null; canvas.style.cursor = ''; if (hoverNode) { hoverNode = null; updateGizmo(); } }
    return;
  }
  const d = drag, b = d.b, ray = setRay(e);
  if (d.kind === 'radius') {
    b.r = clamp(d.r0 * Math.exp((d.y0 - e.clientY) * 0.008), 0.02, 3);
  } else if (d.kind === 'node' || d.kind === 'hin' || d.kind === 'hout') {
    const c = splineCtrl(b), i = d.i;
    const cur = d.kind === 'node' ? c.nodes[i] : d.kind === 'hin' ? c.ins[i] : c.outs[i];
    const p = ray.intersectPlane(new THREE.Plane().setFromNormalAndCoplanarPoint(camera.getWorldDirection(new V3()), cur), new V3());
    if (p) {
      const l = toLocal(b, p);
      if (d.kind === 'node') b.pts[i].copy(l);
      else {
        const isIn = d.kind === 'hin', mine = isIn ? b.hin : b.hout, other = isIn ? b.hout : b.hin;
        mine[i].copy(l).sub(b.pts[i]);
        const hasOther = isIn ? i < b.pts.length - 1 : i > 0;
        if (hasOther && !e.altKey) {                         // smooth node: opposite handle mirrors direction (ALT = break)
          const len = other[i].length() || mine[i].length();
          other[i].copy(mine[i]).normalize().multiplyScalar(-len);
        }
      }
      updateSpline(b);
    }
  } else if (d.kind === 'move') {
    const t = axisParam(ray, d.pos0, d.D);
    b.pos.copy(d.pos0).addScaledVector(d.D, t - d.t0);
  } else if (d.kind === 'rotate') {
    const p = ray.intersectPlane(d.plane, new V3());
    if (!p) return;
    p.sub(d.pos0);
    const ang = Math.atan2(p.dot(d.v), p.dot(d.u)) - d.a0;
    b.quat.copy(new THREE.Quaternion().setFromAxisAngle(d.A, ang)).multiply(d.quat0);
  } else if (d.kind === 'scale') {
    if (d.axis === 'c') {
      const mn = Math.min(d.scale0.x, d.scale0.y, d.scale0.z), mx = Math.max(d.scale0.x, d.scale0.y, d.scale0.z);
      const f = clamp(Math.exp((d.y0 - e.clientY) * 0.008), 0.05 / mn, 20 / mx);   // uniform: proportions are always kept
      b.scale.copy(d.scale0).multiplyScalar(f);
    } else {
      const f = clamp(axisParam(ray, d.pos0, d.D) / d.t0, 0.05, 20);
      b.scale.copy(d.scale0); b.scale.setComponent(d.axis, d.scale0.getComponent(d.axis) * f);
    }
    if (d.axis !== 'c') b.scale.clampScalar(0.05, 20);
  }
  syncSelUI(); markDirty();
});

window.addEventListener('pointerup', e => {
  if (O.on) { ornUp(e); return; }
  if (S.drawing) {
    if (downPos && e.target === canvas && e.button === 0) {
      const moved = Math.hypot(e.clientX - downPos.x, e.clientY - downPos.y);
      if (moved < 5) drawDown(e);
    }
    downPos = null; return;
  }
  if (drag) { drag = null; downPos = null; return; }
  if (downPos) {
    const moved = Math.hypot(e.clientX - downPos.x, e.clientY - downPos.y);
    downPos = null;
    if (moved < 5 && e.target === canvas) select(pickBlob(e) || pickField(e));
  }
});

canvas.addEventListener('wheel', e => {                       // SHIFT + wheel = radius
  if (!e.shiftKey || !S.sel || S.drawing) return;
  e.preventDefault(); e.stopImmediatePropagation();
  const d = e.deltaY || e.deltaX;
  S.sel.r = clamp(S.sel.r * (d < 0 ? 1.06 : 0.94), 0.02, 3);
  syncSelUI(); markDirty();
}, { passive: false, capture: true });
canvas.addEventListener('contextmenu', e => e.preventDefault());

window.addEventListener('keydown', e => {
  if (e.key === 'Shift') { S.shift = true; controls.enableZoom = false; updateHint(); }
  if (e.target.tagName === 'INPUT' && e.target.type === 'text') return;
  const np = { Numpad7: ['top', 'bottom'], Numpad1: ['front', 'back'], Numpad3: ['right', 'left'] }[e.code];   // Blender numpad; Ctrl = opposite side
  if (np) { e.preventDefault(); setView(np[e.ctrlKey ? 1 : 0]); return; }
  if (O.on) return;
  if (S.drawing) {
    if (e.key === 'Enter') { e.preventDefault(); finishDraw(); }
    if (e.key === 'Escape') cancelDraw();
    return;
  }
  if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {                // copy / paste
    const k = e.key.toLowerCase();
    if (k === 's') { e.preventDefault(); saveLocal(); return; }
    if (k === 'c' && S.sel) { S.clip = cloneBlob(S.sel); S.pasteN = 0; e.preventDefault(); return; }
    if (k === 'v' && S.clip) { e.preventDefault(); pasteBlob(); return; }
  }
  if ((e.key === 'Delete' || e.key === 'Backspace') && S.sel) { e.preventDefault(); deleteSel(); }
  else if (e.key === 'Escape') select(null);
  else if (S.sel && !e.ctrlKey && !e.metaKey) {
    const k = e.key.toLowerCase();
    if (k === 'g' || k === 'w') setMode('move');
    if (k === 'r') setMode('rotate');
    if (k === 's') setMode('scale');
  }
});
window.addEventListener('keyup', e => { if (e.key === 'Shift') { S.shift = false; controls.enableZoom = true; updateHint(); } });
window.addEventListener('blur', () => { S.shift = false; controls.enableZoom = true; updateHint(); });

/* ---------- selection / modes / delete ---------- */
function select(b) {
  S.sel = b; hover = null; S.node = null; hoverNode = null;
  syncSelUI(); updateBar(); applyTint(); updateHint(); rebuildOutlines();
}
function setMode(m) { S.mode = m; updateBar(); }
function deleteSel() {
  if (!S.sel) return;
  S.blobs.splice(S.blobs.indexOf(S.sel), 1);
  select(null); markDirty();
}
function addBlob(type) {
  if (type === 'spline') { startDraw(); return; }
  const p = controls.target.clone().add(new V3((Math.random() - .5) * .7, (Math.random() - .3) * .5, (Math.random() - .5) * .7));
  const b = makeBlob(type, p);
  S.blobs.push(b); S.mode = 'move'; select(b); markDirty();
}

/* ---------- spline drawing ---------- */
function drawPointFrom(e) {
  setRay(e);
  if (layerMesh().geometry.attributes.position) {
    const h = raycaster.intersectObject(layerMesh(), false)[0];
    if (h) return h.point.clone();
  }
  const n = camera.getWorldDirection(new V3());
  return raycaster.ray.intersectPlane(new THREE.Plane().setFromNormalAndCoplanarPoint(n, controls.target), new V3());
}
function screenOf(p, e) {
  const r = canvas.getBoundingClientRect(), q = p.clone().project(camera);
  return [(q.x * .5 + .5) * r.width + r.left, (-q.y * .5 + .5) * r.height + r.top];
}
function startDraw() {
  select(null);
  S.drawing = { pts: [], hover: null };
  canvas.style.cursor = 'crosshair';
  updateBar(); updateHint(); refreshDraw();
}
function endDrawMode() {
  S.drawing = null;
  canvas.style.cursor = '';
  draw.pts.visible = false; drawLine.visible = false;
  updateBar(); updateHint();
}
function cancelDraw() { endDrawMode(); }
function finishDraw() {
  const d = S.drawing; if (!d) return;
  if (d.pts.length < 2) { endDrawMode(); return; }
  const c = new V3(); d.pts.forEach(p => c.add(p)); c.divideScalar(d.pts.length);
  const b = makeBlob('spline', c);
  b.pts = d.pts.map(p => p.clone().sub(c)); autoHandles(b); updateSpline(b);
  S.blobs.push(b); endDrawMode();
  S.mode = 'move'; select(b); markDirty();
}
function tooClose(e, p, px) {
  const [sx, sy] = screenOf(p, e);
  return Math.hypot(sx - e.clientX, sy - e.clientY) < px;
}
function drawDown(e) {
  const d = S.drawing, p = drawPointFrom(e);
  if (!p || d.pts.length >= MAXD) return;
  const last = d.pts[d.pts.length - 1];
  if (!last || !tooClose(e, last, 8)) d.pts.push(p);      // ignore the 2nd click of a double-click
  refreshDraw();
}
function drawMove(e) {
  const d = S.drawing; if (!d) return;
  d.hover = e.target === canvas ? drawPointFrom(e) : null;   // preview only, never adds points
  refreshDraw();
}
view.addEventListener('dblclick', e => { if (S.drawing) finishDraw(); else insertNodeAt(e); });
function refreshDraw() {
  const d = S.drawing; if (!d) return;
  const a = draw.attr.array, all = d.hover ? [...d.pts, d.hover] : d.pts;
  all.forEach((p, i) => { a[i * 3] = p.x; a[i * 3 + 1] = p.y; a[i * 3 + 2] = p.z; });
  draw.attr.needsUpdate = true;
  draw.geo.setDrawRange(0, d.pts.length);
  drawLine.geometry.setDrawRange(0, all.length);
  draw.pts.visible = d.pts.length > 0; drawLine.visible = all.length > 1;
  $('drawN').textContent = d.pts.length ? `· ${d.pts.length} PTS` : '';
}

/* ============================================================
   UI
============================================================ */
function setStatus(t) { $('status').textContent = t; }
function updateBar() {
  const on = !!S.sel || !!S.drawing || O.on;
  $('bar').hidden = !on;
  $('barSel').hidden = O.on || !!S.drawing; $('barDraw').hidden = O.on || !S.drawing; $('barOrn').hidden = !O.on;
  document.querySelectorAll('[data-mode]').forEach(b => b.classList.toggle('on', b.dataset.mode === S.mode));
}
function updateHint() {}
function syncSelUI() {
  const b = S.sel;
  syncSym();
  $('selSec').hidden = !b;
  if (!b) return;
  $('selTitle').textContent = 'SELECTED · ' + b.type.toUpperCase();
  $('sR').value = b.r; $('vR').textContent = b.r.toFixed(2);
  $('rowRound').hidden = b.type !== 'cube';
  $('rowLen').hidden = b.type !== 'capsule';
  $('rowSpl').hidden = b.type !== 'spline';
  $('rowField').hidden = !FORCE_FIELDS || b.type === 'spline';
  $('rowFstr').hidden = !b.field;
  $('ffSolid').classList.toggle('on', !b.field); $('ffAttr').classList.toggle('on', b.field === 1); $('ffRep').classList.toggle('on', b.field === -1);
  $('sFstr').value = b.fstr; $('vFstr').textContent = b.fstr.toFixed(2);
  $('sRound').value = b.round; $('vRound').textContent = b.round.toFixed(2);
  $('sLen').value = b.len; $('vLen').textContent = b.len.toFixed(2);
}
function bind(id, vid, fmt, cb) {
  const el = $(id), v = $(vid);
  const upd = () => v.textContent = fmt(+el.value);
  el.addEventListener('input', () => { upd(); cb(+el.value); });
  upd();
}
bind('sFall', 'vFall', v => v.toFixed(2), v => { S.fall = v; markDirty(); });
bind('sFstr', 'vFstr', v => v.toFixed(2), v => { if (S.sel) { S.sel.fstr = v; markDirty(); } });
[['ffSolid', 0], ['ffAttr', 1], ['ffRep', -1]].forEach(([id, v]) => $(id).addEventListener('click', () => { if (S.sel) { S.sel.field = v; syncSelUI(); markDirty(); } }));
bind('sBlend', 'vBlend', v => v.toFixed(2), v => { S.blend = v; markDirty(); });
bind('sPrev', 'vPrev', v => v + ' / UNIT', v => { S.prevRes = v; markDirty(); });
bind('sRend', 'vRend', v => v + ' / UNIT', v => { S.rendRes = v; });
bind('sR', 'vR', v => v.toFixed(2), v => { if (S.sel) { S.sel.r = v; markDirty(); } });
bind('sRound', 'vRound', v => v.toFixed(2), v => { if (S.sel) { S.sel.round = v; markDirty(); } });
bind('sLen', 'vLen', v => v.toFixed(2), v => { if (S.sel) { S.sel.len = v; markDirty(); } });

document.querySelectorAll('[data-add]').forEach(b => b.addEventListener('click', () => addBlob(b.dataset.add)));
document.querySelectorAll('[data-mode]').forEach(b => b.addEventListener('click', () => setMode(b.dataset.mode)));
$('btnDel').addEventListener('click', deleteSel);
// symmetry is stored per blob; the last toggled state is also the default for new blobs
['symX', 'symY', 'symZ'].forEach((id, i) => $(id).addEventListener('click', () => {
  const v = !(S.sel ? S.sel.sym[i] : S.sym[i]);
  S.sym[i] = v; if (S.sel) S.sel.sym[i] = v;
  syncSym(); markDirty();
}));
function syncSym() {
  const s = S.sel ? S.sel.sym : S.sym;
  ['symX', 'symY', 'symZ'].forEach((id, i) => $(id).classList.toggle('on', !!s[i]));
  $('symLbl').textContent = S.sel ? 'SYMMETRY · SELECTED' : 'SYMMETRY · NEW BLOBS';
}
// positive / negative layer switch: the other layer turns into a faint, locked ghost
function setNegMode(v) {
  S.neg = v;
  $('modePos').classList.toggle('on', !v); $('modeNeg').classList.toggle('on', v);
  if (S.sel && !!S.sel.neg !== v) select(null);
  applyLayerLook(); applyTint(); updateHint(); rebuildOutlines();
}
$('modePos').addEventListener('click', () => setNegMode(false));
$('modeNeg').addEventListener('click', () => setNegMode(true));
$('btnAuto').addEventListener('click', () => { if (S.sel && S.sel.type === 'spline') { autoHandles(S.sel); updateSpline(S.sel); markDirty(); } });
document.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
$('btnFinish').addEventListener('click', finishDraw);
$('btnCancel').addEventListener('click', cancelDraw);
document.querySelectorAll('button').forEach(b => b.addEventListener('click', () => b.blur()));

function setShading(flat) {
  S.flat = flat;
  matPrev.flatShading = matHQ.flatShading = matNeg.flatShading = matOB.flatShading = matOrn.flatShading = flat;
  matPrev.needsUpdate = matHQ.needsUpdate = matNeg.needsUpdate = matOB.needsUpdate = matOrn.needsUpdate = true;
  $('shSmooth').classList.toggle('on', !flat); $('shFlat').classList.toggle('on', flat);
}
$('shSmooth').addEventListener('click', () => setShading(false));
$('shFlat').addEventListener('click', () => setShading(true));
function setGrid(on) { grid.visible = on; $('gridOn').classList.toggle('on', on); $('gridOff').classList.toggle('on', !on); }
function setOutline(on) {
  S.outline = on; $('outOn').classList.toggle('on', on); $('outOff').classList.toggle('on', !on);
  for (const k in outLines) outLines[k].visible = on;
}
$('outOn').addEventListener('click', () => setOutline(true));
$('outOff').addEventListener('click', () => setOutline(false));
bind('sSm', 'vSm', v => v.toFixed(2), v => { S.mat.smooth = v; applyMaterial(); });
bind('sMe', 'vMe', v => v.toFixed(2), v => { S.mat.metal = v; applyMaterial(); });
bind('sTr', 'vTr', v => v.toFixed(2), v => { S.mat.trans = v; applyMaterial(); });
$('mColor').addEventListener('input', e => { S.mat.color = e.target.value; applyMaterial(); });
$('gridOn').addEventListener('click', () => setGrid(true));
$('gridOff').addEventListener('click', () => setGrid(false));

/* ---------- project: save / load / JSON ---------- */
const LS_KEY = 'blob_designer_v1';
const r4 = v => Math.round(v * 1e4) / 1e4;
const vArr = v => [r4(v.x), r4(v.y), r4(v.z)];
function serialize() {
  return {
    app: 'BLOB AND ORNAMENT', version: '1.0',
    settings: { blend: S.blend, fall: S.fall, prevRes: S.prevRes, rendRes: S.rendRes, flat: S.flat, outline: S.outline,
                sym: S.sym.slice(), mat: { ...S.mat }, grid: grid.visible },
    ornament: serializeOrn(),
    blobs: S.blobs.map(b => ({
      type: b.type, pos: vArr(b.pos), quat: [r4(b.quat.x), r4(b.quat.y), r4(b.quat.z), r4(b.quat.w)], scale: vArr(b.scale),
      r: b.r, len: b.len, round: b.round, sym: b.sym.slice(), neg: !!b.neg, field: b.field | 0, fstr: b.fstr,
      ...(b.pts ? { pts: b.pts.map(vArr), hin: b.hin.map(vArr), hout: b.hout.map(vArr) } : {}),
    })),
  };
}
function loadProject(d) {
  if (!d || !Array.isArray(d.blobs)) throw new Error('not a blob project');
  const st = d.settings || {}, num = (v, def) => (typeof v === 'number' && isFinite(v) ? v : def);
  const setSlider = (id, v) => { if (typeof v === 'number') { $(id).value = v; $(id).dispatchEvent(new Event('input')); } };
  if (S.drawing) endDrawMode();
  S.blobs = []; S.sel = null; S.clip = null;
  const keepNeg = S.neg; S.neg = false;
  for (const o of d.blobs) {
    if (!['sphere', 'cube', 'capsule', 'spline'].includes(o.type)) continue;
    const b = makeBlob(o.type, new V3(...(o.pos || [0, 0, 0])));
    if (o.quat) b.quat.set(...o.quat);
    if (o.scale) b.scale.set(...o.scale);
    b.r = num(o.r, b.r); b.len = num(o.len, b.len); b.round = num(o.round, b.round);
    b.sym = Array.isArray(o.sym) ? o.sym.map(Boolean) : [false, false, false];
    b.neg = !!o.neg; b.field = [-1, 0, 1].includes(o.field) ? o.field : 0; b.fstr = num(o.fstr, 0.6);
    if (o.type === 'spline') {
      if (!Array.isArray(o.pts) || o.pts.length < 2) continue;
      b.pts = o.pts.map(a => new V3(...a));
      if (o.hin && o.hout) { b.hin = o.hin.map(a => new V3(...a)); b.hout = o.hout.map(a => new V3(...a)); } else autoHandles(b);
      updateSpline(b);
    }
    S.blobs.push(b);
  }
  S.neg = keepNeg;
  setSlider('sBlend', st.blend); setSlider('sFall', st.fall); setSlider('sPrev', st.prevRes); setSlider('sRend', st.rendRes);
  if (st.mat) {
    Object.assign(S.mat, st.mat); $('mColor').value = S.mat.color;
    setSlider('sSm', S.mat.smooth); setSlider('sMe', S.mat.metal); setSlider('sTr', S.mat.trans); applyMaterial();
  }
  if (typeof st.flat === 'boolean') setShading(st.flat);
  if (typeof st.outline === 'boolean') setOutline(st.outline);
  if (typeof st.grid === 'boolean') setGrid(st.grid);
  if (Array.isArray(st.sym)) S.sym = st.sym.map(Boolean);
  if (hqMesh) { scene.remove(hqMesh); hqMesh = null; }
  if (hqGeo) { hqGeo.dispose(); hqGeo = null; }
  $('btnGlb').disabled = true;
  select(null); setNegMode(S.neg); markDirty();
  loadOrn(d.ornament);
}
function downloadBlob(blob, name) {
  const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name;
  document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 2000);
}
function saveLocal() {
  try { localStorage.setItem(LS_KEY, JSON.stringify(serialize())); setStatus('PROJECT SAVED'); }
  catch (err) { setStatus('SAVE FAILED — USE EXPORT .JSON'); }
}
function loadLocal() {
  try {
    const t = localStorage.getItem(LS_KEY);
    if (!t) { setStatus('NO SAVED PROJECT'); return; }
    loadProject(JSON.parse(t)); setStatus('PROJECT LOADED');
  } catch (err) { console.error(err); setStatus('LOAD FAILED'); }
}
$('pSave').addEventListener('click', saveLocal);
$('pLoad').addEventListener('click', loadLocal);
$('pExp').addEventListener('click', () => {
  downloadBlob(new Blob([JSON.stringify(serialize(), null, 1)], { type: 'application/json' }), 'blob_project.json');
  setStatus('PROJECT EXPORTED');
});
$('pImp').addEventListener('click', () => $('pFile').click());
$('pFile').addEventListener('change', e => {
  const f = e.target.files[0]; if (!f) return;
  f.text().then(t => { loadProject(JSON.parse(t)); setStatus('PROJECT IMPORTED'); })
          .catch(err => { console.error(err); setStatus('IMPORT FAILED — INVALID JSON'); });
  e.target.value = '';
});

/* ---------- high quality render + GLB ---------- */
function renderHQ() {
  if (!S.blobs.some(b => !b.neg)) { setStatus('NOTHING SOLID TO RENDER'); return; }
  setStatus('BUILDING…'); $('btnRender').disabled = true;
  setTimeout(() => {
    try {
      const t0 = performance.now();
      const r = polygonize(compile(S.blobs), S.blend, 1 / S.rendRes, 24e6, false);
      if (!r) { setStatus('NOTHING SOLID TO RENDER'); $('btnRender').disabled = false; return; }
      if (hqMesh) scene.remove(hqMesh);
      if (hqGeo) hqGeo.dispose();
      hqGeo = r.geo;
      hqMesh = new THREE.Mesh(hqGeo, matHQ); scene.add(hqMesh);
      prevMesh.visible = false; negMesh.visible = false; fieldMesh.visible = false; S.hqFresh = true;
      lastTris = r.tris; lastMs = performance.now() - t0;
      const capped = r.H > 1 / S.rendRes * 1.001 ? ` · CAPPED AT ${Math.round(1 / r.H)}` : '';
      setStatus(`DONE · ${fmtN(r.tris)} TRIS · ${(lastMs / 1000).toFixed(1)} S${capped}`);
      $('btnGlb').disabled = false;
    } catch (err) {
      console.error(err); setStatus('ERROR — LOWER THE RENDER RES');
    }
    $('btnRender').disabled = false;
  }, 40);
}
$('btnRender').addEventListener('click', renderHQ);

function exportGLB() {
  if (!hqGeo) return;
  let g = hqGeo;
  if (S.flat) { g = hqGeo.toNonIndexed(); g.computeVertexNormals(); }           // raw / faceted normals baked in
  const m = S.mat;
  const mesh = new THREE.Mesh(g, new THREE.MeshPhysicalMaterial({
    color: m.color, roughness: 1 - m.smooth, metalness: m.metal,
    transmission: m.trans, thickness: 0.6, ior: 1.4, attenuationColor: new THREE.Color(m.color), attenuationDistance: 1.5 }));
  mesh.name = 'blob_mesh';
  new GLTFExporter().parse(mesh, buf => {
    const a = document.createElement('a');
    a.href = URL.createObjectURL(new Blob([buf], { type: 'model/gltf-binary' }));
    a.download = 'blob_mesh.glb'; a.click();
    setTimeout(() => URL.revokeObjectURL(a.href), 3000);
  }, err => { console.error(err); setStatus('EXPORT FAILED'); }, { binary: true });
}
$('btnGlb').addEventListener('click', exportGLB);
const fmtN = n => n >= 1e6 ? (n / 1e6).toFixed(2) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : String(n);

/* ============================================================
   ORNAMENT MODE
   Procedural baroque / rococo relief that grows ON the blob surface.
   Every ornament element is a ribbon swept along a curve that "walks" over the surface
   (heading profile theta(t), projected back onto the iso-surface after every step), so
   scrolls, volutes and leaves are draped over the form. Motifs branch recursively.
============================================================ */
const SURF_RES = 40;
let SF = null, gF = null, gox = 0, goy = 0, goz = 0, gnx = 0, gny = 0, gnz = 0, gH = 1, gs2 = 0;
let ornBase = null, ornMesh = null, ORB = null, CTX = null, growTimer = 0, tintTimer = 0;
const SP = new Float64Array(6), GR = new Float64Array(3);
const OCC = { map: new Map(), cell: 0.1, rmax: 0 };
const PM = { d: null, ox: 0, oy: 0, oz: 0, nx: 0, ny: 0, nz: 0, c: 0.04 };

const cross3 = (a, b) => [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]];
const nrm3 = a => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0]/l, a[1]/l, a[2]/l]; };
const sm01 = x => { x = x < 0 ? 0 : x > 1 ? 1 : x; return x*x*(3-2*x); };
function rotT(n, t, ang) {                       // rotate tangent t about normal n
  const c = Math.cos(ang), s = Math.sin(ang), k = cross3(n, t);
  return [t[0]*c + k[0]*s, t[1]*c + k[1]*s, t[2]*c + k[2]*s];
}
function rng(seed) {
  let a = (seed | 0) ^ 0x9E3779B9;
  return () => { a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}

/* ---------- surface field of the current form ---------- */
function buildSurface() {
  const r = polygonize(compile(S.blobs), S.blend, 1 / SURF_RES, 6e6, false);
  if (!r || !r.tris) { SF = null; return false; }
  const g = r.grid;
  gF = g.F; gox = g.ox; goy = g.oy; goz = g.oz; gnx = g.nx; gny = g.ny; gnz = g.nz; gH = g.H; gs2 = gnx * gny;
  const pos = r.geo.attributes.position.array, idx = r.geo.index.array, nt = idx.length / 3, cum = new Float64Array(nt);
  let A = 0;
  for (let t = 0; t < nt; t++) {
    const a = idx[t*3]*3, b = idx[t*3+1]*3, c = idx[t*3+2]*3;
    const ux = pos[b]-pos[a], uy = pos[b+1]-pos[a+1], uz = pos[b+2]-pos[a+2], vx = pos[c]-pos[a], vy = pos[c+1]-pos[a+1], vz = pos[c+2]-pos[a+2];
    A += 0.5 * Math.hypot(uy*vz-uz*vy, uz*vx-ux*vz, ux*vy-uy*vx);
    cum[t] = A;
  }
  r.geo.computeBoundingBox();
  r.geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(r.verts * 3).fill(1), 3));
  SF = { geo: r.geo, cum, area: A, bb: r.geo.boundingBox.clone() };
  buildPM(); buildFM();
  return true;
}
function sdf(x, y, z) {
  const fx = (x - gox) / gH, fy = (y - goy) / gH, fz = (z - goz) / gH;
  if (fx < 0 || fy < 0 || fz < 0 || fx >= gnx - 1 || fy >= gny - 1 || fz >= gnz - 1) return BIG;
  const i = fx | 0, j = fy | 0, k = fz | 0, u = fx - i, v = fy - j, w = fz - k;
  const p = i + gnx * (j + gny * k), F = gF;
  const a = F[p] + (F[p+1] - F[p]) * u, b = F[p+gnx] + (F[p+gnx+1] - F[p+gnx]) * u;
  const c = F[p+gs2] + (F[p+gs2+1] - F[p+gs2]) * u, d = F[p+gs2+gnx] + (F[p+gs2+gnx+1] - F[p+gs2+gnx]) * u;
  const e = a + (b - a) * v, f = c + (d - c) * v;
  return e + (f - e) * w;
}
function grad(x, y, z) {                          // normalised gradient -> GR, returns slope (≈1 for a true SDF), 0 on failure
  const e = gH * 0.75;
  const gx = sdf(x+e, y, z) - sdf(x-e, y, z), gy = sdf(x, y+e, z) - sdf(x, y-e, z), gz = sdf(x, y, z+e) - sdf(x, y, z-e);
  const l = Math.hypot(gx, gy, gz);
  if (!(l > 1e-9) || l > 100) return 0;
  GR[0] = gx / l; GR[1] = gy / l; GR[2] = gz / l;
  return l / (2 * e);
}
function snap(x, y, z) {                          // project a point onto the surface -> SP (pos + normal)
  for (let it = 0; it < 3; it++) {
    const f = sdf(x, y, z);
    if (!(Math.abs(f) < 0.5)) return false;
    const sl = grad(x, y, z); if (sl < 0.2) return false;
    if (it > 0 && Math.abs(f) < 3e-4) break;
    const k = f / sl; x -= GR[0] * k; y -= GR[1] * k; z -= GR[2] * k;
  }
  if (!(Math.abs(sdf(x, y, z)) < 0.02)) return false;
  if (grad(x, y, z) < 0.2) return false;
  SP[0] = x; SP[1] = y; SP[2] = z; SP[3] = GR[0]; SP[4] = GR[1]; SP[5] = GR[2];
  return true;
}
function randSurf(R) {
  const cum = SF.cum; let x = R() * SF.area, lo = 0, hi = cum.length - 1;
  while (lo < hi) { const m = (lo + hi) >> 1; if (cum[m] < x) lo = m + 1; else hi = m; }
  const idx = SF.geo.index.array, pos = SF.geo.attributes.position.array;
  const a = idx[lo*3]*3, b = idx[lo*3+1]*3, c = idx[lo*3+2]*3;
  let u = R(), v = R(); if (u + v > 1) { u = 1 - u; v = 1 - v; }
  return [pos[a] + (pos[b]-pos[a])*u + (pos[c]-pos[a])*v, pos[a+1] + (pos[b+1]-pos[a+1])*u + (pos[c+1]-pos[a+1])*v, pos[a+2] + (pos[b+2]-pos[a+2])*u + (pos[c+2]-pos[a+2])*v];
}
function pickSurf(e) {                            // ray-march the field: no mesh raycast needed
  if (!SF) return null;
  const ray = setRay(e), o = ray.origin, d = ray.direction;
  let t0 = 0, t1 = 1e9;
  for (const [oo, dd, lo, hi] of [[o.x, d.x, gox, gox + (gnx-1)*gH], [o.y, d.y, goy, goy + (gny-1)*gH], [o.z, d.z, goz, goz + (gnz-1)*gH]]) {
    if (Math.abs(dd) < 1e-9) { if (oo < lo || oo > hi) return null; continue; }
    let a = (lo - oo) / dd, b = (hi - oo) / dd; if (a > b) { const q = a; a = b; b = q; }
    t0 = Math.max(t0, a); t1 = Math.min(t1, b);
  }
  if (t0 >= t1) return null;
  let t = t0 + 1e-4, last = 0.05;
  for (let i = 0; i < 800 && t < t1; i++) {
    const f = sdf(o.x + d.x*t, o.y + d.y*t, o.z + d.z*t);
    if (f < 0) {
      let a = Math.max(t0, t - last), b = t;
      for (let k = 0; k < 8; k++) { const m = 0.5 * (a + b); if (sdf(o.x + d.x*m, o.y + d.y*m, o.z + d.z*m) < 0) b = m; else a = m; }
      if (!snap(o.x + d.x*b, o.y + d.y*b, o.z + d.z*b)) return null;
      return { p: [SP[0], SP[1], SP[2]], n: [SP[3], SP[4], SP[5]] };
    }
    last = f >= BIG * 0.5 ? 0.08 : Math.max(f * 0.75, gH * 0.3);
    t += last;
  }
  return null;
}

/* ---------- painted zone ---------- */
function buildPM() {
  const bb = SF.bb, c = Math.max(gH * 1.5, 0.03);
  PM.c = c; PM.ox = bb.min.x - 2*c; PM.oy = bb.min.y - 2*c; PM.oz = bb.min.z - 2*c;
  PM.nx = Math.ceil((bb.max.x - bb.min.x) / c) + 5; PM.ny = Math.ceil((bb.max.y - bb.min.y) / c) + 5; PM.nz = Math.ceil((bb.max.z - bb.min.z) / c) + 5;
  PM.d = new Uint8Array(PM.nx * PM.ny * PM.nz);
  for (const s of O.stamps) pmStamp(s[0], s[1], s[2], s[3], s[4]);
}
function pmStamp(x, y, z, r, v) {
  const c = PM.c, r2 = r * r;
  const i0 = Math.max(0, Math.floor((x-r-PM.ox)/c)), i1 = Math.min(PM.nx-1, Math.ceil((x+r-PM.ox)/c));
  const j0 = Math.max(0, Math.floor((y-r-PM.oy)/c)), j1 = Math.min(PM.ny-1, Math.ceil((y+r-PM.oy)/c));
  const k0 = Math.max(0, Math.floor((z-r-PM.oz)/c)), k1 = Math.min(PM.nz-1, Math.ceil((z+r-PM.oz)/c));
  for (let k = k0; k <= k1; k++) for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) {
    const dx = PM.ox + (i+0.5)*c - x, dy = PM.oy + (j+0.5)*c - y, dz = PM.oz + (k+0.5)*c - z;
    if (dx*dx + dy*dy + dz*dz <= r2) PM.d[i + PM.nx * (j + PM.ny * k)] = v;
  }
  const ci = Math.floor((x-PM.ox)/c), cj = Math.floor((y-PM.oy)/c), ck = Math.floor((z-PM.oz)/c);
  if (ci >= 0 && cj >= 0 && ck >= 0 && ci < PM.nx && cj < PM.ny && ck < PM.nz) PM.d[ci + PM.nx * (cj + PM.ny * ck)] = v;
}
function inZone(x, y, z) {
  if (O.zone !== 'paint') return true;
  const i = Math.floor((x-PM.ox)/PM.c), j = Math.floor((y-PM.oy)/PM.c), k = Math.floor((z-PM.oz)/PM.c);
  if (i < 0 || j < 0 || k < 0 || i >= PM.nx || j >= PM.ny || k >= PM.nz) return false;
  return PM.d[i + PM.nx * (j + PM.ny * k)] === 1;
}
function symOK(p) { for (let a = 0; a < 3; a++) if (O.sym[a] && p[a] < -0.0005) return false; return true; }
function mirrorList() {
  const L = [];
  for (let m = 1; m < 8; m++) {
    if (((m & 1) && !O.sym[0]) || ((m & 2) && !O.sym[1]) || ((m & 4) && !O.sym[2])) continue;
    L.push([(m & 1) ? -1 : 1, (m & 2) ? -1 : 1, (m & 4) ? -1 : 1]);
  }
  return L;
}
function zoneArea() {                             // area where growth is allowed (and on the generated half for symmetry)
  const idx = SF.geo.index.array, pos = SF.geo.attributes.position.array, nt = idx.length / 3;
  let A = 0;
  for (let t = 0; t < nt; t += 1) {
    const a = idx[t*3]*3, b = idx[t*3+1]*3, c = idx[t*3+2]*3;
    const cx = (pos[a]+pos[b]+pos[c])/3, cy = (pos[a+1]+pos[b+1]+pos[c+1])/3, cz = (pos[a+2]+pos[b+2]+pos[c+2])/3;
    if (!symOK([cx, cy, cz]) || !inZone(cx, cy, cz)) continue;
    const ux = pos[b]-pos[a], uy = pos[b+1]-pos[a+1], uz = pos[b+2]-pos[a+2], vx = pos[c]-pos[a], vy = pos[c+1]-pos[a+1], vz = pos[c+2]-pos[a+2];
    A += 0.5 * Math.hypot(uy*vz-uz*vy, uz*vx-ux*vz, ux*vy-uy*vx);
  }
  return A;
}

/* ---------- occupancy (so ornaments pack instead of overlapping) ---------- */
const okey = (i, j, k) => (i + 2048) * 16777216 + (j + 2048) * 4096 + (k + 2048);
function occInit(cell) { OCC.map = new Map(); OCC.cell = cell; OCC.rmax = 0; }
function occAdd1(x, y, z, r, id, mot) {
  const c = OCC.cell, key = okey(Math.floor(x / c), Math.floor(y / c), Math.floor(z / c));
  let arr = OCC.map.get(key); if (!arr) { arr = []; OCC.map.set(key, arr); }
  arr.push(x, y, z, r, id, mot);
  if (r > OCC.rmax) OCC.rmax = r;
}
function occAdd(x, y, z, r, id, mot) {
  occAdd1(x, y, z, r, id, mot);
  for (const m of CTX.mir) occAdd1(x * m[0], y * m[1], z * m[2], r, id, mot);
}
function isBlocked(x, y, z, r, anc, mot, free) {
  if (!inZone(x, y, z)) return true;
  const c = OCC.cell, inv = 1 / c, nc = Math.ceil((r + OCC.rmax) * inv);
  const ci = Math.floor(x * inv), cj = Math.floor(y * inv), ck = Math.floor(z * inv);
  for (let a = -nc; a <= nc; a++) for (let b = -nc; b <= nc; b++) for (let d = -nc; d <= nc; d++) {
    const arr = OCC.map.get(okey(ci + a, cj + b, ck + d)); if (!arr) continue;
    for (let q = 0; q < arr.length; q += 6) {
      if (anc.has(arr[q+4])) continue;
      if (free && arr[q+5] === mot) continue;
      const dx = arr[q] - x, dy = arr[q+1] - y, dz = arr[q+2] - z, rr = (r + arr[q+3]) * 0.8;
      if (dx*dx + dy*dy + dz*dz < rr*rr) return true;
    }
  }
  return false;
}
function occClear(x, y, z, rmax) {
  const c = OCC.cell, inv = 1 / c, nc = Math.ceil((rmax + OCC.rmax) * inv);
  const ci = Math.floor(x * inv), cj = Math.floor(y * inv), ck = Math.floor(z * inv);
  let best = rmax;
  for (let a = -nc; a <= nc; a++) for (let b = -nc; b <= nc; b++) for (let d = -nc; d <= nc; d++) {
    const arr = OCC.map.get(okey(ci + a, cj + b, ck + d)); if (!arr) continue;
    for (let q = 0; q < arr.length; q += 6) {
      const dx = arr[q] - x, dy = arr[q+1] - y, dz = arr[q+2] - z, dd = Math.sqrt(dx*dx + dy*dy + dz*dz) - arr[q+3];
      if (dd < best) best = dd;
    }
  }
  return best;
}

/* ---------- surface walker ---------- */
// theta(t): heading angle relative to the start heading (radians, + = turn left). Returns the draped curve.
function walk(p0, t0, L, theta, chk, fk) {
  if (!snap(p0[0], p0[1], p0[2])) return null;
  let px = SP[0], py = SP[1], pz = SP[2], nx = SP[3], ny = SP[4], nz = SP[5];
  let tx = t0[0], ty = t0[1], tz = t0[2];
  let d = tx*nx + ty*ny + tz*nz; tx -= nx*d; ty -= ny*d; tz -= nz*d;
  let l = Math.hypot(tx, ty, tz);
  if (l < 1e-5) { const a = Math.abs(nx) < 0.8 ? [1,0,0] : [0,1,0]; d = a[0]*nx + a[1]*ny + a[2]*nz; tx = a[0]-nx*d; ty = a[1]-ny*d; tz = a[2]-nz*d; l = Math.hypot(tx, ty, tz); }
  tx /= l; ty /= l; tz /= l;
  let TV = 0, tp = theta(0);
  for (let i = 1; i <= 40; i++) { const tc = theta(i / 40); TV += Math.abs(tc - tp); tp = tc; }
  const steps = Math.min(140, Math.max(14, Math.ceil(L / CTX.ds), Math.ceil(TV / 0.24)));
  const ds = L / steps;
  const P = new Float64Array((steps + 1) * 3), N = new Float64Array((steps + 1) * 3), T = new Float64Array((steps + 1) * 3);
  P[0] = px; P[1] = py; P[2] = pz; N[0] = nx; N[1] = ny; N[2] = nz; T[0] = tx; T[1] = ty; T[2] = tz;
  let thp = theta(0), i = 1, reason = 0;
  for (; i <= steps; i++) {
    const th = theta(i / steps), dth = th - thp; thp = th;
    if (fk > 0 && flowAt(px, py, pz, nx, ny, nz, tx, ty, tz)) {       // steer toward the flow field
      const a = Math.min(1, fk * (0.03 + 0.2 * FS));
      tx += (FD[0] - tx) * a; ty += (FD[1] - ty) * a; tz += (FD[2] - tz) * a;
      const dn = tx*nx + ty*ny + tz*nz; tx -= nx*dn; ty -= ny*dn; tz -= nz*dn;
      const ll = Math.hypot(tx, ty, tz) || 1; tx /= ll; ty /= ll; tz /= ll;
    }
    const c = Math.cos(dth), s = Math.sin(dth);
    const kx = ny*tz - nz*ty, ky = nz*tx - nx*tz, kz = nx*ty - ny*tx;
    const ux = tx*c + kx*s, uy = ty*c + ky*s, uz = tz*c + kz*s;
    if (!snap(px + ux*ds, py + uy*ds, pz + uz*ds)) { reason = 1; break; }
    const jx = SP[0]-px, jy = SP[1]-py, jz = SP[2]-pz;
    if (jx*jx + jy*jy + jz*jz > 6.25 * ds * ds) { reason = 2; break; }
    px = SP[0]; py = SP[1]; pz = SP[2]; nx = SP[3]; ny = SP[4]; nz = SP[5];
    d = ux*nx + uy*ny + uz*nz; tx = ux - nx*d; ty = uy - ny*d; tz = uz - nz*d;
    l = Math.hypot(tx, ty, tz) || 1; tx /= l; ty /= l; tz /= l;
    const o = i * 3;
    P[o] = px; P[o+1] = py; P[o+2] = pz; N[o] = nx; N[o+1] = ny; N[o+2] = nz; T[o] = tx; T[o+1] = ty; T[o+2] = tz;
    if (chk && chk(px, py, pz, i, i / steps)) { reason = 3; break; }
  }
  return { n: i, P, N, T, L: ds * (i - 1), steps, full: reason === 0 };
}
function at(el, t) {                              // anchor (position, normal, tangent) at parameter t of an element
  const f = Math.min(Math.max(t, 0), 1) * (el.n - 1), i = Math.min(el.n - 2, f | 0), u = f - i, o = i * 3, q = o + 3;
  const lp = A => [A[o] + (A[q] - A[o]) * u, A[o+1] + (A[q+1] - A[o+1]) * u, A[o+2] + (A[q+2] - A[o+2]) * u];
  const n = nrm3(lp(el.N)); let tt = lp(el.T);
  const d = tt[0]*n[0] + tt[1]*n[1] + tt[2]*n[2];
  tt = nrm3([tt[0] - n[0]*d, tt[1] - n[1]*d, tt[2] - n[2]*d]);
  return { p: lp(el.P), n, t: tt, tk: t };
}
function slide(a, dir, dist) {                    // move an anchor along the surface
  const r = walk(a.p, dir, dist, () => 0, null);
  if (!r || r.n < 2) return a;
  const o = (r.n - 1) * 3;
  return { p: [r.P[o], r.P[o+1], r.P[o+2]], n: [r.N[o], r.N[o+1], r.N[o+2]], t: [r.T[o], r.T[o+1], r.T[o+2]], tk: a.tk };
}

/* ---------- geometry builder (+ symmetry copies) ---------- */
function newBuilder() { return { v: new Float32Array(3 * 65536), vc: 0, i: new Uint32Array(3 * 131072), ic: 0 }; }
function bAdd(B, V, nv, I, ni, sx, sy, sz) {
  while ((B.vc + nv) * 3 > B.v.length) { const a = new Float32Array(B.v.length * 2); a.set(B.v); B.v = a; }
  while (B.ic + ni > B.i.length) { const a = new Uint32Array(B.i.length * 2); a.set(B.i); B.i = a; }
  const base = B.vc, flip = (sx * sy * sz) < 0;
  for (let k = 0; k < nv; k++) { B.v[(base+k)*3] = V[k*3] * sx; B.v[(base+k)*3+1] = V[k*3+1] * sy; B.v[(base+k)*3+2] = V[k*3+2] * sz; }
  for (let k = 0; k < ni; k += 3) {
    if (!flip) { B.i[B.ic++] = I[k] + base; B.i[B.ic++] = I[k+1] + base; B.i[B.ic++] = I[k+2] + base; }
    else       { B.i[B.ic++] = I[k] + base; B.i[B.ic++] = I[k+2] + base; B.i[B.ic++] = I[k+1] + base; }
  }
  B.vc += nv;
}
function bEmit(V, nv, I, ni) {
  const B = CTX.B;
  bAdd(B, V, nv, I, ni, 1, 1, 1);
  for (const m of CTX.mir) bAdd(B, V, nv, I, ni, m[0], m[1], m[2]);
}

/* ---------- ribbon cross-section profiles (u = -1..1) ---------- */
const PROFILE = {
  round: u => Math.sqrt(Math.max(0, 1 - u*u)),
  flute: u => Math.sqrt(Math.max(0, 1 - u*u)) * (0.8 + 0.2 * Math.cos(3 * Math.PI * u)),
  leaf:  u => { const v = Math.max(0, 1 - u*u); return Math.pow(v, 0.6) * (0.6 + 0.4 * Math.exp(-u*u / 0.05)); },
};
function emitRibbon(el) {
  const sp = el.spec, n = el.n, J = sp.J, M = 2 * (J + 1), rel = O.relief, prof = PROFILE[sp.kind];
  const step = n > 70 ? 2 : 1, ids = [];
  for (let i = 0; i < n; i += step) ids.push(i);
  if (ids[ids.length - 1] !== n - 1) ids.push(n - 1);
  const ns = ids.length;
  if (ns < 3) return;
  const V = new Float32Array((ns * M + 2) * 3), P = el.P, N = el.N;
  for (let a = 0; a < ns; a++) {
    const i = ids[a], t = a / (ns - 1), o = i * 3;
    const px = P[o], py = P[o+1], pz = P[o+2], nx = N[o], ny = N[o+1], nz = N[o+2];
    const i0 = Math.max(0, i - 1) * 3, i1 = Math.min(n - 1, i + 1) * 3;
    let tx = P[i1] - P[i0], ty = P[i1+1] - P[i0+1], tz = P[i1+2] - P[i0+2];
    const d = tx*nx + ty*ny + tz*nz; tx -= nx*d; ty -= ny*d; tz -= nz*d;
    const tl = Math.hypot(tx, ty, tz) || 1; tx /= tl; ty /= tl; tz /= tl;
    const bx = ny*tz - nz*ty, by = nz*tx - nx*tz, bz = nx*ty - ny*tx;
    const wl = sp.wl(t), wr = sp.wr ? sp.wr(t) : wl, mid = 0.5 * (wl + wr);
    const z = el.zf(t), H = sp.hr * mid * rel, rl = sp.roll ? sp.roll(t) : 0, cup = sp.cup ? sp.cup * mid * rel : 0;
    const thk = sp.th + 0.5 * H + 0.5 * Math.max(0, z);
    const cr = Math.cos(rl), sr = Math.sin(rl);
    const Bx = bx*cr + nx*sr, By = by*cr + ny*sr, Bz = bz*cr + nz*sr;
    const Nx = nx*cr - bx*sr, Ny = ny*cr - by*sr, Nz = nz*cr - bz*sr;
    const so = sp.off ? sp.off(t) : 0, zo = sp.zo ? sp.zo(t) : 0;
    const cx = px + nx*(z + zo) + bx*so, cy = py + ny*(z + zo) + by*so, cz = pz + nz*(z + zo) + bz*so;
    let k = a * M * 3;
    for (let j = 0; j <= J; j++) {
      const u = -1 + 2*j/J, lat = u >= 0 ? u*wl : u*wr, h = H * prof(u) + cup * u*u;
      V[k++] = cx + Bx*lat + Nx*h; V[k++] = cy + By*lat + Ny*h; V[k++] = cz + Bz*lat + Nz*h;
    }
    for (let j = J; j >= 0; j--) {
      const u = -1 + 2*j/J, lat = u >= 0 ? u*wl : u*wr;
      V[k++] = cx + Bx*lat - Nx*thk; V[k++] = cy + By*lat - Ny*thk; V[k++] = cz + Bz*lat - Nz*thk;
    }
  }
  const c0 = ns * M, c1 = c0 + 1;
  for (let m = 0; m < M; m++) for (let q = 0; q < 3; q++) {
    V[c0*3+q] += V[m*3+q] / M; V[c1*3+q] += V[((ns-1)*M+m)*3+q] / M;
  }
  const I = new Uint32Array(((ns - 1) * M * 2 + 2 * M) * 3); let ic = 0;
  for (let a = 0; a < ns - 1; a++) for (let m = 0; m < M; m++) {
    const A = a*M + m, Bm = a*M + (m+1) % M, C = (a+1)*M + m, D = (a+1)*M + (m+1) % M;
    I[ic++] = A; I[ic++] = C; I[ic++] = Bm; I[ic++] = Bm; I[ic++] = C; I[ic++] = D;
  }
  for (let m = 0; m < M; m++) {
    const m2 = (m + 1) % M;
    I[ic++] = c0; I[ic++] = m; I[ic++] = m2;
    I[ic++] = c1; I[ic++] = (ns-1)*M + m2; I[ic++] = (ns-1)*M + m;
  }
  bEmit(V, ns * M + 2, I, ic);
}
function emitDome(p, n, t, r, h) {                // little half-pearl sunk into the surface
  const RR = 5, SG = 14, b = cross3(n, t), V = [], I = [];
  for (let k = 0; k < RR; k++) {
    const al = k / RR * Math.PI / 2, rad = r * Math.cos(al), hh = h * Math.sin(al) - (k === 0 ? 0.3 * h : 0);
    for (let s = 0; s < SG; s++) {
      const ph = s / SG * 6.2832, cu = Math.cos(ph) * rad, cv = Math.sin(ph) * rad;
      V.push(p[0] + t[0]*cu + b[0]*cv + n[0]*hh, p[1] + t[1]*cu + b[1]*cv + n[1]*hh, p[2] + t[2]*cu + b[2]*cv + n[2]*hh);
    }
  }
  V.push(p[0] + n[0]*h, p[1] + n[1]*h, p[2] + n[2]*h);
  const apex = RR * SG;
  for (let k = 0; k < RR; k++) for (let s = 0; s < SG; s++) {
    const a = k*SG + s, b2 = k*SG + (s+1) % SG;
    if (k < RR - 1) { const c = (k+1)*SG + s, d = (k+1)*SG + (s+1) % SG; I.push(a, b2, c, b2, d, c); }
    else I.push(a, b2, apex);
  }
  bEmit(new Float32Array(V), V.length / 3, new Uint32Array(I), I.length);
}
function pearl(p, n, t, r) {
  emitDome(p, n, t, r, r * 0.95);
  occAdd(p[0], p[1], p[2], r, ++CTX.eid, CTX.mot);
}
function pearlAt(el, r) { const q = at(el, 1); pearl(q.p, q.n, q.t, r); }

/* ---------- grow one ribbon element (with collision-aware retries) ---------- */
function walkEl(par, p, tv, L, theta, spec, o = {}) {      // walk + collision retries; returns the element (not yet emitted)
  const C = CTX, id = ++C.eid, anc = new Set(par ? [...par.anc, par.id] : []);
  const wl = spec.wl, wr = spec.wr || spec.wl, wmid = t => 0.5 * (wl(t) + wr(t));
  const z0 = (par && o.tk !== undefined && par.zf) ? par.zf(o.tk) : 0;
  const fk = o.fk === undefined ? 0 : o.fk;
  let LL = L, w = null;
  for (let att = 0; att < 3 && !w; att++) {
    const chk = (x, y, z, i, t) => i > 2 && isBlocked(x, y, z, wmid(t) * 0.75, anc, C.mot, o.free);
    const r = walk(p, tv, LL, theta, chk, fk);
    if (!r || r.n < 5) return null;
    if (r.full) w = r;
    else {
      const f = (r.n - 1) / r.steps;
      if (f * LL < L * (o.minFrac === undefined ? 0.4 : o.minFrac)) return null;
      if (att === 2) w = r; else LL *= f * 0.9;
    }
  }
  if (!w) return null;
  const el = { id, anc, n: w.n, P: w.P, N: w.N, T: w.T, L: w.L, steps: w.steps, spec, z0, wmid, free: o.free };
  el.zf = t => z0 * (1 - sm01(t * 2.5)) + (spec.z ? spec.z(t) * O.relief : 0);
  return el;
}
function regEl(el, wm) {                          // occupancy hash + coverage estimate
  const C = CTX, w = el, wmid = wm || el.wmid;
  const stride = Math.max(1, Math.floor(wmid(0.5) * 0.7 / (w.L / w.steps || 1)));
  const dsr = w.L / Math.max(1, w.n - 1);
  for (let i = 0; i < w.n; i += stride) {
    const t = i / (w.n - 1), o3 = i * 3;
    occAdd(w.P[o3], w.P[o3+1], w.P[o3+2], wmid(t) * 0.92, el.id, C.mot);
  }
  for (let i = 0; i < w.n; i++) C.cover += dsr * 2 * Math.max(wmid(i / (w.n - 1)), C.U * 0.05) * (el.free ? 0.45 : 1);
  C.nEl++;
}
function grow(par, p, tv, L, theta, spec, o = {}) {
  const el = walkEl(par, p, tv, L, theta, spec, o);
  if (!el) return null;
  if (spec.emit) spec.emit(el); else emitRibbon(el);
  regEl(el);
  return el;
}
const go = (par, b, dir, L, theta, spec, o) => grow(par, b.p, dir, L, theta, spec, Object.assign(b.tk !== undefined ? { tk: b.tk } : {}, o));

/* ---------- solid pieces: ellipsoid bead, stadium (chain) link ---------- */
function emitBlob(p, n, t, a, b, c, lift, SG0, RR0) {       // ellipsoid: a along t, b across, c along n; centre lifted by `lift` along n
  const bb = cross3(n, t), SG = SG0 || 14, RR = RR0 || 9, V = [], I = [];
  for (let k = 0; k <= RR; k++) {
    const al = -Math.PI / 2 + Math.PI * k / RR, ca = Math.cos(al), sa = Math.sin(al);
    for (let s = 0; s < SG; s++) {
      const ph = s / SG * 6.2832, u = Math.cos(ph) * ca * a, v = Math.sin(ph) * ca * b, w = sa * c + lift;
      V.push(p[0] + t[0]*u + bb[0]*v + n[0]*w, p[1] + t[1]*u + bb[1]*v + n[1]*w, p[2] + t[2]*u + bb[2]*v + n[2]*w);
    }
  }
  for (let k = 0; k < RR; k++) for (let s = 0; s < SG; s++) {
    const A = k*SG + s, B = k*SG + (s+1) % SG, C = (k+1)*SG + s, D = (k+1)*SG + (s+1) % SG;
    I.push(A, B, C, B, D, C);
  }
  bEmit(new Float32Array(V), V.length / 3, new Uint32Array(I), I.length);
}
function emitLink(p, n, t, R, r, s, ang, lift) {  // stadium-shaped torus link. long axis along t; ring plane = (t, rot(b,n by ang))
  const b = cross3(n, t), ca = Math.cos(ang), sa = Math.sin(ang);
  const e2 = [b[0]*ca + n[0]*sa, b[1]*ca + n[1]*sa, b[2]*ca + n[2]*sa];      // across-direction in the ring plane
  const e3 = [-b[0]*sa + n[0]*ca, -b[1]*sa + n[1]*ca, -b[2]*sa + n[2]*ca];   // ring-plane normal
  const NU = 30, NV = 8, V = [], I = [], per = 2 * s + 2 * Math.PI * R;
  for (let i = 0; i < NU; i++) {
    const d = i / NU * per; let u, v, tu, tv;
    if (d < s) { u = d - s/2; v = -R; tu = 1; tv = 0; }
    else if (d < s + Math.PI * R) { const q = (d - s) / R; u = s/2 + Math.sin(q) * R; v = -Math.cos(q) * R; tu = Math.cos(q); tv = Math.sin(q); }
    else if (d < 2*s + Math.PI * R) { u = s/2 - (d - s - Math.PI*R); v = R; tu = -1; tv = 0; }
    else { const q = (d - 2*s - Math.PI*R) / R; u = -s/2 - Math.sin(q) * R; v = Math.cos(q) * R; tu = -Math.cos(q); tv = -Math.sin(q); }
    const cx = p[0] + t[0]*u + e2[0]*v + n[0]*lift, cy = p[1] + t[1]*u + e2[1]*v + n[1]*lift, cz = p[2] + t[2]*u + e2[2]*v + n[2]*lift;
    const nu = tv, nv2 = -tu;                                                   // in-plane normal of the centreline
    for (let j = 0; j < NV; j++) {
      const ph = j / NV * 6.2832, c1 = Math.cos(ph) * r, c2 = Math.sin(ph) * r;
      V.push(cx + (t[0]*nu + e2[0]*nv2)*c1 + e3[0]*c2, cy + (t[1]*nu + e2[1]*nv2)*c1 + e3[1]*c2, cz + (t[2]*nu + e2[2]*nv2)*c1 + e3[2]*c2);
    }
  }
  for (let i = 0; i < NU; i++) for (let j = 0; j < NV; j++) {
    const A = i*NV + j, B = i*NV + (j+1) % NV, C = ((i+1) % NU)*NV + j, D = ((i+1) % NU)*NV + (j+1) % NV;
    I.push(A, C, B, B, C, D);
  }
  bEmit(new Float32Array(V), V.length / 3, new Uint32Array(I), I.length);
}

/* ---------- general tube sweep along a 3D polyline (parallel transport), optional closed loop ---------- */
function emitSweep(pts, rad, closed, seg) {
  seg = seg || 8; const m = pts.length; if (m < 2) return;
  const V = new Float32Array(m * seg * 3), I = []; let u = null;
  for (let i = 0; i < m; i++) {
    const a = pts[closed ? (i - 1 + m) % m : Math.max(0, i - 1)], b = pts[closed ? (i + 1) % m : Math.min(m - 1, i + 1)];
    const t = nrm3([b[0] - a[0], b[1] - a[1], b[2] - a[2]]);
    if (!u) u = Math.abs(t[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0];
    const d = u[0]*t[0] + u[1]*t[1] + u[2]*t[2]; u = nrm3([u[0] - t[0]*d, u[1] - t[1]*d, u[2] - t[2]*d]);
    const w = cross3(t, u);
    for (let j = 0; j < seg; j++) {
      const ph = j / seg * 6.2832, c = Math.cos(ph) * rad[i], s = Math.sin(ph) * rad[i], o = (i * seg + j) * 3;
      V[o] = pts[i][0] + u[0]*c + w[0]*s; V[o+1] = pts[i][1] + u[1]*c + w[1]*s; V[o+2] = pts[i][2] + u[2]*c + w[2]*s;
    }
  }
  const nI = closed ? m : m - 1;
  for (let i = 0; i < nI; i++) for (let j = 0; j < seg; j++) {
    const A = i*seg + j, B = i*seg + (j + 1) % seg, C = ((i + 1) % m)*seg + j, D = ((i + 1) % m)*seg + (j + 1) % seg;
    I.push(A, C, B, B, C, D);
  }
  bEmit(V, m * seg, new Uint32Array(I), I.length);
}
/* ---------- lathe with elliptical section along t (vertebra body): waist = pinch in the middle ---------- */
function emitLathe(p, n, t, a, bw, bh, lift, waist, K, SG) {
  const b = cross3(n, t), V = [], I = []; K = K || 8; SG = SG || 14;
  for (let k = 0; k <= K; k++) {
    const xn = -Math.cos(Math.PI * k / K), rim = Math.sqrt(Math.max(0, 1 - Math.pow(Math.abs(xn), 6))), r = ((1 - waist) + waist * xn * xn) * rim;
    for (let s = 0; s < SG; s++) {
      const ph = s / SG * 6.2832, y = Math.cos(ph) * bw * r, z = lift + Math.sin(ph) * bh * r, x = xn * a;
      V.push(p[0] + t[0]*x + b[0]*y + n[0]*z, p[1] + t[1]*x + b[1]*y + n[1]*z, p[2] + t[2]*x + b[2]*y + n[2]*z);
    }
  }
  for (let k = 0; k < K; k++) for (let s = 0; s < SG; s++) {
    const A = k*SG + s, B = k*SG + (s + 1) % SG, C = (k + 1)*SG + s, D = (k + 1)*SG + (s + 1) % SG;
    I.push(A, B, C, B, D, C);
  }
  bEmit(new Float32Array(V), V.length / 3, new Uint32Array(I), I.length);
}

function emitLatheF(p, n, t, a, fb, fc, lift, K, SG) {   // lathe with arbitrary half-width / half-height profile along t (bones)
  const b = cross3(n, t), V = [], I = []; K = K || 12; SG = SG || 14;
  for (let k = 0; k <= K; k++) {
    const xn = -Math.cos(Math.PI * k / K), rim = Math.sqrt(Math.max(0, 1 - Math.pow(Math.abs(xn), 10))), rb = fb(xn) * rim, rc = fc(xn) * rim;
    for (let s = 0; s < SG; s++) {
      const ph = s / SG * 6.2832, y = Math.cos(ph) * rb, z = lift + Math.sin(ph) * rc, x = xn * a;
      V.push(p[0] + t[0]*x + b[0]*y + n[0]*z, p[1] + t[1]*x + b[1]*y + n[1]*z, p[2] + t[2]*x + b[2]*y + n[2]*z);
    }
  }
  for (let k = 0; k < K; k++) for (let s = 0; s < SG; s++) {
    const A = k*SG + s, B = k*SG + (s + 1) % SG, C = (k + 1)*SG + s, D = (k + 1)*SG + (s + 1) % SG;
    I.push(A, B, C, B, D, C);
  }
  bEmit(new Float32Array(V), V.length / 3, new Uint32Array(I), I.length);
}

/* ============================================================
   FLOW FIELD  — a line field (tensor grid) living on the surface.
   Sources: principal curvature directions of the form (follow bulges, tubes, hole rims, windings),
   smooth noise where the form has no preferred direction, and hand-painted strokes.
============================================================ */
const FM = { d: null, has: null, ox: 0, oy: 0, oz: 0, nx: 0, ny: 0, nz: 0, c: 0.05, f: 4, ph: [0, 0, 0, 0, 0, 0], ok: false };
const FD = [0, 0, 0]; let FS = 0;
const dot3 = (u, v) => u[0]*v[0] + u[1]*v[1] + u[2]*v[2];
function curvDir(p, n) {                          // principal directions + anisotropy of the surface at p
  let e1 = Math.abs(n[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0];
  const d = dot3(e1, n);
  e1 = nrm3([e1[0] - n[0]*d, e1[1] - n[1]*d, e1[2] - n[2]*d]);
  const e2 = cross3(n, e1), dl = Math.max(gH * 2.5, 0.05);
  const nAt = (s, e) => grad(p[0] + e[0]*dl*s, p[1] + e[1]*dl*s, p[2] + e[2]*dl*s) ? [GR[0], GR[1], GR[2]] : null;
  const a1 = nAt(1, e1), b1 = nAt(-1, e1), a2 = nAt(1, e2), b2 = nAt(-1, e2);
  if (!a1 || !b1 || !a2 || !b2) return null;
  const d1 = [(a1[0]-b1[0])/(2*dl), (a1[1]-b1[1])/(2*dl), (a1[2]-b1[2])/(2*dl)], d2 = [(a2[0]-b2[0])/(2*dl), (a2[1]-b2[1])/(2*dl), (a2[2]-b2[2])/(2*dl)];
  const a = dot3(d1, e1), c = dot3(d2, e2), b = 0.5 * (dot3(d1, e2) + dot3(d2, e1));
  const m = (a + c) / 2, df = Math.sqrt(((a - c) / 2) ** 2 + b * b), l1 = m + df, l2 = m - df;
  const psi = 0.5 * Math.atan2(2 * b, a - c);
  const v1 = [e1[0]*Math.cos(psi) + e2[0]*Math.sin(psi), e1[1]*Math.cos(psi) + e2[1]*Math.sin(psi), e1[2]*Math.cos(psi) + e2[2]*Math.sin(psi)];
  const v2 = cross3(n, v1);
  return Math.abs(l1) < Math.abs(l2) ? { along: v1, around: v2, aniso: 2 * df } : { along: v2, around: v1, aniso: 2 * df };
}
function noiseVec(x, y, z) {
  const f = FM.f, q = FM.ph;
  return [Math.sin(f*y + q[0]) + Math.sin(f*1.3*z + q[1]), Math.sin(f*z + q[2]) + Math.sin(f*1.2*x + q[3]), Math.sin(f*x + q[4]) + Math.sin(f*1.1*y + q[5])];
}
function tAdd(d, i, v, w) {
  d[i] += w*v[0]*v[0]; d[i+1] += w*v[1]*v[1]; d[i+2] += w*v[2]*v[2]; d[i+3] += w*v[0]*v[1]; d[i+4] += w*v[0]*v[2]; d[i+5] += w*v[1]*v[2];
}
const fmIdx = (x, y, z) => {
  const i = Math.floor((x - FM.ox) / FM.c), j = Math.floor((y - FM.oy) / FM.c), k = Math.floor((z - FM.oz) / FM.c);
  return (i < 0 || j < 0 || k < 0 || i >= FM.nx || j >= FM.ny || k >= FM.nz) ? -1 : i + FM.nx * (j + FM.ny * k);
};
function tanProj(v, n) { const d = dot3(v, n); return [v[0] - n[0]*d, v[1] - n[1]*d, v[2] - n[2]*d]; }
function buildFM() {
  const bb = SF.bb, c = Math.max(gH * 2, 0.04), ext = Math.max(bb.max.x - bb.min.x, bb.max.y - bb.min.y, bb.max.z - bb.min.z);
  FM.c = c; FM.ox = bb.min.x - 3*c; FM.oy = bb.min.y - 3*c; FM.oz = bb.min.z - 3*c;
  FM.nx = Math.ceil((bb.max.x - bb.min.x) / c) + 7; FM.ny = Math.ceil((bb.max.y - bb.min.y) / c) + 7; FM.nz = Math.ceil((bb.max.z - bb.min.z) / c) + 7;
  const nc = FM.nx * FM.ny * FM.nz, d = FM.d = new Float32Array(nc * 6), has = FM.has = new Uint8Array(nc);
  const R = rng(O.seed + 101); FM.ph = [0, 1, 2, 3, 4, 5].map(() => R() * 6.2832); FM.f = 6.2832 / (ext * (0.9 + 0.6 * R()));
  const pos = SF.geo.attributes.position.array, nor = SF.geo.attributes.normal.array, nv = pos.length / 3, stride = Math.max(1, Math.floor(nv / 25000));
  const wn = 0.12 + 0.7 * O.fnoise, wcur = 1 - O.fnoise;
  for (let v = 0; v < nv; v += stride) {
    const p = [pos[v*3], pos[v*3+1], pos[v*3+2]], n = [nor[v*3], nor[v*3+1], nor[v*3+2]], ci = fmIdx(p[0], p[1], p[2]);
    if (ci < 0) continue;
    has[ci] = 1;
    let dir = null, w = 0;
    if (O.flow === 'up') { dir = tanProj([0, 1, 0], n); if (Math.hypot(dir[0], dir[1], dir[2]) > 0.3) { dir = nrm3(dir); w = wcur * 0.9; } else dir = null; }
    else if (O.flow !== 'free') { const cd = curvDir(p, n); if (cd) { dir = O.flow === 'along' ? cd.along : cd.around; w = wcur * Math.min(1, cd.aniso / 2.2); } }
    if (dir && w > 0.001) tAdd(d, ci * 6, dir, w);
    const m = tanProj(noiseVec(p[0], p[1], p[2]), n), ml = Math.hypot(m[0], m[1], m[2]);
    if (ml > 1e-4) tAdd(d, ci * 6, [m[0]/ml, m[1]/ml, m[2]/ml], wn);
  }
  // dilate + diffuse so every cell touching the surface carries a smooth tensor
  const nxy = FM.nx * FM.ny, hd = has.slice();
  for (let k = 1; k < FM.nz - 1; k++) for (let j = 1; j < FM.ny - 1; j++) for (let i = 1; i < FM.nx - 1; i++) {
    const q = i + FM.nx * j + nxy * k;
    if (has[q]) { hd[q+1] = hd[q-1] = hd[q+FM.nx] = hd[q-FM.nx] = hd[q+nxy] = hd[q-nxy] = 1; }
  }
  for (let q = 0; q < nc; q++) if (hd[q]) has[q] = 1;
  let cur = d, nxt = new Float32Array(nc * 6);
  for (let it = 0; it < 5; it++) {
    for (let q = 0; q < nc; q++) {
      if (!has[q]) continue;
      let cnt = 1; for (let s = 0; s < 6; s++) nxt[q*6+s] = cur[q*6+s];
      for (const o of [1, -1, FM.nx, -FM.nx, nxy, -nxy]) { const r = q + o; if (r < 0 || r >= nc || !has[r]) continue; cnt++; for (let s = 0; s < 6; s++) nxt[q*6+s] += cur[r*6+s]; }
      for (let s = 0; s < 6; s++) nxt[q*6+s] /= cnt;
    }
    const sw = cur; cur = nxt; nxt = sw;
  }
  FM.d = cur;
  FM.base = FM.d.slice(); FM.ok = true; FM.gs = [];
  if (O.guides && typeof guidePolys === 'function') for (const w of guidePolys()) {   // drawn curves steer the field
    const m = w.length / 3;
    for (let i = 0; i < m; i++) {
      const a = Math.max(0, i - 1) * 3, b = Math.min(m - 1, i + 1) * 3, t = nrm3([w[b]-w[a], w[b+1]-w[a+1], w[b+2]-w[a+2]]);
      if (i % 2 === 0) FM.gs.push([w[i*3], w[i*3+1], w[i*3+2], FM.c * 5, 1, t[0], t[1], t[2]]);
    }
  }
  for (const s of FM.gs) fmApply(s);
  for (const s of O.fstrokes) fmApply(s);
}
function fmApply(s) {                             // hand stroke: [x,y,z,r,kind,dx,dy,dz]  kind 1 = direction, 2/3 = swirl left/right
  const c = FM.c, r = s[3], d = FM.d;
  const i0 = Math.max(0, Math.floor((s[0]-r-FM.ox)/c)), i1 = Math.min(FM.nx-1, Math.ceil((s[0]+r-FM.ox)/c));
  const j0 = Math.max(0, Math.floor((s[1]-r-FM.oy)/c)), j1 = Math.min(FM.ny-1, Math.ceil((s[1]+r-FM.oy)/c));
  const k0 = Math.max(0, Math.floor((s[2]-r-FM.oz)/c)), k1 = Math.min(FM.nz-1, Math.ceil((s[2]+r-FM.oz)/c));
  for (let k = k0; k <= k1; k++) for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) {
    const q = i + FM.nx * (j + FM.ny * k);
    if (!FM.has[q]) continue;
    const cx = FM.ox + (i+0.5)*c, cy = FM.oy + (j+0.5)*c, cz = FM.oz + (k+0.5)*c, dist = Math.hypot(cx - s[0], cy - s[1], cz - s[2]);
    if (dist > r) continue;
    let dir;
    if (s[4] === 1) dir = [s[5], s[6], s[7]];
    else { if (!grad(cx, cy, cz)) continue; dir = cross3([GR[0], GR[1], GR[2]], [cx - s[0], cy - s[1], cz - s[2]]); if (s[4] === 3) dir = [-dir[0], -dir[1], -dir[2]]; }
    const dl = Math.hypot(dir[0], dir[1], dir[2]); if (dl < 1e-5) continue;
    dir = [dir[0]/dl, dir[1]/dl, dir[2]/dl];
    const w = sm01(1 - dist / r) * 0.85, tr = Math.max(d[q*6] + d[q*6+1] + d[q*6+2], 1e-3);
    for (let t = 0; t < 6; t++) d[q*6+t] *= (1 - w);
    tAdd(d, q * 6, dir, w * tr);
  }
}
function fmRebuildStrokes() { if (!FM.base) return; FM.d.set(FM.base); for (const s of FM.gs || []) fmApply(s); for (const s of O.fstrokes) fmApply(s); }
// query: dominant tangent direction at a surface point; sign aligned with the previous heading. Sets FD (dir) and FS (coherence 0..1)
function flowAt(x, y, z, nx, ny, nz, tx, ty, tz) {
  if (!FM.ok) return false;
  const fx = (x - FM.ox) / FM.c - 0.5, fy = (y - FM.oy) / FM.c - 0.5, fz = (z - FM.oz) / FM.c - 0.5;
  const i = Math.floor(fx), j = Math.floor(fy), k = Math.floor(fz), u = fx - i, v = fy - j, w = fz - k;
  if (i < 0 || j < 0 || k < 0 || i + 1 >= FM.nx || j + 1 >= FM.ny || k + 1 >= FM.nz) return false;
  const S = [0, 0, 0, 0, 0, 0]; let ws = 0;
  for (let c = 0; c < 8; c++) {
    const di = c & 1, dj = (c >> 1) & 1, dk = (c >> 2) & 1, q = (i + di) + FM.nx * ((j + dj) + FM.ny * (k + dk));
    if (!FM.has[q]) continue;
    const wt = (di ? u : 1 - u) * (dj ? v : 1 - v) * (dk ? w : 1 - w); ws += wt;
    for (let s = 0; s < 6; s++) S[s] += FM.d[q*6+s] * wt;
  }
  if (ws < 1e-4) return false;
  let e1 = Math.abs(ny) < 0.9 ? [0, 1, 0] : [1, 0, 0];
  const dd = e1[0]*nx + e1[1]*ny + e1[2]*nz; e1 = nrm3([e1[0] - nx*dd, e1[1] - ny*dd, e1[2] - nz*dd]);
  const e2 = cross3([nx, ny, nz], e1);
  const q = (A, B) => A[0]*B[0]*S[0] + A[1]*B[1]*S[1] + A[2]*B[2]*S[2] + (A[0]*B[1] + A[1]*B[0])*S[3] + (A[0]*B[2] + A[2]*B[0])*S[4] + (A[1]*B[2] + A[2]*B[1])*S[5];
  const a = q(e1, e1), c = q(e2, e2), b = q(e1, e2), tr = a + c;
  if (tr < 1e-6) return false;
  const psi = 0.5 * Math.atan2(2 * b, a - c), cp = Math.cos(psi), sp = Math.sin(psi);
  let dx = e1[0]*cp + e2[0]*sp, dy = e1[1]*cp + e2[1]*sp, dz = e1[2]*cp + e2[2]*sp;
  if (dx*tx + dy*ty + dz*tz < 0) { dx = -dx; dy = -dy; dz = -dz; }
  FD[0] = dx; FD[1] = dy; FD[2] = dz; FS = Math.min(1, Math.sqrt(((a - c) * 0.5) ** 2 + b * b) * 2 / tr);
  return true;
}
function flowDir(p, n) {                          // start heading for a new strand
  const R = CTX.R, t0 = nrm3(tanProj([R() - 0.5, R() - 0.5, R() - 0.5], n));
  let t = flowAt(p[0], p[1], p[2], n[0], n[1], n[2], t0[0], t0[1], t0[2]) ? [FD[0], FD[1], FD[2]] : t0;
  if (R() < 0.5) t = [-t[0], -t[1], -t[2]];
  return rotT(n, t, (R() - 0.5) * 6.2832 * O.chaos * 0.5);
}

/* ---------- visualise the flow as short hatch lines ---------- */
let flowLines = null;
function showFlow() {
  if (!flowLines) {
    flowLines = new THREE.LineSegments(new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: 0x3a56c8, transparent: true, opacity: 0.55, depthTest: false }));
    flowLines.renderOrder = 6; flowLines.frustumCulled = false; scene.add(flowLines);
  }
  if (!O.showFlow || !SF || !FM.ok) { flowLines.visible = false; return; }
  const R = rng(5), pts = [], len = FM.c * 1.1, N = Math.min(1100, Math.floor(SF.area / (FM.c * FM.c) * 0.25));
  for (let i = 0; i < N; i++) {
    const p = randSurf(R); if (!snap(p[0], p[1], p[2])) continue;
    const n = [SP[3], SP[4], SP[5]], q = [SP[0] + n[0]*0.006, SP[1] + n[1]*0.006, SP[2] + n[2]*0.006];
    const t0 = nrm3(tanProj([1, 0.3, 0.2], n));
    if (!flowAt(q[0], q[1], q[2], n[0], n[1], n[2], t0[0], t0[1], t0[2])) continue;
    const l = len * (0.4 + 0.6 * FS);
    pts.push(q[0] - FD[0]*l, q[1] - FD[1]*l, q[2] - FD[2]*l, q[0] + FD[0]*l, q[1] + FD[1]*l, q[2] + FD[2]*l);
  }
  const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(pts), 3));
  flowLines.geometry.dispose(); flowLines.geometry = g; flowLines.visible = true;
}
/* ============================================================
   LANE STYLES — a lane is a streamline of the flow field with a half-width per sample (L.hw).
   Every style turns the lane into a detailed carved element: ribbons, cables, chains, vertebrae, beads, bundles…
============================================================ */
const hwAt = (L, t) => { const f = Math.min(Math.max(t, 0), 1) * (L.n - 1), i = Math.min(L.n - 2, f | 0), u = f - i; return L.hw[i] * (1 - u) + L.hw[i + 1] * u; };
const rib = (L, o) => emitRibbon({ n: L.n, P: L.P, N: L.N, T: L.T, zf: () => 0, spec: Object.assign({ kind: 'round', J: 6, hr: 0.95, th: CTX.dB * 0.05 }, o) });
function ringAt(q, cy, cz, ry, rz, r, seg) {                // closed band around a lane (plane across the lane)
  const b = cross3(q.n, q.t), pts = [], rad = [], NS = 18;
  for (let i = 0; i < NS; i++) { const a = i / NS * 6.2832, y = Math.cos(a) * ry + cy, z = Math.sin(a) * rz + cz; pts.push([q.p[0] + b[0]*y + q.n[0]*z, q.p[1] + b[1]*y + q.n[1]*z, q.p[2] + b[2]*y + q.n[2]*z]); rad.push(r); }
  emitSweep(pts, rad, true, seg || 5);
}
const rotFrame = (t, b, a) => { const c = Math.cos(a), s = Math.sin(a); return [[t[0]*c + b[0]*s, t[1]*c + b[1]*s, t[2]*c + b[2]*s], [b[0]*c - t[0]*s, b[1]*c - t[1]*s, b[2]*c - t[2]*s]]; };

const bed = (L, w, hr, z) => rib(L, { kind: 'round', J: 6, wl: t => hwAt(L, t) * w, hr, zo: () => z || 0 });   // low underlay so no base shows between elements
/* ---------- FLOW: fluted, twisting, cupped ribbon ---------- */
function sFlow(L) {
  const R = CTX.R, tw = O.twist, ph = R() * 6.28, f = L.len / (L.hwm * (7 + 6 * R()));
  rib(L, { kind: 'flute', J: 16, wl: t => hwAt(L, t), hr: 0.62, th: CTX.dB * 0.05, roll: t => tw * 0.95 * Math.sin(6.2832 * f * t + ph), cup: 0.28 });
}
/* ---------- CABLE: three or two wound strands around a core, with binding bands ---------- */
function sCable(L) {
  const R = CTX.R, m = R() < 0.6 ? 3 : 2, turns = L.len / (L.hwm * (3.6 + 1.6 * R())), ph0 = R() * 6.28, hand = R() < 0.5 ? 1 : -1;
  const rs = h => h * (m === 2 ? 0.5 : 0.4), rad = h => h * (m === 2 ? 0.5 : 0.56);
  for (let i = 0; i < m; i++) {
    const ph = ph0 + 6.2832 * i / m, ang = t => ph + hand * 6.2832 * turns * t;
    rib(L, { kind: 'round', J: 6, wl: t => rs(hwAt(L, t)), hr: 0.95, off: t => rad(hwAt(L, t)) * Math.cos(ang(t)), zo: t => rad(hwAt(L, t)) * (Math.sin(ang(t)) + 0.95) });
  }
  if (m === 3) rib(L, { kind: 'round', J: 6, wl: t => hwAt(L, t) * 0.3, hr: 0.9, zo: t => hwAt(L, t) * 0.55 });
  const nb = Math.floor(turns / 1.6);
  for (let k = 0; k < nb; k++) { const t = (k + 0.5) / Math.max(1, nb), h = hwAt(L, t); if (h > CTX.dB * 0.09) ringAt(at(L, t), 0, h * 0.5, h * 0.98, h * 0.7, h * 0.07); }
}
/* ---------- CHAIN: interlocking stadium links, alternately flat and standing ---------- */
function sChain(L) {
  const R = CTX.R; let d = 0, k = 0;
  bed(L, 1.0, 0.3);
  while (d < L.len) {
    const t = d / L.len, h = hwAt(L, t), Rr = h * 0.56, r = Rr * 0.34, s = Rr * 1.0;
    if (Rr < CTX.dB * 0.035) { d += CTX.dB * 0.1; continue; }
    const q = at(L, Math.min(1, (d + (s + 2 * Rr) * 0.5) / L.len)), stand = k % 2 === 1, b = cross3(q.n, q.t);
    emitLink(q.p, q.n, q.t, Rr, r, s, stand ? Math.PI / 2 + (R() - 0.5) * 0.15 : (R() - 0.5) * 0.15, stand ? Rr * 0.45 : r * 0.9);
    d += s + 2 * Rr - 3.1 * r; k++;
  }
}
/* ---------- SPINE: real vertebrae — body with waist, neural arch, spinous + transverse processes, facets, discs ---------- */
function vertebra(p, n, t0, h, pv, rot) {
  const b0 = cross3(n, t0), fr = rotFrame(t0, b0, rot), t = fr[0], b = fr[1];
  const W = (x, y, z) => [p[0] + t[0]*x + b[0]*y + n[0]*z, p[1] + t[1]*x + b[1]*y + n[1]*z, p[2] + t[2]*x + b[2]*y + n[2]*z];
  const a = pv * 0.36, bw = h * 0.66, bh = h * 0.46, lift = bh * 0.4, zr = lift + bh * 0.95;          // zr = height of the neural-arch roof
  emitLathe(p, n, t, a, bw, bh, lift, 0.24, 8, 16);                                                    // centrum with waist and rolled rims
  emitBlob(W(-a * 0.15, 0, zr), n, t, a * 0.95, bw * 0.78, h * 0.2, 0, 12, 6);                          // lamina roof of the neural arch
  emitBlob(W(-a * 0.35, 0, zr + h * 0.36), n, t, a * 1.25, h * 0.1, h * 0.46, 0, 12, 6);                // spinous process (blade, leaning back)
  emitBlob(W(-a * 1.0, 0, zr + h * 0.78), n, t, h * 0.13, h * 0.12, h * 0.1, 0, 8, 5);                  // its tip
  for (const sd of [1, -1]) {
    const pts = [], rd = [];                                                                           // transverse process
    for (let i = 0; i <= 5; i++) { const u = i / 5; pts.push(W(-a * 0.2 - 0.3 * h * u, sd * (bw * 0.7 + 0.42 * h * u), zr - h * 0.04 + 0.12 * h * u)); rd.push(h * (0.17 * (1 - u) + 0.075 * u)); }
    emitSweep(pts, rd, false, 6);
    emitBlob(pts[5], n, t, h * 0.1, h * 0.11, h * 0.09, 0, 8, 5);
    for (const fx of [1, -1]) emitBlob(W(fx * a * 0.78, sd * bw * 0.5, zr + h * 0.1), n, t, h * 0.11, h * 0.08, h * 0.09, 0, 8, 5);   // articular facets
  }
  emitBlob(W(pv * 0.5, 0, 0), n, t, pv * 0.12, bw * 0.84, bh * 0.66, lift, 14, 7);                     // intervertebral disc
}
function sSpine(L) {
  const R = CTX.R; let d = 0;
  bed(L, 1.0, 0.32);
  while (d < L.len) {
    const t = d / L.len, h = hwAt(L, t) * 0.98;
    if (h < CTX.dB * 0.1) { d += CTX.dB * 0.15; continue; }
    const pv = h * 1.4, q = at(L, Math.min(1, (d + pv * 0.5) / L.len));
    vertebra(q.p, q.n, q.t, h * (0.94 + 0.12 * R()), pv, (R() - 0.5) * 0.14);
    d += pv;
  }
}
/* ---------- BEADS: bead-and-reel moulding, big–small rhythm, optional second row ---------- */
function sBeads(L) {
  bed(L, 0.95, 0.3);
  const R = CTX.R, two = L.hwm > CTX.dB * 0.75, rows = two ? [-0.5, 0.5] : [0], ph = R() * 6;
  for (const ro of rows) {
    let d = 0, k = 0;
    while (d < L.len) {
      const t = d / L.len, h = hwAt(L, t), rb = h * (two ? 0.5 : 0.88) * (0.62 + 0.38 * Math.abs(Math.cos(k * 0.55 + ph)));
      if (rb < CTX.dB * 0.03) { d += CTX.dB * 0.06; continue; }
      const q = at(L, Math.min(1, (d + rb) / L.len)), b = cross3(q.n, q.t), pp = [q.p[0] + b[0]*ro*h, q.p[1] + b[1]*ro*h, q.p[2] + b[2]*ro*h];
      emitBlob(pp, q.n, q.t, rb, rb, rb * 0.95, rb * 0.42, 14, 8);
      const qe = at(L, Math.min(1, (d + 2 * rb + rb * 0.15) / L.len)), pe = [qe.p[0] + b[0]*ro*h, qe.p[1] + b[1]*ro*h, qe.p[2] + b[2]*ro*h];
      for (const g of [0, 1]) emitBlob([pe[0] + qe.t[0]*g*rb*0.3, pe[1] + qe.t[1]*g*rb*0.3, pe[2] + qe.t[2]*g*rb*0.3], qe.n, qe.t, rb * 0.16, rb * 0.78, rb * 0.78, rb * 0.22, 12, 6);
      d += 2 * rb + rb * 0.62; k++;
    }
  }
  bed(L, 0.95, 0.3);
}
/* ---------- BUNDLE: parallel braided cords with binding bands ---------- */
function sBundle(L) {
  const R = CTX.R, m = 3 + ((R() * 3) | 0), br = 2 * Math.PI * L.len / (L.hwm * 9), ph = R() * 6;
  for (let i = 0; i < m; i++) {
    const c0 = (i - (m - 1) / 2) * 2 / m;
    rib(L, { kind: 'round', J: 6, wl: t => hwAt(L, t) / m * 1.1, hr: 0.95, off: t => hwAt(L, t) * (c0 + 0.16 * Math.sin(br * t + i * 1.7 + ph) * (1 - Math.abs(c0))), zo: t => hwAt(L, t) * 0.12 * (1 + Math.cos(br * t + i * 2.1 + ph)) });
  }
  const nb = Math.max(1, Math.floor(L.len / (L.hwm * 5)));
  for (let k = 0; k < nb; k++) { const t = (k + 0.5) / nb, h = hwAt(L, t); if (h > CTX.dB * 0.09) ringAt(at(L, t), 0, h * 0.18, h * 1.0, h * 0.5, h * 0.085); }
}
/* ---------- CARTILAGE: knotted swelling tube with collars and pearls ---------- */
function sCartilage(L) {
  const R = CTX.R, k = Math.max(2, Math.round(L.len / (L.hwm * 3.6))), ph = R() * 3;
  const sw = t => 0.62 + 0.38 * Math.pow(Math.abs(Math.sin(Math.PI * k * t + ph)), 0.8);
  rib(L, { kind: 'round', J: 8, wl: t => hwAt(L, t) * sw(t) * 0.98, hr: 1.12, zo: t => hwAt(L, t) * 0.06 });
  for (let i = 0; i < k; i++) {
    const t = (i + 0.5 - ph / Math.PI) / k; if (t < 0.03 || t > 0.97) continue;
    const h = hwAt(L, t), q = at(L, t);
    ringAt(q, 0, h * 0.35, h * 0.7, h * 0.62, h * 0.09, 6);
    if (i % 2 === 0) emitBlob(q.p, q.n, q.t, h * 0.2, h * 0.2, h * 0.2, h * 0.95, 12, 7);
  }
}


const wLeaf = (wm, k = 0, amp = 0, ph = 0, sh = 0.72) => t => wm * Math.pow(Math.sin(Math.PI * Math.pow(t, sh)), 0.85) * (1 + amp * Math.sin(6.2832 * k * t + ph));
const Wf = (q, b, x, y, z) => [q.p[0] + q.t[0]*x + b[0]*y + q.n[0]*z, q.p[1] + q.t[1]*x + b[1]*y + q.n[1]*z, q.p[2] + q.t[2]*x + b[2]*y + q.n[2]*z];
function leafRib(p, n, dir, len, wm, curl, sd, o) {          // draped leaf ribbon that keeps following the flow field
  o = o || {};
  const w = walk(p, dir, len, t => -sd * curl * Math.pow(t, o.pw || 1.8), null, o.fk === undefined ? 0.5 : o.fk);
  if (!w || w.n < 5) return null;
  const lift = o.lift === undefined ? wm * 0.9 : o.lift;
  emitRibbon({ n: w.n, P: w.P, N: w.N, T: w.T, zf: t => lift * Math.pow(t, 1.3) + (o.z0 || 0),
    spec: { kind: 'leaf', J: 10, wl: wLeaf(wm, o.k || 0, o.amp || 0, o.ph || 0, o.sh || 0.7), hr: o.hr || 0.55, th: CTX.dB * 0.04, roll: t => sd * O.twist * (o.roll === undefined ? 0.9 : o.roll) * t * t, cup: o.cup === undefined ? 0.3 : o.cup } });
  return w;
}
/* ---------- ACANTHUS: shingled, lobed, curling leaves running along the flow ---------- */
function sAcanthus(L) {
  const R = CTX.R; bed(L, 0.9, 0.28); let d = L.hwm * 0.6, k = 0;
  while (d < L.len) {
    const t = d / L.len, h = hwAt(L, t);
    if (h < CTX.dB * 0.1) { d += CTX.dB * 0.2; continue; }
    const q = at(L, t), b = cross3(q.n, q.t), sd = k % 2 ? 1 : -1, len = h * (2.9 + 0.7 * R()), curl = 0.9 + 2.4 * O.curl;
    const base = [q.p[0] + b[0]*sd*h*0.3, q.p[1] + b[1]*sd*h*0.3, q.p[2] + b[2]*sd*h*0.3];
    const mw = leafRib(base, q.n, rotT(q.n, q.t, sd * 0.35), len, h * 0.68, curl * 0.9, sd, { k: 2, amp: 0.1, ph: R() * 6, lift: h * 0.55, sh: 0.82, cup: 0.45, roll: 1.2, pw: 2.2 });
    if (mw) for (const tk of [0.3, 0.56]) for (const ls of [1, -1]) {
      const a = at(mw, tk);
      leafRib(a.p, a.n, rotT(a.n, a.t, ls * (0.75 + 0.2 * tk)), len * 0.58 * (1.05 - 0.4 * tk), h * (0.34 - 0.08 * tk), curl * 1.5, ls, { k: 2, amp: 0.14, lift: h * 0.4, sh: 0.8, cup: 0.4, roll: 1.4, pw: 2.0 });
    }
    d += h * 1.75; k++;
  }
}
/* ---------- LAUREL: wreath of paired pointed leaves with a midrib cord ---------- */
function sLaurel(L) {
  const R = CTX.R; bed(L, 0.8, 0.3); rib(L, { kind: 'round', J: 6, wl: t => hwAt(L, t) * 0.14, hr: 1, zo: t => hwAt(L, t) * 0.3 });
  let d = 0;
  while (d < L.len) {
    const t = d / L.len, h = hwAt(L, t); if (h < CTX.dB * 0.08) { d += CTX.dB * 0.15; continue; }
    const q = at(L, t);
    for (const sd of [1, -1]) leafRib(q.p, q.n, rotT(q.n, q.t, sd * 0.72), h * (2.3 + 0.4 * R()), h * 0.42, 0.5, sd, { sh: 0.6, lift: h * 0.3, fk: 0.7, cup: 0.38, roll: 0.5 });
    d += h * 1.15;
  }
}
/* ---------- THORNS: cord with curved hooked thorns, big–small rhythm ---------- */
function sThorns(L) {
  const R = CTX.R, ph = R() * 6; bed(L, 0.85, 0.3); rib(L, { kind: 'round', J: 6, wl: t => hwAt(L, t) * 0.3, hr: 1, zo: t => hwAt(L, t) * 0.25 });
  let d = L.hwm * 0.4, k = 0;
  while (d < L.len) {
    const t = d / L.len, h = hwAt(L, t); if (h < CTX.dB * 0.08) { d += CTX.dB * 0.15; continue; }
    const q = at(L, t), b = cross3(q.n, q.t), sd = k % 2 ? 1 : -1, sc = 0.6 + 0.4 * Math.abs(Math.cos(k * 0.65 + ph)), len = h * (1.7 * sc + 0.5), r0 = h * 0.19 * sc, pts = [], rd = [];
    for (let i = 0; i <= 9; i++) { const u = i / 9, ang = u * 1.7; pts.push(Wf(q, b, len * 0.7 * (1 - Math.cos(ang)), sd * (h * 0.15 + len * 0.66 * Math.sin(ang)), h * 0.32 + len * 0.5 * Math.sin(u * 2.7))); rd.push(r0 * Math.pow(1 - u, 0.85) + h * 0.01); }
    emitSweep(pts, rd, false, 7);
    emitBlob(Wf(q, b, 0, sd * h * 0.15, h * 0.3), q.n, q.t, r0 * 1.3, r0 * 1.3, r0 * 1.1, 0, 10, 6);
    d += h * (1.0 + 0.25 * R()) * (0.8 + 0.5 * sc); k++;
  }
}
/* ---------- BONES: long bones end to end — shaft, bulbous epiphyses with condyles, trochanter ---------- */
function sBones(L) {
  const R = CTX.R; bed(L, 0.5, 0.35); let d = 0;
  while (d < L.len) {
    const t = d / L.len, h = hwAt(L, t) * 0.98; if (h < CTX.dB * 0.08) { d += CTX.dB * 0.15; continue; }
    let lb = h * (5.2 + 1.6 * R()); lb = Math.min(lb, L.len - d); if (lb < h * 2.4) break;
    const q = at(L, Math.min(1, (d + lb / 2) / L.len)), b = cross3(q.n, q.t), a = lb / 2, tw = (R() - 0.5) * 0.2;
    emitLatheF(q.p, q.n, q.t, a, x => h * (0.27 + 0.4 * Math.pow(Math.abs(x), 4.5)), x => h * (0.23 + 0.32 * Math.pow(Math.abs(x), 4.5)), h * 0.3, 12, 14);
    for (const e of [1, -1]) for (const sd of [1, -1]) {
      emitBlob(Wf(q, b, e * a * 0.9, sd * h * 0.34, h * 0.3), q.n, q.t, h * 0.34, h * 0.3, h * 0.28, 0, 10, 6);          // condyles
    }
    emitBlob(Wf(q, b, a * 0.55, h * 0.2, h * 0.52), q.n, q.t, h * 0.2, h * 0.16, h * 0.16, 0, 8, 5);                    // trochanter
    d += lb + h * 0.3;
  }
}
/* ---------- RIBS: spine ridge with paired curved ribs diving into the surface ---------- */
function sRibs(L) {
  const R = CTX.R; bed(L, 0.4, 0.9); rib(L, { kind: 'flute', J: 10, wl: t => hwAt(L, t) * 0.3, hr: 0.9, zo: t => hwAt(L, t) * 0.18 });
  let d = L.hwm * 0.3;
  while (d < L.len) {
    const t = d / L.len, h = hwAt(L, t); if (h < CTX.dB * 0.08) { d += CTX.dB * 0.15; continue; }
    const q = at(L, t), b = cross3(q.n, q.t);
    for (const sd of [1, -1]) {
      const pts = [], rd = []; for (let i = 0; i <= 11; i++) { const u = i / 11, ang = u * 1.95; pts.push(Wf(q, b, -h * 0.55 * u, sd * h * 1.02 * Math.sin(ang), h * 0.32 + h * 0.85 * Math.cos(ang) - h * 0.25 * u)); rd.push(h * (0.17 - 0.09 * u)); }
      emitSweep(pts, rd, false, 6);
    }
    emitBlob(Wf(q, b, 0, 0, h * 0.4), q.n, q.t, h * 0.19, h * 0.19, h * 0.15, 0, 8, 5);
    d += h * (0.82 + 0.16 * R());
  }
}
/* ---------- SCROLL: Vitruvian wave — looping, interlocked double curl ---------- */
function sScroll(L) {
  const R = CTX.R, h0 = L.hwm, a = h0 * 0.55, bb = a * 1.5, nph = Math.max(1, Math.ceil(L.len / (6.2832 * a))), steps = nph * 28;
  bed(L, 0.25, 0.9, h0 * 0.05);
  for (const ph of [0, Math.PI]) {
    const pts = [], rd = [];
    for (let i = 0; i <= steps; i++) {
      const f = i / steps * nph * 6.2832, x = a * f - bb * Math.sin(f + ph) + bb * Math.sin(ph), s = Math.min(1, Math.max(0, x / L.len)), q = at(L, s), b = cross3(q.n, q.t), hl = hwAt(L, s);
      pts.push(Wf(q, b, 0, -bb * Math.cos(f + ph) * hl / h0, hl * 0.2 + hl * 0.1)); rd.push(hl * 0.17);
    }
    emitSweep(pts, rd, false, 7);
  }
}
/* ---------- COIL: tight spring winding around a core ---------- */
function sCoil(L) {
  const R = CTX.R, hand = R() < 0.5 ? 1 : -1, pts = [], rd = []; let s = 0, th = R() * 6;
  rib(L, { kind: 'round', J: 6, wl: t => hwAt(L, t) * 0.24, hr: 1, zo: t => hwAt(L, t) * 0.32 }); bed(L, 0.8, 0.3);
  while (s < L.len) {
    const t = s / L.len, h = hwAt(L, t), q = at(L, t), b = cross3(q.n, q.t);
    pts.push(Wf(q, b, 0, h * 0.7 * Math.cos(th), h * 0.55 + h * 0.5 * Math.sin(th))); rd.push(h * 0.2);
    s += h * 1.5 / 13; th += hand * 6.2832 / 13;
  }
  if (pts.length > 3) emitSweep(pts, rd, false, 7);
}

const ic = d => `<path d="${d}"/>`;
const STYLES = [
  { id: 'acanthus', name: 'ACANTHUS', fn: sAcanthus, pref: 0.5, hint: 'carved shingled leaves', icon: ic('M2.5 12C2.5 6.5 6 2.5 11.5 2.5C11.5 6.5 8.5 9 5.5 9C6.8 8 7.4 6.8 7.4 5.6') },
  { id: 'laurel', name: 'LAUREL', fn: sLaurel, pref: 0.1, hint: 'paired leaves on a cord', icon: ic('M3 12.5C5.5 9 8 6 11 1.5M5 10C3.6 9.6 3 8.2 3.2 7.2C4.4 7.4 5.2 8.4 5 10M7 7.4C8.6 7.2 9.6 6.2 9.8 5C8.4 5 7.2 5.8 7 7.4') },
  { id: 'thorns', name: 'THORNS', fn: sThorns, pref: 0.0, hint: 'hooked thorn chain', icon: ic('M1.5 10H12.5M3 10C3 6 6 5 7.5 5M7 10C7 7 9 6.4 10.5 6.6') },
  { id: 'bones', name: 'BONES', fn: sBones, pref: 0.3, hint: 'long bones', icon: '<path d="M3 5.2C1.6 4.4 1.6 2.6 3.2 2.8C3.6 1.6 5 1.8 5.2 3L9 10.2C10 10 11.4 10.4 11.2 11.6C12.6 11.8 12.6 13 11 12.6C10 13.4 8.6 12.6 8.8 11.4L5 4.2"/>' },
  { id: 'ribs', name: 'RIBS', fn: sRibs, pref: 0.45, hint: 'rib arches', icon: ic('M1.5 7H12.5M4 7C4 3.5 6.4 3 7 3M4 7C4 10.5 6.4 11 7 11M8 7C8 4 9.8 3.6 10.4 3.6M8 7C8 10 9.8 10.4 10.4 10.4') },
  { id: 'scroll', name: 'SCROLL', fn: sScroll, pref: -0.1, hint: 'Vitruvian wave', icon: ic('M1.5 8C3 3 6.2 3 6.2 6C6.2 8.6 3.4 8 4 6.4C4.6 4.8 7.4 5 8.4 8.4C9.2 11 12 10.2 12.5 6') },
  { id: 'coil', name: 'COIL', fn: sCoil, pref: -0.3, hint: 'spring winding', icon: ic('M1.5 9C1.5 5 4 5 4 9C4 5 6.5 5 6.5 9C6.5 5 9 5 9 9C9 5 11.5 5 11.5 9') },
  { id: 'flow', name: 'FLOW', fn: sFlow, pref: 0.0, hint: 'fluted twisting ribbon', icon: ic('M1.5 10.5C4 4 6 11 8 6.5C9.2 3.8 11 4.5 12.5 3') },
  { id: 'cartilage', name: 'CARTILAGE', fn: sCartilage, pref: 0.35, hint: 'knotted swelling tube', icon: ic('M2.5 10.5C2.5 7 6 7.5 6 5.5C6 3.5 9 3 9.5 5C10 6.8 8 7.5 9.2 9C10.4 10.5 12 9.5 12 8') },
  { id: 'cable', name: 'CABLE', fn: sCable, pref: 0.25, hint: 'wound strands', icon: ic('M1.5 5C4 5 4 9 7 9C10 9 10 5 12.5 5M1.5 9C4 9 4 5 7 5C10 5 10 9 12.5 9') },
  { id: 'chain', name: 'CHAIN', fn: sChain, pref: 0.15, hint: 'interlocking links', icon: '<rect x="1.2" y="5" width="5.2" height="4" rx="2"/><rect x="7.6" y="5" width="5.2" height="4" rx="2"/>' },
  { id: 'spine', name: 'SPINE', fn: sSpine, pref: 0.55, hint: 'vertebrae', icon: '<ellipse cx="3" cy="8" rx="1.4" ry="2"/><ellipse cx="7" cy="8" rx="1.4" ry="2"/><ellipse cx="11" cy="8" rx="1.4" ry="2"/><path d="M3 6V3.5M7 6V2.5M11 6V3.5"/>' },
  { id: 'beads', name: 'BEADS', fn: sBeads, pref: -0.45, hint: 'bead and reel', icon: '<circle cx="2.8" cy="7" r="1.3"/><circle cx="7" cy="7" r="2"/><circle cx="11.4" cy="7" r="1.3"/>' },
  { id: 'bundle', name: 'BUNDLE', fn: sBundle, pref: -0.2, hint: 'braided cords', icon: ic('M1.5 5C5 4 9 6 12.5 5M1.5 7C5 6 9 8 12.5 7M1.5 9C5 8 9 10 12.5 9') },
];
/* ============================================================
   LANE GENERATOR — evenly-spaced streamlines of the flow field cover the whole surface (Jobard–Lefer on the form).
   Lane width follows a log-scaled field made of Voronoi cells + Perlin noise, so big and small lanes run in rows,
   taper into each other and curl into volutes. Each lane becomes a detailed element (see STYLES).
============================================================ */
const activeStyles = () => { const l = STYLES.filter(s => O.styles[s.id]); return l.length ? l : [STYLES[0]]; };
function guidePolys() { const o = []; for (const c of compile(S.blobs)) if (c.t === 3 && !c.neg && c.wpts) o.push(c.wpts); return o; }
const clampN = (x, a, b) => x < a ? a : x > b ? b : x;

/* ---------- scale field ---------- */
function initScale(R, ext) {
  const act = activeStyles(), cellR = Math.max(0.1, O.cells * ext * 0.3);
  const K = clampN(Math.round(SF.area / (Math.PI * cellR * cellR) * 1.3), 3, 60), sites = [];
  for (let k = 0; k < K; k++) {
    let best = null, bd = -1;
    for (let c = 0; c < 8; c++) {
      const p = randSurf(R); let md = 1e9;
      for (const q of sites) { const dx = p[0]-q.p[0], dy = p[1]-q.p[1], dz = p[2]-q.p[2]; md = Math.min(md, dx*dx + dy*dy + dz*dz); }
      if (md > bd) { bd = md; best = p; }
    }
    sites.push({ p: best, s: 0, style: null });
  }
  const vals = sites.map((_, i) => -1 + 2 * (i + 0.5) / K);
  for (let i = vals.length - 1; i > 0; i--) { const j = (R() * (i + 1)) | 0; [vals[i], vals[j]] = [vals[j], vals[i]]; }
  sites.forEach((c, i) => {
    c.s = vals[i];
    const w = act.map(st => Math.exp(-Math.pow(c.s - st.pref, 2) / 0.4) + 0.03); let x = R() * w.reduce((a, b) => a + b, 0), j = 0;
    while (j < w.length - 1 && x > w[j]) { x -= w[j]; j++; }
    c.style = act[j].id;
  });
  CTX.sites = sites; CTX.cellR = cellR; CTX.nph = [R() * 6.28, R() * 6.28, R() * 6.28, R() * 6.28]; CTX.nf = 6.2832 / (ext * 0.55);
}
function scaleS(x, y, z) {                       // log-scale position s in [-1,1]; also sets CTX.cell (nearest Voronoi site)
  const S_ = CTX.sites, R = CTX.cellR, sg2 = 2 * Math.pow(R * 0.65, 2);
  let sw = 0, sv = 0, nd = 1e30, nc = null;
  for (let i = 0; i < S_.length; i++) {
    const c = S_[i], dx = x - c.p[0], dy = y - c.p[1], dz = z - c.p[2], d2 = dx*dx + dy*dy + dz*dz;
    if (d2 < nd) { nd = d2; nc = c; }
    const w = Math.exp(-d2 / sg2); sw += w; sv += w * (c.s + 0.45 - 0.9 * Math.min(1.5, Math.sqrt(d2) / R));
  }
  CTX.cell = nc;
  let s = sw > 1e-9 ? sv / sw : nc.s - 0.45;
  const f = CTX.nf, q = CTX.nph, pn = 0.5 * (Math.sin(f*x + q[0]) * Math.cos(f*0.8*y + q[1]) + Math.sin(f*1.1*z + q[2]) * Math.cos(f*0.9*x + q[3]));
  return clampN(s + O.chaos * 0.8 * pn, -1, 1);
}
const dAt = (x, y, z) => CTX.dB * Math.pow(2, CTX.range * scaleS(x, y, z));

/* ---------- spatial hash of lane samples ---------- */
function lhInit(cell) { CTX.LH = { map: new Map(), cell, X: [], Y: [], Z: [], D: [] }; }
const lhKey = (i, j, k) => ((i + 1024) * 2048 + (j + 1024)) * 2048 + (k + 1024);
function lhAdd(x, y, z, d) {
  const H = CTX.LH, id = H.X.length; H.X.push(x); H.Y.push(y); H.Z.push(z); H.D.push(d);
  const key = lhKey(Math.floor(x / H.cell), Math.floor(y / H.cell), Math.floor(z / H.cell)); let a = H.map.get(key); if (!a) H.map.set(key, a = []); a.push(id);
}
function lhNear(x, y, z, d, fr) {
  const H = CTX.LH, i0 = Math.floor(x / H.cell), j0 = Math.floor(y / H.cell), k0 = Math.floor(z / H.cell);
  for (let i = i0 - 1; i <= i0 + 1; i++) for (let j = j0 - 1; j <= j0 + 1; j++) for (let k = k0 - 1; k <= k0 + 1; k++) {
    const a = H.map.get(lhKey(i, j, k)); if (!a) continue;
    for (let q = 0; q < a.length; q++) {
      const id = a[q], dx = x - H.X[id], dy = y - H.Y[id], dz = z - H.Z[id], r = fr * 0.5 * (d + H.D[id]);
      if (dx*dx + dy*dy + dz*dz < r * r) return true;
    }
  }
  return false;
}

/* ---------- streamline tracing ---------- */
function traceDir(p0, t0, ds, maxSteps, out, other) {
  let px = p0[0], py = p0[1], pz = p0[2], tx = t0[0], ty = t0[1], tz = t0[2], nx = 0, ny = 0, nz = 0;
  for (let i = 0; i < maxSteps; i++) {
    if (!snap(px + tx*ds, py + ty*ds, pz + tz*ds)) break;
    const jx = SP[0]-px, jy = SP[1]-py, jz = SP[2]-pz; if (jx*jx + jy*jy + jz*jz > 6.25*ds*ds) break;
    px = SP[0]; py = SP[1]; pz = SP[2]; nx = SP[3]; ny = SP[4]; nz = SP[5];
    let dd = tx*nx + ty*ny + tz*nz; tx -= nx*dd; ty -= ny*dd; tz -= nz*dd; let l = Math.hypot(tx, ty, tz) || 1; tx /= l; ty /= l; tz /= l;
    if (flowAt(px, py, pz, nx, ny, nz, tx, ty, tz)) {
      tx += (FD[0] - tx) * 0.5; ty += (FD[1] - ty) * 0.5; tz += (FD[2] - tz) * 0.5;
      dd = tx*nx + ty*ny + tz*nz; tx -= nx*dd; ty -= ny*dd; tz -= nz*dd; l = Math.hypot(tx, ty, tz) || 1; tx /= l; ty /= l; tz /= l;
    }
    if (!symOK([px, py, pz]) || !inZone(px, py, pz)) break;
    const d = dAt(px, py, pz);
    if ((i & 1) === 0 && lhNear(px, py, pz, d, 0.52)) break;
    if (i % 3 === 0) {                                                        // do not run into the own lane
      const kb = Math.ceil(2.4 * d / ds), r2 = Math.pow(0.5 * d, 2); let hit = false;
      for (let q = 0; q < out.length - kb * 7 && !hit; q += 14) { const dx = px-out[q], dy = py-out[q+1], dz = pz-out[q+2]; if (dx*dx + dy*dy + dz*dz < r2) hit = true; }
      for (let q = kb * 7; q < other.length && !hit; q += 14) { const dx = px-other[q], dy = py-other[q+1], dz = pz-other[q+2]; if (dx*dx + dy*dy + dz*dz < r2) hit = true; }
      if (hit) break;
    }
    out.push(px, py, pz, nx, ny, nz, d);
  }
}
function buildLane(P, N, D, ds, floorA, floorB) {   // flat arrays -> lane object with taper
  const n = P.length / 3; if (n < 4) return null;
  for (let it = 0; it < 4; it++) {                                     // smooth the polyline, re-project onto the surface
    for (let i = 1; i < n - 1; i++) {
      const o = i * 3, x = 0.5*P[o] + 0.25*(P[o-3] + P[o+3]), y = 0.5*P[o+1] + 0.25*(P[o-2] + P[o+4]), z = 0.5*P[o+2] + 0.25*(P[o-1] + P[o+5]);
      if (snap(x, y, z)) { P[o] = SP[0]; P[o+1] = SP[1]; P[o+2] = SP[2]; N[o] = SP[3]; N[o+1] = SP[4]; N[o+2] = SP[5]; }
    }
  }
  const T = new Float64Array(n * 3), hw = new Float64Array(n); let len = 0, dm = 0;
  for (let i = 0; i < n; i++) {
    const a = Math.max(0, i - 2) * 3, b = Math.min(n - 1, i + 2) * 3, i3 = i * 3;
    let tx = P[b]-P[a], ty = P[b+1]-P[a+1], tz = P[b+2]-P[a+2]; const dd = tx*N[i3] + ty*N[i3+1] + tz*N[i3+2]; tx -= N[i3]*dd; ty -= N[i3+1]*dd; tz -= N[i3+2]*dd;
    const l = Math.hypot(tx, ty, tz) || 1; T[i3] = tx/l; T[i3+1] = ty/l; T[i3+2] = tz/l; dm += D[i] / n;
    if (i) len += Math.hypot(P[i3]-P[i3-3], P[i3+1]-P[i3-2], P[i3+2]-P[i3-1]);
  }
  if (len < 0.9 * dm) return null;
  const eLen = Math.min(1.9 * dm, len * 0.48); let hs = 0;
  for (let i = 0; i < n; i++) {
    const s = len * i / (n - 1), qa = sm01(s / eLen), qb = sm01((len - s) / eLen);
    const ta = Math.max(floorA, Math.pow(qa, 0.75)), tb = Math.max(floorB, Math.pow(qb, 0.75));
    hw[i] = Math.max(1e-4, 0.5 * D[i] * 1.22 * Math.min(ta, tb)); hs += hw[i] / n;
  }
  return { id: ++CTX.eid, n, P: Float64Array.from(P), N: Float64Array.from(N), T, hw, len, hwm: hs, dm, ds: len / (n - 1) };
}
function laneStyle(L) {
  const act = activeStyles(), s = clampN(Math.log2(L.dm / CTX.dB) / CTX.range, -1, 1), R = CTX.R, cell = CTX.cell;
  const w = act.map(st => Math.exp(-Math.pow(s - st.pref, 2) / 0.4) * (cell && cell.style === st.id ? 3.5 : 1) + 0.04);
  let x = R() * w.reduce((a, b) => a + b, 0), j = 0; while (j < w.length - 1 && x > w[j]) { x -= w[j]; j++; } return act[j];
}
function emitLaneVolute(L, end) {                  // thin spiral continuing the lane end, ending in a pearl
  const R = CTX.R, i = end ? L.n - 1 : 0, o = i * 3, hc = L.hw[i], sgn = end ? 1 : -1, side = R() < 0.5 ? 1 : -1;
  if (hc < CTX.dB * 0.03) return;
  const t0 = [L.T[o] * sgn, L.T[o+1] * sgn, L.T[o+2] * sgn], cs = side * (5.5 + 4 * R()), Lc = Math.max(L.hwm, hc * 3) * (4.2 + 2.5 * R());
  const w = walk([L.P[o], L.P[o+1], L.P[o+2]], t0, Lc, t => cs * Math.pow(t, 1.9), null, 0);
  if (!w || w.n < 6) return;
  emitRibbon({ n: w.n, P: w.P, N: w.N, T: w.T, zf: () => hc * 0.35, spec: { kind: 'round', J: 6, hr: 1, th: CTX.dB * 0.04, wl: t => hc * (1.0 * Math.pow(1 - t, 0.85) + 0.1) } });
  const q = at(w, 1); emitDome(q.p, q.n, q.t, hc * 0.5, hc * 0.5); CTX.nEl++;
}
function commitLane(L, st, curlEnd) {
  try { st.fn(L); } catch (e) { console.error('lane style', st.id, e); }
  if (curlEnd >= 0) emitLaneVolute(L, curlEnd);
  const stride = Math.max(1, Math.round(0.5 * L.dm / L.ds));
  for (let i = 0; i < L.n; i += stride) {
    const o = i * 3, x = L.P[o], y = L.P[o+1], z = L.P[o+2], d = L.dm; lhAdd(x, y, z, d);
    for (const m of CTX.mir) lhAdd(x * m[0], y * m[1], z * m[2], d);
  }
  CTX.nMot++; CTX.nEl++; CTX.cover += L.len * 2 * L.hwm;
  const ks = Math.max(1, Math.round(0.75 * L.dm / L.ds)), q = CTX.queue;
  for (let i = (CTX.R() * ks) | 0; i < L.n; i += ks) {
    const o = i * 3, b = cross3([L.N[o], L.N[o+1], L.N[o+2]], [L.T[o], L.T[o+1], L.T[o+2]]), d = dAt(L.P[o], L.P[o+1], L.P[o+2]);
    for (const sg of [1, -1]) q.push({ p: [L.P[o] + b[0]*sg*d, L.P[o+1] + b[1]*sg*d, L.P[o+2] + b[2]*sg*d], t: [L.T[o], L.T[o+1], L.T[o+2]] });
  }
}
function makeLane(p, n, t) {                       // trace both ways from a seed on the surface and commit the lane
  const R = CTX.R, d0 = dAt(p[0], p[1], p[2]), ds = clampN(0.22 * d0, 0.012, 0.045), maxSteps = Math.min(520, Math.ceil(CTX.ext * 1.4 * O.length / ds / 2));
  const fw = [], bk = [];
  traceDir(p, t, ds, maxSteps, fw, bk);
  traceDir(p, [-t[0], -t[1], -t[2]], ds, maxSteps, bk, fw);
  const nb = bk.length / 7, nf = fw.length / 7, P = [], N = [], D = [];
  for (let i = nb - 1; i >= 0; i--) { P.push(bk[i*7], bk[i*7+1], bk[i*7+2]); N.push(bk[i*7+3], bk[i*7+4], bk[i*7+5]); D.push(bk[i*7+6]); }
  P.push(p[0], p[1], p[2]); N.push(n[0], n[1], n[2]); D.push(d0);
  for (let i = 0; i < nf; i++) { P.push(fw[i*7], fw[i*7+1], fw[i*7+2]); N.push(fw[i*7+3], fw[i*7+4], fw[i*7+5]); D.push(fw[i*7+6]); }
  const curl = R() < O.curl * 0.85, ce = curl ? (R() < 0.5 ? 1 : 0) : -1;
  const L = buildLane(P, N, D, ds, ce === 0 ? 0.3 : 0, ce === 1 ? 0.3 : 0);
  if (!L) return false;
  scaleS(L.P[(L.n >> 1) * 3], L.P[(L.n >> 1) * 3 + 1], L.P[(L.n >> 1) * 3 + 2]);
  commitLane(L, laneStyle(L), ce);
  return true;
}

/* ---------- generator ---------- */
function* generate() {
  const R = rng(O.seed), bb = SF.bb, ext = Math.max(bb.max.x - bb.min.x, bb.max.y - bb.min.y, bb.max.z - bb.min.z);
  ORB = newBuilder();
  CTX = { R, eid: 0, mot: 0, U: 0, ds: 0.02, B: ORB, mir: mirrorList(), cover: 0, nEl: 0, nMot: 0, ext, queue: [], cell: null,
          dB: 0.075 * ext * O.size / (0.55 + 0.45 * O.density), range: 0.4 + 1.8 * O.mix };
  CTX.U = CTX.dB;
  initScale(R, ext); lhInit(CTX.dB * Math.pow(2, CTX.range) * 0.65);
  const Az = zoneArea();
  if (Az <= 0) { O.job.msg = O.zone === 'paint' ? 'PAINT A ZONE FIRST' : 'NOTHING TO GROW ON'; return; }
  if (O.guides) for (const w of guidePolys()) {                  // drawn curves become the first, widest lanes
    const m = w.length / 3, ds = clampN(0.2 * CTX.dB, 0.012, 0.04), P = [], N = [], D = [];
    let carry = 0, prev = [w[0], w[1], w[2]];
    const add = q => { if (snap(q[0], q[1], q[2])) { P.push(SP[0], SP[1], SP[2]); N.push(SP[3], SP[4], SP[5]); D.push(CTX.dB * 1.35); } };
    add(prev);
    for (let i = 1; i < m; i++) {
      const cur = [w[i*3], w[i*3+1], w[i*3+2]], seg = Math.hypot(cur[0]-prev[0], cur[1]-prev[1], cur[2]-prev[2]); let s = ds - carry;
      while (s <= seg) { const u = s / seg; add([prev[0] + (cur[0]-prev[0])*u, prev[1] + (cur[1]-prev[1])*u, prev[2] + (cur[2]-prev[2])*u]); s += ds; }
      carry = seg - (s - ds); prev = cur;
    }
    const L = buildLane(P, N, D, ds, 0, 0); if (!L) continue;
    scaleS(L.P[(L.n >> 1) * 3], L.P[(L.n >> 1) * 3 + 1], L.P[(L.n >> 1) * 3 + 2]);
    commitLane(L, laneStyle(L), -1); yield;
  }
  let fails = 0, guard = 0;
  while (fails < 40 && guard++ < 6000 && ORB.ic < 7.5e6) {
    let item = null;
    const q = CTX.queue;
    while (q.length && !item) {
      const k = (R() * Math.min(q.length, 10)) | 0, c = q[k]; q[k] = q[q.length - 1]; q.pop();
      if (!snap(c.p[0], c.p[1], c.p[2])) continue;
      const p = [SP[0], SP[1], SP[2]], n = [SP[3], SP[4], SP[5]];
      if (!symOK(p) || !inZone(p[0], p[1], p[2])) continue;
      const d = dAt(p[0], p[1], p[2]); if (lhNear(p[0], p[1], p[2], d, 0.78)) continue;
      const dd = c.t[0]*n[0] + c.t[1]*n[1] + c.t[2]*n[2], t = nrm3([c.t[0] - n[0]*dd, c.t[1] - n[1]*dd, c.t[2] - n[2]*dd]);
      item = { p, n, t };
    }
    if (!item) {                                                 // queue empty: reseed somewhere uncovered
      let bestP = null, bc = -1;
      for (let k = 0; k < 14; k++) {
        const p = randSurf(R); if (!snap(p[0], p[1], p[2])) continue;
        const pp = [SP[0], SP[1], SP[2]]; if (!symOK(pp) || !inZone(pp[0], pp[1], pp[2])) continue;
        const d = dAt(pp[0], pp[1], pp[2]); if (lhNear(pp[0], pp[1], pp[2], d, 0.7)) continue; bestP = { p: pp, n: [SP[3], SP[4], SP[5]] }; break;
      }
      if (!bestP) { fails++; continue; }
      item = { p: bestP.p, n: bestP.n, t: flowDir(bestP.p, bestP.n) };
    }
    if (makeLane(item.p, item.n, item.t)) fails = 0; else fails++;
    O.job.progress = Math.min(1, CTX.cover / (Az * 1.05));
    yield;
  }
}

/* ---------- job driver ---------- */
const setStatus2 = t => { $('status2').textContent = t; };
function uploadOrn() {
  if (!ORB) return;
  if (!ornMesh) { ornMesh = new THREE.Mesh(new THREE.BufferGeometry(), matOrn); scene.add(ornMesh); }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(ORB.v.slice(0, ORB.vc * 3), 3));
  geo.setIndex(new THREE.BufferAttribute(ORB.i.slice(0, ORB.ic), 1));
  geo.computeVertexNormals();
  setGeo(ornMesh, geo);
}
function setBase() {
  if (!ornBase) { ornBase = new THREE.Mesh(SF.geo, matOB); scene.add(ornBase); }
  else if (ornBase.geometry !== SF.geo) { const old = ornBase.geometry; ornBase.geometry = SF.geo; old.dispose(); }
  tintBase();
}
function tintBase() {
  if (!ornBase) return;
  const col = ornBase.geometry.attributes.color, pos = ornBase.geometry.attributes.position.array, a = col.array, paint = O.zone === 'paint';
  for (let v = 0; v < col.count; v++) {
    const m = paint && inZone(pos[v*3], pos[v*3+1], pos[v*3+2]);
    a[v*3] = m ? 0.72 : 1; a[v*3+1] = m ? 0.83 : 1; a[v*3+2] = 1;
  }
  col.needsUpdate = true;
}
function startGrow(reseed) {
  if (!O.on) return;
  clearTimeout(growTimer);
  if (reseed) O.seed = (Math.random() * 1e6) | 0;
  $('vSeed').textContent = O.seed;
  setStatus2('BUILDING SURFACE…');
  if (!buildSurface()) { setStatus2('NOTHING SOLID TO DECORATE'); O.job = null; return; }
  setBase(); showFlow();
  O.genStamp = O.formStamp;
  const job = { t0: performance.now(), lastUp: 0, progress: 0, msg: '' };
  O.job = job; job.gen = generate();
}
const scheduleGrow = (ms = 260) => { clearTimeout(growTimer); if (O.on) growTimer = setTimeout(() => startGrow(false), ms); };
function finishJob(j) {
  uploadOrn();
  const tris = ORB ? ORB.ic / 3 : 0;
  setStatus2(j.msg || (CTX ? `${CTX.nMot} LANES · ${CTX.nEl} ELEMENTS · ${fmtN(tris)} TRIS · ${((performance.now() - j.t0) / 1000).toFixed(1)} S` : '—'));
  $('progress').style.width = '0';
  O.job = null;
}
function ornFrame() {
  if (!O.on) return;
  prevMesh.visible = negMesh.visible = fieldMesh.visible = false; if (hqMesh) hqMesh.visible = false;
  for (const k in outLines) outLines[k].visible = false;
  if (ornBase) ornBase.visible = O.body; if (ornMesh) ornMesh.visible = true;
  const j = O.job;
  if (!j) return;
  const end = performance.now() + 12;
  let done = false;
  while (performance.now() < end) { if (j.gen.next().done) { done = true; break; } }
  $('progress').style.width = (j.progress * 100) + '%';
  const now = performance.now();
  if (done) finishJob(j);
  else if (now - j.lastUp > 240) { uploadOrn(); j.lastUp = now; setStatus2(`GROWING · ${CTX.nMot} LANES`); }
}
function growSync() {                                           // for tests / scripting
  O.on = true; buildSurface(); setBase(); O.job = { t0: performance.now(), progress: 0, msg: '' }; const g = generate();
  while (!g.next().done); finishJob(O.job || { t0: performance.now(), msg: '' }); O.job = null; return { motifs: CTX.nMot, elements: CTX.nEl, tris: ORB.ic / 3 };
}
function clearOrn() {
  O.job = null; ORB = null; if (ornMesh) setGeo(ornMesh, new THREE.BufferGeometry()); setStatus2('CLEARED'); $('progress').style.width = '0';
}

/* ---------- pointer: zone painting ---------- */
const brushRing = new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(ringPts),
  new THREE.LineBasicMaterial({ color: 0x5a78d6, transparent: true, opacity: 0.9, depthTest: false }));
brushRing.renderOrder = 998; brushRing.visible = false; scene.add(brushRing);
let lastHoverT = 0, lastStamp = null;
function paintOn() { return O.on && ((O.zone === 'paint' && !!O.tool) || !!O.ftool); }
let flowT = 0;
function flowStroke(h) {
  const r = O.brush, q = v => Math.round(v * 1e4) / 1e4;
  if (O.ftool === 'ferase') {
    const n0 = O.fstrokes.length;
    O.fstrokes = O.fstrokes.filter(s => Math.hypot(s[0]-h.p[0], s[1]-h.p[1], s[2]-h.p[2]) > r);
    if (O.fstrokes.length !== n0) { fmRebuildStrokes(); showFlow(); }
    return;
  }
  let s;
  if (O.ftool === 'fdir') {
    if (!lastStamp) { lastStamp = h.p; return; }
    const d = [h.p[0]-lastStamp[0], h.p[1]-lastStamp[1], h.p[2]-lastStamp[2]], l = Math.hypot(d[0], d[1], d[2]);
    if (l < r * 0.3) return;
    s = [h.p[0], h.p[1], h.p[2], r, 1, d[0]/l, d[1]/l, d[2]/l];
  } else {
    if (lastStamp) return;                                   // one swirl per click
    s = [h.p[0], h.p[1], h.p[2], r * 1.4, O.ftool === 'fswl' ? 2 : 3, 0, 0, 0];
  }
  lastStamp = h.p; s = s.map(q); O.fstrokes.push(s); fmApply(s);
  const now = performance.now(); if (now - flowT > 80) { flowT = now; showFlow(); }
}
function paintAt(h) {
  if (lastStamp && Math.hypot(h.p[0]-lastStamp[0], h.p[1]-lastStamp[1], h.p[2]-lastStamp[2]) < O.brush * 0.3) return;
  lastStamp = h.p;
  const s = [h.p[0], h.p[1], h.p[2], O.brush, O.tool === 'erase' ? 0 : 1];
  O.stamps.push(s); pmStamp(...s);
  clearTimeout(tintTimer); tintTimer = setTimeout(tintBase, 40);
}
function placeRing(h) {
  brushRing.visible = !!h;
  if (!h) return;
  brushRing.position.set(h.p[0], h.p[1], h.p[2]);
  brushRing.quaternion.setFromUnitVectors(Z_UP, new V3(h.n[0], h.n[1], h.n[2]));
  brushRing.scale.setScalar(O.brush);
}
function ornDown(e) {
  if (!paintOn()) return;
  const h = pickSurf(e); if (!h) return;
  e.stopPropagation(); O.painting = true; lastStamp = null; if (O.ftool) flowStroke(h); else paintAt(h); placeRing(h);
}
function ornMove(e) {
  if (!paintOn()) { brushRing.visible = false; return; }
  if (e.target !== canvas && !O.painting) return;
  const now = performance.now();
  if (now - lastHoverT < 25) return;
  lastHoverT = now;
  const h = pickSurf(e);
  if (O.painting && h) { if (O.ftool) flowStroke(h); else paintAt(h); }
  placeRing(O.painting && !h ? null : h);
}
function ornUp() {
  if (O.painting) { O.painting = false; if (O.ftool) { showFlow(); } else tintBase(); scheduleGrow(500); }
}

/* ---------- modes ---------- */
function setSpace(sp) {
  const on = sp === 'orn';
  if (on === O.on) return;
  if (S.drawing) cancelDraw();
  O.on = on;
  $('spForm').classList.toggle('on', !on); $('spOrn').classList.toggle('on', on);
  document.querySelectorAll('.formOnly').forEach(el => el.hidden = on);
  document.querySelectorAll('.ornOnly').forEach(el => el.hidden = !on);
  $('add').hidden = on; $('pal').hidden = !on;
  if (on) {
    select(null);
    if (O.genStamp !== O.formStamp || !ORB) startGrow(false);
    else { if (!SF) buildSurface(); setBase(); }
  } else {
    clearTimeout(growTimer); O.job = null; brushRing.visible = false; O.ftool = null; syncZoneUI(); if (flowLines) flowLines.visible = false; $('progress').style.width = '0';
    if (ornBase) ornBase.visible = false; if (ornMesh) ornMesh.visible = false;
    if (S.hqFresh && hqMesh) hqMesh.visible = true; else { prevMesh.visible = negMesh.visible = fieldMesh.visible = true; if (hqMesh) hqMesh.visible = false; }
    rebuildOutlines();
  }
  canvas.style.cursor = ''; updateBar();
}
$('spForm').addEventListener('click', () => setSpace('form'));
$('spOrn').addEventListener('click', () => setSpace('orn'));

/* ---------- UI wiring ---------- */
function buildPalette() {
  const host = $('palList');
  STYLES.forEach(st => {
    const b = document.createElement('button'); b.dataset.style = st.id; b.title = st.hint;
    b.innerHTML = `<svg viewBox="0 0 14 14">${st.icon}</svg>${st.name}`;
    b.addEventListener('click', () => {
      O.styles[st.id] = !O.styles[st.id];
      if (!STYLES.some(s => O.styles[s.id])) O.styles[st.id] = true;
      syncPalette(); scheduleGrow(); b.blur();
    });
    host.appendChild(b);
  });
  syncPalette();
}
function syncPalette() { document.querySelectorAll('#palList button').forEach(b => b.classList.toggle('on', !!O.styles[b.dataset.style])); }
const ORN_SLIDERS = [['sOd', 'vOd', 'density'], ['sOs', 'vOs', 'size'], ['sOm', 'vOm', 'mix'], ['sOr', 'vOr', 'relief'], ['sOc', 'vOc', 'curl'], ['sOt', 'vOt', 'twist'], ['sOh', 'vOh', 'chaos'], ['sOl', 'vOl', 'length'], ['sOf', 'vOf', 'fol'], ['sOn', 'vOn', 'fnoise'], ['sOv', 'vOv', 'cells']];
ORN_SLIDERS.forEach(([id, vid, key]) => $(id).addEventListener('input', () => { O[key] = +$(id).value; $(vid).textContent = (+$(id).value).toFixed(2); scheduleGrow(); }));
const brushSync = () => { for (const k of ['sBr', 'sBr2']) $(k).value = O.brush; for (const k of ['vBr', 'vBr2']) $(k).textContent = O.brush.toFixed(2); };
for (const k of ['sBr', 'sBr2']) $(k).addEventListener('input', () => { O.brush = +$(k).value; brushSync(); });
$('sRend2').addEventListener('input', () => { S.rendRes = +$('sRend2').value; $('vRend2').textContent = S.rendRes + ' / UNIT'; });
function segSet(map, key, val, after) { for (const id in map) $(id).classList.toggle('on', map[id] === val); O[key] = val; if (after) after(); }
const FL = { flAlong: 'along', flAround: 'around', flUp: 'up', flFree: 'free' };
for (const id in FL) $(id).addEventListener('click', () => { segSet(FL, 'flow', FL[id]); scheduleGrow(); });
['oSymX', 'oSymY', 'oSymZ'].forEach((id, i) => $(id).addEventListener('click', () => { O.sym[i] = !O.sym[i]; $(id).classList.toggle('on', O.sym[i]); scheduleGrow(); }));
const BD = { bdOn: true, bdOff: false };
for (const id in BD) $(id).addEventListener('click', () => segSet(BD, 'body', BD[id]));
const GD = { gdOn: true, gdOff: false };
for (const id in GD) $(id).addEventListener('click', () => { segSet(GD, 'guides', GD[id]); scheduleGrow(); });
const FT = { fDir: 'fdir', fSwl: 'fswl', fSwr: 'fswr', fErase: 'ferase' };
function syncZoneUI() {
  $('znAll').classList.toggle('on', O.zone === 'all'); $('znPaint').classList.toggle('on', O.zone === 'paint');
  $('paintRows').hidden = O.zone !== 'paint';
  $('tBrush').classList.toggle('on', O.tool === 'brush'); $('tErase').classList.toggle('on', O.tool === 'erase');
  for (const id in FT) $(id).classList.toggle('on', O.ftool === FT[id]);
  $('fShow').classList.toggle('on', O.showFlow);
  canvas.style.cursor = paintOn() ? 'crosshair' : ''; if (!paintOn()) brushRing.visible = false;
}
$('znAll').addEventListener('click', () => { O.zone = 'all'; O.tool = null;  syncZoneUI(); tintBase(); scheduleGrow(); });
$('znPaint').addEventListener('click', () => { O.zone = 'paint'; O.ftool = null; if (!O.tool) O.tool = 'brush'; syncZoneUI(); tintBase(); scheduleGrow(); });
$('tBrush').addEventListener('click', () => { O.tool = 'brush'; O.ftool = null; syncZoneUI(); });
$('tErase').addEventListener('click', () => { O.tool = 'erase'; O.ftool = null; syncZoneUI(); });
$('tClear').addEventListener('click', () => { O.stamps = []; if (PM.d) PM.d.fill(0); tintBase(); scheduleGrow(); });
for (const id in FT) $(id).addEventListener('click', () => {
  O.ftool = O.ftool === FT[id] ? null : FT[id];
  if (O.ftool) { O.tool = null; if (!O.showFlow) O.showFlow = true; }
  syncZoneUI(); showFlow();
});
$('fClear').addEventListener('click', () => { O.fstrokes = []; fmRebuildStrokes(); showFlow(); scheduleGrow(); });
$('fShow').addEventListener('click', () => { O.showFlow = !O.showFlow; syncZoneUI(); showFlow(); });
$('oColor').addEventListener('input', e => { O.color = e.target.value; O.follow = false; applyMaterial(); });
$('btnGrow').addEventListener('click', () => startGrow(false));
$('btnReroll').addEventListener('click', () => startGrow(true));
$('btnClearOrn').addEventListener('click', clearOrn);

/* ---------- export ---------- */
function exportOrn(withBody) {
  if (!ORB || !ORB.ic) { setStatus2('GROW AN ORNAMENT FIRST'); return; }
  setStatus2(withBody ? 'BUILDING BODY…' : 'EXPORTING…');
  setTimeout(() => {
    try {
      const group = new THREE.Group(), m = S.mat;
      const mat = col => new THREE.MeshPhysicalMaterial({ color: col, roughness: 1 - m.smooth, metalness: m.metal, transmission: m.trans, thickness: 0.6, ior: 1.4, attenuationColor: new THREE.Color(col), attenuationDistance: 1.5 });
      const flat = g => { if (!S.flat) return g; const q = g.toNonIndexed(); q.computeVertexNormals(); return q; };
      if (withBody) {
        const r = polygonize(compile(S.blobs), S.blend, 1 / S.rendRes, 24e6, false);
        if (r) { const bm = new THREE.Mesh(flat(r.geo), mat(m.color)); bm.name = 'blob_mesh'; group.add(bm); }
      }
      const om = new THREE.Mesh(flat(ornMesh.geometry.clone()), mat(O.follow ? m.color : O.color)); om.name = 'ornament'; group.add(om);
      new GLTFExporter().parse(group, buf => {
        downloadBlob(new Blob([buf], { type: 'model/gltf-binary' }), withBody ? 'blob_and_ornament.glb' : 'ornament_only.glb');
        setStatus2('EXPORTED');
      }, err => { console.error(err); setStatus2('EXPORT FAILED'); }, { binary: true });
    } catch (err) { console.error(err); setStatus2('EXPORT FAILED — LOWER BODY RES'); }
  }, 40);
}
$('btnExpAll').addEventListener('click', () => exportOrn(true));
$('btnExpOrn').addEventListener('click', () => exportOrn(false));

/* ---------- project save / load ---------- */
function serializeOrn() {
  const q = v => Math.round(v * 1e4) / 1e4;
  return { seed: O.seed, density: O.density, size: O.size, mix: O.mix, relief: O.relief, curl: O.curl, twist: O.twist, chaos: O.chaos,
           body: O.body, length: O.length, fol: O.fol, fnoise: O.fnoise, fstrokes: O.fstrokes.map(s => s.map(q)), flow: O.flow, sym: O.sym.slice(), guides: O.guides, zone: O.zone, brush: O.brush, styles: { ...O.styles },
           color: O.color, follow: O.follow, stamps: O.stamps.map(s => s.map(q)) };
}
function loadOrn(d) {
  if (!d || typeof d !== 'object') return;
  const num = (v, def) => (typeof v === 'number' && isFinite(v) ? v : def);
  for (const [, , key] of ORN_SLIDERS) O[key] = num(d[key], O[key]);
  O.seed = num(d.seed, O.seed); O.brush = num(d.brush, O.brush);
  if (['along', 'around', 'up', 'free'].includes(d.flow)) O.flow = d.flow;
  if (Array.isArray(d.sym)) O.sym = [0, 1, 2].map(i => !!d.sym[i]);
  if (typeof d.guides === 'boolean') O.guides = d.guides;
  if (d.zone === 'all' || d.zone === 'paint') O.zone = d.zone;
  if (d.styles) for (const k in O.styles) if (typeof d.styles[k] === 'boolean') O.styles[k] = d.styles[k];
  if (typeof d.color === 'string') O.color = d.color;
  if (typeof d.follow === 'boolean') O.follow = d.follow;
  O.stamps = Array.isArray(d.stamps) ? d.stamps.filter(s => Array.isArray(s) && s.length === 5) : [];
  O.fstrokes = Array.isArray(d.fstrokes) ? d.fstrokes.filter(s => Array.isArray(s) && s.length === 8 && s.every(Number.isFinite)) : [];
  if (typeof d.body === 'boolean') O.body = d.body;
  O.tool = null; O.ftool = null;
  ORN_SLIDERS.forEach(([id, vid, key]) => { $(id).value = O[key]; $(vid).textContent = O[key].toFixed(2); });
  brushSync(); $('vSeed').textContent = O.seed;
  segSet(BD, 'body', O.body); segSet(FL, 'flow', O.flow); segSet(GD, 'guides', O.guides);
  ['oSymX', 'oSymY', 'oSymZ'].forEach((id, i) => $(id).classList.toggle('on', O.sym[i]));
  $('oColor').value = O.color; syncPalette(); syncZoneUI(); applyMaterial();
  ORB = null; if (ornMesh) setGeo(ornMesh, new THREE.BufferGeometry());
  if (O.on) startGrow(false);
}
matOrn.side = THREE.DoubleSide;
buildPalette();
ORN_SLIDERS.forEach(([id, vid, key]) => { $(vid).textContent = (+$(id).value).toFixed(2); });
brushSync(); $('vSeed').textContent = O.seed; $('vRend2').textContent = S.rendRes + ' / UNIT';
const ORN_API = { FM, flowAt, fmApply, showFlow, growSync, startGrow, setSpace, buildSurface, get CTX() { return CTX; }, get ORB() { return ORB; }, STYLES, pickSurf, clearOrn };


/* ============================================================
   LOOP
============================================================ */
function resize() {
  const w = innerWidth, h = innerHeight;
  renderer.setSize(w, h, false); persp.aspect = w / h; persp.updateProjectionMatrix();
  const hh = ortho.top; ortho.left = -hh * w / h; ortho.right = hh * w / h; ortho.updateProjectionMatrix();
}
addEventListener('resize', resize); resize();

let frames = 0, tFps = performance.now(), fps = 0;
function loop() {
  requestAnimationFrame(loop);
  controls.update();
  if (camera === ortho && snapQ && camera.quaternion.angleTo(snapQ) > 0.002) leavePerspective();   // rotating = back to perspective
  lightRig.position.copy(camera.position); lightRig.quaternion.copy(camera.quaternion);
  if (S.dirty) { remeshPreview(); rebuildOutlines(); S.dirty = false; }
  updateGizmo();
  ornFrame();
  renderer.render(scene, camera);
  frames++;
  const now = performance.now();
  if (now - tFps > 500) {
    fps = Math.round(frames * 1000 / (now - tFps)); frames = 0; tFps = now;
    $('stats').textContent = `${fps} FPS · ${fmtN(lastTris)} TRIS · ${lastMs.toFixed(0)} MS`;
  }
}

/* start with one sphere */
const first = makeBlob('sphere', new V3(0, 0.5, 0));
S.blobs.push(first);
setShading(false); setGrid(true); applyMaterial(); syncSym(); updateBar(); updateHint();
loop();

// handy for debugging from the console
window.__blob = { O, ornApi: () => ORN_API, S, makeBlob, updateSpline, select, markDirty, renderHQ, THREE, get camera() { return camera; }, persp, ortho, setView, gizmo, controls };
</script>
</body>
</html>