From 1883fcd629f3e6b14c73ff1e86bd634ca3fce13e Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Thu, 12 Nov 2026 10:30:12 +0100 Subject: [PATCH 1/6] chore(sculpt): contract for the sculpt core --- src/areas/generate/sculpt/SculptMesh.ts | 78 +++++++++++++++++++++++++ src/areas/generate/sculpt/types.ts | 49 ++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/areas/generate/sculpt/SculptMesh.ts create mode 100644 src/areas/generate/sculpt/types.ts diff --git a/src/areas/generate/sculpt/SculptMesh.ts b/src/areas/generate/sculpt/SculptMesh.ts new file mode 100644 index 00000000..ead4f87d --- /dev/null +++ b/src/areas/generate/sculpt/SculptMesh.ts @@ -0,0 +1,78 @@ +// Sculpt core: brushes applied in place on a geometry's position/normal arrays. +// CONTRACT STUB — the public surface below is fixed; the implementation is +// filled in separately (see tasks/todo.md, #432/#433). + +import type { + BrushSettings, + DirtyRegion, + SculptMeshOptions, + StrokeRecord, + Vec3, +} from './types' + +export class SculptMesh { + /** The array passed to the constructor, mutated in place. */ + readonly positions: Float32Array + /** The normals passed in, or a newly allocated array when there were none. Mutated in place. */ + readonly normals: Float32Array + readonly vertexCount: number + /** Bounding-box centre at construction: the symmetry planes pass through it. */ + readonly symmetryCenter: Vec3 + + /** + * Builds the weld map (UV-seam duplicates), vertex adjacency and a spatial + * index. Must stay fast on ~1M-triangle meshes (target < 1.5 s). + * `normals` null → normals are computed from the faces. + */ + constructor( + positions: Float32Array, + normals: Float32Array | null, + index: Uint32Array | Uint16Array | null, + options?: SculptMeshOptions, + ) { + void positions; void normals; void index; void options + throw new Error('SculptMesh: not implemented') + } + + /** + * Starts a stroke at a surface point. For grab, the vertices inside the + * radius and their weights are captured here and stay fixed for the stroke. + */ + beginStroke(settings: BrushSettings, point: Vec3, normal: Vec3): DirtyRegion { + void settings; void point; void normal + throw new Error('SculptMesh: not implemented') + } + + /** + * Continues the stroke to a new point. + * - grab: `point` is the cursor projected on the camera-facing plane through + * the stroke's start point (computed by the viewer); the captured vertices + * follow point − previous point, scaled by their weight. + * - other brushes: dabs are spaced along the path (every ~radius/4) from the + * previous point; `point`/`normal` are the latest surface hit. + * Positions, normals of the affected region, and weld duplicates are updated. + */ + strokeTo(point: Vec3, normal: Vec3): DirtyRegion { + void point; void normal + throw new Error('SculptMesh: not implemented') + } + + /** Ends the stroke. Returns its undo record, or null if nothing moved. */ + endStroke(): StrokeRecord | null { + throw new Error('SculptMesh: not implemented') + } + + /** + * Swaps the vertices of `record` with their current state and returns the + * record of what was overwritten, so undo and redo are the same call. + */ + restore(record: StrokeRecord): { region: DirtyRegion; inverse: StrokeRecord } { + void record + throw new Error('SculptMesh: not implemented') + } + + /** True once any stroke or restore changed the mesh since construction. */ + get changed(): boolean { + throw new Error('SculptMesh: not implemented') + } +} diff --git a/src/areas/generate/sculpt/types.ts b/src/areas/generate/sculpt/types.ts new file mode 100644 index 00000000..a302b4d6 --- /dev/null +++ b/src/areas/generate/sculpt/types.ts @@ -0,0 +1,49 @@ +// Contract between the sculpt core (pure geometry, ./SculptMesh.ts) and the +// viewer integration (Viewer3D / EditPanel). No three.js imports here: the core +// works on raw typed arrays so it can be unit-tested with node --test. +// +// Coordinates are always in the LOCAL space of one THREE.Mesh geometry (the +// same space as geometry.attributes.position). The viewer converts world-space +// hits and radii into that space before calling the core. + +export type Vec3 = [number, number, number] + +export type BrushKind = 'grab' | 'smooth' | 'inflate' | 'flatten' + +export type SymmetryAxis = 'x' | 'y' | 'z' + +export interface BrushSettings { + kind: BrushKind + /** Brush radius, in local units. */ + radius: number + /** 0..1. */ + strength: number + /** Inflate → deflate, flatten → pull outwards (Ctrl held). Ignored by grab and smooth. */ + invert: boolean + /** Mirror every dab across these planes through SculptMesh.symmetryCenter. */ + symmetry: SymmetryAxis[] +} + +/** Buffer vertices touched by one call, so the viewer can upload only that range. */ +export interface DirtyRegion { + /** + * Every buffer vertex index whose position or normal changed, including the + * duplicates welded to a moved vertex (UV seams). Empty when nothing moved. + */ + vertices: Uint32Array + /** Smallest and largest touched buffer index (for BufferAttribute.addUpdateRange). */ + minIndex: number + maxIndex: number +} + +/** Undo record of one stroke: swap it back with SculptMesh.restore(). */ +export interface StrokeRecord { + vertices: Uint32Array + positions: Float32Array + normals: Float32Array +} + +export interface SculptMeshOptions { + /** Distance under which two buffer vertices count as one (UV-seam duplicates). Default: 1e-6 × bbox diagonal. */ + weldEpsilon?: number +} From 0c7b8c504bb78ae0d9a132ece0595814bdf474f6 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Thu, 12 Nov 2026 10:43:05 +0100 Subject: [PATCH 2/6] feat(optimize): patch-vertices endpoint to write sculpted positions into 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. --- api/routers/optimize.py | 32 +++ api/services/mesh_ops/glb_patch.py | 205 +++++++++++++++ api/tests/test_optimize_patch_vertices.py | 299 ++++++++++++++++++++++ 3 files changed, 536 insertions(+) create mode 100644 api/services/mesh_ops/glb_patch.py create mode 100644 api/tests/test_optimize_patch_vertices.py diff --git a/api/routers/optimize.py b/api/routers/optimize.py index a362b3b6..5fafc334 100644 --- a/api/routers/optimize.py +++ b/api/routers/optimize.py @@ -24,6 +24,7 @@ MeshOpUnavailableError, mesh_ops_registry, ) +from services.mesh_ops.glb_patch import GlbPatchError, VertexPatch, patch_glb_file from services.mesh_ops.transforms import apply_transforms, transform_file router = APIRouter(tags=["optimize"]) @@ -55,6 +56,18 @@ class ArrangeRequest(BaseModel): params: dict[str, dict[str, object]] = Field(default_factory=dict) # per step id +class VertexPatchBody(BaseModel): + mesh: int # index into gltf.meshes + primitive: int # index into meshes[mesh].primitives + positions: str # base64 little-endian float32, vertexCount * 3 + normals: str | None = None # same format, optional + + +class PatchVerticesRequest(BaseModel): + path: str # format: "{collection}/{filename}.glb" + patches: list[VertexPatchBody] + + def _resolve_input_path(raw_path: str) -> Path: candidate = Path(raw_path) if candidate.is_absolute(): @@ -238,6 +251,25 @@ def arrange_mesh(body: ArrangeRequest): return _operation_response(result) +@router.post("/patch-vertices") +def patch_vertices(body: PatchVerticesRequest): + # Sculpt commit: write the viewer's edited vertex data into a copy of the + # GLB at container level, so textures, materials and extensions survive. + input_path = _resolve_input_path(body.path) + if input_path.suffix.lower() != ".glb": + raise HTTPException(400, "Sculpt patches can only be applied to .glb files") + output_path = _operation_output_path( + input_path, + f"{input_path.stem}_sculpt_{uuid.uuid4().hex[:8]}.glb", + ) + patches = [VertexPatch(**patch.model_dump()) for patch in body.patches] + try: + patch_glb_file(input_path, output_path, patches) + except GlbPatchError as exc: + raise HTTPException(400, str(exc)) from exc + return _operation_response(MeshOpResult(output_path, {})) + + def _transform_output_path(input_path: Path) -> Path: return _operation_output_path( input_path, diff --git a/api/services/mesh_ops/glb_patch.py b/api/services/mesh_ops/glb_patch.py new file mode 100644 index 00000000..56839f4a --- /dev/null +++ b/api/services/mesh_ops/glb_patch.py @@ -0,0 +1,205 @@ +"""Write new vertex positions/normals into a copy of a GLB, leaving the rest intact. + +The sculpt tool edits geometry in the viewer and sends back, per glTF primitive, +the new POSITION (and optionally NORMAL) arrays. Re-exporting through trimesh +would lose extensions and rewrite materials, so instead the GLB is patched at +the container level: the new data is appended to the BIN chunk as fresh +bufferViews + accessors and the primitive's attributes are repointed to them. +Existing accessors are never mutated (they may be shared with other primitives +or morph targets); the old data is simply left unreferenced. +""" + +from __future__ import annotations + +import base64 +import binascii +import json +import math +import struct +import sys +from array import array +from dataclasses import dataclass +from pathlib import Path + +_GLB_MAGIC = 0x46546C67 # "glTF" +_CHUNK_JSON = 0x4E4F534A # "JSON" +_CHUNK_BIN = 0x004E4942 # "BIN\0" +_FLOAT = 5126 +_ARRAY_BUFFER = 34962 +_DRACO = "KHR_draco_mesh_compression" + + +class GlbPatchError(ValueError): + """The GLB or a patch is invalid; the message is safe to show to the user.""" + + +@dataclass(frozen=True) +class VertexPatch: + mesh: int + primitive: int + positions: str # base64, little-endian float32, count * 3 + normals: str | None = None # same format, optional + + +def patch_glb_file(input_path: Path, output_path: Path, patches: list[VertexPatch]) -> None: + data = patch_glb_bytes(Path(input_path).read_bytes(), patches) + Path(output_path).write_bytes(data) + + +def patch_glb_bytes(data: bytes, patches: list[VertexPatch]) -> bytes: + if not patches: + raise GlbPatchError("At least one patch is required") + + gltf, binary = _parse_glb(data) + buffers = gltf.setdefault("buffers", []) + if binary is None: + if buffers: + raise GlbPatchError("GLB has no BIN chunk but declares external buffers") + buffers.append({"byteLength": 0}) + binary = b"" + elif not buffers or "uri" in buffers[0]: + raise GlbPatchError("GLB BIN chunk is not bound to buffers[0]") + + # GLB BIN chunk may be padded beyond the declared buffer length; append after it. + out = bytearray(binary) + meshes = gltf.get("meshes") or [] + accessors = gltf.setdefault("accessors", []) + views = gltf.setdefault("bufferViews", []) + + for patch in patches: + primitive = _primitive(meshes, patch.mesh, patch.primitive) + if _DRACO in (primitive.get("extensions") or {}): + raise GlbPatchError( + f"mesh {patch.mesh} primitive {patch.primitive} is Draco-compressed " + f"({_DRACO}); sculpting Draco meshes is not supported" + ) + attributes = primitive.get("attributes") or {} + position_index = attributes.get("POSITION") + if not isinstance(position_index, int) or not 0 <= position_index < len(accessors): + raise GlbPatchError(f"mesh {patch.mesh} primitive {patch.primitive} has no POSITION accessor") + count = int(accessors[position_index].get("count", 0)) + + label = f"mesh {patch.mesh} primitive {patch.primitive}" + positions_raw, positions = _decode(patch.positions, count, f"{label} positions") + normals_raw = None + if patch.normals is not None: + normals_raw, _ = _decode(patch.normals, count, f"{label} normals") + + new_attributes = dict(attributes) + new_attributes["POSITION"] = _append_accessor( + out, views, accessors, positions_raw, count, bounds=_bounds(positions), + ) + if normals_raw is not None: + new_attributes["NORMAL"] = _append_accessor(out, views, accessors, normals_raw, count) + primitive["attributes"] = new_attributes + + buffers[0]["byteLength"] = len(out) + return _build_glb(gltf, bytes(out)) + + +# --------------------------------------------------------------------------- + +def _parse_glb(data: bytes) -> tuple[dict, bytes | None]: + if len(data) < 20: + raise GlbPatchError("Not a valid GLB file (too short)") + magic, version, length = struct.unpack_from(" len(data): + raise GlbPatchError("GLB file is truncated") + + json_len, json_type = struct.unpack_from(" length: + raise GlbPatchError("GLB is missing its JSON chunk") + try: + gltf = json.loads(data[20:20 + json_len].decode("utf-8")) + except (UnicodeDecodeError, ValueError) as exc: + raise GlbPatchError(f"GLB JSON chunk is invalid: {exc}") from exc + if not isinstance(gltf, dict): + raise GlbPatchError("GLB JSON chunk is not an object") + + offset = 20 + json_len + binary = None + if offset + 8 <= length: + bin_len, bin_type = struct.unpack_from(" length: + raise GlbPatchError("GLB BIN chunk is truncated") + binary = data[offset + 8:offset + 8 + bin_len] + return gltf, binary + + +def _build_glb(gltf: dict, binary: bytes) -> bytes: + json_bytes = json.dumps(gltf, separators=(",", ":")).encode("utf-8") + json_bytes += b" " * (-len(json_bytes) % 4) + binary += b"\0" * (-len(binary) % 4) + body = ( + struct.pack(" dict: + if not 0 <= mesh < len(meshes): + raise GlbPatchError(f"mesh {mesh} does not exist (GLB has {len(meshes)} meshes)") + primitives = meshes[mesh].get("primitives") or [] + if not 0 <= primitive < len(primitives): + raise GlbPatchError( + f"primitive {primitive} does not exist in mesh {mesh} " + f"({len(primitives)} primitives)" + ) + return primitives[primitive] + + +def _decode(encoded: str, count: int, label: str) -> tuple[bytes, array]: + try: + raw = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError) as exc: + raise GlbPatchError(f"{label}: invalid base64 ({exc})") from exc + if len(raw) % 4: + raise GlbPatchError(f"{label}: byte length {len(raw)} is not a multiple of 4") + values = array("f") + values.frombytes(raw) + if sys.byteorder == "big": + values.byteswap() + if len(values) != count * 3: + raise GlbPatchError(f"{label}: got {len(values)} floats, expected {count * 3} ({count} vertices x 3)") + if not all(map(math.isfinite, values)): + raise GlbPatchError(f"{label}: contains non-finite values (NaN or infinity)") + return raw, values + + +def _bounds(values: array) -> tuple[list[float], list[float]]: + if not values: + return [0.0, 0.0, 0.0], [0.0, 0.0, 0.0] + columns = [values[axis::3] for axis in range(3)] + return [min(c) for c in columns], [max(c) for c in columns] + + +def _append_accessor( + out: bytearray, + views: list, + accessors: list, + raw: bytes, + count: int, + bounds: tuple[list[float], list[float]] | None = None, +) -> int: + out.extend(b"\0" * (-len(out) % 4)) + views.append({ + "buffer": 0, + "byteOffset": len(out), + "byteLength": len(raw), + "target": _ARRAY_BUFFER, + }) + out.extend(raw) + accessor: dict = { + "bufferView": len(views) - 1, + "componentType": _FLOAT, + "count": count, + "type": "VEC3", + } + if bounds is not None: + accessor["min"], accessor["max"] = bounds + accessors.append(accessor) + return len(accessors) - 1 diff --git a/api/tests/test_optimize_patch_vertices.py b/api/tests/test_optimize_patch_vertices.py new file mode 100644 index 00000000..187a64c8 --- /dev/null +++ b/api/tests/test_optimize_patch_vertices.py @@ -0,0 +1,299 @@ +import base64 +import json +import struct +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import numpy as np +import trimesh +from fastapi import HTTPException +from PIL import Image + +from routers import optimize + + +# --------------------------------------------------------------------------- +# GLB helpers (test-side, independent from the implementation under test) +# --------------------------------------------------------------------------- + +def _read_glb(path: Path) -> tuple[dict, bytes]: + data = path.read_bytes() + magic, version, length = struct.unpack_from(" None: + json_bytes = json.dumps(gltf).encode() + json_bytes += b" " * (-len(json_bytes) % 4) + chunks = struct.pack(" np.ndarray: + accessor = gltf["accessors"][index] + view = gltf["bufferViews"][accessor["bufferView"]] + start = view.get("byteOffset", 0) + accessor.get("byteOffset", 0) + count = accessor["count"] + stride = view.get("byteStride", 12) + raw = np.frombuffer(binary, dtype=np.uint8, count=stride * (count - 1) + 12, offset=start) + rows = np.lib.stride_tricks.as_strided(raw, shape=(count, 12), strides=(stride, 1)) + return np.frombuffer(rows.copy().tobytes(), dtype=" str: + return base64.b64encode(np.asarray(array, dtype=" None: + textured = trimesh.creation.box() + uv = np.random.default_rng(0).random((len(textured.vertices), 2)) + image = Image.new("RGB", (8, 8), (200, 30, 30)) + textured.visual = trimesh.visual.TextureVisuals(uv=uv, image=image) + plain = trimesh.creation.icosphere(subdivisions=1) + scene = trimesh.Scene() + scene.add_geometry(textured, geom_name="textured") + scene.add_geometry(plain, geom_name="plain") + scene.export(str(path)) + + +def _mesh_index(gltf: dict, name: str) -> int: + return next(i for i, m in enumerate(gltf["meshes"]) if m.get("name") == name) + + +def _shared_accessor_glb(path: Path) -> None: + """Two meshes whose single primitives share one POSITION accessor.""" + positions = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=" None: + self._dir = tempfile.TemporaryDirectory() + self.workspace = Path(self._dir.name) + self.collection = self.workspace / "Default" + self.collection.mkdir() + patcher = patch.object(optimize.registry, "WORKSPACE_DIR", self.workspace) + patcher.start() + self.addCleanup(patcher.stop) + self.addCleanup(self._dir.cleanup) + + def _patch(self, path: str, patches: list[dict]): + return optimize.patch_vertices(optimize.PatchVerticesRequest(path=path, patches=patches)) + + def _assert_400(self, path: str, patches: list[dict], fragment: str) -> None: + with self.assertRaises(HTTPException) as caught: + self._patch(path, patches) + self.assertEqual(caught.exception.status_code, 400) + self.assertIn(fragment, str(caught.exception.detail)) + + # -- success ------------------------------------------------------------ + + def test_patches_positions_and_normals_keeping_texture_and_other_mesh(self) -> None: + source = self.collection / "model.glb" + _textured_two_mesh_scene(source) + gltf, binary = _read_glb(source) + textured = _mesh_index(gltf, "textured") + plain = _mesh_index(gltf, "plain") + primitive = gltf["meshes"][textured]["primitives"][0] + old_positions = _accessor_floats(gltf, binary, primitive["attributes"]["POSITION"]) + plain_prim = gltf["meshes"][plain]["primitives"][0] + plain_positions = _accessor_floats(gltf, binary, plain_prim["attributes"]["POSITION"]) + + new_positions = old_positions * 2.0 + np.array([0.5, -1.0, 3.0], dtype=" None: + source = self.collection / "shared.glb" + _shared_accessor_glb(source) + normals = np.tile(np.array([0, 0, 1], dtype=" None: + source = self.collection / "shared.glb" + _shared_accessor_glb(source) + moved = np.array([[5, 5, 5], [6, 5, 5], [5, 6, 5]], dtype=" None: + source = self.collection / "odd.glb" + _shared_accessor_glb(source) + gltf, binary = _read_glb(source) + # Make the declared buffer length not a multiple of 4 (padding follows). + binary += b"\x01\x02" + gltf["buffers"][0]["byteLength"] = len(binary) + _write_glb(source, gltf, binary) + response = self._patch("Default/odd.glb", [{ + "mesh": 0, "primitive": 0, "positions": _b64(np.ones((3, 3))), + }]) + out_gltf, _ = _read_glb(self.workspace / response["path"]) + for view in out_gltf["bufferViews"][1:]: + self.assertEqual(view["byteOffset"] % 4, 0) + + # -- errors ------------------------------------------------------------- + + def test_rejects_non_glb_input(self) -> None: + (self.collection / "mesh.obj").write_text("v 0 0 0\n") + self._assert_400("Default/mesh.obj", [{ + "mesh": 0, "primitive": 0, "positions": _b64(np.zeros((1, 3))), + }], ".glb") + + def test_rejects_empty_patch_list(self) -> None: + _shared_accessor_glb(self.collection / "shared.glb") + self._assert_400("Default/shared.glb", [], "patch") + + def test_rejects_count_mismatch(self) -> None: + _shared_accessor_glb(self.collection / "shared.glb") + self._assert_400("Default/shared.glb", [{ + "mesh": 0, "primitive": 0, "positions": _b64(np.zeros((4, 3))), + }], "expected 9") + self._assert_400("Default/shared.glb", [{ + "mesh": 0, "primitive": 0, "positions": _b64(np.zeros((3, 3))), + "normals": _b64(np.zeros((2, 3))), + }], "normals") + + def test_rejects_bad_indices(self) -> None: + _shared_accessor_glb(self.collection / "shared.glb") + self._assert_400("Default/shared.glb", [{ + "mesh": 7, "primitive": 0, "positions": _b64(np.zeros((3, 3))), + }], "mesh 7") + self._assert_400("Default/shared.glb", [{ + "mesh": 0, "primitive": 3, "positions": _b64(np.zeros((3, 3))), + }], "primitive 3") + self._assert_400("Default/shared.glb", [{ + "mesh": -1, "primitive": 0, "positions": _b64(np.zeros((3, 3))), + }], "mesh -1") + + def test_rejects_bad_base64(self) -> None: + _shared_accessor_glb(self.collection / "shared.glb") + self._assert_400("Default/shared.glb", [{ + "mesh": 0, "primitive": 0, "positions": "not base64!!", + }], "base64") + + def test_rejects_non_finite_values(self) -> None: + _shared_accessor_glb(self.collection / "shared.glb") + values = np.zeros((3, 3), dtype=" None: + source = self.collection / "draco.glb" + _shared_accessor_glb(source) + gltf, binary = _read_glb(source) + gltf["meshes"][0]["primitives"][0]["extensions"] = { + "KHR_draco_mesh_compression": {"bufferView": 0, "attributes": {"POSITION": 0}}, + } + gltf["extensionsUsed"] = ["KHR_draco_mesh_compression"] + _write_glb(source, gltf, binary) + self._assert_400("Default/draco.glb", [{ + "mesh": 0, "primitive": 0, "positions": _b64(np.zeros((3, 3))), + }], "Draco") + + def test_rejects_corrupt_glb(self) -> None: + (self.collection / "broken.glb").write_bytes(b"glTF garbage") + self._assert_400("Default/broken.glb", [{ + "mesh": 0, "primitive": 0, "positions": _b64(np.zeros((3, 3))), + }], "GLB") + + +if __name__ == "__main__": + unittest.main() From 39321fec6848f27b49c152a7110f26cc3a056601 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Thu, 12 Nov 2026 10:46:50 +0100 Subject: [PATCH 3/6] feat(sculpt): brush core with weld map, spatial hash and stroke undo 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. --- package.json | 2 +- .../generate/sculpt/SculptMesh.perf.test.ts | 92 +++ src/areas/generate/sculpt/SculptMesh.test.ts | 394 +++++++++++ src/areas/generate/sculpt/SculptMesh.ts | 633 +++++++++++++++++- src/areas/generate/sculpt/SpatialGrid.ts | 144 ++++ src/areas/generate/sculpt/testMeshes.ts | 119 ++++ src/areas/generate/sculpt/topology.ts | 67 ++ src/areas/generate/sculpt/weld.ts | 126 ++++ 8 files changed, 1564 insertions(+), 13 deletions(-) create mode 100644 src/areas/generate/sculpt/SculptMesh.perf.test.ts create mode 100644 src/areas/generate/sculpt/SculptMesh.test.ts create mode 100644 src/areas/generate/sculpt/SpatialGrid.ts create mode 100644 src/areas/generate/sculpt/testMeshes.ts create mode 100644 src/areas/generate/sculpt/topology.ts create mode 100644 src/areas/generate/sculpt/weld.ts diff --git a/package.json b/package.json index d6117ff3..69337f6f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "prepare-resources": "node scripts/download-python-embed.js", "test": "npm run test:py && npm run test:node", "test:py": "node scripts/run-pytests.mjs", - "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", + "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts src/areas/generate/sculpt/SculptMesh.test.ts src/areas/generate/sculpt/SculptMesh.perf.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", "package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder", "package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64", "lint": "eslint ." diff --git a/src/areas/generate/sculpt/SculptMesh.perf.test.ts b/src/areas/generate/sculpt/SculptMesh.perf.test.ts new file mode 100644 index 00000000..e900b8a1 --- /dev/null +++ b/src/areas/generate/sculpt/SculptMesh.perf.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { DAB_SPACING, SculptMesh } from './SculptMesh.ts' +import type { BrushKind, BrushSettings, Vec3 } from './types.ts' +import { seamSphere } from './testMeshes.ts' + +// Targets: constructor < 1.5 s, one dab over ~2k vertices < 4 ms, a grab +// strokeTo over ~10k vertices < 8 ms. Asserted with a 3× margin so slow CI +// machines do not flake; the measured timings are printed. + +function countWithin(positions: Float32Array, p: Vec3, r: number): number { + let c = 0 + for (let v = 0; v < positions.length / 3; v++) { + const d = (positions[v * 3] - p[0]) ** 2 + (positions[v * 3 + 1] - p[1]) ** 2 + (positions[v * 3 + 2] - p[2]) ** 2 + if (d < r * r) c++ + } + return c +} + +function brush(kind: BrushKind, radius: number): BrushSettings { + return { kind, radius, strength: 0.5, invert: false, symmetry: [] } +} + +test('performance on a ~1M-triangle mesh with seam duplicates', () => { + // Warm the JIT on a small mesh first. + const small = seamSphere(40, 80, 8) + const warm = new SculptMesh(small.positions, null, small.index) + for (const kind of ['inflate', 'smooth', 'flatten', 'grab'] as BrushKind[]) { + warm.beginStroke(brush(kind, 0.3), [1, 0, 0], [1, 0, 0]) + for (let i = 1; i <= 20; i++) warm.strokeTo([1, 0.01 * i, 0], [1, 0, 0]) + warm.endStroke() + } + + const big = seamSphere(500, 1000, 8) + const triangles = big.index!.length / 3 + const t0 = performance.now() + const mesh = new SculptMesh(big.positions, null, big.index) + const buildMs = performance.now() - t0 + console.log(`[sculpt perf] ${triangles} triangles, ${mesh.vertexCount} buffer / ${mesh.weldedVertexCount} welded vertices: constructor ${buildMs.toFixed(0)} ms`) + assert.ok(triangles >= 1_000_000) + assert.ok(mesh.weldedVertexCount < mesh.vertexCount) + assert.ok(buildMs < 4500, `constructor took ${buildMs.toFixed(0)} ms`) + + const a = 0.3 + const centre: Vec3 = [Math.cos(a), 0, Math.sin(a)] + const dabRadius = 0.18 + const dabVerts = countWithin(big.positions, centre, dabRadius) + for (const kind of ['inflate', 'smooth', 'flatten'] as BrushKind[]) { + const settings = brush(kind, dabRadius) + const step = dabRadius * DAB_SPACING + mesh.beginStroke(settings, centre, centre) + // Each strokeTo moves along the equator by a chord just over one spacing, + // so it places exactly one dab. + const dTheta = 2 * Math.asin((step * 1.001) / 2) + const times: number[] = [] + let touched = 0 + for (let i = 1; i <= 30; i++) { + const p: Vec3 = [Math.cos(a + dTheta * i), 0, Math.sin(a + dTheta * i)] + const t = performance.now() + const region = mesh.strokeTo(p, p) + times.push(performance.now() - t) + touched = Math.min(touched || Infinity, region.vertices.length) + } + assert.ok(touched > 2000, `every dab should touch ~2k+ vertices (min ${touched})`) + mesh.endStroke() + times.sort((x, y) => x - y) + const median = times[times.length >> 1] + console.log(`[sculpt perf] ${kind} dab over ~${dabVerts} vertices: median ${median.toFixed(2)} ms, max ${times[times.length - 1].toFixed(2)} ms`) + assert.ok(median < 12, `${kind} dab took ${median.toFixed(2)} ms`) + } + + const grabRadius = 0.4 + const grabVerts = countWithin(big.positions, centre, grabRadius) + mesh.beginStroke({ ...brush('grab', grabRadius), strength: 1 }, centre, centre) + const times: number[] = [] + for (let i = 1; i <= 20; i++) { + const t = performance.now() + mesh.strokeTo([centre[0] + 0.01 * i, centre[1], centre[2]], centre) + times.push(performance.now() - t) + } + const record = mesh.endStroke() + times.sort((x, y) => x - y) + const median = times[times.length >> 1] + console.log(`[sculpt perf] grab strokeTo over ~${grabVerts} vertices: median ${median.toFixed(2)} ms, max ${times[times.length - 1].toFixed(2)} ms`) + assert.ok(grabVerts > 10_000) + assert.ok(median < 24, `grab strokeTo took ${median.toFixed(2)} ms`) + + const t1 = performance.now() + mesh.restore(record!) + console.log(`[sculpt perf] restore of the grab stroke: ${(performance.now() - t1).toFixed(2)} ms`) +}) diff --git a/src/areas/generate/sculpt/SculptMesh.test.ts b/src/areas/generate/sculpt/SculptMesh.test.ts new file mode 100644 index 00000000..8a995f37 --- /dev/null +++ b/src/areas/generate/sculpt/SculptMesh.test.ts @@ -0,0 +1,394 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { SculptMesh } from './SculptMesh.ts' +import type { BrushKind, BrushSettings, DirtyRegion, SymmetryAxis, Vec3 } from './types.ts' +import { grid, gridMirror, seamSphere, splitCube, unindex } from './testMeshes.ts' + +function brush(kind: BrushKind, radius: number, extra: Partial = {}): BrushSettings { + return { kind, radius, strength: 1, invert: false, symmetry: [], ...extra } +} + +function vec(arr: Float32Array, v: number): Vec3 { + return [arr[v * 3], arr[v * 3 + 1], arr[v * 3 + 2]] +} + +function nearestVertex(positions: Float32Array, p: Vec3): number { + let best = -1 + let bestD = Infinity + for (let v = 0; v < positions.length / 3; v++) { + const d = (positions[v * 3] - p[0]) ** 2 + (positions[v * 3 + 1] - p[1]) ** 2 + (positions[v * 3 + 2] - p[2]) ** 2 + if (d < bestD) { bestD = d; best = v } + } + return best +} + +function bitsEqual(a: Float32Array, b: Float32Array): boolean { + const ua = new Uint32Array(a.buffer, a.byteOffset, a.length) + const ub = new Uint32Array(b.buffer, b.byteOffset, b.length) + if (ua.length !== ub.length) return false + for (let i = 0; i < ua.length; i++) if (ua[i] !== ub[i]) return false + return true +} + +/** Asserts that every buffer vertex whose position or normal differs is listed in `region`. */ +function assertRegionCovers(region: DirtyRegion, before: { p: Float32Array; n: Float32Array }, mesh: SculptMesh): void { + const listed = new Set(region.vertices) + let changed = 0 + for (let v = 0; v < mesh.vertexCount; v++) { + let diff = false + for (let k = 0; k < 3; k++) { + if (!Object.is(before.p[v * 3 + k], mesh.positions[v * 3 + k])) diff = true + if (!Object.is(before.n[v * 3 + k], mesh.normals[v * 3 + k])) diff = true + } + if (diff) { + changed++ + assert.ok(listed.has(v), `changed vertex ${v} missing from the dirty region`) + } + } + assert.ok(changed > 0, 'the stroke should change something') + let min = Infinity + let max = -Infinity + for (const v of region.vertices) { min = Math.min(min, v); max = Math.max(max, v) } + assert.equal(region.minIndex, min) + assert.equal(region.maxIndex, max) +} + +function snapshot(mesh: SculptMesh): { p: Float32Array; n: Float32Array } { + return { p: mesh.positions.slice(), n: mesh.normals.slice() } +} + +/** All buffer vertices sharing a welded position must hold identical positions and normals. */ +function assertNoTear(mesh: SculptMesh, original: Float32Array): void { + const groups = new Map() + for (let v = 0; v < mesh.vertexCount; v++) { + const key = `${original[v * 3]},${original[v * 3 + 1]},${original[v * 3 + 2]}` + const list = groups.get(key) + if (list) list.push(v) + else groups.set(key, [v]) + } + for (const list of groups.values()) { + for (const v of list.slice(1)) { + assert.deepEqual(vec(mesh.positions, v), vec(mesh.positions, list[0]), `position of duplicate ${v} tore away`) + assert.deepEqual(vec(mesh.normals, v), vec(mesh.normals, list[0]), `normal of duplicate ${v} differs`) + } + } +} + +test('weld map merges the split vertices of a cube', () => { + const cube = splitCube() + const mesh = new SculptMesh(cube.positions, null, cube.index) + assert.equal(mesh.vertexCount, 24) + assert.equal(mesh.weldedVertexCount, 8) + const soup = unindex(splitCube()) + assert.equal(new SculptMesh(soup.positions, null, null).weldedVertexCount, 8) +}) + +test('computed normals are smooth across the split faces of a corner and point outwards', () => { + const cube = splitCube() + const original = cube.positions.slice() + const mesh = new SculptMesh(cube.positions, null, cube.index) + for (let v = 0; v < 24; v++) { + const p = vec(mesh.positions, v) + const n = vec(mesh.normals, v) + assert.ok(Math.abs(Math.hypot(...n) - 1) < 1e-6) + assert.ok((n[0] * p[0] + n[1] * p[1] + n[2] * p[2]) / Math.sqrt(3) > 0.9, `normal of ${v} is not outward`) + } + assertNoTear(mesh, original) + const s = seamSphere(16, 32, 4) + const sphere = new SculptMesh(s.positions, null, s.index) + for (let v = 0; v < sphere.vertexCount; v++) { + const p = vec(sphere.positions, v) + const n = vec(sphere.normals, v) + assert.ok(n[0] * p[0] + n[1] * p[1] + n[2] * p[2] > 0.98, `sphere normal ${v} not radial`) + } +}) + +test('grab on a cube corner moves its three duplicates identically', () => { + const cube = splitCube() + const original = cube.positions.slice() + const mesh = new SculptMesh(cube.positions, null, cube.index) + mesh.beginStroke(brush('grab', 0.5), [1, 1, 1], [1, 1, 1]) + mesh.strokeTo([1.5, 1.25, 1], [1, 1, 1]) + const record = mesh.endStroke() + assert.ok(record) + let moved = 0 + for (let v = 0; v < 24; v++) { + if (original[v * 3] === 1 && original[v * 3 + 1] === 1 && original[v * 3 + 2] === 1) { + assert.deepEqual(vec(mesh.positions, v), [1.5, 1.25, 1]) + moved++ + } else { + assert.deepEqual(vec(mesh.positions, v), vec(original, v)) + } + } + assert.equal(moved, 3) + assertNoTear(mesh, original) + assert.equal(mesh.changed, true) +}) + +test('grab moves the centre by the delta and leaves vertices outside the radius untouched', () => { + const g = grid(40) + const original = g.positions.slice() + const mesh = new SculptMesh(g.positions, null, g.index) + assert.equal(mesh.changed, false) + const centre = nearestVertex(original, [0, 0, 0]) + const region = mesh.beginStroke(brush('grab', 0.3), [0, 0, 0], [0, 0, 1]) + assert.equal(region.vertices.length, 0) + mesh.strokeTo([0, 0, 0.1], [0, 0, 1]) + mesh.strokeTo([0, 0, 0.2], [0, 0, 1]) + mesh.endStroke() + assert.ok(Math.abs(mesh.positions[centre * 3 + 2] - 0.2) < 1e-6) + for (let v = 0; v < mesh.vertexCount; v++) { + const d = Math.hypot(original[v * 3], original[v * 3 + 1]) + if (d >= 0.3) assert.deepEqual(vec(mesh.positions, v), vec(original, v)) + else if (d > 0) { + const z = mesh.positions[v * 3 + 2] + assert.ok(z > 0 && z < 0.2, `vertex at distance ${d} should move partially, z=${z}`) + } + } +}) + +test('grab at half strength moves the centre half the delta', () => { + const g = grid(20) + const mesh = new SculptMesh(g.positions, null, g.index) + const centre = nearestVertex(g.positions, [0, 0, 0]) + mesh.beginStroke(brush('grab', 0.3, { strength: 0.5 }), [0, 0, 0], [0, 0, 1]) + mesh.strokeTo([0, 0, 0.2], [0, 0, 1]) + assert.ok(Math.abs(mesh.positions[centre * 3 + 2] - 0.1) < 1e-6) +}) + +test('smooth reduces a spike', () => { + const g = grid(40) + const centre = nearestVertex(g.positions, [0, 0, 0]) + g.positions[centre * 3 + 2] = 0.5 + const mesh = new SculptMesh(g.positions, null, g.index) + mesh.beginStroke(brush('smooth', 0.3), [0, 0, 0.5], [0, 0, 1]) + mesh.strokeTo([0.02, 0, 0], [0, 0, 1]) + mesh.endStroke() + assert.ok(mesh.positions[centre * 3 + 2] < 0.25, `spike still ${mesh.positions[centre * 3 + 2]}`) +}) + +test('inflate pushes a sphere outwards and invert pulls it in, without tearing seams', () => { + for (const invert of [false, true]) { + const s = seamSphere(32, 64, 4) + const original = s.positions.slice() + const mesh = new SculptMesh(s.positions, null, s.index) + const v = nearestVertex(original, [1, 0, 0]) + mesh.beginStroke(brush('inflate', 0.4, { invert }), [1, 0, 0], [1, 0, 0]) + mesh.strokeTo([1, 0.1, 0], [1, 0, 0]) + mesh.endStroke() + const r = Math.hypot(...vec(mesh.positions, v)) + if (invert) assert.ok(r < 0.99, `deflate should shrink, r=${r}`) + else assert.ok(r > 1.01, `inflate should grow, r=${r}`) + assertNoTear(mesh, original) + } +}) + +test('flatten reduces the spread of distances to the plane, invert increases it', () => { + const height = (x: number, y: number) => 0.05 * Math.sin(12 * x) * Math.cos(12 * y) + const spread = (positions: Float32Array) => { + let sum = 0 + let sq = 0 + let count = 0 + for (let v = 0; v < positions.length / 3; v++) { + if (Math.hypot(positions[v * 3], positions[v * 3 + 1]) > 0.2) continue + const z = positions[v * 3 + 2] + sum += z + sq += z * z + count++ + } + return sq / count - (sum / count) ** 2 + } + for (const invert of [false, true]) { + const g = grid(60, height) + const before = spread(g.positions) + const mesh = new SculptMesh(g.positions, null, g.index) + mesh.beginStroke(brush('flatten', 0.4, { invert, strength: 0.5 }), [0, 0, 0], [0, 0, 1]) + mesh.strokeTo([0.05, 0, 0], [0, 0, 1]) + mesh.endStroke() + const after = spread(mesh.positions) + if (invert) assert.ok(after > before * 1.1, `invert flatten should roughen: ${before} → ${after}`) + else assert.ok(after < before * 0.5, `flatten should flatten: ${before} → ${after}`) + } +}) + +test('symmetry across x gives a mirror-symmetric result for every brush', () => { + const n = 40 + const height = (x: number, y: number) => 0.3 * x * x * y + 0.08 * Math.sin(7 * y) + 0.05 * Math.cos(9 * x * x) + const kinds: BrushKind[] = ['grab', 'smooth', 'inflate', 'flatten'] + for (const kind of kinds) { + const g = grid(n, height) + const mesh = new SculptMesh(g.positions, null, g.index) + assert.ok(Math.abs(mesh.symmetryCenter[0]) < 1e-7) + const settings = brush(kind, 0.45, { symmetry: ['x'] as SymmetryAxis[], strength: 0.8 }) + // Starts near the plane so the mirrored dabs overlap. + mesh.beginStroke(settings, [0.12, 0.05, height(0.12, 0.05)], [0, 0, 1]) + if (kind === 'grab') { + mesh.strokeTo([0.3, 0.1, 0.2], [0, 0, 1]) + } else { + mesh.strokeTo([0.35, 0.3, height(0.35, 0.3)], [0, 0, 1]) + } + mesh.endStroke() + for (let v = 0; v < mesh.vertexCount; v++) { + const m = gridMirror(n, v) + const p = vec(mesh.positions, v) + const q = vec(mesh.positions, m) + assert.ok(Math.abs(p[0] + q[0]) < 1e-5 && Math.abs(p[1] - q[1]) < 1e-5 && Math.abs(p[2] - q[2]) < 1e-5, + `${kind}: vertex ${v} ${p} is not the mirror of ${m} ${q}`) + const nv = vec(mesh.normals, v) + const nm = vec(mesh.normals, m) + assert.ok(Math.abs(nv[0] + nm[0]) < 1e-4 && Math.abs(nv[1] - nm[1]) < 1e-4 && Math.abs(nv[2] - nm[2]) < 1e-4, + `${kind}: normal of ${v} is not mirrored`) + } + assert.ok(mesh.changed, `${kind} should change the mesh`) + } +}) + +test('a dab on the symmetry plane is not applied twice', () => { + const height = (x: number, y: number) => 0.1 * Math.sin(5 * y) + 0.1 * x * x + for (const kind of ['inflate', 'smooth', 'flatten'] as BrushKind[]) { + const a = grid(30, height) + const b = grid(30, height) + const plain = new SculptMesh(a.positions, null, a.index) + const mirrored = new SculptMesh(b.positions, null, b.index) + plain.beginStroke(brush(kind, 0.4), [0, 0.1, height(0, 0.1)], [0, 0, 1]) + mirrored.beginStroke(brush(kind, 0.4, { symmetry: ['x'] }), [0, 0.1, height(0, 0.1)], [0, 0, 1]) + for (let v = 0; v < plain.positions.length; v++) { + assert.ok(Math.abs(plain.positions[v] - mirrored.positions[v]) < 1e-6, `${kind}: component ${v} differs`) + } + } + // Grab on the plane: a delta along the plane is followed once, not twice. + const g = grid(30) + const mesh = new SculptMesh(g.positions, null, g.index) + const centre = nearestVertex(g.positions, [0, 0, 0]) + mesh.beginStroke(brush('grab', 0.3, { symmetry: ['x'] }), [0, 0, 0], [0, 0, 1]) + mesh.strokeTo([0.2, 0.1, 0.2], [0, 0, 1]) + assert.deepEqual(vec(mesh.positions, centre).map((c) => Math.round(c * 1e6) / 1e6), [0, 0.1, 0.2]) +}) + +test('symmetry on several axes mirrors every combination', () => { + const s = seamSphere(24, 48, 2) + const mesh = new SculptMesh(s.positions, null, s.index) + const p: Vec3 = [Math.SQRT1_2, 0.5, 0.5] + const before = s.positions.slice() + mesh.beginStroke(brush('inflate', 0.3, { symmetry: ['x', 'y', 'z'] }), p, p) + for (const sx of [1, -1]) for (const sy of [1, -1]) for (const sz of [1, -1]) { + const v = nearestVertex(before, [p[0] * sx, p[1] * sy, p[2] * sz]) + assert.ok(Math.hypot(...vec(mesh.positions, v)) > 1.005, `octant ${sx},${sy},${sz} not inflated`) + } +}) + +test('undo restores positions and normals bit-exactly and the inverse redoes', () => { + const s = seamSphere(32, 64, 4) + const mesh = new SculptMesh(s.positions, null, s.index) + const before = snapshot(mesh) + mesh.beginStroke(brush('inflate', 0.5, { symmetry: ['x'] }), [1, 0, 0], [1, 0, 0]) + mesh.strokeTo([0.9, 0.43, 0], [1, 0, 0]) + mesh.strokeTo([0.7, 0.7, 0], [1, 0, 0]) + const record = mesh.endStroke() + assert.ok(record) + const after = snapshot(mesh) + assert.ok(!bitsEqual(before.p, after.p)) + + const undo = mesh.restore(record) + assert.ok(bitsEqual(mesh.positions, before.p), 'positions not restored') + assert.ok(bitsEqual(mesh.normals, before.n), 'normals not restored') + assert.ok(undo.region.vertices.length > 0) + + const redo = mesh.restore(undo.inverse) + assert.ok(bitsEqual(mesh.positions, after.p), 'positions not redone') + assert.ok(bitsEqual(mesh.normals, after.n), 'normals not redone') + + mesh.restore(redo.inverse) + assert.ok(bitsEqual(mesh.positions, before.p)) + + // The spatial index follows restore: a grab right after undo still finds the vertices. + const v = nearestVertex(before.p, [1, 0, 0]) + mesh.beginStroke(brush('grab', 0.2), [1, 0, 0], [1, 0, 0]) + mesh.strokeTo([1.2, 0, 0], [1, 0, 0]) + assert.ok(Math.abs(mesh.positions[v * 3] - 1.2) < 1e-6) +}) + +test('grab undo after a far move restores everything', () => { + const g = grid(30) + const mesh = new SculptMesh(g.positions, null, g.index) + const before = snapshot(mesh) + mesh.beginStroke(brush('grab', 0.3), [0, 0, 0], [0, 0, 1]) + mesh.strokeTo([5, 5, 5], [0, 0, 1]) + const record = mesh.endStroke() + assert.ok(record) + mesh.restore(record) + assert.ok(bitsEqual(mesh.positions, before.p)) + assert.ok(bitsEqual(mesh.normals, before.n)) +}) + +test('the spatial index follows vertices moved far by grab', () => { + const g = grid(30) + const mesh = new SculptMesh(g.positions, null, g.index) + const centre = nearestVertex(g.positions, [0, 0, 0]) + mesh.beginStroke(brush('grab', 0.3), [0, 0, 0], [0, 0, 1]) + mesh.strokeTo([0, 0, 3], [0, 0, 1]) + mesh.endStroke() + // Grab again at the new location: the moved centre must be found there. + mesh.beginStroke(brush('grab', 0.1), [0, 0, 3], [0, 0, 1]) + mesh.strokeTo([0, 0, 4], [0, 0, 1]) + mesh.endStroke() + assert.ok(Math.abs(mesh.positions[centre * 3 + 2] - 4) < 1e-5) +}) + +test('the dirty region lists every changed buffer vertex', () => { + const kinds: BrushKind[] = ['grab', 'smooth', 'inflate', 'flatten'] + for (const kind of kinds) { + const s = seamSphere(32, 64, 4) + const mesh = new SculptMesh(s.positions, null, s.index) + let before = snapshot(mesh) + const settings = brush(kind, 0.35, { symmetry: ['z'] }) + const start = mesh.beginStroke(settings, [1, 0, 0], [1, 0, 0]) + if (kind !== 'grab') assertRegionCovers(start, before, mesh) + before = snapshot(mesh) + const region = mesh.strokeTo(kind === 'grab' ? [1.2, 0.1, 0.05] : [0.8, 0.6, 0], [1, 0, 0]) + assertRegionCovers(region, before, mesh) + const record = mesh.endStroke() + assert.ok(record) + before = snapshot(mesh) + const { region: undoRegion } = mesh.restore(record) + assertRegionCovers(undoRegion, before, mesh) + } +}) + +test('a stroke that touches nothing returns no record', () => { + const g = grid(10) + const mesh = new SculptMesh(g.positions, null, g.index) + const region = mesh.beginStroke(brush('inflate', 0.1), [10, 10, 10], [0, 0, 1]) + assert.equal(region.vertices.length, 0) + mesh.strokeTo([10, 10.5, 10], [0, 0, 1]) + assert.equal(mesh.endStroke(), null) + assert.equal(mesh.changed, false) +}) + +test('provided normals are kept until a stroke recomputes them, and are written to', () => { + const g = grid(10) + const normals = new Float32Array(g.positions.length) + for (let v = 0; v < normals.length / 3; v++) normals[v * 3 + 2] = 1 + const mesh = new SculptMesh(g.positions, normals, g.index) + assert.equal(mesh.normals, normals) + assert.equal(mesh.positions, g.positions) + mesh.beginStroke(brush('grab', 0.5), [0, 0, 0], [0, 0, 1]) + mesh.strokeTo([0, 0, 0.3], [0, 0, 1]) + const v = nearestVertex(g.positions, [0.2, 0, 0.2]) + assert.ok(normals[v * 3] > 0.05, 'normal on the slope of the bump should tilt') +}) + +test('Uint16 indices and dab spacing along a long stroke', () => { + const g = grid(30) + const index = Uint16Array.from(g.index!) + const mesh = new SculptMesh(g.positions, null, index) + const before = g.positions.slice() + mesh.beginStroke(brush('inflate', 0.1), [-0.8, 0, 0], [0, 0, 1]) + mesh.strokeTo([0.8, 0, 0], [0, 0, 1]) + // Dabs are spaced along the segment: every vertex on the path rose. + for (const x of [-0.5, -0.2, 0, 0.3, 0.6]) { + const v = nearestVertex(before, [x, 0, 0]) + assert.ok(mesh.positions[v * 3 + 2] > 0.001, `no dab near x=${x}`) + } +}) diff --git a/src/areas/generate/sculpt/SculptMesh.ts b/src/areas/generate/sculpt/SculptMesh.ts index ead4f87d..558496b6 100644 --- a/src/areas/generate/sculpt/SculptMesh.ts +++ b/src/areas/generate/sculpt/SculptMesh.ts @@ -1,6 +1,9 @@ // Sculpt core: brushes applied in place on a geometry's position/normal arrays. -// CONTRACT STUB — the public surface below is fixed; the implementation is -// filled in separately (see tasks/todo.md, #432/#433). +// +// Everything works on "welded" vertices: buffer vertices closer than the weld +// epsilon (UV-seam duplicates, triangle-soup corners) form one logical vertex, +// and every write goes to all its duplicates so the surface never tears. +// Pure typed-array code, no three.js, so it runs under node --test. import type { BrushSettings, @@ -9,6 +12,44 @@ import type { StrokeRecord, Vec3, } from './types' +import { SpatialGrid } from './SpatialGrid' +import { type Csr, vertexFaces, vertexNeighbours, weldedTriangles } from './topology' +import { buildWeldMap } from './weld' + +/** Inflate displacement per dab at the brush centre, as a fraction of the radius (× strength). */ +export const INFLATE_RATE = 0.05 +/** Dab spacing along a stroke, as a fraction of the radius. */ +export const DAB_SPACING = 0.25 +/** Upper bound on dabs placed by one strokeTo call (the spacing grows past it). */ +const MAX_DABS_PER_CALL = 256 + +/** Brush falloff from a squared distance: 1 at the centre, smoothly 0 at the radius. */ +export function falloff(dist2: number, radius2: number): number { + if (!(dist2 < radius2)) return 0 + const t = 1 - dist2 / radius2 + return t * t +} + +/** + * Sign triples (±1 per axis) of every mirror combination for the given axes, + * identity first: [] → 1 copy, ['x'] → 2, ['x','y','z'] → 8 (like Blender). + */ +export function mirrorSigns(axes: readonly string[]): Float64Array { + const flags = [axes.includes('x'), axes.includes('y'), axes.includes('z')] + const used = [0, 1, 2].filter((a) => flags[a]) + const total = 1 << used.length + const out = new Float64Array(total * 3).fill(1) + for (let m = 0; m < total; m++) { + used.forEach((axis, bit) => { + if (m & (1 << bit)) out[m * 3 + axis] = -1 + }) + } + return out +} + +function emptyRegion(): DirtyRegion { + return { vertices: new Uint32Array(0), minIndex: 0, maxIndex: -1 } +} export class SculptMesh { /** The array passed to the constructor, mutated in place. */ @@ -18,6 +59,56 @@ export class SculptMesh { readonly vertexCount: number /** Bounding-box centre at construction: the symmetry planes pass through it. */ readonly symmetryCenter: Vec3 + /** Number of logical (welded) vertices the brushes act on. */ + readonly weldedVertexCount: number + + // Topology (welded space). + private readonly weldOf: Uint32Array + private readonly dupStart: Uint32Array + private readonly dupList: Uint32Array + private readonly tri: Uint32Array + private readonly faces: Csr + private readonly adj: Csr + /** Welded positions, kept in sync with the buffer (the representative's value). */ + private readonly wpos: Float32Array + private readonly grid: SpatialGrid + + // Per-dab accumulation over the mirrored copies of a dab. + private readonly accW: Float64Array + private readonly accMax: Float64Array + private readonly accD: Float64Array + private readonly groupMark: Uint32Array + private readonly groupList: Int32Array + private groupId = 0 + private readonly movedList: Int32Array + private readonly ringMark: Uint32Array + private readonly ringList: Int32Array + private ringId = 0 + + // Dirty welded vertices of the current public call. + private readonly callMark: Uint32Array + private readonly callList: Int32Array + private callId = 0 + private callCount = 0 + + // Undo record of the current stroke (first touch of each buffer vertex). + private readonly strokeMark: Uint32Array + private strokeId = 0 + private recVerts = new Uint32Array(0) + private recPos = new Float32Array(0) + private recNrm = new Float32Array(0) + private recCount = 0 + + // Current stroke. + private settings: BrushSettings | null = null + private mirrors: Float64Array = new Float64Array([1, 1, 1]) + private lastX = 0 + private lastY = 0 + private lastZ = 0 + private grabIds = new Int32Array(0) + private grabG = new Float32Array(0) + + private isChanged = false /** * Builds the weld map (UV-seam duplicates), vertex adjacency and a spatial @@ -30,8 +121,59 @@ export class SculptMesh { index: Uint32Array | Uint16Array | null, options?: SculptMeshOptions, ) { - void positions; void normals; void index; void options - throw new Error('SculptMesh: not implemented') + const n = Math.floor(positions.length / 3) + if (normals && normals.length < n * 3) throw new RangeError('SculptMesh: normals shorter than positions') + this.positions = positions + this.vertexCount = n + + const min = [Infinity, Infinity, Infinity] + const max = [-Infinity, -Infinity, -Infinity] + for (let i = 0; i < n * 3; i += 3) { + for (let k = 0; k < 3; k++) { + const v = positions[i + k] + if (v < min[k]) min[k] = v + if (v > max[k]) max[k] = v + } + } + if (n === 0) { min.fill(0); max.fill(0) } + this.symmetryCenter = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2] + const diag = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) + + const weld = buildWeldMap(positions, n, options?.weldEpsilon ?? diag * 1e-6, [min[0], min[1], min[2]]) + const W = weld.count + this.weldedVertexCount = W + this.weldOf = weld.weldOf + this.dupStart = weld.dupStart + this.dupList = weld.dupList + + this.wpos = new Float32Array(W * 3) + for (let w = 0; w < W; w++) { + const r = this.dupList[this.dupStart[w]] * 3 + this.wpos[w * 3] = positions[r] + this.wpos[w * 3 + 1] = positions[r + 1] + this.wpos[w * 3 + 2] = positions[r + 2] + } + + this.tri = weldedTriangles(index, n, this.weldOf) + this.faces = vertexFaces(this.tri, W) + this.adj = vertexNeighbours(this.tri, this.faces, W) + + this.normals = normals ?? new Float32Array(n * 3) + if (!normals) this.computeAllNormals() + + this.grid = new SpatialGrid(this.wpos, W, min, max, diag / 64) + + this.accW = new Float64Array(W) + this.accMax = new Float64Array(W) + this.accD = new Float64Array(W * 3) + this.groupMark = new Uint32Array(W) + this.groupList = new Int32Array(W) + this.movedList = new Int32Array(W) + this.ringMark = new Uint32Array(W) + this.ringList = new Int32Array(W) + this.callMark = new Uint32Array(W) + this.callList = new Int32Array(W) + this.strokeMark = new Uint32Array(W) } /** @@ -39,8 +181,20 @@ export class SculptMesh { * radius and their weights are captured here and stay fixed for the stroke. */ beginStroke(settings: BrushSettings, point: Vec3, normal: Vec3): DirtyRegion { - void settings; void point; void normal - throw new Error('SculptMesh: not implemented') + this.settings = { ...settings, symmetry: [...settings.symmetry] } + this.mirrors = mirrorSigns(settings.symmetry) + this.strokeId++ + this.recCount = 0 + this.beginCall() + this.lastX = point[0] + this.lastY = point[1] + this.lastZ = point[2] + if (settings.kind === 'grab') { + this.captureGrab(point) + return emptyRegion() + } + this.dab(point[0], point[1], point[2], normal) + return this.takeRegion() } /** @@ -53,13 +207,58 @@ export class SculptMesh { * Positions, normals of the affected region, and weld duplicates are updated. */ strokeTo(point: Vec3, normal: Vec3): DirtyRegion { - void point; void normal - throw new Error('SculptMesh: not implemented') + const s = this.settings + if (!s) return emptyRegion() + this.beginCall() + const dx = point[0] - this.lastX + const dy = point[1] - this.lastY + const dz = point[2] - this.lastZ + if (s.kind === 'grab') { + this.lastX = point[0] + this.lastY = point[1] + this.lastZ = point[2] + this.moveGrab(dx * s.strength, dy * s.strength, dz * s.strength) + return this.takeRegion() + } + const length = Math.hypot(dx, dy, dz) + let spacing = Math.max(s.radius * DAB_SPACING, 1e-12) + if (!(length >= spacing)) return emptyRegion() + let steps = Math.floor(length / spacing) + if (steps > MAX_DABS_PER_CALL) { + steps = MAX_DABS_PER_CALL + spacing = length / steps + } + const ux = dx / length + const uy = dy / length + const uz = dz / length + const x0 = this.lastX + const y0 = this.lastY + const z0 = this.lastZ + for (let i = 1; i <= steps; i++) { + const t = i * spacing + this.dab(x0 + ux * t, y0 + uy * t, z0 + uz * t, normal) + } + this.lastX = x0 + ux * steps * spacing + this.lastY = y0 + uy * steps * spacing + this.lastZ = z0 + uz * steps * spacing + return this.takeRegion() } /** Ends the stroke. Returns its undo record, or null if nothing moved. */ endStroke(): StrokeRecord | null { - throw new Error('SculptMesh: not implemented') + this.settings = null + this.grabIds = new Int32Array(0) + this.grabG = new Float32Array(0) + const count = this.recCount + this.recCount = 0 + // Invalidate the first-touch marks so a later touch records again. + this.strokeId++ + if (count === 0) return null + return { + vertices: this.recVerts.slice(0, count), + positions: this.recPos.slice(0, count * 3), + normals: this.recNrm.slice(0, count * 3), + } } /** @@ -67,12 +266,422 @@ export class SculptMesh { * record of what was overwritten, so undo and redo are the same call. */ restore(record: StrokeRecord): { region: DirtyRegion; inverse: StrokeRecord } { - void record - throw new Error('SculptMesh: not implemented') + this.beginCall() + const { positions, normals, weldOf } = this + const m = record.vertices.length + const inverse: StrokeRecord = { + vertices: record.vertices.slice(), + positions: new Float32Array(m * 3), + normals: new Float32Array(m * 3), + } + let minIndex = Infinity + let maxIndex = -1 + for (let i = 0; i < m; i++) { + const b = record.vertices[i] + if (b < minIndex) minIndex = b + if (b > maxIndex) maxIndex = b + for (let k = 0; k < 3; k++) { + inverse.positions[i * 3 + k] = positions[b * 3 + k] + positions[b * 3 + k] = record.positions[i * 3 + k] + inverse.normals[i * 3 + k] = normals[b * 3 + k] + normals[b * 3 + k] = record.normals[i * 3 + k] + } + this.markDirty(weldOf[b]) + } + for (let i = 0; i < this.callCount; i++) { + const w = this.callList[i] + const r = this.dupList[this.dupStart[w]] * 3 + this.wpos[w * 3] = positions[r] + this.wpos[w * 3 + 1] = positions[r + 1] + this.wpos[w * 3 + 2] = positions[r + 2] + this.grid.update(w) + } + if (m > 0) this.isChanged = true + const region: DirtyRegion = m > 0 + ? { vertices: record.vertices.slice(), minIndex, maxIndex } + : emptyRegion() + return { region, inverse } } /** True once any stroke or restore changed the mesh since construction. */ get changed(): boolean { - throw new Error('SculptMesh: not implemented') + return this.isChanged + } + + // ── Brushes ──────────────────────────────────────────────────────────── + + /** Grab: captures the vertices under the (mirrored) brush and their per-axis follow factors. */ + private captureGrab(point: Vec3): void { + const s = this.settings! + const gid = this.nextGroup() + let count = 0 + const { accW, accMax, accD, groupMark, groupList } = this + const r2 = s.radius * s.radius + const mirrors = this.mirrors + for (let m = 0; m < mirrors.length; m += 3) { + const sx = mirrors[m], sy = mirrors[m + 1], sz = mirrors[m + 2] + const hits = this.grid.query(this.mirrorX(point[0], sx), this.mirrorY(point[1], sy), this.mirrorZ(point[2], sz), s.radius) + const ids = this.grid.resultIds + const dist = this.grid.resultDist2 + for (let i = 0; i < hits; i++) { + const wt = falloff(dist[i], r2) + if (wt <= 0) continue + const w = ids[i] + if (groupMark[w] !== gid) { + groupMark[w] = gid + groupList[count++] = w + accW[w] = 0 + accMax[w] = 0 + accD[w * 3] = accD[w * 3 + 1] = accD[w * 3 + 2] = 0 + } + accW[w] += wt + if (wt > accMax[w]) accMax[w] = wt + accD[w * 3] += wt * sx + accD[w * 3 + 1] += wt * sy + accD[w * 3 + 2] += wt * sz + } + } + // Overlapping mirrored copies are blended (weighted mean scaled by the + // strongest weight), so a vertex near the plane is never pulled twice. + this.grabIds = groupList.slice(0, count) + this.grabG = new Float32Array(count * 3) + for (let i = 0; i < count; i++) { + const w = groupList[i] + const f = accMax[w] / accW[w] + this.grabG[i * 3] = accD[w * 3] * f + this.grabG[i * 3 + 1] = accD[w * 3 + 1] * f + this.grabG[i * 3 + 2] = accD[w * 3 + 2] * f + } + } + + private moveGrab(dx: number, dy: number, dz: number): void { + if (dx === 0 && dy === 0 && dz === 0) return + const { grabIds, grabG } = this + let moved = 0 + for (let i = 0; i < grabIds.length; i++) { + if (this.moveVertex(grabIds[i], grabG[i * 3] * dx, grabG[i * 3 + 1] * dy, grabG[i * 3 + 2] * dz)) { + this.movedList[moved++] = grabIds[i] + } + } + this.finishMove(moved) + } + + /** + * One dab of smooth / inflate / flatten at (cx, cy, cz) and its mirrored + * copies. Displacements are computed from the pre-dab state, then applied. + */ + private dab(cx: number, cy: number, cz: number, hitNormal: Vec3): void { + const s = this.settings! + const r = s.radius + if (!(r > 0) || !(s.strength > 0)) return + const r2 = r * r + const kind = s.kind + const strength = s.strength + const sign = s.invert ? -1 : 1 + const inflateStep = strength * r * INFLATE_RATE * sign + const { accW, accMax, accD, groupMark, groupList, wpos, normals, dupList, dupStart } = this + const adjStart = this.adj.start + const adjList = this.adj.list + const gid = this.nextGroup() + let count = 0 + const mirrors = this.mirrors + + for (let m = 0; m < mirrors.length; m += 3) { + const sx = mirrors[m], sy = mirrors[m + 1], sz = mirrors[m + 2] + const hits = this.grid.query(this.mirrorX(cx, sx), this.mirrorY(cy, sy), this.mirrorZ(cz, sz), r) + const ids = this.grid.resultIds + const dist = this.grid.resultDist2 + + // Flatten: plane through the weighted mean point, along the weighted mean normal. + let px = 0, py = 0, pz = 0, nx = 0, ny = 0, nz = 0 + if (kind === 'flatten') { + let sum = 0 + for (let i = 0; i < hits; i++) { + const wt = falloff(dist[i], r2) + if (wt <= 0) continue + const w = ids[i] + const b = dupList[dupStart[w]] * 3 + sum += wt + px += wt * wpos[w * 3] + py += wt * wpos[w * 3 + 1] + pz += wt * wpos[w * 3 + 2] + nx += wt * normals[b] + ny += wt * normals[b + 1] + nz += wt * normals[b + 2] + } + if (sum <= 0) continue + px /= sum + py /= sum + pz /= sum + let len = Math.hypot(nx, ny, nz) + if (!(len > 1e-12)) { + nx = hitNormal[0] * sx + ny = hitNormal[1] * sy + nz = hitNormal[2] * sz + len = Math.hypot(nx, ny, nz) + if (!(len > 1e-12)) continue + } + nx /= len + ny /= len + nz /= len + } + + for (let i = 0; i < hits; i++) { + const wt = falloff(dist[i], r2) + if (wt <= 0) continue + const w = ids[i] + const o = w * 3 + let dx = 0, dy = 0, dz = 0 + if (kind === 'smooth') { + const a = adjStart[w] + const e = adjStart[w + 1] + if (e > a) { + let ax = 0, ay = 0, az = 0 + for (let k = a; k < e; k++) { + const u = adjList[k] * 3 + ax += wpos[u] + ay += wpos[u + 1] + az += wpos[u + 2] + } + const inv = 1 / (e - a) + dx = (ax * inv - wpos[o]) * strength + dy = (ay * inv - wpos[o + 1]) * strength + dz = (az * inv - wpos[o + 2]) * strength + } + } else if (kind === 'inflate') { + const b = dupList[dupStart[w]] * 3 + dx = normals[b] * inflateStep + dy = normals[b + 1] * inflateStep + dz = normals[b + 2] * inflateStep + } else { + const d = ((wpos[o] - px) * nx + (wpos[o + 1] - py) * ny + (wpos[o + 2] - pz) * nz) * strength * sign + dx = -d * nx + dy = -d * ny + dz = -d * nz + } + if (groupMark[w] !== gid) { + groupMark[w] = gid + groupList[count++] = w + accW[w] = 0 + accMax[w] = 0 + accD[o] = accD[o + 1] = accD[o + 2] = 0 + } + accW[w] += wt + if (wt > accMax[w]) accMax[w] = wt + accD[o] += wt * dx + accD[o + 1] += wt * dy + accD[o + 2] += wt * dz + } + } + + // Blend the mirrored copies: weighted mean of their displacements, scaled + // by the strongest weight. Away from the planes only one copy reaches a + // vertex (plain weight × displacement); on a plane the copies average out + // instead of adding up. + let moved = 0 + for (let i = 0; i < count; i++) { + const w = groupList[i] + const f = accMax[w] / accW[w] + if (this.moveVertex(w, accD[w * 3] * f, accD[w * 3 + 1] * f, accD[w * 3 + 2] * f)) this.movedList[moved++] = w + } + this.finishMove(moved) + } + + private mirrorX(v: number, s: number): number { + return s > 0 ? v : 2 * this.symmetryCenter[0] - v + } + + private mirrorY(v: number, s: number): number { + return s > 0 ? v : 2 * this.symmetryCenter[1] - v + } + + private mirrorZ(v: number, s: number): number { + return s > 0 ? v : 2 * this.symmetryCenter[2] - v + } + + // ── Vertex updates ───────────────────────────────────────────────────── + + /** Moves welded vertex `w` by d (all duplicates). Returns false when the float32 position does not change. */ + private moveVertex(w: number, dx: number, dy: number, dz: number): boolean { + const o = w * 3 + const { wpos } = this + const x = Math.fround(wpos[o] + dx) + const y = Math.fround(wpos[o + 1] + dy) + const z = Math.fround(wpos[o + 2] + dz) + if (x === wpos[o] && y === wpos[o + 1] && z === wpos[o + 2]) return false + this.touch(w) + wpos[o] = x + wpos[o + 1] = y + wpos[o + 2] = z + const { positions, dupList } = this + for (let k = this.dupStart[w]; k < this.dupStart[w + 1]; k++) { + const b = dupList[k] * 3 + positions[b] = x + positions[b + 1] = y + positions[b + 2] = z + } + this.grid.update(w) + this.markDirty(w) + return true + } + + /** After `moved` vertices (movedList) moved: recompute normals of them and their 1-ring. */ + private finishMove(moved: number): void { + if (moved === 0) return + this.isChanged = true + const rid = ++this.ringId + const { ringMark, ringList, movedList } = this + const adjStart = this.adj.start + const adjList = this.adj.list + let count = 0 + for (let i = 0; i < moved; i++) { + const w = movedList[i] + if (ringMark[w] !== rid) { ringMark[w] = rid; ringList[count++] = w } + for (let k = adjStart[w]; k < adjStart[w + 1]; k++) { + const u = adjList[k] + if (ringMark[u] !== rid) { ringMark[u] = rid; ringList[count++] = u } + } + } + for (let i = 0; i < count; i++) this.updateNormal(ringList[i]) + } + + /** Area-weighted smooth normal of welded vertex `w`, written to all its duplicates. */ + private updateNormal(w: number): void { + const { tri, wpos } = this + const fStart = this.faces.start + const fList = this.faces.list + let sx = 0, sy = 0, sz = 0 + for (let k = fStart[w]; k < fStart[w + 1]; k++) { + const f = fList[k] * 3 + const a = tri[f] * 3 + const b = tri[f + 1] * 3 + const c = tri[f + 2] * 3 + const ux = wpos[b] - wpos[a], uy = wpos[b + 1] - wpos[a + 1], uz = wpos[b + 2] - wpos[a + 2] + const vx = wpos[c] - wpos[a], vy = wpos[c + 1] - wpos[a + 1], vz = wpos[c + 2] - wpos[a + 2] + sx += uy * vz - uz * vy + sy += uz * vx - ux * vz + sz += ux * vy - uy * vx + } + const len = Math.hypot(sx, sy, sz) + if (!(len > 0)) return + this.touch(w) + const nx = sx / len, ny = sy / len, nz = sz / len + const { normals, dupList } = this + for (let k = this.dupStart[w]; k < this.dupStart[w + 1]; k++) { + const b = dupList[k] * 3 + normals[b] = nx + normals[b + 1] = ny + normals[b + 2] = nz + } + this.markDirty(w) + } + + private computeAllNormals(): void { + const { tri, wpos, normals, dupList, dupStart } = this + const W = this.weldedVertexCount + const acc = new Float64Array(W * 3) + for (let f = 0; f < tri.length; f += 3) { + const a = tri[f] * 3 + const b = tri[f + 1] * 3 + const c = tri[f + 2] * 3 + const ux = wpos[b] - wpos[a], uy = wpos[b + 1] - wpos[a + 1], uz = wpos[b + 2] - wpos[a + 2] + const vx = wpos[c] - wpos[a], vy = wpos[c + 1] - wpos[a + 1], vz = wpos[c + 2] - wpos[a + 2] + const nx = uy * vz - uz * vy + const ny = uz * vx - ux * vz + const nz = ux * vy - uy * vx + acc[a] += nx; acc[a + 1] += ny; acc[a + 2] += nz + acc[b] += nx; acc[b + 1] += ny; acc[b + 2] += nz + acc[c] += nx; acc[c + 1] += ny; acc[c + 2] += nz + } + for (let w = 0; w < W; w++) { + const o = w * 3 + const len = Math.hypot(acc[o], acc[o + 1], acc[o + 2]) + const inv = len > 0 ? 1 / len : 0 + for (let k = dupStart[w]; k < dupStart[w + 1]; k++) { + const b = dupList[k] * 3 + normals[b] = acc[o] * inv + normals[b + 1] = acc[o + 1] * inv + normals[b + 2] = acc[o + 2] * inv + } + } + } + + // ── Bookkeeping ──────────────────────────────────────────────────────── + + private nextGroup(): number { + return ++this.groupId + } + + private beginCall(): void { + this.callId++ + this.callCount = 0 + } + + private markDirty(w: number): void { + if (this.callMark[w] === this.callId) return + this.callMark[w] = this.callId + this.callList[this.callCount++] = w + } + + /** Records the original state of all duplicates of `w`, the first time the stroke touches it. */ + private touch(w: number): void { + if (this.strokeMark[w] === this.strokeId) return + this.strokeMark[w] = this.strokeId + const start = this.dupStart[w] + const end = this.dupStart[w + 1] + const needed = this.recCount + (end - start) + if (needed > this.recVerts.length) this.growRecord(needed) + const { positions, normals, recVerts, recPos, recNrm, dupList } = this + let c = this.recCount + for (let k = start; k < end; k++) { + const b = dupList[k] + recVerts[c] = b + recPos[c * 3] = positions[b * 3] + recPos[c * 3 + 1] = positions[b * 3 + 1] + recPos[c * 3 + 2] = positions[b * 3 + 2] + recNrm[c * 3] = normals[b * 3] + recNrm[c * 3 + 1] = normals[b * 3 + 1] + recNrm[c * 3 + 2] = normals[b * 3 + 2] + c++ + } + this.recCount = c + } + + private growRecord(needed: number): void { + const size = Math.max(needed, this.recVerts.length * 2, 1024) + const verts = new Uint32Array(size) + verts.set(this.recVerts.subarray(0, this.recCount)) + const pos = new Float32Array(size * 3) + pos.set(this.recPos.subarray(0, this.recCount * 3)) + const nrm = new Float32Array(size * 3) + nrm.set(this.recNrm.subarray(0, this.recCount * 3)) + this.recVerts = verts + this.recPos = pos + this.recNrm = nrm + } + + /** Expands the welded vertices dirtied by the current call to buffer indices. */ + private takeRegion(): DirtyRegion { + const { callList, dupStart, dupList } = this + let total = 0 + for (let i = 0; i < this.callCount; i++) { + const w = callList[i] + total += dupStart[w + 1] - dupStart[w] + } + if (total === 0) return emptyRegion() + const vertices = new Uint32Array(total) + let minIndex = Infinity + let maxIndex = -1 + let c = 0 + for (let i = 0; i < this.callCount; i++) { + const w = callList[i] + for (let k = dupStart[w]; k < dupStart[w + 1]; k++) { + const b = dupList[k] + vertices[c++] = b + if (b < minIndex) minIndex = b + if (b > maxIndex) maxIndex = b + } + } + return { vertices, minIndex, maxIndex } } } diff --git a/src/areas/generate/sculpt/SpatialGrid.ts b/src/areas/generate/sculpt/SpatialGrid.ts new file mode 100644 index 00000000..cd8a4840 --- /dev/null +++ b/src/areas/generate/sculpt/SpatialGrid.ts @@ -0,0 +1,144 @@ +// Uniform dense grid over the welded vertices, for brush sphere queries. +// +// The grid covers the mesh bounding box plus a margin; vertices outside it +// (e.g. dragged far away by grab) are clamped into the border cells, and +// queries clamp their cell range the same way, so a query is always exact +// whatever the radius or how far vertices moved. Cells hold doubly linked +// lists so a moved vertex is re-bucketed in O(1). + +const MAX_CELLS = 1 << 21 + +export class SpatialGrid { + /** Query results: welded vertex ids and squared distances, valid up to the count returned by query(). */ + resultIds: Int32Array + resultDist2: Float64Array + + private readonly pos: Float32Array + private readonly ox: number + private readonly oy: number + private readonly oz: number + private readonly inv: number + private readonly cell: number + private readonly nx: number + private readonly ny: number + private readonly nz: number + private readonly head: Int32Array + private readonly next: Int32Array + private readonly prev: Int32Array + private readonly cellOf: Int32Array + + /** + * @param pos welded positions (xyz per welded vertex), read live by queries. + * @param cellSize target cell edge; grown if the grid would get too large. + */ + constructor(pos: Float32Array, count: number, min: readonly number[], max: readonly number[], cellSize: number) { + this.pos = pos + const ext = Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2], 0) + let cell = cellSize > 0 && Number.isFinite(cellSize) ? cellSize : ext > 0 ? ext / 64 : 1 + const margin = Math.max(ext * 0.125, cell) + const dims = (c: number): [number, number, number] => [ + Math.max(1, Math.ceil((max[0] - min[0] + 2 * margin) / c)), + Math.max(1, Math.ceil((max[1] - min[1] + 2 * margin) / c)), + Math.max(1, Math.ceil((max[2] - min[2] + 2 * margin) / c)), + ] + let d = dims(cell) + while (d[0] * d[1] * d[2] > MAX_CELLS) { + cell *= 1.25 + d = dims(cell) + } + this.cell = cell + this.inv = 1 / cell + this.ox = min[0] - margin + this.oy = min[1] - margin + this.oz = min[2] - margin + ;[this.nx, this.ny, this.nz] = d + this.head = new Int32Array(d[0] * d[1] * d[2]).fill(-1) + this.next = new Int32Array(count) + this.prev = new Int32Array(count) + this.cellOf = new Int32Array(count) + this.resultIds = new Int32Array(1024) + this.resultDist2 = new Float64Array(1024) + for (let w = 0; w < count; w++) this.link(w, this.cellAt(pos[w * 3], pos[w * 3 + 1], pos[w * 3 + 2])) + } + + private axis(v: number, o: number, n: number): number { + const i = Math.floor((v - o) * this.inv) + // NaN (degenerate input) lands in cell 0. + return i > 0 ? (i < n ? i : n - 1) : 0 + } + + private cellAt(x: number, y: number, z: number): number { + return (this.axis(z, this.oz, this.nz) * this.ny + this.axis(y, this.oy, this.ny)) * this.nx + this.axis(x, this.ox, this.nx) + } + + private link(w: number, c: number): void { + const h = this.head[c] + this.next[w] = h + this.prev[w] = -1 + if (h !== -1) this.prev[h] = w + this.head[c] = w + this.cellOf[w] = c + } + + /** Re-buckets welded vertex `w` after its position changed. */ + update(w: number): void { + const c = this.cellAt(this.pos[w * 3], this.pos[w * 3 + 1], this.pos[w * 3 + 2]) + const old = this.cellOf[w] + if (c === old) return + const p = this.prev[w] + const n = this.next[w] + if (p !== -1) this.next[p] = n + else this.head[old] = n + if (n !== -1) this.prev[n] = p + this.link(w, c) + } + + /** Collects the welded vertices strictly inside the sphere into resultIds/resultDist2; returns their count. */ + query(x: number, y: number, z: number, radius: number): number { + if (!(radius > 0)) return 0 + const r2 = radius * radius + const x0 = this.axis(x - radius, this.ox, this.nx) + const x1 = this.axis(x + radius, this.ox, this.nx) + const y0 = this.axis(y - radius, this.oy, this.ny) + const y1 = this.axis(y + radius, this.oy, this.ny) + const z0 = this.axis(z - radius, this.oz, this.nz) + const z1 = this.axis(z + radius, this.oz, this.nz) + const pos = this.pos + const head = this.head + const next = this.next + let ids = this.resultIds + let dist = this.resultDist2 + let count = 0 + for (let cz = z0; cz <= z1; cz++) { + for (let cy = y0; cy <= y1; cy++) { + const row = (cz * this.ny + cy) * this.nx + for (let cx = x0; cx <= x1; cx++) { + for (let w = head[row + cx]; w !== -1; w = next[w]) { + const dx = pos[w * 3] - x + const dy = pos[w * 3 + 1] - y + const dz = pos[w * 3 + 2] - z + const d2 = dx * dx + dy * dy + dz * dz + if (d2 >= r2) continue + if (count === ids.length) { + const grownIds = new Int32Array(ids.length * 2) + grownIds.set(ids) + const grownDist = new Float64Array(ids.length * 2) + grownDist.set(dist) + ids = this.resultIds = grownIds + dist = this.resultDist2 = grownDist + } + ids[count] = w + dist[count] = d2 + count++ + } + } + } + } + return count + } + + /** Cell edge length actually used. */ + get cellSize(): number { + return this.cell + } +} diff --git a/src/areas/generate/sculpt/testMeshes.ts b/src/areas/generate/sculpt/testMeshes.ts new file mode 100644 index 00000000..571fb041 --- /dev/null +++ b/src/areas/generate/sculpt/testMeshes.ts @@ -0,0 +1,119 @@ +// Procedural meshes for the sculpt core tests (not a test file itself). + +export interface TestMesh { + positions: Float32Array + index: Uint32Array | null +} + +/** Unit cube with split vertices per face (24 vertices, 12 triangles), like a UV-mapped GLB. */ +export function splitCube(): TestMesh { + const faces: number[][][] = [ + [[1, -1, -1], [1, 1, -1], [1, 1, 1], [1, -1, 1]], + [[-1, -1, 1], [-1, 1, 1], [-1, 1, -1], [-1, -1, -1]], + [[-1, 1, -1], [-1, 1, 1], [1, 1, 1], [1, 1, -1]], + [[-1, -1, 1], [-1, -1, -1], [1, -1, -1], [1, -1, 1]], + [[-1, -1, 1], [1, -1, 1], [1, 1, 1], [-1, 1, 1]], + [[1, -1, -1], [-1, -1, -1], [-1, 1, -1], [1, 1, -1]], + ] + const positions = new Float32Array(24 * 3) + const index = new Uint32Array(36) + faces.forEach((quad, f) => { + quad.forEach((p, k) => positions.set(p, (f * 4 + k) * 3)) + index.set([f * 4, f * 4 + 1, f * 4 + 2, f * 4, f * 4 + 2, f * 4 + 3], f * 6) + }) + return { positions, index } +} + +/** + * Grid in the xy plane over [-1, 1]², z = height(x, y). The triangulation is + * mirrored across x = 0 so the mesh is exactly symmetric (topology included). + */ +export function grid(n: number, height: (x: number, y: number) => number = () => 0): TestMesh { + const side = n + 1 + const positions = new Float32Array(side * side * 3) + for (let j = 0; j < side; j++) { + for (let i = 0; i < side; i++) { + // Build x from the centre outwards so mirrored columns are exact negatives. + const x = (2 * i - n) / n + const y = (2 * j - n) / n + const o = (j * side + i) * 3 + positions[o] = x + positions[o + 1] = y + positions[o + 2] = height(x, y) + } + } + const index = new Uint32Array(n * n * 6) + let t = 0 + for (let j = 0; j < n; j++) { + for (let i = 0; i < n; i++) { + const a = j * side + i + const b = a + 1 + const c = a + side + const d = c + 1 + if (i < n / 2) index.set([a, b, d, a, d, c], t) + else index.set([a, b, c, b, d, c], t) + t += 6 + } + } + return { positions, index } +} + +/** Mirror buffer index of grid vertex `v` across x = 0. */ +export function gridMirror(n: number, v: number): number { + const side = n + 1 + const i = v % side + const j = (v - i) / side + return j * side + (n - i) +} + +/** + * UV sphere (radius 1, centred at the origin) whose longitudes are split into + * `islands` separate vertex sets, so every island border, the 0/2π seam and + * the poles carry duplicated vertices, like a UV-unwrapped GLB. + */ +export function seamSphere(lat: number, lon: number, islands: number): TestMesh { + const per = lon / islands + const islandVerts = (per + 1) * (lat + 1) + const positions = new Float32Array(islands * islandVerts * 3) + const index = new Uint32Array(islands * per * lat * 6) + let t = 0 + for (let k = 0; k < islands; k++) { + const base = k * islandVerts + for (let r = 0; r <= lat; r++) { + const theta = (Math.PI * r) / lat + const st = Math.sin(theta) + const ct = Math.cos(theta) + for (let c = 0; c <= per; c++) { + const phi = (2 * Math.PI * ((k * per + c) % lon)) / lon + const o = (base + r * (per + 1) + c) * 3 + positions[o] = r === 0 || r === lat ? 0 : st * Math.cos(phi) + positions[o + 1] = ct + positions[o + 2] = r === 0 || r === lat ? 0 : st * Math.sin(phi) + } + } + for (let r = 0; r < lat; r++) { + for (let c = 0; c < per; c++) { + const a = base + r * (per + 1) + c + const b = a + 1 + const d = a + per + 1 + const e = d + 1 + index.set([a, b, d, b, e, d], t) + t += 6 + } + } + } + return { positions, index } +} + +/** Turns an indexed mesh into a triangle soup (non-indexed). */ +export function unindex(mesh: TestMesh): TestMesh { + if (!mesh.index) return mesh + const out = new Float32Array(mesh.index.length * 3) + for (let k = 0; k < mesh.index.length; k++) { + const v = mesh.index[k] + out[k * 3] = mesh.positions[v * 3] + out[k * 3 + 1] = mesh.positions[v * 3 + 1] + out[k * 3 + 2] = mesh.positions[v * 3 + 2] + } + return { positions: out, index: null } +} diff --git a/src/areas/generate/sculpt/topology.ts b/src/areas/generate/sculpt/topology.ts new file mode 100644 index 00000000..40232f5a --- /dev/null +++ b/src/areas/generate/sculpt/topology.ts @@ -0,0 +1,67 @@ +// Connectivity over welded vertices: welded triangles, vertex → faces and +// vertex → neighbours, all as CSR typed arrays. + +export interface Csr { + start: Uint32Array + list: Uint32Array +} + +/** Triangle corners expressed as welded vertex ids. `index` null → consecutive triples (triangle soup). */ +export function weldedTriangles(index: Uint32Array | Uint16Array | null, vertexCount: number, weldOf: Uint32Array): Uint32Array { + if (index) { + const length = index.length - (index.length % 3) + const tri = new Uint32Array(length) + for (let k = 0; k < length; k++) { + const v = index[k] + if (v >= vertexCount) throw new RangeError(`SculptMesh: index ${v} out of range (${vertexCount} vertices)`) + tri[k] = weldOf[v] + } + return tri + } + const length = vertexCount - (vertexCount % 3) + const tri = new Uint32Array(length) + for (let k = 0; k < length; k++) tri[k] = weldOf[k] + return tri +} + +/** Welded vertex → incident triangles. */ +export function vertexFaces(tri: Uint32Array, count: number): Csr { + const start = new Uint32Array(count + 1) + for (let k = 0; k < tri.length; k++) start[tri[k] + 1]++ + for (let w = 0; w < count; w++) start[w + 1] += start[w] + const fill = start.slice(0, count) + const list = new Uint32Array(tri.length) + for (let k = 0; k < tri.length; k++) list[fill[tri[k]]++] = (k / 3) | 0 + return { start, list } +} + +/** Welded vertex → distinct edge neighbours (from the triangles). */ +export function vertexNeighbours(tri: Uint32Array, faces: Csr, count: number): Csr { + const mark = new Int32Array(count).fill(-1) + const start = new Uint32Array(count + 1) + // Pass 1: count distinct neighbours. + for (let w = 0; w < count; w++) { + let c = 0 + for (let k = faces.start[w]; k < faces.start[w + 1]; k++) { + const f = faces.list[k] * 3 + for (let j = 0; j < 3; j++) { + const u = tri[f + j] + if (u !== w && mark[u] !== w) { mark[u] = w; c++ } + } + } + start[w + 1] = start[w] + c + } + mark.fill(-1) + const list = new Uint32Array(start[count]) + for (let w = 0; w < count; w++) { + let o = start[w] + for (let k = faces.start[w]; k < faces.start[w + 1]; k++) { + const f = faces.list[k] * 3 + for (let j = 0; j < 3; j++) { + const u = tri[f + j] + if (u !== w && mark[u] !== w) { mark[u] = w; list[o++] = u } + } + } + } + return { start, list } +} diff --git a/src/areas/generate/sculpt/weld.ts b/src/areas/generate/sculpt/weld.ts new file mode 100644 index 00000000..cea595d7 --- /dev/null +++ b/src/areas/generate/sculpt/weld.ts @@ -0,0 +1,126 @@ +// Weld map: buffer vertices closer than `epsilon` (UV-seam duplicates, or the +// corners of a triangle soup) become one logical "welded" vertex. +// +// Vertices are bucketed in an integer-keyed open-addressing hash grid with +// cells of 4 × epsilon; a vertex only probes the neighbouring cells it is +// within epsilon of, so almost every vertex costs a single probe. + +export interface WeldMap { + /** Number of welded vertices. */ + count: number + /** Buffer vertex → welded vertex. */ + weldOf: Uint32Array + /** CSR: buffer vertices of welded vertex w are dupList[dupStart[w] .. dupStart[w + 1]). The first one is its representative. */ + dupStart: Uint32Array + dupList: Uint32Array +} + +function hashCell(x: number, y: number, z: number, mask: number): number { + return (Math.imul(x, 73856093) ^ Math.imul(y, 19349663) ^ Math.imul(z, 83492791)) & mask +} + +export function buildWeldMap( + positions: Float32Array, + vertexCount: number, + epsilon: number, + origin: readonly [number, number, number], +): WeldMap { + const n = vertexCount + const eps = epsilon > 0 && Number.isFinite(epsilon) ? epsilon : 0 + const cell = eps > 0 ? eps * 4 : 1 + const inv = 1 / cell + const epsCell = eps * inv + const eps2 = eps * eps + const [ox, oy, oz] = origin + + let cap = 16 + while (cap < n * 2) cap *= 2 + const mask = cap - 1 + const keyX = new Int32Array(cap) + const keyY = new Int32Array(cap) + const keyZ = new Int32Array(cap) + const head = new Int32Array(cap).fill(-1) + // Per welded vertex: next welded vertex in the same cell, and its representative buffer vertex. + const next = new Int32Array(n) + const rep = new Uint32Array(n) + const weldOf = new Uint32Array(n) + let count = 0 + + const find = (x: number, y: number, z: number): number => { + let h = hashCell(x, y, z, mask) + for (;;) { + if (head[h] === -1) return -1 + if (keyX[h] === x && keyY[h] === y && keyZ[h] === z) return h + h = (h + 1) & mask + } + } + + for (let i = 0; i < n; i++) { + const px = positions[i * 3] + const py = positions[i * 3 + 1] + const pz = positions[i * 3 + 2] + const fx = (px - ox) * inv + const fy = (py - oy) * inv + const fz = (pz - oz) * inv + const qx = Math.floor(fx) + const qy = Math.floor(fy) + const qz = Math.floor(fz) + const rx = fx - qx + const ry = fy - qy + const rz = fz - qz + const x0 = rx < epsCell ? qx - 1 : qx + const x1 = 1 - rx <= epsCell ? qx + 1 : qx + const y0 = ry < epsCell ? qy - 1 : qy + const y1 = 1 - ry <= epsCell ? qy + 1 : qy + const z0 = rz < epsCell ? qz - 1 : qz + const z1 = 1 - rz <= epsCell ? qz + 1 : qz + + let found = -1 + search: for (let cx = x0; cx <= x1; cx++) { + for (let cy = y0; cy <= y1; cy++) { + for (let cz = z0; cz <= z1; cz++) { + const slot = find(cx | 0, cy | 0, cz | 0) + if (slot < 0) continue + for (let w = head[slot]; w !== -1; w = next[w]) { + const r = rep[w] * 3 + const dx = positions[r] - px + const dy = positions[r + 1] - py + const dz = positions[r + 2] - pz + if (dx * dx + dy * dy + dz * dz <= eps2) { found = w; break search } + } + } + } + } + + if (found >= 0) { + weldOf[i] = found + continue + } + const w = count++ + rep[w] = i + weldOf[i] = w + // Keys wrap to int32 for tiny epsilons; aliased cells only cost extra distance checks. + const kx = qx | 0 + const ky = qy | 0 + const kz = qz | 0 + let h = hashCell(kx, ky, kz, mask) + while (head[h] !== -1 && !(keyX[h] === kx && keyY[h] === ky && keyZ[h] === kz)) h = (h + 1) & mask + if (head[h] === -1) { + keyX[h] = kx + keyY[h] = ky + keyZ[h] = kz + } + next[w] = head[h] + head[h] = w + } + + const dupStart = new Uint32Array(count + 1) + for (let i = 0; i < n; i++) dupStart[weldOf[i] + 1]++ + for (let w = 0; w < count; w++) dupStart[w + 1] += dupStart[w] + const fill = dupStart.slice(0, count) + const dupList = new Uint32Array(n) + // Increasing i, so the representative (lowest index) comes first. + for (let i = 0; i < n; i++) dupList[fill[weldOf[i]]++] = i + + return { count, weldOf, dupStart, dupList } +} From 2521a1b92c8a102c08886ce9a9434bce6321255a Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Thu, 12 Nov 2026 10:51:00 +0100 Subject: [PATCH 4/6] feat(generate): sculpt mode in the viewer and the edit panel 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. --- package.json | 2 +- src/areas/generate/GeneratePage.tsx | 99 +++- src/areas/generate/components/ChatPanel.tsx | 10 +- src/areas/generate/components/EditPanel.tsx | 12 +- .../generate/components/SculptSection.tsx | 199 +++++++ src/areas/generate/components/Viewer3D.tsx | 70 ++- src/areas/generate/sculpt/SculptLayer.tsx | 547 ++++++++++++++++++ src/areas/generate/sculpt/sculptLogic.test.ts | 194 +++++++ src/areas/generate/sculpt/sculptLogic.ts | 231 ++++++++ src/areas/generate/sculpt/sculptStore.ts | 183 ++++++ src/shared/hooks/useApi.ts | 16 +- 11 files changed, 1525 insertions(+), 38 deletions(-) create mode 100644 src/areas/generate/components/SculptSection.tsx create mode 100644 src/areas/generate/sculpt/SculptLayer.tsx create mode 100644 src/areas/generate/sculpt/sculptLogic.test.ts create mode 100644 src/areas/generate/sculpt/sculptLogic.ts create mode 100644 src/areas/generate/sculpt/sculptStore.ts diff --git a/package.json b/package.json index d6117ff3..e89cca40 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "prepare-resources": "node scripts/download-python-embed.js", "test": "npm run test:py && npm run test:node", "test:py": "node scripts/run-pytests.mjs", - "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", + "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts src/areas/generate/sculpt/sculptLogic.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", "package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder", "package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64", "lint": "eslint ." diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index 1fdd98fd..9f77ef10 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -10,6 +10,8 @@ import Viewer3D from './components/Viewer3D' import WorkflowPanel from './components/WorkflowPanel' import EditPanel from './components/EditPanel' import { meshPathFromUrl } from './meshEditPanel' +import { commitSculpt, useSculptStore } from './sculpt/sculptStore' +import { sculptKeyAction } from './sculpt/sculptLogic' import { getDefaultAssetLibraryService } from './assetLibraryService' import { buildOrcaSlicerDeepLink, canOpenInOrcaSlicer } from './orcaSlicerLink' import { resolveAssetLibraryOpenTarget, type ProjectedAssetLibraryEntry } from './assetLibraryProjection' @@ -707,15 +709,57 @@ export default function GeneratePage(): JSX.Element { const { optimizeMesh, smoothMesh, importMesh } = useApi() const assetLibraryService = useMemo(() => getDefaultAssetLibraryService(), []) + const sculpting = useSculptStore((s) => s.active) + + // Global mesh undo/redo. Pending sculpt strokes are saved first, so undo + // steps back over them as one edit (in sculpt mode Ctrl+Z is the stroke undo). + const undoMeshEdit = useCallback(async () => { if (await commitSculpt()) undoMesh() }, [undoMesh]) + const redoMeshEdit = useCallback(async () => { if (await commitSculpt()) redoMesh() }, [redoMesh]) + useEffect(() => { const handler = (e: KeyboardEvent) => { if (!e.ctrlKey && !e.metaKey) return - if (e.key === 'z') { e.preventDefault(); if (gizmoUndoRef.current?.()) return; undoMesh() } - if (e.key === 'y') { e.preventDefault(); redoMesh() } + if (useSculptStore.getState().active) return // sculpt shortcuts below + if (e.key === 'z') { e.preventDefault(); if (gizmoUndoRef.current?.()) return; void undoMeshEdit() } + if (e.key === 'y') { e.preventDefault(); void redoMeshEdit() } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [undoMeshEdit, redoMeshEdit]) + + // Sculpt-mode shortcuts (see sculptKeyAction). Ignored while typing. + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const sculpt = useSculptStore.getState() + if (!sculpt.active) return + const el = document.activeElement as HTMLElement | null + if (el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el.isContentEditable)) return + const action = sculptKeyAction(e) + if (!action) return + e.preventDefault() + switch (action.type) { + case 'undo': sculpt.undo(); break + case 'redo': sculpt.redo(); break + case 'exit': void sculpt.exit(); break + case 'radius': sculpt.stepRadius(action.direction); break + case 'brush': sculpt.setBrush(action.kind); break + } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) - }, [undoMesh, redoMesh]) + }, []) + + // Sculpting replaces the transform tools. + useEffect(() => { + if (sculpting) setGizmoMode(null) + }, [sculpting]) + + // Leaving the page leaves sculpt mode; the viewer saves pending strokes as it unmounts. + useEffect(() => () => useSculptStore.setState({ active: false }), []) + + const closeEditPanel = useCallback(async () => { + if (await useSculptStore.getState().exit()) setEditPanelOpen(false) + }, []) const hasModel = currentJob?.status === 'done' && !!currentJob.outputUrl const showOpenInSlicer = hasModel && canOpenInOrcaSlicer(currentJob?.outputUrl) @@ -743,6 +787,7 @@ export default function GeneratePage(): JSX.Element { const handler = (e: KeyboardEvent) => { const el = document.activeElement as HTMLElement | null if (el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el.isContentEditable)) return + if (useSculptStore.getState().active) return // W/R/S/Esc belong to sculpt mode if (e.key === 'Escape') { setGizmoMode((m) => (m ? null : m)); return } if (!meshSelected && !selectedPointLightId) return const k = e.key.toLowerCase() @@ -766,14 +811,21 @@ export default function GeneratePage(): JSX.Element { setTimeout(() => setUnloadStatus('idle'), 2000) } - function handleExport(format: 'glb' | 'obj' | 'stl' | 'ply') { - if (!currentJob?.outputUrl) return + /** Saves pending sculpt strokes, then returns the mesh URL to read (or null). */ + async function committedMeshUrl(): Promise { + if (!(await commitSculpt())) return null + return useAppStore.getState().currentJob?.outputUrl ?? null + } + + async function handleExport(format: 'glb' | 'obj' | 'stl' | 'ply') { + const outputUrl = await committedMeshUrl() + if (!outputUrl) return const stem = `modly-${Date.now()}` const link = document.createElement('a') if (format === 'glb') { - link.href = `${apiUrl}${currentJob.outputUrl}` + link.href = `${apiUrl}${outputUrl}` } else { - const path = encodeURIComponent(currentJob.outputUrl.replace('/workspace/', '')) + const path = encodeURIComponent(outputUrl.replace('/workspace/', '')) link.href = `${apiUrl}/optimize/export?path=${path}&format=${format}` } link.download = `${stem}.${format}` @@ -781,9 +833,10 @@ export default function GeneratePage(): JSX.Element { } async function handleOpenInOrcaSlicer() { - if (!currentJob?.outputUrl) return + const outputUrl = await committedMeshUrl() + if (!outputUrl) return try { - const link = buildOrcaSlicerDeepLink(apiUrl, currentJob.outputUrl) + const link = buildOrcaSlicerDeepLink(apiUrl, outputUrl) const result = await window.electron.slicer.open(link) if (!result.success) { // Deliberately not "make sure it is installed": the main process cannot @@ -796,6 +849,8 @@ export default function GeneratePage(): JSX.Element { } async function handleImportMesh() { + // Save the strokes before the model is replaced (else they'd be dropped). + if (!(await commitSculpt())) return const filePath = await window.electron.fs.selectMeshFile() if (!filePath) return setOpenPanel(null) @@ -855,6 +910,7 @@ export default function GeneratePage(): JSX.Element { setLibraryError(describeAssetLibraryOpenability(selectedEntry)) return } + if (!(await commitSculpt())) return setLibraryOpening(true) setLibraryError(null) @@ -883,10 +939,11 @@ export default function GeneratePage(): JSX.Element { } async function handleSmooth(iterations: number) { - if (!currentJob?.outputUrl) return setSmoothing(true) try { - const path = meshPathFromUrl(currentJob.outputUrl) + const outputUrl = await committedMeshUrl() + if (!outputUrl) return + const path = meshPathFromUrl(outputUrl) const { url } = await smoothMesh(path, iterations) applyMeshEdit(url) setOpenPanel(null) @@ -898,10 +955,11 @@ export default function GeneratePage(): JSX.Element { } async function handleDecimate(targetFaces: number) { - if (!currentJob?.outputUrl) return setDecimating(true) try { - const path = meshPathFromUrl(currentJob.outputUrl) + const outputUrl = await committedMeshUrl() + if (!outputUrl) return + const path = meshPathFromUrl(outputUrl) const { url } = await optimizeMesh(path, targetFaces) applyMeshEdit(url) setOpenPanel(null) @@ -962,7 +1020,7 @@ export default function GeneratePage(): JSX.Element { {/* Undo / Redo */} + ) : ( + + )} + + + {!active && block && !block.transient && ( +

