Add core-powered web sculpting studio with tablet controls
Add pressure-aware worker sculpting, bounded shared input, project files and history, and compact icon navigation with an XYZ orbit gizmo. 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:
+3
-1
@@ -36,7 +36,7 @@ func newApplication(store *playgroundStore, packages map[string]memoryAsset, mon
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pages := make(map[string][]byte)
|
pages := make(map[string][]byte)
|
||||||
for _, name := range []string{"home.html", "docs.html", "playground.html", "gltf.html"} {
|
for _, name := range []string{"home.html", "docs.html", "playground.html", "gltf.html", "assets/sculpt/index.html"} {
|
||||||
content, err := webFiles.ReadFile("web/" + name)
|
content, err := webFiles.ReadFile("web/" + name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -89,6 +89,8 @@ func (app *application) routes() http.Handler {
|
|||||||
mux.HandleFunc("/playground/", app.servePlayground)
|
mux.HandleFunc("/playground/", app.servePlayground)
|
||||||
mux.HandleFunc("/view/gltf", app.servePage("gltf.html"))
|
mux.HandleFunc("/view/gltf", app.servePage("gltf.html"))
|
||||||
mux.HandleFunc("/view/gltf/", app.servePage("gltf.html"))
|
mux.HandleFunc("/view/gltf/", app.servePage("gltf.html"))
|
||||||
|
mux.HandleFunc("/view/sculpt", app.servePage("assets/sculpt/index.html"))
|
||||||
|
mux.HandleFunc("/view/sculpt/", app.servePage("assets/sculpt/index.html"))
|
||||||
mux.HandleFunc("/", app.serveHome)
|
mux.HandleFunc("/", app.serveHome)
|
||||||
return app.headers(mux)
|
return app.headers(mux)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Yawn Sculpt
|
||||||
|
|
||||||
|
Open `/view/sculpt` on the Yawn server (`npm run server`). All example code lives here; the only integration outside this folder is the embedded-page route in `server/app.go`. Rendering imports **only `@yawn/core`**. No handles, third-party scene graph, or remote document storage.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Drag the top-right XYZ gizmo to orbit, or its neighboring pan/zoom icons to move the view. Click an axis endpoint to align the camera; click the facing endpoint again to flip sides. The scene is Y-up. Navigation captures its pointer outside the controls and never sculpts; Escape, lost capture, blur and viewport changes restore the starting view. Navigation waits for an unfinished sculpt transaction to settle. Axis buttons and navigation controls also support keyboard use.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Performance bounds and limitations
|
||||||
|
|
||||||
|
| New sphere | Vertices | Triangles | History cap |
|
||||||
|
| ---------- | -------: | --------: | ----------: |
|
||||||
|
| Simple | 2,562 | 5,120 | 8 MiB |
|
||||||
|
| Balanced | 10,242 | 20,480 | 16 MiB |
|
||||||
|
| Fine | 40,962 | 81,920 | 32 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.
|
||||||
|
|
||||||
|
Input occupies **one 64-byte `sculptSample` row in core's SAB**, not a sample queue. Main publishes the latest endpoint/pressure; subtracting the previously consumed endpoint gives the accumulated movement. Atomic version tokens protect a coherent snapshot and retire only the consumed update, leaving concurrent newer input pending. A dedicated worker samples this slot with its own `requestAnimationFrame` and performs brush raycasts, deformation, normals and BVH refits. It interpolates straight segments between consumed endpoints; this frame-level approximation does not retain intermediate bends or pressure changes. Grab separately preserves the first effective pressure that establishes its influence.
|
||||||
|
|
||||||
|
The worker retains at most one unfinished segment plus the latest shared endpoint. Resumable interpolation yields after a soft 4 ms budget or 64 brush attempts; an unfinished segment exceeding 250 ms cancels the provisional stroke. One synchronous dab can exceed the budget, especially for large brushes and Detail. Shared memory avoids an input message queue, not the CPU cost of sculpting. Worker and renderer animation frames are independently scheduled, not a same-frame presentation guarantee.
|
||||||
|
|
||||||
|
Worker geometry packets are immutable, transferred, and single-flight; main applies them to its document mirror and acknowledges them. GPU uploads retain their separate single-flight staging guard. Initial picking, document/history operations, and one face-normal/BVH maintenance pass at stroke completion remain on main. Completed geometry/history survive renderer or sculpt-worker failure; unfinished work rolls back. Resize recreates both workers and the shared row because core does not expose surface resizing. Viewport rectangle changes cancel unfinished strokes immediately; new strokes wait for cancellation acknowledgement and the matching renderer size. The sculpt worker stops its animation loop between strokes; core's renderer still has its own loop.
|
||||||
|
|
||||||
|
Requires WebGPU, OffscreenCanvas, shared memory, cross-origin isolation, and dedicated-worker animation frames. Observable initialization/upload failures preserve the document and expose Retry plus Save/Export; core does not expose comprehensive device-loss detection. A front-facing brush filter is not geodesic/occlusion isolation on folded surfaces. This is a fixed-topology, single-sphere sculpting studio inspired by Nomad, **not Nomad parity**: no remesh, dynamic topology, layers, painting, multi-object scenes or arbitrary mesh import. Coordinates are bounded to ±8 model units.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node --test server/web/assets/sculpt/*.test.js
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Run `await (await import('/assets/sculpt/browser-check.js')).check()` on a fresh page for transaction and UI regressions. This harness stubs pointer capture; native acceptance must use a separate fresh page. Through the browser CDP endpoint (`agent-browser get cdp-url`), use `Input.dispatchMouseEvent` with `pointerType: 'pen'` to drag each navigation control beyond its bounds. Assert actual `hasPointerCapture(event.pointerId)`, release outside, and verify Escape restores all camera fields. During a visibly deforming stroke, focus Center view, send Escape with `Input.dispatchKeyEvent`, release the pen, and verify exact geometry and unchanged history after settlement.
|
||||||
|
|
||||||
|
For rail scrolling, enable `Emulation.setTouchEmulationEnabled` and set a 640×360 CSS-pixel viewport. Verify `(pointer: coarse)` and `scrollHeight > clientHeight`, then use `Input.dispatchTouchEvent` to swipe upward from the center of a brush button. Assert increasing rail `scrollTop`, unchanged selected brush and geometry, and successful tapping of Settings at the bottom. Temporarily overriding rail buttons to `touch-action: none` should reproduce zero scrolling. Do not substitute dispatched DOM pointer events, wheel scrolling, or assigning `scrollTop` for this native gesture check. Browser emulation is not physical-tablet verification.
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
import {
|
||||||
|
Sculpt,
|
||||||
|
defaults,
|
||||||
|
pressureValue,
|
||||||
|
saveProject,
|
||||||
|
loadProject,
|
||||||
|
exportOBJ,
|
||||||
|
} from "./engine.js";
|
||||||
|
import { sphere } from "./mesh.js";
|
||||||
|
import { Renderer } from "./renderer.js";
|
||||||
|
import { Input } from "./input.js";
|
||||||
|
import { StrokeClient } from "./stroke-client.js";
|
||||||
|
import { clamp, clampZoom, cameraBasis } from "./math.js";
|
||||||
|
import { Menus } from "./menus.js";
|
||||||
|
import { Navigation } from "./navigation.js";
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id),
|
||||||
|
viewport = $("viewport");
|
||||||
|
const settings = { ...defaults },
|
||||||
|
camera = {
|
||||||
|
yaw: 0,
|
||||||
|
pitch: 0,
|
||||||
|
zoom: 1.5,
|
||||||
|
target: [0, 0, 0],
|
||||||
|
color: [0.5, 0.3, 0.2],
|
||||||
|
};
|
||||||
|
let engine = new Sculpt(),
|
||||||
|
renderer = null,
|
||||||
|
initializing = false,
|
||||||
|
pendingResize = false,
|
||||||
|
frameRequest = 0,
|
||||||
|
publishing = false;
|
||||||
|
let viewRevision = 0,
|
||||||
|
published = -1,
|
||||||
|
generation = 0,
|
||||||
|
lastSize = "",
|
||||||
|
modified = false,
|
||||||
|
saved = engine.mesh.positions.slice(),
|
||||||
|
fileBusy = false;
|
||||||
|
const brushes = ["draw", "inflate", "smooth", "flatten", "grab"];
|
||||||
|
let viewNavigation, cameraAspect, messageTimer;
|
||||||
|
const input = new Input(viewport, {
|
||||||
|
engine: () => engine,
|
||||||
|
processor: () => renderer.processor,
|
||||||
|
camera,
|
||||||
|
settings,
|
||||||
|
ready: () =>
|
||||||
|
!!renderer &&
|
||||||
|
!initializing &&
|
||||||
|
!fileBusy &&
|
||||||
|
!viewNavigation?.busy &&
|
||||||
|
sizeKey() === lastSize,
|
||||||
|
blocked: () => !!viewNavigation?.busy,
|
||||||
|
navigation: () => "sculpt",
|
||||||
|
palmRejection: () => $("palm-rejection").checked,
|
||||||
|
schedule,
|
||||||
|
invalidate,
|
||||||
|
changed: updateUI,
|
||||||
|
committed: checkModified,
|
||||||
|
message,
|
||||||
|
cursor,
|
||||||
|
pressure,
|
||||||
|
});
|
||||||
|
const menus = new Menus(
|
||||||
|
() => !interactionBusy(),
|
||||||
|
(e) => e.target === viewport.querySelector("canvas") && input.rejectsTouch(e),
|
||||||
|
);
|
||||||
|
viewNavigation = new Navigation($("view-navigation"), viewport, {
|
||||||
|
camera,
|
||||||
|
ready: () =>
|
||||||
|
!!renderer &&
|
||||||
|
!initializing &&
|
||||||
|
!fileBusy &&
|
||||||
|
!input.busy &&
|
||||||
|
sizeKey() === lastSize &&
|
||||||
|
!document.querySelector("dialog[open]"),
|
||||||
|
start: () => {
|
||||||
|
menus.close(false);
|
||||||
|
cursor(null);
|
||||||
|
},
|
||||||
|
changed: updateUI,
|
||||||
|
invalidate,
|
||||||
|
});
|
||||||
|
function interactionBusy() {
|
||||||
|
return input.busy || !!viewNavigation?.busy;
|
||||||
|
}
|
||||||
|
$("palm-rejection").onchange = () => {
|
||||||
|
if ($("palm-rejection").checked && input.active?.type === "touch")
|
||||||
|
input.cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
function message(text) {
|
||||||
|
$("status").textContent = text;
|
||||||
|
$("status").classList.add("visible");
|
||||||
|
clearTimeout(messageTimer);
|
||||||
|
messageTimer = setTimeout(
|
||||||
|
() => $("status").classList.remove("visible"),
|
||||||
|
3500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function checkModified() {
|
||||||
|
modified = !engine.mesh.positions.every((v, i) => v === saved[i]);
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
function updateUI() {
|
||||||
|
const busy = interactionBusy() || initializing || fileBusy;
|
||||||
|
$("undo").disabled = busy || !engine.undoStack.length;
|
||||||
|
$("redo").disabled = busy || !engine.redoStack.length;
|
||||||
|
for (const id of ["new", "open", "quality"]) $(id).disabled = busy;
|
||||||
|
for (const id of ["save", "export"])
|
||||||
|
$(id).disabled = interactionBusy() || fileBusy;
|
||||||
|
$("modified").hidden = !modified;
|
||||||
|
if (pendingResize && !resizeTimer && !interactionBusy() && !initializing) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (pendingResize && !resizeTimer) recreate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function cursor(sample) {
|
||||||
|
const el = $("cursor");
|
||||||
|
el.hidden = !sample || !renderer || initializing || !!viewNavigation?.busy;
|
||||||
|
if (!sample) return;
|
||||||
|
const s = engine.stroke?.settings ?? settings;
|
||||||
|
const p = pressureValue(sample.pressure, s.gain, s.gamma),
|
||||||
|
radius =
|
||||||
|
(s.radius *
|
||||||
|
(s.pressureRadius ? 0.25 + 0.75 * p : 1) *
|
||||||
|
viewport.clientHeight) /
|
||||||
|
(2 * camera.zoom);
|
||||||
|
el.style.width = el.style.height = `${radius * 2}px`;
|
||||||
|
el.style.left = `${sample.x}px`;
|
||||||
|
el.style.top = `${sample.y}px`;
|
||||||
|
}
|
||||||
|
function pressure(value) {
|
||||||
|
$("pen-state").textContent =
|
||||||
|
value === null ? "Mouse / touch" : `Pen · ${Math.round(value * 100)}%`;
|
||||||
|
$("pressure-fill").style.width =
|
||||||
|
`${value === null ? 0 : pressureValue(value, settings.gain, settings.gamma) * 100}%`;
|
||||||
|
}
|
||||||
|
function invalidate() {
|
||||||
|
viewRevision++;
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
function schedule() {
|
||||||
|
if (!frameRequest) frameRequest = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
function fail(error) {
|
||||||
|
viewNavigation?.cancel();
|
||||||
|
generation++;
|
||||||
|
renderer?.dispose();
|
||||||
|
renderer = null;
|
||||||
|
engine.markAll();
|
||||||
|
input.cancel();
|
||||||
|
$("loading").hidden = true;
|
||||||
|
$("error").hidden = false;
|
||||||
|
console.error(error);
|
||||||
|
$("error-detail").textContent =
|
||||||
|
"The canvas could not start. Try again, or open this page in an up-to-date browser. You can still download your project in Settings.";
|
||||||
|
message("Canvas paused. Your completed work is still here.");
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
function tick() {
|
||||||
|
frameRequest = 0;
|
||||||
|
viewNavigation.checkViewport();
|
||||||
|
viewNavigation.render();
|
||||||
|
input.checkViewport();
|
||||||
|
if (
|
||||||
|
renderer &&
|
||||||
|
!initializing &&
|
||||||
|
!publishing &&
|
||||||
|
(published !== viewRevision || engine.dirty.size)
|
||||||
|
) {
|
||||||
|
const active = renderer,
|
||||||
|
token = generation,
|
||||||
|
revision = viewRevision;
|
||||||
|
const snapshot = {
|
||||||
|
...camera,
|
||||||
|
target: camera.target.slice(),
|
||||||
|
color: camera.color.slice(),
|
||||||
|
};
|
||||||
|
publishing = true;
|
||||||
|
active
|
||||||
|
.publish(engine, snapshot)
|
||||||
|
.then(() => {
|
||||||
|
if (token === generation) published = revision;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (token === generation) fail(error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
publishing = false;
|
||||||
|
if (
|
||||||
|
renderer &&
|
||||||
|
!initializing &&
|
||||||
|
(published !== viewRevision || engine.dirty.size)
|
||||||
|
)
|
||||||
|
schedule();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function sizeKey() {
|
||||||
|
const rect = viewport.getBoundingClientRect();
|
||||||
|
return `${rect.width}:${rect.height}:${devicePixelRatio}:${$("quality").value}`;
|
||||||
|
}
|
||||||
|
async function recreate() {
|
||||||
|
if (interactionBusy() || initializing) {
|
||||||
|
pendingResize = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!viewport.clientWidth || !viewport.clientHeight) return;
|
||||||
|
const aspect = viewport.clientWidth / viewport.clientHeight;
|
||||||
|
if (cameraAspect)
|
||||||
|
camera.zoom *= Math.min(1, cameraAspect) / Math.min(1, aspect);
|
||||||
|
cameraAspect = aspect;
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = 0;
|
||||||
|
pendingResize = false;
|
||||||
|
initializing = true;
|
||||||
|
const token = ++generation;
|
||||||
|
lastSize = sizeKey();
|
||||||
|
renderer?.dispose();
|
||||||
|
renderer = null;
|
||||||
|
$("loading").hidden = false;
|
||||||
|
$("error").hidden = true;
|
||||||
|
cursor(null);
|
||||||
|
updateUI();
|
||||||
|
try {
|
||||||
|
if (!crossOriginIsolated || typeof SharedArrayBuffer === "undefined")
|
||||||
|
throw new Error("Cross-origin isolation is required");
|
||||||
|
if (!navigator.gpu) throw new Error("WebGPU is not available");
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.setAttribute("aria-label", "3D sculpt");
|
||||||
|
viewport.querySelector("canvas").replaceWith(canvas);
|
||||||
|
const next = await Renderer.create(
|
||||||
|
canvas,
|
||||||
|
engine.mesh,
|
||||||
|
camera,
|
||||||
|
$("quality").value,
|
||||||
|
);
|
||||||
|
if (token !== generation) {
|
||||||
|
next.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderer = next;
|
||||||
|
next.processor = new StrokeClient(
|
||||||
|
next.sample,
|
||||||
|
engine.mesh.level,
|
||||||
|
(error) => {
|
||||||
|
if (token === generation) fail(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await next.processor.ready;
|
||||||
|
if (token !== generation) {
|
||||||
|
next.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
engine.markAll();
|
||||||
|
published = -1;
|
||||||
|
$("loading").hidden = true;
|
||||||
|
if (sizeKey() !== lastSize) resized();
|
||||||
|
else message("Ready to sculpt");
|
||||||
|
} catch (error) {
|
||||||
|
if (token === generation) fail(error);
|
||||||
|
} finally {
|
||||||
|
initializing = false;
|
||||||
|
updateUI();
|
||||||
|
if (renderer) invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function frameModel() {
|
||||||
|
cameraAspect = viewport.clientWidth / viewport.clientHeight;
|
||||||
|
const bounds = engine.bvh.root;
|
||||||
|
camera.target = bounds.min.map((v, k) => (v + bounds.max[k]) / 2);
|
||||||
|
const { right, up } = cameraBasis(camera.yaw, camera.pitch);
|
||||||
|
const half = bounds.max.map((v, k) => (v - bounds.min[k]) / 2);
|
||||||
|
const width = half.reduce((sum, v, k) => sum + v * Math.abs(right[k]), 0);
|
||||||
|
const height = half.reduce((sum, v, k) => sum + v * Math.abs(up[k]), 0);
|
||||||
|
camera.zoom = Math.max(
|
||||||
|
0.2,
|
||||||
|
1.45 *
|
||||||
|
Math.max(height, (width * viewport.clientHeight) / viewport.clientWidth),
|
||||||
|
);
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
function replace(mesh, name) {
|
||||||
|
engine = new Sculpt(mesh, 2 ** (mesh.level - 4) * 8 * 1048576);
|
||||||
|
saved = mesh.positions.slice();
|
||||||
|
modified = false;
|
||||||
|
$("document-name").textContent = name;
|
||||||
|
camera.yaw = 0;
|
||||||
|
camera.pitch = 0;
|
||||||
|
frameModel();
|
||||||
|
recreate();
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
function download(text, name, type) {
|
||||||
|
const url = URL.createObjectURL(new Blob([text], { type }));
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = name;
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
$("save").onclick = () => {
|
||||||
|
if (interactionBusy() || fileBusy) return;
|
||||||
|
download(saveProject(engine.mesh), "sculpt.yawn-sculpt", "application/json");
|
||||||
|
saved = engine.mesh.positions.slice();
|
||||||
|
modified = false;
|
||||||
|
updateUI();
|
||||||
|
message("Project downloaded");
|
||||||
|
};
|
||||||
|
$("export").onclick = () => {
|
||||||
|
if (interactionBusy() || fileBusy) return;
|
||||||
|
download(exportOBJ(engine.mesh), "sculpt.obj", "text/plain");
|
||||||
|
message("Model downloaded");
|
||||||
|
};
|
||||||
|
$("open").onclick = () => {
|
||||||
|
if (!interactionBusy() && !initializing) $("file").click();
|
||||||
|
};
|
||||||
|
$("file").onchange = async () => {
|
||||||
|
const file = $("file").files[0];
|
||||||
|
$("file").value = "";
|
||||||
|
if (!file || interactionBusy() || initializing) return;
|
||||||
|
fileBusy = true;
|
||||||
|
updateUI();
|
||||||
|
try {
|
||||||
|
if (file.size > 8 * 1048576)
|
||||||
|
throw new Error("Project exceeds the 8 MiB limit");
|
||||||
|
const mesh = loadProject(await file.text());
|
||||||
|
if (
|
||||||
|
modified &&
|
||||||
|
!confirm(
|
||||||
|
"Replace your current sculpt? Download it first if you want to keep it.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
replace(mesh, file.name.replace(/\.yawn-sculpt$/, ""));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
message(
|
||||||
|
"Could not open this project. Choose a project downloaded from Sculpt.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fileBusy = false;
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$("new").onclick = () => {
|
||||||
|
if (interactionBusy() || initializing) return;
|
||||||
|
$("new-warning").textContent = modified
|
||||||
|
? "This replaces your unsaved sculpt. Save it first to keep it."
|
||||||
|
: "Choose how much detail you want.";
|
||||||
|
$("new-dialog").returnValue = "cancel";
|
||||||
|
$("new-dialog").showModal();
|
||||||
|
};
|
||||||
|
$("new-dialog").addEventListener("close", () => {
|
||||||
|
if ($("new-dialog").returnValue === "create")
|
||||||
|
replace(sphere(Number($("density").value)), "Untitled sphere");
|
||||||
|
});
|
||||||
|
$("help").onclick = () => $("help-dialog").showModal();
|
||||||
|
$("undo").onclick = () => {
|
||||||
|
if (!interactionBusy() && engine.restore()) {
|
||||||
|
checkModified();
|
||||||
|
invalidate();
|
||||||
|
message("Undo");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$("redo").onclick = () => {
|
||||||
|
if (!interactionBusy() && engine.restore(true)) {
|
||||||
|
checkModified();
|
||||||
|
invalidate();
|
||||||
|
message("Redo");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$("frame").onclick = () => {
|
||||||
|
if (!interactionBusy()) frameModel();
|
||||||
|
};
|
||||||
|
function chooseBrush(name) {
|
||||||
|
if (interactionBusy()) return;
|
||||||
|
settings.brush = name;
|
||||||
|
document
|
||||||
|
.querySelectorAll("[data-brush]")
|
||||||
|
.forEach((b) =>
|
||||||
|
b.setAttribute("aria-pressed", String(b.dataset.brush === name)),
|
||||||
|
);
|
||||||
|
$("brush-title").textContent = name[0].toUpperCase() + name.slice(1);
|
||||||
|
$("invert").disabled = !["draw", "inflate"].includes(name);
|
||||||
|
}
|
||||||
|
document
|
||||||
|
.querySelectorAll("[data-brush]")
|
||||||
|
.forEach((b) => (b.onclick = () => chooseBrush(b.dataset.brush)));
|
||||||
|
function settingsUI() {
|
||||||
|
for (const id of ["radius", "strength", "gain", "gamma"]) {
|
||||||
|
settings[id] = Number($(id).value);
|
||||||
|
$(id).style.setProperty(
|
||||||
|
"--slider-value",
|
||||||
|
`${(100 * (settings[id] - Number($(id).min))) / (Number($(id).max) - Number($(id).min))}%`,
|
||||||
|
);
|
||||||
|
$(id + "-value").textContent =
|
||||||
|
id === "radius"
|
||||||
|
? `${Math.round((settings[id] / 0.6) * 100)}%`
|
||||||
|
: id === "strength"
|
||||||
|
? `${Math.round(settings[id] * 100)}%`
|
||||||
|
: settings[id].toFixed(2) + (id === "gain" ? "×" : "");
|
||||||
|
}
|
||||||
|
for (const id of ["symmetry", "invert", "pressureRadius"])
|
||||||
|
settings[id] = $(id).checked;
|
||||||
|
$("curve-line").setAttribute(
|
||||||
|
"d",
|
||||||
|
Array.from(
|
||||||
|
{ length: 41 },
|
||||||
|
(_, i) =>
|
||||||
|
`${i ? "L" : "M"}${i * 5} ${53 - 52 * pressureValue(i / 40, settings.gain, settings.gamma)}`,
|
||||||
|
).join(" "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const id of [
|
||||||
|
"radius",
|
||||||
|
"strength",
|
||||||
|
"gain",
|
||||||
|
"gamma",
|
||||||
|
"symmetry",
|
||||||
|
"invert",
|
||||||
|
"pressureRadius",
|
||||||
|
])
|
||||||
|
$(id).addEventListener("input", settingsUI);
|
||||||
|
$("reset-pressure").onclick = () => {
|
||||||
|
$("gain").value = "1";
|
||||||
|
$("gamma").value = "1";
|
||||||
|
$("pressureRadius").checked = false;
|
||||||
|
settingsUI();
|
||||||
|
};
|
||||||
|
document.querySelectorAll("[data-color]").forEach(
|
||||||
|
(b) =>
|
||||||
|
(b.onclick = () => {
|
||||||
|
camera.color = b.dataset.color.split(",").map(Number);
|
||||||
|
document
|
||||||
|
.querySelectorAll("[data-color]")
|
||||||
|
.forEach((x) => x.setAttribute("aria-pressed", String(x === b)));
|
||||||
|
invalidate();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
$("fullscreen").hidden = !document.fullscreenEnabled;
|
||||||
|
$("fullscreen").onclick = async () => {
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement) await document.exitFullscreen();
|
||||||
|
else await document.documentElement.requestFullscreen();
|
||||||
|
} catch {
|
||||||
|
message("Full screen isn't available in this browser.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("fullscreenchange", () => {
|
||||||
|
$("fullscreen").textContent = document.fullscreenElement
|
||||||
|
? "Exit full screen"
|
||||||
|
: "Full screen";
|
||||||
|
});
|
||||||
|
$("quality").onchange = () => {
|
||||||
|
pendingResize = true;
|
||||||
|
recreate();
|
||||||
|
};
|
||||||
|
$("retry").onclick = recreate;
|
||||||
|
let resizeTimer;
|
||||||
|
function resized() {
|
||||||
|
viewNavigation.checkViewport();
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(() => {
|
||||||
|
resizeTimer = 0;
|
||||||
|
if (sizeKey() !== lastSize) {
|
||||||
|
pendingResize = true;
|
||||||
|
recreate();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
const unchanged = input.checkViewport();
|
||||||
|
if (sizeKey() !== lastSize) {
|
||||||
|
cursor(null);
|
||||||
|
if (unchanged) message("Updating viewport…");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
new ResizeObserver(resized).observe(viewport);
|
||||||
|
window.addEventListener("resize", resized);
|
||||||
|
function watchDpr() {
|
||||||
|
const query = matchMedia(`(resolution: ${devicePixelRatio}dppx)`);
|
||||||
|
query.addEventListener(
|
||||||
|
"change",
|
||||||
|
() => {
|
||||||
|
resized();
|
||||||
|
watchDpr();
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
watchDpr();
|
||||||
|
window.addEventListener("beforeunload", (e) => {
|
||||||
|
if (modified || engine.stroke) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.hidden) input.cancel();
|
||||||
|
const active = renderer,
|
||||||
|
token = generation;
|
||||||
|
if (!active) return;
|
||||||
|
(document.hidden ? active.core.pause() : active.core.play())
|
||||||
|
.then(() => {
|
||||||
|
if (token === generation && !document.hidden) invalidate();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (token === generation) fail(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
window.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && !document.querySelector("dialog[open]")) {
|
||||||
|
viewNavigation.cancel();
|
||||||
|
input.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
document.querySelector("dialog[open]") ||
|
||||||
|
e.target.closest?.(".panel,.tool-rail,#view-navigation") ||
|
||||||
|
/INPUT|SELECT|TEXTAREA/.test(e.target.tagName) ||
|
||||||
|
e.target.isContentEditable
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (interactionBusy()) return;
|
||||||
|
const key = e.key.toLowerCase(),
|
||||||
|
mod = e.ctrlKey || e.metaKey;
|
||||||
|
if (mod && ["z", "s", "o", "y"].includes(key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
$(
|
||||||
|
key === "s"
|
||||||
|
? "save"
|
||||||
|
: key === "o"
|
||||||
|
? "open"
|
||||||
|
: key === "y" || e.shiftKey
|
||||||
|
? "redo"
|
||||||
|
: "undo",
|
||||||
|
).click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mod || e.altKey) return;
|
||||||
|
if (/^[1-5]$/.test(key)) chooseBrush(brushes[Number(key) - 1]);
|
||||||
|
else if (key === "f") $("frame").click();
|
||||||
|
else if (key === "?") $("help").click();
|
||||||
|
else if (key === "[" || key === "]") {
|
||||||
|
$("radius").value = String(
|
||||||
|
clamp(settings.radius + (key === "]" ? 0.01 : -0.01), 0.04, 0.6),
|
||||||
|
);
|
||||||
|
settingsUI();
|
||||||
|
} else if (
|
||||||
|
["arrowleft", "arrowright", "arrowup", "arrowdown", "+", "=", "-"].includes(
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (key === "arrowleft") camera.yaw -= 0.1;
|
||||||
|
else if (key === "arrowright") camera.yaw += 0.1;
|
||||||
|
else if (key === "arrowup")
|
||||||
|
camera.pitch = clamp(camera.pitch + 0.1, -Math.PI / 2, Math.PI / 2);
|
||||||
|
else if (key === "arrowdown")
|
||||||
|
camera.pitch = clamp(camera.pitch - 0.1, -Math.PI / 2, Math.PI / 2);
|
||||||
|
else
|
||||||
|
camera.zoom = clampZoom(
|
||||||
|
camera.zoom * (key === "-" ? 1.1 : 0.9),
|
||||||
|
viewport.getBoundingClientRect(),
|
||||||
|
);
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
settingsUI();
|
||||||
|
frameModel();
|
||||||
|
updateUI();
|
||||||
|
recreate();
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
// Run explicitly in a fresh /view/sculpt page:
|
||||||
|
// await (await import('/assets/sculpt/browser-check.js')).check()
|
||||||
|
// Synthetic input checks logic, not physical pen feel. Hooks are always restored.
|
||||||
|
import { Renderer } from "./renderer.js";
|
||||||
|
import { Navigation } from "./navigation.js";
|
||||||
|
|
||||||
|
export async function check() {
|
||||||
|
const $ = (id) => document.getElementById(id),
|
||||||
|
results = [];
|
||||||
|
const assert = (value, message) => {
|
||||||
|
if (!value) throw new Error(message);
|
||||||
|
};
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const wait = async (fn) => {
|
||||||
|
const start = performance.now();
|
||||||
|
while (!fn()) {
|
||||||
|
if (performance.now() - start > 15000)
|
||||||
|
throw new Error("Timed out waiting for UI");
|
||||||
|
await sleep(25);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const viewport = $("viewport"),
|
||||||
|
capture = viewport.setPointerCapture,
|
||||||
|
createURL = URL.createObjectURL,
|
||||||
|
publish = Renderer.prototype.publish;
|
||||||
|
const navRoot = $("view-navigation"),
|
||||||
|
navCapture = navRoot.setPointerCapture,
|
||||||
|
navRender = Navigation.prototype.render;
|
||||||
|
let nav;
|
||||||
|
navRoot.setPointerCapture = () => {};
|
||||||
|
Navigation.prototype.render = function () {
|
||||||
|
nav = this;
|
||||||
|
return navRender.call(this);
|
||||||
|
};
|
||||||
|
let blob,
|
||||||
|
engine,
|
||||||
|
camera,
|
||||||
|
processor,
|
||||||
|
publications = 0,
|
||||||
|
failNext = false;
|
||||||
|
viewport.setPointerCapture = () => {};
|
||||||
|
URL.createObjectURL = function (value) {
|
||||||
|
blob = value;
|
||||||
|
return createURL.call(this, value);
|
||||||
|
};
|
||||||
|
Renderer.prototype.publish = function (...args) {
|
||||||
|
engine = args[0];
|
||||||
|
camera = args[1];
|
||||||
|
processor = this.processor;
|
||||||
|
publications++;
|
||||||
|
if (failNext) {
|
||||||
|
failNext = false;
|
||||||
|
return Promise.reject(new Error("Synthetic upload failure"));
|
||||||
|
}
|
||||||
|
return publish.apply(this, args);
|
||||||
|
};
|
||||||
|
const saved = async () => {
|
||||||
|
await wait(() => !$("save").disabled);
|
||||||
|
$("save").click();
|
||||||
|
return JSON.parse(await blob.text());
|
||||||
|
};
|
||||||
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
const pointer = (type, x, y, pressure = 0.7, pointerType = "pen", id = 7) => {
|
||||||
|
const r = viewport.getBoundingClientRect();
|
||||||
|
viewport.querySelector("canvas").dispatchEvent(
|
||||||
|
new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
pointerId: id,
|
||||||
|
pointerType,
|
||||||
|
clientX: r.left + r.width * x,
|
||||||
|
clientY: r.top + r.height * y,
|
||||||
|
pressure,
|
||||||
|
button: 0,
|
||||||
|
buttons: type === "pointerup" ? 0 : 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const stroke = async (p) => {
|
||||||
|
pointer("pointerdown", 0.55, 0.44, p);
|
||||||
|
pointer("pointermove", 0.59, 0.51, p);
|
||||||
|
pointer("pointerup", 0.59, 0.51, 0);
|
||||||
|
await wait(() => !$("save").disabled);
|
||||||
|
await sleep(100);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await wait(() => $("loading").hidden && $("error").hidden);
|
||||||
|
const initialCanvas = viewport.querySelector("canvas");
|
||||||
|
const bounds = JSON.stringify(viewport.getBoundingClientRect());
|
||||||
|
assert(
|
||||||
|
$("tools-panel").hidden && $("inspector").hidden,
|
||||||
|
"Panels must start closed",
|
||||||
|
);
|
||||||
|
const hover = (id, type = "pointerenter") =>
|
||||||
|
$(id).dispatchEvent(
|
||||||
|
new PointerEvent(type, { pointerType: "pen", buttons: 0 }),
|
||||||
|
);
|
||||||
|
hover("tools-toggle");
|
||||||
|
assert(!$("tools-panel").hidden, "Pen hover did not reveal brushes");
|
||||||
|
const palette = $("tools-panel").getBoundingClientRect();
|
||||||
|
assert(
|
||||||
|
palette.left >= 50 && palette.width <= 216,
|
||||||
|
"Brush popover overlaps rail or has oversized width",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
document.querySelectorAll(".tool-rail [data-brush] svg").length === 5,
|
||||||
|
"Brush rail must have five icons",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
$("radius").style.getPropertyValue("--slider-value").startsWith("32.14"),
|
||||||
|
"Basecoat size fill is not synchronized",
|
||||||
|
);
|
||||||
|
pointer("pointerdown", 0.8, 0.8, 1, "touch", 91);
|
||||||
|
pointer("pointermove", 0.82, 0.81, 1, "touch", 91);
|
||||||
|
pointer("pointerup", 0.82, 0.81, 0, "touch", 91);
|
||||||
|
assert(!$("tools-panel").hidden, "Rejected palm dismissed hover tools");
|
||||||
|
hover("tools-toggle", "pointerleave");
|
||||||
|
await sleep(100);
|
||||||
|
hover("tools-panel");
|
||||||
|
await sleep(300);
|
||||||
|
assert(!$("tools-panel").hidden, "Crossing panel gap closed tools");
|
||||||
|
hover("tools-panel", "pointerleave");
|
||||||
|
await sleep(300);
|
||||||
|
assert($("tools-panel").hidden, "Hover panel did not dismiss");
|
||||||
|
$("settings-toggle").click();
|
||||||
|
hover("settings-toggle", "pointerleave");
|
||||||
|
await sleep(300);
|
||||||
|
assert(!$("inspector").hidden, "Tapped panel did not stay open");
|
||||||
|
pointer("pointerdown", 0.2, 0.8, 1, "touch", 91);
|
||||||
|
pointer("pointerup", 0.2, 0.8, 0, "touch", 91);
|
||||||
|
assert(!$("inspector").hidden, "Rejected palm dismissed pinned settings");
|
||||||
|
$("pen-settings").open = true;
|
||||||
|
assert(
|
||||||
|
$("reset-pressure").getBoundingClientRect().bottom <=
|
||||||
|
$("pen-settings").nextElementSibling.getBoundingClientRect().top,
|
||||||
|
"Expanded pen controls overlap display disclosure",
|
||||||
|
);
|
||||||
|
$("gain").dispatchEvent(
|
||||||
|
new PointerEvent("pointerdown", { pointerType: "pen", bubbles: true }),
|
||||||
|
);
|
||||||
|
pointer("pointerdown", 0.2, 0.8, 1, "touch", 91);
|
||||||
|
assert(
|
||||||
|
!$("inspector").hidden,
|
||||||
|
"Rejected palm interrupted slider interaction",
|
||||||
|
);
|
||||||
|
pointer("pointerup", 0.2, 0.8, 0, "touch", 91);
|
||||||
|
$("gain").focus();
|
||||||
|
$("gain").dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
$("inspector").hidden && document.activeElement === $("settings-toggle"),
|
||||||
|
"Escape did not restore launcher focus",
|
||||||
|
);
|
||||||
|
$("pen-settings").open = false;
|
||||||
|
assert(
|
||||||
|
initialCanvas === viewport.querySelector("canvas") &&
|
||||||
|
bounds === JSON.stringify(viewport.getBoundingClientRect()),
|
||||||
|
"Menus resized/replaced canvas",
|
||||||
|
);
|
||||||
|
results.push(
|
||||||
|
"pen hover / gap grace / auto-hide / tap pin / palm-safe panels and slider / Escape focus / unchanged canvas",
|
||||||
|
);
|
||||||
|
$("new").click();
|
||||||
|
$("density").value = "4";
|
||||||
|
$("new-dialog").close("create");
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
viewport.querySelector("canvas") !== initialCanvas &&
|
||||||
|
$("loading").hidden &&
|
||||||
|
!$("new").disabled,
|
||||||
|
);
|
||||||
|
await sleep(100);
|
||||||
|
const original = await saved();
|
||||||
|
await stroke(0);
|
||||||
|
assert(same(original, await saved()), "Zero pen pressure altered geometry");
|
||||||
|
results.push("zero pen pressure");
|
||||||
|
await stroke(0.8);
|
||||||
|
const edited = await saved();
|
||||||
|
assert(!same(original, edited), "Pen stroke did not deform mesh");
|
||||||
|
assert(!$("undo").disabled, "Undo unavailable");
|
||||||
|
$("undo").click();
|
||||||
|
assert(same(original, await saved()), "Undo not exact");
|
||||||
|
$("redo").click();
|
||||||
|
assert(same(edited, await saved()), "Redo not exact");
|
||||||
|
results.push("pen sculpt / exact undo / redo");
|
||||||
|
pointer("pointerdown", 0.48, 0.5, 0.9);
|
||||||
|
await sleep(100);
|
||||||
|
pointer("pointermove", 0.52, 0.55, 0.9);
|
||||||
|
viewport.dispatchEvent(new PointerEvent("pointercancel", { pointerId: 7 }));
|
||||||
|
assert(same(edited, await saved()), "Cancel did not restore mesh");
|
||||||
|
results.push("pointer cancellation");
|
||||||
|
for (const settling of [false, true]) {
|
||||||
|
const before = engine.mesh.positions.slice();
|
||||||
|
const history = [...engine.undoStack];
|
||||||
|
const redoHistory = [...engine.redoStack];
|
||||||
|
pointer("pointerdown", 0.48, 0.5, 0.9);
|
||||||
|
pointer("pointermove", 0.56, 0.55, 0.9);
|
||||||
|
await wait(() => engine.mesh.positions.some((v, i) => v !== before[i]));
|
||||||
|
$("frame").focus();
|
||||||
|
if (settling) pointer("pointerup", 0.56, 0.55, 0);
|
||||||
|
$("frame").dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", {
|
||||||
|
key: "Escape",
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
engine.mesh.positions.every((v, i) => v === before[i]),
|
||||||
|
"Focused navigation swallowed sculpt cancellation",
|
||||||
|
);
|
||||||
|
if (!settling) pointer("pointerup", 0.56, 0.55, 0);
|
||||||
|
await wait(() => !$("save").disabled);
|
||||||
|
assert(
|
||||||
|
engine.mesh.positions.every((v, i) => v === before[i]) &&
|
||||||
|
engine.undoStack.length === history.length &&
|
||||||
|
engine.undoStack.every((v, i) => v === history[i]) &&
|
||||||
|
engine.redoStack.length === redoHistory.length &&
|
||||||
|
engine.redoStack.every((v, i) => v === redoHistory[i]),
|
||||||
|
"Focused navigation cancellation changed geometry/history after settlement",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
results.push(
|
||||||
|
"focused navigation Escape rolls back active and settling sculpt",
|
||||||
|
);
|
||||||
|
assert($("palm-rejection").checked, "Palm rejection must default on");
|
||||||
|
await sleep(100);
|
||||||
|
const beforePalm = structuredClone(camera);
|
||||||
|
pointer("pointerdown", 0.48, 0.5, 1, "touch", 1);
|
||||||
|
pointer("pointermove", 0.58, 0.54, 1, "touch", 1);
|
||||||
|
pointer("pointerup", 0.58, 0.54, 0, "touch", 1);
|
||||||
|
await sleep(100);
|
||||||
|
assert(
|
||||||
|
same(beforePalm, camera) && same(edited, await saved()),
|
||||||
|
"Palm changed canvas",
|
||||||
|
);
|
||||||
|
results.push("default palm rejection");
|
||||||
|
$("palm-rejection").checked = false;
|
||||||
|
$("palm-rejection").dispatchEvent(new Event("change"));
|
||||||
|
pointer("pointerdown", 0.48, 0.5, 1, "touch", 1);
|
||||||
|
await sleep(100);
|
||||||
|
pointer("pointerdown", 0.56, 0.52, 1, "touch", 2);
|
||||||
|
pointer("pointermove", 0.58, 0.54, 1, "touch", 2);
|
||||||
|
pointer("pointerup", 0.48, 0.5, 0, "touch", 1);
|
||||||
|
pointer("pointerup", 0.58, 0.54, 0, "touch", 2);
|
||||||
|
assert(same(edited, await saved()), "Pinch left sculpt marks");
|
||||||
|
await wait(() => camera.zoom !== beforePalm.zoom);
|
||||||
|
$("palm-rejection").checked = true;
|
||||||
|
$("palm-rejection").dispatchEvent(new Event("change"));
|
||||||
|
results.push("two-touch rollback/navigation");
|
||||||
|
$("quality").value = "economy";
|
||||||
|
$("quality").dispatchEvent(new Event("change"));
|
||||||
|
await wait(() => $("loading").hidden && !$("quality").disabled);
|
||||||
|
assert(same(edited, await saved()), "Quality changed geometry");
|
||||||
|
assert(!$("undo").disabled, "Quality lost history");
|
||||||
|
results.push("quality preserves mesh/history");
|
||||||
|
const oldCanvas = viewport.querySelector("canvas");
|
||||||
|
viewport.style.width = "85%";
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
viewport.querySelector("canvas") !== oldCanvas && $("loading").hidden,
|
||||||
|
);
|
||||||
|
assert(same(edited, await saved()), "Resize changed geometry");
|
||||||
|
assert(!$("undo").disabled, "Resize lost history");
|
||||||
|
const smallCanvas = viewport.querySelector("canvas");
|
||||||
|
viewport.style.width = "";
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
viewport.querySelector("canvas") !== smallCanvas && $("loading").hidden,
|
||||||
|
);
|
||||||
|
results.push("resize preserves mesh/history");
|
||||||
|
const positions = engine.mesh.positions.slice(),
|
||||||
|
undo = [...engine.undoStack],
|
||||||
|
redo = [...engine.redoStack],
|
||||||
|
bytes = engine.historyBytes;
|
||||||
|
pointer("pointerdown", 0.48, 0.5, 0.9);
|
||||||
|
pointer("pointermove", 0.56, 0.55, 0.9);
|
||||||
|
await wait(() => engine.mesh.positions.some((v, i) => v !== positions[i]));
|
||||||
|
const beforeResize = viewport.querySelector("canvas");
|
||||||
|
viewport.style.width = "90%";
|
||||||
|
// Release in this task, before ResizeObserver or the 200 ms debounce runs.
|
||||||
|
pointer("pointerup", 0.56, 0.55, 0);
|
||||||
|
const exact = () =>
|
||||||
|
engine.mesh.positions.every((v, i) => v === positions[i]) &&
|
||||||
|
engine.undoStack.length === undo.length &&
|
||||||
|
engine.undoStack.every((v, i) => v === undo[i]) &&
|
||||||
|
engine.redoStack.length === redo.length &&
|
||||||
|
engine.redoStack.every((v, i) => v === redo[i]) &&
|
||||||
|
engine.historyBytes === bytes;
|
||||||
|
assert(
|
||||||
|
exact(),
|
||||||
|
"Resize/release committed or retained provisional geometry",
|
||||||
|
);
|
||||||
|
pointer("pointerdown", 0.5, 0.5, 0.9);
|
||||||
|
assert(!engine.stroke, "Stroke started against a stale renderer size");
|
||||||
|
pointer("pointerup", 0.5, 0.5, 0);
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
viewport.querySelector("canvas") !== beforeResize &&
|
||||||
|
$("loading").hidden,
|
||||||
|
);
|
||||||
|
assert(exact(), "Resize recreation changed history or geometry");
|
||||||
|
await stroke(0.8);
|
||||||
|
assert(
|
||||||
|
!same(edited, await saved()),
|
||||||
|
"New stroke failed after resize recovery",
|
||||||
|
);
|
||||||
|
$("undo").click();
|
||||||
|
assert(same(edited, await saved()), "Recovery stroke undo failed");
|
||||||
|
const resizedCanvas = viewport.querySelector("canvas");
|
||||||
|
viewport.style.width = "";
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
viewport.querySelector("canvas") !== resizedCanvas &&
|
||||||
|
$("loading").hidden,
|
||||||
|
);
|
||||||
|
results.push(
|
||||||
|
"resize during stroke: immediate exact rollback / stale-start gate / recovery",
|
||||||
|
);
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(new File(['{"version":42}'], "invalid.yawn-sculpt"));
|
||||||
|
$("file").files = dt.files;
|
||||||
|
$("file").dispatchEvent(new Event("change"));
|
||||||
|
await wait(() => !$("open").disabled);
|
||||||
|
assert(same(edited, await saved()), "Invalid project replaced geometry");
|
||||||
|
results.push("invalid project preservation");
|
||||||
|
const native = JSON.stringify(edited),
|
||||||
|
valid = new DataTransfer();
|
||||||
|
valid.items.add(new File([native], "roundtrip.yawn-sculpt"));
|
||||||
|
$("file").files = valid.files;
|
||||||
|
$("file").dispatchEvent(new Event("change"));
|
||||||
|
await wait(() => !$("open").disabled && $("loading").hidden);
|
||||||
|
assert(same(edited, await saved()), "Project roundtrip changed geometry");
|
||||||
|
results.push("native project roundtrip");
|
||||||
|
$("export").click();
|
||||||
|
const obj = await blob.text();
|
||||||
|
assert(
|
||||||
|
obj.split("\n").filter((l) => l.startsWith("v ")).length === 2562,
|
||||||
|
"OBJ vertex count",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
obj.split("\n").filter((l) => l.startsWith("f ")).length === 5120,
|
||||||
|
"OBJ face count",
|
||||||
|
);
|
||||||
|
results.push("OBJ export");
|
||||||
|
const navPointer = (mode, type, x = 100, y = 100) =>
|
||||||
|
document.querySelector(`[data-nav="${mode}"]`).dispatchEvent(
|
||||||
|
new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
pointerType: "pen",
|
||||||
|
pointerId: 90,
|
||||||
|
button: 0,
|
||||||
|
clientX: x,
|
||||||
|
clientY: y,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await wait(() => nav);
|
||||||
|
for (const mode of ["orbit", "pan", "zoom"]) {
|
||||||
|
const before = structuredClone(nav.host.camera);
|
||||||
|
navPointer(mode, "pointerdown");
|
||||||
|
assert(nav.busy, "Navigation did not capture gesture");
|
||||||
|
pointer("pointerdown", 0.5, 0.5);
|
||||||
|
assert(!engine.stroke, "Canvas sculpt started during navigation");
|
||||||
|
viewport.dispatchEvent(
|
||||||
|
new WheelEvent("wheel", {
|
||||||
|
deltaY: 200,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert(same(nav.host.camera, before), "Wheel competed with navigation");
|
||||||
|
navPointer(mode, "pointermove", 126, 119);
|
||||||
|
assert(!same(nav.host.camera, before), "Navigation did not move camera");
|
||||||
|
navRoot.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!nav.busy && same(nav.host.camera, before),
|
||||||
|
"Escape did not restore camera",
|
||||||
|
);
|
||||||
|
navPointer(mode, "pointerdown");
|
||||||
|
navPointer(mode, "pointerup", 126, 119);
|
||||||
|
assert(
|
||||||
|
!nav.busy && !same(nav.host.camera, before),
|
||||||
|
"Navigation release did not finish",
|
||||||
|
);
|
||||||
|
await sleep(80);
|
||||||
|
}
|
||||||
|
const navBefore = structuredClone(nav.host.camera);
|
||||||
|
pointer("pointerdown", 0.5, 0.5);
|
||||||
|
navPointer("orbit", "pointerdown");
|
||||||
|
assert(!nav.busy, "Navigation started during sculpt");
|
||||||
|
pointer("pointerup", 0.5, 0.5, 0);
|
||||||
|
navPointer("orbit", "pointerdown");
|
||||||
|
assert(!nav.busy, "Navigation started during worker settlement");
|
||||||
|
await wait(() => !$("save").disabled);
|
||||||
|
$("undo").click();
|
||||||
|
assert(
|
||||||
|
same(nav.host.camera, navBefore),
|
||||||
|
"Rejected navigation changed camera",
|
||||||
|
);
|
||||||
|
const viewBefore = structuredClone(camera),
|
||||||
|
aspectBefore = viewport.clientWidth / viewport.clientHeight;
|
||||||
|
const shortZoom = viewBefore.zoom * Math.min(1, aspectBefore);
|
||||||
|
const resizeTo = async (width, height) => {
|
||||||
|
const previous = viewport.querySelector("canvas");
|
||||||
|
viewport.style.width = width;
|
||||||
|
viewport.style.height = height;
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
viewport.querySelector("canvas") !== previous && $("loading").hidden,
|
||||||
|
);
|
||||||
|
await sleep(100);
|
||||||
|
};
|
||||||
|
await resizeTo("600px", "1000px");
|
||||||
|
assert(
|
||||||
|
Math.abs(camera.zoom * 0.6 - shortZoom) < 1e-9,
|
||||||
|
"Portrait changed apparent zoom",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
same(camera.target, viewBefore.target) &&
|
||||||
|
camera.yaw === viewBefore.yaw &&
|
||||||
|
camera.pitch === viewBefore.pitch,
|
||||||
|
"Orientation reset camera pose",
|
||||||
|
);
|
||||||
|
await resizeTo("", "");
|
||||||
|
assert(
|
||||||
|
Math.abs(camera.zoom - viewBefore.zoom) < 1e-9,
|
||||||
|
"Orientation roundtrip changed zoom",
|
||||||
|
);
|
||||||
|
assert(same(edited, await saved()), "Navigation changed geometry");
|
||||||
|
document.querySelector('[data-brush="draw"]').click();
|
||||||
|
results.push(
|
||||||
|
"direct navigation / mutual exclusion / Escape rollback / orientation / geometry intact",
|
||||||
|
);
|
||||||
|
await sleep(300);
|
||||||
|
const idle = publications;
|
||||||
|
await sleep(500);
|
||||||
|
assert(publications === idle, "Idle repeated publications");
|
||||||
|
results.push("idle: zero publications over 500 ms");
|
||||||
|
failNext = true;
|
||||||
|
$("frame").click();
|
||||||
|
await wait(() => !$("error").hidden);
|
||||||
|
assert(!$("save").disabled, "Error disabled save");
|
||||||
|
assert(same(edited, await saved()), "Upload failure lost geometry");
|
||||||
|
$("retry").click();
|
||||||
|
await wait(() => $("loading").hidden && $("error").hidden);
|
||||||
|
assert(same(edited, await saved()), "Retry lost geometry");
|
||||||
|
results.push("upload failure / save / retry");
|
||||||
|
const recovered = engine.mesh.positions.slice();
|
||||||
|
pointer("pointerdown", 0.5, 0.5, 0.9);
|
||||||
|
await wait(() => engine.mesh.positions.some((v, i) => v !== recovered[i]));
|
||||||
|
// Publication captures the renderer's client after recreation; geometry can
|
||||||
|
// arrive first. Never inject into the previous, already-disposed worker.
|
||||||
|
await wait(() => processor && !processor.disposed && processor.job);
|
||||||
|
const oldProcessor = processor;
|
||||||
|
oldProcessor.worker.dispatchEvent(new Event("error"));
|
||||||
|
await wait(() => !$("error").hidden && !$("save").disabled);
|
||||||
|
assert(
|
||||||
|
same(edited, await saved()),
|
||||||
|
"Worker failure retained provisional geometry",
|
||||||
|
);
|
||||||
|
assert(oldProcessor.disposed, "Failed worker was not terminated");
|
||||||
|
$("retry").click();
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
$("loading").hidden && $("error").hidden && processor !== oldProcessor,
|
||||||
|
);
|
||||||
|
await stroke(0.8);
|
||||||
|
assert(
|
||||||
|
!same(edited, await saved()),
|
||||||
|
"New worker failed to sculpt after retry",
|
||||||
|
);
|
||||||
|
$("undo").click();
|
||||||
|
assert(same(edited, await saved()), "Recovered worker undo failed");
|
||||||
|
results.push(
|
||||||
|
"sculpt worker failure / exact rollback / new worker / sculpt / undo",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
passed: results.length,
|
||||||
|
checks: results,
|
||||||
|
publications,
|
||||||
|
physicalPenVerified: false,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
viewport.setPointerCapture = capture;
|
||||||
|
URL.createObjectURL = createURL;
|
||||||
|
Renderer.prototype.publish = publish;
|
||||||
|
navRoot.setPointerCapture = navCapture;
|
||||||
|
Navigation.prototype.render = navRender;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { add, mul, sub, dot, cross, unit, point } from "./math.js";
|
||||||
|
|
||||||
|
export function triangleHit(mesh, f, origin, direction) {
|
||||||
|
const ids = mesh.indices.subarray(f * 3, f * 3 + 3);
|
||||||
|
const a = point(mesh.positions, ids[0]);
|
||||||
|
const e1 = sub(point(mesh.positions, ids[1]), a),
|
||||||
|
e2 = sub(point(mesh.positions, ids[2]), a);
|
||||||
|
const h = cross(direction, e2),
|
||||||
|
det = dot(e1, h);
|
||||||
|
if (Math.abs(det) < 1e-9) return null;
|
||||||
|
const s = sub(origin, a),
|
||||||
|
u = dot(s, h) / det;
|
||||||
|
if (u < 0 || u > 1) return null;
|
||||||
|
const q = cross(s, e1),
|
||||||
|
v = dot(direction, q) / det;
|
||||||
|
if (v < 0 || u + v > 1) return null;
|
||||||
|
const t = dot(e2, q) / det;
|
||||||
|
return t > 0
|
||||||
|
? {
|
||||||
|
t,
|
||||||
|
position: add(origin, mul(direction, t)),
|
||||||
|
normal: unit(cross(e1, e2)),
|
||||||
|
face: f,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BVH {
|
||||||
|
constructor(mesh) {
|
||||||
|
this.mesh = mesh;
|
||||||
|
this.leaves = new Array(mesh.indices.length / 3);
|
||||||
|
const centers = Array.from({ length: this.leaves.length }, (_, f) =>
|
||||||
|
[0, 1, 2].map(
|
||||||
|
(k) =>
|
||||||
|
(mesh.positions[mesh.indices[f * 3] * 3 + k] +
|
||||||
|
mesh.positions[mesh.indices[f * 3 + 1] * 3 + k] +
|
||||||
|
mesh.positions[mesh.indices[f * 3 + 2] * 3 + k]) /
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const build = (faces, parent = null) => {
|
||||||
|
const node = { parent, min: [0, 0, 0], max: [0, 0, 0] };
|
||||||
|
if (faces.length <= 8) {
|
||||||
|
node.faces = faces;
|
||||||
|
for (const f of faces) this.leaves[f] = node;
|
||||||
|
} else {
|
||||||
|
const spans = [0, 1, 2].map((k) => {
|
||||||
|
let lo = Infinity,
|
||||||
|
hi = -Infinity;
|
||||||
|
for (const f of faces) {
|
||||||
|
lo = Math.min(lo, centers[f][k]);
|
||||||
|
hi = Math.max(hi, centers[f][k]);
|
||||||
|
}
|
||||||
|
return hi - lo;
|
||||||
|
});
|
||||||
|
const axis = spans.indexOf(Math.max(...spans));
|
||||||
|
faces.sort((a, b) => centers[a][axis] - centers[b][axis]);
|
||||||
|
const mid = faces.length >> 1;
|
||||||
|
node.left = build(faces.slice(0, mid), node);
|
||||||
|
node.right = build(faces.slice(mid), node);
|
||||||
|
}
|
||||||
|
this.bounds(node);
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
this.root = build(Array.from({ length: this.leaves.length }, (_, i) => i));
|
||||||
|
this.marks = new Uint32Array(mesh.positions.length / 3);
|
||||||
|
this.generation = 0;
|
||||||
|
}
|
||||||
|
bounds(node) {
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
let lo = Infinity,
|
||||||
|
hi = -Infinity;
|
||||||
|
if (node.faces)
|
||||||
|
for (const f of node.faces)
|
||||||
|
for (let j = 0; j < 3; j++) {
|
||||||
|
const v = this.mesh.positions[this.mesh.indices[f * 3 + j] * 3 + k];
|
||||||
|
lo = Math.min(lo, v);
|
||||||
|
hi = Math.max(hi, v);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
lo = Math.min(node.left.min[k], node.right.min[k]);
|
||||||
|
hi = Math.max(node.left.max[k], node.right.max[k]);
|
||||||
|
}
|
||||||
|
node.min[k] = lo;
|
||||||
|
node.max[k] = hi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refit(faces) {
|
||||||
|
const dirty = new Set(faces.map((f) => this.leaves[f]));
|
||||||
|
for (const leaf of dirty) this.bounds(leaf);
|
||||||
|
let parents = new Set([...dirty].map((n) => n.parent).filter(Boolean));
|
||||||
|
while (parents.size) {
|
||||||
|
const next = new Set();
|
||||||
|
for (const n of parents) {
|
||||||
|
this.bounds(n);
|
||||||
|
if (n.parent) next.add(n.parent);
|
||||||
|
}
|
||||||
|
parents = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raycast(origin, direction) {
|
||||||
|
let best = null;
|
||||||
|
const entry = (n) => {
|
||||||
|
let near = 0,
|
||||||
|
far = best?.t ?? Infinity;
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
if (Math.abs(direction[k]) < 1e-12) {
|
||||||
|
if (origin[k] < n.min[k] || origin[k] > n.max[k]) return Infinity;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const a = (n.min[k] - origin[k]) / direction[k],
|
||||||
|
b = (n.max[k] - origin[k]) / direction[k];
|
||||||
|
near = Math.max(near, Math.min(a, b));
|
||||||
|
far = Math.min(far, Math.max(a, b));
|
||||||
|
if (near > far) return Infinity;
|
||||||
|
}
|
||||||
|
return near;
|
||||||
|
};
|
||||||
|
const visit = (n) => {
|
||||||
|
if (entry(n) === Infinity) return;
|
||||||
|
if (n.faces) {
|
||||||
|
for (const f of n.faces) {
|
||||||
|
const hit = triangleHit(this.mesh, f, origin, direction);
|
||||||
|
if (hit && (!best || hit.t < best.t)) best = hit;
|
||||||
|
}
|
||||||
|
} else if (entry(n.left) < entry(n.right)) {
|
||||||
|
visit(n.left);
|
||||||
|
visit(n.right);
|
||||||
|
} else {
|
||||||
|
visit(n.right);
|
||||||
|
visit(n.left);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(this.root);
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
within(center, radius) {
|
||||||
|
const result = [],
|
||||||
|
gen = ++this.generation,
|
||||||
|
r2 = radius * radius;
|
||||||
|
const visit = (n) => {
|
||||||
|
let d = 0;
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
const e = Math.max(n.min[k] - center[k], 0, center[k] - n.max[k]);
|
||||||
|
d += e * e;
|
||||||
|
}
|
||||||
|
if (d > r2) return;
|
||||||
|
if (n.faces)
|
||||||
|
for (const f of n.faces)
|
||||||
|
for (let j = 0; j < 3; j++) {
|
||||||
|
const v = this.mesh.indices[f * 3 + j];
|
||||||
|
if (this.marks[v] === gen) continue;
|
||||||
|
this.marks[v] = gen;
|
||||||
|
const p = this.mesh.positions;
|
||||||
|
const d2 =
|
||||||
|
(p[v * 3] - center[0]) ** 2 +
|
||||||
|
(p[v * 3 + 1] - center[1]) ** 2 +
|
||||||
|
(p[v * 3 + 2] - center[2]) ** 2;
|
||||||
|
if (d2 < r2) result.push(v);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
visit(n.left);
|
||||||
|
visit(n.right);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(this.root);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { sphere, updateNormals } from "./mesh.js";
|
||||||
|
import { BVH } from "./bvh.js";
|
||||||
|
import { point, sub, dot, clamp } from "./math.js";
|
||||||
|
|
||||||
|
export const pressureValue = (pressure, gain = 1, gamma = 1) =>
|
||||||
|
clamp(gain * clamp(pressure, 0, 1) ** gamma, 0, 1);
|
||||||
|
export const defaults = {
|
||||||
|
brush: "draw",
|
||||||
|
radius: 0.22,
|
||||||
|
strength: 0.45,
|
||||||
|
gain: 1,
|
||||||
|
gamma: 1,
|
||||||
|
symmetry: true,
|
||||||
|
invert: false,
|
||||||
|
pressureRadius: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Sculpt {
|
||||||
|
constructor(mesh = sphere(), historyCap = 16 * 1024 * 1024) {
|
||||||
|
this.mesh = mesh;
|
||||||
|
this.bvh = new BVH(mesh);
|
||||||
|
this.undoStack = [];
|
||||||
|
this.redoStack = [];
|
||||||
|
this.historyCap = historyCap;
|
||||||
|
this.revision = 0;
|
||||||
|
this.dirty = new Set();
|
||||||
|
this.markAll();
|
||||||
|
this.stroke = null;
|
||||||
|
}
|
||||||
|
markAll() {
|
||||||
|
for (let v = 0; v < this.mesh.positions.length / 3; v++) this.dirty.add(v);
|
||||||
|
this.revision++;
|
||||||
|
}
|
||||||
|
get historyBytes() {
|
||||||
|
return [...this.undoStack, ...this.redoStack].reduce(
|
||||||
|
(sum, e) =>
|
||||||
|
sum + e.ids.byteLength + e.before.byteLength + e.after.byteLength,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
begin(settings) {
|
||||||
|
if (this.stroke) throw new Error("A stroke is already active.");
|
||||||
|
this.stroke = {
|
||||||
|
settings: { ...defaults, ...settings },
|
||||||
|
before: this.mesh.positions.slice(),
|
||||||
|
touched: new Set(),
|
||||||
|
grab: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
changed(ids) {
|
||||||
|
if (!ids.length) return;
|
||||||
|
const { faces, vertices } = updateNormals(this.mesh, ids);
|
||||||
|
this.bvh.refit(faces);
|
||||||
|
for (const v of vertices) this.dirty.add(v);
|
||||||
|
this.revision++;
|
||||||
|
}
|
||||||
|
dab(hit, rawPressure = 1, drag = [0, 0, 0]) {
|
||||||
|
if (!this.stroke || !hit) return;
|
||||||
|
const { settings: s } = this.stroke;
|
||||||
|
const pressure = pressureValue(rawPressure, s.gain, s.gamma);
|
||||||
|
if (pressure === 0) return;
|
||||||
|
const radius = s.radius * (s.pressureRadius ? 0.25 + 0.75 * pressure : 1);
|
||||||
|
const p = this.mesh.positions,
|
||||||
|
n = this.mesh.normals;
|
||||||
|
// Merge reflected influence instead of applying two sequential dabs at the seam.
|
||||||
|
let affected = this.stroke.grab;
|
||||||
|
if (!affected) {
|
||||||
|
affected = new Map();
|
||||||
|
for (const mirror of s.symmetry ? [1, -1] : [1]) {
|
||||||
|
const center = [
|
||||||
|
hit.position[0] * mirror,
|
||||||
|
hit.position[1],
|
||||||
|
hit.position[2],
|
||||||
|
];
|
||||||
|
const normal = [hit.normal[0] * mirror, hit.normal[1], hit.normal[2]];
|
||||||
|
for (const v of this.bvh.within(center, radius)) {
|
||||||
|
if (dot(point(n, v), normal) < 0.05) continue;
|
||||||
|
const delta = sub(point(p, v), center),
|
||||||
|
weight = (1 - dot(delta, delta) / (radius * radius)) ** 2;
|
||||||
|
if (!affected.has(v)) affected.set(v, []);
|
||||||
|
affected.get(v).push({ weight, center, normal, mirror });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (s.brush === "grab") this.stroke.grab = affected;
|
||||||
|
}
|
||||||
|
const moved = [],
|
||||||
|
updates = [];
|
||||||
|
for (const [v, contributions] of affected) {
|
||||||
|
const position = point(p, v),
|
||||||
|
displacement = [0, 0, 0];
|
||||||
|
let total = 0,
|
||||||
|
envelope = 0;
|
||||||
|
for (const { weight, center, normal, mirror } of contributions) {
|
||||||
|
total += weight;
|
||||||
|
envelope = Math.max(envelope, weight);
|
||||||
|
let delta;
|
||||||
|
if (s.brush === "smooth") {
|
||||||
|
const neighbors = this.mesh.neighbors[v];
|
||||||
|
delta = [0, 1, 2].map((k) => {
|
||||||
|
let sum = 0;
|
||||||
|
for (const neighbor of neighbors) sum += p[neighbor * 3 + k];
|
||||||
|
return sum / neighbors.length - position[k];
|
||||||
|
});
|
||||||
|
} else if (s.brush === "flatten") {
|
||||||
|
const distance = dot(sub(position, center), normal);
|
||||||
|
delta = normal.map((x) => -x * distance);
|
||||||
|
} else if (s.brush === "grab") {
|
||||||
|
delta = drag.map((x, k) => x * (k === 0 ? mirror : 1));
|
||||||
|
} else {
|
||||||
|
const direction = s.brush === "inflate" ? point(n, v) : normal;
|
||||||
|
delta = direction.map((x) => x * radius * 0.12 * (s.invert ? -1 : 1));
|
||||||
|
}
|
||||||
|
for (let k = 0; k < 3; k++) displacement[k] += delta[k] * weight;
|
||||||
|
}
|
||||||
|
const base = s.brush === "grab" ? point(this.stroke.before, v) : position;
|
||||||
|
let next = base.map(
|
||||||
|
(x, k) =>
|
||||||
|
x + (displacement[k] * s.strength * pressure * envelope) / total,
|
||||||
|
);
|
||||||
|
if (s.symmetry && this.stroke.before[v * 3] === 0) next[0] = 0;
|
||||||
|
// Bounded document coordinates keep camera and project validation meaningful.
|
||||||
|
next = next.map((x) => clamp(x, -8, 8));
|
||||||
|
if (next.some((x, k) => Math.fround(x) !== position[k])) {
|
||||||
|
moved.push(v);
|
||||||
|
updates.push(...next);
|
||||||
|
this.stroke.touched.add(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Jacobi update: smoothing reads only the pre-dab mesh, independent of iteration order.
|
||||||
|
for (let i = 0; i < moved.length; i++)
|
||||||
|
p.set(updates.slice(i * 3, i * 3 + 3), moved[i] * 3);
|
||||||
|
this.changed(moved);
|
||||||
|
}
|
||||||
|
end(cancel = false) {
|
||||||
|
if (!this.stroke) return;
|
||||||
|
const { before, touched } = this.stroke;
|
||||||
|
this.stroke = null;
|
||||||
|
const ids = Uint32Array.from(
|
||||||
|
[...touched].filter((v) =>
|
||||||
|
[0, 1, 2].some(
|
||||||
|
(k) => before[v * 3 + k] !== this.mesh.positions[v * 3 + k],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (cancel) {
|
||||||
|
for (const v of ids)
|
||||||
|
this.mesh.positions.set(before.subarray(v * 3, v * 3 + 3), v * 3);
|
||||||
|
this.changed(Array.from(ids));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ids.length) return;
|
||||||
|
const entry = {
|
||||||
|
ids,
|
||||||
|
before: new Float32Array(ids.length * 3),
|
||||||
|
after: new Float32Array(ids.length * 3),
|
||||||
|
};
|
||||||
|
ids.forEach((v, i) => {
|
||||||
|
entry.before.set(before.subarray(v * 3, v * 3 + 3), i * 3);
|
||||||
|
entry.after.set(this.mesh.positions.subarray(v * 3, v * 3 + 3), i * 3);
|
||||||
|
});
|
||||||
|
this.redoStack = [];
|
||||||
|
this.undoStack.push(entry);
|
||||||
|
while (this.undoStack.length > 50 || this.historyBytes > this.historyCap)
|
||||||
|
this.undoStack.shift();
|
||||||
|
}
|
||||||
|
restore(redo = false) {
|
||||||
|
if (this.stroke) return false;
|
||||||
|
const source = redo ? this.redoStack : this.undoStack,
|
||||||
|
target = redo ? this.undoStack : this.redoStack,
|
||||||
|
entry = source.pop();
|
||||||
|
if (!entry) return false;
|
||||||
|
const values = redo ? entry.after : entry.before;
|
||||||
|
entry.ids.forEach((v, i) =>
|
||||||
|
this.mesh.positions.set(values.subarray(i * 3, i * 3 + 3), v * 3),
|
||||||
|
);
|
||||||
|
target.push(entry);
|
||||||
|
this.changed(Array.from(entry.ids));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen-space arc-length sampler. Call break() when the ray misses the surface.
|
||||||
|
export class StrokePath {
|
||||||
|
constructor(spacing, emit) {
|
||||||
|
this.spacing = spacing;
|
||||||
|
this.emit = emit;
|
||||||
|
this.break();
|
||||||
|
}
|
||||||
|
break() {
|
||||||
|
this.generation = (this.generation ?? 0) + 1;
|
||||||
|
this.last = null;
|
||||||
|
this.remaining = 0;
|
||||||
|
}
|
||||||
|
push(sample, budget = Infinity) {
|
||||||
|
const generation = this.generation;
|
||||||
|
if (!this.last) {
|
||||||
|
this.last = sample;
|
||||||
|
this.remaining = this.spacing;
|
||||||
|
this.emit(sample);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const a = this.last,
|
||||||
|
dx = sample.x - a.x,
|
||||||
|
dy = sample.y - a.y,
|
||||||
|
length = Math.hypot(dx, dy);
|
||||||
|
let distance = this.remaining;
|
||||||
|
let emitted = 0;
|
||||||
|
while (distance <= length && length > 0) {
|
||||||
|
const t = distance / length;
|
||||||
|
const current = {
|
||||||
|
x: a.x + dx * t,
|
||||||
|
y: a.y + dy * t,
|
||||||
|
pressure: a.pressure + (sample.pressure - a.pressure) * t,
|
||||||
|
};
|
||||||
|
this.emit(current);
|
||||||
|
if (this.generation !== generation) return true;
|
||||||
|
distance += this.spacing;
|
||||||
|
if (++emitted >= budget && distance <= length) {
|
||||||
|
this.last = current;
|
||||||
|
this.remaining = this.spacing;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.remaining = distance - length;
|
||||||
|
this.last = sample;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveProject(mesh) {
|
||||||
|
return JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
level: mesh.level,
|
||||||
|
positions: Array.from(mesh.positions),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function loadProject(text) {
|
||||||
|
if (text.length > 8 * 1024 * 1024)
|
||||||
|
throw new Error("Project exceeds the 8 MiB limit.");
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
if (
|
||||||
|
data?.version !== 1 ||
|
||||||
|
!Number.isInteger(data.level) ||
|
||||||
|
data.level < 4 ||
|
||||||
|
data.level > 6 ||
|
||||||
|
!Array.isArray(data.positions) ||
|
||||||
|
data.positions.length !== (10 * 4 ** data.level + 2) * 3 ||
|
||||||
|
!data.positions.every(
|
||||||
|
(v) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) <= 8,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error("Invalid sculpt project.");
|
||||||
|
const mesh = sphere(data.level);
|
||||||
|
mesh.positions.set(data.positions);
|
||||||
|
updateNormals(mesh);
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
export function exportOBJ(mesh) {
|
||||||
|
const lines = ["# Yawn Sculpt — local mesh export"];
|
||||||
|
for (let i = 0; i < mesh.positions.length; i += 3)
|
||||||
|
lines.push(
|
||||||
|
`v ${mesh.positions[i]} ${mesh.positions[i + 1]} ${mesh.positions[i + 2]}`,
|
||||||
|
);
|
||||||
|
for (let i = 0; i < mesh.indices.length; i += 3)
|
||||||
|
lines.push(
|
||||||
|
`f ${mesh.indices[i] + 1} ${mesh.indices[i + 1] + 1} ${mesh.indices[i + 2] + 1}`,
|
||||||
|
);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { sphere, updateNormals } from "./mesh.js";
|
||||||
|
import { triangleHit } from "./bvh.js";
|
||||||
|
import {
|
||||||
|
Sculpt,
|
||||||
|
pressureValue,
|
||||||
|
StrokePath,
|
||||||
|
saveProject,
|
||||||
|
loadProject,
|
||||||
|
exportOBJ,
|
||||||
|
} from "./engine.js";
|
||||||
|
import { point, dot, unit, clampZoom } from "./math.js";
|
||||||
|
|
||||||
|
const hit = { position: [0.19, 0.11, 0.97], normal: unit([0.19, 0.11, 0.97]) };
|
||||||
|
const near = (a, b, epsilon = 1e-6) =>
|
||||||
|
assert.ok(Math.abs(a - b) < epsilon, `${a} != ${b}`);
|
||||||
|
test("zoom limits use apparent scale, including portrait values outside landscape limits", () => {
|
||||||
|
const portrait = { width: 600, height: 1000 },
|
||||||
|
landscape = { width: 1000, height: 600 };
|
||||||
|
near(clampZoom(20, portrait), 20);
|
||||||
|
near(clampZoom(30, portrait), 14 / 0.6);
|
||||||
|
near(clampZoom(0.1, portrait), 0.2 / 0.6);
|
||||||
|
near(clampZoom(20, landscape), 14);
|
||||||
|
near(clampZoom(0.1, landscape), 0.2);
|
||||||
|
});
|
||||||
|
test("welded outward sphere has two faces per edge and expected topology", () => {
|
||||||
|
const m = sphere(4),
|
||||||
|
edges = new Map();
|
||||||
|
assert.equal(m.positions.length / 3, 2562);
|
||||||
|
assert.equal(m.indices.length / 3, 5120);
|
||||||
|
for (let f = 0; f < m.indices.length / 3; f++) {
|
||||||
|
const ids = m.indices.subarray(f * 3, f * 3 + 3);
|
||||||
|
assert.ok(dot(point(m.faceNormals, f), point(m.positions, ids[0])) > 0);
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
const a = ids[k],
|
||||||
|
b = ids[(k + 1) % 3],
|
||||||
|
key = [Math.min(a, b), Math.max(a, b)].join(":");
|
||||||
|
edges.set(key, (edges.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok([...edges.values()].every((n) => n === 2));
|
||||||
|
});
|
||||||
|
test("BVH agrees with brute-force picking and sphere queries after edits and undo", () => {
|
||||||
|
const s = new Sculpt(sphere(4));
|
||||||
|
const check = () => {
|
||||||
|
for (const origin of [
|
||||||
|
[0.19, 0.11, 4],
|
||||||
|
[-0.31, 0.42, 4],
|
||||||
|
[1.4, 0, 4],
|
||||||
|
[0.01, -0.99, 4],
|
||||||
|
]) {
|
||||||
|
const direction = [0, 0, -1];
|
||||||
|
let expected = null;
|
||||||
|
for (let f = 0; f < s.mesh.indices.length / 3; f++) {
|
||||||
|
const h = triangleHit(s.mesh, f, origin, direction);
|
||||||
|
if (h && (!expected || h.t < expected.t)) expected = h;
|
||||||
|
}
|
||||||
|
const actual = s.bvh.raycast(origin, direction);
|
||||||
|
assert.equal(!!actual, !!expected);
|
||||||
|
if (actual) near(actual.t, expected.t);
|
||||||
|
}
|
||||||
|
const expected = [];
|
||||||
|
for (let v = 0; v < s.mesh.positions.length / 3; v++)
|
||||||
|
if (
|
||||||
|
Math.hypot(
|
||||||
|
...point(s.mesh.positions, v).map((x, k) => x - hit.position[k]),
|
||||||
|
) < 0.37
|
||||||
|
)
|
||||||
|
expected.push(v);
|
||||||
|
assert.deepEqual(
|
||||||
|
s.bvh.within(hit.position, 0.37).sort((a, b) => a - b),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
s.begin({ radius: 0.37 });
|
||||||
|
for (let i = 0; i < 12; i++) s.dab(hit, 0.7);
|
||||||
|
s.end();
|
||||||
|
check();
|
||||||
|
s.restore();
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
test("local normals equal full recomputation including brush boundary", () => {
|
||||||
|
const s = new Sculpt(sphere(4));
|
||||||
|
s.begin({ radius: 0.29, symmetry: false });
|
||||||
|
s.dab(hit);
|
||||||
|
s.end();
|
||||||
|
const local = s.mesh.normals.slice();
|
||||||
|
updateNormals(s.mesh);
|
||||||
|
local.forEach((v, i) => near(v, s.mesh.normals[i]));
|
||||||
|
});
|
||||||
|
test("pressure zero is a no-op; nonlinear pressure has the independently expected displacement ratio", () => {
|
||||||
|
assert.equal(pressureValue(0, 2, 0.5), 0);
|
||||||
|
assert.equal(pressureValue(0.5, 1, 2), 0.25);
|
||||||
|
const a = new Sculpt(sphere(4)),
|
||||||
|
b = new Sculpt(sphere(4));
|
||||||
|
const before = a.mesh.positions.slice();
|
||||||
|
a.begin({ symmetry: false });
|
||||||
|
a.dab(hit, 0);
|
||||||
|
a.end();
|
||||||
|
assert.equal(a.undoStack.length, 0);
|
||||||
|
for (const [s, p] of [
|
||||||
|
[a, 0.5],
|
||||||
|
[b, 1],
|
||||||
|
]) {
|
||||||
|
s.begin({ symmetry: false, gamma: 2 });
|
||||||
|
s.dab(hit, p);
|
||||||
|
s.end();
|
||||||
|
}
|
||||||
|
let da = 0,
|
||||||
|
db = 0;
|
||||||
|
before.forEach((x, i) => {
|
||||||
|
da += Math.abs(a.mesh.positions[i] - x);
|
||||||
|
db += Math.abs(b.mesh.positions[i] - x);
|
||||||
|
});
|
||||||
|
near(da / db, 0.25, 1e-4);
|
||||||
|
});
|
||||||
|
test("symmetry does not double strength on seam", () => {
|
||||||
|
const a = new Sculpt(sphere(4)),
|
||||||
|
b = new Sculpt(sphere(4));
|
||||||
|
for (const [s, symmetry] of [
|
||||||
|
[a, false],
|
||||||
|
[b, true],
|
||||||
|
]) {
|
||||||
|
s.begin({ symmetry });
|
||||||
|
s.dab({ position: [0, 0, 1], normal: [0, 0, 1] });
|
||||||
|
s.end();
|
||||||
|
}
|
||||||
|
assert.deepEqual(a.mesh.positions, b.mesh.positions);
|
||||||
|
});
|
||||||
|
test("grab uses stroke origin, not repeated accumulated deltas; smooth is order independent", () => {
|
||||||
|
const a = new Sculpt(sphere(4)),
|
||||||
|
b = new Sculpt(sphere(4));
|
||||||
|
a.begin({ brush: "grab", symmetry: false });
|
||||||
|
b.begin({ brush: "grab", symmetry: false });
|
||||||
|
a.dab(hit, 1, [0.1, 0.04, 0]);
|
||||||
|
a.dab(hit, 1, [0.2, 0.08, 0]);
|
||||||
|
b.dab(hit, 1, [0.2, 0.08, 0]);
|
||||||
|
assert.deepEqual(a.mesh.positions, b.mesh.positions);
|
||||||
|
a.end();
|
||||||
|
b.end();
|
||||||
|
const original = b.bvh.within.bind(b.bvh);
|
||||||
|
b.bvh.within = (...args) => original(...args).reverse();
|
||||||
|
for (const s of [a, b]) {
|
||||||
|
s.begin({ brush: "smooth", symmetry: false });
|
||||||
|
s.dab(hit);
|
||||||
|
s.end();
|
||||||
|
}
|
||||||
|
assert.deepEqual(a.mesh.positions, b.mesh.positions);
|
||||||
|
});
|
||||||
|
test("exact history, cancel, redo truncation and byte cap", () => {
|
||||||
|
const s = new Sculpt(sphere(4), 12000),
|
||||||
|
original = s.mesh.positions.slice();
|
||||||
|
s.begin({});
|
||||||
|
s.dab(hit);
|
||||||
|
s.end();
|
||||||
|
const after = s.mesh.positions.slice();
|
||||||
|
assert.ok(s.historyBytes > 0);
|
||||||
|
s.restore();
|
||||||
|
assert.deepEqual(s.mesh.positions, original);
|
||||||
|
s.restore(true);
|
||||||
|
assert.deepEqual(s.mesh.positions, after);
|
||||||
|
s.begin({});
|
||||||
|
s.dab(hit);
|
||||||
|
s.end(true);
|
||||||
|
assert.deepEqual(s.mesh.positions, after);
|
||||||
|
assert.equal(s.undoStack.length, 1);
|
||||||
|
s.restore();
|
||||||
|
s.begin({ brush: "inflate" });
|
||||||
|
s.dab(hit);
|
||||||
|
s.end();
|
||||||
|
assert.equal(s.redoStack.length, 0);
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
s.begin({});
|
||||||
|
s.dab(hit);
|
||||||
|
s.end();
|
||||||
|
}
|
||||||
|
assert.ok(s.historyBytes <= 12000);
|
||||||
|
assert.ok(s.undoStack.length <= 50);
|
||||||
|
});
|
||||||
|
test("arc-length and interpolated pressure do not depend on event frequency", () => {
|
||||||
|
const run = (step) => {
|
||||||
|
const samples = [];
|
||||||
|
const path = new StrokePath(7, (p) => samples.push(p));
|
||||||
|
for (let x = 0; x <= 100; x += step)
|
||||||
|
path.push({ x, y: 0, pressure: x / 100 });
|
||||||
|
return samples;
|
||||||
|
};
|
||||||
|
const a = run(1),
|
||||||
|
b = run(25);
|
||||||
|
assert.equal(a.length, 15);
|
||||||
|
assert.equal(a.length, b.length);
|
||||||
|
a.forEach((p, i) => {
|
||||||
|
near(p.x, b[i].x);
|
||||||
|
near(p.pressure, b[i].pressure);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test("project roundtrip, malformed project rejection, OBJ indices", () => {
|
||||||
|
const s = new Sculpt(sphere(4));
|
||||||
|
s.begin({});
|
||||||
|
s.dab(hit);
|
||||||
|
s.end();
|
||||||
|
const mesh = loadProject(saveProject(s.mesh));
|
||||||
|
assert.deepEqual(mesh.positions, s.mesh.positions);
|
||||||
|
for (const data of [
|
||||||
|
{ version: 2 },
|
||||||
|
{ version: 1, level: 99, positions: [] },
|
||||||
|
{ version: 1, level: 4, positions: ["x"] },
|
||||||
|
])
|
||||||
|
assert.throws(() => loadProject(JSON.stringify(data)));
|
||||||
|
const obj = exportOBJ(mesh).split("\n");
|
||||||
|
assert.equal(obj.filter((l) => l.startsWith("v ")).length, 2562);
|
||||||
|
assert.equal(obj.filter((l) => l.startsWith("f ")).length, 5120);
|
||||||
|
});
|
||||||
|
test("directional symmetry and Grab strength preserve the seam and overlap envelope", () => {
|
||||||
|
const center = { position: [0, 0, 1], normal: [0, 0, 1] };
|
||||||
|
const a = new Sculpt(sphere(4)),
|
||||||
|
b = new Sculpt(sphere(4));
|
||||||
|
const before = a.mesh.positions.slice();
|
||||||
|
a.begin({ brush: "grab", symmetry: true, strength: 1 });
|
||||||
|
a.dab(center, 1, [0.2, 0, 0]);
|
||||||
|
a.end();
|
||||||
|
assert.deepEqual(a.mesh.positions, before);
|
||||||
|
for (const [s, symmetry] of [
|
||||||
|
[a, true],
|
||||||
|
[b, false],
|
||||||
|
]) {
|
||||||
|
s.begin({ brush: "grab", symmetry, strength: 0.5 });
|
||||||
|
s.dab(center, 1, [0, 0.12, 0.08]);
|
||||||
|
s.end();
|
||||||
|
}
|
||||||
|
assert.deepEqual(a.mesh.positions, b.mesh.positions);
|
||||||
|
const s = new Sculpt(sphere(4));
|
||||||
|
s.dirty.clear();
|
||||||
|
const revision = s.revision;
|
||||||
|
s.begin({ brush: "grab", strength: 0 });
|
||||||
|
s.dab(hit, 1, [0.3, 0.1, 0.2]);
|
||||||
|
s.end();
|
||||||
|
assert.equal(s.revision, revision);
|
||||||
|
assert.equal(s.dirty.size, 0);
|
||||||
|
assert.equal(s.undoStack.length, 0);
|
||||||
|
const original = s.mesh.positions.slice(),
|
||||||
|
radius = 0.4,
|
||||||
|
drag = [0.13, 0.09, 0.05];
|
||||||
|
s.begin({ brush: "grab", strength: 0.5, symmetry: true, radius });
|
||||||
|
s.dab(hit, 0.8, drag);
|
||||||
|
s.end();
|
||||||
|
let overlaps = 0;
|
||||||
|
for (let v = 0; v < original.length / 3; v++) {
|
||||||
|
const p = point(original, v),
|
||||||
|
normal = point(sphereReference.normals, v);
|
||||||
|
const weights = [1, -1].map((m) => {
|
||||||
|
const c = [hit.position[0] * m, ...hit.position.slice(1)],
|
||||||
|
n = [hit.normal[0] * m, ...hit.normal.slice(1)];
|
||||||
|
const d2 = p.reduce((sum, x, k) => sum + (x - c[k]) ** 2, 0);
|
||||||
|
return d2 < radius ** 2 && dot(normal, n) >= 0.05
|
||||||
|
? (1 - d2 / radius ** 2) ** 2
|
||||||
|
: 0;
|
||||||
|
});
|
||||||
|
if (weights.every((w) => w > 0) && Math.abs(weights[0] - weights[1]) > 1e-4)
|
||||||
|
overlaps++;
|
||||||
|
const total = weights[0] + weights[1];
|
||||||
|
if (!total) continue;
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
const blend = k === 0 ? weights[0] - weights[1] : total;
|
||||||
|
const expected =
|
||||||
|
k === 0 && p[0] === 0
|
||||||
|
? 0
|
||||||
|
: p[k] + (drag[k] * 0.5 * 0.8 * Math.max(...weights) * blend) / total;
|
||||||
|
near(s.mesh.positions[v * 3 + k], expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(overlaps > 0);
|
||||||
|
});
|
||||||
|
const sphereReference = sphere(4);
|
||||||
|
test("breaking inside the sampler callback aborts interpolation and resets continuity", () => {
|
||||||
|
const samples = [];
|
||||||
|
let path;
|
||||||
|
path = new StrokePath(7, (p) => {
|
||||||
|
samples.push(Math.round(p.x));
|
||||||
|
if (p.x >= 14) path.break();
|
||||||
|
});
|
||||||
|
path.push({ x: 0, y: 0, pressure: 1 });
|
||||||
|
path.push({ x: 100, y: 0, pressure: 1 });
|
||||||
|
assert.deepEqual(samples, [0, 7, 14]);
|
||||||
|
assert.equal(path.last, null);
|
||||||
|
path.push({ x: 101, y: 0, pressure: 0.3 });
|
||||||
|
assert.deepEqual(samples, [0, 7, 14, 101]);
|
||||||
|
});
|
||||||
|
test("history count eviction retains exactly the latest fifty real commits", () => {
|
||||||
|
const s = new Sculpt(sphere(4), 64 * 1024 * 1024),
|
||||||
|
snapshots = [];
|
||||||
|
for (let i = 0; i < 55; i++) {
|
||||||
|
const previous = s.mesh.positions.slice();
|
||||||
|
const h = s.bvh.raycast([0.19, 0.11, 4], [0, 0, -1]);
|
||||||
|
s.begin({ brush: "grab", strength: 1, symmetry: false });
|
||||||
|
s.dab(h, 1, [0.001, 0.0005, 0]);
|
||||||
|
s.end();
|
||||||
|
assert.notDeepEqual(s.mesh.positions, previous);
|
||||||
|
snapshots.push(s.mesh.positions.slice());
|
||||||
|
assert.ok(s.historyBytes < 64 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
assert.equal(s.undoStack.length, 50);
|
||||||
|
for (let i = 0; i < 50; i++) assert.equal(s.restore(), true);
|
||||||
|
assert.deepEqual(s.mesh.positions, snapshots[4]);
|
||||||
|
assert.equal(s.restore(), false);
|
||||||
|
for (let i = 0; i < 50; i++) assert.equal(s.restore(true), true);
|
||||||
|
assert.deepEqual(s.mesh.positions, snapshots[54]);
|
||||||
|
});
|
||||||
|
test("bounded-resolution benchmark (CPU only, not a device guarantee)", () => {
|
||||||
|
for (const level of [4, 5, 6]) {
|
||||||
|
const s = new Sculpt(sphere(level));
|
||||||
|
s.dirty.clear();
|
||||||
|
const times = [];
|
||||||
|
s.begin({ radius: 0.22 });
|
||||||
|
for (let i = 0; i < 35; i++) {
|
||||||
|
const start = performance.now();
|
||||||
|
const h = s.bvh.raycast([0.19, 0.11, 4], [0, 0, -1]);
|
||||||
|
s.dab(h, 0.65);
|
||||||
|
times.push(performance.now() - start);
|
||||||
|
}
|
||||||
|
s.end();
|
||||||
|
times.sort((a, b) => a - b);
|
||||||
|
let lo = Infinity,
|
||||||
|
hi = -1;
|
||||||
|
for (const v of s.dirty) {
|
||||||
|
lo = Math.min(lo, v);
|
||||||
|
hi = Math.max(hi, v);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
level,
|
||||||
|
vertices: s.mesh.positions.length / 3,
|
||||||
|
p50ms: times[17].toFixed(2),
|
||||||
|
p95ms: times[33].toFixed(2),
|
||||||
|
uploadBytes: (hi - lo + 1) * 32,
|
||||||
|
historyBytes: s.historyBytes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<meta name="theme-color" content="#20242b" />
|
||||||
|
<meta name="description" content="A quiet space to sculpt with your pen." />
|
||||||
|
<title>Sculpt — Yawn</title>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/assets/sculpt/vendor/basecoat/basecoat.min.css"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="/assets/sculpt/styles.css" />
|
||||||
|
<script type="importmap">
|
||||||
|
{ "imports": { "@yawn/core": "/pkg/core.js" } }
|
||||||
|
</script>
|
||||||
|
<script type="module" src="/assets/sculpt/app.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="viewport" tabindex="0" aria-label="Sculpting canvas">
|
||||||
|
<canvas aria-label="Your sculpt"></canvas>
|
||||||
|
<div id="cursor" hidden aria-hidden="true"><i></i></div>
|
||||||
|
<div id="loading" class="canvas-message" role="status">
|
||||||
|
<h1>Preparing your clay.</h1>
|
||||||
|
<p>A moment to get ready.</p>
|
||||||
|
</div>
|
||||||
|
<div id="error" class="canvas-message" role="alert" hidden>
|
||||||
|
<h1>Your sculpt is safe.</h1>
|
||||||
|
<p id="error-detail"></p>
|
||||||
|
<button id="retry" class="btn">Try again</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<nav class="tool-rail" aria-label="Sculpt tools">
|
||||||
|
<div class="brushes" role="group" aria-label="Brush">
|
||||||
|
<button
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-brush="draw"
|
||||||
|
aria-pressed="true"
|
||||||
|
aria-label="Draw"
|
||||||
|
title="Draw · 1"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m14 4 6 6M5 15 16 4a2.8 2.8 0 0 1 4 4L9 19l-5 1z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-brush="inflate"
|
||||||
|
aria-pressed="false"
|
||||||
|
aria-label="Inflate"
|
||||||
|
title="Inflate · 2"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="8" />
|
||||||
|
<path d="M12 7v10M7 12h10" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-brush="smooth"
|
||||||
|
aria-pressed="false"
|
||||||
|
aria-label="Smooth"
|
||||||
|
title="Smooth · 3"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M3 8c6-7 12 7 18 0M3 12c6-7 12 7 18 0M3 16c6-7 12 7 18 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-brush="flatten"
|
||||||
|
aria-pressed="false"
|
||||||
|
aria-label="Flatten"
|
||||||
|
title="Flatten · 4"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m3 16 5-7h13l-5 7zM5 20h14M12 2v4m-2-2 2 2 2-2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-brush="grab"
|
||||||
|
aria-pressed="false"
|
||||||
|
aria-label="Grab"
|
||||||
|
title="Grab · 5"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M8 12V5a2 2 0 0 1 4 0v7-8a2 2 0 0 1 4 0v8-5a2 2 0 0 1 4 0v9c0 4-3 6-7 6-3 0-5-2-6-4l-3-5c-1-2 2-3 3-1l1 1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="rail-group">
|
||||||
|
<button
|
||||||
|
id="tools-toggle"
|
||||||
|
class="btn icon-button launcher"
|
||||||
|
data-variant="ghost"
|
||||||
|
aria-label="Brush settings"
|
||||||
|
title="Brush settings"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="tools-panel"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 7h16M4 17h16M8 4v6M16 14v6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="undo"
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
aria-label="Undo"
|
||||||
|
title="Undo"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m8 4-5 5 5 5M3 9h10a7 7 0 0 1 0 14" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="redo"
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
aria-label="Redo"
|
||||||
|
title="Redo"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m16 4 5 5-5 5M21 9H11a7 7 0 0 0 0 14" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="settings-toggle"
|
||||||
|
class="btn icon-button launcher"
|
||||||
|
data-variant="ghost"
|
||||||
|
aria-label="Settings and projects"
|
||||||
|
title="Settings and projects"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="inspector"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M9 3h6l1 3 3 1 2 5-2 5-3 1-1 3H9l-1-3-3-1-2-5 2-5 3-1z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<div id="view-navigation" aria-label="View navigation">
|
||||||
|
<div
|
||||||
|
id="orbit-gizmo"
|
||||||
|
data-nav="orbit"
|
||||||
|
tabindex="0"
|
||||||
|
role="group"
|
||||||
|
aria-label="Orbit view. Drag to rotate, or use arrow keys."
|
||||||
|
title="Drag to orbit · click an axis to align"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 104 104" aria-hidden="true">
|
||||||
|
<line class="axis-line" x1="52" y1="52" />
|
||||||
|
<line class="axis-line" x1="52" y1="52" />
|
||||||
|
<line class="axis-line" x1="52" y1="52" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="view-buttons">
|
||||||
|
<button
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-nav="pan"
|
||||||
|
aria-label="Pan view. Drag or use arrow keys."
|
||||||
|
title="Drag to pan"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M12 3v18M3 12h18m-12-6 3-3 3 3m-6 12 3 3 3-3M6 9l-3 3 3 3m12-6 3 3-3 3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-nav="zoom"
|
||||||
|
aria-label="Zoom view. Drag up or down, or use arrow keys."
|
||||||
|
title="Drag to zoom"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="10" cy="10" r="6" />
|
||||||
|
<path d="m15 15 6 6M10 7v6M7 10h6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="frame"
|
||||||
|
class="btn icon-button"
|
||||||
|
data-variant="ghost"
|
||||||
|
aria-label="Center view"
|
||||||
|
title="Center view · F"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M8 3H3v5m13-5h5v5M3 16v5h5m8 0h5v-5" />
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section
|
||||||
|
id="tools-panel"
|
||||||
|
class="panel tools-panel"
|
||||||
|
aria-label="Brush settings"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2 id="brush-title">Draw</h2>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-size="icon-sm"
|
||||||
|
data-close
|
||||||
|
aria-label="Close brushes"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="control-section">
|
||||||
|
<label for="radius"
|
||||||
|
>Size <output id="radius-value" for="radius"></output></label
|
||||||
|
><input
|
||||||
|
class="input"
|
||||||
|
id="radius"
|
||||||
|
type="range"
|
||||||
|
min="0.04"
|
||||||
|
max="0.6"
|
||||||
|
step="0.01"
|
||||||
|
value="0.22"
|
||||||
|
/>
|
||||||
|
<label for="strength"
|
||||||
|
>Strength <output id="strength-value" for="strength"></output></label
|
||||||
|
><input
|
||||||
|
class="input"
|
||||||
|
id="strength"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value="0.45"
|
||||||
|
/>
|
||||||
|
<label class="toggle-row" for="invert"
|
||||||
|
><span>Subtract</span
|
||||||
|
><input class="input" role="switch" id="invert" type="checkbox"
|
||||||
|
/></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<span id="modified" class="modified-label" hidden>Unsaved</span>
|
||||||
|
<aside
|
||||||
|
id="inspector"
|
||||||
|
class="panel settings-panel"
|
||||||
|
aria-label="Settings and projects"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-size="icon-sm"
|
||||||
|
data-close
|
||||||
|
aria-label="Close settings"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="document-name" class="project-name">Untitled sphere</p>
|
||||||
|
<div class="project-actions">
|
||||||
|
<button id="save" class="btn">Download project</button
|
||||||
|
><button class="btn" data-variant="outline" id="open">Open</button
|
||||||
|
><button class="btn" data-variant="outline" id="new">New</button
|
||||||
|
><button class="btn" data-variant="outline" id="export">Export</button>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-row palm-row" for="palm-rejection"
|
||||||
|
><span>Palm rejection<small>Ignore touch on the canvas</small></span
|
||||||
|
><input
|
||||||
|
class="input"
|
||||||
|
role="switch"
|
||||||
|
id="palm-rejection"
|
||||||
|
type="checkbox"
|
||||||
|
checked
|
||||||
|
/></label>
|
||||||
|
<details id="pen-settings">
|
||||||
|
<summary>Pen response</summary>
|
||||||
|
<div class="disclosure-content">
|
||||||
|
<label for="gain"
|
||||||
|
>Sensitivity <output id="gain-value" for="gain"></output></label
|
||||||
|
><input
|
||||||
|
class="input"
|
||||||
|
id="gain"
|
||||||
|
type="range"
|
||||||
|
min="0.25"
|
||||||
|
max="2"
|
||||||
|
step="0.05"
|
||||||
|
value="1"
|
||||||
|
/>
|
||||||
|
<label for="gamma"
|
||||||
|
>Response <output id="gamma-value" for="gamma"></output></label
|
||||||
|
><input
|
||||||
|
class="input"
|
||||||
|
id="gamma"
|
||||||
|
type="range"
|
||||||
|
min="0.25"
|
||||||
|
max="3"
|
||||||
|
step="0.05"
|
||||||
|
value="1"
|
||||||
|
/>
|
||||||
|
<div class="pressure-meter" aria-label="Pen pressure">
|
||||||
|
<div id="pressure-fill"></div>
|
||||||
|
</div>
|
||||||
|
<span id="pen-state" class="hint">Try your pen on the canvas</span>
|
||||||
|
<svg
|
||||||
|
id="curve"
|
||||||
|
viewBox="0 0 200 54"
|
||||||
|
role="img"
|
||||||
|
aria-label="Pressure response"
|
||||||
|
>
|
||||||
|
<path class="curve-grid" d="M0 53H200M0 27H200M100 0V54" />
|
||||||
|
<path id="curve-line" d="M0 53L200 1" />
|
||||||
|
</svg>
|
||||||
|
<label class="toggle-row" for="pressureRadius"
|
||||||
|
><span>Pressure changes size</span
|
||||||
|
><input
|
||||||
|
class="input"
|
||||||
|
role="switch"
|
||||||
|
id="pressureRadius"
|
||||||
|
type="checkbox"
|
||||||
|
/></label>
|
||||||
|
<button id="reset-pressure" class="btn wide" data-variant="outline">
|
||||||
|
Reset pen settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Sculpt & display</summary>
|
||||||
|
<div class="disclosure-content">
|
||||||
|
<label class="toggle-row" for="symmetry"
|
||||||
|
><span>Mirror left & right</span
|
||||||
|
><input
|
||||||
|
class="input"
|
||||||
|
role="switch"
|
||||||
|
id="symmetry"
|
||||||
|
type="checkbox"
|
||||||
|
checked
|
||||||
|
/></label>
|
||||||
|
<label for="quality">Display</label
|
||||||
|
><select class="select" id="quality">
|
||||||
|
<option value="economy">Battery saver</option>
|
||||||
|
<option value="balanced" selected>Balanced</option>
|
||||||
|
<option value="detail">Sharper</option>
|
||||||
|
</select>
|
||||||
|
<div class="swatches" role="group" aria-label="Clay color">
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-color="0.5,0.3,0.2"
|
||||||
|
style="--swatch: #bb8d70"
|
||||||
|
aria-label="Terracotta clay"
|
||||||
|
aria-pressed="true"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-color="0.42,0.48,0.48"
|
||||||
|
style="--swatch: #aab9b7"
|
||||||
|
aria-label="Stone clay"
|
||||||
|
aria-pressed="false"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
data-variant="ghost"
|
||||||
|
data-color="0.21,0.32,0.43"
|
||||||
|
style="--swatch: #718fa9"
|
||||||
|
aria-label="Slate clay"
|
||||||
|
aria-pressed="false"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div class="settings-footer">
|
||||||
|
<button id="fullscreen" class="btn" data-variant="ghost" hidden>
|
||||||
|
Full screen</button
|
||||||
|
><button id="help" class="btn" data-variant="ghost">Help</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div
|
||||||
|
id="status"
|
||||||
|
class="status-toast"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
></div>
|
||||||
|
<input
|
||||||
|
id="file"
|
||||||
|
type="file"
|
||||||
|
accept=".yawn-sculpt,application/json"
|
||||||
|
hidden
|
||||||
|
/>
|
||||||
|
<dialog id="new-dialog" class="dialog" aria-labelledby="new-title">
|
||||||
|
<form method="dialog">
|
||||||
|
<header>
|
||||||
|
<h2 id="new-title">Fresh clay</h2>
|
||||||
|
<p id="new-warning">Choose how much detail you want.</p>
|
||||||
|
</header>
|
||||||
|
<section>
|
||||||
|
<label for="density">Detail</label
|
||||||
|
><select class="select" id="density">
|
||||||
|
<option value="4">Simple</option>
|
||||||
|
<option value="5" selected>Balanced</option>
|
||||||
|
<option value="6">Fine</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">Simple feels lighter. Fine allows smaller details.</p>
|
||||||
|
</section>
|
||||||
|
<footer>
|
||||||
|
<button class="btn" data-variant="outline" value="cancel">
|
||||||
|
Cancel</button
|
||||||
|
><button value="create" class="btn">Create sphere</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
<dialog id="help-dialog" class="dialog" aria-labelledby="help-title">
|
||||||
|
<form method="dialog">
|
||||||
|
<header>
|
||||||
|
<h2 id="help-title">Make something.</h2>
|
||||||
|
<p>Press lightly for a soft touch, harder for a deeper stroke.</p>
|
||||||
|
</header>
|
||||||
|
<section>
|
||||||
|
<dl>
|
||||||
|
<dt>Find your tools</dt>
|
||||||
|
<dd>
|
||||||
|
Choose a brush in the left toolbar. The sliders button opens brush
|
||||||
|
settings; the gear opens projects and settings.
|
||||||
|
</dd>
|
||||||
|
<dt>Move around</dt>
|
||||||
|
<dd>
|
||||||
|
Drag the colored axis gizmo to orbit. Click an axis for a
|
||||||
|
straight-on view. Drag the pan or magnifier icons to move or zoom.
|
||||||
|
Arrow keys work when these controls are focused. Escape cancels a
|
||||||
|
drag. The scene uses Y as its up axis.
|
||||||
|
</dd>
|
||||||
|
<dt>Rest your hand</dt>
|
||||||
|
<dd>
|
||||||
|
Palm rejection is on. Turn it off in Settings to draw with a
|
||||||
|
finger or move with two fingers.
|
||||||
|
</dd>
|
||||||
|
<dt>Keep your work</dt>
|
||||||
|
<dd>
|
||||||
|
Download your project before leaving. Open it here to keep
|
||||||
|
sculpting later.
|
||||||
|
</dd>
|
||||||
|
<dt>With a mouse</dt>
|
||||||
|
<dd>Right drag to rotate, middle drag to pan, scroll to zoom.</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<footer><button value="close" class="btn">Got it</button></footer>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import { cameraBasis, add, mul, clamp, clampZoom } from "./math.js";
|
||||||
|
|
||||||
|
export class Input {
|
||||||
|
constructor(viewport, host) {
|
||||||
|
this.viewport = viewport;
|
||||||
|
this.host = host;
|
||||||
|
this.pointers = new Map();
|
||||||
|
this.active = null;
|
||||||
|
this.ending = false;
|
||||||
|
viewport.addEventListener("pointerdown", (e) => this.down(e));
|
||||||
|
viewport.addEventListener("pointermove", (e) => this.move(e));
|
||||||
|
viewport.addEventListener("pointerup", (e) => this.up(e));
|
||||||
|
viewport.addEventListener("pointercancel", (e) => {
|
||||||
|
const t = this.transaction;
|
||||||
|
if (
|
||||||
|
this.pointers.has(e.pointerId) ||
|
||||||
|
t?.pendingDown?.pointerId === e.pointerId ||
|
||||||
|
(t && !t.canceling && t.active.id === e.pointerId)
|
||||||
|
)
|
||||||
|
this.cancel();
|
||||||
|
});
|
||||||
|
viewport.addEventListener("lostpointercapture", (e) => {
|
||||||
|
if (
|
||||||
|
this.pointers.has(e.pointerId) ||
|
||||||
|
this.transaction?.pendingDown?.pointerId === e.pointerId
|
||||||
|
)
|
||||||
|
this.cancel();
|
||||||
|
});
|
||||||
|
viewport.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||||
|
viewport.addEventListener(
|
||||||
|
"wheel",
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.active || !host.ready()) return;
|
||||||
|
host.camera.zoom = clampZoom(
|
||||||
|
host.camera.zoom * Math.exp(e.deltaY * 0.001),
|
||||||
|
viewport.getBoundingClientRect(),
|
||||||
|
);
|
||||||
|
host.invalidate();
|
||||||
|
},
|
||||||
|
{ passive: false },
|
||||||
|
);
|
||||||
|
viewport.addEventListener("pointerleave", () => {
|
||||||
|
if (!this.active) host.cursor(null);
|
||||||
|
});
|
||||||
|
window.addEventListener("blur", () => this.cancel());
|
||||||
|
}
|
||||||
|
get busy() {
|
||||||
|
return !!this.active || !!this.transaction;
|
||||||
|
}
|
||||||
|
rejectsTouch(e) {
|
||||||
|
return (
|
||||||
|
e.pointerType === "touch" &&
|
||||||
|
(this.host.palmRejection() || this.active?.type === "pen")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
checkViewport(rect = this.viewport.getBoundingClientRect()) {
|
||||||
|
const owner = this.transaction?.pendingDown ?? this.active;
|
||||||
|
if (
|
||||||
|
rect.width <= 0 ||
|
||||||
|
rect.height <= 0 ||
|
||||||
|
(owner &&
|
||||||
|
(owner.canvas !== this.viewport.querySelector("canvas") ||
|
||||||
|
["left", "top", "width", "height"].some(
|
||||||
|
(key) => rect[key] !== owner.rect[key],
|
||||||
|
)))
|
||||||
|
) {
|
||||||
|
if (owner) {
|
||||||
|
this.cancel();
|
||||||
|
this.host.message(
|
||||||
|
"Viewport changed · current stroke canceled; completed work preserved",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
sample(e, r = this.active?.rect ?? this.viewport.getBoundingClientRect()) {
|
||||||
|
return {
|
||||||
|
x: e.clientX - r.left,
|
||||||
|
y: e.clientY - r.top,
|
||||||
|
pressure: e.pointerType === "pen" ? e.pressure : 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
world(
|
||||||
|
sample,
|
||||||
|
r = this.active?.rect ?? this.viewport.getBoundingClientRect(),
|
||||||
|
) {
|
||||||
|
const c = this.host.camera,
|
||||||
|
b = cameraBasis(c.yaw, c.pitch);
|
||||||
|
return add(
|
||||||
|
c.target,
|
||||||
|
add(
|
||||||
|
mul(
|
||||||
|
b.right,
|
||||||
|
(((sample.x / r.width) * 2 - 1) * c.zoom * r.width) / r.height,
|
||||||
|
),
|
||||||
|
mul(b.up, (1 - (sample.y / r.height) * 2) * c.zoom),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
pick(sample, rect) {
|
||||||
|
const b = cameraBasis(this.host.camera.yaw, this.host.camera.pitch);
|
||||||
|
return this.host
|
||||||
|
.engine()
|
||||||
|
.bvh.raycast(
|
||||||
|
add(this.world(sample, rect), mul(b.forward, 24)),
|
||||||
|
mul(b.forward, -1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
down(e) {
|
||||||
|
if (this.rejectsTouch(e)) return;
|
||||||
|
const canvas = this.viewport.querySelector("canvas");
|
||||||
|
if (e.target !== canvas) return;
|
||||||
|
if (!this.checkViewport()) return;
|
||||||
|
if (!this.host.ready() || this.ending) return;
|
||||||
|
if (this.pointers.has(e.pointerId) || this.pointers.size >= 2) return;
|
||||||
|
e.preventDefault();
|
||||||
|
this.viewport.focus({ preventScroll: true });
|
||||||
|
this.viewport.setPointerCapture(e.pointerId);
|
||||||
|
const rect = this.viewport.getBoundingClientRect();
|
||||||
|
if (e.pointerType === "pen" && this.active?.type === "touch") {
|
||||||
|
this.cancel();
|
||||||
|
if (this.transaction) {
|
||||||
|
this.transaction.pendingDown = {
|
||||||
|
pointerId: e.pointerId,
|
||||||
|
pointerType: e.pointerType,
|
||||||
|
clientX: e.clientX,
|
||||||
|
clientY: e.clientY,
|
||||||
|
pressure: e.pressure,
|
||||||
|
button: e.button,
|
||||||
|
shiftKey: e.shiftKey,
|
||||||
|
altKey: e.altKey,
|
||||||
|
rect,
|
||||||
|
canvas,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.start(e, rect, canvas);
|
||||||
|
}
|
||||||
|
start(e, rect, canvas) {
|
||||||
|
const sample = this.sample(e, rect);
|
||||||
|
this.pointers.set(e.pointerId, sample);
|
||||||
|
if (this.pointers.size > 1) {
|
||||||
|
if (this.active?.mode === "sculpt") {
|
||||||
|
this.cancelStroke();
|
||||||
|
}
|
||||||
|
this.active = {
|
||||||
|
mode: "gesture",
|
||||||
|
id: e.pointerId,
|
||||||
|
type: "touch",
|
||||||
|
rect,
|
||||||
|
canvas,
|
||||||
|
};
|
||||||
|
this.gesture = this.touchPair();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action =
|
||||||
|
e.button === 1
|
||||||
|
? "pan"
|
||||||
|
: e.button === 2
|
||||||
|
? "rotate"
|
||||||
|
: this.host.navigation();
|
||||||
|
const navigate = action !== "sculpt";
|
||||||
|
const hit = navigate ? null : this.pick(sample, rect);
|
||||||
|
this.active = {
|
||||||
|
id: e.pointerId,
|
||||||
|
type: e.pointerType,
|
||||||
|
mode: navigate || !hit ? "orbit" : "sculpt",
|
||||||
|
action,
|
||||||
|
start: sample,
|
||||||
|
last: sample,
|
||||||
|
hit,
|
||||||
|
rect,
|
||||||
|
canvas,
|
||||||
|
};
|
||||||
|
if (this.active.mode === "sculpt") {
|
||||||
|
const settings = { ...this.host.settings };
|
||||||
|
if (e.shiftKey) settings.brush = "smooth";
|
||||||
|
if (e.altKey) settings.invert = !settings.invert;
|
||||||
|
const engine = this.host.engine(),
|
||||||
|
client = this.host.processor();
|
||||||
|
engine.begin(settings);
|
||||||
|
const transaction = {
|
||||||
|
engine,
|
||||||
|
client,
|
||||||
|
active: this.active,
|
||||||
|
canceling: false,
|
||||||
|
};
|
||||||
|
this.transaction = transaction;
|
||||||
|
client
|
||||||
|
.begin(
|
||||||
|
{
|
||||||
|
positions: engine.mesh.positions.slice(),
|
||||||
|
settings,
|
||||||
|
camera: structuredClone(this.host.camera),
|
||||||
|
rect: {
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.top,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
},
|
||||||
|
hit,
|
||||||
|
sample,
|
||||||
|
},
|
||||||
|
(packet) => {
|
||||||
|
this.checkViewport();
|
||||||
|
if (this.transaction !== transaction || transaction.canceling)
|
||||||
|
return;
|
||||||
|
const { vertices, rows, touched } = packet;
|
||||||
|
for (let row = 0; row < rows.length; row++) {
|
||||||
|
const v = rows[row],
|
||||||
|
i = row * 8,
|
||||||
|
j = v * 3,
|
||||||
|
p = engine.mesh.positions,
|
||||||
|
n = engine.mesh.normals;
|
||||||
|
p[j] = vertices[i];
|
||||||
|
p[j + 1] = vertices[i + 1];
|
||||||
|
p[j + 2] = vertices[i + 2];
|
||||||
|
n[j] = vertices[i + 4];
|
||||||
|
n[j + 1] = vertices[i + 5];
|
||||||
|
n[j + 2] = vertices[i + 6];
|
||||||
|
engine.dirty.add(v);
|
||||||
|
}
|
||||||
|
for (const v of touched) engine.stroke.touched.add(v);
|
||||||
|
engine.revision++;
|
||||||
|
this.host.invalidate();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
(result) => this.settle(transaction, result),
|
||||||
|
() => {
|
||||||
|
// StrokeClient reports worker failure once through the renderer's
|
||||||
|
// error handler; this continuation only settles document ownership.
|
||||||
|
this.settle(transaction, { canceled: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.host.changed();
|
||||||
|
}
|
||||||
|
touchPair() {
|
||||||
|
const [a, b] = [...this.pointers.values()];
|
||||||
|
return a && b
|
||||||
|
? {
|
||||||
|
x: (a.x + b.x) / 2,
|
||||||
|
y: (a.y + b.y) / 2,
|
||||||
|
distance: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
pan(dx, dy) {
|
||||||
|
const c = this.host.camera,
|
||||||
|
b = cameraBasis(c.yaw, c.pitch),
|
||||||
|
scale = (2 * c.zoom) / this.active.rect.height;
|
||||||
|
c.target = add(
|
||||||
|
c.target,
|
||||||
|
add(mul(b.right, -dx * scale), mul(b.up, dy * scale)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
move(e) {
|
||||||
|
if (this.host.blocked?.()) return;
|
||||||
|
if (this.rejectsTouch(e)) return;
|
||||||
|
if (!this.checkViewport()) return;
|
||||||
|
if (!this.active && e.target.tagName !== "CANVAS") {
|
||||||
|
this.host.cursor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sample = this.sample(e);
|
||||||
|
this.host.cursor(sample);
|
||||||
|
this.host.pressure(e.pointerType === "pen" ? sample.pressure : null);
|
||||||
|
if (!this.active || (this.ending && this.active.mode === "sculpt")) return;
|
||||||
|
if (!this.pointers.has(e.pointerId)) return;
|
||||||
|
this.pointers.set(e.pointerId, sample);
|
||||||
|
if (this.active.mode === "gesture") {
|
||||||
|
const next = this.touchPair();
|
||||||
|
if (next && this.gesture) {
|
||||||
|
this.pan(next.x - this.gesture.x, next.y - this.gesture.y);
|
||||||
|
this.host.camera.zoom = clampZoom(
|
||||||
|
(this.host.camera.zoom * this.gesture.distance) / next.distance,
|
||||||
|
this.active.rect,
|
||||||
|
);
|
||||||
|
this.host.invalidate();
|
||||||
|
}
|
||||||
|
this.gesture = next;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.pointerId !== this.active.id) return;
|
||||||
|
if (this.active.mode === "sculpt") {
|
||||||
|
const samples = e.getCoalescedEvents?.() ?? [];
|
||||||
|
// Only Grab needs to observe the first effective pressure in the batch;
|
||||||
|
// writes overwrite a single slot, never enqueue samples.
|
||||||
|
if (this.host.engine().stroke.settings.brush === "grab")
|
||||||
|
for (const event of samples.length ? samples : [e])
|
||||||
|
this.transaction.client.write(this.sample(event));
|
||||||
|
else this.transaction.client.write(this.sample(samples.at(-1) ?? e));
|
||||||
|
} else {
|
||||||
|
const dx = sample.x - this.active.last.x,
|
||||||
|
dy = sample.y - this.active.last.y;
|
||||||
|
if (this.active.action === "pan") this.pan(dx, dy);
|
||||||
|
else if (this.active.action === "zoom")
|
||||||
|
this.host.camera.zoom = clampZoom(
|
||||||
|
this.host.camera.zoom * Math.exp(dy * 0.008),
|
||||||
|
this.active.rect,
|
||||||
|
);
|
||||||
|
else {
|
||||||
|
this.host.camera.yaw -= dx * 0.008;
|
||||||
|
this.host.camera.pitch = clamp(
|
||||||
|
this.host.camera.pitch + dy * 0.008,
|
||||||
|
-Math.PI / 2,
|
||||||
|
Math.PI / 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.host.invalidate();
|
||||||
|
}
|
||||||
|
if (this.active) this.active.last = sample;
|
||||||
|
}
|
||||||
|
up(e) {
|
||||||
|
if (e.pointerType === "touch" && this.host.palmRejection()) return;
|
||||||
|
if (this.transaction?.pendingDown?.pointerId === e.pointerId) {
|
||||||
|
this.transaction.pendingDown = null;
|
||||||
|
this.release(e.pointerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.checkViewport()) return;
|
||||||
|
this.pointers.delete(e.pointerId);
|
||||||
|
this.release(e.pointerId);
|
||||||
|
if (!this.active) return;
|
||||||
|
if (this.active.mode === "gesture") {
|
||||||
|
if (!this.pointers.size) {
|
||||||
|
this.active = null;
|
||||||
|
this.host.changed();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.pointerId !== this.active.id) return;
|
||||||
|
if (this.active.mode === "sculpt") {
|
||||||
|
this.ending = true;
|
||||||
|
this.transaction.client.end();
|
||||||
|
} else {
|
||||||
|
this.active = null;
|
||||||
|
this.host.changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settle(transaction, result) {
|
||||||
|
if (this.transaction !== transaction) return;
|
||||||
|
// Resize may precede the observer callback or this completion message.
|
||||||
|
this.checkViewport();
|
||||||
|
const engine = transaction.engine;
|
||||||
|
if (result.canceled || transaction.canceling) {
|
||||||
|
engine.end(true);
|
||||||
|
if (!transaction.canceling) {
|
||||||
|
const ids = [...this.pointers.keys()];
|
||||||
|
this.pointers.clear();
|
||||||
|
for (const id of ids) this.release(id);
|
||||||
|
}
|
||||||
|
this.host.invalidate();
|
||||||
|
} else {
|
||||||
|
// Repair main's face-normal cache and picking BVH only at the boundary.
|
||||||
|
engine.changed([...engine.stroke.touched]);
|
||||||
|
engine.end();
|
||||||
|
this.host.committed();
|
||||||
|
}
|
||||||
|
const pending = transaction.pendingDown;
|
||||||
|
this.transaction = null;
|
||||||
|
if (this.active === transaction.active) this.active = null;
|
||||||
|
this.ending = false;
|
||||||
|
if (result.message) this.host.message(result.message);
|
||||||
|
if (pending) {
|
||||||
|
if (
|
||||||
|
this.host.ready() &&
|
||||||
|
this.host.engine() === engine &&
|
||||||
|
this.host.processor() === transaction.client &&
|
||||||
|
this.viewport.hasPointerCapture(pending.pointerId)
|
||||||
|
)
|
||||||
|
this.start(pending, pending.rect, pending.canvas);
|
||||||
|
else this.release(pending.pointerId);
|
||||||
|
}
|
||||||
|
this.host.changed();
|
||||||
|
}
|
||||||
|
cancelStroke() {
|
||||||
|
const transaction = this.transaction;
|
||||||
|
if (!transaction || transaction.canceling) return;
|
||||||
|
transaction.canceling = true;
|
||||||
|
this.ending = true;
|
||||||
|
transaction.client.cancel();
|
||||||
|
transaction.engine.end(true);
|
||||||
|
this.host.invalidate();
|
||||||
|
}
|
||||||
|
cancel() {
|
||||||
|
const ids = [...this.pointers.keys()];
|
||||||
|
if (this.transaction?.pendingDown) {
|
||||||
|
ids.push(this.transaction.pendingDown.pointerId);
|
||||||
|
this.transaction.pendingDown = null;
|
||||||
|
}
|
||||||
|
this.cancelStroke();
|
||||||
|
this.pointers.clear();
|
||||||
|
this.active = null;
|
||||||
|
for (const id of ids) this.release(id);
|
||||||
|
this.host.changed();
|
||||||
|
}
|
||||||
|
release(id) {
|
||||||
|
if (this.viewport.hasPointerCapture(id))
|
||||||
|
this.viewport.releasePointerCapture(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Input } from "./input.js";
|
||||||
|
import { Sculpt, defaults } from "./engine.js";
|
||||||
|
import { sphere, updateNormals } from "./mesh.js";
|
||||||
|
import { Renderer } from "./renderer.js";
|
||||||
|
import { StrokeClient } from "./stroke-client.js";
|
||||||
|
import { StrokeProcessor } from "./stroke-worker.js";
|
||||||
|
import { Mailbox } from "./mailbox.js";
|
||||||
|
|
||||||
|
// Exercise the real client protocol, with frame delivery under test control.
|
||||||
|
// Structured clone/transfer and asynchronous messages match worker ownership.
|
||||||
|
class ControlledWorker {
|
||||||
|
postMessage(data, transfer = []) {
|
||||||
|
const copy = structuredClone(data, { transfer });
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (this.dead) return;
|
||||||
|
if (copy.type === "init") {
|
||||||
|
this.processor = new StrokeProcessor(
|
||||||
|
sphere(copy.level),
|
||||||
|
new Mailbox(copy.row),
|
||||||
|
(message, transfers = []) => {
|
||||||
|
const value = structuredClone(message, { transfer: transfers });
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!this.dead) this.onmessage({ data: value });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
() => 0,
|
||||||
|
);
|
||||||
|
this.onmessage({ data: { type: "ready" } });
|
||||||
|
} else if (copy.type === "begin") this.processor.begin(copy);
|
||||||
|
else if (copy.type === "end") this.processor.end(copy.id, copy.version);
|
||||||
|
else if (copy.type === "cancel") this.processor.cancel(copy.id);
|
||||||
|
else if (copy.type === "ack") this.processor.acknowledge(copy.packet);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
terminate() {
|
||||||
|
this.dead = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
async function setup(t) {
|
||||||
|
const oldWorker = globalThis.Worker,
|
||||||
|
oldWindow = globalThis.window;
|
||||||
|
globalThis.Worker = ControlledWorker;
|
||||||
|
globalThis.window = { addEventListener() {} };
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.Worker = oldWorker;
|
||||||
|
globalThis.window = oldWindow;
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
const row = { buffer: new SharedArrayBuffer(64), descriptor: { offset: 0 } };
|
||||||
|
row.share = () => ({ buffer: row.buffer, descriptor: row.descriptor });
|
||||||
|
const client = new StrokeClient(row, 4, (error) => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
await client.ready;
|
||||||
|
const engine = new Sculpt(sphere(4)),
|
||||||
|
listeners = new Map();
|
||||||
|
let commits = 0,
|
||||||
|
message = "";
|
||||||
|
const rect = { left: 0, top: 0, width: 800, height: 600 };
|
||||||
|
const captures = new Set();
|
||||||
|
const viewport = {
|
||||||
|
canvas: { tagName: "CANVAS" },
|
||||||
|
querySelector() {
|
||||||
|
return this.canvas;
|
||||||
|
},
|
||||||
|
addEventListener: (name, fn) => listeners.set(name, fn),
|
||||||
|
focus() {},
|
||||||
|
setPointerCapture(id) {
|
||||||
|
captures.add(id);
|
||||||
|
},
|
||||||
|
hasPointerCapture(id) {
|
||||||
|
return captures.has(id);
|
||||||
|
},
|
||||||
|
releasePointerCapture(id) {
|
||||||
|
captures.delete(id);
|
||||||
|
listeners.get("lostpointercapture")({ pointerId: id });
|
||||||
|
},
|
||||||
|
getBoundingClientRect: () => ({ ...rect }),
|
||||||
|
};
|
||||||
|
const host = {
|
||||||
|
engine: () => engine,
|
||||||
|
processor: () => client,
|
||||||
|
error: (e) => {
|
||||||
|
throw e;
|
||||||
|
},
|
||||||
|
camera: { yaw: 0, pitch: 0, zoom: 1.5, target: [0, 0, 0] },
|
||||||
|
settings: { ...defaults, symmetry: false },
|
||||||
|
ready: () => true,
|
||||||
|
navigation: () => "sculpt",
|
||||||
|
palmRejection: () => false,
|
||||||
|
schedule() {},
|
||||||
|
invalidate() {},
|
||||||
|
changed() {},
|
||||||
|
committed() {
|
||||||
|
commits++;
|
||||||
|
},
|
||||||
|
message(text) {
|
||||||
|
message = text;
|
||||||
|
},
|
||||||
|
cursor() {},
|
||||||
|
pressure() {},
|
||||||
|
};
|
||||||
|
const input = new Input(viewport, host);
|
||||||
|
const event = (x = 400, y = 300, type = "pen", pressure = 0.7, id = 1) => ({
|
||||||
|
clientX: x,
|
||||||
|
clientY: y,
|
||||||
|
pointerType: type,
|
||||||
|
pressure,
|
||||||
|
pointerId: id,
|
||||||
|
button: 0,
|
||||||
|
target: viewport.canvas,
|
||||||
|
preventDefault() {},
|
||||||
|
});
|
||||||
|
const frame = async () => {
|
||||||
|
await flush();
|
||||||
|
client.worker.processor.frame();
|
||||||
|
await flush();
|
||||||
|
};
|
||||||
|
const finish = async () => {
|
||||||
|
let count = 0;
|
||||||
|
while (input.transaction) {
|
||||||
|
await frame();
|
||||||
|
assert.ok(++count < 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
input,
|
||||||
|
engine,
|
||||||
|
host,
|
||||||
|
client,
|
||||||
|
viewport,
|
||||||
|
captures,
|
||||||
|
event,
|
||||||
|
rect,
|
||||||
|
listeners,
|
||||||
|
frame,
|
||||||
|
finish,
|
||||||
|
get commits() {
|
||||||
|
return commits;
|
||||||
|
},
|
||||||
|
get message() {
|
||||||
|
return message;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("palm rejection ignores touch before capture, focus, cursor or geometry; pen still works", async (t) => {
|
||||||
|
const a = await setup(t),
|
||||||
|
before = a.engine.mesh.positions.slice();
|
||||||
|
a.host.palmRejection = () => true;
|
||||||
|
a.viewport.focus =
|
||||||
|
a.host.cursor =
|
||||||
|
a.host.pressure =
|
||||||
|
() => {
|
||||||
|
throw Error("Rejected palm reached UI");
|
||||||
|
};
|
||||||
|
a.input.down(a.event(400, 300, "touch"));
|
||||||
|
a.input.move(a.event(460, 330, "touch"));
|
||||||
|
a.input.up(a.event(460, 330, "touch"));
|
||||||
|
assert.equal(a.captures.size, 0);
|
||||||
|
assert.equal(a.input.busy, false);
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
a.viewport.focus = a.host.cursor = a.host.pressure = () => {};
|
||||||
|
a.input.down(a.event());
|
||||||
|
a.input.up(a.event());
|
||||||
|
await a.finish();
|
||||||
|
assert.notDeepEqual(a.engine.mesh.positions, before);
|
||||||
|
});
|
||||||
|
test("pen navigation snapshots rotate/pan/zoom without sculpting", async (t) => {
|
||||||
|
const a = await setup(t),
|
||||||
|
before = a.engine.mesh.positions.slice();
|
||||||
|
a.host.palmRejection = () => true;
|
||||||
|
for (const mode of ["rotate", "pan", "zoom"]) {
|
||||||
|
a.host.camera = { yaw: 0, pitch: 0, zoom: 1.5, target: [0, 0, 0] };
|
||||||
|
a.host.navigation = () => mode;
|
||||||
|
a.input.down(a.event());
|
||||||
|
a.host.navigation = () => "sculpt";
|
||||||
|
a.input.move(a.event(440, 320));
|
||||||
|
a.input.up(a.event(440, 320));
|
||||||
|
const c = a.host.camera;
|
||||||
|
assert.equal(c.yaw, mode === "rotate" ? -0.32 : 0);
|
||||||
|
assert.equal(c.pitch, mode === "rotate" ? 0.16 : 0);
|
||||||
|
assert.deepEqual(c.target, mode === "pan" ? [-0.2, 0.1, 0] : [0, 0, 0]);
|
||||||
|
assert.equal(c.zoom, mode === "zoom" ? 1.5 * Math.exp(0.16) : 1.5);
|
||||||
|
assert.equal(a.input.busy, false);
|
||||||
|
}
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.engine.undoStack.length, 0);
|
||||||
|
});
|
||||||
|
test("zero pen is a no-op; release flushes newest mouse movement and commits once", async (t) => {
|
||||||
|
const a = await setup(t),
|
||||||
|
before = a.engine.mesh.positions.slice();
|
||||||
|
a.input.down(a.event(400, 300, "pen", 0));
|
||||||
|
a.input.up(a.event());
|
||||||
|
await a.finish();
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.engine.undoStack.length, 0);
|
||||||
|
a.input.down(a.event(400, 300, "mouse", 0));
|
||||||
|
a.input.move(a.event(520, 300, "mouse", 0));
|
||||||
|
a.input.up(a.event(520, 300, "mouse", 0));
|
||||||
|
assert.equal(a.engine.undoStack.length, 0);
|
||||||
|
await a.finish();
|
||||||
|
assert.notDeepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.engine.undoStack.length, 1);
|
||||||
|
assert.equal(a.commits, 2);
|
||||||
|
a.input.up(a.event());
|
||||||
|
assert.equal(a.commits, 2);
|
||||||
|
});
|
||||||
|
test("hundreds of moves occupy one slot, no main-thread dabs or per-frame BVH maintenance", async (t) => {
|
||||||
|
const a = await setup(t);
|
||||||
|
let changed = 0;
|
||||||
|
const original = a.engine.changed.bind(a.engine);
|
||||||
|
a.engine.changed = (ids) => {
|
||||||
|
changed++;
|
||||||
|
original(ids);
|
||||||
|
};
|
||||||
|
a.engine.dab = () => assert.fail("main thread performed a dab");
|
||||||
|
a.input.down(a.event());
|
||||||
|
for (let i = 0; i < 1000; i++) a.input.move(a.event(400 + i / 10, 310));
|
||||||
|
assert.equal(a.input.queue, undefined);
|
||||||
|
assert.equal(a.client.mailbox.words.byteLength, 64);
|
||||||
|
await a.frame();
|
||||||
|
await a.frame();
|
||||||
|
assert.equal(changed, 0);
|
||||||
|
a.input.up(a.event());
|
||||||
|
await a.finish();
|
||||||
|
assert.equal(changed, 1);
|
||||||
|
assert.equal(a.engine.undoStack.length, 1);
|
||||||
|
});
|
||||||
|
test("cancel rejects late geometry and rolls back without another animation frame", async (t) => {
|
||||||
|
const a = await setup(t),
|
||||||
|
before = a.engine.mesh.positions.slice();
|
||||||
|
a.input.down(a.event());
|
||||||
|
await a.frame();
|
||||||
|
assert.notDeepEqual(a.engine.mesh.positions, before);
|
||||||
|
a.input.move(a.event(480, 310));
|
||||||
|
await flush();
|
||||||
|
a.client.worker.processor.frame(); // geometry message is now in flight
|
||||||
|
a.input.cancel();
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.input.busy, true);
|
||||||
|
await flush();
|
||||||
|
assert.equal(a.input.busy, false);
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.engine.undoStack.length, 0);
|
||||||
|
});
|
||||||
|
test("cancel wins over a completion that has not yet committed on main", async (t) => {
|
||||||
|
const a = await setup(t),
|
||||||
|
before = a.engine.mesh.positions.slice();
|
||||||
|
a.input.down(a.event());
|
||||||
|
await a.frame();
|
||||||
|
a.input.up(a.event());
|
||||||
|
await flush();
|
||||||
|
a.client.worker.processor.frame();
|
||||||
|
a.input.cancel();
|
||||||
|
await flush();
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.engine.undoStack.length, 0);
|
||||||
|
assert.equal(a.commits, 0);
|
||||||
|
});
|
||||||
|
test("touch takeover, pen palm rejection, capture loss and pen-over-touch handoff", async (t) => {
|
||||||
|
const a = await setup(t),
|
||||||
|
before = a.engine.mesh.positions.slice();
|
||||||
|
a.input.down(a.event(400, 300, "touch", 1, 1));
|
||||||
|
await a.frame();
|
||||||
|
a.input.down(a.event(450, 320, "touch", 1, 2));
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.input.active.mode, "gesture");
|
||||||
|
a.input.move(a.event(500, 340, "touch", 1, 2));
|
||||||
|
assert.notEqual(a.host.camera.zoom, 1.5);
|
||||||
|
a.input.up(a.event(400, 300, "touch", 0, 1));
|
||||||
|
a.input.up(a.event(500, 340, "touch", 0, 2));
|
||||||
|
await flush();
|
||||||
|
assert.equal(a.input.busy, false);
|
||||||
|
a.input.down(a.event(400, 300, "pen", 0.7, 3));
|
||||||
|
a.input.down(a.event(440, 310, "touch", 1, 4));
|
||||||
|
assert.equal(a.input.active.id, 3);
|
||||||
|
await a.frame();
|
||||||
|
a.listeners.get("lostpointercapture")({ pointerId: 3 });
|
||||||
|
await flush();
|
||||||
|
assert.equal(a.input.busy, false);
|
||||||
|
a.input.down(a.event(400, 300, "touch", 1, 5));
|
||||||
|
await a.frame();
|
||||||
|
a.input.down(a.event(400, 300, "pen", 0.8, 6));
|
||||||
|
await flush();
|
||||||
|
assert.equal(a.input.active.id, 6);
|
||||||
|
a.input.up(a.event(400, 300, "pen", 0, 6));
|
||||||
|
await a.finish();
|
||||||
|
assert.equal(a.engine.undoStack.length, 1);
|
||||||
|
});
|
||||||
|
test("resize before release, geometry delivery, or completion preserves existing undo and redo", async (t) => {
|
||||||
|
const a = await setup(t);
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
a.input.down(a.event(400 + i * 20, 300));
|
||||||
|
a.input.up(a.event());
|
||||||
|
await a.finish();
|
||||||
|
}
|
||||||
|
a.engine.restore();
|
||||||
|
const before = a.engine.mesh.positions.slice(),
|
||||||
|
undo = [...a.engine.undoStack],
|
||||||
|
redo = [...a.engine.redoStack];
|
||||||
|
for (const mode of ["release", "packet", "complete", "grab"]) {
|
||||||
|
a.host.settings.brush = mode === "grab" ? "grab" : "draw";
|
||||||
|
a.input.down(a.event());
|
||||||
|
a.input.move(a.event(440, 320));
|
||||||
|
await a.frame();
|
||||||
|
await a.frame();
|
||||||
|
assert.notDeepEqual(a.engine.mesh.positions, before);
|
||||||
|
if (mode === "complete") a.input.up(a.event());
|
||||||
|
a.rect.width = 650;
|
||||||
|
if (mode === "packet") {
|
||||||
|
a.input.move(a.event(470, 330));
|
||||||
|
} else a.input.up(a.event());
|
||||||
|
await a.finish();
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.deepEqual(a.engine.undoStack, undo);
|
||||||
|
assert.deepEqual(a.engine.redoStack, redo);
|
||||||
|
a.rect.width = 800;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("commits repair face-normal caches; undo/redo and adjacent strokes agree with full normals", async (t) => {
|
||||||
|
const a = await setup(t);
|
||||||
|
for (const x of [400, 430, 450]) {
|
||||||
|
a.input.down(a.event(x, 300));
|
||||||
|
a.input.move(a.event(x + 35, 330));
|
||||||
|
a.input.up(a.event());
|
||||||
|
await a.finish();
|
||||||
|
const expected = sphere(4);
|
||||||
|
expected.positions.set(a.engine.mesh.positions);
|
||||||
|
updateNormals(expected);
|
||||||
|
assert.deepEqual(a.engine.mesh.faceNormals, expected.faceNormals);
|
||||||
|
assert.deepEqual(a.engine.mesh.normals, expected.normals);
|
||||||
|
}
|
||||||
|
for (const redo of [false, true]) {
|
||||||
|
a.engine.restore(redo);
|
||||||
|
const expected = sphere(4);
|
||||||
|
expected.positions.set(a.engine.mesh.positions);
|
||||||
|
updateNormals(expected);
|
||||||
|
assert.deepEqual(a.engine.mesh.normals, expected.normals);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("worker termination rolls back provisional geometry and late packets cannot revive it", async (t) => {
|
||||||
|
const a = await setup(t),
|
||||||
|
before = a.engine.mesh.positions.slice();
|
||||||
|
a.input.down(a.event());
|
||||||
|
await a.frame();
|
||||||
|
a.client.dispose();
|
||||||
|
await flush();
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.input.busy, false);
|
||||||
|
assert.equal(a.engine.undoStack.length, 0);
|
||||||
|
});
|
||||||
|
test("pending pen termination and stale canvas cannot revive a canceled contact", async (t) => {
|
||||||
|
const a = await setup(t),
|
||||||
|
before = a.engine.mesh.positions.slice();
|
||||||
|
for (const termination of [
|
||||||
|
"pointercancel",
|
||||||
|
"lostpointercapture",
|
||||||
|
"pointerup",
|
||||||
|
"resize",
|
||||||
|
"canvas",
|
||||||
|
"unready",
|
||||||
|
"continuation",
|
||||||
|
]) {
|
||||||
|
a.input.down(a.event(400, 300, "touch", 1, 1));
|
||||||
|
await a.frame();
|
||||||
|
const pen = a.event(400, 300, "pen", 0.8, 2);
|
||||||
|
let prevented = false;
|
||||||
|
pen.preventDefault = () => {
|
||||||
|
prevented = true;
|
||||||
|
};
|
||||||
|
a.input.down(pen);
|
||||||
|
assert.ok(prevented);
|
||||||
|
assert.ok(a.captures.has(2));
|
||||||
|
const id = a.client.nextId;
|
||||||
|
if (termination === "pointerup") a.input.up(a.event(400, 300, "pen", 0, 2));
|
||||||
|
else if (termination === "resize") a.rect.left = 10;
|
||||||
|
else if (termination === "canvas")
|
||||||
|
a.viewport.canvas = { tagName: "CANVAS" };
|
||||||
|
else if (termination === "unready") a.host.ready = () => false;
|
||||||
|
else if (termination === "continuation") {
|
||||||
|
a.client.message({ type: "canceled", id: a.client.job.id });
|
||||||
|
a.listeners.get("pointercancel")({ pointerId: 2 });
|
||||||
|
} else a.listeners.get(termination)({ pointerId: 2 });
|
||||||
|
await flush();
|
||||||
|
assert.equal(a.client.nextId, id);
|
||||||
|
assert.equal(a.input.busy, false);
|
||||||
|
assert.equal(a.captures.size, 0);
|
||||||
|
assert.equal(a.engine.stroke, null);
|
||||||
|
assert.deepEqual(a.engine.mesh.positions, before);
|
||||||
|
assert.equal(a.engine.undoStack.length, 0);
|
||||||
|
a.rect.left = 0;
|
||||||
|
a.host.ready = () => true;
|
||||||
|
}
|
||||||
|
// A late old-touch cancellation cannot take ownership from the live pen.
|
||||||
|
a.input.down(a.event(400, 300, "touch", 1, 1));
|
||||||
|
await a.frame();
|
||||||
|
a.input.down(a.event(400, 300, "pen", 0.8, 2));
|
||||||
|
a.listeners.get("pointercancel")({ pointerId: 1 });
|
||||||
|
await flush();
|
||||||
|
assert.equal(a.input.active.id, 2);
|
||||||
|
a.input.up(a.event(400, 300, "pen", 0, 2));
|
||||||
|
await a.finish();
|
||||||
|
assert.equal(a.engine.undoStack.length, 1);
|
||||||
|
// DOM controls never acquire canvas capture or prevent touch defaults.
|
||||||
|
let prevented = false;
|
||||||
|
a.input.down({
|
||||||
|
...a.event(),
|
||||||
|
pointerType: "touch",
|
||||||
|
target: { tagName: "BUTTON" },
|
||||||
|
preventDefault: () => {
|
||||||
|
prevented = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(prevented, false);
|
||||||
|
assert.equal(a.captures.size, 0);
|
||||||
|
});
|
||||||
|
test("renderer staging remains immutable while upload is in flight", async () => {
|
||||||
|
const s = new Sculpt(sphere(4)),
|
||||||
|
r = new Renderer();
|
||||||
|
r.mesh = s.mesh;
|
||||||
|
r.canvas = { width: 800, height: 600 };
|
||||||
|
r.vertices = { view: new Float32Array((s.mesh.positions.length / 3) * 8) };
|
||||||
|
r.camera = { view: new Float32Array(20) };
|
||||||
|
const waits = [];
|
||||||
|
r.core = {
|
||||||
|
syncRows: () =>
|
||||||
|
new Promise((resolve, reject) => waits.push({ resolve, reject })),
|
||||||
|
};
|
||||||
|
const camera = {
|
||||||
|
yaw: 0,
|
||||||
|
pitch: 0,
|
||||||
|
zoom: 1.5,
|
||||||
|
target: [0, 0, 0],
|
||||||
|
color: [0.5, 0.3, 0.2],
|
||||||
|
};
|
||||||
|
const pending = r.publish(s, camera),
|
||||||
|
staged = r.vertices.view.slice();
|
||||||
|
s.begin({});
|
||||||
|
s.dab({ position: [0, 0, 1], normal: [0, 0, 1] });
|
||||||
|
s.end();
|
||||||
|
assert.equal(await r.publish(s, camera), false);
|
||||||
|
assert.deepEqual(r.vertices.view, staged);
|
||||||
|
waits.shift().resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
waits.shift().resolve();
|
||||||
|
await pending;
|
||||||
|
assert.ok(s.dirty.size > 0);
|
||||||
|
const failure = r.publish(s, camera);
|
||||||
|
waits.shift().reject(new Error("upload"));
|
||||||
|
await assert.rejects(failure, /upload/);
|
||||||
|
assert.equal(r.busy, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Explicit browser benchmark; never imported by the application.
|
||||||
|
// await (await import('/assets/sculpt/latency-check.js')).check()
|
||||||
|
import { Input } from "./input.js";
|
||||||
|
import { Renderer } from "./renderer.js";
|
||||||
|
import { StrokeClient } from "./stroke-client.js";
|
||||||
|
import { Sculpt } from "./engine.js";
|
||||||
|
|
||||||
|
export async function check() {
|
||||||
|
const viewport = document.getElementById("viewport");
|
||||||
|
const move = Input.prototype.move,
|
||||||
|
publish = Renderer.prototype.publish,
|
||||||
|
message = StrokeClient.prototype.message,
|
||||||
|
dab = Sculpt.prototype.dab;
|
||||||
|
const capture = viewport.setPointerCapture;
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const frame = () => new Promise(requestAnimationFrame);
|
||||||
|
const wait = async (predicate) => {
|
||||||
|
const start = performance.now();
|
||||||
|
while (!predicate()) {
|
||||||
|
if (performance.now() - start > 15000)
|
||||||
|
throw new Error("Latency check timed out");
|
||||||
|
await sleep(10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let input,
|
||||||
|
durations = [],
|
||||||
|
uploads = [],
|
||||||
|
packets = [],
|
||||||
|
mainDabs = 0,
|
||||||
|
canceled = false;
|
||||||
|
Input.prototype.move = function (event) {
|
||||||
|
input = this;
|
||||||
|
const start = performance.now();
|
||||||
|
const result = move.call(this, event);
|
||||||
|
durations.push(performance.now() - start);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
Sculpt.prototype.dab = function (...args) {
|
||||||
|
mainDabs++;
|
||||||
|
return dab.apply(this, args);
|
||||||
|
};
|
||||||
|
StrokeClient.prototype.message = function (data) {
|
||||||
|
const start = performance.now();
|
||||||
|
const result = message.call(this, data);
|
||||||
|
if (data.type === "geometry") packets.push(performance.now() - start);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
Renderer.prototype.publish = async function (...args) {
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
return await publish.apply(this, args);
|
||||||
|
} finally {
|
||||||
|
uploads.push(performance.now() - start);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
viewport.setPointerCapture = () => {};
|
||||||
|
const pointer = (type, x, y, pressure = 0.8) => {
|
||||||
|
const r = viewport.getBoundingClientRect();
|
||||||
|
return new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
pointerId: 7,
|
||||||
|
pointerType: "pen",
|
||||||
|
clientX: r.left + r.width / 2 + x,
|
||||||
|
clientY: r.top + r.height / 2 + y,
|
||||||
|
pressure,
|
||||||
|
button: 0,
|
||||||
|
buttons: type === "pointerup" ? 0 : 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const send = (event) => viewport.querySelector("canvas").dispatchEvent(event);
|
||||||
|
const percentile = (values, p) =>
|
||||||
|
[...values].sort((a, b) => a - b)[
|
||||||
|
Math.min(values.length - 1, Math.floor(values.length * p))
|
||||||
|
] ?? 0;
|
||||||
|
const results = [];
|
||||||
|
try {
|
||||||
|
for (const brush of ["draw", "grab"])
|
||||||
|
for (const perFrame of [1, 4, 16]) {
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
document.getElementById("loading").hidden &&
|
||||||
|
!document.getElementById("new").disabled,
|
||||||
|
);
|
||||||
|
const old = viewport.querySelector("canvas");
|
||||||
|
document.getElementById("new").click();
|
||||||
|
document.getElementById("density").value = "5";
|
||||||
|
document.getElementById("new-dialog").close("create");
|
||||||
|
await wait(
|
||||||
|
() =>
|
||||||
|
old !== viewport.querySelector("canvas") &&
|
||||||
|
document.getElementById("loading").hidden,
|
||||||
|
);
|
||||||
|
document.querySelector(`[data-brush="${brush}"]`).click();
|
||||||
|
await sleep(300);
|
||||||
|
durations = [];
|
||||||
|
uploads = [];
|
||||||
|
packets = [];
|
||||||
|
mainDabs = 0;
|
||||||
|
canceled = false;
|
||||||
|
send(pointer("pointerdown", -80, -30));
|
||||||
|
for (let f = 0; f < 60; f++) {
|
||||||
|
await frame();
|
||||||
|
const samples = Array.from({ length: perFrame }, (_, i) => {
|
||||||
|
const t = (f + (i + 1) / perFrame) / 60;
|
||||||
|
return pointer(
|
||||||
|
"pointermove",
|
||||||
|
-80 + 160 * t,
|
||||||
|
-30 + 30 * Math.sin(t * Math.PI),
|
||||||
|
0.3 + 0.6 * t,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const event = samples.at(-1);
|
||||||
|
Object.defineProperty(event, "getCoalescedEvents", {
|
||||||
|
value: () => samples,
|
||||||
|
});
|
||||||
|
send(event);
|
||||||
|
if (input && !input.busy) canceled = true;
|
||||||
|
}
|
||||||
|
const release = performance.now();
|
||||||
|
send(pointer("pointerup", 80, -30, 0));
|
||||||
|
await wait(() => !document.getElementById("save").disabled);
|
||||||
|
const releaseMs = performance.now() - release;
|
||||||
|
const undoEntries = input.host.engine().undoStack.length;
|
||||||
|
canceled ||= undoEntries !== 1;
|
||||||
|
if (canceled || mainDabs || input.queue !== undefined)
|
||||||
|
throw new Error("Worker input regression");
|
||||||
|
results.push({
|
||||||
|
brush,
|
||||||
|
samplesPerFrame: perFrame,
|
||||||
|
sharedInputBytes: input.host.processor().mailbox.words.byteLength,
|
||||||
|
mainDabs,
|
||||||
|
canceled,
|
||||||
|
releaseMs,
|
||||||
|
mainInputP95: percentile(durations, 0.95),
|
||||||
|
mainPacketP95: percentile(packets, 0.95),
|
||||||
|
uploadP95: percentile(uploads, 0.95),
|
||||||
|
undoEntries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
} finally {
|
||||||
|
Input.prototype.move = move;
|
||||||
|
StrokeClient.prototype.message = message;
|
||||||
|
Sculpt.prototype.dab = dab;
|
||||||
|
Renderer.prototype.publish = publish;
|
||||||
|
viewport.setPointerCapture = capture;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// One latest target in a core shared row. Versions are snapshot tokens, not a
|
||||||
|
// sample count. The producer never touches the consumer's acknowledgement.
|
||||||
|
export class Mailbox {
|
||||||
|
constructor({ buffer, descriptor }) {
|
||||||
|
this.words = new Int32Array(buffer, descriptor.offset, 16);
|
||||||
|
this.scratch = new ArrayBuffer(4);
|
||||||
|
this.bits = new Int32Array(this.scratch);
|
||||||
|
this.float = new Float32Array(this.scratch);
|
||||||
|
}
|
||||||
|
encode(value) {
|
||||||
|
this.float[0] = value;
|
||||||
|
return this.bits[0];
|
||||||
|
}
|
||||||
|
decode(value) {
|
||||||
|
this.bits[0] = value;
|
||||||
|
return this.float[0];
|
||||||
|
}
|
||||||
|
write(stroke, sample, seed) {
|
||||||
|
const version = (Atomics.load(this.words, 0) + 2) | 0;
|
||||||
|
Atomics.store(this.words, 0, version - 1);
|
||||||
|
Atomics.store(this.words, 2, stroke);
|
||||||
|
Atomics.store(this.words, 3, this.encode(sample.x));
|
||||||
|
Atomics.store(this.words, 4, this.encode(sample.y));
|
||||||
|
Atomics.store(this.words, 5, this.encode(sample.pressure));
|
||||||
|
Atomics.store(this.words, 6, this.encode(seed ?? 0));
|
||||||
|
Atomics.store(this.words, 7, seed === undefined ? 0 : 1);
|
||||||
|
Atomics.store(this.words, 0, version);
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
read(stroke) {
|
||||||
|
const a = Atomics.load(this.words, 0);
|
||||||
|
if (a & 1 || a === Atomics.load(this.words, 1)) return null;
|
||||||
|
const id = Atomics.load(this.words, 2);
|
||||||
|
const x = this.decode(Atomics.load(this.words, 3));
|
||||||
|
const y = this.decode(Atomics.load(this.words, 4));
|
||||||
|
const pressure = this.decode(Atomics.load(this.words, 5));
|
||||||
|
const seed = this.decode(Atomics.load(this.words, 6));
|
||||||
|
const hasSeed = Atomics.load(this.words, 7);
|
||||||
|
if (a !== Atomics.load(this.words, 0) || id !== stroke) return null;
|
||||||
|
return { version: a, x, y, pressure, seed: hasSeed ? seed : undefined };
|
||||||
|
}
|
||||||
|
acknowledge(version) {
|
||||||
|
Atomics.store(this.words, 1, version);
|
||||||
|
}
|
||||||
|
get processed() {
|
||||||
|
return Atomics.load(this.words, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export const add = (a, b) => a.map((v, i) => v + b[i]);
|
||||||
|
export const sub = (a, b) => a.map((v, i) => v - b[i]);
|
||||||
|
export const mul = (a, s) => a.map((v) => v * s);
|
||||||
|
export const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||||
|
export const cross = (a, b) => [
|
||||||
|
a[1] * b[2] - a[2] * b[1],
|
||||||
|
a[2] * b[0] - a[0] * b[2],
|
||||||
|
a[0] * b[1] - a[1] * b[0],
|
||||||
|
];
|
||||||
|
export const unit = (a) => mul(a, 1 / (Math.hypot(...a) || 1));
|
||||||
|
export const point = (p, i) => Array.from(p.subarray(i * 3, i * 3 + 3));
|
||||||
|
export const clamp = (n, a, b) => Math.max(a, Math.min(b, n));
|
||||||
|
|
||||||
|
// Bound apparent scale along the viewport's shorter side, in either orientation.
|
||||||
|
export function clampZoom(zoom, rect) {
|
||||||
|
const scale = Math.min(1, rect.width / rect.height);
|
||||||
|
return clamp(zoom * scale, 0.2, 14) / scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orthographic camera: the same basis drives rendering, rays and grab planes.
|
||||||
|
export function cameraBasis(yaw, pitch) {
|
||||||
|
const forward = [
|
||||||
|
Math.sin(yaw) * Math.cos(pitch),
|
||||||
|
Math.sin(pitch),
|
||||||
|
Math.cos(yaw) * Math.cos(pitch),
|
||||||
|
];
|
||||||
|
// Analytic basis remains defined at the top/bottom cardinal views.
|
||||||
|
const right = [Math.cos(yaw), 0, -Math.sin(yaw)];
|
||||||
|
return { right, up: cross(forward, right), forward };
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Panels overlay the canvas: revealing tools never resizes or replaces it.
|
||||||
|
export class Menus {
|
||||||
|
constructor(canOpen, ignoreOutsidePointer) {
|
||||||
|
this.canOpen = canOpen;
|
||||||
|
this.items = [
|
||||||
|
["tools-toggle", "tools-panel"],
|
||||||
|
["settings-toggle", "inspector"],
|
||||||
|
].map(([button, panel]) => ({
|
||||||
|
button: document.getElementById(button),
|
||||||
|
panel: document.getElementById(panel),
|
||||||
|
}));
|
||||||
|
for (const item of this.items) {
|
||||||
|
item.button.addEventListener("pointerenter", (e) => {
|
||||||
|
if (["pen", "mouse"].includes(e.pointerType) && e.buttons === 0)
|
||||||
|
this.open(item);
|
||||||
|
});
|
||||||
|
item.button.addEventListener("click", () => {
|
||||||
|
if (this.current === item && this.pinned) this.close();
|
||||||
|
else this.open(item, true);
|
||||||
|
});
|
||||||
|
item.button.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.open(item, true, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const el of [item.button, item.panel]) {
|
||||||
|
el.addEventListener("pointerenter", () => clearTimeout(this.timer));
|
||||||
|
el.addEventListener("pointerleave", () => {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
if (!this.pinned) this.close(false);
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
item.panel.addEventListener("pointerdown", () => {
|
||||||
|
this.pinned = true;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
});
|
||||||
|
item.panel.addEventListener("focusin", () => {
|
||||||
|
this.pinned = true;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
});
|
||||||
|
item.panel.querySelector("[data-close]").onclick = () => this.close();
|
||||||
|
}
|
||||||
|
document.addEventListener(
|
||||||
|
"pointerdown",
|
||||||
|
(e) => {
|
||||||
|
if (
|
||||||
|
this.current &&
|
||||||
|
!ignoreOutsidePointer(e) &&
|
||||||
|
!this.current.panel.contains(e.target) &&
|
||||||
|
!this.current.button.contains(e.target)
|
||||||
|
)
|
||||||
|
this.close(false);
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
document.addEventListener(
|
||||||
|
"keydown",
|
||||||
|
(e) => {
|
||||||
|
if (
|
||||||
|
e.key === "Escape" &&
|
||||||
|
this.current &&
|
||||||
|
!document.querySelector("dialog[open]")
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
window.addEventListener("resize", () => this.close(false));
|
||||||
|
}
|
||||||
|
open(item, pin = false, focus = false) {
|
||||||
|
if (!this.canOpen() || document.querySelector("dialog[open]")) return;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
if (this.current !== item) {
|
||||||
|
this.close(false);
|
||||||
|
this.current = item;
|
||||||
|
this.pinned = false;
|
||||||
|
}
|
||||||
|
this.pinned ||= pin;
|
||||||
|
item.panel.hidden = false;
|
||||||
|
const top = Math.max(
|
||||||
|
12,
|
||||||
|
Math.min(
|
||||||
|
item.button.getBoundingClientRect().top,
|
||||||
|
window.innerHeight - item.panel.offsetHeight - 12,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
item.panel.style.top = `${top}px`;
|
||||||
|
item.panel.style.maxHeight = `calc(100dvh - ${top + 12}px)`;
|
||||||
|
item.button.setAttribute("aria-expanded", "true");
|
||||||
|
if (focus)
|
||||||
|
item.panel.querySelector("button:not(:disabled),input,select")?.focus();
|
||||||
|
}
|
||||||
|
close(restore = true) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
if (!this.current) return;
|
||||||
|
const item = this.current;
|
||||||
|
this.current = null;
|
||||||
|
this.pinned = false;
|
||||||
|
item.panel.hidden = true;
|
||||||
|
item.button.setAttribute("aria-expanded", "false");
|
||||||
|
if (restore) item.button.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { unit } from "./math.js";
|
||||||
|
|
||||||
|
export function sphere(level = 5) {
|
||||||
|
if (!Number.isInteger(level) || level < 4 || level > 6)
|
||||||
|
throw new Error("Choose a supported resolution.");
|
||||||
|
const t = (1 + Math.sqrt(5)) / 2;
|
||||||
|
const vertices = [
|
||||||
|
[-1, t, 0],
|
||||||
|
[1, t, 0],
|
||||||
|
[-1, -t, 0],
|
||||||
|
[1, -t, 0],
|
||||||
|
[0, -1, t],
|
||||||
|
[0, 1, t],
|
||||||
|
[0, -1, -t],
|
||||||
|
[0, 1, -t],
|
||||||
|
[t, 0, -1],
|
||||||
|
[t, 0, 1],
|
||||||
|
[-t, 0, -1],
|
||||||
|
[-t, 0, 1],
|
||||||
|
].map(unit);
|
||||||
|
let faces = [
|
||||||
|
[0, 11, 5],
|
||||||
|
[0, 5, 1],
|
||||||
|
[0, 1, 7],
|
||||||
|
[0, 7, 10],
|
||||||
|
[0, 10, 11],
|
||||||
|
[1, 5, 9],
|
||||||
|
[5, 11, 4],
|
||||||
|
[11, 10, 2],
|
||||||
|
[10, 7, 6],
|
||||||
|
[7, 1, 8],
|
||||||
|
[3, 9, 4],
|
||||||
|
[3, 4, 2],
|
||||||
|
[3, 2, 6],
|
||||||
|
[3, 6, 8],
|
||||||
|
[3, 8, 9],
|
||||||
|
[4, 9, 5],
|
||||||
|
[2, 4, 11],
|
||||||
|
[6, 2, 10],
|
||||||
|
[8, 6, 7],
|
||||||
|
[9, 8, 1],
|
||||||
|
];
|
||||||
|
for (let l = 0; l < level; l++) {
|
||||||
|
const cache = new Map();
|
||||||
|
const midpoint = (a, b) => {
|
||||||
|
const key = Math.min(a, b) * 100000 + Math.max(a, b);
|
||||||
|
if (!cache.has(key)) {
|
||||||
|
cache.set(key, vertices.length);
|
||||||
|
vertices.push(unit(vertices[a].map((v, i) => v + vertices[b][i])));
|
||||||
|
}
|
||||||
|
return cache.get(key);
|
||||||
|
};
|
||||||
|
faces = faces.flatMap(([a, b, c]) => {
|
||||||
|
const ab = midpoint(a, b),
|
||||||
|
bc = midpoint(b, c),
|
||||||
|
ca = midpoint(c, a);
|
||||||
|
return [
|
||||||
|
[a, ab, ca],
|
||||||
|
[b, bc, ab],
|
||||||
|
[c, ca, bc],
|
||||||
|
[ab, bc, ca],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const positions = new Float32Array(vertices.flat());
|
||||||
|
const indices = new Uint32Array(faces.flat());
|
||||||
|
const incident = Array.from({ length: vertices.length }, () => []);
|
||||||
|
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.
|
||||||
|
export function updateNormals(mesh, moved) {
|
||||||
|
const { positions: p, indices: idx, normals: n, faceNormals: fn } = mesh;
|
||||||
|
const faces = [],
|
||||||
|
vertices = [];
|
||||||
|
const gen = ++mesh.generation;
|
||||||
|
if (moved) {
|
||||||
|
for (const v of moved)
|
||||||
|
for (const f of mesh.incident[v]) {
|
||||||
|
if (mesh.faceMarks[f] !== gen) {
|
||||||
|
mesh.faceMarks[f] = gen;
|
||||||
|
faces.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else for (let f = 0; f < idx.length / 3; f++) faces.push(f);
|
||||||
|
for (const f of faces) {
|
||||||
|
const a = idx[f * 3],
|
||||||
|
b = idx[f * 3 + 1],
|
||||||
|
c = idx[f * 3 + 2];
|
||||||
|
const ax = p[b * 3] - p[a * 3],
|
||||||
|
ay = p[b * 3 + 1] - p[a * 3 + 1],
|
||||||
|
az = p[b * 3 + 2] - p[a * 3 + 2];
|
||||||
|
const bx = p[c * 3] - p[a * 3],
|
||||||
|
by = p[c * 3 + 1] - p[a * 3 + 1],
|
||||||
|
bz = p[c * 3 + 2] - p[a * 3 + 2];
|
||||||
|
fn[f * 3] = ay * bz - az * by;
|
||||||
|
fn[f * 3 + 1] = az * bx - ax * bz;
|
||||||
|
fn[f * 3 + 2] = ax * by - ay * bx;
|
||||||
|
for (let j = 0; j < 3; j++) {
|
||||||
|
const v = idx[f * 3 + j];
|
||||||
|
if (mesh.vertexMarks[v] !== gen) {
|
||||||
|
mesh.vertexMarks[v] = gen;
|
||||||
|
vertices.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const v of vertices) {
|
||||||
|
let x = 0,
|
||||||
|
y = 0,
|
||||||
|
z = 0;
|
||||||
|
for (const f of mesh.incident[v]) {
|
||||||
|
x += fn[f * 3];
|
||||||
|
y += fn[f * 3 + 1];
|
||||||
|
z += fn[f * 3 + 2];
|
||||||
|
}
|
||||||
|
const length = Math.hypot(x, y, z) || 1;
|
||||||
|
n[v * 3] = x / length;
|
||||||
|
n[v * 3 + 1] = y / length;
|
||||||
|
n[v * 3 + 2] = z / length;
|
||||||
|
}
|
||||||
|
return { faces, vertices };
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { cameraBasis, clamp, clampZoom, dot } from "./math.js";
|
||||||
|
|
||||||
|
export const axes = [
|
||||||
|
{
|
||||||
|
name: "X",
|
||||||
|
vector: [1, 0, 0],
|
||||||
|
yaw: Math.PI / 2,
|
||||||
|
pitch: 0,
|
||||||
|
color: "#ed5265",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Y",
|
||||||
|
vector: [0, 1, 0],
|
||||||
|
yaw: 0,
|
||||||
|
pitch: Math.PI / 2,
|
||||||
|
color: "#91be38",
|
||||||
|
},
|
||||||
|
{ name: "Z", vector: [0, 0, 1], yaw: 0, pitch: 0, color: "#438cdd" },
|
||||||
|
{
|
||||||
|
name: "−X",
|
||||||
|
vector: [-1, 0, 0],
|
||||||
|
yaw: -Math.PI / 2,
|
||||||
|
pitch: 0,
|
||||||
|
color: "#ed5265",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "−Y",
|
||||||
|
vector: [0, -1, 0],
|
||||||
|
yaw: 0,
|
||||||
|
pitch: -Math.PI / 2,
|
||||||
|
color: "#91be38",
|
||||||
|
},
|
||||||
|
{ name: "−Z", vector: [0, 0, -1], yaw: Math.PI, pitch: 0, color: "#438cdd" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function dragView(camera, start, action, dx, dy, rect) {
|
||||||
|
if (action === "orbit") {
|
||||||
|
camera.yaw = start.yaw - dx * 0.008;
|
||||||
|
camera.pitch = clamp(start.pitch + dy * 0.008, -Math.PI / 2, Math.PI / 2);
|
||||||
|
} else if (action === "pan") {
|
||||||
|
const { right, up } = cameraBasis(start.yaw, start.pitch);
|
||||||
|
const scale = (2 * start.zoom) / rect.height;
|
||||||
|
camera.target = start.target.map(
|
||||||
|
(v, k) => v + (-dx * right[k] + dy * up[k]) * scale,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
camera.zoom = clampZoom(start.zoom * Math.exp(dy * 0.008), rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM-only navigation; the camera remains owned by the app, never by the gizmo.
|
||||||
|
export class Navigation {
|
||||||
|
constructor(root, viewport, host) {
|
||||||
|
this.root = root;
|
||||||
|
this.viewport = viewport;
|
||||||
|
this.host = host;
|
||||||
|
this.gizmo = root.querySelector("#orbit-gizmo");
|
||||||
|
this.lines = root.querySelectorAll(".axis-line");
|
||||||
|
this.buttons = axes.map((axis, i) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.className = "axis";
|
||||||
|
button.type = "button";
|
||||||
|
button.dataset.axis = i;
|
||||||
|
button.setAttribute("aria-label", `View from ${axis.name}`);
|
||||||
|
button.title = `View from ${axis.name}`;
|
||||||
|
button.textContent = i < 3 ? axis.name : "";
|
||||||
|
button.style.setProperty("--axis-color", axis.color);
|
||||||
|
button.addEventListener("click", (e) => {
|
||||||
|
if (e.detail === 0 && !this.busy && host.ready()) this.snap(i);
|
||||||
|
});
|
||||||
|
this.gizmo.append(button);
|
||||||
|
return button;
|
||||||
|
});
|
||||||
|
root.addEventListener("pointerdown", (e) => this.down(e));
|
||||||
|
root.addEventListener("pointermove", (e) => this.move(e));
|
||||||
|
root.addEventListener("pointerup", (e) => this.up(e));
|
||||||
|
for (const event of ["pointercancel", "lostpointercapture"])
|
||||||
|
root.addEventListener(event, (e) => {
|
||||||
|
if (this.active?.id === e.pointerId) this.cancel();
|
||||||
|
});
|
||||||
|
root.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||||
|
root.addEventListener("keydown", (e) => this.key(e));
|
||||||
|
window.addEventListener("blur", () => this.cancel());
|
||||||
|
window.addEventListener("resize", () => this.checkViewport());
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.hidden) this.cancel();
|
||||||
|
});
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
get busy() {
|
||||||
|
return !!this.active;
|
||||||
|
}
|
||||||
|
down(e) {
|
||||||
|
const control = e.target.closest("[data-nav]");
|
||||||
|
if (!control || this.busy || !this.host.ready() || e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
this.host.start();
|
||||||
|
const c = this.host.camera;
|
||||||
|
this.active = {
|
||||||
|
id: e.pointerId,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
action: control.dataset.nav,
|
||||||
|
axis: e.target.closest("[data-axis]")?.dataset.axis,
|
||||||
|
moved: false,
|
||||||
|
rect: this.viewport.getBoundingClientRect(),
|
||||||
|
start: {
|
||||||
|
yaw: c.yaw,
|
||||||
|
pitch: c.pitch,
|
||||||
|
zoom: c.zoom,
|
||||||
|
target: c.target.slice(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.root.setPointerCapture(e.pointerId);
|
||||||
|
control.focus({ preventScroll: true });
|
||||||
|
this.host.changed();
|
||||||
|
}
|
||||||
|
checkViewport() {
|
||||||
|
if (!this.active) return true;
|
||||||
|
const rect = this.viewport.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
["left", "top", "width", "height"].some(
|
||||||
|
(k) => rect[k] !== this.active.rect[k],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.cancel();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
move(e) {
|
||||||
|
if (this.active?.id !== e.pointerId || !this.checkViewport()) return;
|
||||||
|
const a = this.active,
|
||||||
|
dx = e.clientX - a.x,
|
||||||
|
dy = e.clientY - a.y;
|
||||||
|
a.moved ||= Math.hypot(dx, dy) >= 4;
|
||||||
|
if (a.moved) {
|
||||||
|
dragView(this.host.camera, a.start, a.action, dx, dy, a.rect);
|
||||||
|
this.host.invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
up(e) {
|
||||||
|
if (this.active?.id !== e.pointerId) return;
|
||||||
|
this.move(e);
|
||||||
|
const a = this.active;
|
||||||
|
if (!a) return;
|
||||||
|
this.active = null;
|
||||||
|
if (this.root.hasPointerCapture(a.id))
|
||||||
|
this.root.releasePointerCapture(a.id);
|
||||||
|
if (!a.moved && a.axis !== undefined) this.snap(Number(a.axis));
|
||||||
|
this.host.changed();
|
||||||
|
}
|
||||||
|
cancel() {
|
||||||
|
const a = this.active;
|
||||||
|
if (!a) return;
|
||||||
|
this.active = null;
|
||||||
|
Object.assign(this.host.camera, a.start);
|
||||||
|
if (this.root.hasPointerCapture(a.id))
|
||||||
|
this.root.releasePointerCapture(a.id);
|
||||||
|
this.host.invalidate();
|
||||||
|
this.host.changed();
|
||||||
|
}
|
||||||
|
snap(i) {
|
||||||
|
// Clicking the already-facing endpoint flips to the opposite view, making
|
||||||
|
// the occluded endpoint reachable when two axes project to the same point.
|
||||||
|
if (
|
||||||
|
dot(
|
||||||
|
cameraBasis(this.host.camera.yaw, this.host.camera.pitch).forward,
|
||||||
|
axes[i].vector,
|
||||||
|
) > 0.999999
|
||||||
|
)
|
||||||
|
i = (i + 3) % 6;
|
||||||
|
const { yaw, pitch } = axes[i];
|
||||||
|
Object.assign(this.host.camera, { yaw, pitch });
|
||||||
|
this.host.invalidate();
|
||||||
|
}
|
||||||
|
key(e) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
if (!this.busy) return;
|
||||||
|
this.cancel();
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action = e.target.closest("[data-nav]")?.dataset.nav;
|
||||||
|
if (
|
||||||
|
!action ||
|
||||||
|
![
|
||||||
|
"ArrowLeft",
|
||||||
|
"ArrowRight",
|
||||||
|
"ArrowUp",
|
||||||
|
"ArrowDown",
|
||||||
|
"+",
|
||||||
|
"=",
|
||||||
|
"-",
|
||||||
|
].includes(e.key)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (this.busy || !this.host.ready()) return;
|
||||||
|
const dx = e.key === "ArrowLeft" ? -12 : e.key === "ArrowRight" ? 12 : 0;
|
||||||
|
const dy = ["ArrowUp", "+", "="].includes(e.key)
|
||||||
|
? -12
|
||||||
|
: ["ArrowDown", "-"].includes(e.key)
|
||||||
|
? 12
|
||||||
|
: 0;
|
||||||
|
const c = this.host.camera;
|
||||||
|
dragView(
|
||||||
|
c,
|
||||||
|
{ ...c, target: c.target.slice() },
|
||||||
|
action,
|
||||||
|
dx,
|
||||||
|
dy,
|
||||||
|
this.viewport.getBoundingClientRect(),
|
||||||
|
);
|
||||||
|
this.host.invalidate();
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
const basis = cameraBasis(this.host.camera.yaw, this.host.camera.pitch);
|
||||||
|
axes.forEach((axis, i) => {
|
||||||
|
const x = 52 + 34 * dot(axis.vector, basis.right),
|
||||||
|
y = 52 - 34 * dot(axis.vector, basis.up),
|
||||||
|
depth = dot(axis.vector, basis.forward);
|
||||||
|
const b = this.buttons[i];
|
||||||
|
b.style.left = `${x}px`;
|
||||||
|
b.style.top = `${y}px`;
|
||||||
|
b.style.zIndex = String(10 + Math.round(depth * 5));
|
||||||
|
b.classList.toggle("rear", i >= 3);
|
||||||
|
if (i < 3) {
|
||||||
|
this.lines[i].setAttribute("x2", x);
|
||||||
|
this.lines[i].setAttribute("y2", y);
|
||||||
|
this.lines[i].style.stroke = axis.color;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Navigation, axes, dragView } from "./navigation.js";
|
||||||
|
import { cameraBasis, dot } from "./math.js";
|
||||||
|
const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-10, `${a} != ${b}`);
|
||||||
|
test("all six cardinal cameras and pole departures have orthonormal finite bases", () => {
|
||||||
|
for (const a of axes) {
|
||||||
|
const b = cameraBasis(a.yaw, a.pitch);
|
||||||
|
a.vector.forEach((v, k) => near(b.forward[k], v));
|
||||||
|
near(dot(b.right, b.right), 1);
|
||||||
|
near(dot(b.up, b.up), 1);
|
||||||
|
near(dot(b.right, b.up), 0);
|
||||||
|
const c = {};
|
||||||
|
dragView(c, { yaw: a.yaw, pitch: a.pitch }, "orbit", 7, -3, {
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
});
|
||||||
|
assert.ok(cameraBasis(c.yaw, c.pitch).up.every(Number.isFinite));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("drag uses snapshot totals, asymmetric pan, bounded zoom and no target drift", () => {
|
||||||
|
const start = { yaw: 0, pitch: 0, zoom: 2, target: [3, 4, 5] },
|
||||||
|
c = structuredClone(start),
|
||||||
|
rect = { width: 800, height: 400 };
|
||||||
|
dragView(c, start, "pan", 30, -10, rect);
|
||||||
|
assert.deepEqual(c.target, [2.7, 3.9, 5]);
|
||||||
|
dragView(c, start, "pan", 60, 20, rect);
|
||||||
|
assert.deepEqual(c.target, [2.4, 4.2, 5]);
|
||||||
|
dragView(c, start, "orbit", 25, -10, rect);
|
||||||
|
near(c.yaw, -0.2);
|
||||||
|
near(c.pitch, -0.08);
|
||||||
|
assert.deepEqual(c.target, [2.4, 4.2, 5]);
|
||||||
|
dragView(c, start, "zoom", 0, 50, rect);
|
||||||
|
near(c.zoom, 2 * Math.exp(0.4));
|
||||||
|
dragView(c, start, "zoom", 0, 1e5, rect);
|
||||||
|
near(c.zoom, 14);
|
||||||
|
});
|
||||||
|
function fixture() {
|
||||||
|
const n = Object.create(Navigation.prototype),
|
||||||
|
captures = new Set();
|
||||||
|
let ready = true;
|
||||||
|
const rect = { left: 0, top: 0, width: 800, height: 600 },
|
||||||
|
camera = { yaw: 0.3, pitch: 0.2, zoom: 2, target: [1, 2, 3] };
|
||||||
|
n.host = {
|
||||||
|
camera,
|
||||||
|
ready: () => ready,
|
||||||
|
start() {},
|
||||||
|
changed() {},
|
||||||
|
invalidate() {},
|
||||||
|
};
|
||||||
|
n.viewport = { getBoundingClientRect: () => ({ ...rect }) };
|
||||||
|
n.root = {
|
||||||
|
setPointerCapture: (id) => captures.add(id),
|
||||||
|
hasPointerCapture: (id) => captures.has(id),
|
||||||
|
releasePointerCapture: (id) => {
|
||||||
|
captures.delete(id);
|
||||||
|
if (n.active?.id === id) n.cancel();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const event = (x = 100, y = 100, id = 1, axis) => ({
|
||||||
|
pointerId: id,
|
||||||
|
clientX: x,
|
||||||
|
clientY: y,
|
||||||
|
button: 0,
|
||||||
|
preventDefault() {},
|
||||||
|
target: {
|
||||||
|
closest: (s) =>
|
||||||
|
s === "[data-nav]"
|
||||||
|
? { dataset: { nav: "orbit" }, focus() {} }
|
||||||
|
: axis === undefined
|
||||||
|
? null
|
||||||
|
: { dataset: { axis } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { n, camera, rect, captures, event, ready: (v) => (ready = v) };
|
||||||
|
}
|
||||||
|
test("navigation admission, single pointer, release coordinates, cancel and viewport rollback", () => {
|
||||||
|
const { n, camera, rect, captures, event, ready } = fixture(),
|
||||||
|
before = structuredClone(camera);
|
||||||
|
ready(false);
|
||||||
|
n.down(event());
|
||||||
|
assert.equal(n.busy, false);
|
||||||
|
ready(true);
|
||||||
|
n.down(event());
|
||||||
|
n.move(event(120, 110, 2));
|
||||||
|
assert.deepEqual(camera, before);
|
||||||
|
n.up(event(125, 115));
|
||||||
|
near(camera.yaw, 0.1);
|
||||||
|
near(camera.pitch, 0.32);
|
||||||
|
assert.equal(n.busy, false);
|
||||||
|
assert.equal(captures.size, 0);
|
||||||
|
const committed = structuredClone(camera);
|
||||||
|
n.down(event());
|
||||||
|
n.move(event(180, 140));
|
||||||
|
n.cancel();
|
||||||
|
assert.deepEqual(camera, committed);
|
||||||
|
n.down(event());
|
||||||
|
n.move(event(200, 160));
|
||||||
|
rect.width = 700;
|
||||||
|
n.up(event(210, 170));
|
||||||
|
assert.deepEqual(camera, committed);
|
||||||
|
assert.equal(n.busy, false);
|
||||||
|
});
|
||||||
|
test("Escape is consumed only by an active navigation gesture", () => {
|
||||||
|
const { n, camera, captures, event, ready } = fixture();
|
||||||
|
const before = structuredClone(camera);
|
||||||
|
let prevented = 0,
|
||||||
|
stopped = 0;
|
||||||
|
const escape = {
|
||||||
|
key: "Escape",
|
||||||
|
preventDefault: () => prevented++,
|
||||||
|
stopPropagation: () => stopped++,
|
||||||
|
};
|
||||||
|
n.key(escape);
|
||||||
|
ready(false);
|
||||||
|
n.key(escape);
|
||||||
|
assert.equal(prevented, 0);
|
||||||
|
assert.equal(stopped, 0);
|
||||||
|
assert.deepEqual(camera, before);
|
||||||
|
ready(true);
|
||||||
|
n.down(event());
|
||||||
|
n.move(event(180, 140));
|
||||||
|
n.key(escape);
|
||||||
|
n.up(event(200, 160));
|
||||||
|
assert.deepEqual(camera, before);
|
||||||
|
assert.equal(n.busy, false);
|
||||||
|
assert.equal(captures.size, 0);
|
||||||
|
assert.equal(prevented, 1);
|
||||||
|
assert.equal(stopped, 1);
|
||||||
|
});
|
||||||
|
test("axis clicks snap/flip without changing zoom or target; drags never snap", () => {
|
||||||
|
const { n, camera, event } = fixture();
|
||||||
|
n.down(event(100, 100, 1, "0"));
|
||||||
|
n.up(event(101, 101));
|
||||||
|
near(camera.yaw, Math.PI / 2);
|
||||||
|
near(camera.pitch, 0);
|
||||||
|
n.down(event(100, 100, 1, "0"));
|
||||||
|
n.up(event(100, 100));
|
||||||
|
near(camera.yaw, -Math.PI / 2);
|
||||||
|
assert.deepEqual(camera.target, [1, 2, 3]);
|
||||||
|
near(camera.zoom, 2);
|
||||||
|
n.down(event(100, 100, 1, "1"));
|
||||||
|
n.up(event(140, 115));
|
||||||
|
near(camera.yaw, -Math.PI / 2 - 0.32);
|
||||||
|
near(camera.pitch, 0.12);
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { YawnCore } from "@yawn/core";
|
||||||
|
import { cameraBasis } from "./math.js";
|
||||||
|
|
||||||
|
function wire(v) {
|
||||||
|
if (Array.isArray(v)) return `(array ${v.map(wire).join(" ")})`;
|
||||||
|
if (v && typeof v === "object")
|
||||||
|
return `(object ${Object.entries(v)
|
||||||
|
.map(([k, x]) => `(field ${JSON.stringify(k)} ${wire(x)})`)
|
||||||
|
.join(" ")})`;
|
||||||
|
return JSON.stringify(v);
|
||||||
|
}
|
||||||
|
const shader = `
|
||||||
|
struct Camera { right:vec4f, up:vec4f, forward:vec4f, center:vec4f, color:vec4f }
|
||||||
|
@group(0) @binding(0) var<uniform> camera:Camera;
|
||||||
|
struct Out { @builtin(position) position:vec4f, @location(0) normal:vec3f }
|
||||||
|
@vertex fn vertex(@location(0) p:vec3f, @location(1) n:vec3f)->Out {
|
||||||
|
let d=p-camera.center.xyz;
|
||||||
|
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.normal=vec3f(dot(n,camera.right.xyz),dot(n,camera.up.xyz),dot(n,camera.forward.xyz));return o;
|
||||||
|
}
|
||||||
|
@fragment fn fragment(o:Out)->@location(0) vec4f {
|
||||||
|
let n=normalize(o.normal);
|
||||||
|
let key=max(dot(n,normalize(vec3f(-0.5,0.8,1.0))),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 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;
|
||||||
|
return vec4f(pow(color,vec3f(1.0/2.2)),1.0);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
export class Renderer {
|
||||||
|
static async create(canvas, mesh, camera, quality = "balanced") {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const pixels =
|
||||||
|
quality === "economy" ? 600000 : quality === "detail" ? 2000000 : 1000000;
|
||||||
|
const scale = Math.min(
|
||||||
|
devicePixelRatio,
|
||||||
|
quality === "economy" ? 1 : quality === "detail" ? 2 : 1.5,
|
||||||
|
Math.sqrt(pixels / (rect.width * rect.height)),
|
||||||
|
);
|
||||||
|
canvas.width = Math.max(1, Math.round(rect.width * scale));
|
||||||
|
canvas.height = Math.max(1, Math.round(rect.height * scale));
|
||||||
|
const r = new Renderer();
|
||||||
|
r.canvas = canvas;
|
||||||
|
r.mesh = mesh;
|
||||||
|
const bytes =
|
||||||
|
(mesh.positions.length / 3) * 32 +
|
||||||
|
Math.ceil(mesh.indices.length / 4) * 16 +
|
||||||
|
80 +
|
||||||
|
64 +
|
||||||
|
256;
|
||||||
|
if (bytes > 8 * 1024 * 1024)
|
||||||
|
throw new Error("Mesh exceeds the render arena");
|
||||||
|
r.core = new YawnCore(canvas, { arenaBytes: 8 * 1024 * 1024 });
|
||||||
|
let timedOut = false;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
r.dispose();
|
||||||
|
}, 20000);
|
||||||
|
try {
|
||||||
|
await r.core.ready;
|
||||||
|
[r.vertices, r.indices, r.camera, r.sample] =
|
||||||
|
await r.core.createRowsBatch([
|
||||||
|
{
|
||||||
|
name: "sculptVertices",
|
||||||
|
rows: mesh.positions.length / 3,
|
||||||
|
stride: 32,
|
||||||
|
format: "f32",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sculptIndices",
|
||||||
|
rows: Math.ceil(mesh.indices.length / 4),
|
||||||
|
stride: 16,
|
||||||
|
format: "u32",
|
||||||
|
},
|
||||||
|
{ name: "sculptCamera", rows: 1, stride: 80, format: "f32" },
|
||||||
|
{ name: "sculptSample", rows: 1, stride: 64, format: "i32" },
|
||||||
|
]);
|
||||||
|
r.sample.view.fill(0);
|
||||||
|
r.indices.view.set(mesh.indices);
|
||||||
|
r.writeVertices(0, mesh.positions.length / 3);
|
||||||
|
r.writeCamera(camera);
|
||||||
|
const graph = {
|
||||||
|
id: "sculpt",
|
||||||
|
resources: {
|
||||||
|
buffers: [
|
||||||
|
{
|
||||||
|
id: "vertices",
|
||||||
|
array: "sculptVertices",
|
||||||
|
usage: ["vertex"],
|
||||||
|
sync: "manual",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "indices",
|
||||||
|
array: "sculptIndices",
|
||||||
|
usage: ["index"],
|
||||||
|
sync: "manual",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "camera",
|
||||||
|
array: "sculptCamera",
|
||||||
|
usage: ["uniform"],
|
||||||
|
sync: "manual",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
textures: [
|
||||||
|
{
|
||||||
|
id: "depth",
|
||||||
|
format: "depth24plus",
|
||||||
|
size: ["canvas", "canvas"],
|
||||||
|
usage: ["render"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
pipelines: {
|
||||||
|
render: [
|
||||||
|
{
|
||||||
|
id: "clay",
|
||||||
|
code: shader,
|
||||||
|
vertex: {
|
||||||
|
buffers: [
|
||||||
|
{
|
||||||
|
arrayStride: 32,
|
||||||
|
attributes: [
|
||||||
|
{ format: "float32x3", offset: 0, shaderLocation: 0 },
|
||||||
|
{ format: "float32x3", offset: 16, shaderLocation: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fragment: { targets: [{ format: "canvas" }] },
|
||||||
|
primitive: {
|
||||||
|
topology: "triangle-list",
|
||||||
|
frontFace: "ccw",
|
||||||
|
cullMode: "back",
|
||||||
|
conservative: false,
|
||||||
|
},
|
||||||
|
depthStencil: {
|
||||||
|
format: "depth24plus",
|
||||||
|
depth_write_enabled: true,
|
||||||
|
depth_compare: "less",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
passes: [
|
||||||
|
{
|
||||||
|
id: "sculpt",
|
||||||
|
type: "render",
|
||||||
|
pipeline: "clay",
|
||||||
|
bindings: [{ binding: 0, resource: "camera" }],
|
||||||
|
color: [{ resource: "canvas", clear: [0.12, 0.14, 0.17, 1] }],
|
||||||
|
depth: { resource: "depth" },
|
||||||
|
vertexBuffers: [{ resource: "vertices" }],
|
||||||
|
indexBuffer: { resource: "indices" },
|
||||||
|
draw: { indices: mesh.indices.length, instances: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await r.core.switchLoadout(
|
||||||
|
await r.core.compileGraph(`(yawn-graph 1 ${wire(graph)})`),
|
||||||
|
);
|
||||||
|
await r.core.setFps(quality === "economy" ? 30 : 60);
|
||||||
|
await r.core.play();
|
||||||
|
return r;
|
||||||
|
} catch (error) {
|
||||||
|
r.dispose();
|
||||||
|
if (timedOut) throw new Error("Renderer initialization timed out");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeVertices(first, count) {
|
||||||
|
const p = this.mesh.positions,
|
||||||
|
n = this.mesh.normals,
|
||||||
|
v = this.vertices.view;
|
||||||
|
for (let i = first; i < first + count; i++) {
|
||||||
|
const a = i * 3,
|
||||||
|
b = i * 8;
|
||||||
|
v[b] = p[a];
|
||||||
|
v[b + 1] = p[a + 1];
|
||||||
|
v[b + 2] = p[a + 2];
|
||||||
|
v[b + 4] = n[a];
|
||||||
|
v[b + 5] = n[a + 1];
|
||||||
|
v[b + 6] = n[a + 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeCamera(camera) {
|
||||||
|
const { right, up, forward } = cameraBasis(camera.yaw, camera.pitch);
|
||||||
|
const aspect = this.canvas.width / this.canvas.height;
|
||||||
|
this.camera.view.set([
|
||||||
|
...right,
|
||||||
|
camera.zoom * aspect,
|
||||||
|
...up,
|
||||||
|
camera.zoom,
|
||||||
|
...forward,
|
||||||
|
0,
|
||||||
|
...camera.target,
|
||||||
|
0,
|
||||||
|
...camera.color,
|
||||||
|
0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// One acknowledged publication at a time: shared staging bytes are immutable in flight.
|
||||||
|
async publish(engine, camera) {
|
||||||
|
if (this.busy || this.disposed) return false;
|
||||||
|
this.busy = true;
|
||||||
|
const timeout = setTimeout(() => this.dispose(), 10000);
|
||||||
|
try {
|
||||||
|
let first = Infinity,
|
||||||
|
last = -1;
|
||||||
|
for (const v of engine.dirty) {
|
||||||
|
first = Math.min(first, v);
|
||||||
|
last = Math.max(last, v);
|
||||||
|
}
|
||||||
|
engine.dirty.clear();
|
||||||
|
if (last >= first) {
|
||||||
|
this.writeVertices(first, last - first + 1);
|
||||||
|
await this.core.syncRows("sculptVertices", first, last - first + 1);
|
||||||
|
}
|
||||||
|
if (this.disposed) return false;
|
||||||
|
this.writeCamera(camera);
|
||||||
|
await this.core.syncRows("sculptCamera", 0, 1);
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dispose() {
|
||||||
|
this.disposed = true;
|
||||||
|
this.processor?.dispose();
|
||||||
|
this.core?.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { Mailbox } from "./mailbox.js";
|
||||||
|
import { pressureValue } from "./engine.js";
|
||||||
|
|
||||||
|
export class StrokeClient {
|
||||||
|
constructor(row, level, onError) {
|
||||||
|
this.mailbox = new Mailbox(row.share());
|
||||||
|
this.worker = new Worker(new URL("./stroke-worker.js", import.meta.url), {
|
||||||
|
type: "module",
|
||||||
|
name: "sculpt-samples",
|
||||||
|
});
|
||||||
|
this.nextId = 0;
|
||||||
|
this.onError = onError;
|
||||||
|
this.ready = new Promise((resolve, reject) => {
|
||||||
|
this.resolveReady = resolve;
|
||||||
|
this.rejectReady = reject;
|
||||||
|
});
|
||||||
|
this.timer = setTimeout(
|
||||||
|
() => this.fail(new Error("Sculpt worker initialization timed out")),
|
||||||
|
20000,
|
||||||
|
);
|
||||||
|
this.worker.onmessage = ({ data }) => this.message(data);
|
||||||
|
this.worker.onerror = () => this.fail(new Error("Sculpt worker stopped"));
|
||||||
|
this.worker.onmessageerror = () =>
|
||||||
|
this.fail(new Error("Sculpt worker message failed"));
|
||||||
|
this.worker.postMessage({ type: "init", row: row.share(), level });
|
||||||
|
}
|
||||||
|
begin(data, apply) {
|
||||||
|
if (this.job || this.disposed)
|
||||||
|
throw new Error("Sculpt worker is unavailable");
|
||||||
|
const id = ++this.nextId;
|
||||||
|
const promise = new Promise((resolve, reject) => {
|
||||||
|
this.job = { id, settings: data.settings, apply, resolve, reject };
|
||||||
|
});
|
||||||
|
this.write(data.sample);
|
||||||
|
this.worker.postMessage(
|
||||||
|
{ type: "begin", ...data, id, version: this.version },
|
||||||
|
[data.positions.buffer],
|
||||||
|
);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
write(sample) {
|
||||||
|
const j = this.job;
|
||||||
|
if (!j || j.canceling) return;
|
||||||
|
if (j.settings.brush === "grab") {
|
||||||
|
if (
|
||||||
|
pressureValue(sample.pressure, j.settings.gain, j.settings.gamma) ===
|
||||||
|
0 &&
|
||||||
|
this.version !== undefined &&
|
||||||
|
j.hasSample
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (
|
||||||
|
pressureValue(sample.pressure, j.settings.gain, j.settings.gamma) > 0 &&
|
||||||
|
j.seed === undefined
|
||||||
|
)
|
||||||
|
j.seed = sample.pressure;
|
||||||
|
}
|
||||||
|
this.version = this.mailbox.write(j.id, sample, j.seed);
|
||||||
|
j.hasSample = true;
|
||||||
|
}
|
||||||
|
end() {
|
||||||
|
if (!this.job || this.job.canceling) return;
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "end",
|
||||||
|
id: this.job.id,
|
||||||
|
version: this.version,
|
||||||
|
});
|
||||||
|
this.armTimeout();
|
||||||
|
}
|
||||||
|
cancel() {
|
||||||
|
if (!this.job) return;
|
||||||
|
this.job.canceling = true;
|
||||||
|
this.worker.postMessage({ type: "cancel", id: this.job.id });
|
||||||
|
this.armTimeout();
|
||||||
|
}
|
||||||
|
armTimeout() {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = setTimeout(
|
||||||
|
() => this.fail(new Error("Sculpt worker did not settle")),
|
||||||
|
10000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
message(data) {
|
||||||
|
if (this.disposed) return;
|
||||||
|
if (data.type === "ready") {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.resolveReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type === "error") {
|
||||||
|
this.fail(new Error(data.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const j = this.job;
|
||||||
|
if (data.type === "geometry") {
|
||||||
|
if (j?.id === data.id && !j.canceling) j.apply(data);
|
||||||
|
this.worker.postMessage({ type: "ack", packet: data.packet });
|
||||||
|
} else if (
|
||||||
|
j?.id === data.id &&
|
||||||
|
["complete", "canceled"].includes(data.type)
|
||||||
|
) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.job = null;
|
||||||
|
j.resolve({
|
||||||
|
canceled: j.canceling || data.type === "canceled",
|
||||||
|
message: data.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail(error) {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.rejectReady(error);
|
||||||
|
this.job?.reject(error);
|
||||||
|
this.job = null;
|
||||||
|
this.dispose();
|
||||||
|
this.onError(error);
|
||||||
|
}
|
||||||
|
dispose() {
|
||||||
|
this.disposed = true;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.worker.terminate();
|
||||||
|
this.rejectReady(new Error("Sculpt worker disposed"));
|
||||||
|
this.job?.resolve({ canceled: true });
|
||||||
|
this.job = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { Sculpt, StrokePath, pressureValue } from "./engine.js";
|
||||||
|
import { sphere, updateNormals } from "./mesh.js";
|
||||||
|
import { cameraBasis, add, mul, sub } from "./math.js";
|
||||||
|
import { Mailbox } from "./mailbox.js";
|
||||||
|
|
||||||
|
// Sole owner of brush-time geometry work. Exported for deterministic tests;
|
||||||
|
// the browser entry below only supplies worker rAF and postMessage.
|
||||||
|
export class StrokeProcessor {
|
||||||
|
constructor(mesh, mailbox, send, clock = () => performance.now()) {
|
||||||
|
this.engine = new Sculpt(mesh);
|
||||||
|
this.mailbox = mailbox;
|
||||||
|
this.send = send;
|
||||||
|
this.clock = clock;
|
||||||
|
this.packet = 0;
|
||||||
|
this.outstanding = 0;
|
||||||
|
this.job = null;
|
||||||
|
}
|
||||||
|
begin(data) {
|
||||||
|
if (this.job) throw new Error("Previous stroke has not settled");
|
||||||
|
const e = this.engine;
|
||||||
|
const moved = [];
|
||||||
|
for (let v = 0; v < data.positions.length / 3; v++) {
|
||||||
|
const i = v * 3,
|
||||||
|
p = e.mesh.positions;
|
||||||
|
if (
|
||||||
|
p[i] !== data.positions[i] ||
|
||||||
|
p[i + 1] !== data.positions[i + 1] ||
|
||||||
|
p[i + 2] !== data.positions[i + 2]
|
||||||
|
)
|
||||||
|
moved.push(v);
|
||||||
|
}
|
||||||
|
e.mesh.positions.set(data.positions);
|
||||||
|
if (moved.length) {
|
||||||
|
const { faces } = updateNormals(e.mesh, moved);
|
||||||
|
e.bvh.refit(faces);
|
||||||
|
}
|
||||||
|
e.dirty.clear();
|
||||||
|
e.begin(data.settings);
|
||||||
|
const basis = cameraBasis(data.camera.yaw, data.camera.pitch);
|
||||||
|
this.job = {
|
||||||
|
...data,
|
||||||
|
basis,
|
||||||
|
target: { ...data.sample, version: data.version },
|
||||||
|
admittedAt: this.clock(),
|
||||||
|
ending: false,
|
||||||
|
attempts: 0,
|
||||||
|
};
|
||||||
|
this.path = new StrokePath(
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
((data.settings.radius * data.rect.height) / (2 * data.camera.zoom)) *
|
||||||
|
0.15,
|
||||||
|
),
|
||||||
|
(sample) => {
|
||||||
|
this.job.attempts++;
|
||||||
|
const hit = e.bvh.raycast(
|
||||||
|
add(this.world(sample), mul(basis.forward, 24)),
|
||||||
|
mul(basis.forward, -1),
|
||||||
|
);
|
||||||
|
if (!hit) this.path.break();
|
||||||
|
else e.dab(hit, sample.pressure);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.origin = this.world(data.sample);
|
||||||
|
}
|
||||||
|
world(sample) {
|
||||||
|
const { camera: c, rect: r, basis: b } = this.job;
|
||||||
|
return add(
|
||||||
|
c.target,
|
||||||
|
add(
|
||||||
|
mul(b.right, ((2 * sample.x - r.width) * c.zoom) / r.height),
|
||||||
|
mul(b.up, (1 - (2 * sample.y) / r.height) * c.zoom),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
end(id, version) {
|
||||||
|
if (this.job?.id === id) {
|
||||||
|
this.job.ending = true;
|
||||||
|
this.job.finalVersion = version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel(id, message) {
|
||||||
|
if (this.job?.id === id) {
|
||||||
|
this.job = null;
|
||||||
|
this.engine.stroke = null;
|
||||||
|
this.engine.dirty.clear();
|
||||||
|
this.outstanding = 0;
|
||||||
|
}
|
||||||
|
this.send({ type: "canceled", id, message });
|
||||||
|
}
|
||||||
|
acknowledge(packet) {
|
||||||
|
if (packet === this.outstanding) this.outstanding = 0;
|
||||||
|
}
|
||||||
|
publish() {
|
||||||
|
const e = this.engine;
|
||||||
|
if (!this.job || this.outstanding || !e.dirty.size) return;
|
||||||
|
const rows = Uint32Array.from(e.dirty);
|
||||||
|
const vertices = new Float32Array(rows.length * 8);
|
||||||
|
for (let row = 0; row < rows.length; row++) {
|
||||||
|
const i = rows[row] * 3,
|
||||||
|
j = row * 8,
|
||||||
|
p = e.mesh.positions,
|
||||||
|
n = e.mesh.normals;
|
||||||
|
vertices[j] = p[i];
|
||||||
|
vertices[j + 1] = p[i + 1];
|
||||||
|
vertices[j + 2] = p[i + 2];
|
||||||
|
vertices[j + 4] = n[i];
|
||||||
|
vertices[j + 5] = n[i + 1];
|
||||||
|
vertices[j + 6] = n[i + 2];
|
||||||
|
}
|
||||||
|
const touched = Uint32Array.from(e.stroke.touched);
|
||||||
|
e.dirty.clear();
|
||||||
|
this.outstanding = ++this.packet;
|
||||||
|
this.send(
|
||||||
|
{
|
||||||
|
type: "geometry",
|
||||||
|
id: this.job.id,
|
||||||
|
packet: this.packet,
|
||||||
|
vertices,
|
||||||
|
rows,
|
||||||
|
touched,
|
||||||
|
},
|
||||||
|
[vertices.buffer, rows.buffer, touched.buffer],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
frame() {
|
||||||
|
const j = this.job;
|
||||||
|
if (!j) return false;
|
||||||
|
const start = this.clock();
|
||||||
|
j.attempts = 0;
|
||||||
|
if (!j.target) {
|
||||||
|
j.target = this.mailbox.read(j.id);
|
||||||
|
j.admittedAt = start;
|
||||||
|
}
|
||||||
|
if (j.target) {
|
||||||
|
const sample = j.target;
|
||||||
|
if (j.settings.brush === "grab") {
|
||||||
|
// Influence is fixed by the first effective pressure, even if newer
|
||||||
|
// samples replace that seed before this worker gets a frame.
|
||||||
|
const seed =
|
||||||
|
sample.seed ??
|
||||||
|
(pressureValue(sample.pressure, j.settings.gain, j.settings.gamma) > 0
|
||||||
|
? sample.pressure
|
||||||
|
: undefined);
|
||||||
|
if (this.engine.stroke.grab === null && seed !== undefined)
|
||||||
|
this.engine.dab(j.hit, seed, [0, 0, 0]);
|
||||||
|
this.engine.dab(
|
||||||
|
j.hit,
|
||||||
|
sample.pressure,
|
||||||
|
sub(this.world(sample), this.origin),
|
||||||
|
);
|
||||||
|
this.mailbox.acknowledge(sample.version);
|
||||||
|
j.target = null;
|
||||||
|
} else {
|
||||||
|
let iterations = 0;
|
||||||
|
while (
|
||||||
|
j.target &&
|
||||||
|
j.attempts < 64 &&
|
||||||
|
(iterations === 0 || this.clock() - start < 4)
|
||||||
|
) {
|
||||||
|
iterations++;
|
||||||
|
if (this.path.push(sample, 1)) {
|
||||||
|
this.mailbox.acknowledge(sample.version);
|
||||||
|
j.target = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (j.target && this.clock() - j.admittedAt > 250) {
|
||||||
|
this.cancel(
|
||||||
|
j.id,
|
||||||
|
"Stroke canceled because editing fell behind. Previous strokes preserved. Try a smaller brush.",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.publish();
|
||||||
|
if (
|
||||||
|
j.ending &&
|
||||||
|
!j.target &&
|
||||||
|
this.mailbox.processed === j.finalVersion &&
|
||||||
|
!this.outstanding &&
|
||||||
|
!this.engine.dirty.size
|
||||||
|
) {
|
||||||
|
this.engine.stroke = null;
|
||||||
|
this.job = null;
|
||||||
|
this.send({ type: "complete", id: j.id });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof self !== "undefined" && typeof document === "undefined") {
|
||||||
|
let processor,
|
||||||
|
frame = 0;
|
||||||
|
const schedule = () => {
|
||||||
|
if (!frame && processor?.job)
|
||||||
|
frame = requestAnimationFrame(() => {
|
||||||
|
frame = 0;
|
||||||
|
try {
|
||||||
|
if (processor.frame()) schedule();
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({ type: "error", message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
self.onmessage = ({ data }) => {
|
||||||
|
try {
|
||||||
|
if (data.type === "init") {
|
||||||
|
if (typeof requestAnimationFrame !== "function")
|
||||||
|
throw new Error(
|
||||||
|
"Sculpt worker requires worker requestAnimationFrame support",
|
||||||
|
);
|
||||||
|
processor = new StrokeProcessor(
|
||||||
|
sphere(data.level),
|
||||||
|
new Mailbox(data.row),
|
||||||
|
(message, transfer = []) => self.postMessage(message, transfer),
|
||||||
|
);
|
||||||
|
self.postMessage({ type: "ready" });
|
||||||
|
} else if (data.type === "begin") {
|
||||||
|
processor.begin(data);
|
||||||
|
schedule();
|
||||||
|
} else if (data.type === "end") {
|
||||||
|
processor.end(data.id, data.version);
|
||||||
|
schedule();
|
||||||
|
} else if (data.type === "ack") {
|
||||||
|
processor.acknowledge(data.packet);
|
||||||
|
schedule();
|
||||||
|
} else if (data.type === "cancel") {
|
||||||
|
if (frame) cancelAnimationFrame(frame);
|
||||||
|
frame = 0;
|
||||||
|
processor.cancel(data.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({ type: "error", message: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
/* Basecoat owns controls; these rules own the sculpt workspace and density. */
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family:
|
||||||
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
#viewport {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
outline: none;
|
||||||
|
background: #20242b;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
summary {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.tool-rail {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 4;
|
||||||
|
top: max(12px, env(safe-area-inset-top));
|
||||||
|
left: max(8px, env(safe-area-inset-left));
|
||||||
|
max-height: calc(100dvh - 24px - env(safe-area-inset-bottom));
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.tool-rail .brushes,
|
||||||
|
.rail-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #22252bd9;
|
||||||
|
}
|
||||||
|
.icon-button.btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #d4d7db;
|
||||||
|
background: #22252bd9;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.tool-rail .icon-button.btn {
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
.icon-button svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.icon-button.btn:hover {
|
||||||
|
background: #43484f;
|
||||||
|
}
|
||||||
|
.tool-rail .btn[aria-pressed="true"] {
|
||||||
|
color: #fff;
|
||||||
|
background: #386fa9;
|
||||||
|
}
|
||||||
|
.tool-rail > .btn {
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
#view-navigation {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 4;
|
||||||
|
right: max(12px, env(safe-area-inset-right));
|
||||||
|
top: max(12px, env(safe-area-inset-top));
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
#orbit-gizmo {
|
||||||
|
position: relative;
|
||||||
|
width: 104px;
|
||||||
|
height: 104px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
#orbit-gizmo:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
#orbit-gizmo > svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 104px;
|
||||||
|
height: 104px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.axis-line {
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
.axis {
|
||||||
|
position: absolute;
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--axis-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: var(--axis-color);
|
||||||
|
color: #15202c;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.axis.rear {
|
||||||
|
background: #30343bec;
|
||||||
|
}
|
||||||
|
.axis:hover {
|
||||||
|
filter: brightness(1.3);
|
||||||
|
}
|
||||||
|
.axis:focus-visible,
|
||||||
|
#orbit-gizmo:focus-visible {
|
||||||
|
outline: 2px solid white;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
.view-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.view-buttons .btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.modified-label {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3;
|
||||||
|
top: max(8px, env(safe-area-inset-top));
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #b7bac1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.modified-label {
|
||||||
|
left: 62px;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 5;
|
||||||
|
top: max(12px, env(safe-area-inset-top));
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--popover);
|
||||||
|
color: var(--popover-foreground);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 8px 24px #0003;
|
||||||
|
max-height: calc(
|
||||||
|
100dvh - max(24px, env(safe-area-inset-top)) -
|
||||||
|
max(12px, env(safe-area-inset-bottom))
|
||||||
|
);
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
.tools-panel {
|
||||||
|
left: max(58px, calc(env(safe-area-inset-left) + 50px));
|
||||||
|
width: min(216px, calc(100vw - 74px));
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.settings-panel {
|
||||||
|
left: max(58px, calc(env(safe-area-inset-left) + 50px));
|
||||||
|
width: min(264px, calc(100vw - 74px));
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.panel-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 28px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.panel-heading h2 {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.panel .btn {
|
||||||
|
height: 30px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding-inline: 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.panel-heading .btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.brushes {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.brushes .btn {
|
||||||
|
justify-content: space-between;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.brushes .btn span {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.panel [aria-pressed="true"] {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-foreground);
|
||||||
|
box-shadow: inset 2px 0 0 #dedee4;
|
||||||
|
}
|
||||||
|
.control-section {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
output {
|
||||||
|
float: right;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 10px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.panel input[type="range"] {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 24px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toggle-row small {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
.toggle-row input {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.history,
|
||||||
|
.navigation,
|
||||||
|
.project-actions,
|
||||||
|
.settings-footer {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.history,
|
||||||
|
.settings-footer {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.navigation {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
.wide {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.project-name {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 11px;
|
||||||
|
margin: 4px 0 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.project-actions {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
#save {
|
||||||
|
grid-column: 1/-1;
|
||||||
|
}
|
||||||
|
.palm-row {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
details {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
/* Avoid transient overlapping controls during the library's height animation. */
|
||||||
|
details::details-content {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 36px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
summary::after {
|
||||||
|
content: "+";
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
details[open] > summary::after {
|
||||||
|
content: "−";
|
||||||
|
}
|
||||||
|
summary:focus-visible {
|
||||||
|
outline: 2px solid var(--ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.disclosure-content {
|
||||||
|
padding: 4px 0 12px;
|
||||||
|
}
|
||||||
|
.settings-footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
.select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.panel .select {
|
||||||
|
height: 32px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.pressure-meter {
|
||||||
|
height: 3px;
|
||||||
|
background: var(--muted);
|
||||||
|
margin: 8px 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#pressure-fill {
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--foreground);
|
||||||
|
}
|
||||||
|
#curve {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.curve-grid {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--border);
|
||||||
|
}
|
||||||
|
.curve-grid,
|
||||||
|
#curve-line {
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
#curve-line {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--foreground);
|
||||||
|
}
|
||||||
|
.swatches {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.swatches .btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.swatches .btn::before {
|
||||||
|
content: "";
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--swatch);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.swatches .btn[aria-pressed="true"] {
|
||||||
|
border-color: var(--muted-foreground);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
#cursor {
|
||||||
|
position: absolute;
|
||||||
|
border: 1px solid #d0efdc;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
#cursor i {
|
||||||
|
position: absolute;
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d0efdc;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
.status-toast {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 8;
|
||||||
|
bottom: max(12px, env(safe-area-inset-bottom));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: min(400px, calc(100vw - 32px));
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--popover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--popover-foreground);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.status-toast.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.canvas-message {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: min(340px, calc(100% - 48px));
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--popover-foreground);
|
||||||
|
background: var(--popover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.canvas-message h1 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.canvas-message p {
|
||||||
|
margin: 10px 0 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.dialog > form {
|
||||||
|
width: min(380px, calc(100vw - 32px));
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.dialog label {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.dialog .hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.dialog dl {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.dialog dt {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.dialog dd {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
@media (any-pointer: coarse) {
|
||||||
|
.icon-button.btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.view-buttons .btn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
.panel .btn {
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
.toggle-row,
|
||||||
|
summary {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.panel input[type="range"] {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.btn,
|
||||||
|
.dialog,
|
||||||
|
.dialog > * {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Ronan Berder
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Basecoat 1.0.2
|
||||||
|
|
||||||
|
`basecoat.min.css` is the unmodified `dist/basecoat.cdn.min.css` (Vega style)
|
||||||
|
from the npm package `basecoat-css@1.0.2`, licensed under MIT (see LICENSE.md).
|
||||||
|
Source: https://www.npmjs.com/package/basecoat-css/v/1.0.2
|
||||||
|
Documentation: https://basecoatui.com/
|
||||||
|
|
||||||
|
SHA-256: `8123677adb9bba43be3298e1543bcc5fc763e8cda3d32dc74c806046a3537ca0`
|
||||||
|
|
||||||
|
Served locally through Go's embedded assets. No CDN, Tailwind build, or Basecoat
|
||||||
|
JavaScript runtime is required. Native controls and the existing Menus controller
|
||||||
|
own interactions. Do not format or modify the upstream stylesheet.
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,217 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Worker } from "node:worker_threads";
|
||||||
|
import { Mailbox } from "./mailbox.js";
|
||||||
|
import { StrokeProcessor } from "./stroke-worker.js";
|
||||||
|
import { Sculpt, defaults, StrokePath } from "./engine.js";
|
||||||
|
import { sphere } from "./mesh.js";
|
||||||
|
|
||||||
|
const row = () => ({
|
||||||
|
buffer: new SharedArrayBuffer(128),
|
||||||
|
descriptor: { offset: 64 },
|
||||||
|
});
|
||||||
|
test("mailbox overwrites one slot, keeps a newer publication pending, wraps and rejects torn reads", (t) => {
|
||||||
|
const m = new Mailbox(row()),
|
||||||
|
p = { x: 12, y: -8, pressure: 0.5 };
|
||||||
|
const first = m.write(7, p);
|
||||||
|
assert.deepEqual(m.read(7), { ...p, seed: undefined, version: first });
|
||||||
|
const second = m.write(7, { x: 20, y: 3, pressure: 0.75 });
|
||||||
|
m.acknowledge(first);
|
||||||
|
assert.equal(m.read(7).version, second);
|
||||||
|
m.acknowledge(second);
|
||||||
|
assert.equal(m.read(7), null);
|
||||||
|
Atomics.store(m.words, 0, 0x7ffffffe);
|
||||||
|
const wrapped = m.write(7, p);
|
||||||
|
assert.equal(wrapped, -2147483648);
|
||||||
|
assert.equal(m.read(7).x, 12);
|
||||||
|
const load = Atomics.load;
|
||||||
|
let changed = false;
|
||||||
|
t.mock.method(Atomics, "load", (array, index) => {
|
||||||
|
const v = load(array, index);
|
||||||
|
if (array === m.words && index === 5 && !changed) {
|
||||||
|
changed = true;
|
||||||
|
m.write(7, { x: 99, y: 100, pressure: 0.2 });
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
});
|
||||||
|
assert.equal(m.read(7), null);
|
||||||
|
Atomics.store(m.words, 0, 11);
|
||||||
|
assert.equal(m.read(7), null);
|
||||||
|
});
|
||||||
|
test("actual concurrent SAB writer and reader never observe mixed coordinates", async (t) => {
|
||||||
|
const shared = row(),
|
||||||
|
m = new Mailbox(shared),
|
||||||
|
control = new Int32Array(shared.buffer, 0, 1);
|
||||||
|
const code = `import {parentPort,workerData} from 'node:worker_threads';
|
||||||
|
const {Mailbox}=await import(workerData.module);
|
||||||
|
const m=new Mailbox(workerData.row),control=new Int32Array(workerData.row.buffer,0,1);
|
||||||
|
let reads=0,last=0;
|
||||||
|
parentPort.postMessage('ready');
|
||||||
|
while(!Atomics.load(control,0)||last!==100000){
|
||||||
|
const p=m.read(7);if(!p)continue;
|
||||||
|
if(p.y!==-p.x||p.pressure!==p.x*2||p.seed!==p.x*3)throw new Error('torn tuple');
|
||||||
|
last=p.x;reads++;m.acknowledge(p.version);
|
||||||
|
}parentPort.postMessage({reads,last});`;
|
||||||
|
const worker = new Worker(
|
||||||
|
new URL("data:text/javascript," + encodeURIComponent(code)),
|
||||||
|
{
|
||||||
|
workerData: {
|
||||||
|
module: new URL("./mailbox.js", import.meta.url).href,
|
||||||
|
row: shared,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
t.after(() => worker.terminate());
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
worker.once("message", resolve);
|
||||||
|
worker.once("error", reject);
|
||||||
|
});
|
||||||
|
const result = new Promise((resolve, reject) => {
|
||||||
|
worker.once("message", resolve);
|
||||||
|
worker.once("error", reject);
|
||||||
|
});
|
||||||
|
for (let i = 1; i <= 100000; i++) {
|
||||||
|
m.write(7, { x: i, y: -i, pressure: i * 2 }, i * 3);
|
||||||
|
if (i % 1000 === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
Atomics.store(control, 0, 1);
|
||||||
|
const out = await result;
|
||||||
|
assert.ok(out.reads > 0);
|
||||||
|
assert.equal(out.last, 100000);
|
||||||
|
});
|
||||||
|
function setup(settings = {}, sample = { x: 400, y: 300, pressure: 0.7 }) {
|
||||||
|
const mailbox = new Mailbox(row()),
|
||||||
|
messages = [],
|
||||||
|
mesh = sphere(4);
|
||||||
|
let now = 0;
|
||||||
|
const processor = new StrokeProcessor(
|
||||||
|
mesh,
|
||||||
|
mailbox,
|
||||||
|
(m) => messages.push(m),
|
||||||
|
() => now,
|
||||||
|
);
|
||||||
|
const version = mailbox.write(1, sample);
|
||||||
|
processor.begin({
|
||||||
|
id: 1,
|
||||||
|
version,
|
||||||
|
sample,
|
||||||
|
positions: mesh.positions.slice(),
|
||||||
|
settings: { ...defaults, symmetry: false, ...settings },
|
||||||
|
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] },
|
||||||
|
});
|
||||||
|
const ack = () => {
|
||||||
|
for (const m of messages)
|
||||||
|
if (m.type === "geometry") processor.acknowledge(m.packet);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
processor,
|
||||||
|
mailbox,
|
||||||
|
messages,
|
||||||
|
ack,
|
||||||
|
advance: (ms) => {
|
||||||
|
now += ms;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
test("latest frame target equals feeding only that target, intermediate samples create no hidden queue", () => {
|
||||||
|
const a = setup(),
|
||||||
|
b = setup();
|
||||||
|
a.processor.frame();
|
||||||
|
b.processor.frame();
|
||||||
|
a.ack();
|
||||||
|
b.ack();
|
||||||
|
for (let i = 0; i < 10000; i++)
|
||||||
|
a.mailbox.write(1, {
|
||||||
|
x: 410 + i / 100,
|
||||||
|
y: 300 + Math.sin(i) * 30,
|
||||||
|
pressure: 0.4,
|
||||||
|
});
|
||||||
|
const end = { x: 470, y: 310, pressure: 0.8 };
|
||||||
|
const version = a.mailbox.write(1, end);
|
||||||
|
b.mailbox.write(1, end);
|
||||||
|
a.processor.frame();
|
||||||
|
b.processor.frame();
|
||||||
|
assert.deepEqual(
|
||||||
|
a.processor.engine.mesh.positions,
|
||||||
|
b.processor.engine.mesh.positions,
|
||||||
|
);
|
||||||
|
assert.equal(a.mailbox.processed, version);
|
||||||
|
assert.equal(a.processor.job.target, null);
|
||||||
|
});
|
||||||
|
test("Grab seeds pressure-radius from first effective pressure and ignores a zero-pressure tail", () => {
|
||||||
|
const a = setup(
|
||||||
|
{ brush: "grab", pressureRadius: true },
|
||||||
|
{ x: 400, y: 300, pressure: 0 },
|
||||||
|
);
|
||||||
|
a.processor.frame();
|
||||||
|
const version = a.mailbox.write(1, { x: 460, y: 310, pressure: 0.8 }, 0.25);
|
||||||
|
a.processor.frame();
|
||||||
|
const expected = new Sculpt(sphere(4));
|
||||||
|
expected.begin({ brush: "grab", pressureRadius: true, symmetry: false });
|
||||||
|
expected.dab({ position: [0, 0, 1], normal: [0, 0, 1] }, 0.25, [0, 0, 0]);
|
||||||
|
expected.dab(
|
||||||
|
{ position: [0, 0, 1], normal: [0, 0, 1] },
|
||||||
|
Math.fround(0.8),
|
||||||
|
[0.3, -0.05, 0],
|
||||||
|
);
|
||||||
|
assert.deepEqual(a.processor.engine.mesh.positions, expected.mesh.positions);
|
||||||
|
assert.equal(a.mailbox.processed, version);
|
||||||
|
});
|
||||||
|
test("one immutable output in flight, new dirty work survives acknowledgement, end waits for applied output", () => {
|
||||||
|
const a = setup();
|
||||||
|
a.processor.frame();
|
||||||
|
const first = a.messages.find((m) => m.type === "geometry"),
|
||||||
|
bytes = first.vertices.slice();
|
||||||
|
const version = a.mailbox.write(1, { x: 470, y: 310, pressure: 0.9 });
|
||||||
|
a.processor.end(1, version);
|
||||||
|
a.processor.frame();
|
||||||
|
assert.deepEqual(first.vertices, bytes);
|
||||||
|
assert.equal(a.messages.filter((m) => m.type === "geometry").length, 1);
|
||||||
|
assert.ok(a.processor.engine.dirty.size);
|
||||||
|
assert.ok(!a.messages.some((m) => m.type === "complete"));
|
||||||
|
a.processor.acknowledge(first.packet);
|
||||||
|
a.processor.frame();
|
||||||
|
const second = a.messages.filter((m) => m.type === "geometry").at(-1);
|
||||||
|
assert.notEqual(second.packet, first.packet);
|
||||||
|
a.processor.acknowledge(first.packet);
|
||||||
|
assert.equal(a.processor.outstanding, second.packet);
|
||||||
|
a.processor.acknowledge(second.packet);
|
||||||
|
a.processor.frame();
|
||||||
|
assert.equal(a.messages.at(-1).type, "complete");
|
||||||
|
});
|
||||||
|
test("worker bounds real work; unfinished segment age is not reset by release or newer samples", () => {
|
||||||
|
const a = setup();
|
||||||
|
a.processor.frame();
|
||||||
|
a.ack();
|
||||||
|
const dab = a.processor.engine.dab.bind(a.processor.engine);
|
||||||
|
let calls = 0;
|
||||||
|
a.processor.engine.dab = (...args) => {
|
||||||
|
calls++;
|
||||||
|
a.advance(5);
|
||||||
|
dab(...args);
|
||||||
|
};
|
||||||
|
a.mailbox.write(1, { x: 590, y: 300, pressure: 0.8 });
|
||||||
|
a.processor.frame();
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.ok(a.processor.job.target);
|
||||||
|
a.advance(250);
|
||||||
|
const latest = a.mailbox.write(1, { x: 610, y: 305, pressure: 0.9 });
|
||||||
|
a.processor.end(1, latest);
|
||||||
|
a.processor.frame();
|
||||||
|
assert.equal(a.processor.job, null);
|
||||||
|
assert.equal(a.messages.at(-1).type, "canceled");
|
||||||
|
});
|
||||||
|
test("resuming a segment preserves independently computed positions and pressure", () => {
|
||||||
|
const emitted = [],
|
||||||
|
path = new StrokePath(5, (p) => emitted.push(p));
|
||||||
|
path.push({ x: 0, y: 0, pressure: 0.2 });
|
||||||
|
while (!path.push({ x: 24, y: 18, pressure: 0.8 }, 1)) {}
|
||||||
|
assert.equal(emitted.length, 7);
|
||||||
|
emitted.forEach((p, i) => {
|
||||||
|
assert.ok(Math.abs(p.x - 4 * i) < 1e-10);
|
||||||
|
assert.ok(Math.abs(p.y - 3 * i) < 1e-10);
|
||||||
|
assert.ok(Math.abs(p.pressure - (0.2 + 0.1 * i)) < 1e-10);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user