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 <heaust.azure@gmail.com>
This commit is contained in:
@@ -4,7 +4,9 @@ Open `/sculpt` on the Yawn server (`npm run server`); `/view/sculpt` remains a c
|
|||||||
|
|
||||||
## Workflow
|
## 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.
|
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.
|
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
|
## Performance bounds and limitations
|
||||||
|
|
||||||
| New sphere | Vertices | Triangles | History cap |
|
| 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 |
|
| 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.
|
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.
|
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.
|
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.
|
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
|
## 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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
+233
-20
@@ -13,6 +13,7 @@ import { StrokeClient } from "./stroke-client.js";
|
|||||||
import { clamp, clampZoom, cameraBasis } from "./math.js";
|
import { clamp, clampZoom, cameraBasis } from "./math.js";
|
||||||
import { Menus } from "./menus.js";
|
import { Menus } from "./menus.js";
|
||||||
import { Navigation } from "./navigation.js";
|
import { Navigation } from "./navigation.js";
|
||||||
|
import { snapshot } from "./document.js";
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id),
|
const $ = (id) => document.getElementById(id),
|
||||||
viewport = $("viewport");
|
viewport = $("viewport");
|
||||||
@@ -35,9 +36,11 @@ let viewRevision = 0,
|
|||||||
generation = 0,
|
generation = 0,
|
||||||
lastSize = "",
|
lastSize = "",
|
||||||
modified = false,
|
modified = false,
|
||||||
saved = engine.mesh.positions.slice(),
|
saved = engine.state,
|
||||||
fileBusy = false;
|
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;
|
let viewNavigation, cameraAspect, messageTimer;
|
||||||
const input = new Input(viewport, {
|
const input = new Input(viewport, {
|
||||||
engine: () => engine,
|
engine: () => engine,
|
||||||
@@ -48,6 +51,7 @@ const input = new Input(viewport, {
|
|||||||
!!renderer &&
|
!!renderer &&
|
||||||
!initializing &&
|
!initializing &&
|
||||||
!fileBusy &&
|
!fileBusy &&
|
||||||
|
!operation &&
|
||||||
!viewNavigation?.busy &&
|
!viewNavigation?.busy &&
|
||||||
sizeKey() === lastSize,
|
sizeKey() === lastSize,
|
||||||
blocked: () => !!viewNavigation?.busy,
|
blocked: () => !!viewNavigation?.busy,
|
||||||
@@ -72,6 +76,7 @@ viewNavigation = new Navigation($("view-navigation"), viewport, {
|
|||||||
!initializing &&
|
!initializing &&
|
||||||
!fileBusy &&
|
!fileBusy &&
|
||||||
!input.busy &&
|
!input.busy &&
|
||||||
|
!operation &&
|
||||||
sizeKey() === lastSize &&
|
sizeKey() === lastSize &&
|
||||||
!document.querySelector("dialog[open]"),
|
!document.querySelector("dialog[open]"),
|
||||||
start: () => {
|
start: () => {
|
||||||
@@ -82,8 +87,193 @@ viewNavigation = new Navigation($("view-navigation"), viewport, {
|
|||||||
invalidate,
|
invalidate,
|
||||||
});
|
});
|
||||||
function interactionBusy() {
|
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 = () => {
|
$("palm-rejection").onchange = () => {
|
||||||
if ($("palm-rejection").checked && input.active?.type === "touch")
|
if ($("palm-rejection").checked && input.active?.type === "touch")
|
||||||
input.cancel();
|
input.cancel();
|
||||||
@@ -99,7 +289,7 @@ function message(text) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
function checkModified() {
|
function checkModified() {
|
||||||
modified = !engine.mesh.positions.every((v, i) => v === saved[i]);
|
modified = engine.state !== saved;
|
||||||
updateUI();
|
updateUI();
|
||||||
}
|
}
|
||||||
function updateUI() {
|
function updateUI() {
|
||||||
@@ -110,9 +300,18 @@ function updateUI() {
|
|||||||
for (const id of ["save", "export"])
|
for (const id of ["save", "export"])
|
||||||
$(id).disabled = interactionBusy() || fileBusy;
|
$(id).disabled = interactionBusy() || fileBusy;
|
||||||
$("modified").hidden = !modified;
|
$("modified").hidden = !modified;
|
||||||
|
updatePieces(busy);
|
||||||
if (pendingResize && !resizeTimer && !interactionBusy() && !initializing) {
|
if (pendingResize && !resizeTimer && !interactionBusy() && !initializing) {
|
||||||
|
const token = generation;
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (pendingResize && !resizeTimer) recreate();
|
if (
|
||||||
|
token === generation &&
|
||||||
|
pendingResize &&
|
||||||
|
!resizeTimer &&
|
||||||
|
!interactionBusy() &&
|
||||||
|
!initializing
|
||||||
|
)
|
||||||
|
recreate();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,6 +344,10 @@ function schedule() {
|
|||||||
if (!frameRequest) frameRequest = requestAnimationFrame(tick);
|
if (!frameRequest) frameRequest = requestAnimationFrame(tick);
|
||||||
}
|
}
|
||||||
function fail(error) {
|
function fail(error) {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = 0;
|
||||||
|
pendingResize = false;
|
||||||
|
settleOperation(operation);
|
||||||
viewNavigation?.cancel();
|
viewNavigation?.cancel();
|
||||||
generation++;
|
generation++;
|
||||||
renderer?.dispose();
|
renderer?.dispose();
|
||||||
@@ -242,13 +445,9 @@ async function recreate() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderer = next;
|
renderer = next;
|
||||||
next.processor = new StrokeClient(
|
next.processor = new StrokeClient(next.sample, engine.mesh, (error) => {
|
||||||
next.sample,
|
if (token === generation) fail(error);
|
||||||
engine.mesh.level,
|
});
|
||||||
(error) => {
|
|
||||||
if (token === generation) fail(error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await next.processor.ready;
|
await next.processor.ready;
|
||||||
if (token !== generation) {
|
if (token !== generation) {
|
||||||
next.dispose();
|
next.dispose();
|
||||||
@@ -269,6 +468,12 @@ async function recreate() {
|
|||||||
}
|
}
|
||||||
function frameModel() {
|
function frameModel() {
|
||||||
cameraAspect = viewport.clientWidth / viewport.clientHeight;
|
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;
|
const bounds = engine.bvh.root;
|
||||||
camera.target = bounds.min.map((v, k) => (v + bounds.max[k]) / 2);
|
camera.target = bounds.min.map((v, k) => (v + bounds.max[k]) / 2);
|
||||||
const { right, up } = cameraBasis(camera.yaw, camera.pitch);
|
const { right, up } = cameraBasis(camera.yaw, camera.pitch);
|
||||||
@@ -283,8 +488,8 @@ function frameModel() {
|
|||||||
invalidate();
|
invalidate();
|
||||||
}
|
}
|
||||||
function replace(mesh, name) {
|
function replace(mesh, name) {
|
||||||
engine = new Sculpt(mesh, 2 ** (mesh.level - 4) * 8 * 1048576);
|
engine = new Sculpt(mesh);
|
||||||
saved = mesh.positions.slice();
|
saved = engine.state;
|
||||||
modified = false;
|
modified = false;
|
||||||
$("document-name").textContent = name;
|
$("document-name").textContent = name;
|
||||||
camera.yaw = 0;
|
camera.yaw = 0;
|
||||||
@@ -304,7 +509,7 @@ function download(text, name, type) {
|
|||||||
$("save").onclick = () => {
|
$("save").onclick = () => {
|
||||||
if (interactionBusy() || fileBusy) return;
|
if (interactionBusy() || fileBusy) return;
|
||||||
download(saveProject(engine.mesh), "sculpt.yawn-sculpt", "application/json");
|
download(saveProject(engine.mesh), "sculpt.yawn-sculpt", "application/json");
|
||||||
saved = engine.mesh.positions.slice();
|
saved = engine.state;
|
||||||
modified = false;
|
modified = false;
|
||||||
updateUI();
|
updateUI();
|
||||||
message("Project downloaded");
|
message("Project downloaded");
|
||||||
@@ -359,15 +564,19 @@ $("new-dialog").addEventListener("close", () => {
|
|||||||
});
|
});
|
||||||
$("help").onclick = () => $("help-dialog").showModal();
|
$("help").onclick = () => $("help-dialog").showModal();
|
||||||
$("undo").onclick = () => {
|
$("undo").onclick = () => {
|
||||||
if (!interactionBusy() && engine.restore()) {
|
const mesh = engine.mesh;
|
||||||
|
if (!interactionBusy() && !initializing && engine.restore()) {
|
||||||
checkModified();
|
checkModified();
|
||||||
|
if (engine.mesh !== mesh) recreate();
|
||||||
invalidate();
|
invalidate();
|
||||||
message("Undo");
|
message("Undo");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
$("redo").onclick = () => {
|
$("redo").onclick = () => {
|
||||||
if (!interactionBusy() && engine.restore(true)) {
|
const mesh = engine.mesh;
|
||||||
|
if (!interactionBusy() && !initializing && engine.restore(true)) {
|
||||||
checkModified();
|
checkModified();
|
||||||
|
if (engine.mesh !== mesh) recreate();
|
||||||
invalidate();
|
invalidate();
|
||||||
message("Redo");
|
message("Redo");
|
||||||
}
|
}
|
||||||
@@ -384,7 +593,8 @@ function chooseBrush(name) {
|
|||||||
b.setAttribute("aria-pressed", String(b.dataset.brush === name)),
|
b.setAttribute("aria-pressed", String(b.dataset.brush === name)),
|
||||||
);
|
);
|
||||||
$("brush-title").textContent = name[0].toUpperCase() + name.slice(1);
|
$("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
|
document
|
||||||
.querySelectorAll("[data-brush]")
|
.querySelectorAll("[data-brush]")
|
||||||
@@ -433,7 +643,9 @@ $("reset-pressure").onclick = () => {
|
|||||||
document.querySelectorAll("[data-color]").forEach(
|
document.querySelectorAll("[data-color]").forEach(
|
||||||
(b) =>
|
(b) =>
|
||||||
(b.onclick = () => {
|
(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
|
document
|
||||||
.querySelectorAll("[data-color]")
|
.querySelectorAll("[data-color]")
|
||||||
.forEach((x) => x.setAttribute("aria-pressed", String(x === b)));
|
.forEach((x) => x.setAttribute("aria-pressed", String(x === b)));
|
||||||
@@ -470,6 +682,7 @@ function resized() {
|
|||||||
recreate();
|
recreate();
|
||||||
}
|
}
|
||||||
}, 200);
|
}, 200);
|
||||||
|
if (operation && operation.size !== sizeKey()) cancelOperation();
|
||||||
const unchanged = input.checkViewport();
|
const unchanged = input.checkViewport();
|
||||||
if (sizeKey() !== lastSize) {
|
if (sizeKey() !== lastSize) {
|
||||||
cursor(null);
|
cursor(null);
|
||||||
@@ -539,7 +752,7 @@ window.addEventListener("keydown", (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mod || e.altKey) 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 === "f") $("frame").click();
|
||||||
else if (key === "?") $("help").click();
|
else if (key === "?") $("help").click();
|
||||||
else if (key === "[" || key === "]") {
|
else if (key === "[" || key === "]") {
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ export async function check() {
|
|||||||
"Brush popover overlaps rail or has oversized width",
|
"Brush popover overlaps rail or has oversized width",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
document.querySelectorAll(".tool-rail [data-brush] svg").length === 5,
|
document.querySelectorAll(".tool-rail [data-brush] svg").length === 6,
|
||||||
"Brush rail must have five icons",
|
"Brush rail must include five sculpt brushes and Mask",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
$("radius").style.getPropertyValue("--slider-value").startsWith("32.14"),
|
$("radius").style.getPropertyValue("--slider-value").startsWith("32.14"),
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ export class BVH {
|
|||||||
this.bounds(node);
|
this.bounds(node);
|
||||||
return 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.marks = new Uint32Array(mesh.positions.length / 3);
|
||||||
this.generation = 0;
|
this.generation = 0;
|
||||||
}
|
}
|
||||||
@@ -86,6 +88,7 @@ export class BVH {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
refit(faces) {
|
refit(faces) {
|
||||||
|
if (!this.root) return;
|
||||||
const dirty = new Set(faces.map((f) => this.leaves[f]));
|
const dirty = new Set(faces.map((f) => this.leaves[f]));
|
||||||
for (const leaf of dirty) this.bounds(leaf);
|
for (const leaf of dirty) this.bounds(leaf);
|
||||||
let parents = new Set([...dirty].map((n) => n.parent).filter(Boolean));
|
let parents = new Set([...dirty].map((n) => n.parent).filter(Boolean));
|
||||||
@@ -99,6 +102,7 @@ export class BVH {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
raycast(origin, direction) {
|
raycast(origin, direction) {
|
||||||
|
if (!this.root) return null;
|
||||||
let best = null;
|
let best = null;
|
||||||
const entry = (n) => {
|
const entry = (n) => {
|
||||||
let near = 0,
|
let near = 0,
|
||||||
@@ -135,6 +139,7 @@ export class BVH {
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
within(center, radius) {
|
within(center, radius) {
|
||||||
|
if (!this.root) return [];
|
||||||
const result = [],
|
const result = [],
|
||||||
gen = ++this.generation,
|
gen = ++this.generation,
|
||||||
r2 = radius * radius;
|
r2 = radius * radius;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import { sphere, updateNormals } from "./mesh.js";
|
import { sphere, updateNormals } from "./mesh.js";
|
||||||
import { BVH } from "./bvh.js";
|
import { BVH } from "./bvh.js";
|
||||||
import { point, sub, dot, clamp } from "./math.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) =>
|
export const pressureValue = (pressure, gain = 1, gamma = 1) =>
|
||||||
clamp(gain * clamp(pressure, 0, 1) ** gamma, 0, 1);
|
clamp(gain * clamp(pressure, 0, 1) ** gamma, 0, 1);
|
||||||
@@ -15,14 +22,53 @@ export const defaults = {
|
|||||||
pressureRadius: false,
|
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 {
|
export class Sculpt {
|
||||||
constructor(mesh = sphere(), historyCap = 16 * 1024 * 1024) {
|
constructor(mesh = sphere(), historyCap = 16 * 1024 * 1024) {
|
||||||
this.mesh = mesh;
|
this.mesh = ensureDocument(mesh);
|
||||||
this.bvh = new BVH(mesh);
|
this.bvh = new BVH(mesh);
|
||||||
this.undoStack = [];
|
this.undoStack = [];
|
||||||
this.redoStack = [];
|
this.redoStack = [];
|
||||||
this.historyCap = historyCap;
|
this.historyCap = historyCap;
|
||||||
this.revision = 0;
|
this.revision = 0;
|
||||||
|
this.state = 0;
|
||||||
|
this.nextState = 1;
|
||||||
|
this.activeId = this.mesh.pieces[0]?.id ?? null;
|
||||||
this.dirty = new Set();
|
this.dirty = new Set();
|
||||||
this.markAll();
|
this.markAll();
|
||||||
this.stroke = null;
|
this.stroke = null;
|
||||||
@@ -33,16 +79,30 @@ export class Sculpt {
|
|||||||
}
|
}
|
||||||
get historyBytes() {
|
get historyBytes() {
|
||||||
return [...this.undoStack, ...this.redoStack].reduce(
|
return [...this.undoStack, ...this.redoStack].reduce(
|
||||||
(sum, e) =>
|
(sum, e) => sum + (e.bytes ?? 0),
|
||||||
sum + e.ids.byteLength + e.before.byteLength + e.after.byteLength,
|
|
||||||
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) {
|
begin(settings) {
|
||||||
if (this.stroke) throw new Error("A stroke is already active.");
|
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 = {
|
this.stroke = {
|
||||||
settings: { ...defaults, ...settings },
|
settings: { ...defaults, ...settings },
|
||||||
|
piece,
|
||||||
before: this.mesh.positions.slice(),
|
before: this.mesh.positions.slice(),
|
||||||
|
beforeMasks: this.mesh.masks.slice(),
|
||||||
touched: new Set(),
|
touched: new Set(),
|
||||||
grab: null,
|
grab: null,
|
||||||
};
|
};
|
||||||
@@ -56,7 +116,14 @@ export class Sculpt {
|
|||||||
}
|
}
|
||||||
dab(hit, rawPressure = 1, drag = [0, 0, 0]) {
|
dab(hit, rawPressure = 1, drag = [0, 0, 0]) {
|
||||||
if (!this.stroke || !hit) return;
|
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);
|
const pressure = pressureValue(rawPressure, s.gain, s.gamma);
|
||||||
if (pressure === 0) return;
|
if (pressure === 0) return;
|
||||||
const radius = s.radius * (s.pressureRadius ? 0.25 + 0.75 * pressure : 1);
|
const radius = s.radius * (s.pressureRadius ? 0.25 + 0.75 * pressure : 1);
|
||||||
@@ -68,12 +135,17 @@ export class Sculpt {
|
|||||||
affected = new Map();
|
affected = new Map();
|
||||||
for (const mirror of s.symmetry ? [1, -1] : [1]) {
|
for (const mirror of s.symmetry ? [1, -1] : [1]) {
|
||||||
const center = [
|
const center = [
|
||||||
hit.position[0] * mirror,
|
piece.symmetryX + (hit.position[0] - piece.symmetryX) * mirror,
|
||||||
hit.position[1],
|
hit.position[1],
|
||||||
hit.position[2],
|
hit.position[2],
|
||||||
];
|
];
|
||||||
const normal = [hit.normal[0] * mirror, hit.normal[1], hit.normal[2]];
|
const normal = [hit.normal[0] * mirror, hit.normal[1], hit.normal[2]];
|
||||||
for (const v of this.bvh.within(center, radius)) {
|
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;
|
if (dot(point(n, v), normal) < 0.05) continue;
|
||||||
const delta = sub(point(p, v), center),
|
const delta = sub(point(p, v), center),
|
||||||
weight = (1 - dot(delta, delta) / (radius * radius)) ** 2;
|
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;
|
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;
|
const base = s.brush === "grab" ? point(this.stroke.before, v) : position;
|
||||||
let next = base.map(
|
let next = base.map(
|
||||||
(x, k) =>
|
(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.
|
// Bounded document coordinates keep camera and project validation meaningful.
|
||||||
next = next.map((x) => clamp(x, -8, 8));
|
next = next.map((x) => clamp(x, -8, 8));
|
||||||
if (next.some((x, k) => Math.fround(x) !== position[k])) {
|
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.
|
// Jacobi update: smoothing reads only the pre-dab mesh, independent of iteration order.
|
||||||
for (let i = 0; i < moved.length; i++)
|
for (let i = 0; i < moved.length; i++)
|
||||||
p.set(updates.slice(i * 3, i * 3 + 3), moved[i] * 3);
|
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) {
|
end(cancel = false) {
|
||||||
if (!this.stroke) return;
|
if (!this.stroke) return;
|
||||||
const { before, touched } = this.stroke;
|
const { before, beforeMasks, touched, settings } = this.stroke;
|
||||||
this.stroke = null;
|
this.stroke = null;
|
||||||
|
const maskStroke = settings.brush === "mask";
|
||||||
const ids = Uint32Array.from(
|
const ids = Uint32Array.from(
|
||||||
[...touched].filter((v) =>
|
[...touched].filter((v) =>
|
||||||
[0, 1, 2].some(
|
maskStroke
|
||||||
(k) => before[v * 3 + k] !== this.mesh.positions[v * 3 + k],
|
? beforeMasks[v] !== this.mesh.masks[v]
|
||||||
),
|
: [0, 1, 2].some(
|
||||||
|
(k) => before[v * 3 + k] !== this.mesh.positions[v * 3 + k],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (cancel) {
|
if (cancel) {
|
||||||
for (const v of ids)
|
if (maskStroke) {
|
||||||
this.mesh.positions.set(before.subarray(v * 3, v * 3 + 3), v * 3);
|
for (const v of ids) {
|
||||||
this.changed(Array.from(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;
|
return;
|
||||||
}
|
}
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
|
const width = maskStroke ? 1 : 3;
|
||||||
const entry = {
|
const entry = {
|
||||||
|
kind: maskStroke ? "mask" : "positions",
|
||||||
ids,
|
ids,
|
||||||
before: new Float32Array(ids.length * 3),
|
before: new Float32Array(ids.length * width),
|
||||||
after: new Float32Array(ids.length * 3),
|
after: new Float32Array(ids.length * width),
|
||||||
};
|
};
|
||||||
ids.forEach((v, i) => {
|
ids.forEach((v, i) => {
|
||||||
entry.before.set(before.subarray(v * 3, v * 3 + 3), i * 3);
|
if (maskStroke) {
|
||||||
entry.after.set(this.mesh.positions.subarray(v * 3, v * 3 + 3), i * 3);
|
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 = [];
|
entry.bytes =
|
||||||
this.undoStack.push(entry);
|
entry.ids.byteLength + entry.before.byteLength + entry.after.byteLength;
|
||||||
while (this.undoStack.length > 50 || this.historyBytes > this.historyCap)
|
this.commit(entry);
|
||||||
this.undoStack.shift();
|
|
||||||
}
|
}
|
||||||
restore(redo = false) {
|
restore(redo = false) {
|
||||||
if (this.stroke) return false;
|
if (this.stroke) return false;
|
||||||
const source = redo ? this.redoStack : this.undoStack,
|
const source = redo ? this.redoStack : this.undoStack,
|
||||||
target = redo ? this.undoStack : this.redoStack,
|
target = redo ? this.undoStack : this.redoStack,
|
||||||
entry = source.pop();
|
entry = source[source.length - 1];
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
const values = redo ? entry.after : entry.before;
|
const expectedState = redo ? entry.beforeState : entry.afterState;
|
||||||
entry.ids.forEach((v, i) =>
|
const nextState = redo ? entry.afterState : entry.beforeState;
|
||||||
this.mesh.positions.set(values.subarray(i * 3, i * 3 + 3), v * 3),
|
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);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,43 +500,25 @@ export class StrokePath {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveProject(mesh) {
|
export const saveProject = saveDocument;
|
||||||
return JSON.stringify({
|
export const loadProject = loadDocument;
|
||||||
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 function exportOBJ(mesh) {
|
export function exportOBJ(mesh) {
|
||||||
const lines = ["# Yawn Sculpt — local mesh export"];
|
const lines = ["# Yawn Sculpt — local mesh export"];
|
||||||
for (let i = 0; i < mesh.positions.length; i += 3)
|
for (let i = 0; i < mesh.positions.length; i += 3)
|
||||||
lines.push(
|
lines.push(
|
||||||
`v ${mesh.positions[i]} ${mesh.positions[i + 1]} ${mesh.positions[i + 2]}`,
|
`v ${mesh.positions[i]} ${mesh.positions[i + 1]} ${mesh.positions[i + 2]}`,
|
||||||
);
|
);
|
||||||
for (let i = 0; i < mesh.indices.length; i += 3)
|
const pieces = ensureDocument(mesh).pieces;
|
||||||
lines.push(
|
for (const piece of pieces) {
|
||||||
`f ${mesh.indices[i] + 1} ${mesh.indices[i + 1] + 1} ${mesh.indices[i + 2] + 1}`,
|
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");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,16 @@
|
|||||||
<main id="viewport" tabindex="0" aria-label="Sculpting canvas">
|
<main id="viewport" tabindex="0" aria-label="Sculpting canvas">
|
||||||
<canvas aria-label="Your sculpt"></canvas>
|
<canvas aria-label="Your sculpt"></canvas>
|
||||||
<div id="cursor" hidden aria-hidden="true"><i></i></div>
|
<div id="cursor" hidden aria-hidden="true"><i></i></div>
|
||||||
|
<div id="empty-clay" class="canvas-message" hidden>
|
||||||
|
<h1>A fresh canvas.</h1>
|
||||||
|
<p>Add a piece of clay to begin.</p>
|
||||||
|
<button id="add-empty" class="btn">Add clay</button>
|
||||||
|
</div>
|
||||||
|
<div id="clay-progress" class="canvas-message" role="status" hidden>
|
||||||
|
<h1 id="operation-label">Updating clay…</h1>
|
||||||
|
<p>Your other pieces stay unchanged.</p>
|
||||||
|
<button id="cancel-operation" class="btn">Cancel</button>
|
||||||
|
</div>
|
||||||
<div id="loading" class="canvas-message" role="status">
|
<div id="loading" class="canvas-message" role="status">
|
||||||
<h1>Preparing your clay.</h1>
|
<h1>Preparing your clay.</h1>
|
||||||
<p>A moment to get ready.</p>
|
<p>A moment to get ready.</p>
|
||||||
@@ -35,6 +45,18 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<nav class="tool-rail" aria-label="Sculpt tools">
|
<nav class="tool-rail" aria-label="Sculpt tools">
|
||||||
|
<button
|
||||||
|
id="pieces-toggle"
|
||||||
|
class="btn icon-button launcher"
|
||||||
|
aria-label="Clay pieces"
|
||||||
|
title="Clay pieces"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="pieces-panel"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m12 3 9 5-9 5-9-5zm-9 9 9 5 9-5M3 16l9 5 9-5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<div class="brushes" role="group" aria-label="Brush">
|
<div class="brushes" role="group" aria-label="Brush">
|
||||||
<button
|
<button
|
||||||
class="btn icon-button"
|
class="btn icon-button"
|
||||||
@@ -101,6 +123,18 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn icon-button"
|
||||||
|
data-brush="mask"
|
||||||
|
aria-pressed="false"
|
||||||
|
aria-label="Mask"
|
||||||
|
title="Mask · 6"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 5h16v8c0 5-8 8-8 8s-8-3-8-8zM12 5v16" />
|
||||||
|
<path d="M6 8h4M6 11h4M7 14h3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="rail-group">
|
<div class="rail-group">
|
||||||
<button
|
<button
|
||||||
@@ -253,12 +287,107 @@
|
|||||||
value="0.45"
|
value="0.45"
|
||||||
/>
|
/>
|
||||||
<label class="toggle-row" for="invert"
|
<label class="toggle-row" for="invert"
|
||||||
><span>Subtract</span
|
><span id="invert-label">Subtract</span
|
||||||
><input class="input" role="switch" id="invert" type="checkbox"
|
><input class="input" role="switch" id="invert" type="checkbox"
|
||||||
/></label>
|
/></label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="pieces-panel"
|
||||||
|
class="panel pieces-panel"
|
||||||
|
aria-label="Clay pieces"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>Clay</h2>
|
||||||
|
<button class="btn" data-close aria-label="Close clay pieces">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="piece-list" aria-label="Select a piece"></div>
|
||||||
|
<div class="piece-material">
|
||||||
|
<label for="piece-color">Color</label
|
||||||
|
><input id="piece-color" type="color" aria-label="Piece color" />
|
||||||
|
<button
|
||||||
|
id="delete-piece"
|
||||||
|
class="btn"
|
||||||
|
aria-label="Delete selected piece"
|
||||||
|
title="Delete selected piece"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="preset-grid" aria-label="Add clay preset">
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-preset="sphere"
|
||||||
|
aria-label="Add sphere"
|
||||||
|
title="Add sphere"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<ellipse cx="12" cy="12" rx="4" ry="9" />
|
||||||
|
<path d="M3 12h18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-preset="cube"
|
||||||
|
aria-label="Add cube"
|
||||||
|
title="Add cube"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m12 2 9 5v10l-9 5-9-5V7zm0 10 9-5M12 12 3 7m9 5v10" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-preset="cylinder"
|
||||||
|
aria-label="Add cylinder"
|
||||||
|
title="Add cylinder"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<ellipse cx="12" cy="5" rx="8" ry="3" />
|
||||||
|
<path d="M4 5v14c0 4 16 4 16 0V5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-preset="cone"
|
||||||
|
aria-label="Add cone"
|
||||||
|
title="Add cone"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m4 18 8-16 8 16" />
|
||||||
|
<ellipse cx="12" cy="18" rx="8" ry="3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-preset="torus"
|
||||||
|
aria-label="Add torus"
|
||||||
|
title="Add torus"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<ellipse cx="12" cy="12" rx="10" ry="7" />
|
||||||
|
<ellipse cx="12" cy="10" rx="4" ry="2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<details class="disclosure">
|
||||||
|
<summary>Detail & mask</summary>
|
||||||
|
<div class="piece-actions">
|
||||||
|
<button id="subdivide" class="btn">Subdivide</button
|
||||||
|
><button id="decimate" class="btn">Decimate</button>
|
||||||
|
<button id="clear-mask" class="btn">Clear mask</button
|
||||||
|
><button id="invert-mask" class="btn">Invert mask</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
Subdivide adds resolution. Decimate reduces detail and preserves
|
||||||
|
masked areas.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
<span id="modified" class="modified-label" hidden>Unsaved</span>
|
<span id="modified" class="modified-label" hidden>Unsaved</span>
|
||||||
<aside
|
<aside
|
||||||
id="inspector"
|
id="inspector"
|
||||||
@@ -456,6 +585,20 @@
|
|||||||
Palm rejection is on. Turn it off in Settings to draw with a
|
Palm rejection is on. Turn it off in Settings to draw with a
|
||||||
finger or move with two fingers.
|
finger or move with two fingers.
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt>Build with clay</dt>
|
||||||
|
<dd>
|
||||||
|
Open Clay at the top of the toolbar. Add a preset, select its row,
|
||||||
|
then change its color or delete it. Each new piece gets a pastel
|
||||||
|
color.
|
||||||
|
</dd>
|
||||||
|
<dt>Protect and refine</dt>
|
||||||
|
<dd>
|
||||||
|
Mask paints protected areas; use Erase mask in brush settings to
|
||||||
|
remove it. Clay → Detail & mask contains Clear, Invert,
|
||||||
|
Subdivide and Decimate. Subdivide adds resolution without
|
||||||
|
smoothing. Decimate preserves masked areas and may reduce less
|
||||||
|
than half.
|
||||||
|
</dd>
|
||||||
<dt>Keep your work</dt>
|
<dt>Keep your work</dt>
|
||||||
<dd>
|
<dd>
|
||||||
Download your project before leaving. Open it here to keep
|
Download your project before leaving. Open it here to keep
|
||||||
|
|||||||
@@ -101,12 +101,20 @@ export class Input {
|
|||||||
}
|
}
|
||||||
pick(sample, rect) {
|
pick(sample, rect) {
|
||||||
const b = cameraBasis(this.host.camera.yaw, this.host.camera.pitch);
|
const b = cameraBasis(this.host.camera.yaw, this.host.camera.pitch);
|
||||||
return this.host
|
const hit = this.host
|
||||||
.engine()
|
.engine()
|
||||||
.bvh.raycast(
|
.bvh.raycast(
|
||||||
add(this.world(sample, rect), mul(b.forward, 24)),
|
add(this.world(sample, rect), mul(b.forward, 24)),
|
||||||
mul(b.forward, -1),
|
mul(b.forward, -1),
|
||||||
);
|
);
|
||||||
|
if (!hit) return null;
|
||||||
|
const engine = this.host.engine();
|
||||||
|
const piece = engine.mesh.pieces?.find((p) => p.id === engine.activeId);
|
||||||
|
return piece &&
|
||||||
|
hit.face * 3 >= piece.indexStart &&
|
||||||
|
hit.face * 3 < piece.indexStart + piece.indexCount
|
||||||
|
? hit
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
down(e) {
|
down(e) {
|
||||||
if (this.rejectsTouch(e)) return;
|
if (this.rejectsTouch(e)) return;
|
||||||
@@ -176,7 +184,10 @@ export class Input {
|
|||||||
canvas,
|
canvas,
|
||||||
};
|
};
|
||||||
if (this.active.mode === "sculpt") {
|
if (this.active.mode === "sculpt") {
|
||||||
const settings = { ...this.host.settings };
|
const settings = {
|
||||||
|
...this.host.settings,
|
||||||
|
activeId: this.host.engine().activeId,
|
||||||
|
};
|
||||||
if (e.shiftKey) settings.brush = "smooth";
|
if (e.shiftKey) settings.brush = "smooth";
|
||||||
if (e.altKey) settings.invert = !settings.invert;
|
if (e.altKey) settings.invert = !settings.invert;
|
||||||
const engine = this.host.engine(),
|
const engine = this.host.engine(),
|
||||||
@@ -193,6 +204,8 @@ export class Input {
|
|||||||
.begin(
|
.begin(
|
||||||
{
|
{
|
||||||
positions: engine.mesh.positions.slice(),
|
positions: engine.mesh.positions.slice(),
|
||||||
|
masks: engine.mesh.masks.slice(),
|
||||||
|
activeId: engine.activeId,
|
||||||
settings,
|
settings,
|
||||||
camera: structuredClone(this.host.camera),
|
camera: structuredClone(this.host.camera),
|
||||||
rect: {
|
rect: {
|
||||||
@@ -208,7 +221,17 @@ export class Input {
|
|||||||
this.checkViewport();
|
this.checkViewport();
|
||||||
if (this.transaction !== transaction || transaction.canceling)
|
if (this.transaction !== transaction || transaction.canceling)
|
||||||
return;
|
return;
|
||||||
const { vertices, rows, touched } = packet;
|
const { vertices, masks, rows, touched } = packet;
|
||||||
|
if (packet.type === "mask") {
|
||||||
|
rows.forEach((v, i) => {
|
||||||
|
engine.mesh.masks[v] = masks[i];
|
||||||
|
engine.dirty.add(v);
|
||||||
|
});
|
||||||
|
for (const v of touched) engine.stroke.touched.add(v);
|
||||||
|
engine.revision++;
|
||||||
|
this.host.invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (let row = 0; row < rows.length; row++) {
|
for (let row = 0; row < rows.length; row++) {
|
||||||
const v = rows[row],
|
const v = rows[row],
|
||||||
i = row * 8,
|
i = row * 8,
|
||||||
@@ -357,7 +380,8 @@ export class Input {
|
|||||||
this.host.invalidate();
|
this.host.invalidate();
|
||||||
} else {
|
} else {
|
||||||
// Repair main's face-normal cache and picking BVH only at the boundary.
|
// Repair main's face-normal cache and picking BVH only at the boundary.
|
||||||
engine.changed([...engine.stroke.touched]);
|
if (engine.stroke.settings.brush !== "mask")
|
||||||
|
engine.changed([...engine.stroke.touched]);
|
||||||
engine.end();
|
engine.end();
|
||||||
this.host.committed();
|
this.host.committed();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export class Menus {
|
|||||||
constructor(canOpen, ignoreOutsidePointer) {
|
constructor(canOpen, ignoreOutsidePointer) {
|
||||||
this.canOpen = canOpen;
|
this.canOpen = canOpen;
|
||||||
this.items = [
|
this.items = [
|
||||||
|
["pieces-toggle", "pieces-panel"],
|
||||||
["tools-toggle", "tools-panel"],
|
["tools-toggle", "tools-panel"],
|
||||||
["settings-toggle", "inspector"],
|
["settings-toggle", "inspector"],
|
||||||
].map(([button, panel]) => ({
|
].map(([button, panel]) => ({
|
||||||
|
|||||||
@@ -1,5 +1,51 @@
|
|||||||
import { unit } from "./math.js";
|
import { unit } from "./math.js";
|
||||||
|
|
||||||
|
export function buildMesh(positions, indices, level = 5, masks) {
|
||||||
|
positions =
|
||||||
|
positions instanceof Float32Array
|
||||||
|
? positions
|
||||||
|
: new Float32Array(positions.flat ? positions.flat(Infinity) : positions);
|
||||||
|
indices =
|
||||||
|
indices instanceof Uint32Array
|
||||||
|
? indices
|
||||||
|
: new Uint32Array(indices.flat ? indices.flat(Infinity) : indices);
|
||||||
|
if (positions.length % 3 || indices.length % 3)
|
||||||
|
throw new Error(
|
||||||
|
"Mesh positions and indices must contain complete triples.",
|
||||||
|
);
|
||||||
|
const vertexCount = positions.length / 3;
|
||||||
|
const incident = Array.from({ length: vertexCount }, () => []);
|
||||||
|
const neighbors = Array.from({ length: vertexCount }, () => new Set());
|
||||||
|
for (let f = 0; f < indices.length / 3; f++) {
|
||||||
|
const face = indices.subarray(f * 3, f * 3 + 3);
|
||||||
|
for (const v of face) {
|
||||||
|
if (v >= vertexCount) throw new Error("Mesh index is out of range.");
|
||||||
|
incident[v].push(f);
|
||||||
|
for (const other of face) if (other !== v) neighbors[v].add(other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (masks && masks.length !== vertexCount)
|
||||||
|
throw new Error("Mask count must match vertex count.");
|
||||||
|
const mesh = {
|
||||||
|
level,
|
||||||
|
positions,
|
||||||
|
indices,
|
||||||
|
masks:
|
||||||
|
masks instanceof Float32Array
|
||||||
|
? masks
|
||||||
|
: new Float32Array(masks || vertexCount),
|
||||||
|
normals: new Float32Array(positions.length),
|
||||||
|
faceNormals: new Float32Array(indices.length),
|
||||||
|
incident: incident.map((a) => Uint32Array.from(a)),
|
||||||
|
neighbors: neighbors.map((a) => Uint32Array.from(a)),
|
||||||
|
faceMarks: new Uint32Array(indices.length / 3),
|
||||||
|
vertexMarks: new Uint32Array(vertexCount),
|
||||||
|
generation: 0,
|
||||||
|
};
|
||||||
|
updateNormals(mesh);
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
|
||||||
export function sphere(level = 5) {
|
export function sphere(level = 5) {
|
||||||
if (!Number.isInteger(level) || level < 4 || level > 6)
|
if (!Number.isInteger(level) || level < 4 || level > 6)
|
||||||
throw new Error("Choose a supported resolution.");
|
throw new Error("Choose a supported resolution.");
|
||||||
@@ -64,28 +110,7 @@ export function sphere(level = 5) {
|
|||||||
}
|
}
|
||||||
const positions = new Float32Array(vertices.flat());
|
const positions = new Float32Array(vertices.flat());
|
||||||
const indices = new Uint32Array(faces.flat());
|
const indices = new Uint32Array(faces.flat());
|
||||||
const incident = Array.from({ length: vertices.length }, () => []);
|
return buildMesh(positions, indices, level);
|
||||||
const neighbors = Array.from({ length: vertices.length }, () => new Set());
|
|
||||||
faces.forEach((face, f) =>
|
|
||||||
face.forEach((v) => {
|
|
||||||
incident[v].push(f);
|
|
||||||
for (const other of face) if (other !== v) neighbors[v].add(other);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const mesh = {
|
|
||||||
level,
|
|
||||||
positions,
|
|
||||||
indices,
|
|
||||||
normals: new Float32Array(positions.length),
|
|
||||||
faceNormals: new Float32Array(indices.length),
|
|
||||||
incident: incident.map((a) => Uint32Array.from(a)),
|
|
||||||
neighbors: neighbors.map((a) => Uint32Array.from(a)),
|
|
||||||
};
|
|
||||||
mesh.faceMarks = new Uint32Array(faces.length);
|
|
||||||
mesh.vertexMarks = new Uint32Array(vertices.length);
|
|
||||||
mesh.generation = 0;
|
|
||||||
updateNormals(mesh);
|
|
||||||
return mesh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include the unchanged incident faces when recomputing a boundary vertex normal.
|
// Include the unchanged incident faces when recomputing a boundary vertex normal.
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { preset, subdivide, decimate } from "./topology.js";
|
||||||
|
import {
|
||||||
|
hydrate,
|
||||||
|
snapshot,
|
||||||
|
ensureDocument,
|
||||||
|
splitPiece,
|
||||||
|
combinePieces,
|
||||||
|
} from "./document.js";
|
||||||
|
|
||||||
|
function bounds(mesh, piece) {
|
||||||
|
const min = [Infinity, Infinity, Infinity],
|
||||||
|
max = [-Infinity, -Infinity, -Infinity];
|
||||||
|
for (
|
||||||
|
let v = piece.vertexStart;
|
||||||
|
v < piece.vertexStart + piece.vertexCount;
|
||||||
|
v++
|
||||||
|
)
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
min[k] = Math.min(min[k], mesh.positions[v * 3 + k]);
|
||||||
|
max[k] = Math.max(max[k], mesh.positions[v * 3 + k]);
|
||||||
|
}
|
||||||
|
return { min, max };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs on owned document copies in a disposable worker. Cancel terminates it.
|
||||||
|
export function operate(data, action, activeId, options = {}) {
|
||||||
|
const mesh = hydrate(data);
|
||||||
|
const active = mesh.pieces.find((p) => p.id === activeId);
|
||||||
|
let items = mesh.pieces.map((piece) => ({ mesh, piece }));
|
||||||
|
let note = "";
|
||||||
|
if (action === "add") {
|
||||||
|
if (items.length >= 16)
|
||||||
|
throw new Error("Keep up to 16 pieces in a project.");
|
||||||
|
const added = ensureDocument(preset(options.preset));
|
||||||
|
const piece = added.pieces[0];
|
||||||
|
Object.assign(piece, {
|
||||||
|
id: options.id,
|
||||||
|
name: options.preset[0].toUpperCase() + options.preset.slice(1),
|
||||||
|
color: options.color,
|
||||||
|
});
|
||||||
|
const boxes = mesh.pieces.map((p) => bounds(mesh, p));
|
||||||
|
const shape = bounds(added, piece);
|
||||||
|
// Compact deterministic placement; keep new clay separate and within bounds.
|
||||||
|
const candidates = [];
|
||||||
|
if (active) {
|
||||||
|
const b = bounds(mesh, active);
|
||||||
|
candidates.push([
|
||||||
|
b.max[0] - shape.min[0] + 0.15,
|
||||||
|
(b.min[1] + b.max[1]) / 2,
|
||||||
|
(b.min[2] + b.max[2]) / 2,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for (let y = 0; y <= 6; y += 2.5)
|
||||||
|
for (let x = 0; x <= 6; x += 2.5)
|
||||||
|
for (const sx of x ? [1, -1] : [1])
|
||||||
|
for (const sy of y ? [1, -1] : [1])
|
||||||
|
candidates.push([x * sx, y * sy, 0]);
|
||||||
|
const offset = candidates.find((o) => {
|
||||||
|
const lo = shape.min.map((x, k) => x + o[k]),
|
||||||
|
hi = shape.max.map((x, k) => x + o[k]);
|
||||||
|
return (
|
||||||
|
lo.every((x) => x >= -8) &&
|
||||||
|
hi.every((x) => x <= 8) &&
|
||||||
|
boxes.every((b) =>
|
||||||
|
lo.some((x, k) => x > b.max[k] + 0.1 || hi[k] < b.min[k] - 0.1),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!offset)
|
||||||
|
throw new Error("No room for another piece. Delete a piece first.");
|
||||||
|
for (let i = 0; i < added.positions.length; i++)
|
||||||
|
added.positions[i] += offset[i % 3];
|
||||||
|
piece.symmetryX = Math.fround(offset[0]);
|
||||||
|
items.push({ mesh: added, piece });
|
||||||
|
activeId = piece.id;
|
||||||
|
} else {
|
||||||
|
if (!active) throw new Error("Select a piece of clay first.");
|
||||||
|
if (action === "delete")
|
||||||
|
items = items.filter((i) => i.piece.id !== activeId);
|
||||||
|
else if (action === "subdivide" || action === "decimate") {
|
||||||
|
const source = splitPiece(mesh, active);
|
||||||
|
const result =
|
||||||
|
action === "subdivide" ? subdivide(source) : decimate(source);
|
||||||
|
result.level = mesh.level;
|
||||||
|
const piece = {
|
||||||
|
...active,
|
||||||
|
vertexStart: 0,
|
||||||
|
indexStart: 0,
|
||||||
|
vertexCount: result.positions.length / 3,
|
||||||
|
indexCount: result.indices.length,
|
||||||
|
};
|
||||||
|
result.pieces = [piece];
|
||||||
|
items = items.map((i) =>
|
||||||
|
i.piece.id === activeId ? { mesh: result, piece } : i,
|
||||||
|
);
|
||||||
|
if (action === "decimate")
|
||||||
|
note = `${Math.round((1 - result.indices.length / source.indices.length) * 100)}% fewer triangles`;
|
||||||
|
} else throw new Error("Unknown clay operation.");
|
||||||
|
}
|
||||||
|
const combined = combinePieces(items);
|
||||||
|
combined.level = mesh.level;
|
||||||
|
return {
|
||||||
|
document: snapshot(combined),
|
||||||
|
activeId: combined.pieces.some((p) => p.id === activeId)
|
||||||
|
? activeId
|
||||||
|
: (combined.pieces[0]?.id ?? null),
|
||||||
|
note,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof self !== "undefined" && typeof document === "undefined")
|
||||||
|
self.onmessage = ({ data }) => {
|
||||||
|
try {
|
||||||
|
const result = operate(
|
||||||
|
data.document,
|
||||||
|
data.action,
|
||||||
|
data.activeId,
|
||||||
|
data.options,
|
||||||
|
);
|
||||||
|
self.postMessage(result, [
|
||||||
|
result.document.positions.buffer,
|
||||||
|
result.document.indices.buffer,
|
||||||
|
result.document.masks.buffer,
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { sphere, buildMesh } from "./mesh.js";
|
||||||
|
import { snapshot, ensureDocument, splitPiece } from "./document.js";
|
||||||
|
import { operate } from "./operations.js";
|
||||||
|
import { Sculpt } from "./engine.js";
|
||||||
|
import { decimate } from "./topology.js";
|
||||||
|
|
||||||
|
test("add, color, mask, subdivision, decimation and delete round-trip mixed history", () => {
|
||||||
|
const engine = new Sculpt(sphere(4));
|
||||||
|
const original = snapshot(engine.mesh);
|
||||||
|
const add = operate(original, "add", engine.activeId, {
|
||||||
|
preset: "torus",
|
||||||
|
id: "ring",
|
||||||
|
color: [0.7, 0.4, 0.5],
|
||||||
|
});
|
||||||
|
engine.install(add.document, add.activeId);
|
||||||
|
assert.equal(engine.activeId, "ring");
|
||||||
|
assert.equal(engine.mesh.pieces.length, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
engine.mesh.positions.subarray(0, original.positions.length),
|
||||||
|
original.positions,
|
||||||
|
);
|
||||||
|
engine.setColor([0.3, 0.6, 0.7]);
|
||||||
|
engine.editMask(true);
|
||||||
|
const beforeSubdivision = snapshot(engine.mesh);
|
||||||
|
const divided = operate(beforeSubdivision, "subdivide", "ring");
|
||||||
|
engine.install(divided.document);
|
||||||
|
assert.equal(
|
||||||
|
engine.mesh.pieces[1].indexCount,
|
||||||
|
beforeSubdivision.pieces[1].indexCount * 4,
|
||||||
|
);
|
||||||
|
const masked = snapshot(engine.mesh);
|
||||||
|
const unchanged = operate(masked, "decimate", "ring");
|
||||||
|
assert.equal(engine.install(unchanged.document), false);
|
||||||
|
engine.editMask();
|
||||||
|
const simplified = operate(snapshot(engine.mesh), "decimate", "ring");
|
||||||
|
assert.ok(simplified.document.indices.length < engine.mesh.indices.length);
|
||||||
|
engine.install(simplified.document);
|
||||||
|
const removed = operate(snapshot(engine.mesh), "delete", "ring");
|
||||||
|
engine.install(removed.document, removed.activeId);
|
||||||
|
assert.equal(engine.mesh.pieces.length, 1);
|
||||||
|
const final = snapshot(engine.mesh),
|
||||||
|
finalState = engine.state;
|
||||||
|
while (engine.restore()) {}
|
||||||
|
assert.deepEqual(snapshot(engine.mesh), original);
|
||||||
|
assert.equal(engine.state, 0);
|
||||||
|
while (engine.restore(true)) {}
|
||||||
|
assert.deepEqual(snapshot(engine.mesh), final);
|
||||||
|
assert.equal(engine.state, finalState);
|
||||||
|
const empty = operate(snapshot(engine.mesh), "delete", engine.activeId);
|
||||||
|
engine.install(empty.document, empty.activeId);
|
||||||
|
assert.equal(engine.activeId, null);
|
||||||
|
assert.equal(engine.mesh.positions.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("piece sculpting and local symmetry cannot change neighboring clay", () => {
|
||||||
|
const first = ensureDocument(sphere(4));
|
||||||
|
const result = operate(snapshot(first), "add", "clay-1", {
|
||||||
|
preset: "sphere",
|
||||||
|
id: "second",
|
||||||
|
color: [0.5, 0.7, 0.4],
|
||||||
|
});
|
||||||
|
const e = new Sculpt();
|
||||||
|
e.install(result.document, "second");
|
||||||
|
const before = e.mesh.positions.slice(),
|
||||||
|
piece = e.mesh.pieces[1];
|
||||||
|
assert.equal(piece.symmetryX, Math.fround(2.15));
|
||||||
|
const seam = [];
|
||||||
|
for (
|
||||||
|
let v = piece.vertexStart;
|
||||||
|
v < piece.vertexStart + piece.vertexCount;
|
||||||
|
v++
|
||||||
|
)
|
||||||
|
if (before[v * 3] === piece.symmetryX) seam.push(v);
|
||||||
|
assert.ok(seam.length > 0);
|
||||||
|
const hit = e.bvh.raycast([piece.symmetryX + 0.2, 0, 5], [0, 0, -1]);
|
||||||
|
e.begin({ brush: "draw", radius: 0.5, symmetry: true });
|
||||||
|
e.dab(hit);
|
||||||
|
e.end();
|
||||||
|
for (const v of seam) assert.equal(e.mesh.positions[v * 3], piece.symmetryX);
|
||||||
|
assert.deepEqual(
|
||||||
|
e.mesh.positions.subarray(0, first.positions.length),
|
||||||
|
before.subarray(0, first.positions.length),
|
||||||
|
);
|
||||||
|
assert.notDeepEqual(
|
||||||
|
splitPiece(e.mesh, piece).positions,
|
||||||
|
before.subarray(piece.vertexStart * 3),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decimator keeps tetrahedron components and open boundary fan intact", () => {
|
||||||
|
const tetra = [1, 1, 1, -1, -1, 1, -1, 1, -1, 1, -1, -1];
|
||||||
|
const faces = [0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3];
|
||||||
|
const mesh = buildMesh(
|
||||||
|
[...tetra, ...tetra.map((x, i) => x + (i % 3 === 0 ? 4 : 0))],
|
||||||
|
[...faces, ...faces.map((i) => i + 4)],
|
||||||
|
);
|
||||||
|
const result = decimate(mesh);
|
||||||
|
assert.deepEqual(result.indices, mesh.indices);
|
||||||
|
assert.deepEqual(result.positions, mesh.positions);
|
||||||
|
const p = [0, 0, 0],
|
||||||
|
f = [];
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
p.push(Math.cos((i * Math.PI) / 3), Math.sin((i * Math.PI) / 3), 0);
|
||||||
|
f.push(0, i + 1, ((i + 1) % 6) + 1);
|
||||||
|
}
|
||||||
|
const fan = buildMesh(p, f),
|
||||||
|
reduced = decimate(fan);
|
||||||
|
assert.deepEqual(reduced.positions, fan.positions);
|
||||||
|
assert.deepEqual(reduced.indices, fan.indices);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every geometry brush respects protection; mask erase and cancellation are exact", () => {
|
||||||
|
const e = new Sculpt(sphere(4));
|
||||||
|
const hit = e.bvh.raycast([0.2, 0.1, 5], [0, 0, -1]);
|
||||||
|
const positions = e.mesh.positions.slice(),
|
||||||
|
normals = e.mesh.normals.slice();
|
||||||
|
e.editMask(true);
|
||||||
|
for (const brush of ["draw", "inflate", "smooth", "flatten", "grab"]) {
|
||||||
|
e.begin({ brush, strength: 1, radius: 0.5 });
|
||||||
|
e.dab(hit, 1, [0.3, -0.2, 0.1]);
|
||||||
|
e.end();
|
||||||
|
assert.deepEqual(e.mesh.positions, positions, brush);
|
||||||
|
}
|
||||||
|
const protectedMasks = e.mesh.masks.slice();
|
||||||
|
const geometryChange = e.changed;
|
||||||
|
e.changed = () => {
|
||||||
|
throw new Error("Mask edit performed geometry maintenance");
|
||||||
|
};
|
||||||
|
e.begin({ brush: "mask", invert: true, radius: 0.5 });
|
||||||
|
e.dab(hit);
|
||||||
|
e.end(true);
|
||||||
|
assert.deepEqual(e.mesh.masks, protectedMasks);
|
||||||
|
e.begin({ brush: "mask", invert: true, radius: 0.5 });
|
||||||
|
e.dab(hit);
|
||||||
|
e.end();
|
||||||
|
assert.ok(e.mesh.masks.some((x) => x < 1));
|
||||||
|
const erased = e.mesh.masks.slice();
|
||||||
|
e.restore();
|
||||||
|
assert.deepEqual(e.mesh.masks, protectedMasks);
|
||||||
|
e.restore(true);
|
||||||
|
assert.deepEqual(e.mesh.masks, erased);
|
||||||
|
e.editMask();
|
||||||
|
assert.ok(e.mesh.masks.every((x) => x === 0));
|
||||||
|
e.restore();
|
||||||
|
assert.deepEqual(e.mesh.masks, erased);
|
||||||
|
assert.deepEqual(e.mesh.normals, normals);
|
||||||
|
assert.deepEqual(e.mesh.positions, positions);
|
||||||
|
e.changed = geometryChange;
|
||||||
|
});
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
// Explicit browser integration check on a fresh /sculpt page; no permanent hooks.
|
||||||
|
import { Renderer } from "./renderer.js";
|
||||||
|
import { snapshot, loadDocument, saveDocument } from "./document.js";
|
||||||
|
import { cameraBasis, dot, sub } from "./math.js";
|
||||||
|
|
||||||
|
export async function check() {
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const assert = (ok, message) => {
|
||||||
|
if (!ok) throw new Error(message);
|
||||||
|
};
|
||||||
|
const wait = async (fn) => {
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
if (fn()) return;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
throw new Error("Timed out: " + fn);
|
||||||
|
};
|
||||||
|
const ready = () =>
|
||||||
|
$("loading").hidden &&
|
||||||
|
$("clay-progress").hidden &&
|
||||||
|
!$("new").disabled &&
|
||||||
|
$("error").hidden;
|
||||||
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
const publish = Renderer.prototype.publish,
|
||||||
|
capture = $("viewport").setPointerCapture;
|
||||||
|
let engine, camera, processor;
|
||||||
|
Renderer.prototype.publish = function (e, c) {
|
||||||
|
engine = e;
|
||||||
|
camera = c;
|
||||||
|
processor = this.processor;
|
||||||
|
return publish.call(this, e, c);
|
||||||
|
};
|
||||||
|
$("viewport").setPointerCapture = () => {};
|
||||||
|
const command = async (id) => {
|
||||||
|
$(id).click();
|
||||||
|
await wait(ready);
|
||||||
|
await sleep(120);
|
||||||
|
};
|
||||||
|
const stroke = async (brush) => {
|
||||||
|
document.querySelector(`[data-brush="${brush}"]`).click();
|
||||||
|
const p = engine.mesh.pieces.find((p) => p.id === engine.activeId);
|
||||||
|
const center = [0, 0, 0];
|
||||||
|
for (let v = p.vertexStart; v < p.vertexStart + p.vertexCount; v++)
|
||||||
|
for (let k = 0; k < 3; k++)
|
||||||
|
center[k] += engine.mesh.positions[v * 3 + k] / p.vertexCount;
|
||||||
|
center[0] += 0.15;
|
||||||
|
const rect = $("viewport").getBoundingClientRect(),
|
||||||
|
basis = cameraBasis(camera.yaw, camera.pitch),
|
||||||
|
d = sub(center, camera.target);
|
||||||
|
const x =
|
||||||
|
rect.left +
|
||||||
|
rect.width / 2 +
|
||||||
|
(dot(d, basis.right) * rect.height) / (2 * camera.zoom);
|
||||||
|
const y =
|
||||||
|
rect.top +
|
||||||
|
rect.height / 2 -
|
||||||
|
(dot(d, basis.up) * rect.height) / (2 * camera.zoom);
|
||||||
|
const pointer = (type, dx = 0) =>
|
||||||
|
$("viewport")
|
||||||
|
.querySelector("canvas")
|
||||||
|
.dispatchEvent(
|
||||||
|
new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
pointerId: 9,
|
||||||
|
pointerType: "pen",
|
||||||
|
button: 0,
|
||||||
|
buttons: type === "pointerup" ? 0 : 1,
|
||||||
|
pressure: 0.8,
|
||||||
|
clientX: x + dx,
|
||||||
|
clientY: y,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
pointer("pointerdown");
|
||||||
|
pointer("pointermove", 14);
|
||||||
|
await sleep(150);
|
||||||
|
pointer("pointerup", 14);
|
||||||
|
await wait(ready);
|
||||||
|
await sleep(100);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await wait(ready);
|
||||||
|
$("frame").click();
|
||||||
|
await wait(() => !!engine);
|
||||||
|
const initial = snapshot(engine.mesh);
|
||||||
|
await command("delete-piece");
|
||||||
|
assert(
|
||||||
|
engine.mesh.pieces.length === 0 && !$("empty-clay").hidden,
|
||||||
|
"Delete last piece failed",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
loadDocument(saveDocument(engine.mesh)).pieces.length === 0,
|
||||||
|
"Empty roundtrip failed",
|
||||||
|
);
|
||||||
|
await command("undo");
|
||||||
|
assert(same(snapshot(engine.mesh), initial), "Delete undo lost clay");
|
||||||
|
await stroke("mask");
|
||||||
|
assert(
|
||||||
|
engine.mesh.masks.some((x) => x > 0),
|
||||||
|
"Worker mask brush did not paint",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
same(engine.mesh.positions, initial.positions),
|
||||||
|
"Mask moved geometry",
|
||||||
|
);
|
||||||
|
await command("undo");
|
||||||
|
assert(
|
||||||
|
engine.mesh.masks.every((x) => x === 0),
|
||||||
|
"Mask undo not exact",
|
||||||
|
);
|
||||||
|
for (const name of ["sphere", "cube", "cylinder", "cone", "torus"]) {
|
||||||
|
const count = engine.mesh.pieces.length;
|
||||||
|
document.querySelector(`[data-preset="${name}"]`).click();
|
||||||
|
await wait(ready);
|
||||||
|
await sleep(120);
|
||||||
|
assert(engine.mesh.pieces.length === count + 1, `Add ${name} failed`);
|
||||||
|
assert(
|
||||||
|
engine.activeId === engine.mesh.pieces.at(-1).id,
|
||||||
|
"Addition was not selected",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
new Set(engine.mesh.pieces.slice(1).map((p) => JSON.stringify(p.color)))
|
||||||
|
.size === 5,
|
||||||
|
"Pastels were not individually chosen",
|
||||||
|
);
|
||||||
|
const cube = engine.mesh.pieces.find((p) => p.name === "Cube");
|
||||||
|
document.querySelector(`[data-piece="${cube.id}"]`).click();
|
||||||
|
$("piece-color").value = "#99bbee";
|
||||||
|
$("piece-color").dispatchEvent(new Event("change"));
|
||||||
|
const colored = snapshot(engine.mesh);
|
||||||
|
await command("undo");
|
||||||
|
assert(
|
||||||
|
!same(
|
||||||
|
engine.mesh.pieces.find((p) => p.id === cube.id).color,
|
||||||
|
colored.pieces.find((p) => p.id === cube.id).color,
|
||||||
|
),
|
||||||
|
"Color undo failed",
|
||||||
|
);
|
||||||
|
await command("redo");
|
||||||
|
assert(same(snapshot(engine.mesh), colored), "Color redo failed");
|
||||||
|
const count = cube.indexCount;
|
||||||
|
await command("subdivide");
|
||||||
|
assert(
|
||||||
|
engine.mesh.pieces.find((p) => p.id === cube.id).indexCount === count * 4,
|
||||||
|
"Subdivision did not split triangles",
|
||||||
|
);
|
||||||
|
const subdivided = snapshot(engine.mesh);
|
||||||
|
await stroke("draw");
|
||||||
|
assert(
|
||||||
|
!same(engine.mesh.positions, subdivided.positions),
|
||||||
|
"Cannot sculpt replacement topology",
|
||||||
|
);
|
||||||
|
await command("undo");
|
||||||
|
assert(
|
||||||
|
same(snapshot(engine.mesh), subdivided),
|
||||||
|
"Post-topology stroke undo failed",
|
||||||
|
);
|
||||||
|
await command("invert-mask");
|
||||||
|
const masked = snapshot(engine.mesh),
|
||||||
|
state = engine.state;
|
||||||
|
await stroke("draw");
|
||||||
|
assert(same(snapshot(engine.mesh), masked), "Fully masked piece moved");
|
||||||
|
await command("decimate");
|
||||||
|
assert(
|
||||||
|
engine.state === state && same(snapshot(engine.mesh), masked),
|
||||||
|
"Masked decimation was not a no-op",
|
||||||
|
);
|
||||||
|
await command("clear-mask");
|
||||||
|
await command("decimate");
|
||||||
|
assert(
|
||||||
|
engine.mesh.pieces.find((p) => p.id === cube.id).indexCount < count * 4,
|
||||||
|
"Decimation did not reduce triangles",
|
||||||
|
);
|
||||||
|
const beforeCancel = snapshot(engine.mesh),
|
||||||
|
beforeState = engine.state;
|
||||||
|
$("subdivide").click();
|
||||||
|
$("cancel-operation").click();
|
||||||
|
await sleep(300);
|
||||||
|
assert(
|
||||||
|
ready() &&
|
||||||
|
engine.state === beforeState &&
|
||||||
|
same(snapshot(engine.mesh), beforeCancel),
|
||||||
|
"Canceled topology changed clay",
|
||||||
|
);
|
||||||
|
const loaded = loadDocument(saveDocument(engine.mesh));
|
||||||
|
assert(
|
||||||
|
same(snapshot(loaded), snapshot(engine.mesh)),
|
||||||
|
"Mixed scene project roundtrip failed",
|
||||||
|
);
|
||||||
|
// Hold real worker results to exercise races without production hooks.
|
||||||
|
const NativeWorker = window.Worker,
|
||||||
|
create = Renderer.create;
|
||||||
|
const jobs = [];
|
||||||
|
window.Worker = class {
|
||||||
|
constructor(url, options) {
|
||||||
|
if (!String(url).endsWith("/operations.js"))
|
||||||
|
return new NativeWorker(url, options);
|
||||||
|
this.inner = new NativeWorker(url, options);
|
||||||
|
this.inner.onmessage = (event) => {
|
||||||
|
this.packet = event;
|
||||||
|
};
|
||||||
|
this.inner.onerror = (event) => this.onerror?.(event);
|
||||||
|
jobs.push(this);
|
||||||
|
}
|
||||||
|
postMessage(...args) {
|
||||||
|
this.inner.postMessage(...args);
|
||||||
|
}
|
||||||
|
terminate() {
|
||||||
|
this.terminated = true;
|
||||||
|
this.inner.terminate();
|
||||||
|
}
|
||||||
|
deliver() {
|
||||||
|
this.onmessage(this.packet);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pending = async () => {
|
||||||
|
$("delete-piece").click();
|
||||||
|
const job = jobs.at(-1);
|
||||||
|
await wait(() => !!job.packet);
|
||||||
|
return job;
|
||||||
|
};
|
||||||
|
const baseline = saveDocument(engine.mesh),
|
||||||
|
baselineState = engine.state;
|
||||||
|
const unchanged = () =>
|
||||||
|
saveDocument(engine.mesh) === baseline && engine.state === baselineState;
|
||||||
|
try {
|
||||||
|
const a = await pending();
|
||||||
|
$("cancel-operation").click();
|
||||||
|
const b = await pending();
|
||||||
|
const status = $("status").textContent;
|
||||||
|
a.deliver();
|
||||||
|
a.onerror();
|
||||||
|
assert(
|
||||||
|
!$("clay-progress").hidden &&
|
||||||
|
unchanged() &&
|
||||||
|
$("status").textContent === status,
|
||||||
|
"Old operation affected new job",
|
||||||
|
);
|
||||||
|
$("cancel-operation").click();
|
||||||
|
const c = await pending();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
assert(!c.terminated, "Same-size resize canceled operation");
|
||||||
|
$("viewport").style.width = "90%";
|
||||||
|
c.deliver(); // Deliver before ResizeObserver: receipt must detect stale size.
|
||||||
|
assert(c.terminated && unchanged(), "Stale viewport result installed");
|
||||||
|
await wait(ready);
|
||||||
|
$("viewport").style.width = "";
|
||||||
|
await sleep(350);
|
||||||
|
await wait(ready);
|
||||||
|
const d = await pending();
|
||||||
|
d.onmessageerror();
|
||||||
|
assert(
|
||||||
|
d.terminated && unchanged() && $("clay-progress").hidden,
|
||||||
|
"Message error stranded operation",
|
||||||
|
);
|
||||||
|
$("pieces-toggle").click();
|
||||||
|
const e = await pending();
|
||||||
|
$("delete-piece").dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
e.terminated && $("pieces-panel").hidden && unchanged(),
|
||||||
|
"Panel Escape did not cancel operation",
|
||||||
|
);
|
||||||
|
const f = await pending();
|
||||||
|
processor.fail(new Error("Synthetic failure during topology"));
|
||||||
|
f.deliver();
|
||||||
|
await sleep(350);
|
||||||
|
assert(
|
||||||
|
f.terminated &&
|
||||||
|
!$("error").hidden &&
|
||||||
|
!$("save").disabled &&
|
||||||
|
unchanged(),
|
||||||
|
"Canvas failure allowed pending topology commit/restart",
|
||||||
|
);
|
||||||
|
await command("retry");
|
||||||
|
const g = await pending();
|
||||||
|
Renderer.create = async () => {
|
||||||
|
throw new Error("Synthetic post-commit startup failure");
|
||||||
|
};
|
||||||
|
g.deliver();
|
||||||
|
await wait(() => !$("error").hidden && !$("new").disabled);
|
||||||
|
assert(
|
||||||
|
engine.state !== baselineState && !$("save").disabled,
|
||||||
|
"Post-commit failure lost document or Save",
|
||||||
|
);
|
||||||
|
const committed = saveDocument(engine.mesh);
|
||||||
|
assert(
|
||||||
|
loadDocument(committed).pieces.length ===
|
||||||
|
loadDocument(baseline).pieces.length - 1,
|
||||||
|
"Saved document missed committed deletion",
|
||||||
|
);
|
||||||
|
Renderer.create = create;
|
||||||
|
await command("undo");
|
||||||
|
assert(
|
||||||
|
unchanged(),
|
||||||
|
"Undo after renderer failure did not restore document",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
window.Worker = NativeWorker;
|
||||||
|
Renderer.create = create;
|
||||||
|
for (const job of jobs) job.terminate();
|
||||||
|
$("viewport").style.width = "";
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
passed: 21,
|
||||||
|
checks:
|
||||||
|
"preset add/selection/pastels, delete last/undo, mask worker/undo, color history, subdivision, replacement-topology sculpt/undo, full protection, masked decimation no-op, clear, decimation, cancellation, mixed project roundtrip; stale jobs, same-size resize, changed-size receipt, message error, panel Escape, canvas failure, post-commit failure/save/undo",
|
||||||
|
physicalTabletVerified: false,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
Renderer.prototype.publish = publish;
|
||||||
|
$("viewport").setPointerCapture = capture;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,12 +12,12 @@ function wire(v) {
|
|||||||
const shader = `
|
const shader = `
|
||||||
struct Camera { right:vec4f, up:vec4f, forward:vec4f, center:vec4f, color:vec4f }
|
struct Camera { right:vec4f, up:vec4f, forward:vec4f, center:vec4f, color:vec4f }
|
||||||
@group(0) @binding(0) var<uniform> camera:Camera;
|
@group(0) @binding(0) var<uniform> camera:Camera;
|
||||||
struct Out { @builtin(position) position:vec4f, @location(0) normal:vec3f }
|
struct Out { @builtin(position) position:vec4f, @location(0) normal:vec3f, @location(1) mask:f32, @location(2) material:vec3f }
|
||||||
@vertex fn vertex(@location(0) p:vec3f, @location(1) n:vec3f)->Out {
|
@vertex fn vertex(@location(0) p:vec3f, @location(1) mask:f32, @location(2) n:vec3f, @location(3) material:vec4f)->Out {
|
||||||
let d=p-camera.center.xyz;
|
let d=p-camera.center.xyz;
|
||||||
var o:Out;
|
var o:Out;
|
||||||
o.position=vec4f(dot(d,camera.right.xyz)/camera.right.w,dot(d,camera.up.xyz)/camera.up.w,0.5-dot(d,camera.forward.xyz)/32.0,1.0);
|
o.position=vec4f(dot(d,camera.right.xyz)/camera.right.w,dot(d,camera.up.xyz)/camera.up.w,0.5-dot(d,camera.forward.xyz)/32.0,1.0);
|
||||||
o.normal=vec3f(dot(n,camera.right.xyz),dot(n,camera.up.xyz),dot(n,camera.forward.xyz));return o;
|
o.normal=vec3f(dot(n,camera.right.xyz),dot(n,camera.up.xyz),dot(n,camera.forward.xyz));o.mask=mask;o.material=material.xyz;return o;
|
||||||
}
|
}
|
||||||
@fragment fn fragment(o:Out)->@location(0) vec4f {
|
@fragment fn fragment(o:Out)->@location(0) vec4f {
|
||||||
let n=normalize(o.normal);
|
let n=normalize(o.normal);
|
||||||
@@ -25,7 +25,8 @@ struct Out { @builtin(position) position:vec4f, @location(0) normal:vec3f }
|
|||||||
let fill=max(dot(n,normalize(vec3f(0.8,0.0,0.4))),0.0);
|
let fill=max(dot(n,normalize(vec3f(0.8,0.0,0.4))),0.0);
|
||||||
let rim=pow(1.0-max(n.z,0.0),3.0);
|
let rim=pow(1.0-max(n.z,0.0),3.0);
|
||||||
let spec=pow(max(dot(n,normalize(vec3f(-0.3,0.45,1.0))),0.0),48.0);
|
let spec=pow(max(dot(n,normalize(vec3f(-0.3,0.45,1.0))),0.0),48.0);
|
||||||
let color=camera.color.xyz*(0.24+key*0.65+fill*0.16)+vec3f(0.12,0.14,0.16)*rim+vec3f(0.12)*spec;
|
let base=mix(o.material,o.material*0.22,clamp(o.mask,0.0,1.0));
|
||||||
|
let color=base*(0.24+key*0.65+fill*0.16)+vec3f(0.12,0.14,0.16)*rim+vec3f(0.12)*spec;
|
||||||
return vec4f(pow(color,vec3f(1.0/2.2)),1.0);
|
return vec4f(pow(color,vec3f(1.0/2.2)),1.0);
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
@@ -64,13 +65,13 @@ export class Renderer {
|
|||||||
await r.core.createRowsBatch([
|
await r.core.createRowsBatch([
|
||||||
{
|
{
|
||||||
name: "sculptVertices",
|
name: "sculptVertices",
|
||||||
rows: mesh.positions.length / 3,
|
rows: Math.max(1, mesh.positions.length / 3),
|
||||||
stride: 32,
|
stride: 32,
|
||||||
format: "f32",
|
format: "f32",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "sculptIndices",
|
name: "sculptIndices",
|
||||||
rows: Math.ceil(mesh.indices.length / 4),
|
rows: Math.max(1, Math.ceil(mesh.indices.length / 4)),
|
||||||
stride: 16,
|
stride: 16,
|
||||||
format: "u32",
|
format: "u32",
|
||||||
},
|
},
|
||||||
@@ -124,7 +125,9 @@ export class Renderer {
|
|||||||
arrayStride: 32,
|
arrayStride: 32,
|
||||||
attributes: [
|
attributes: [
|
||||||
{ format: "float32x3", offset: 0, shaderLocation: 0 },
|
{ format: "float32x3", offset: 0, shaderLocation: 0 },
|
||||||
{ format: "float32x3", offset: 16, shaderLocation: 1 },
|
{ format: "float32", offset: 12, shaderLocation: 1 },
|
||||||
|
{ format: "float32x3", offset: 16, shaderLocation: 2 },
|
||||||
|
{ format: "unorm8x4", offset: 28, shaderLocation: 3 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -175,16 +178,33 @@ export class Renderer {
|
|||||||
writeVertices(first, count) {
|
writeVertices(first, count) {
|
||||||
const p = this.mesh.positions,
|
const p = this.mesh.positions,
|
||||||
n = this.mesh.normals,
|
n = this.mesh.normals,
|
||||||
v = this.vertices.view;
|
v = this.vertices.view,
|
||||||
|
bytes = new Uint8Array(v.buffer, v.byteOffset, v.byteLength),
|
||||||
|
pieces = this.mesh.pieces ?? [];
|
||||||
|
let pieceIndex = pieces.findIndex(
|
||||||
|
(piece) => first < piece.vertexStart + piece.vertexCount,
|
||||||
|
);
|
||||||
for (let i = first; i < first + count; i++) {
|
for (let i = first; i < first + count; i++) {
|
||||||
const a = i * 3,
|
const a = i * 3,
|
||||||
b = i * 8;
|
b = i * 8;
|
||||||
v[b] = p[a];
|
v[b] = p[a];
|
||||||
v[b + 1] = p[a + 1];
|
v[b + 1] = p[a + 1];
|
||||||
v[b + 2] = p[a + 2];
|
v[b + 2] = p[a + 2];
|
||||||
|
v[b + 3] = this.mesh.masks?.[i] ?? 0;
|
||||||
v[b + 4] = n[a];
|
v[b + 4] = n[a];
|
||||||
v[b + 5] = n[a + 1];
|
v[b + 5] = n[a + 1];
|
||||||
v[b + 6] = n[a + 2];
|
v[b + 6] = n[a + 2];
|
||||||
|
while (
|
||||||
|
pieceIndex >= 0 &&
|
||||||
|
pieceIndex < pieces.length - 1 &&
|
||||||
|
i >= pieces[pieceIndex].vertexStart + pieces[pieceIndex].vertexCount
|
||||||
|
)
|
||||||
|
pieceIndex++;
|
||||||
|
const piece = pieces[pieceIndex];
|
||||||
|
const color = piece?.color ?? [0.5, 0.3, 0.2];
|
||||||
|
for (let k = 0; k < 3; k++)
|
||||||
|
bytes[b * 4 + 28 + k] = Math.round(color[k] * 255);
|
||||||
|
bytes[b * 4 + 31] = 255;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeCamera(camera) {
|
writeCamera(camera) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Mailbox } from "./mailbox.js";
|
|||||||
import { pressureValue } from "./engine.js";
|
import { pressureValue } from "./engine.js";
|
||||||
|
|
||||||
export class StrokeClient {
|
export class StrokeClient {
|
||||||
constructor(row, level, onError) {
|
constructor(row, mesh, onError) {
|
||||||
this.mailbox = new Mailbox(row.share());
|
this.mailbox = new Mailbox(row.share());
|
||||||
this.worker = new Worker(new URL("./stroke-worker.js", import.meta.url), {
|
this.worker = new Worker(new URL("./stroke-worker.js", import.meta.url), {
|
||||||
type: "module",
|
type: "module",
|
||||||
@@ -22,7 +22,19 @@ export class StrokeClient {
|
|||||||
this.worker.onerror = () => this.fail(new Error("Sculpt worker stopped"));
|
this.worker.onerror = () => this.fail(new Error("Sculpt worker stopped"));
|
||||||
this.worker.onmessageerror = () =>
|
this.worker.onmessageerror = () =>
|
||||||
this.fail(new Error("Sculpt worker message failed"));
|
this.fail(new Error("Sculpt worker message failed"));
|
||||||
this.worker.postMessage({ type: "init", row: row.share(), level });
|
const topology =
|
||||||
|
typeof mesh === "number"
|
||||||
|
? { level: mesh }
|
||||||
|
: {
|
||||||
|
mesh: {
|
||||||
|
level: mesh.level,
|
||||||
|
positions: mesh.positions.slice(),
|
||||||
|
indices: mesh.indices.slice(),
|
||||||
|
masks: mesh.masks.slice(),
|
||||||
|
pieces: structuredClone(mesh.pieces),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.worker.postMessage({ type: "init", row: row.share(), ...topology });
|
||||||
}
|
}
|
||||||
begin(data, apply) {
|
begin(data, apply) {
|
||||||
if (this.job || this.disposed)
|
if (this.job || this.disposed)
|
||||||
@@ -34,7 +46,7 @@ export class StrokeClient {
|
|||||||
this.write(data.sample);
|
this.write(data.sample);
|
||||||
this.worker.postMessage(
|
this.worker.postMessage(
|
||||||
{ type: "begin", ...data, id, version: this.version },
|
{ type: "begin", ...data, id, version: this.version },
|
||||||
[data.positions.buffer],
|
[data.positions.buffer, ...(data.masks ? [data.masks.buffer] : [])],
|
||||||
);
|
);
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
@@ -92,7 +104,7 @@ export class StrokeClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const j = this.job;
|
const j = this.job;
|
||||||
if (data.type === "geometry") {
|
if (data.type === "geometry" || data.type === "mask") {
|
||||||
if (j?.id === data.id && !j.canceling) j.apply(data);
|
if (j?.id === data.id && !j.canceling) j.apply(data);
|
||||||
this.worker.postMessage({ type: "ack", packet: data.packet });
|
this.worker.postMessage({ type: "ack", packet: data.packet });
|
||||||
} else if (
|
} else if (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Sculpt, StrokePath, pressureValue } from "./engine.js";
|
|||||||
import { sphere, updateNormals } from "./mesh.js";
|
import { sphere, updateNormals } from "./mesh.js";
|
||||||
import { cameraBasis, add, mul, sub } from "./math.js";
|
import { cameraBasis, add, mul, sub } from "./math.js";
|
||||||
import { Mailbox } from "./mailbox.js";
|
import { Mailbox } from "./mailbox.js";
|
||||||
|
import { hydrate } from "./document.js";
|
||||||
|
|
||||||
// Sole owner of brush-time geometry work. Exported for deterministic tests;
|
// Sole owner of brush-time geometry work. Exported for deterministic tests;
|
||||||
// the browser entry below only supplies worker rAF and postMessage.
|
// the browser entry below only supplies worker rAF and postMessage.
|
||||||
@@ -30,12 +31,14 @@ export class StrokeProcessor {
|
|||||||
moved.push(v);
|
moved.push(v);
|
||||||
}
|
}
|
||||||
e.mesh.positions.set(data.positions);
|
e.mesh.positions.set(data.positions);
|
||||||
|
if (data.masks) e.mesh.masks.set(data.masks);
|
||||||
|
if (data.activeId) e.activeId = data.activeId;
|
||||||
if (moved.length) {
|
if (moved.length) {
|
||||||
const { faces } = updateNormals(e.mesh, moved);
|
const { faces } = updateNormals(e.mesh, moved);
|
||||||
e.bvh.refit(faces);
|
e.bvh.refit(faces);
|
||||||
}
|
}
|
||||||
e.dirty.clear();
|
e.dirty.clear();
|
||||||
e.begin(data.settings);
|
e.begin({ ...data.settings, activeId: data.activeId });
|
||||||
const basis = cameraBasis(data.camera.yaw, data.camera.pitch);
|
const basis = cameraBasis(data.camera.yaw, data.camera.pitch);
|
||||||
this.job = {
|
this.job = {
|
||||||
...data,
|
...data,
|
||||||
@@ -57,7 +60,14 @@ export class StrokeProcessor {
|
|||||||
add(this.world(sample), mul(basis.forward, 24)),
|
add(this.world(sample), mul(basis.forward, 24)),
|
||||||
mul(basis.forward, -1),
|
mul(basis.forward, -1),
|
||||||
);
|
);
|
||||||
if (!hit) this.path.break();
|
const piece = e.mesh.pieces.find((p) => p.id === e.activeId);
|
||||||
|
if (
|
||||||
|
!hit ||
|
||||||
|
!piece ||
|
||||||
|
hit.face * 3 < piece.indexStart ||
|
||||||
|
hit.face * 3 >= piece.indexStart + piece.indexCount
|
||||||
|
)
|
||||||
|
this.path.break();
|
||||||
else e.dab(hit, sample.pressure);
|
else e.dab(hit, sample.pressure);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -95,6 +105,24 @@ export class StrokeProcessor {
|
|||||||
const e = this.engine;
|
const e = this.engine;
|
||||||
if (!this.job || this.outstanding || !e.dirty.size) return;
|
if (!this.job || this.outstanding || !e.dirty.size) return;
|
||||||
const rows = Uint32Array.from(e.dirty);
|
const rows = Uint32Array.from(e.dirty);
|
||||||
|
if (this.job.settings.brush === "mask") {
|
||||||
|
const masks = Float32Array.from(rows, (v) => e.mesh.masks[v]);
|
||||||
|
const touched = Uint32Array.from(e.stroke.touched);
|
||||||
|
e.dirty.clear();
|
||||||
|
this.outstanding = ++this.packet;
|
||||||
|
this.send(
|
||||||
|
{
|
||||||
|
type: "mask",
|
||||||
|
id: this.job.id,
|
||||||
|
packet: this.packet,
|
||||||
|
masks,
|
||||||
|
rows,
|
||||||
|
touched,
|
||||||
|
},
|
||||||
|
[masks.buffer, rows.buffer, touched.buffer],
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const vertices = new Float32Array(rows.length * 8);
|
const vertices = new Float32Array(rows.length * 8);
|
||||||
for (let row = 0; row < rows.length; row++) {
|
for (let row = 0; row < rows.length; row++) {
|
||||||
const i = rows[row] * 3,
|
const i = rows[row] * 3,
|
||||||
@@ -212,7 +240,7 @@ if (typeof self !== "undefined" && typeof document === "undefined") {
|
|||||||
"Sculpt worker requires worker requestAnimationFrame support",
|
"Sculpt worker requires worker requestAnimationFrame support",
|
||||||
);
|
);
|
||||||
processor = new StrokeProcessor(
|
processor = new StrokeProcessor(
|
||||||
sphere(data.level),
|
data.mesh ? hydrate(data.mesh) : sphere(data.level),
|
||||||
new Mailbox(data.row),
|
new Mailbox(data.row),
|
||||||
(message, transfer = []) => self.postMessage(message, transfer),
|
(message, transfer = []) => self.postMessage(message, transfer),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -197,6 +197,79 @@ summary {
|
|||||||
width: min(264px, calc(100vw - 74px));
|
width: min(264px, calc(100vw - 74px));
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
.pieces-panel {
|
||||||
|
left: max(58px, calc(env(safe-area-inset-left) + 50px));
|
||||||
|
width: min(248px, calc(100vw - 74px));
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.pieces-panel .btn {
|
||||||
|
background: #262a30;
|
||||||
|
color: #d4d7db;
|
||||||
|
}
|
||||||
|
.pieces-panel .btn:hover {
|
||||||
|
background: #343b45;
|
||||||
|
}
|
||||||
|
#piece-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.piece-row.btn {
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pieces-panel .piece-row[aria-pressed="true"] {
|
||||||
|
background: #386fa9;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.piece-row i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.piece-material {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.piece-material .btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
#piece-color {
|
||||||
|
width: 30px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.preset-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 3px;
|
||||||
|
margin-block: 10px;
|
||||||
|
}
|
||||||
|
.preset-grid .btn {
|
||||||
|
padding: 5px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.preset-grid svg {
|
||||||
|
width: 23px;
|
||||||
|
height: 23px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.4;
|
||||||
|
}
|
||||||
|
.piece-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 5px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
.panel-heading {
|
.panel-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import { buildMesh, sphere } from "./mesh.js";
|
||||||
|
|
||||||
|
export const MAX_VERTICES = 65536;
|
||||||
|
export const MAX_TRIANGLES = 131072;
|
||||||
|
|
||||||
|
function checked(positions, indices, level = 0, masks) {
|
||||||
|
if (positions.length / 3 > MAX_VERTICES || indices.length / 3 > MAX_TRIANGLES)
|
||||||
|
throw new Error("Mesh exceeds topology limits.");
|
||||||
|
return buildMesh(positions, indices, level, masks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gridSurface(slices, stacks, point) {
|
||||||
|
const p = [],
|
||||||
|
i = [];
|
||||||
|
for (let y = 0; y < stacks; y++)
|
||||||
|
for (let x = 0; x < slices; x++) p.push(...point(x / slices, y / stacks));
|
||||||
|
const at = (x, y) => y * slices + ((x + slices) % slices);
|
||||||
|
for (let y = 0; y < stacks; y++)
|
||||||
|
for (let x = 0; x < slices; x++) {
|
||||||
|
const a = at(x, y),
|
||||||
|
b = at(x + 1, y),
|
||||||
|
c = at(x + 1, (y + 1) % stacks),
|
||||||
|
d = at(x, (y + 1) % stacks);
|
||||||
|
i.push(a, b, c, a, c, d);
|
||||||
|
}
|
||||||
|
return [p, i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cubeMesh(n) {
|
||||||
|
const p = [],
|
||||||
|
faces = [],
|
||||||
|
map = new Map();
|
||||||
|
const vertex = (v) => {
|
||||||
|
const key = v.join(",");
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, p.length / 3);
|
||||||
|
p.push(...v.map((x) => x / n));
|
||||||
|
}
|
||||||
|
return map.get(key);
|
||||||
|
};
|
||||||
|
const sides = [
|
||||||
|
[
|
||||||
|
[n, -n, -n],
|
||||||
|
[0, 2 * n, 0],
|
||||||
|
[0, 0, 2 * n],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[-n, -n, n],
|
||||||
|
[0, 2 * n, 0],
|
||||||
|
[0, 0, -2 * n],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[-n, n, -n],
|
||||||
|
[0, 0, 2 * n],
|
||||||
|
[2 * n, 0, 0],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[-n, -n, n],
|
||||||
|
[0, 0, -2 * n],
|
||||||
|
[2 * n, 0, 0],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[-n, -n, -n],
|
||||||
|
[0, 2 * n, 0],
|
||||||
|
[2 * n, 0, 0],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[n, -n, n],
|
||||||
|
[0, 2 * n, 0],
|
||||||
|
[-2 * n, 0, 0],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
for (const [o, u, v] of sides)
|
||||||
|
for (let y = 0; y < n; y++)
|
||||||
|
for (let x = 0; x < n; x++) {
|
||||||
|
const q = (xx, yy) =>
|
||||||
|
vertex(o.map((z, k) => z + (u[k] * xx) / n + (v[k] * yy) / n));
|
||||||
|
const a = q(x, y),
|
||||||
|
b = q(x + 1, y),
|
||||||
|
c = q(x + 1, y + 1),
|
||||||
|
d = q(x, y + 1);
|
||||||
|
faces.push(a, b, c, a, c, d);
|
||||||
|
}
|
||||||
|
return [p, faces];
|
||||||
|
}
|
||||||
|
|
||||||
|
function radial(kind, slices, rings) {
|
||||||
|
const p = [],
|
||||||
|
idx = [];
|
||||||
|
const cone = kind === "cone";
|
||||||
|
for (let y = 0; y <= (cone ? rings - 1 : rings); y++) {
|
||||||
|
const t = y / rings,
|
||||||
|
r = cone ? 1 - t : 1,
|
||||||
|
z = -1 + 2 * t;
|
||||||
|
for (let x = 0; x < slices; x++) {
|
||||||
|
const a = (2 * Math.PI * x) / slices;
|
||||||
|
p.push(r * Math.cos(a), r * Math.sin(a), z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let y = 0; y < rings - (cone ? 1 : 0); y++)
|
||||||
|
for (let x = 0; x < slices; x++) {
|
||||||
|
const a = y * slices + x,
|
||||||
|
b = y * slices + ((x + 1) % slices),
|
||||||
|
c = (y + 1) * slices + ((x + 1) % slices),
|
||||||
|
d = (y + 1) * slices + x;
|
||||||
|
idx.push(a, b, c, a, c, d);
|
||||||
|
}
|
||||||
|
if (cone) {
|
||||||
|
const apex = p.length / 3,
|
||||||
|
o = (rings - 1) * slices;
|
||||||
|
p.push(0, 0, 1);
|
||||||
|
for (let x = 0; x < slices; x++)
|
||||||
|
idx.push(o + x, o + ((x + 1) % slices), apex);
|
||||||
|
}
|
||||||
|
const bottom = p.length / 3;
|
||||||
|
p.push(0, 0, -1);
|
||||||
|
for (let x = 0; x < slices; x++) idx.push(bottom, (x + 1) % slices, x);
|
||||||
|
if (!cone) {
|
||||||
|
const top = p.length / 3;
|
||||||
|
p.push(0, 0, 1);
|
||||||
|
const o = rings * slices;
|
||||||
|
for (let x = 0; x < slices; x++)
|
||||||
|
idx.push(top, o + x, o + ((x + 1) % slices));
|
||||||
|
}
|
||||||
|
return [p, idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preset(name, level = 4) {
|
||||||
|
if (!Number.isInteger(level) || level < 1 || level > 6)
|
||||||
|
throw new Error("Choose a supported resolution.");
|
||||||
|
if (name === "sphere") return sphere(level);
|
||||||
|
let data;
|
||||||
|
const scale = 2 ** level;
|
||||||
|
if (name === "cube") data = cubeMesh(scale);
|
||||||
|
else if (name === "cylinder" || name === "cone")
|
||||||
|
data = radial(name, scale * 4, scale + 8);
|
||||||
|
else if (name === "torus")
|
||||||
|
data = gridSurface(scale * 4, scale * 2, (u, v) => {
|
||||||
|
const a = 2 * Math.PI * u,
|
||||||
|
b = 2 * Math.PI * v,
|
||||||
|
r = 0.32,
|
||||||
|
R = 0.68;
|
||||||
|
return [
|
||||||
|
(R + r * Math.cos(b)) * Math.cos(a),
|
||||||
|
(R + r * Math.cos(b)) * Math.sin(a),
|
||||||
|
r * Math.sin(b),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
else throw new Error(`Unknown topology preset: ${name}`);
|
||||||
|
return checked(data[0], data[1], level);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subdivide(mesh) {
|
||||||
|
const tri = mesh.indices.length / 3,
|
||||||
|
vertices = mesh.positions.length / 3;
|
||||||
|
const edges = new Map();
|
||||||
|
for (let k = 0; k < mesh.indices.length; k += 3)
|
||||||
|
for (const [a, b] of [
|
||||||
|
[mesh.indices[k], mesh.indices[k + 1]],
|
||||||
|
[mesh.indices[k + 1], mesh.indices[k + 2]],
|
||||||
|
[mesh.indices[k + 2], mesh.indices[k]],
|
||||||
|
])
|
||||||
|
edges.set(a < b ? `${a},${b}` : `${b},${a}`, 0);
|
||||||
|
if (vertices + edges.size > MAX_VERTICES || tri * 4 > MAX_TRIANGLES)
|
||||||
|
throw new Error("Subdivision exceeds topology limits.");
|
||||||
|
const p = Array.from(mesh.positions),
|
||||||
|
masks = Array.from(mesh.masks || new Float32Array(vertices));
|
||||||
|
for (const key of edges.keys()) {
|
||||||
|
const [a, b] = key.split(",").map(Number),
|
||||||
|
v = p.length / 3;
|
||||||
|
edges.set(key, v);
|
||||||
|
for (let j = 0; j < 3; j++) p.push((p[a * 3 + j] + p[b * 3 + j]) / 2);
|
||||||
|
masks.push((masks[a] + masks[b]) / 2);
|
||||||
|
}
|
||||||
|
const mid = (a, b) => edges.get(a < b ? `${a},${b}` : `${b},${a}`),
|
||||||
|
out = [];
|
||||||
|
for (let k = 0; k < mesh.indices.length; k += 3) {
|
||||||
|
const a = mesh.indices[k],
|
||||||
|
b = mesh.indices[k + 1],
|
||||||
|
c = mesh.indices[k + 2],
|
||||||
|
ab = mid(a, b),
|
||||||
|
bc = mid(b, c),
|
||||||
|
ca = mid(c, a);
|
||||||
|
out.push(a, ab, ca, b, bc, ab, c, ca, bc, ab, bc, ca);
|
||||||
|
}
|
||||||
|
return checked(p, out, mesh.level + 1, masks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function topology(indices, count) {
|
||||||
|
const neighbors = Array.from({ length: count }, () => new Set()),
|
||||||
|
incident = Array.from({ length: count }, () => []),
|
||||||
|
edgeFaces = new Map(),
|
||||||
|
faces = new Set();
|
||||||
|
let valid = true;
|
||||||
|
for (let k = 0; k < indices.length; k += 3) {
|
||||||
|
const face = [indices[k], indices[k + 1], indices[k + 2]],
|
||||||
|
faceKey = [...face].sort((a, b) => a - b).join(",");
|
||||||
|
if (new Set(face).size !== 3 || faces.has(faceKey)) valid = false;
|
||||||
|
faces.add(faceKey);
|
||||||
|
for (const v of face) incident[v].push(k / 3);
|
||||||
|
for (const [a, b] of [
|
||||||
|
[indices[k], indices[k + 1]],
|
||||||
|
[indices[k + 1], indices[k + 2]],
|
||||||
|
[indices[k + 2], indices[k]],
|
||||||
|
]) {
|
||||||
|
neighbors[a].add(b);
|
||||||
|
neighbors[b].add(a);
|
||||||
|
const key = a < b ? `${a},${b}` : `${b},${a}`;
|
||||||
|
const list = edgeFaces.get(key) || [];
|
||||||
|
list.push(k / 3);
|
||||||
|
edgeFaces.set(key, list);
|
||||||
|
if (list.length > 2) valid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A manifold vertex link is one cycle (interior) or one path (boundary).
|
||||||
|
// Edge incidence alone does not detect two components touching at a vertex.
|
||||||
|
for (let v = 0; valid && v < count; v++) {
|
||||||
|
if (!incident[v].length) continue;
|
||||||
|
const link = new Map();
|
||||||
|
for (const f of incident[v]) {
|
||||||
|
const other = indices.slice(f * 3, f * 3 + 3).filter((x) => x !== v);
|
||||||
|
if (other.length !== 2) {
|
||||||
|
valid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!link.has(other[0])) link.set(other[0], new Set());
|
||||||
|
if (!link.has(other[1])) link.set(other[1], new Set());
|
||||||
|
link.get(other[0]).add(other[1]);
|
||||||
|
link.get(other[1]).add(other[0]);
|
||||||
|
}
|
||||||
|
if (!valid) break;
|
||||||
|
const boundary = [...neighbors[v]].filter((n) => {
|
||||||
|
const key = v < n ? `${v},${n}` : `${n},${v}`;
|
||||||
|
return edgeFaces.get(key).length === 1;
|
||||||
|
});
|
||||||
|
const degrees = [...link.values()].map((x) => x.size);
|
||||||
|
if (
|
||||||
|
degrees.some((d) => d < 1 || d > 2) ||
|
||||||
|
(boundary.length === 0
|
||||||
|
? degrees.some((d) => d !== 2)
|
||||||
|
: boundary.length !== 2 || degrees.filter((d) => d === 1).length !== 2)
|
||||||
|
) {
|
||||||
|
valid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const reached = new Set(),
|
||||||
|
pending = [link.keys().next().value];
|
||||||
|
while (pending.length) {
|
||||||
|
const x = pending.pop();
|
||||||
|
if (reached.has(x)) continue;
|
||||||
|
reached.add(x);
|
||||||
|
for (const n of link.get(x)) pending.push(n);
|
||||||
|
}
|
||||||
|
if (reached.size !== link.size) valid = false;
|
||||||
|
}
|
||||||
|
return { neighbors, incident, edgeFaces, valid };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decimate(mesh, ratio = 0.5) {
|
||||||
|
if (!Number.isFinite(ratio) || ratio <= 0 || ratio > 1)
|
||||||
|
throw new Error("Ratio must be in (0, 1].");
|
||||||
|
let p = Array.from(mesh.positions),
|
||||||
|
idx = Array.from(mesh.indices),
|
||||||
|
masks = Array.from(mesh.masks || new Float32Array(p.length / 3));
|
||||||
|
const target = Math.max(4, Math.floor((idx.length / 3) * ratio)),
|
||||||
|
deadline = Date.now() + 2500;
|
||||||
|
let accepted = 0,
|
||||||
|
work = 0;
|
||||||
|
const MAX_PASSES = 64,
|
||||||
|
MAX_WORK = 1000000;
|
||||||
|
for (
|
||||||
|
let pass = 0;
|
||||||
|
pass < MAX_PASSES && idx.length / 3 > target && Date.now() < deadline;
|
||||||
|
pass++
|
||||||
|
) {
|
||||||
|
const { neighbors, incident, edgeFaces, valid } = topology(
|
||||||
|
idx,
|
||||||
|
p.length / 3,
|
||||||
|
),
|
||||||
|
protectedVertices = new Set();
|
||||||
|
if (!valid) break;
|
||||||
|
for (const [key, faces] of edgeFaces)
|
||||||
|
if (faces.length === 1)
|
||||||
|
for (const v of key.split(",").map(Number)) protectedVertices.add(v);
|
||||||
|
masks.forEach((m, v) => {
|
||||||
|
if (m !== 0) {
|
||||||
|
protectedVertices.add(v);
|
||||||
|
for (const n of neighbors[v]) protectedVertices.add(n);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const candidates = [];
|
||||||
|
for (const [key, edgeIncident] of edgeFaces) {
|
||||||
|
if (Date.now() >= deadline || work++ >= MAX_WORK) break;
|
||||||
|
if (edgeIncident.length === 2) {
|
||||||
|
const [a, b] = key.split(",").map(Number);
|
||||||
|
if (protectedVertices.has(a) || protectedVertices.has(b)) continue;
|
||||||
|
const opposite = edgeIncident.map((f) =>
|
||||||
|
idx.slice(f * 3, f * 3 + 3).find((v) => v !== a && v !== b),
|
||||||
|
),
|
||||||
|
common = [...neighbors[a]].filter((v) => neighbors[b].has(v));
|
||||||
|
if (
|
||||||
|
new Set(opposite).size !== 2 ||
|
||||||
|
common.length !== 2 ||
|
||||||
|
common.some((v) => !opposite.includes(v))
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
let d = 0;
|
||||||
|
for (let j = 0; j < 3; j++) d += (p[a * 3 + j] - p[b * 3 + j]) ** 2;
|
||||||
|
candidates.push([d, a, b]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]);
|
||||||
|
const replace = new Map(),
|
||||||
|
locked = new Set();
|
||||||
|
let removed = 0;
|
||||||
|
for (const [, a, b] of candidates) {
|
||||||
|
if (Date.now() >= deadline || work++ >= MAX_WORK) break;
|
||||||
|
if (idx.length / 3 - removed * 2 <= target) break;
|
||||||
|
if (locked.has(a) || locked.has(b)) continue;
|
||||||
|
const pos = [0, 1, 2].map((j) =>
|
||||||
|
Math.fround((p[a * 3 + j] + p[b * 3 + j]) / 2),
|
||||||
|
),
|
||||||
|
localFaces = [...new Set([...incident[a], ...incident[b]])],
|
||||||
|
edgeKey = a < b ? `${a},${b}` : `${b},${a}`,
|
||||||
|
omitted = new Set(edgeFaces.get(edgeKey)),
|
||||||
|
seen = new Set();
|
||||||
|
let candidateValid = true;
|
||||||
|
for (const f of localFaces) {
|
||||||
|
if (Date.now() >= deadline || work++ >= MAX_WORK) {
|
||||||
|
candidateValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const k = f * 3;
|
||||||
|
if (omitted.has(f)) continue;
|
||||||
|
const vs = [idx[k], idx[k + 1], idx[k + 2]].map((v) =>
|
||||||
|
v === b ? a : v,
|
||||||
|
);
|
||||||
|
const triangleKey = [...vs].sort((x, y) => x - y).join(",");
|
||||||
|
if (new Set(vs).size < 3 || seen.has(triangleKey)) {
|
||||||
|
candidateValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
seen.add(triangleKey);
|
||||||
|
const q = vs.map((v) => (v === a ? pos : p.slice(v * 3, v * 3 + 3)));
|
||||||
|
const ux = q[1][0] - q[0][0],
|
||||||
|
uy = q[1][1] - q[0][1],
|
||||||
|
uz = q[1][2] - q[0][2],
|
||||||
|
vx = q[2][0] - q[0][0],
|
||||||
|
vy = q[2][1] - q[0][1],
|
||||||
|
vz = q[2][2] - q[0][2];
|
||||||
|
const nx = uy * vz - uz * vy,
|
||||||
|
ny = uz * vx - ux * vz,
|
||||||
|
nz = ux * vy - uy * vx;
|
||||||
|
const ox = meshLikeNormal(p, idx, k);
|
||||||
|
if (
|
||||||
|
nx * nx + ny * ny + nz * nz < 1e-16 ||
|
||||||
|
nx * ox[0] + ny * ox[1] + nz * ox[2] <= 0
|
||||||
|
) {
|
||||||
|
candidateValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!candidateValid) continue;
|
||||||
|
replace.set(b, a);
|
||||||
|
p.splice(a * 3, 3, ...pos);
|
||||||
|
locked.add(a);
|
||||||
|
locked.add(b);
|
||||||
|
for (const n of neighbors[a]) locked.add(n);
|
||||||
|
for (const n of neighbors[b]) locked.add(n);
|
||||||
|
removed++;
|
||||||
|
accepted++;
|
||||||
|
}
|
||||||
|
if (!replace.size) break;
|
||||||
|
idx = idx.map((v) => replace.get(v) ?? v);
|
||||||
|
const seen = new Set();
|
||||||
|
idx = idx.reduce((o, v, k) => {
|
||||||
|
if (k % 3) return o;
|
||||||
|
const f = idx.slice(k, k + 3);
|
||||||
|
if (new Set(f).size < 3) return o;
|
||||||
|
const key = [...f].sort((a, b) => a - b).join(",");
|
||||||
|
// Candidate validation guarantees this. Never use duplicate removal to
|
||||||
|
// repair a collapse which violated the link condition.
|
||||||
|
if (seen.has(key))
|
||||||
|
throw new Error("Decimation produced duplicate faces.");
|
||||||
|
seen.add(key);
|
||||||
|
o.push(...f);
|
||||||
|
return o;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
if (!accepted)
|
||||||
|
return checked(mesh.positions, mesh.indices, mesh.level, mesh.masks);
|
||||||
|
const used = [...new Set(idx)].sort((a, b) => a - b),
|
||||||
|
remap = new Map(used.map((v, i) => [v, i])),
|
||||||
|
outp = [],
|
||||||
|
outm = [];
|
||||||
|
for (const v of used) {
|
||||||
|
outp.push(...p.slice(v * 3, v * 3 + 3));
|
||||||
|
outm.push(masks[v]);
|
||||||
|
}
|
||||||
|
return checked(
|
||||||
|
outp,
|
||||||
|
idx.map((v) => remap.get(v)),
|
||||||
|
mesh.level,
|
||||||
|
outm,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function meshLikeNormal(p, idx, k) {
|
||||||
|
const a = idx[k],
|
||||||
|
b = idx[k + 1],
|
||||||
|
c = idx[k + 2],
|
||||||
|
ux = p[b * 3] - p[a * 3],
|
||||||
|
uy = p[b * 3 + 1] - p[a * 3 + 1],
|
||||||
|
uz = p[b * 3 + 2] - p[a * 3 + 2],
|
||||||
|
vx = p[c * 3] - p[a * 3],
|
||||||
|
vy = p[c * 3 + 1] - p[a * 3 + 1],
|
||||||
|
vz = p[c * 3 + 2] - p[a * 3 + 2];
|
||||||
|
return [uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx];
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { buildMesh } from "./mesh.js";
|
||||||
|
import {
|
||||||
|
MAX_TRIANGLES,
|
||||||
|
MAX_VERTICES,
|
||||||
|
decimate,
|
||||||
|
preset,
|
||||||
|
subdivide,
|
||||||
|
} from "./topology.js";
|
||||||
|
|
||||||
|
function inspect(mesh) {
|
||||||
|
const edges = new Map();
|
||||||
|
let volume = 0;
|
||||||
|
for (let k = 0; k < mesh.indices.length; k += 3) {
|
||||||
|
const face = Array.from(mesh.indices.subarray(k, k + 3));
|
||||||
|
assert.equal(new Set(face).size, 3);
|
||||||
|
const [a, b, c] = face;
|
||||||
|
const p = mesh.positions;
|
||||||
|
volume +=
|
||||||
|
(p[a * 3] * (p[b * 3 + 1] * p[c * 3 + 2] - p[b * 3 + 2] * p[c * 3 + 1]) +
|
||||||
|
p[a * 3 + 1] * (p[b * 3 + 2] * p[c * 3] - p[b * 3] * p[c * 3 + 2]) +
|
||||||
|
p[a * 3 + 2] * (p[b * 3] * p[c * 3 + 1] - p[b * 3 + 1] * p[c * 3])) /
|
||||||
|
6;
|
||||||
|
for (const [x, y] of [
|
||||||
|
[a, b],
|
||||||
|
[b, c],
|
||||||
|
[c, a],
|
||||||
|
]) {
|
||||||
|
const key = x < y ? `${x},${y}` : `${y},${x}`;
|
||||||
|
edges.set(key, (edges.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok([...mesh.positions, ...mesh.normals].every(Number.isFinite));
|
||||||
|
assert.ok([...edges.values()].every((count) => count === 2));
|
||||||
|
assert.ok(
|
||||||
|
volume > 0,
|
||||||
|
`expected outward winding, signed volume was ${volume}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("presets are bounded, outward, closed manifold meshes", () => {
|
||||||
|
for (const name of ["sphere", "cube", "cylinder", "cone", "torus"]) {
|
||||||
|
const mesh = preset(name);
|
||||||
|
assert.ok(mesh.positions.length / 3 <= MAX_VERTICES);
|
||||||
|
assert.ok(mesh.indices.length / 3 <= MAX_TRIANGLES);
|
||||||
|
assert.ok(mesh.indices.length / 3 >= 2000);
|
||||||
|
assert.equal(mesh.masks.length, mesh.positions.length / 3);
|
||||||
|
inspect(mesh);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subdivision shares midpoints and interpolates masks without changing winding", () => {
|
||||||
|
const tetra = buildMesh(
|
||||||
|
[1, 1, 1, -1, -1, 1, -1, 1, -1, 1, -1, -1],
|
||||||
|
[0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3],
|
||||||
|
0,
|
||||||
|
[0, 1, 0.5, 0],
|
||||||
|
);
|
||||||
|
const result = subdivide(tetra);
|
||||||
|
assert.equal(result.indices.length, tetra.indices.length * 4);
|
||||||
|
assert.equal(result.positions.length / 3, 10); // 4 vertices + 6 unique edges.
|
||||||
|
const midpoint =
|
||||||
|
Array.from(result.positions).findIndex(
|
||||||
|
(_, k, p) =>
|
||||||
|
k % 3 === 0 && p[k] === 0 && p[k + 1] === 0 && p[k + 2] === 1,
|
||||||
|
) / 3;
|
||||||
|
assert.equal(result.masks[midpoint], 0.5);
|
||||||
|
assert.deepEqual(
|
||||||
|
Array.from(result.positions.slice(0, 12)),
|
||||||
|
Array.from(tetra.positions),
|
||||||
|
);
|
||||||
|
inspect(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decimation keeps a closed mesh and protects masked neighborhoods", () => {
|
||||||
|
const source = preset("sphere", 4);
|
||||||
|
source.masks[0] = 1;
|
||||||
|
const protectedFaces = Array.from(source.incident[0], (f) =>
|
||||||
|
Array.from(source.indices.slice(f * 3, f * 3 + 3)).map((v) =>
|
||||||
|
Array.from(source.positions.slice(v * 3, v * 3 + 3)).join(","),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const result = decimate(source, 0.75);
|
||||||
|
assert.ok(result.indices.length < source.indices.length);
|
||||||
|
inspect(result);
|
||||||
|
const coordinates = new Set();
|
||||||
|
for (let v = 0; v < result.positions.length / 3; v++)
|
||||||
|
coordinates.add(
|
||||||
|
Array.from(result.positions.slice(v * 3, v * 3 + 3)).join(","),
|
||||||
|
);
|
||||||
|
assert.ok(protectedFaces.flat().every((point) => coordinates.has(point)));
|
||||||
|
assert.equal([...result.masks].filter(Boolean).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fully masked decimation is a no-op", () => {
|
||||||
|
const source = preset("torus", 2);
|
||||||
|
source.masks.fill(1);
|
||||||
|
const result = decimate(source, 0.25);
|
||||||
|
assert.deepEqual(result.indices, source.indices);
|
||||||
|
assert.deepEqual(result.positions, source.positions);
|
||||||
|
assert.deepEqual(result.masks, source.masks);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subdivision rejects cap overflow before allocating output", () => {
|
||||||
|
const positions = new Float32Array((MAX_VERTICES - 1) * 3);
|
||||||
|
const mesh = buildMesh(positions, new Uint32Array([0, 1, 2]));
|
||||||
|
assert.throws(() => subdivide(mesh), /limits/);
|
||||||
|
});
|
||||||
@@ -115,6 +115,48 @@ function setup(settings = {}, sample = { x: 400, y: 300, pressure: 0.7 }) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
test("mask packets are single-flight and canceled geometry is repaired before masking", () => {
|
||||||
|
const { processor, mailbox, messages } = setup();
|
||||||
|
// Cancel intentionally leaves provisional worker geometry.
|
||||||
|
const before = processor.engine.mesh.positions.slice();
|
||||||
|
processor.frame();
|
||||||
|
assert.notDeepEqual(processor.engine.mesh.positions, before);
|
||||||
|
processor.cancel(1);
|
||||||
|
const sample = { x: 420, y: 290, pressure: 0.8 };
|
||||||
|
const version = mailbox.write(2, sample);
|
||||||
|
processor.begin({
|
||||||
|
id: 2,
|
||||||
|
version,
|
||||||
|
sample,
|
||||||
|
positions: before,
|
||||||
|
settings: { ...defaults, brush: "mask", symmetry: false },
|
||||||
|
camera: { yaw: 0, pitch: 0, zoom: 1.5, target: [0, 0, 0] },
|
||||||
|
rect: { width: 800, height: 600 },
|
||||||
|
hit: { position: [0, 0, 1], normal: [0, 0, 1] },
|
||||||
|
});
|
||||||
|
// Independently rebuild at the fixture's actual level.
|
||||||
|
const fresh = new Sculpt(sphere(processor.engine.mesh.level));
|
||||||
|
assert.deepEqual(processor.engine.mesh.positions, fresh.mesh.positions);
|
||||||
|
assert.deepEqual(processor.engine.mesh.normals, fresh.mesh.normals);
|
||||||
|
const start = messages.length;
|
||||||
|
processor.frame();
|
||||||
|
const packet = messages.at(-1);
|
||||||
|
assert.equal(packet.type, "mask");
|
||||||
|
const values = packet.masks.slice();
|
||||||
|
const end = mailbox.write(2, { x: 450, y: 310, pressure: 0.9 });
|
||||||
|
processor.end(2, end);
|
||||||
|
processor.frame();
|
||||||
|
assert.equal(messages.length, start + 1);
|
||||||
|
assert.deepEqual(packet.masks, values);
|
||||||
|
processor.acknowledge(packet.packet);
|
||||||
|
processor.frame();
|
||||||
|
const last = messages.at(-1);
|
||||||
|
assert.equal(last.type, "mask");
|
||||||
|
processor.acknowledge(last.packet);
|
||||||
|
processor.frame();
|
||||||
|
assert.equal(messages.at(-1).type, "complete");
|
||||||
|
assert.deepEqual(processor.engine.mesh.positions, before);
|
||||||
|
});
|
||||||
test("latest frame target equals feeding only that target, intermediate samples create no hidden queue", () => {
|
test("latest frame target equals feeding only that target, intermediate samples create no hidden queue", () => {
|
||||||
const a = setup(),
|
const a = setup(),
|
||||||
b = setup();
|
b = setup();
|
||||||
|
|||||||
Reference in New Issue
Block a user