{block.reason}

+ )} + + {active && ( +
+
+ {BRUSH_KINDS.map((kind) => ( + + ))} +
+

{BRUSH_LABELS[brush].hint}

+ + + + + +
+ Symmetry +
+ {AXES.map((axis) => { + const on = symmetry.includes(axis) + return ( + + ) + })} +
+
+ +

+ {dirty + ? `${strokes} unsaved stroke${strokes === 1 ? '' : 's'} — saved when you click Done.` + : 'No unsaved strokes.'} +

+ {error &&

{error}

} + +
+ {SHORTCUTS.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ +

Mesh tools are disabled while sculpting. Click Done to save the strokes and use them.

+
+ )} + + ) +} diff --git a/src/areas/generate/components/Viewer3D.tsx b/src/areas/generate/components/Viewer3D.tsx index a2621cc6..71d4a321 100644 --- a/src/areas/generate/components/Viewer3D.tsx +++ b/src/areas/generate/components/Viewer3D.tsx @@ -18,6 +18,8 @@ import { useAppStore } from '@shared/stores/appStore' import { ViewerToolbar, type ViewMode } from './ViewerToolbar' import type { LightSettings, PointLight } from '@shared/stores/appStore' import { DEFAULT_LIGHT_SETTINGS } from '@shared/stores/appStore' +import SculptLayer from '../sculpt/SculptLayer' +import { useSculptStore } from '../sculpt/sculptStore' export type GizmoMode = 'translate' | 'rotate' | 'scale' @@ -190,23 +192,32 @@ function ModelLoadError(): JSX.Element { interface MeshModelProps { url: string + /** Workspace URL of the file (currentJob.outputUrl): what sculpt commits patch. */ + outputUrl: string jobId: string viewMode: ViewMode selected: boolean + /** Sculpt mode: clicks paint instead of selecting. */ + sculpting: boolean onStats: (stats: { vertices: number; triangles: number }) => void onSelect: () => void onObject: (obj: THREE.Object3D | null) => void } -function MeshModel({ url, jobId, viewMode, selected, onStats, onSelect, onObject }: MeshModelProps): JSX.Element { - const extension = url.split('?')[0]?.split('.').pop()?.toLowerCase() - const common = { url, jobId, viewMode, selected, onStats, onSelect, onObject } - return extension === 'obj' ? : +function MeshModel(props: MeshModelProps): JSX.Element { + const extension = props.url.split('?')[0]?.split('.').pop()?.toLowerCase() + return extension === 'obj' ? : } function GltfMeshModel(props: MeshModelProps): JSX.Element { - const { scene } = useGLTF(props.url) - return + const { scene, parser } = useGLTF(props.url) + return ( + <> + + {/* Keyed by URL: a new mesh file starts a new sculpt session. */} + + + ) } function ObjMeshModel(props: MeshModelProps): JSX.Element { @@ -218,6 +229,7 @@ function SceneMeshModel({ url, viewMode, selected, + sculpting, onStats, onSelect, onObject, @@ -351,7 +363,7 @@ function SceneMeshModel({ ) @@ -945,6 +957,10 @@ export default function Viewer3D({ const [meshObject, setMeshObject] = useState(null) + const sculpting = useSculptStore((s) => s.active) + const sculptStatus = useSculptStore((s) => s.status) + const sculptError = useSculptStore((s) => s.error) + // Local gizmo-transform history (live TRS), undoable with Ctrl+Z. A snapshot // is taken when a drag starts and committed on release only if it changed. const transformHistory = useRef([]) @@ -969,11 +985,17 @@ export default function Viewer3D({ // Reset view state when model changes useEffect(() => { setSelected(false) - setViewMode('solid') + // A sculpt commit swaps in the patched file: keep the sculptor's view mode. + if (!useSculptStore.getState().active) setViewMode('solid') setStoreMeshStats(null) // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when the model changes; setters are stable }, [modelUrl]) + // Sculpt mode owns the left button: no selection, so no transform gizmo. + useEffect(() => { + if (sculpting) setSelected(false) + }, [sculpting, setSelected]) + // Clear the shared selection when the viewer unmounts — the store would // otherwise keep it set and flash a stale selection on the next mount. useEffect(() => () => setSelected(false), [setSelected]) @@ -983,7 +1005,7 @@ export default function Viewer3D({ const handler = (e: KeyboardEvent) => { if (e.key !== 'Delete') return if (document.activeElement instanceof HTMLInputElement) return - if (!selected) return + if (!selected || useSculptStore.getState().active) return setCurrentJob(null) setSelected(false) } @@ -1126,9 +1148,11 @@ export default function Viewer3D({ ))} - {selected && meshObject && gizmoMode === 'translate' && ( + {!sculpting && selected && meshObject && gizmoMode === 'translate' && ( )} - {selected && meshObject && gizmoMode === 'rotate' && ( + {!sculpting && selected && meshObject && gizmoMode === 'rotate' && ( )} - {selected && meshObject && gizmoMode === 'scale' && ( + {!sculpting && selected && meshObject && gizmoMode === 'scale' && ( )} @@ -1204,11 +1228,31 @@ export default function Viewer3D({ )} + {/* Sculpt status: preparing / saving / last error */} + {sculpting && (sculptStatus !== 'idle' || sculptError) && ( +
+ {sculptStatus !== 'idle' ? ( +

+ + + + {sculptStatus === 'preparing' ? 'Preparing mesh…' : 'Saving sculpt…'} +

+ ) : ( +

+ {sculptError} +

+ )} +
+ )} + {/* Bottom-right hint */} {modelUrl && (

- {selected + {sculpting + ? <>Drag on the mesh to sculpt • drag outside to orbit • Esc to finish + : selected ? <>Click mesh to select • Delete to remove : 'Drag to rotate \u2022 Scroll to zoom' } diff --git a/src/areas/generate/sculpt/SculptLayer.tsx b/src/areas/generate/sculpt/SculptLayer.tsx new file mode 100644 index 00000000..e8be4c47 --- /dev/null +++ b/src/areas/generate/sculpt/SculptLayer.tsx @@ -0,0 +1,547 @@ +// Viewer side of sculpt mode, mounted next to a glTF model (one instance per +// model URL). Turns left-drags on the mesh into SculptMesh strokes, uploads the +// touched vertex ranges, draws the brush ring, and registers the session that +// commits the strokes back to the mesh file. +// +// Blender-like input: a left press that hits the mesh starts a stroke (orbit is +// suspended for it); a press on empty space, right/middle drag and the wheel +// still drive OrbitControls. + +import { useEffect, useMemo, useRef } from 'react' +import { useFrame, useThree } from '@react-three/fiber' +import * as THREE from 'three' +import { useAppStore } from '@shared/stores/appStore' +import { useApi } from '@shared/hooks/useApi' +import { apiErrorMessage, meshPathFromUrl } from '../meshEditPanel' +import { SculptMesh } from './SculptMesh' +import type { BrushKind, BrushSettings, DirtyRegion, Vec3 } from './types' +import { useSculptStore, type SculptSession, type SculptStrokeEntry } from './sculptStore' +import { + attributeUpdateRange, + buildVertexPatch, + mergeRange, + pushStroke, + redoStroke, + sessionEndOutcome, + toLocalRadius, + undoStroke, + worldRadius, + type IndexRange, + type VertexPatch, +} from './sculptLogic' + +/** GLTFLoader's parser, reduced to what the commit needs. */ +interface GltfParserLike { + associations: Map +} + +interface SculptTarget { + key: string + /** A THREE.Mesh using the geometry: its glTF association names the primitive to patch. */ + mesh: THREE.Mesh + geometry: THREE.BufferGeometry + core: SculptMesh + /** Vertex range touched since the last GPU upload. */ + pending: IndexRange | null +} + +interface Stroke { + target: SculptTarget + mesh: THREE.Mesh + kind: BrushKind + /** Grab: camera-facing plane through the start point (world space). */ + plane: THREE.Plane | null + /** Grab: surface normal at the start point (local space). */ + startNormal: Vec3 +} + +interface Drag { + pointerId: number + stroke: Stroke | null + released: boolean +} + +const RING_COLOR = '#a78bfa' +const Z_AXIS = new THREE.Vector3(0, 0, 1) + +function vec(v: THREE.Vector3): Vec3 { + return [v.x, v.y, v.z] +} + +function isSculptable(obj: THREE.Object3D): obj is THREE.Mesh { + const mesh = obj as THREE.Mesh & { isSkinnedMesh?: boolean; isInstancedMesh?: boolean } + return mesh.isMesh === true + && !mesh.isSkinnedMesh + && !mesh.isInstancedMesh + && !!mesh.geometry?.getAttribute('position') + && !mesh.geometry.morphAttributes?.position?.length +} + +/** + * The geometry's attribute as a plain, non-normalized Float32 vec3 attribute, + * so the sculpt core can mutate its array in place. Interleaved, quantized or + * non-float data is copied into a fresh Float32BufferAttribute first. + */ +function float32Attribute(geometry: THREE.BufferGeometry, name: 'position' | 'normal'): THREE.BufferAttribute | null { + const attr = geometry.getAttribute(name) as THREE.BufferAttribute | THREE.InterleavedBufferAttribute | undefined + if (!attr) return null + const plain = !(attr as THREE.InterleavedBufferAttribute).isInterleavedBufferAttribute + && attr.array instanceof Float32Array + && !attr.normalized + && attr.itemSize === 3 + if (plain) return attr as THREE.BufferAttribute + const out = new Float32Array(attr.count * 3) + for (let i = 0; i < attr.count; i++) { + out[i * 3] = attr.getX(i) + out[i * 3 + 1] = attr.getY(i) + out[i * 3 + 2] = attr.getZ(i) + } + const fresh = new THREE.Float32BufferAttribute(out, 3) + geometry.setAttribute(name, fresh) + return fresh +} + +function indexArray(geometry: THREE.BufferGeometry): Uint32Array | Uint16Array | null { + const array = geometry.index?.array + if (!array) return null + if (array instanceof Uint32Array || array instanceof Uint16Array) return array + return Uint32Array.from(array as ArrayLike) +} + +function createTarget(mesh: THREE.Mesh): SculptTarget { + const geometry = mesh.geometry + const position = float32Attribute(geometry, 'position') + if (!position) throw new Error('This part has no vertex positions.') + const normal = float32Attribute(geometry, 'normal') + const core = new SculptMesh( + position.array as Float32Array, + normal ? normal.array as Float32Array : null, + indexArray(geometry), + ) + if (!normal) geometry.setAttribute('normal', new THREE.BufferAttribute(core.normals, 3)) + return { key: geometry.uuid, mesh, geometry, core, pending: null } +} + +function uploadPending(target: SculptTarget): void { + if (!target.pending) return + const { start, count } = attributeUpdateRange(target.pending, 3) + for (const name of ['position', 'normal'] as const) { + const attr = target.geometry.getAttribute(name) as THREE.BufferAttribute | undefined + if (!attr) continue + attr.addUpdateRange(start, count) + attr.needsUpdate = true + } + target.pending = null +} + +/** BVH and bounds follow the moved vertices (end of a stroke, undo, redo). */ +function refreshSpatialData(target: SculptTarget): void { + const geometry = target.geometry as THREE.BufferGeometry & { boundsTree?: { refit: () => void } } + geometry.boundsTree?.refit() + geometry.computeBoundingBox() + geometry.computeBoundingSphere() +} + +function markDirty(target: SculptTarget, region: DirtyRegion): void { + target.pending = mergeRange(target.pending, { min: region.minIndex, max: region.maxIndex }) +} + +function errorText(err: unknown): string { + return apiErrorMessage(err) +} + +export default function SculptLayer({ + scene, + parser, + outputUrl, +}: { + scene: THREE.Object3D + parser: GltfParserLike + /** Workspace URL of the mesh file (currentJob.outputUrl) these strokes apply to. */ + outputUrl: string +}): JSX.Element { + const active = useSculptStore((s) => s.active) + const camera = useThree((s) => s.camera) + const gl = useThree((s) => s.gl) + const controls = useThree((s) => s.controls) as { enabled: boolean } | null + const { patchMeshVertices } = useApi() + + const targets = useRef(new Map()) + const drag = useRef(null) + const ringRef = useRef(null) + const hoverNdc = useRef(null) + const diagonal = useRef(null) + const activeRef = useRef(active) + activeRef.current = active + + // Latest values for the long-lived DOM listeners and session callbacks. + const live = useRef({ camera, gl, controls, patchMeshVertices }) + live.current = { camera, gl, controls, patchMeshVertices } + + const raycaster = useMemo(() => { + const r = new THREE.Raycaster() as THREE.Raycaster & { firstHitOnly?: boolean } + r.firstHitOnly = true + return r + }, []) + + const meshes = useMemo(() => { + const list: THREE.Mesh[] = [] + scene.traverse((obj) => { if (isSculptable(obj)) list.push(obj) }) + return list + }, [scene]) + + // Shared helpers, rebuilt only with the model. + const api = useMemo(() => { + const ndc = new THREE.Vector2() + + const setRay = (clientX: number, clientY: number): THREE.Ray => { + const rect = live.current.gl.domElement.getBoundingClientRect() + ndc.set(((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1) + raycaster.setFromCamera(ndc, live.current.camera) + return raycaster.ray + } + + const hitTest = (objects: THREE.Object3D[]): THREE.Intersection | null => { + const hits = raycaster.intersectObjects(objects, false) as THREE.Intersection[] + return hits.find((h) => h.object.visible && h.face) ?? null + } + + const modelDiagonal = (): number => { + if (diagonal.current === null) { + diagonal.current = new THREE.Box3().setFromObject(scene).getSize(new THREE.Vector3()).length() + } + return diagonal.current + } + + const localRadius = (mesh: THREE.Mesh, kind: BrushKind): number => { + const { brushes } = useSculptStore.getState() + mesh.updateWorldMatrix(true, false) + return toLocalRadius(worldRadius(brushes[kind].radius, modelDiagonal()), mesh.matrixWorld.getMaxScaleOnAxis()) + } + + const reportError = (prefix: string, err: unknown) => { + console.warn(`[Sculpt] ${prefix}:`, err) + useSculptStore.getState().setError(`${prefix}: ${errorText(err)}`) + } + + const setOrbit = (enabled: boolean) => { + if (live.current.controls) live.current.controls.enabled = enabled + } + + const startStroke = (target: SculptTarget, hit: THREE.Intersection, ev: PointerEvent): Stroke | null => { + const state = useSculptStore.getState() + // Shift held = smooth, whatever the brush (as in Blender). + const kind: BrushKind = ev.shiftKey ? 'smooth' : state.brush + const mesh = hit.object + const settings: BrushSettings = { + kind, + radius: localRadius(mesh, kind), + strength: state.brushes[kind].strength, + invert: ev.ctrlKey || ev.metaKey, + symmetry: [...state.symmetry], + } + const point = mesh.worldToLocal(hit.point.clone()) + const normal = hit.face!.normal.clone() + try { + markDirty(target, target.core.beginStroke(settings, vec(point), vec(normal))) + } catch (err) { + reportError('Sculpt stroke failed', err) + return null + } + let plane: THREE.Plane | null = null + if (kind === 'grab') { + const facing = live.current.camera.getWorldDirection(new THREE.Vector3()) + plane = new THREE.Plane().setFromNormalAndCoplanarPoint(facing, hit.point) + } + return { target, mesh, kind, plane, startNormal: vec(normal) } + } + + const continueStroke = (stroke: Stroke, ev: PointerEvent) => { + const ray = setRay(ev.clientX, ev.clientY) + let point: THREE.Vector3 | null = null + let normal: Vec3 = stroke.startNormal + if (stroke.plane) { + const world = ray.intersectPlane(stroke.plane, new THREE.Vector3()) + if (world) point = stroke.mesh.worldToLocal(world) + } else { + const hit = hitTest([stroke.mesh]) + if (hit) { + point = stroke.mesh.worldToLocal(hit.point.clone()) + normal = vec(hit.face!.normal) + } + } + if (!point) return + try { + markDirty(stroke.target, stroke.target.core.strokeTo(vec(point), normal)) + } catch (err) { + reportError('Sculpt stroke failed', err) + abortStroke() + } + } + + const finishStroke = (stroke: Stroke) => { + let record = null + try { + record = stroke.target.core.endStroke() + } catch (err) { + reportError('Sculpt stroke failed', err) + } + uploadPending(stroke.target) + refreshSpatialData(stroke.target) + diagonal.current = null + if (record) { + const store = useSculptStore.getState() + const entry: SculptStrokeEntry = { meshKey: stroke.target.key, record } + store.setHistory(pushStroke(store.history, entry)) + } + } + + const onMove = (ev: PointerEvent) => { + const d = drag.current + if (!d || ev.pointerId !== d.pointerId || !d.stroke) return + continueStroke(d.stroke, ev) + hoverNdc.current = null + updateRingFromEvent(ev, d.stroke.mesh) + } + + const endDrag = () => { + const d = drag.current + drag.current = null + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onUp) + if (d?.stroke) finishStroke(d.stroke) + setOrbit(true) + } + + function onUp(ev: PointerEvent) { + const d = drag.current + if (!d || ev.pointerId !== d.pointerId) return + d.released = true + // Still preparing the mesh: the stroke (if any) ends once it starts. + if (useSculptStore.getState().status === 'preparing') return + endDrag() + } + + function abortStroke() { + const d = drag.current + if (d?.stroke) { + try { d.stroke.target.core.endStroke() } catch { /* already reported */ } + uploadPending(d.stroke.target) + d.stroke = null + } + endDrag() + } + + // Ring under the cursor, sized to the brush and lying on the surface. + const placeRing = (hit: THREE.Intersection | null) => { + const ring = ringRef.current + if (!ring) return + if (!hit) { ring.visible = false; return } + const mesh = hit.object + const state = useSculptStore.getState() + const kind = state.brush + const radius = worldRadius(state.brushes[kind].radius, modelDiagonal()) + const normal = hit.face!.normal.clone().applyNormalMatrix(new THREE.Matrix3().getNormalMatrix(mesh.matrixWorld)) + if (normal.lengthSq() < 1e-12) normal.set(0, 1, 0) + ring.position.copy(hit.point).addScaledVector(normal, radius * 0.01) + ring.quaternion.setFromUnitVectors(Z_AXIS, normal.normalize()) + ring.scale.setScalar(Math.max(radius, 1e-6)) + ring.visible = true + } + + function updateRingFromEvent(ev: PointerEvent, mesh: THREE.Mesh) { + setRay(ev.clientX, ev.clientY) + placeRing(hitTest([mesh])) + } + + const onDown = (ev: PointerEvent) => { + if (!activeRef.current || ev.button !== 0 || drag.current) return + const store = useSculptStore.getState() + if (store.status !== 'idle') return + setRay(ev.clientX, ev.clientY) + const hit = hitTest(meshes) + if (!hit) return // empty space: let OrbitControls orbit + + // Runs before OrbitControls' own pointerdown (capture phase), so it never starts. + setOrbit(false) + store.setError(null) + const d: Drag = { pointerId: ev.pointerId, stroke: null, released: false } + drag.current = d + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onUp) + + const geometryKey = hit.object.geometry.uuid + const existing = targets.current.get(geometryKey) + if (existing) { + d.stroke = startStroke(existing, hit, ev) + if (!d.stroke) endDrag() + return + } + + // First stroke on this part: build its sculpt data (can take ~1 s on big + // meshes). Let React paint "Preparing mesh…" before blocking the thread. + store.setStatus('preparing') + window.setTimeout(() => { + let target: SculptTarget | null = null + try { + target = createTarget(hit.object) + targets.current.set(geometryKey, target) + } catch (err) { + reportError('Could not prepare the mesh for sculpting', err) + } + useSculptStore.getState().setStatus('idle') + if (drag.current !== d) return + if (target && activeRef.current) d.stroke = startStroke(target, hit, ev) + if (!d.stroke || d.released) endDrag() + }, 30) + } + + const onHover = (ev: PointerEvent) => { + if (!activeRef.current || drag.current) return + const rect = live.current.gl.domElement.getBoundingClientRect() + hoverNdc.current = new THREE.Vector2( + ((ev.clientX - rect.left) / rect.width) * 2 - 1, + -((ev.clientY - rect.top) / rect.height) * 2 + 1, + ) + } + + const onLeave = () => { + hoverNdc.current = null + if (ringRef.current && !drag.current) ringRef.current.visible = false + } + + const hover = (point: THREE.Vector2) => { + raycaster.setFromCamera(point, live.current.camera) + placeRing(hitTest(meshes)) + } + + const replay = (entry: SculptStrokeEntry): SculptStrokeEntry => { + const target = targets.current.get(entry.meshKey) + if (!target) throw new Error('The sculpted part is no longer loaded.') + const { region, inverse } = target.core.restore(entry.record) + markDirty(target, region) + uploadPending(target) + refreshSpatialData(target) + return { meshKey: entry.meshKey, record: inverse } + } + + const session: SculptSession = { + async commit() { + // A stroke in progress belongs to this commit. + if (drag.current) endDrag() + // Let "Saving sculpt…" paint before the (synchronous) encoding. + await new Promise((resolve) => window.setTimeout(resolve, 0)) + const patches: VertexPatch[] = [] + for (const target of targets.current.values()) { + if (!target.core.changed) continue + const patch = buildVertexPatch( + parser.associations.get(target.mesh) as { meshes?: number; primitives?: number } | undefined, + target.core.positions, + target.core.normals, + ) + if (!patch) throw new Error('A sculpted part is not a glTF primitive and cannot be written back.') + patches.push(patch) + } + if (patches.length === 0) return + const { url } = await live.current.patchMeshVertices(meshPathFromUrl(outputUrl), patches) + // Another mesh replaced this one while saving: applying would drop it. + if (useAppStore.getState().currentJob?.outputUrl !== outputUrl) { + throw new Error('The mesh changed while saving; the sculpt was not applied.') + } + useAppStore.getState().applyMeshEdit(url) + }, + undo() { + if (drag.current) return + const store = useSculptStore.getState() + try { store.setHistory(undoStroke(store.history, replay)) } catch (err) { reportError('Undo failed', err) } + }, + redo() { + if (drag.current) return + const store = useSculptStore.getState() + try { store.setHistory(redoStroke(store.history, replay)) } catch (err) { reportError('Redo failed', err) } + }, + } + + return { onDown, onHover, onLeave, hover, session, endDrag, flush: () => targets.current.forEach(uploadPending) } + }, [scene, meshes, parser, outputUrl, raycaster]) + + // One session per model: commit (viewer going away) or drop (mesh replaced) + // whatever is still pending when it ends. + useEffect(() => { + const { session } = api + const unregister = useSculptStore.getState().registerSession(session) + const owned = targets.current + return () => { + const store = useSculptStore.getState() + const outcome = sessionEndOutcome( + store.session === session && store.dirty, + outputUrl, + useAppStore.getState().currentJob?.outputUrl, + ) + if (drag.current) api.endDrag() + if (outcome === 'commit') { + session.commit().catch((err) => { + useAppStore.getState().showToast(`Could not save the sculpt: ${errorText(err)}`, 5000) + }) + } else if (outcome === 'discard') { + useAppStore.getState().showToast('Unsaved sculpt strokes were dropped: the mesh was replaced.', 5000) + } + unregister() + if (outcome !== 'commit') owned.clear() + } + }, [api, outputUrl]) + + // Pointer input, capture phase on the canvas: decides stroke vs orbit before + // OrbitControls (listening further up) sees the press. + useEffect(() => { + const el = gl.domElement + el.addEventListener('pointerdown', api.onDown, { capture: true }) + el.addEventListener('pointermove', api.onHover) + el.addEventListener('pointerleave', api.onLeave) + return () => { + el.removeEventListener('pointerdown', api.onDown, { capture: true }) + el.removeEventListener('pointermove', api.onHover) + el.removeEventListener('pointerleave', api.onLeave) + } + }, [gl, api]) + + // Entering / leaving sculpt mode: fresh model size, no stale ring, no stuck stroke. + useEffect(() => { + diagonal.current = null + if (!active) { + if (drag.current) api.endDrag() + hoverNdc.current = null + if (ringRef.current) ringRef.current.visible = false + } + gl.domElement.style.cursor = active ? 'crosshair' : '' + return () => { gl.domElement.style.cursor = '' } + }, [active, api, gl]) + + // At most one GPU upload per frame, and one hover raycast per frame. + useFrame(() => { + api.flush() + if (!activeRef.current || drag.current) return + const point = hoverNdc.current + if (point) { + hoverNdc.current = null + api.hover(point) + } + }) + + return ( + null}> + + + + ) +} diff --git a/src/areas/generate/sculpt/sculptLogic.test.ts b/src/areas/generate/sculpt/sculptLogic.test.ts new file mode 100644 index 00000000..e8c73f69 --- /dev/null +++ b/src/areas/generate/sculpt/sculptLogic.test.ts @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + RADIUS_MAX, + RADIUS_MIN, + SCULPT_MAX_TRIANGLES, + attributeUpdateRange, + buildVertexPatch, + bytesToBase64, + clampRadius, + emptyHistory, + float32ToBase64, + isHistoryDirty, + mergeRange, + pushStroke, + redoStroke, + sculptBlock, + sculptKeyAction, + sessionEndOutcome, + stepRadius, + toLocalRadius, + undoStroke, + worldRadius, + type KeyLike, +} from './sculptLogic.ts' + +// ─── Availability ──────────────────────────────────────────────────────────── + +test('sculptBlock allows a loaded GLB under the triangle limit', () => { + assert.equal(sculptBlock({ meshUrl: '/workspace/a/mesh.glb', generating: false, triangles: 10_000 }), null) + assert.equal(sculptBlock({ meshUrl: '/workspace/a/mesh.glb', generating: false, triangles: SCULPT_MAX_TRIANGLES }), null) +}) + +test('sculptBlock rejects missing models, generation, OBJ and splats', () => { + assert.match(sculptBlock({ meshUrl: undefined, generating: false, triangles: null })!.reason, /Generate, import or open/) + assert.match(sculptBlock({ meshUrl: '/workspace/a/mesh.glb', generating: true, triangles: 10 })!.reason, /Generating/) + assert.match(sculptBlock({ meshUrl: '/workspace/a/mesh.OBJ', generating: false, triangles: 10 })!.reason, /OBJ/) + assert.match(sculptBlock({ meshUrl: '/workspace/a/s.ply', generating: false, triangles: 10 })!.reason, /splat/) +}) + +test('sculptBlock names the triangle count of a mesh that is too big', () => { + const block = sculptBlock({ meshUrl: '/workspace/a/mesh.glb', generating: false, triangles: 3_500_000 }) + assert.equal(block?.transient, false) + assert.equal( + block?.reason, + 'This mesh has too many triangles to sculpt (3,500,000). Decimate it or use Prepare for Game first.', + ) +}) + +test('sculptBlock treats a loading mesh as transient', () => { + const block = sculptBlock({ meshUrl: '/workspace/a/mesh.glb', generating: false, triangles: null }) + assert.equal(block?.transient, true) +}) + +// ─── Radius ────────────────────────────────────────────────────────────────── + +test('radius is clamped and stepped geometrically', () => { + assert.equal(clampRadius(0), RADIUS_MIN) + assert.equal(clampRadius(10), RADIUS_MAX) + assert.equal(clampRadius(Number.NaN), 0.05) + assert.equal(stepRadius(0.05, 1), 0.0625) + assert.equal(stepRadius(0.05, -1), 0.04) + assert.equal(stepRadius(RADIUS_MAX, 1), RADIUS_MAX) + assert.equal(stepRadius(RADIUS_MIN, -1), RADIUS_MIN) +}) + +test('world radius scales with the model and local radius undoes the world scale', () => { + assert.equal(worldRadius(0.1, 2), 0.2) + assert.equal(toLocalRadius(0.2, 4), 0.05) + assert.equal(toLocalRadius(0.2, 0), 0.2) +}) + +// ─── History ───────────────────────────────────────────────────────────────── + +test('undo and redo swap entries through apply and keep the stacks in step', () => { + let h = pushStroke(pushStroke(emptyHistory(), 'a'), 'b') + assert.deepEqual(h, { undo: ['a', 'b'], redo: [], truncated: false }) + assert.equal(isHistoryDirty(h), true) + + const applied: string[] = [] + h = undoStroke(h, (e) => { applied.push(e); return `${e}'` }) + assert.deepEqual(h, { undo: ['a'], redo: ["b'"], truncated: false }) + h = undoStroke(h, (e) => { applied.push(e); return `${e}'` }) + assert.equal(isHistoryDirty(h), false) + assert.equal(undoStroke(h, () => assert.fail('nothing to undo')), h) + + h = redoStroke(h, (e) => { applied.push(e); return e.replace("'", '') }) + assert.deepEqual(h.undo, ['a']) + assert.deepEqual(applied, ['b', 'a', "a'"]) +}) + +test('a new stroke clears the redo stack', () => { + let h = pushStroke(emptyHistory(), 'a') + h = undoStroke(h, (e) => e) + h = pushStroke(h, 'c') + assert.deepEqual(h, { undo: ['c'], redo: [], truncated: false }) +}) + +test('a failing apply leaves the history untouched', () => { + const h = pushStroke(emptyHistory(), 'a') + assert.throws(() => undoStroke(h, () => { throw new Error('boom') }), /boom/) + assert.deepEqual(h.undo, ['a']) +}) + +test('strokes over the limit fall off and keep the mesh dirty', () => { + let h = emptyHistory() + for (let i = 0; i < 4; i++) h = pushStroke(h, i, 3) + assert.deepEqual(h.undo, [1, 2, 3]) + assert.equal(h.truncated, true) + for (let i = 0; i < 3; i++) h = undoStroke(h, (e) => e) + assert.equal(h.undo.length, 0) + assert.equal(isHistoryDirty(h), true) +}) + +// ─── Ranges ────────────────────────────────────────────────────────────────── + +test('dirty ranges merge and map to attribute update ranges', () => { + let r = mergeRange(null, { min: 10, max: 20 }) + r = mergeRange(r, { min: 4, max: 12 }) + assert.deepEqual(r, { min: 4, max: 20 }) + assert.deepEqual(mergeRange(r, { min: 1, max: 0 }), r) // empty region + assert.equal(mergeRange(null, { min: 0, max: -1 }), null) + assert.deepEqual(attributeUpdateRange({ min: 4, max: 20 }, 3), { start: 12, count: 51 }) +}) + +// ─── Encoding ──────────────────────────────────────────────────────────────── + +test('bytesToBase64 matches Buffer on sizes around the chunk boundary', () => { + for (const size of [0, 1, 2, 3, 32_765, 32_766, 32_767, 100_003]) { + const bytes = new Uint8Array(size) + for (let i = 0; i < size; i++) bytes[i] = (i * 31 + 7) & 0xff + assert.equal(bytesToBase64(bytes), Buffer.from(bytes).toString('base64'), `size ${size}`) + } +}) + +test('float32ToBase64 encodes only the view, little-endian', () => { + const backing = new Float32Array([9, 1.5, -2, 9]) + const view = backing.subarray(1, 3) + const expected = Buffer.alloc(8) + expected.writeFloatLE(1.5, 0) + expected.writeFloatLE(-2, 4) + assert.equal(float32ToBase64(view), expected.toString('base64')) +}) + +test('buildVertexPatch maps the glTF association to a patch', () => { + const positions = new Float32Array([1, 2, 3]) + const normals = new Float32Array([0, 0, 1]) + assert.deepEqual(buildVertexPatch({ meshes: 2, primitives: 1 }, positions, normals), { + mesh: 2, + primitive: 1, + positions: float32ToBase64(positions), + normals: float32ToBase64(normals), + }) + assert.equal(buildVertexPatch({ meshes: 0, primitives: 0 }, positions, null)?.normals, undefined) + assert.equal(buildVertexPatch(undefined, positions, normals), null) + assert.equal(buildVertexPatch({ nodes: 3 } as { meshes?: number }, positions, normals), null) +}) + +// ─── Session end ───────────────────────────────────────────────────────────── + +test('sessionEndOutcome saves when the same mesh is shown and drops otherwise', () => { + assert.equal(sessionEndOutcome(false, '/workspace/a.glb', '/workspace/b.glb'), 'none') + assert.equal(sessionEndOutcome(true, '/workspace/a.glb', '/workspace/a.glb'), 'commit') + assert.equal(sessionEndOutcome(true, '/workspace/a.glb', '/workspace/b.glb'), 'discard') + assert.equal(sessionEndOutcome(true, '/workspace/a.glb', undefined), 'discard') +}) + +// ─── Shortcuts ─────────────────────────────────────────────────────────────── + +function key(k: string, mods: Partial = {}): KeyLike { + return { key: k, ctrlKey: false, metaKey: false, shiftKey: false, altKey: false, ...mods } +} + +test('sculptKeyAction maps the sculpt shortcuts', () => { + assert.deepEqual(sculptKeyAction(key('z', { ctrlKey: true })), { type: 'undo' }) + assert.deepEqual(sculptKeyAction(key('Z', { ctrlKey: true, shiftKey: true })), { type: 'redo' }) + assert.deepEqual(sculptKeyAction(key('y', { metaKey: true })), { type: 'redo' }) + assert.deepEqual(sculptKeyAction(key('Escape')), { type: 'exit' }) + assert.deepEqual(sculptKeyAction(key('f')), { type: 'radius', direction: 1 }) + assert.deepEqual(sculptKeyAction(key('F', { shiftKey: true })), { type: 'radius', direction: -1 }) + assert.deepEqual(sculptKeyAction(key('g')), { type: 'brush', kind: 'grab' }) + assert.deepEqual(sculptKeyAction(key('s')), { type: 'brush', kind: 'smooth' }) + assert.deepEqual(sculptKeyAction(key('i')), { type: 'brush', kind: 'inflate' }) + assert.deepEqual(sculptKeyAction(key('t')), { type: 'brush', kind: 'flatten' }) +}) + +test('sculptKeyAction ignores other keys and modified brush keys', () => { + assert.equal(sculptKeyAction(key('w')), null) + assert.equal(sculptKeyAction(key('r')), null) + assert.equal(sculptKeyAction(key('s', { ctrlKey: true })), null) + assert.equal(sculptKeyAction(key('g', { altKey: true })), null) + assert.equal(sculptKeyAction(key('S', { shiftKey: true })), null) +}) diff --git a/src/areas/generate/sculpt/sculptLogic.ts b/src/areas/generate/sculpt/sculptLogic.ts new file mode 100644 index 00000000..57f28ae6 --- /dev/null +++ b/src/areas/generate/sculpt/sculptLogic.ts @@ -0,0 +1,231 @@ +// Pure glue between the sculpt core (SculptMesh), the viewer and the edit +// panel: availability guard, brush radius conversion, stroke history, the +// commit payload encoding and the sculpt-mode shortcuts. No React, no three.js, +// so it can be unit-tested with node --test. + +import type { BrushKind } from './types' + +// ─── Availability ──────────────────────────────────────────────────────────── + +/** Above this many triangles the sculpt core would be too slow / too heavy. */ +export const SCULPT_MAX_TRIANGLES = 3_000_000 + +export interface SculptAvailabilityInput { + /** Mesh URL in the viewer (currentJob.outputUrl when the job is done). */ + meshUrl: string | undefined + generating: boolean + /** Triangle count from the viewer; null while the mesh is still loading. */ + triangles: number | null +} + +export interface SculptBlock { + reason: string + /** + * True while the mesh is merely loading (e.g. the patched file a commit just + * produced): sculpt mode can't start, but an active session isn't ended. + */ + transient: boolean +} + +/** Why sculpt mode can't be used right now, or null when it can. */ +export function sculptBlock({ meshUrl, generating, triangles }: SculptAvailabilityInput): SculptBlock | null { + const block = (reason: string, transient = false): SculptBlock => ({ reason, transient }) + if (generating) return block('Generating… sculpting unlocks once the mesh is ready.') + if (!meshUrl) return block('Generate, import or open a mesh to sculpt it.') + const ext = meshUrl.split('?')[0]?.split('.').pop()?.toLowerCase() + if (ext === 'obj') return block('OBJ models can\'t be sculpted: only GLB meshes can.') + if (ext === 'ply' || ext === 'splat') return block('Gaussian splats can\'t be sculpted.') + if (triangles === null) return block('Loading the mesh…', true) + if (triangles > SCULPT_MAX_TRIANGLES) { + return block(`This mesh has too many triangles to sculpt (${triangles.toLocaleString('en-US')}). Decimate it or use Prepare for Game first.`) + } + return null +} + +// ─── Brush radius ──────────────────────────────────────────────────────────── + +/** Radius is stored as a fraction of the model's bounding-box diagonal. */ +export const RADIUS_MIN = 0.005 +export const RADIUS_MAX = 0.5 +export const RADIUS_DEFAULT = 0.05 +/** F / Shift+F multiply / divide the radius by this. */ +export const RADIUS_STEP = 1.25 + +export function clampRadius(fraction: number): number { + if (!Number.isFinite(fraction)) return RADIUS_DEFAULT + return Math.min(RADIUS_MAX, Math.max(RADIUS_MIN, fraction)) +} + +export function stepRadius(fraction: number, direction: 1 | -1): number { + const next = direction > 0 ? fraction * RADIUS_STEP : fraction / RADIUS_STEP + return clampRadius(Number(next.toPrecision(3))) +} + +/** World-space brush radius for a model whose bbox diagonal is `modelDiagonal`. */ +export function worldRadius(fraction: number, modelDiagonal: number): number { + return clampRadius(fraction) * Math.max(modelDiagonal, 0) +} + +/** + * World radius → local radius of one mesh, whose world matrix scales by + * `maxWorldScale` along its largest axis (Matrix4.getMaxScaleOnAxis). + */ +export function toLocalRadius(radiusWorld: number, maxWorldScale: number): number { + return maxWorldScale > 1e-12 ? radiusWorld / maxWorldScale : radiusWorld +} + +// ─── Stroke history ────────────────────────────────────────────────────────── + +/** Strokes kept for local undo; older ones fall off (and the mesh stays dirty). */ +export const SCULPT_HISTORY_LIMIT = 64 + +export interface SculptHistory { + undo: T[] + redo: T[] + /** True once a stroke fell off the bottom: undoing everything no longer restores the source mesh. */ + truncated: boolean +} + +export function emptyHistory(): SculptHistory { + return { undo: [], redo: [], truncated: false } +} + +export function pushStroke(h: SculptHistory, entry: T, limit = SCULPT_HISTORY_LIMIT): SculptHistory { + const undo = [...h.undo, entry] + const overflow = Math.max(0, undo.length - limit) + return { undo: undo.slice(overflow), redo: [], truncated: h.truncated || overflow > 0 } +} + +/** + * Undo the latest stroke: `apply` swaps it back into the mesh and returns the + * entry that redoes it. Returns `h` unchanged when there is nothing to undo; if + * `apply` throws, the history is left as it was. + */ +export function undoStroke(h: SculptHistory, apply: (entry: T) => T): SculptHistory { + const entry = h.undo[h.undo.length - 1] + if (entry === undefined) return h + const inverse = apply(entry) + return { ...h, undo: h.undo.slice(0, -1), redo: [...h.redo, inverse] } +} + +export function redoStroke(h: SculptHistory, apply: (entry: T) => T): SculptHistory { + const entry = h.redo[h.redo.length - 1] + if (entry === undefined) return h + const inverse = apply(entry) + return { ...h, undo: [...h.undo, inverse], redo: h.redo.slice(0, -1) } +} + +/** Whether the meshes differ from the file on disk (and a commit has work to do). */ +export function isHistoryDirty(h: SculptHistory): boolean { + return h.undo.length > 0 || h.truncated +} + +// ─── Upload ranges ─────────────────────────────────────────────────────────── + +export interface IndexRange { min: number; max: number } + +/** Union of two vertex index ranges; `region` with min > max (nothing moved) is ignored. */ +export function mergeRange(pending: IndexRange | null, region: IndexRange): IndexRange | null { + if (!(region.min <= region.max)) return pending + if (!pending) return { min: region.min, max: region.max } + return { min: Math.min(pending.min, region.min), max: Math.max(pending.max, region.max) } +} + +/** Vertex range → BufferAttribute.addUpdateRange(start, count) arguments. */ +export function attributeUpdateRange(range: IndexRange, itemSize: number): { start: number; count: number } { + return { start: range.min * itemSize, count: (range.max - range.min + 1) * itemSize } +} + +// ─── Commit payload ────────────────────────────────────────────────────────── + +/** Bytes per btoa() call: a multiple of 3 so the chunks concatenate without padding. */ +const BASE64_CHUNK = 3 * 10_922 + +/** Base64 of arbitrary bytes, encoded in slices (no giant binary string, no quadratic concat). */ +export function bytesToBase64(bytes: Uint8Array): string { + const parts: string[] = [] + for (let i = 0; i < bytes.length; i += BASE64_CHUNK) { + const slice = bytes.subarray(i, i + BASE64_CHUNK) + parts.push(btoa(String.fromCharCode.apply(null, slice as unknown as number[]))) + } + return parts.join('') +} + +/** Base64 of a Float32Array's bytes (little-endian on every platform Modly ships on). */ +export function float32ToBase64(values: Float32Array): string { + return bytesToBase64(new Uint8Array(values.buffer, values.byteOffset, values.byteLength)) +} + +/** One entry of POST /optimize/patch-vertices. */ +export interface VertexPatch { + mesh: number + primitive: number + positions: string + normals?: string +} + +/** + * Patch for one glTF primitive, from GLTFLoader's parser.associations entry of + * the sculpted THREE.Mesh. Null when the mesh isn't a glTF primitive. + */ +export function buildVertexPatch( + association: { meshes?: number; primitives?: number } | undefined, + positions: Float32Array, + normals: Float32Array | null, +): VertexPatch | null { + if (!association || typeof association.meshes !== 'number' || typeof association.primitives !== 'number') return null + const patch: VertexPatch = { mesh: association.meshes, primitive: association.primitives, positions: float32ToBase64(positions) } + if (normals) patch.normals = float32ToBase64(normals) + return patch +} + +// ─── Model URL change ──────────────────────────────────────────────────────── + +/** + * What to do with uncommitted strokes when the sculpt session of one model + * goes away: the same mesh still in the viewer (the viewer is unmounting) → + * save it; another mesh replaced it (undo, agent, workflow…) → the strokes no + * longer apply to anything and are dropped. + */ +export function sessionEndOutcome(dirty: boolean, sessionUrl: string, currentUrl: string | undefined): 'none' | 'commit' | 'discard' { + if (!dirty) return 'none' + return currentUrl === sessionUrl ? 'commit' : 'discard' +} + +// ─── Shortcuts ─────────────────────────────────────────────────────────────── + +export type SculptKeyAction = + | { type: 'undo' } + | { type: 'redo' } + | { type: 'exit' } + | { type: 'radius'; direction: 1 | -1 } + | { type: 'brush'; kind: BrushKind } + +const BRUSH_KEYS: Record = { g: 'grab', s: 'smooth', i: 'inflate', t: 'flatten' } + +export interface KeyLike { + key: string + ctrlKey: boolean + metaKey: boolean + shiftKey: boolean + altKey: boolean +} + +/** + * The sculpt-mode shortcut for a keydown, or null when the key isn't one. + * Ctrl+Z / Ctrl+Y (and Ctrl+Shift+Z) are the local stroke undo/redo; Escape + * exits; F / Shift+F grow / shrink the brush; G S I T pick a brush. + */ +export function sculptKeyAction(e: KeyLike): SculptKeyAction | null { + const key = e.key.toLowerCase() + if (e.ctrlKey || e.metaKey) { + if (key === 'z') return e.shiftKey ? { type: 'redo' } : { type: 'undo' } + if (key === 'y') return { type: 'redo' } + return null + } + if (e.altKey) return null + if (key === 'escape') return { type: 'exit' } + if (key === 'f') return { type: 'radius', direction: e.shiftKey ? -1 : 1 } + if (!e.shiftKey && BRUSH_KEYS[key]) return { type: 'brush', kind: BRUSH_KEYS[key] } + return null +} diff --git a/src/areas/generate/sculpt/sculptStore.ts b/src/areas/generate/sculpt/sculptStore.ts new file mode 100644 index 00000000..a2eaf297 --- /dev/null +++ b/src/areas/generate/sculpt/sculptStore.ts @@ -0,0 +1,183 @@ +// Sculpt mode state shared by the edit panel (settings), the viewer (strokes) +// and the page (shortcuts, commit before export / chat / ops). +// +// The meshes being sculpted live in the viewer: it registers a SculptSession +// that knows how to upload them and how to replay the stroke history. Anyone +// about to read or replace the mesh file awaits `commit()` first. + +import { create } from 'zustand' +import { useAppStore } from '@shared/stores/appStore' +import { apiErrorMessage } from '../meshEditPanel' +import type { BrushKind, StrokeRecord, SymmetryAxis } from './types' +import { + RADIUS_DEFAULT, + clampRadius, + emptyHistory, + isHistoryDirty, + stepRadius, + type SculptHistory, +} from './sculptLogic' + +/** One local undo step: a stroke on the SculptMesh of the geometry `meshKey`. */ +export interface SculptStrokeEntry { + meshKey: string + record: StrokeRecord +} + +/** Registered by the viewer for the model it shows. */ +export interface SculptSession { + /** Writes the sculpted vertices to a new mesh file and shows it (one global undo step). Throws on failure. */ + commit(): Promise + undo(): void + redo(): void +} + +export interface BrushParams { + /** Fraction of the model's bounding-box diagonal. */ + radius: number + /** 0..1. */ + strength: number +} + +export type SculptStatus = 'idle' | 'preparing' | 'saving' + +export const BRUSH_KINDS: BrushKind[] = ['grab', 'smooth', 'inflate', 'flatten'] + +const DEFAULT_BRUSHES: Record = { + grab: { radius: 0.08, strength: 1 }, + smooth: { radius: RADIUS_DEFAULT, strength: 0.5 }, + inflate: { radius: RADIUS_DEFAULT, strength: 0.3 }, + flatten: { radius: RADIUS_DEFAULT, strength: 0.5 }, +} + +interface SculptState { + active: boolean + brush: BrushKind + brushes: Record + symmetry: SymmetryAxis[] + history: SculptHistory + /** Strokes not yet written to the mesh file. */ + dirty: boolean + status: SculptStatus + error: string | null + session: SculptSession | null + + enter: () => void + /** Commits, then leaves sculpt mode. Stays in sculpt mode (false) when saving failed. */ + exit: () => Promise + /** Leaves sculpt mode without saving (the model went away). */ + reset: () => void + setBrush: (kind: BrushKind) => void + setRadius: (fraction: number) => void + stepRadius: (direction: 1 | -1) => void + setStrength: (strength: number) => void + toggleSymmetry: (axis: SymmetryAxis) => void + setHistory: (history: SculptHistory) => void + setStatus: (status: SculptStatus) => void + setError: (error: string | null) => void + /** Returns the unregister function. */ + registerSession: (session: SculptSession) => () => void + /** + * Writes pending strokes to the mesh file. Resolves true when there is + * nothing left to save, false when saving failed (the strokes are kept and + * the error is shown). + */ + commit: () => Promise + undo: () => void + redo: () => void +} + +let inflight: Promise | null = null + +export const useSculptStore = create()((set, get) => ({ + active: false, + brush: 'grab', + brushes: DEFAULT_BRUSHES, + symmetry: [], + history: emptyHistory(), + dirty: false, + status: 'idle', + error: null, + session: null, + + enter: () => set({ active: true, error: null }), + + exit: async () => { + const ok = await get().commit() + if (ok) set({ active: false, error: null }) + return ok + }, + + reset: () => set({ active: false, history: emptyHistory(), dirty: false, status: 'idle', error: null }), + + setBrush: (kind) => set({ brush: kind }), + + setRadius: (fraction) => { + const { brush, brushes } = get() + set({ brushes: { ...brushes, [brush]: { ...brushes[brush], radius: clampRadius(fraction) } } }) + }, + + stepRadius: (direction) => { + const { brush, brushes } = get() + get().setRadius(stepRadius(brushes[brush].radius, direction)) + }, + + setStrength: (strength) => { + const { brush, brushes } = get() + const s = Math.min(1, Math.max(0, Number.isFinite(strength) ? strength : 0)) + set({ brushes: { ...brushes, [brush]: { ...brushes[brush], strength: s } } }) + }, + + toggleSymmetry: (axis) => { + const { symmetry } = get() + set({ symmetry: symmetry.includes(axis) ? symmetry.filter((a) => a !== axis) : [...symmetry, axis].sort() }) + }, + + setHistory: (history) => set({ history, dirty: isHistoryDirty(history) }), + setStatus: (status) => set({ status }), + setError: (error) => set({ error }), + + registerSession: (session) => { + set({ session, history: emptyHistory(), dirty: false, status: 'idle' }) + return () => { + if (get().session === session) set({ session: null, history: emptyHistory(), dirty: false, status: 'idle' }) + } + }, + + commit: () => { + if (inflight) return inflight + const { session, dirty } = get() + if (!dirty) return Promise.resolve(true) + if (!session) return Promise.resolve(true) + set({ status: 'saving', error: null }) + inflight = session.commit() + .then(() => { + set({ history: emptyHistory(), dirty: false, status: 'idle' }) + return true + }, (err: unknown) => { + const message = apiErrorMessage(err) + set({ status: 'idle', error: `Could not save the sculpt: ${message}` }) + useAppStore.getState().showToast(`Could not save the sculpt: ${message}`, 5000) + return false + }) + .finally(() => { inflight = null }) + return inflight + }, + + undo: () => { + const { session, status } = get() + if (status !== 'idle') return + session?.undo() + }, + + redo: () => { + const { session, status } = get() + if (status !== 'idle') return + session?.redo() + }, +})) + +/** Await pending sculpt strokes before reading or replacing the mesh file. */ +export function commitSculpt(): Promise { + return useSculptStore.getState().commit() +} diff --git a/src/shared/hooks/useApi.ts b/src/shared/hooks/useApi.ts index d2c7104e..4b24b787 100644 --- a/src/shared/hooks/useApi.ts +++ b/src/shared/hooks/useApi.ts @@ -1,6 +1,7 @@ import axios from 'axios' import { useAppStore, GenerationOptions } from '@shared/stores/appStore' import type { MeshOpDescriptor, MeshOpResponse } from '@areas/generate/meshEditPanel' +import type { VertexPatch } from '@areas/generate/sculpt/sculptLogic' export function useApi() { const apiUrl = useAppStore((s) => s.apiUrl) @@ -150,5 +151,18 @@ export function useApi() { return data } - return { generateFromImage, pollJobStatus, cancelJob, getModelStatus, getAllModelsStatus, downloadModel, optimizeMesh, smoothMesh, importMesh, transformMesh, listMeshOps, runMeshOp } + // Writes sculpted vertex positions/normals into a copy of the mesh file. + // Each patch replaces whole attributes of one glTF primitive (base64 LE float32). + async function patchMeshVertices( + path: string, + patches: VertexPatch[], + ): Promise<{ path: string; url: string }> { + const { data } = await client.post<{ path: string; url: string }>('/optimize/patch-vertices', { + path, + patches, + }) + return data + } + + return { generateFromImage, pollJobStatus, cancelJob, getModelStatus, getAllModelsStatus, downloadModel, optimizeMesh, smoothMesh, importMesh, transformMesh, listMeshOps, runMeshOp, patchMeshVertices } } From 50463fe20028bb2323ee9774a94fa22d75b3d37a Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Thu, 12 Nov 2026 11:19:45 +0100 Subject: [PATCH 5/6] fix(deps): declare Pillow so mesh ops keep their textures 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. --- api/requirements.txt | 3 +++ api/tests/test_requirements.py | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 api/tests/test_requirements.py diff --git a/api/requirements.txt b/api/requirements.txt index 0462a51b..a6d9199d 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -13,6 +13,9 @@ pymeshlab>=2023.12 # trimesh's graph engine: connected parts, hole loops and vertex merging in # inspect_mesh / repair_mesh. Optional for trimesh, so never pulled in by it. scipy>=1.10 +# trimesh decodes glTF images with Pillow. Optional for trimesh: without it, +# every op that reloads a mesh silently drops its textures. +Pillow>=10.0 # Model downloads (HuggingFace) huggingface_hub>=0.27.0 diff --git a/api/tests/test_requirements.py b/api/tests/test_requirements.py new file mode 100644 index 00000000..bbd0445f --- /dev/null +++ b/api/tests/test_requirements.py @@ -0,0 +1,26 @@ +import re +import unittest +from pathlib import Path + + +REQUIREMENTS = Path(__file__).resolve().parents[1] / "requirements.txt" + + +def declared_packages() -> set[str]: + names = set() + for line in REQUIREMENTS.read_text(encoding="utf-8").splitlines(): + line = line.split("#", 1)[0].strip() + if line: + names.add(re.split(r"[\[<>=!~ ;]", line, maxsplit=1)[0].lower()) + return names + + +class RequirementsTest(unittest.TestCase): + def test_pillow_is_declared(self) -> None: + # Without Pillow, trimesh cannot decode glTF images and silently drops + # every texture when an op reloads and re-exports a mesh. + self.assertIn("pillow", declared_packages()) + + +if __name__ == "__main__": + unittest.main() From cca63fd703631b1175e9c68ca91be304fdc18cc2 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Thu, 12 Nov 2026 12:42:21 +0100 Subject: [PATCH 6/6] fix(sculpt): repoint every primitive sharing the patched POSITION accessor 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. --- api/services/mesh_ops/glb_patch.py | 28 ++++++++++++++++++---- api/tests/test_optimize_patch_vertices.py | 29 +++++++++++++++++------ 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/api/services/mesh_ops/glb_patch.py b/api/services/mesh_ops/glb_patch.py index 56839f4a..3d68d588 100644 --- a/api/services/mesh_ops/glb_patch.py +++ b/api/services/mesh_ops/glb_patch.py @@ -85,13 +85,24 @@ def patch_glb_bytes(data: bytes, patches: list[VertexPatch]) -> bytes: if patch.normals is not None: normals_raw, _ = _decode(patch.normals, count, f"{label} normals") - new_attributes = dict(attributes) - new_attributes["POSITION"] = _append_accessor( + old_normal = attributes.get("NORMAL") + new_position = _append_accessor( out, views, accessors, positions_raw, count, bounds=_bounds(positions), ) + new_normal = None if normals_raw is not None: - new_attributes["NORMAL"] = _append_accessor(out, views, accessors, normals_raw, count) - primitive["attributes"] = new_attributes + new_normal = _append_accessor(out, views, accessors, normals_raw, count) + + # The viewer loads an accessor once and shares it between the primitives + # using it, so the stroke moved all of them: repoint them all, or the + # unpatched ones snap back and the mesh tears. Normals follow only where + # they were shared as well. + for other in _primitives_using(meshes, "POSITION", position_index): + other_attributes = dict(other.get("attributes") or {}) + other_attributes["POSITION"] = new_position + if new_normal is not None and other_attributes.get("NORMAL") == old_normal: + other_attributes["NORMAL"] = new_normal + other["attributes"] = other_attributes buffers[0]["byteLength"] = len(out) return _build_glb(gltf, bytes(out)) @@ -152,6 +163,15 @@ def _primitive(meshes: list, mesh: int, primitive: int) -> dict: return primitives[primitive] +def _primitives_using(meshes: list, attribute: str, accessor: int) -> list[dict]: + return [ + primitive + for mesh in meshes + for primitive in (mesh.get("primitives") or []) + if (primitive.get("attributes") or {}).get(attribute) == accessor + ] + + def _decode(encoded: str, count: int, label: str) -> tuple[bytes, array]: try: raw = base64.b64decode(encoded, validate=True) diff --git a/api/tests/test_optimize_patch_vertices.py b/api/tests/test_optimize_patch_vertices.py index 187a64c8..d9e545bc 100644 --- a/api/tests/test_optimize_patch_vertices.py +++ b/api/tests/test_optimize_patch_vertices.py @@ -189,7 +189,9 @@ def test_adds_normal_attribute_when_missing(self) -> None: attributes = out_gltf["meshes"][0]["primitives"][0]["attributes"] np.testing.assert_array_equal(_accessor_floats(out_gltf, out_bin, attributes["NORMAL"]), normals) - def test_shared_position_accessor_only_changes_patched_primitive(self) -> None: + def test_shared_position_accessor_moves_every_primitive_using_it(self) -> None: + # The viewer loads one accessor as one BufferAttribute, so a stroke on + # either primitive moves both; the file must agree or the mesh tears. source = self.collection / "shared.glb" _shared_accessor_glb(source) moved = np.array([[5, 5, 5], [6, 5, 5], [5, 6, 5]], dtype=" None: out_gltf, out_bin = _read_glb(self.workspace / response["path"]) first = out_gltf["meshes"][0]["primitives"][0]["attributes"] second = out_gltf["meshes"][1]["primitives"][0]["attributes"] - self.assertEqual(first, {"POSITION": 0}) self.assertNotEqual(second["POSITION"], 0) - self.assertNotIn("NORMAL", second) - np.testing.assert_array_equal( - _accessor_floats(out_gltf, out_bin, 0), - np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=" None: + source = self.collection / "shared.glb" + _shared_accessor_glb(source) + gltf, binary = _read_glb(source) + gltf["meshes"][0]["primitives"][0]["attributes"]["NORMAL"] = 0 # its own (dummy) normals + _write_glb(source, gltf, binary) + moved = np.array([[5, 5, 5], [6, 5, 5], [5, 6, 5]], dtype=" None: source = self.collection / "odd.glb" _shared_accessor_glb(source)