From d279ec412368e6ced123396dbe856994ccb83d52 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 1 Oct 2026 07:55:48 +0000 Subject: [PATCH] Add clay pieces, masking, and topology editing to sculpt Add pastel presets, piece selection and colors, mask brushes, subdivision and conservative decimation with bounded worker operations and mixed undo history. Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4b4-202c-73f1-984d-9a55c88f9c83 Co-authored-by: Heaust Azure --- server/web/assets/sculpt/README.md | 16 +- server/web/assets/sculpt/app.js | 253 +++++++++++- server/web/assets/sculpt/browser-check.js | 4 +- server/web/assets/sculpt/bvh.js | 7 +- server/web/assets/sculpt/document.js | 287 +++++++++++++ server/web/assets/sculpt/document.test.js | 174 ++++++++ server/web/assets/sculpt/engine.js | 377 +++++++++++++++--- server/web/assets/sculpt/index.html | 145 ++++++- server/web/assets/sculpt/input.js | 32 +- server/web/assets/sculpt/menus.js | 1 + server/web/assets/sculpt/mesh.js | 69 +++- server/web/assets/sculpt/operations.js | 128 ++++++ server/web/assets/sculpt/operations.test.js | 151 +++++++ server/web/assets/sculpt/pieces-check.js | 316 +++++++++++++++ server/web/assets/sculpt/renderer.js | 36 +- server/web/assets/sculpt/stroke-client.js | 20 +- server/web/assets/sculpt/stroke-worker.js | 34 +- server/web/assets/sculpt/styles.css | 73 ++++ server/web/assets/sculpt/topology.js | 420 ++++++++++++++++++++ server/web/assets/sculpt/topology.test.js | 110 +++++ server/web/assets/sculpt/worker.test.js | 42 ++ 21 files changed, 2564 insertions(+), 131 deletions(-) create mode 100644 server/web/assets/sculpt/document.js create mode 100644 server/web/assets/sculpt/document.test.js create mode 100644 server/web/assets/sculpt/operations.js create mode 100644 server/web/assets/sculpt/operations.test.js create mode 100644 server/web/assets/sculpt/pieces-check.js create mode 100644 server/web/assets/sculpt/topology.js create mode 100644 server/web/assets/sculpt/topology.test.js diff --git a/server/web/assets/sculpt/README.md b/server/web/assets/sculpt/README.md index fb745e2..3a92911 100644 --- a/server/web/assets/sculpt/README.md +++ b/server/web/assets/sculpt/README.md @@ -4,7 +4,9 @@ Open `/sculpt` on the Yawn server (`npm run server`); `/view/sculpt` remains a c ## Workflow -Five brushes: Draw, Inflate, Smooth, Flatten, Grab. X symmetry merges reflected displacement without double deposition. Pen pressure modulates strength through `clamp(gain × pressure^gamma, 0, 1)`; pressure-radius is optional. Mouse/touch use constant pressure. Settings and temporary Shift-smooth / Alt-invert are captured at stroke start. Grab uses a fixed camera-facing drag plane and stroke-start positions. +Five geometry brushes: Draw, Inflate, Smooth, Flatten, Grab; Mask paints protected regions and its erase mode removes protection. All geometry brushes respect masks. X symmetry reflects about each piece's stored local plane and merges displacement without double deposition. Pen pressure modulates strength through `clamp(gain × pressure^gamma, 0, 1)`; pressure-radius is optional. Mouse/touch use constant pressure. Settings and temporary Shift-smooth / Alt-invert are captured at stroke start. Grab uses a fixed camera-facing drag plane and stroke-start positions. + +The Clay popover adds Sphere, Cube, Cylinder, Cone and Torus presets, each with a newly chosen pastel material. Select a piece in the list to sculpt, recolor or delete it; even the last piece can be deleted. Additions are placed beside existing clay. Detail & mask contains Clear/Invert mask, Subdivide (linear 4× triangle split, interpolating masks), and Decimate (best-effort 50% reduction). Decimation freezes masked neighborhoods and boundary vertices and rejects topology-breaking collapses. It may achieve less reduction, or none. There are no piece transform handles. The compact left icon rail contains brushes, brush settings, undo/redo and Settings. Hover or tap the settings icons to reveal their panels; tap inside to keep one open. Projects and palm rejection live in Settings, with less-used pen/display controls in disclosures. Panels overlay the canvas without resizing it and close on viewport resize. Orientation changes preserve apparent zoom, pan and rotation. @@ -12,25 +14,27 @@ Drag the top-right XYZ gizmo to orbit, or its neighboring pan/zoom icons to move Controls use the locally vendored Basecoat 1.0.2 standalone stylesheet (shadcn-style native HTML components); provenance and license are in `vendor/basecoat/`. No React, UI JavaScript runtime, CDN, or additional build step. Exclude the unmodified vendor stylesheet from formatters. `styles.css` handles layout/density; `Menus` remains the visibility controller. -Palm rejection defaults on: canvas touch is ignored, including outside-menu dismissal, while touch controls remain usable. With rejection off, one finger sculpts and two fingers pan/pinch, rolling back any provisional one-finger sculpt. Right-drag rotates, middle-drag pans, and the wheel zooms. Escape, blur, canceled input and unexpected capture loss roll back the stroke. Undo/redo acts on whole strokes. Native `.yawn-sculpt` projects preserve mesh geometry and density (not history or settings); OBJ export is one-way. Download before leaving—there is no automatic persistence. +Palm rejection defaults on: canvas touch is ignored, including outside-menu dismissal, while touch controls remain usable. With rejection off, one finger sculpts and two fingers pan/pinch, rolling back any provisional one-finger sculpt. Right-drag rotates, middle-drag pans, and the wheel zooms. Escape, blur, canceled input and unexpected capture loss roll back the stroke. Undo/redo includes whole strokes, masks, colors and structural operations. Native v2 `.yawn-sculpt` projects preserve topology, pieces, colors and masks (not history or settings), and v1 sphere projects still open. OBJ exports geometry and piece groups, not masks/materials. Download before leaving—there is no automatic persistence. ## Performance bounds and limitations | New sphere | Vertices | Triangles | History cap | | ---------- | -------: | --------: | ----------: | -| Simple | 2,562 | 5,120 | 8 MiB | +| Simple | 2,562 | 5,120 | 16 MiB | | Balanced | 10,242 | 20,480 | 16 MiB | -| Fine | 40,962 | 81,920 | 32 MiB | +| Fine | 40,962 | 81,920 | 16 MiB | History is capped by bytes and 50 entries, with a separate bounded active-stroke position snapshot. A refitted triangle BVH accelerates picking and brush neighborhoods. Affected normals include all incident faces at brush boundaries. Display quality changes pixel budgets (~0.6/1/2 MP) and frame caps (30/60/60), never mesh density. The 8 MiB shared-row arena is **not** the application's total memory budget. +Documents are capped at 16 pieces, 65,536 vertices and 131,072 triangles. Colors and masks use the existing 32-byte GPU vertex stride and one draw call. Topology algorithms run in a disposable worker with cancellation and a bounded decimation budget; accepted structural edits rebuild main-thread adjacency/BVH and recreate the renderer/stroke worker. This infrequent installation can still pause on constrained devices; it does not add topology work to the stroke path. Resize, failure or Cancel reject pending work. Renderer failure after commit preserves the new document for Save, Undo and Retry. + Input occupies **one 64-byte `sculptSample` row in core's SAB**, not a sample queue. Main publishes the latest endpoint/pressure; subtracting the previously consumed endpoint gives the accumulated movement. Atomic version tokens protect a coherent snapshot and retire only the consumed update, leaving concurrent newer input pending. A dedicated worker samples this slot with its own `requestAnimationFrame` and performs brush raycasts, deformation, normals and BVH refits. It interpolates straight segments between consumed endpoints; this frame-level approximation does not retain intermediate bends or pressure changes. Grab separately preserves the first effective pressure that establishes its influence. The worker retains at most one unfinished segment plus the latest shared endpoint. Resumable interpolation yields after a soft 4 ms budget or 64 brush attempts; an unfinished segment exceeding 250 ms cancels the provisional stroke. One synchronous dab can exceed the budget, especially for large brushes and Detail. Shared memory avoids an input message queue, not the CPU cost of sculpting. Worker and renderer animation frames are independently scheduled, not a same-frame presentation guarantee. Worker geometry packets are immutable, transferred, and single-flight; main applies them to its document mirror and acknowledges them. GPU uploads retain their separate single-flight staging guard. Initial picking, document/history operations, and one face-normal/BVH maintenance pass at stroke completion remain on main. Completed geometry/history survive renderer or sculpt-worker failure; unfinished work rolls back. Resize recreates both workers and the shared row because core does not expose surface resizing. Viewport rectangle changes cancel unfinished strokes immediately; new strokes wait for cancellation acknowledgement and the matching renderer size. The sculpt worker stops its animation loop between strokes; core's renderer still has its own loop. -Requires WebGPU, OffscreenCanvas, shared memory, cross-origin isolation, and dedicated-worker animation frames. Observable initialization/upload failures preserve the document and expose Retry plus Save/Export; core does not expose comprehensive device-loss detection. A front-facing brush filter is not geodesic/occlusion isolation on folded surfaces. This is a fixed-topology, single-sphere sculpting studio inspired by Nomad, **not Nomad parity**: no remesh, dynamic topology, layers, painting, multi-object scenes or arbitrary mesh import. Coordinates are bounded to ±8 model units. +Requires WebGPU, OffscreenCanvas, shared memory, cross-origin isolation, and dedicated-worker animation frames. Observable initialization/upload failures preserve the document and expose Retry plus Save/Export; core does not expose comprehensive device-loss detection. A front-facing brush filter is not geodesic/occlusion isolation on folded surfaces. This remains **not Nomad parity**: no remesh, dynamic topology during strokes, layers, texture painting, piece transforms or arbitrary mesh import. Coordinates are bounded to ±8 model units. ## Verification @@ -41,6 +45,8 @@ go test ./server Tests exercise topology, brute-force picking comparisons, local normals, pressure, symmetry, history, projects, resumable sampling, input transactions and staged uploads. Benchmark output is orb CPU timing, not a low-end hardware or frame-rate guarantee. Physical pen feel, palm behavior and constrained-device performance require real-device testing. +On a fresh page, `await (await import('/assets/sculpt/pieces-check.js')).check()` exercises all presets, selection, color/mask history, replacement-topology sculpting, subdivision/decimation, empty projects and real operation-worker delivery races. It also injects pre/post-commit renderer failures. Like the original browser check, it stubs pointer capture and restores hooks afterward; it is not physical pen testing. + In a fresh browser page, `await (await import('/assets/sculpt/latency-check.js')).check()` replays Draw and Grab at 1, 4, and 16 coalesced samples per animation frame on Balanced-density spheres. It verifies zero main-thread dabs and fixed shared-input storage, and reports main input/packet handling, uploads, release-to-Save-enabled delay and cancellations separately. This synthetic check does not measure physical pen-to-display latency. Unit tests include a real two-thread SAB stress test and deterministic delayed-publication/cancellation races. For visual checks in the orb, use `agent-browser --headed --webgpu`: this environment's headless WebGPU presentation can be blank even when rendering succeeds. Inspect desktop, narrow portrait/landscape, settings open, error, and modified/undo/redo states. Test save/reopen and rejected files. Use the page's Help for all gestures and shortcuts. diff --git a/server/web/assets/sculpt/app.js b/server/web/assets/sculpt/app.js index 80b7fb4..d8e03a2 100644 --- a/server/web/assets/sculpt/app.js +++ b/server/web/assets/sculpt/app.js @@ -13,6 +13,7 @@ import { StrokeClient } from "./stroke-client.js"; import { clamp, clampZoom, cameraBasis } from "./math.js"; import { Menus } from "./menus.js"; import { Navigation } from "./navigation.js"; +import { snapshot } from "./document.js"; const $ = (id) => document.getElementById(id), viewport = $("viewport"); @@ -35,9 +36,11 @@ let viewRevision = 0, generation = 0, lastSize = "", modified = false, - saved = engine.mesh.positions.slice(), + saved = engine.state, fileBusy = false; -const brushes = ["draw", "inflate", "smooth", "flatten", "grab"]; +let operation = null, + piecesSignature = ""; +const brushes = ["draw", "inflate", "smooth", "flatten", "grab", "mask"]; let viewNavigation, cameraAspect, messageTimer; const input = new Input(viewport, { engine: () => engine, @@ -48,6 +51,7 @@ const input = new Input(viewport, { !!renderer && !initializing && !fileBusy && + !operation && !viewNavigation?.busy && sizeKey() === lastSize, blocked: () => !!viewNavigation?.busy, @@ -72,6 +76,7 @@ viewNavigation = new Navigation($("view-navigation"), viewport, { !initializing && !fileBusy && !input.busy && + !operation && sizeKey() === lastSize && !document.querySelector("dialog[open]"), start: () => { @@ -82,8 +87,193 @@ viewNavigation = new Navigation($("view-navigation"), viewport, { invalidate, }); function interactionBusy() { - return input.busy || !!viewNavigation?.busy; + return input.busy || !!viewNavigation?.busy || !!operation; } +const colorHex = (color) => + "#" + + color + .map((x) => + Math.round(x ** (1 / 2.2) * 255) + .toString(16) + .padStart(2, "0"), + ) + .join(""); +function updatePieces(busy) { + const pieces = engine.mesh.pieces; + const signature = JSON.stringify([pieces, engine.activeId, busy]); + if (signature !== piecesSignature) { + piecesSignature = signature; + $("piece-list").replaceChildren( + ...pieces.map((piece) => { + const button = document.createElement("button"); + button.className = "btn piece-row"; + button.setAttribute( + "aria-pressed", + String(piece.id === engine.activeId), + ); + button.dataset.piece = piece.id; + button.disabled = busy; + const swatch = document.createElement("i"); + swatch.style.background = colorHex(piece.color); + button.append(swatch, document.createTextNode(piece.name)); + button.onclick = () => { + if (interactionBusy() || initializing || fileBusy) return; + engine.activeId = piece.id; + updateUI(); + }; + return button; + }), + ); + } + const active = pieces.find((p) => p.id === engine.activeId); + for (const id of [ + "delete-piece", + "piece-color", + "subdivide", + "decimate", + "clear-mask", + "invert-mask", + ]) + $(id).disabled = busy || !active; + if (active) $("piece-color").value = colorHex(active.color); + document + .querySelectorAll("[data-preset]") + .forEach((b) => (b.disabled = busy || pieces.length >= 16)); + $("empty-clay").hidden = !!pieces.length || busy; +} +function settleOperation(job) { + if (!job || operation !== job) return false; + clearTimeout(job.timer); + job.worker.terminate(); + operation = null; + $("clay-progress").hidden = true; + return true; +} +function cancelOperation() { + if (settleOperation(operation)) updateUI(); +} +function clayOperation(action, options = {}) { + if (interactionBusy() || initializing || fileBusy) return; + const worker = new Worker(new URL("./operations.js", import.meta.url), { + type: "module", + name: "clay-topology", + }); + const job = (operation = { + worker, + engine, + state: engine.state, + generation, + size: sizeKey(), + }); + const finish = () => { + if (!settleOperation(job)) return false; + updateUI(); + return true; + }; + job.timer = setTimeout(() => { + if (finish()) + message("That operation took too long. Your clay is unchanged."); + }, 20000); + worker.onerror = worker.onmessageerror = () => { + if (finish()) message("Could not edit this piece. Your clay is unchanged."); + }; + worker.onmessage = ({ data }) => { + if (operation !== job) return; + if ( + engine !== job.engine || + engine.state !== job.state || + generation !== job.generation || + sizeKey() !== job.size + ) { + finish(); + return; + } + if (data.error) { + finish(); + message(data.error); + return; + } + try { + const changed = engine.install(data.document, data.activeId); + finish(); + checkModified(); + if (changed) { + if (action === "add") frameModel(); + recreate(); + } + message(data.note || (changed ? "Clay updated" : "No change needed")); + } catch (error) { + finish(); + message(error.message); + } + }; + $("clay-progress").hidden = false; + $("operation-label").textContent = + action === "decimate" + ? "Simplifying clay…" + : action === "subdivide" + ? "Adding detail…" + : "Updating clay…"; + updateUI(); + try { + const document = snapshot(engine.mesh); + worker.postMessage( + { document, action, activeId: engine.activeId, options }, + [ + document.positions.buffer, + document.indices.buffer, + document.masks.buffer, + ], + ); + } catch (error) { + if (finish()) message(error.message); + } +} +$("cancel-operation").onclick = cancelOperation; +window.addEventListener( + "keydown", + (e) => { + if (e.key === "Escape" && !document.querySelector("dialog[open]")) + cancelOperation(); + }, + true, +); +$("add-empty").onclick = () => $("pieces-toggle").click(); +$("delete-piece").onclick = () => clayOperation("delete"); +$("subdivide").onclick = () => clayOperation("subdivide"); +$("decimate").onclick = () => clayOperation("decimate"); +document.querySelectorAll("[data-preset]").forEach( + (b) => + (b.onclick = () => { + // Store the randomly chosen material once; undo/redo never rerolls it. + const hue = Math.random() * 6; + const rgb = [0, 4, 2].map((offset) => { + const k = (hue + offset) % 6; + return (0.88 - 0.24 * Math.max(0, Math.min(k, 4 - k, 1))) ** 2.2; + }); + clayOperation("add", { + preset: b.dataset.preset, + id: crypto.randomUUID(), + color: rgb, + }); + }), +); +$("piece-color").onchange = () => { + if (interactionBusy() || initializing || !engine.activeId) return; + const hex = $("piece-color").value; + engine.setColor( + [1, 3, 5].map((i) => (parseInt(hex.slice(i, i + 2), 16) / 255) ** 2.2), + ); + checkModified(); + invalidate(); +}; +for (const id of ["clear-mask", "invert-mask"]) + $(id).onclick = () => { + if (interactionBusy() || initializing) return; + engine.editMask(id === "invert-mask"); + checkModified(); + invalidate(); + }; $("palm-rejection").onchange = () => { if ($("palm-rejection").checked && input.active?.type === "touch") input.cancel(); @@ -99,7 +289,7 @@ function message(text) { ); } function checkModified() { - modified = !engine.mesh.positions.every((v, i) => v === saved[i]); + modified = engine.state !== saved; updateUI(); } function updateUI() { @@ -110,9 +300,18 @@ function updateUI() { for (const id of ["save", "export"]) $(id).disabled = interactionBusy() || fileBusy; $("modified").hidden = !modified; + updatePieces(busy); if (pendingResize && !resizeTimer && !interactionBusy() && !initializing) { + const token = generation; queueMicrotask(() => { - if (pendingResize && !resizeTimer) recreate(); + if ( + token === generation && + pendingResize && + !resizeTimer && + !interactionBusy() && + !initializing + ) + recreate(); }); } } @@ -145,6 +344,10 @@ function schedule() { if (!frameRequest) frameRequest = requestAnimationFrame(tick); } function fail(error) { + clearTimeout(resizeTimer); + resizeTimer = 0; + pendingResize = false; + settleOperation(operation); viewNavigation?.cancel(); generation++; renderer?.dispose(); @@ -242,13 +445,9 @@ async function recreate() { return; } renderer = next; - next.processor = new StrokeClient( - next.sample, - engine.mesh.level, - (error) => { - if (token === generation) fail(error); - }, - ); + next.processor = new StrokeClient(next.sample, engine.mesh, (error) => { + if (token === generation) fail(error); + }); await next.processor.ready; if (token !== generation) { next.dispose(); @@ -269,6 +468,12 @@ async function recreate() { } function frameModel() { cameraAspect = viewport.clientWidth / viewport.clientHeight; + if (!engine.mesh.indices.length) { + camera.target = [0, 0, 0]; + camera.zoom = 1.5; + invalidate(); + return; + } const bounds = engine.bvh.root; camera.target = bounds.min.map((v, k) => (v + bounds.max[k]) / 2); const { right, up } = cameraBasis(camera.yaw, camera.pitch); @@ -283,8 +488,8 @@ function frameModel() { invalidate(); } function replace(mesh, name) { - engine = new Sculpt(mesh, 2 ** (mesh.level - 4) * 8 * 1048576); - saved = mesh.positions.slice(); + engine = new Sculpt(mesh); + saved = engine.state; modified = false; $("document-name").textContent = name; camera.yaw = 0; @@ -304,7 +509,7 @@ function download(text, name, type) { $("save").onclick = () => { if (interactionBusy() || fileBusy) return; download(saveProject(engine.mesh), "sculpt.yawn-sculpt", "application/json"); - saved = engine.mesh.positions.slice(); + saved = engine.state; modified = false; updateUI(); message("Project downloaded"); @@ -359,15 +564,19 @@ $("new-dialog").addEventListener("close", () => { }); $("help").onclick = () => $("help-dialog").showModal(); $("undo").onclick = () => { - if (!interactionBusy() && engine.restore()) { + const mesh = engine.mesh; + if (!interactionBusy() && !initializing && engine.restore()) { checkModified(); + if (engine.mesh !== mesh) recreate(); invalidate(); message("Undo"); } }; $("redo").onclick = () => { - if (!interactionBusy() && engine.restore(true)) { + const mesh = engine.mesh; + if (!interactionBusy() && !initializing && engine.restore(true)) { checkModified(); + if (engine.mesh !== mesh) recreate(); invalidate(); message("Redo"); } @@ -384,7 +593,8 @@ function chooseBrush(name) { b.setAttribute("aria-pressed", String(b.dataset.brush === name)), ); $("brush-title").textContent = name[0].toUpperCase() + name.slice(1); - $("invert").disabled = !["draw", "inflate"].includes(name); + $("invert").disabled = !["draw", "inflate", "mask"].includes(name); + $("invert-label").textContent = name === "mask" ? "Erase mask" : "Subtract"; } document .querySelectorAll("[data-brush]") @@ -433,7 +643,9 @@ $("reset-pressure").onclick = () => { document.querySelectorAll("[data-color]").forEach( (b) => (b.onclick = () => { - camera.color = b.dataset.color.split(",").map(Number); + if (interactionBusy() || initializing || !engine.activeId) return; + engine.setColor(b.dataset.color.split(",").map(Number)); + checkModified(); document .querySelectorAll("[data-color]") .forEach((x) => x.setAttribute("aria-pressed", String(x === b))); @@ -470,6 +682,7 @@ function resized() { recreate(); } }, 200); + if (operation && operation.size !== sizeKey()) cancelOperation(); const unchanged = input.checkViewport(); if (sizeKey() !== lastSize) { cursor(null); @@ -539,7 +752,7 @@ window.addEventListener("keydown", (e) => { return; } if (mod || e.altKey) return; - if (/^[1-5]$/.test(key)) chooseBrush(brushes[Number(key) - 1]); + if (/^[1-6]$/.test(key)) chooseBrush(brushes[Number(key) - 1]); else if (key === "f") $("frame").click(); else if (key === "?") $("help").click(); else if (key === "[" || key === "]") { diff --git a/server/web/assets/sculpt/browser-check.js b/server/web/assets/sculpt/browser-check.js index 5c36e90..23fa8d0 100644 --- a/server/web/assets/sculpt/browser-check.js +++ b/server/web/assets/sculpt/browser-check.js @@ -102,8 +102,8 @@ export async function check() { "Brush popover overlaps rail or has oversized width", ); assert( - document.querySelectorAll(".tool-rail [data-brush] svg").length === 5, - "Brush rail must have five icons", + document.querySelectorAll(".tool-rail [data-brush] svg").length === 6, + "Brush rail must include five sculpt brushes and Mask", ); assert( $("radius").style.getPropertyValue("--slider-value").startsWith("32.14"), diff --git a/server/web/assets/sculpt/bvh.js b/server/web/assets/sculpt/bvh.js index 64e3d99..73ceeba 100644 --- a/server/web/assets/sculpt/bvh.js +++ b/server/web/assets/sculpt/bvh.js @@ -62,7 +62,9 @@ export class BVH { this.bounds(node); return node; }; - this.root = build(Array.from({ length: this.leaves.length }, (_, i) => i)); + this.root = this.leaves.length + ? build(Array.from({ length: this.leaves.length }, (_, i) => i)) + : null; this.marks = new Uint32Array(mesh.positions.length / 3); this.generation = 0; } @@ -86,6 +88,7 @@ export class BVH { } } refit(faces) { + if (!this.root) return; const dirty = new Set(faces.map((f) => this.leaves[f])); for (const leaf of dirty) this.bounds(leaf); let parents = new Set([...dirty].map((n) => n.parent).filter(Boolean)); @@ -99,6 +102,7 @@ export class BVH { } } raycast(origin, direction) { + if (!this.root) return null; let best = null; const entry = (n) => { let near = 0, @@ -135,6 +139,7 @@ export class BVH { return best; } within(center, radius) { + if (!this.root) return []; const result = [], gen = ++this.generation, r2 = radius * radius; diff --git a/server/web/assets/sculpt/document.js b/server/web/assets/sculpt/document.js new file mode 100644 index 0000000..a555b2f --- /dev/null +++ b/server/web/assets/sculpt/document.js @@ -0,0 +1,287 @@ +import { buildMesh, sphere } from "./mesh.js"; +import { MAX_TRIANGLES, MAX_VERTICES } from "./topology.js"; + +export const MAX_PIECES = 16; +const LIMIT = 8 * 1024 * 1024; +const defaultPiece = (vertices, indices) => ({ + id: "clay-1", + name: "Clay", + vertexStart: 0, + vertexCount: vertices, + indexStart: 0, + indexCount: indices, + color: [0.5, 0.3, 0.2], + symmetryX: 0, +}); + +export function ensureDocument(mesh) { + const vertices = mesh.positions.length / 3; + if (!(mesh.masks instanceof Float32Array) || mesh.masks.length !== vertices) + mesh.masks = new Float32Array(vertices); + if (!Array.isArray(mesh.pieces)) + mesh.pieces = + vertices || mesh.indices.length + ? [defaultPiece(vertices, mesh.indices.length)] + : []; + return mesh; +} + +function validate(data) { + const p = data.positions, + i = data.indices, + m = data.masks, + pieces = data.pieces; + if ( + !(p instanceof Float32Array) || + !(i instanceof Uint32Array) || + !(m instanceof Float32Array) || + p.length % 3 || + i.length % 3 || + m.length !== p.length / 3 || + p.length / 3 > MAX_VERTICES || + i.length / 3 > MAX_TRIANGLES || + ![...p].every(Number.isFinite) || + [...p].some((x) => Math.abs(x) > 8) || + [...m].some((x) => !Number.isFinite(x) || x < 0 || x > 1) || + [...i].some((x) => x >= p.length / 3) || + !Array.isArray(pieces) || + pieces.length > MAX_PIECES + ) + throw new Error("Invalid sculpt document."); + let vs = 0, + is = 0; + const ids = new Set(); + for (const piece of pieces) { + if ( + !piece || + typeof piece.id !== "string" || + !piece.id || + ids.has(piece.id) || + typeof piece.name !== "string" || + piece.vertexStart !== vs || + piece.indexStart !== is || + !Number.isInteger(piece.vertexCount) || + !Number.isInteger(piece.indexCount) || + piece.vertexCount < 0 || + piece.indexCount < 0 || + piece.indexCount % 3 || + !Array.isArray(piece.color) || + piece.color.length !== 3 || + piece.color.some((x) => !Number.isFinite(x) || x < 0 || x > 1) || + !Number.isFinite(piece.symmetryX) || + Math.abs(piece.symmetryX) > 8 + ) + throw new Error("Invalid sculpt piece ranges."); + const end = vs + piece.vertexCount; + if (is + piece.indexCount > i.length || end > p.length / 3) + throw new Error("Invalid sculpt piece ranges."); + for (let k = is; k < is + piece.indexCount; k++) + if (i[k] < vs || i[k] >= end) + throw new Error("Piece index is outside its vertex range."); + ids.add(piece.id); + vs = end; + is += piece.indexCount; + } + if ( + vs !== p.length / 3 || + is !== i.length || + (!p.length && !i.length) !== (pieces.length === 0) + ) + throw new Error("Invalid sculpt piece ranges."); +} + +const clonePieces = (pieces) => + pieces.map((p) => ({ ...p, color: [...p.color] })); +export function snapshot(mesh) { + mesh = ensureDocument(mesh); + const out = { + level: Number.isInteger(mesh.level) ? mesh.level : 0, + positions: mesh.positions.slice(), + indices: mesh.indices.slice(), + masks: mesh.masks.slice(), + pieces: clonePieces(mesh.pieces), + }; + validate(out); + return out; +} +export function hydrate(data) { + const canonical = { + level: data?.level ?? 0, + positions: new Float32Array(data?.positions ?? []), + indices: new Uint32Array(data?.indices ?? []), + masks: new Float32Array(data?.masks ?? []), + pieces: clonePieces(data?.pieces ?? []), + }; + validate(canonical); + const mesh = buildMesh( + canonical.positions.slice(), + canonical.indices.slice(), + canonical.level, + canonical.masks.slice(), + ); + mesh.pieces = clonePieces(canonical.pieces); + return mesh; +} +export function splitPiece(mesh, piece) { + const s = snapshot(mesh), + start = piece.vertexStart; + const positions = s.positions.slice( + start * 3, + (start + piece.vertexCount) * 3, + ); + const indices = Uint32Array.from( + s.indices.slice(piece.indexStart, piece.indexStart + piece.indexCount), + (v) => v - start, + ); + const out = buildMesh( + positions, + indices, + s.level, + s.masks.slice(start, start + piece.vertexCount), + ); + out.pieces = [ + { ...piece, vertexStart: 0, indexStart: 0, color: [...piece.color] }, + ]; + return out; +} +export function combinePieces(items) { + if (items.length > MAX_PIECES) throw new Error("Document exceeds 16 pieces."); + const sources = []; + const snapshots = new Map(); + let vertexCount = 0, + indexCount = 0, + level = null; + for (const item of items) { + let mesh = snapshots.get(item.mesh); + if (!mesh) { + mesh = snapshot(item.mesh); + snapshots.set(item.mesh, mesh); + } + const requested = item.piece ?? mesh.pieces[0]; + const source = mesh.pieces.find((piece) => piece.id === requested?.id); + if (!source) throw new Error("Piece is not part of its source document."); + vertexCount += source.vertexCount; + indexCount += source.indexCount; + if (vertexCount > MAX_VERTICES || indexCount / 3 > MAX_TRIANGLES) + throw new Error("Mesh exceeds topology limits."); + level = level === null ? mesh.level : level === mesh.level ? level : 0; + sources.push({ mesh, source }); + } + const positions = new Float32Array(vertexCount * 3); + const indices = new Uint32Array(indexCount); + const masks = new Float32Array(vertexCount); + const pieces = []; + let vertexOffset = 0, + indexOffset = 0; + for (const { mesh, source } of sources) { + positions.set( + mesh.positions.subarray( + source.vertexStart * 3, + (source.vertexStart + source.vertexCount) * 3, + ), + vertexOffset * 3, + ); + masks.set( + mesh.masks.subarray( + source.vertexStart, + source.vertexStart + source.vertexCount, + ), + vertexOffset, + ); + for (let k = 0; k < source.indexCount; k++) + indices[indexOffset + k] = + mesh.indices[source.indexStart + k] - source.vertexStart + vertexOffset; + pieces.push({ + ...source, + color: [...source.color], + vertexStart: vertexOffset, + indexStart: indexOffset, + }); + vertexOffset += source.vertexCount; + indexOffset += source.indexCount; + } + return hydrate({ level: level ?? 0, positions, indices, masks, pieces }); +} + +const base64Encode = (bytes) => { + if (typeof Buffer !== "undefined") + return Buffer.from(bytes).toString("base64"); + let s = ""; + for (const byte of bytes) s += String.fromCharCode(byte); + return btoa(s); +}; +const base64Decode = (text) => { + if (typeof Buffer !== "undefined") + return Uint8Array.from(Buffer.from(text, "base64")); + return Uint8Array.from(atob(text), (c) => c.charCodeAt(0)); +}; +const encode = (typed) => { + const bytes = new Uint8Array(typed.byteLength), + view = new DataView(bytes.buffer); + for (let i = 0; i < typed.length; i++) + if (typed instanceof Float32Array) view.setFloat32(i * 4, typed[i], true); + else view.setUint32(i * 4, typed[i], true); + return base64Encode(bytes); +}; +const decode = (text, Type) => { + const b = base64Decode(text); + if (b.length % Type.BYTES_PER_ELEMENT) + throw new Error("Invalid encoded array."); + const out = new Type(b.length / Type.BYTES_PER_ELEMENT); + const view = new DataView(b.buffer, b.byteOffset, b.byteLength); + for (let i = 0; i < out.length; i++) + out[i] = + Type === Float32Array + ? view.getFloat32(i * 4, true) + : view.getUint32(i * 4, true); + return out; +}; +export function saveDocument(mesh) { + const s = snapshot(mesh); + const text = JSON.stringify({ + version: 2, + level: s.level, + positions: encode(s.positions), + indices: encode(s.indices), + masks: encode(s.masks), + pieces: s.pieces, + }); + if (text.length > LIMIT) throw new Error("Project exceeds the 8 MiB limit."); + return text; +} +export function loadDocument(text) { + if (typeof text !== "string" || text.length > LIMIT) + throw new Error("Project exceeds the 8 MiB limit."); + const d = JSON.parse(text); + if (d?.version === 1) { + if ( + !Number.isInteger(d.level) || + d.level < 4 || + d.level > 6 || + !Array.isArray(d.positions) + ) + throw new Error("Invalid sculpt project."); + const mesh = sphere(d.level); + if ( + d.positions.length !== mesh.positions.length || + !d.positions.every((x) => Number.isFinite(x) && Math.abs(x) <= 8) + ) + throw new Error("Invalid sculpt project."); + mesh.positions.set(d.positions); + return hydrate(snapshot(ensureDocument(mesh))); + } + if ( + d?.version !== 2 || + typeof d.positions !== "string" || + typeof d.indices !== "string" || + typeof d.masks !== "string" + ) + throw new Error("Invalid sculpt project."); + return hydrate({ + level: d.level, + positions: decode(d.positions, Float32Array), + indices: decode(d.indices, Uint32Array), + masks: decode(d.masks, Float32Array), + pieces: d.pieces, + }); +} diff --git a/server/web/assets/sculpt/document.test.js b/server/web/assets/sculpt/document.test.js new file mode 100644 index 0000000..ad85732 --- /dev/null +++ b/server/web/assets/sculpt/document.test.js @@ -0,0 +1,174 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildMesh, sphere } from "./mesh.js"; +import { BVH } from "./bvh.js"; +import { Sculpt } from "./engine.js"; +import { + ensureDocument, + snapshot, + hydrate, + splitPiece, + combinePieces, + saveDocument, + loadDocument, +} from "./document.js"; + +test("document v2 roundtrip owns canonical arrays and supports empty documents", () => { + const mesh = ensureDocument(sphere(4)); + mesh.masks[3] = 0.75; + const copy = loadDocument(saveDocument(mesh)); + assert.deepEqual(snapshot(copy), snapshot(mesh)); + copy.positions[0] = 7; + assert.notEqual(copy.positions[0], mesh.positions[0]); + const empty = buildMesh([], [], 0); + empty.pieces = []; + assert.equal(loadDocument(saveDocument(empty)).positions.length, 0); +}); + +test("piece splitting localizes indices and combination restores contiguous ranges", () => { + const a = ensureDocument( + buildMesh([0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 2], 0), + ); + a.pieces[0].id = "a"; + const b = hydrate(snapshot(a)); + b.pieces[0].id = "b"; + const combined = combinePieces([ + { mesh: a, piece: a.pieces[0] }, + { mesh: b, piece: b.pieces[0] }, + ]); + assert.deepEqual( + combined.pieces.map((p) => [p.vertexStart, p.indexStart]), + [ + [0, 0], + [3, 3], + ], + ); + assert.deepEqual( + splitPiece(combined, combined.pieces[1]).indices, + new Uint32Array([0, 1, 2]), + ); +}); + +test("combining a level 6 sphere avoids argument limits and preserves its level", () => { + const mesh = ensureDocument(sphere(6)); + mesh.masks[40000] = 0.625; + const combined = combinePieces([{ mesh, piece: mesh.pieces[0] }]); + assert.equal(combined.indices.length, 245760); + assert.equal(combined.level, 6); + assert.deepEqual(snapshot(combined), snapshot(mesh)); +}); + +test("combination preflights aggregate limits and accepts no pieces", () => { + const mesh = ensureDocument(sphere(6)); + const other = hydrate(snapshot(mesh)); + other.pieces[0].id = "other"; + assert.throws( + () => combinePieces([{ mesh }, { mesh: other }]), + /topology limits/, + ); + const empty = combinePieces([]); + assert.equal(empty.positions.length, 0); + assert.deepEqual(empty.pieces, []); +}); + +test("hydrate rejects invalid ranges, cross-piece indices, and coordinates", () => { + const base = snapshot( + ensureDocument(buildMesh([0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 2], 0)), + ); + assert.throws(() => + hydrate({ + ...base, + positions: Float32Array.from([9, ...base.positions.slice(1)]), + }), + ); + assert.throws(() => + hydrate({ ...base, pieces: [{ ...base.pieces[0], vertexCount: 2 }] }), + ); + const left = hydrate(base), + right = hydrate(base); + right.pieces[0].id = "other"; + const two = combinePieces([ + { mesh: left, piece: left.pieces[0] }, + { mesh: right, piece: right.pieces[0] }, + ]); + const bad = snapshot(two); + bad.indices[3] = 0; + assert.throws(() => hydrate(bad), /outside/); +}); + +test("canonical no-op installs preserve redo and every live identity", () => { + const sculpt = new Sculpt(sphere(4)); + sculpt.setColor([0.2, 0.4, 0.6]); + sculpt.restore(); + const identities = { + mesh: sculpt.mesh, + bvh: sculpt.bvh, + dirty: sculpt.dirty, + undo: sculpt.undoStack, + redo: sculpt.redoStack, + }; + const values = { + state: sculpt.state, + nextState: sculpt.nextState, + revision: sculpt.revision, + }; + assert.equal(sculpt.install(hydrate(snapshot(sculpt.mesh))), false); + for (const [name, value] of Object.entries(identities)) + assert.equal( + sculpt[ + name === "undo" ? "undoStack" : name === "redo" ? "redoStack" : name + ], + value, + ); + assert.deepEqual( + { + state: sculpt.state, + nextState: sculpt.nextState, + revision: sculpt.revision, + }, + values, + ); + assert.equal(sculpt.redoStack.length, 1); +}); + +test("structural install and restore failures are atomic", () => { + const sculpt = new Sculpt(sphere(4)); + const changed = hydrate(snapshot(sculpt.mesh)); + changed.positions[0] += 0.01; + const before = () => ({ + mesh: sculpt.mesh, + bvh: sculpt.bvh, + dirty: sculpt.dirty, + undo: sculpt.undoStack, + redo: sculpt.redoStack, + state: sculpt.state, + nextState: sculpt.nextState, + revision: sculpt.revision, + }); + const assertSame = (value) => { + assert.equal(sculpt.mesh, value.mesh); + assert.equal(sculpt.bvh, value.bvh); + assert.equal(sculpt.dirty, value.dirty); + assert.equal(sculpt.undoStack, value.undo); + assert.equal(sculpt.redoStack, value.redo); + assert.equal(sculpt.state, value.state); + assert.equal(sculpt.nextState, value.nextState); + assert.equal(sculpt.revision, value.revision); + }; + const originalBounds = BVH.prototype.bounds; + BVH.prototype.bounds = () => { + throw new Error("injected BVH failure"); + }; + const installState = before(); + try { + assert.throws(() => sculpt.install(changed), /injected BVH failure/); + } finally { + BVH.prototype.bounds = originalBounds; + } + assertSame(installState); + assert.equal(sculpt.install(changed), true); + sculpt.undoStack.at(-1).before.activeId = "missing"; + const restoreState = before(); + assert.throws(() => sculpt.restore(), /active piece/); + assertSame(restoreState); +}); diff --git a/server/web/assets/sculpt/engine.js b/server/web/assets/sculpt/engine.js index f375ed6..e7c3dc8 100644 --- a/server/web/assets/sculpt/engine.js +++ b/server/web/assets/sculpt/engine.js @@ -1,6 +1,13 @@ import { sphere, updateNormals } from "./mesh.js"; import { BVH } from "./bvh.js"; import { point, sub, dot, clamp } from "./math.js"; +import { + ensureDocument, + snapshot, + hydrate, + saveDocument, + loadDocument, +} from "./document.js"; export const pressureValue = (pressure, gain = 1, gamma = 1) => clamp(gain * clamp(pressure, 0, 1) ** gamma, 0, 1); @@ -15,14 +22,53 @@ export const defaults = { pressureRadius: false, }; +const persistedJson = (value) => + JSON.stringify(value, (_key, child) => { + if (!child || Array.isArray(child) || typeof child !== "object") + return child; + return Object.fromEntries( + Object.keys(child) + .sort() + .filter((key) => child[key] !== undefined) + .map((key) => [key, child[key]]), + ); + }); +const canonicalEqual = (a, b) => { + if ( + a.level !== b.level || + a.positions.length !== b.positions.length || + a.indices.length !== b.indices.length || + a.masks.length !== b.masks.length || + a.pieces.length !== b.pieces.length + ) + return false; + for (const name of ["positions", "indices", "masks"]) + for (let i = 0; i < a[name].length; i++) + if (a[name][i] !== b[name][i]) return false; + return persistedJson(a.pieces) === persistedJson(b.pieces); +}; +const snapshotBytes = (value) => + value.positions.byteLength + + value.indices.byteLength + + value.masks.byteLength + + 8 + + new TextEncoder().encode(JSON.stringify(value.pieces)).byteLength; +const allDirty = (mesh) => + new Set( + Array.from({ length: mesh.positions.length / 3 }, (_, vertex) => vertex), + ); + export class Sculpt { constructor(mesh = sphere(), historyCap = 16 * 1024 * 1024) { - this.mesh = mesh; + this.mesh = ensureDocument(mesh); this.bvh = new BVH(mesh); this.undoStack = []; this.redoStack = []; this.historyCap = historyCap; this.revision = 0; + this.state = 0; + this.nextState = 1; + this.activeId = this.mesh.pieces[0]?.id ?? null; this.dirty = new Set(); this.markAll(); this.stroke = null; @@ -33,16 +79,30 @@ export class Sculpt { } get historyBytes() { return [...this.undoStack, ...this.redoStack].reduce( - (sum, e) => - sum + e.ids.byteLength + e.before.byteLength + e.after.byteLength, + (sum, e) => sum + (e.bytes ?? 0), 0, ); } + commit(entry) { + entry.beforeState = this.state; + entry.afterState = this.nextState++; + this.state = entry.afterState; + this.redoStack = []; + this.undoStack.push(entry); + while (this.undoStack.length > 50 || this.historyBytes > this.historyCap) + this.undoStack.shift(); + } begin(settings) { if (this.stroke) throw new Error("A stroke is already active."); + const requested = settings?.activeId; + if (requested && this.mesh.pieces.some((p) => p.id === requested)) + this.activeId = requested; + const piece = this.mesh.pieces.find((p) => p.id === this.activeId); this.stroke = { settings: { ...defaults, ...settings }, + piece, before: this.mesh.positions.slice(), + beforeMasks: this.mesh.masks.slice(), touched: new Set(), grab: null, }; @@ -56,7 +116,14 @@ export class Sculpt { } dab(hit, rawPressure = 1, drag = [0, 0, 0]) { if (!this.stroke || !hit) return; - const { settings: s } = this.stroke; + const { settings: s, piece } = this.stroke; + if ( + !piece || + (hit.face != null && + (hit.face * 3 < piece.indexStart || + hit.face * 3 >= piece.indexStart + piece.indexCount)) + ) + return; const pressure = pressureValue(rawPressure, s.gain, s.gamma); if (pressure === 0) return; const radius = s.radius * (s.pressureRadius ? 0.25 + 0.75 * pressure : 1); @@ -68,12 +135,17 @@ export class Sculpt { affected = new Map(); for (const mirror of s.symmetry ? [1, -1] : [1]) { const center = [ - hit.position[0] * mirror, + piece.symmetryX + (hit.position[0] - piece.symmetryX) * mirror, hit.position[1], hit.position[2], ]; const normal = [hit.normal[0] * mirror, hit.normal[1], hit.normal[2]]; for (const v of this.bvh.within(center, radius)) { + if ( + v < piece.vertexStart || + v >= piece.vertexStart + piece.vertexCount + ) + continue; if (dot(point(n, v), normal) < 0.05) continue; const delta = sub(point(p, v), center), weight = (1 - dot(delta, delta) / (radius * radius)) ** 2; @@ -112,12 +184,34 @@ export class Sculpt { } for (let k = 0; k < 3; k++) displacement[k] += delta[k] * weight; } + if (s.brush === "mask") { + const old = this.mesh.masks[v]; + const direction = s.invert ? -1 : 1; + const next = clamp( + old + direction * s.strength * pressure * envelope, + 0, + 1, + ); + if (Math.fround(next) !== old) { + this.mesh.masks[v] = next; + this.stroke.touched.add(v); + this.dirty.add(v); + } + continue; + } const base = s.brush === "grab" ? point(this.stroke.before, v) : position; let next = base.map( (x, k) => - x + (displacement[k] * s.strength * pressure * envelope) / total, + x + + (displacement[k] * + s.strength * + pressure * + envelope * + (1 - this.mesh.masks[v])) / + total, ); - if (s.symmetry && this.stroke.before[v * 3] === 0) next[0] = 0; + if (s.symmetry && this.stroke.before[v * 3] === piece.symmetryX) + next[0] = piece.symmetryX; // Bounded document coordinates keep camera and project validation meaningful. next = next.map((x) => clamp(x, -8, 8)); if (next.some((x, k) => Math.fround(x) !== position[k])) { @@ -129,52 +223,231 @@ export class Sculpt { // Jacobi update: smoothing reads only the pre-dab mesh, independent of iteration order. for (let i = 0; i < moved.length; i++) p.set(updates.slice(i * 3, i * 3 + 3), moved[i] * 3); - this.changed(moved); + if (s.brush === "mask") { + if (this.stroke.touched.size) this.revision++; + } else this.changed(moved); } end(cancel = false) { if (!this.stroke) return; - const { before, touched } = this.stroke; + const { before, beforeMasks, touched, settings } = this.stroke; this.stroke = null; + const maskStroke = settings.brush === "mask"; const ids = Uint32Array.from( [...touched].filter((v) => - [0, 1, 2].some( - (k) => before[v * 3 + k] !== this.mesh.positions[v * 3 + k], - ), + maskStroke + ? beforeMasks[v] !== this.mesh.masks[v] + : [0, 1, 2].some( + (k) => before[v * 3 + k] !== this.mesh.positions[v * 3 + k], + ), ), ); if (cancel) { - for (const v of ids) - this.mesh.positions.set(before.subarray(v * 3, v * 3 + 3), v * 3); - this.changed(Array.from(ids)); + if (maskStroke) { + for (const v of ids) { + this.mesh.masks[v] = beforeMasks[v]; + this.dirty.add(v); + } + if (ids.length) this.revision++; + } else { + for (const v of ids) + this.mesh.positions.set(before.subarray(v * 3, v * 3 + 3), v * 3); + this.changed(Array.from(ids)); + } return; } if (!ids.length) return; + const width = maskStroke ? 1 : 3; const entry = { + kind: maskStroke ? "mask" : "positions", ids, - before: new Float32Array(ids.length * 3), - after: new Float32Array(ids.length * 3), + before: new Float32Array(ids.length * width), + after: new Float32Array(ids.length * width), }; ids.forEach((v, i) => { - entry.before.set(before.subarray(v * 3, v * 3 + 3), i * 3); - entry.after.set(this.mesh.positions.subarray(v * 3, v * 3 + 3), i * 3); + if (maskStroke) { + entry.before[i] = beforeMasks[v]; + entry.after[i] = this.mesh.masks[v]; + } else { + entry.before.set(before.subarray(v * 3, v * 3 + 3), i * 3); + entry.after.set(this.mesh.positions.subarray(v * 3, v * 3 + 3), i * 3); + } }); - this.redoStack = []; - this.undoStack.push(entry); - while (this.undoStack.length > 50 || this.historyBytes > this.historyCap) - this.undoStack.shift(); + entry.bytes = + entry.ids.byteLength + entry.before.byteLength + entry.after.byteLength; + this.commit(entry); } restore(redo = false) { if (this.stroke) return false; const source = redo ? this.redoStack : this.undoStack, target = redo ? this.undoStack : this.redoStack, - entry = source.pop(); + entry = source[source.length - 1]; if (!entry) return false; - const values = redo ? entry.after : entry.before; - entry.ids.forEach((v, i) => - this.mesh.positions.set(values.subarray(i * 3, i * 3 + 3), v * 3), - ); + const expectedState = redo ? entry.beforeState : entry.afterState; + const nextState = redo ? entry.afterState : entry.beforeState; + if ( + !Number.isInteger(expectedState) || + !Number.isInteger(nextState) || + this.state !== expectedState + ) + throw new Error("Invalid history entry."); + let prepared = null; + if (entry.kind === "structural") { + const value = redo ? entry.after : entry.before; + const mesh = hydrate(value?.mesh); + if ( + value.activeId !== null && + !mesh.pieces.some((piece) => piece.id === value.activeId) + ) + throw new Error("Invalid history active piece."); + prepared = { + mesh, + activeId: value.activeId, + bvh: new BVH(mesh), + dirty: allDirty(mesh), + }; + } else if (entry.kind === "color") { + const piece = this.mesh.pieces.find((p) => p.id === entry.id); + const color = redo ? entry.after : entry.before; + if ( + !piece || + !Array.isArray(color) || + color.length !== 3 || + color.some((x) => !Number.isFinite(x) || x < 0 || x > 1) + ) + throw new Error("Invalid history entry."); + piece.color = [...color]; + for ( + let v = piece.vertexStart; + v < piece.vertexStart + piece.vertexCount; + v++ + ) + this.dirty.add(v); + this.revision++; + } else { + const values = redo ? entry.after : entry.before; + const width = + entry.kind === "mask" ? 1 : entry.kind === "positions" ? 3 : 0; + if ( + !(entry.ids instanceof Uint32Array) || + !(values instanceof Float32Array) || + !width || + values.length !== entry.ids.length * width || + [...entry.ids].some((v) => v >= this.mesh.positions.length / 3) || + [...values].some((x) => !Number.isFinite(x)) || + (entry.kind === "mask" && [...values].some((x) => x < 0 || x > 1)) + ) + throw new Error("Invalid history entry."); + if (entry.kind === "mask") { + entry.ids.forEach((v, i) => { + this.mesh.masks[v] = values[i]; + this.dirty.add(v); + }); + this.revision++; + } else { + entry.ids.forEach((v, i) => + this.mesh.positions.set(values.subarray(i * 3, i * 3 + 3), v * 3), + ); + this.changed(Array.from(entry.ids)); + } + } + if (prepared) { + this.mesh = prepared.mesh; + this.activeId = prepared.activeId; + this.bvh = prepared.bvh; + this.dirty = prepared.dirty; + this.revision++; + } + source.pop(); target.push(entry); - this.changed(Array.from(entry.ids)); + this.state = nextState; + return true; + } + install(candidateMesh, selectedId = this.activeId) { + if (this.stroke) + throw new Error("Cannot install topology during a stroke."); + const next = snapshot(candidateMesh), + current = snapshot(this.mesh); + if (canonicalEqual(current, next)) return false; + const before = { mesh: current, activeId: this.activeId }; + const activeId = next.pieces.some((p) => p.id === selectedId) + ? selectedId + : (next.pieces[0]?.id ?? null); + const mesh = hydrate(next), + bvh = new BVH(mesh), + dirty = allDirty(mesh); + const entry = { + kind: "structural", + before, + after: { mesh: next, activeId }, + bytes: snapshotBytes(before.mesh) + snapshotBytes(next), + }; + this.mesh = mesh; + this.activeId = activeId; + this.bvh = bvh; + this.dirty = dirty; + this.revision++; + this.commit(entry); + return true; + } + setColor(color) { + const piece = this.mesh.pieces.find((p) => p.id === this.activeId); + if ( + !piece || + !Array.isArray(color) || + color.length !== 3 || + color.some((x) => !Number.isFinite(x) || x < 0 || x > 1) + ) + throw new Error("Invalid piece color."); + if (color.every((x, i) => x === piece.color[i])) return false; + const before = [...piece.color]; + piece.color = [...color]; + for ( + let v = piece.vertexStart; + v < piece.vertexStart + piece.vertexCount; + v++ + ) + this.dirty.add(v); + this.revision++; + this.commit({ + kind: "color", + id: piece.id, + before, + after: [...color], + bytes: 24, + }); + return true; + } + editMask(invert = false) { + const piece = this.mesh.pieces.find((p) => p.id === this.activeId); + if (!piece) return false; + const ids = [], + before = [], + after = []; + for ( + let v = piece.vertexStart; + v < piece.vertexStart + piece.vertexCount; + v++ + ) { + const n = invert ? 1 - this.mesh.masks[v] : 0; + if (n !== this.mesh.masks[v]) { + ids.push(v); + before.push(this.mesh.masks[v]); + after.push(n); + this.mesh.masks[v] = n; + this.dirty.add(v); + } + } + if (!ids.length) return false; + const entry = { + kind: "mask", + ids: Uint32Array.from(ids), + before: Float32Array.from(before), + after: Float32Array.from(after), + }; + entry.bytes = + entry.ids.byteLength + entry.before.byteLength + entry.after.byteLength; + this.revision++; + this.commit(entry); return true; } } @@ -227,43 +500,25 @@ export class StrokePath { } } -export function saveProject(mesh) { - return JSON.stringify({ - version: 1, - level: mesh.level, - positions: Array.from(mesh.positions), - }); -} -export function loadProject(text) { - if (text.length > 8 * 1024 * 1024) - throw new Error("Project exceeds the 8 MiB limit."); - const data = JSON.parse(text); - if ( - data?.version !== 1 || - !Number.isInteger(data.level) || - data.level < 4 || - data.level > 6 || - !Array.isArray(data.positions) || - data.positions.length !== (10 * 4 ** data.level + 2) * 3 || - !data.positions.every( - (v) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) <= 8, - ) - ) - throw new Error("Invalid sculpt project."); - const mesh = sphere(data.level); - mesh.positions.set(data.positions); - updateNormals(mesh); - return mesh; -} +export const saveProject = saveDocument; +export const loadProject = loadDocument; export function exportOBJ(mesh) { const lines = ["# Yawn Sculpt — local mesh export"]; for (let i = 0; i < mesh.positions.length; i += 3) lines.push( `v ${mesh.positions[i]} ${mesh.positions[i + 1]} ${mesh.positions[i + 2]}`, ); - for (let i = 0; i < mesh.indices.length; i += 3) - lines.push( - `f ${mesh.indices[i] + 1} ${mesh.indices[i + 1] + 1} ${mesh.indices[i + 2] + 1}`, - ); + const pieces = ensureDocument(mesh).pieces; + for (const piece of pieces) { + lines.push(`g ${piece.name.replace(/\s+/g, "_")}`); + for ( + let i = piece.indexStart; + i < piece.indexStart + piece.indexCount; + i += 3 + ) + lines.push( + `f ${mesh.indices[i] + 1} ${mesh.indices[i + 1] + 1} ${mesh.indices[i + 2] + 1}`, + ); + } return lines.join("\n"); } diff --git a/server/web/assets/sculpt/index.html b/server/web/assets/sculpt/index.html index 09fead2..5c93663 100644 --- a/server/web/assets/sculpt/index.html +++ b/server/web/assets/sculpt/index.html @@ -23,6 +23,16 @@
+ +

Preparing your clay.

A moment to get ready.

@@ -35,6 +45,18 @@