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/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..3d68d588 --- /dev/null +++ b/api/services/mesh_ops/glb_patch.py @@ -0,0 +1,225 @@ +"""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") + + 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_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)) + + +# --------------------------------------------------------------------------- + +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 _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) + 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..d9e545bc --- /dev/null +++ b/api/tests/test_optimize_patch_vertices.py @@ -0,0 +1,314 @@ +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: + # 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: + 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) + 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() 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() diff --git a/package.json b/package.json index d6117ff3..22e207ac 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 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/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 new file mode 100644 index 00000000..558496b6 --- /dev/null +++ b/src/areas/generate/sculpt/SculptMesh.ts @@ -0,0 +1,687 @@ +// Sculpt core: brushes applied in place on a geometry's position/normal arrays. +// +// 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, + DirtyRegion, + SculptMeshOptions, + 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. */ + 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 + /** 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 + * 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, + ) { + 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) + } + + /** + * 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 { + 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() + } + + /** + * 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 { + 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 { + 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), + } + } + + /** + * 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 } { + 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 { + 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/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/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/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 +} 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 } +} 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 } }