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>