index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>BLOB MESH DESIGNER</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}
#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}
.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}
.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{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 MESH DESIGNER</b><span id="stats">—</span></div>
<div id="hint"></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">
<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>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>
<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="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">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="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';
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)
neg: false, // active layer: false = positive, true = negative (cutting) blobs
clip: null, pasteN: 0,
};
let uid = 1;
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 };
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;
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 camera = new THREE.PerspectiveCamera(40, 1, 0.05, 100);
camera.position.set(2.3, 1.7, 3.3);
scene.add(camera);
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, 1.0));
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);
camera.add(l); camera.add(t); l.target = t;
}
camLight(0xffffff, 2.2, [-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.MeshStandardMaterial({ color: 0xffffff, vertexColors: true, roughness: 0.62, metalness: 0 });
const matHQ = new THREE.MeshStandardMaterial({ color: 0xe6e6e8, roughness: 0.62, metalness: 0 });
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 };
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);
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;
}
function smin(a, b, k) {
if (k <= 0) return a < b ? a : b;
const h = Math.max(k - Math.abs(a - b), 0) / k;
return (a < b ? a : b) - h * h * k * 0.25;
}
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);
if (!posCs.length) return null; // nothing solid to build (negatives only carve)
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);
}
let H = h0, ox, oy, oz, nx, ny, nz;
for (let it = 0; it < 80; it++) {
const pad = k + 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 = k + 3 * H;
const rng = (lo, hi, o, n) => [Math.max(0, Math.floor((lo - m - o) / H)), Math.min(n - 1, Math.ceil((hi + m - 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)]) {
const neg = !!c.neg;
const [ia, ib] = rng(c.x0, c.x1, ox, nx), [ja, jb] = rng(c.y0, c.y1, oy, ny), [ka, kb] = rng(c.z0, c.z1, oz, nz);
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 };
}
/* ============================================================
PREVIEW MESH
============================================================ */
const TB = [0.72, 0.72, 0.73], TS = [0.50, 0.62, 0.92]; // positive: base / selected tint (vertex colours)
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;
// 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;
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;
});
lastTris = (rp ? rp.tris : 0) + (rn ? rn.tris : 0);
applyTint();
lastMs = performance.now() - t0;
}
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);
}
function markDirty() {
S.dirty = true; S.hqFresh = false;
if (hqMesh) hqMesh.visible = false;
prevMesh.visible = true; negMesh.visible = true;
if (hqGeo) setStatus('OUT OF DATE — RENDER AGAIN');
}
/* ============================================================
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, 1, 6), hitMat), 'scale', i); h.position.y = 0.5;
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 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]))),
};
}
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(camera.position.distanceTo(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);
}
}
/* ============================================================
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);
for (const h of raycaster.intersectObjects(gm[S.mode].children, true))
if (h.object.userData.kind) return h.object.userData;
return null;
}
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);
return cs.length ? S.blobs[ownerAt(cs, h.point.x, h.point.y, h.point.z)] : null;
}
function layerMesh() { return S.neg ? negMesh : prevMesh; }
let drag = null, downPos = null;
view.addEventListener('pointerdown', e => {
if (e.button !== 0) return;
if (S.drawing) { drawDown(e); e.stopPropagation(); return; }
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) { deleteNode(b, hp.i); e.stopPropagation(); return; } // ALT + click node = delete
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 = camera.position.distanceTo(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 (S.drawing) { drawMove(e); return; }
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' : '';
} else { hover = null; canvas.style.cursor = ''; }
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 f = Math.exp((d.y0 - e.clientY) * 0.008);
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);
}
b.scale.clampScalar(0.05, 20);
}
syncSelUI(); markDirty();
});
window.addEventListener('pointerup', e => {
if (S.drawing) { S.drawing.stroke = false; 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));
}
});
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;
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 === '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;
syncSelUI(); updateBar(); applyTint(); updateHint();
}
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, stroke: false };
controls.mouseButtons.RIGHT = THREE.MOUSE.ROTATE;
canvas.style.cursor = 'crosshair';
updateBar(); updateHint(); refreshDraw();
}
function endDrawMode() {
S.drawing = null;
controls.mouseButtons.RIGHT = THREE.MOUSE.PAN;
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, 6)) d.pts.push(p);
d.stroke = true; refreshDraw();
}
function drawMove(e) {
const d = S.drawing; if (!d) return;
const p = e.target === canvas ? drawPointFrom(e) : null;
if (d.stroke && p && d.pts.length < MAXD) {
const last = d.pts[d.pts.length - 1];
if (!last || !tooClose(e, last, 18)) d.pts.push(p);
}
d.hover = d.stroke ? null : p;
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;
$('bar').hidden = !on;
$('barSel').hidden = !!S.drawing; $('barDraw').hidden = !S.drawing;
document.querySelectorAll('[data-mode]').forEach(b => b.classList.toggle('on', b.dataset.mode === S.mode));
}
function updateHint() {
$('hint').textContent = S.drawing
? 'CLICK OR DRAG TO DRAW · ENTER / DOUBLE-CLICK = FINISH · ESC = CANCEL · RIGHT-DRAG = ORBIT'
: S.sel
? (S.shift ? 'SHIFT · DRAG OR WHEEL = RADIUS'
: S.sel.type === 'spline' ? 'DRAG NODES / HANDLES · ALT+DRAG = BREAK HANDLE · ALT+CLICK NODE = DELETE · DOUBLE-CLICK CURVE = ADD NODE'
: 'SHIFT + DRAG / WHEEL = RADIUS · G / R / S = MODE · CTRL+C / V = COPY / PASTE · DEL = DELETE')
: (S.neg ? 'NEGATIVE LAYER · BLACK SHAPES CUT THE POSITIVE · ' : '') + 'DRAG = ORBIT · RIGHT-DRAG = PAN · WHEEL = ZOOM · CLICK A BLOB TO SELECT';
}
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';
$('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('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(); updateHint();
}
$('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(); } });
$('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 = flat;
matPrev.needsUpdate = matHQ.needsUpdate = matNeg.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); }
$('gridOn').addEventListener('click', () => setGrid(true));
$('gridOff').addEventListener('click', () => setGrid(false));
/* ---------- 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; 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 mesh = new THREE.Mesh(g, new THREE.MeshStandardMaterial({ color: 0xe0e0e2, roughness: 0.65, metalness: 0 }));
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);
/* ============================================================
LOOP
============================================================ */
function resize() {
const w = innerWidth, h = innerHeight;
renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix();
}
addEventListener('resize', resize); resize();
let frames = 0, tFps = performance.now(), fps = 0;
function loop() {
requestAnimationFrame(loop);
controls.update();
if (S.dirty) { remeshPreview(); S.dirty = false; }
updateGizmo();
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); applyLayerLook(); syncSym(); updateBar(); updateHint();
loop();
// handy for debugging from the console
window.__blob = { S, makeBlob, updateSpline, select, markDirty, renderHQ, THREE, camera, gizmo, controls };
</script>
</body>
</html>