Add pastel presets, piece selection and colors, mask brushes, subdivision and conservative decimation with bounded worker operations and mixed undo history. Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4b4-202c-73f1-984d-9a55c88f9c83 Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
10 KiB
Yawn Sculpt
Open /sculpt on the Yawn server (npm run server); /view/sculpt remains a compatibility alias. The production Docker image embeds this example, so the existing main-branch deployment poller publishes it at /sculpt with each deployment. 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 geometry brushes: Draw, Inflate, Smooth, Flatten, Grab; Mask paints protected regions and its erase mode removes protection. All geometry brushes respect masks. X symmetry reflects about each piece's stored local plane and merges displacement without double deposition. Pen pressure modulates strength through clamp(gain × pressure^gamma, 0, 1); pressure-radius is optional. Mouse/touch use constant pressure. Settings and temporary Shift-smooth / Alt-invert are captured at stroke start. Grab uses a fixed camera-facing drag plane and stroke-start positions.
The Clay popover adds Sphere, Cube, Cylinder, Cone and Torus presets, each with a newly chosen pastel material. Select a piece in the list to sculpt, recolor or delete it; even the last piece can be deleted. Additions are placed beside existing clay. Detail & mask contains Clear/Invert mask, Subdivide (linear 4× triangle split, interpolating masks), and Decimate (best-effort 50% reduction). Decimation freezes masked neighborhoods and boundary vertices and rejects topology-breaking collapses. It may achieve less reduction, or none. There are no piece transform handles.
The compact left icon rail contains brushes, brush settings, undo/redo and Settings. Hover or tap the settings icons to reveal their panels; tap inside to keep one open. Projects and palm rejection live in Settings, with less-used pen/display controls in disclosures. Panels overlay the canvas without resizing it and close on viewport resize. Orientation changes preserve apparent zoom, pan and rotation.
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 includes whole strokes, masks, colors and structural operations. Native v2 .yawn-sculpt projects preserve topology, pieces, colors and masks (not history or settings), and v1 sphere projects still open. OBJ exports geometry and piece groups, not masks/materials. Download before leaving—there is no automatic persistence.
Performance bounds and limitations
| New sphere | Vertices | Triangles | History cap |
|---|---|---|---|
| Simple | 2,562 | 5,120 | 16 MiB |
| Balanced | 10,242 | 20,480 | 16 MiB |
| Fine | 40,962 | 81,920 | 16 MiB |
History is capped by bytes and 50 entries, with a separate bounded active-stroke position snapshot. A refitted triangle BVH accelerates picking and brush neighborhoods. Affected normals include all incident faces at brush boundaries. Display quality changes pixel budgets (~0.6/1/2 MP) and frame caps (30/60/60), never mesh density. The 8 MiB shared-row arena is not the application's total memory budget.
Documents are capped at 16 pieces, 65,536 vertices and 131,072 triangles. Colors and masks use the existing 32-byte GPU vertex stride and one draw call. Topology algorithms run in a disposable worker with cancellation and a bounded decimation budget; accepted structural edits rebuild main-thread adjacency/BVH and recreate the renderer/stroke worker. This infrequent installation can still pause on constrained devices; it does not add topology work to the stroke path. Resize, failure or Cancel reject pending work. Renderer failure after commit preserves the new document for Save, Undo and Retry.
Input occupies one 64-byte sculptSample row in core's SAB, not a sample queue. Main publishes the latest endpoint/pressure; subtracting the previously consumed endpoint gives the accumulated movement. Atomic version tokens protect a coherent snapshot and retire only the consumed update, leaving concurrent newer input pending. A dedicated worker samples this slot with its own requestAnimationFrame and performs brush raycasts, deformation, normals and BVH refits. It interpolates straight segments between consumed endpoints; this frame-level approximation does not retain intermediate bends or pressure changes. Grab separately preserves the first effective pressure that establishes its influence.
The worker retains at most one unfinished segment plus the latest shared endpoint. Resumable interpolation yields after a soft 4 ms budget or 64 brush attempts; an unfinished segment exceeding 250 ms cancels the provisional stroke. One synchronous dab can exceed the budget, especially for large brushes and Detail. Shared memory avoids an input message queue, not the CPU cost of sculpting. Worker and renderer animation frames are independently scheduled, not a same-frame presentation guarantee.
Worker geometry packets are immutable, transferred, and single-flight; main applies them to its document mirror and acknowledges them. GPU uploads retain their separate single-flight staging guard. Initial picking, document/history operations, and one face-normal/BVH maintenance pass at stroke completion remain on main. Completed geometry/history survive renderer or sculpt-worker failure; unfinished work rolls back. Resize recreates both workers and the shared row because core does not expose surface resizing. Viewport rectangle changes cancel unfinished strokes immediately; new strokes wait for cancellation acknowledgement and the matching renderer size. The sculpt worker stops its animation loop between strokes; core's renderer still has its own loop.
Requires WebGPU, OffscreenCanvas, shared memory, cross-origin isolation, and dedicated-worker animation frames. Observable initialization/upload failures preserve the document and expose Retry plus Save/Export; core does not expose comprehensive device-loss detection. A front-facing brush filter is not geodesic/occlusion isolation on folded surfaces. This remains not Nomad parity: no remesh, dynamic topology during strokes, layers, texture painting, piece transforms or arbitrary mesh import. Coordinates are bounded to ±8 model units.
Verification
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.
On a fresh page, await (await import('/assets/sculpt/pieces-check.js')).check() exercises all presets, selection, color/mask history, replacement-topology sculpting, subdivision/decimation, empty projects and real operation-worker delivery races. It also injects pre/post-commit renderer failures. Like the original browser check, it stubs pointer capture and restores hooks afterward; it is not physical pen testing.
In a fresh browser page, await (await import('/assets/sculpt/latency-check.js')).check() replays Draw and Grab at 1, 4, and 16 coalesced samples per animation frame on Balanced-density spheres. It verifies zero main-thread dabs and fixed shared-input storage, and reports main input/packet handling, uploads, release-to-Save-enabled delay and cancellations separately. This synthetic check does not measure physical pen-to-display latency. Unit tests include a real two-thread SAB stress test and deterministic delayed-publication/cancellation races.
For visual checks in the orb, use agent-browser --headed --webgpu: this environment's headless WebGPU presentation can be blank even when rendering succeeds. Inspect desktop, narrow portrait/landscape, settings open, error, and modified/undo/redo states. Test save/reopen and rejected files. Use the page's Help for all gestures and shortcuts.
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.