Skip to content

feat(generate): sculpt mode with Grab, Smooth, Inflate and Flatten brushes - #436

Merged
lightningpixel merged 9 commits into
devfrom
feat/432-sculpt-mode
Nov 12, 2026
Merged

lightningpixel merged 9 commits into
devfrom
feat/432-sculpt-mode

Conversation

@lightningpixel

Copy link
Copy Markdown
Owner

Summary

Blender-like sculpt mode in the Generate viewer, driven from a new Sculpt section of the Edit panel.

  • Brushes: Grab, Smooth, Inflate, Flatten. Radius, strength and a smooth falloff. X / Y / Z mirror symmetry.
  • Mouse: the left button paints on the mesh. A press on empty space, the right or middle button and the wheel still orbit and zoom.
  • Shortcuts: G / S / I / T pick a brush, F / Shift+F change the radius, Ctrl inverts, holding Shift smooths, Ctrl+Z / Ctrl+Y undo per stroke, Esc or Done saves and leaves.
  • Seams: vertices duplicated along UV seams are welded, so the mesh never tears.
  • Live view: normals and the BVH are refreshed only in the region a stroke touched.
  • Saving:
    • Strokes are undone locally while sculpting.
    • Leaving sculpt mode writes them to a new GLB through POST /optimize/patch-vertices, which goes on the mesh undo stack as a single applyMeshEdit step.
    • The GLB is patched at container level: new accessors are appended, so textures, materials and extensions are kept.
  • Pending strokes are saved before anything reads or replaces the mesh file: export, OrcaSlicer, Edit panel ops, smooth/decimate, the chat agent, imports and the asset library.
  • Size limit: above 3M triangles, sculpting is disabled with a message suggesting Decimate or Prepare for Game.
  • Performance on 1M triangles: preparing the mesh takes about 235 ms, one dab about 0.5–0.7 ms, a grab about 1.8 ms.

This branch also contains the Pillow fix from #435. That part of the diff disappears once #435 is merged.

Closes #432
Closes #433

Review fix

Primitives that share one POSITION accessor (multi-material meshes) share one buffer in the viewer, so a stroke moves all of them. The patch now repoints every primitive that uses the accessor, not only the one that was clicked. Before, the others snapped back after the reload and the mesh tore.

Test plan

  • npm run test:node: 212 pass, which includes the SculptMesh core, perf and sculptLogic suites
  • python -m unittest tests.test_optimize_patch_vertices tests.test_requirements: 14 OK
  • ESLint is clean on src/areas/generate. tsc shows no errors in the new files; the remaining errors are already on dev (assetLibrary, workflowRunStore).
  • Tested by hand in the app: grab, smooth, inflate, flatten, symmetry, local undo, saving on exit and global undo.

…nto a GLB

POST /optimize/patch-vertices appends new float32 POSITION/NORMAL accessors for the edited primitives to a copy of the GLB and repoints the primitive attributes, leaving existing accessors, textures, materials and extensions untouched.
Grab, smooth, inflate and flatten brushes on raw typed arrays, acting on
welded vertices so UV-seam duplicates never tear. Integer-keyed hash weld,
CSR adjacency, a dense re-bucketing grid for sphere queries, regional
normal updates, symmetry with blended mirrored dabs, and first-touch
stroke records that restore() swaps for undo/redo.
Sculpt section in the Edit panel (Grab, Smooth, Inflate, Flatten, radius,
strength, X/Y/Z symmetry), stroke input and brush ring in the viewer, local
stroke undo/redo, and a commit to POST /optimize/patch-vertices that lands
as one global mesh edit. Pending strokes are saved before export, Edit ops,
chat messages, global undo/redo, import and library open.
trimesh decodes glTF images with Pillow but does not depend on it. The
setup venv therefore had no Pillow, and every op that reloads and
re-exports a mesh (presets, transforms, normals) silently wrote it back
without its textures. The dev .venv happened to have it, which hid the bug.

Changing requirements.txt also changes its hash, so existing installs
re-run setup and get Pillow on the next launch.
…essor

The viewer loads a shared accessor once, so a stroke moves every primitive using it. Patching only the clicked primitive made the others snap back after the reload and tore the mesh.
@lightningpixel
lightningpixel merged commit e5a5e3a into dev Nov 12, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant