feat: floorplan sketch editor - #87
Conversation
997517f to
07b0b16
Compare
There was a problem hiding this comment.
Pull request overview
Adds a floorplan sketch editor that extrudes editable room geometry while preserving per-surface materials and persistence.
Changes:
- Adds floorplan validation, topology, triangulation, snapping, and raycast contracts.
- Adds room reconciliation, persistence, and undo/redo support.
- Integrates a Sketch panel and drawing tool with extensive tests.
Reviewed changes
Copilot reviewed 29 out of 29 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
src/test-utils/room-fakes.ts |
Adds lightweight room test doubles. |
src/render/floorplan-tool.ts |
Implements canvas sketch interaction and previews. |
src/render/__tests__/floorplan-tool.spec.ts |
Tests tool projection, input, and lifecycle. |
src/objects/surface.ts |
Persists mesh face identifiers. |
src/objects/room.ts |
Persists editable room meshes. |
src/objects/room-mesh-editor.ts |
Adds mesh editing and history integration. |
src/objects/room-from-mesh.ts |
Converts and reconciles meshes with rooms. |
src/objects/mesh-userdata.ts |
Stores and validates mesh metadata. |
src/objects/__tests__/surface.spec.ts |
Tests face-ID persistence. |
src/objects/__tests__/room-mesh-editor.spec.ts |
Tests editing and history behavior. |
src/objects/__tests__/room-from-mesh.spec.ts |
Tests room reconciliation. |
src/objects/__tests__/mesh-userdata.spec.ts |
Tests metadata persistence and validation. |
src/compute/geometry/triangulate.ts |
Adds polygon ear-clipping triangulation. |
src/compute/geometry/sync-plan.ts |
Plans stable face reconciliation. |
src/compute/geometry/sketch-input.ts |
Implements sketch and snapping state. |
src/compute/geometry/room-mesh.ts |
Defines editable room topology. |
src/compute/geometry/floorplan.ts |
Validates and extrudes floorplans. |
src/compute/geometry/__tests__/triangulate.spec.ts |
Tests triangulation properties. |
src/compute/geometry/__tests__/sync-plan.spec.ts |
Tests reconciliation planning. |
src/compute/geometry/__tests__/sketch-input.spec.ts |
Tests sketch interactions. |
src/compute/geometry/__tests__/room-mesh.spec.ts |
Tests topology and edits. |
src/compute/geometry/__tests__/raycast-contract.spec.ts |
Verifies normals and watertightness. |
src/compute/geometry/__tests__/floorplan.spec.ts |
Tests floorplan extrusion. |
src/components/workbench/WorkbenchLayout.tsx |
Registers the Sketch panel. |
src/components/workbench/panels/SketchPanel.tsx |
Adds the sketch editor UI. |
src/components/workbench/panels/__tests__/SketchPanel.spec.tsx |
Tests panel workflows. |
src/components/workbench/defaultLayout.ts |
Adds the default Sketch tab. |
src/components/workbench/__tests__/flexlayout-smoke.test.tsx |
Updates layout smoke mocks. |
src/components/parameter-config/property-row/PropertyRowNumberInput.tsx |
Adds name and disabled support. |
Suppressed comments (3)
src/components/workbench/panels/SketchPanel.tsx:165
- Once a room has been adopted, this guard prevents reconciliation on later store replacements. Opening another project or deleting the room leaves
roompointing at a disposed object, so subsequent height edits mutate the old room instead of the current project. On each container change, first verify the selected room still exists and adopt/clear it as needed.
useEffect(() => {
if (room || adoptionDismissed) return;
src/objects/room-from-mesh.ts:148
- Reconciliation changes
room.allSurfacesand geometry but never refreshes Room's derived state. After adding a wall,room.surfaceMaplacks its UUID, soresponse-by-intensity.ts:25dereferencesundefinedwhen a ray hits it;boundingBoxand cachedvolumealso remain stale. Rebuild these fields after all add/update/remove operations.
// Keep the Room's record current, so the next diff is against what is
// actually on screen. This is also what lets undo/redo be a plain re-sync.
setRoomMesh(room, mesh);
src/objects/room-mesh-editor.ts:65
- Undo/redo only mutates the Room object. It does not request a render, increment observable store state, or update SketchPanel's separate
height/draft state, so undoing a height/point edit leaves both the viewport and controls showing the post-edit value until another action occurs. The recall path must publish the restored mesh and request rendering.
recallFunction: (direction?: keyof Directions) => {
syncRoomFromMesh(room, direction === 'UNDO' ? before : after, options);
},
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Eight issues raised on #87, all verified against the code before acting. Two were masked by tests of mine that asserted the right outcome by the wrong route; those tests are corrected here and now fail without the fix. - Adoption never fired on a real project load. The panel subscribed to the container store's `version`, but addContainer/removeContainer replace `containers` without bumping it. Subscribes to `containers` instead. The test only passed because its fixture set `version` by hand; it no longer does. - Editing a point coordinate updated the preview but never reconciled the room, so rendered and acoustic geometry silently diverged from the outline until an unrelated height change flushed it. The test that claimed to cover this changed the height immediately afterwards, which is what actually triggered the reconcile. - Sketch tab was unreachable for existing users. Persisted layouts are restored verbatim and never consult DEFAULT_LAYOUT. Adds an idempotent ensureSketchTab migration applied on load. - Keyboard shortcuts are bound to window, so Backspace/Delete/Escape/Enter fired while a coordinate input had focus — deleting a digit deleted a sketch point. Events originating from inputs, textareas, selects and contenteditable elements are now ignored. - Non-finite coordinates and baseZ passed validation and reached BufferGeometry as NaN vertices. NaN slips through the shoelace and duplicate checks silently, since comparisons against it are false. - isRoomMesh accepted string/NaN vertex components and fractional, negative or out-of-range loop indices, marking such a save editable so triangulation could then dereference undefined — the crash the validator exists to prevent. - Geometry-only edits emitted no MARK_DIRTY, so a project could hold unsaved mesh changes with hasUnsavedChanges false, skipping the Open/New warning. Topology changes were covered incidentally via add/removeContainer; height and vertex edits were not. - Surface.init detached the old mesh, wire, edges and normal helper without freeing their GPU buffers, leaking a set per surface on every re-init — once per keystroke while dragging a height. Disposes them, deduping the geometry mesh and wire deliberately share, skipping any geometry handed back in, and leaving shared materials alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated 5 comments.
Suppressed comments (6)
src/objects/room-from-mesh.ts:143
Surface.dispose()only detaches the object; it does not dispose the mesh, edge, or vertex-normal GPU resources. Therefore every topology edit that removes a face leaks all buffers (and the helper material) for that wall, and undo/redo can repeat the leak. Add resource cleanup to the Surface disposal path before emitting removal.
for (const id of plan.removed) {
const surface = byFaceId.get(id)!;
surface.dispose();
emit('REMOVE_SURFACE', surface.uuid);
src/compute/geometry/floorplan.ts:164
- The preceding loop records a missing/null point as
non-finite, but execution continues here and dereferencesa.x/b.xbefore the early return at line 176. Thus malformed runtime input throws instead of returning the collected validation issue. Skip this geometric check whenever either endpoint is not a finite point.
for (let i = 0; i < points.length; i++) {
const a = points[i];
const b = points[(i + 1) % points.length];
if (Math.abs(a.x - b.x) < EPS && Math.abs(a.y - b.y) < EPS) {
src/components/workbench/panels/SketchPanel.tsx:170
- This early return prevents the panel from reacting when the current project replaces or removes the adopted room. After Open/New,
roomstill points at the old disposed object, so subsequent height edits callsetFloorplanon an object no longer in the store;adoptionDismissedlikewise suppresses adoption in later projects. On each containers change, first verify the selected room UUID is still present, then clear/re-adopt when the project contents change.
useEffect(() => {
if (room || adoptionDismissed) return;
src/compute/geometry/floorplan.ts:249
- Wall IDs are array positions rather than stable edge identities. Inserting/appending a point changes the physical edge represented by an existing ID (for example, the former closing
wall-3becomes the edge from the old last point to the new point), so reconciliation preserves that wall's material on the wrong wall. Use persistent vertex/edge IDs, or explicitly remap unchanged geometric edges, before claiming material-preserving topology edits.
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
faces.push({
id: `wall-${i}`,
name: `Wall ${i + 1}`,
// floor i -> ceiling i -> ceiling j -> floor j, which for a CCW outline
// puts the normal at (-dy, dx): the interior side of the wall.
loop: [i, n + i, n + j, j],
src/objects/surface.ts:275
- The claim that all materials are module singletons does not hold for
VertexNormalsHelper: each helper constructs its own material. Re-init disposes the helper geometry but abandons that material, so every live edit still leaks one material per surface. Dispose the old helper's material (or invoke a helper disposal method that releases both geometry and material) while retaining the shared mesh/wire materials.
// Release the GPU buffers behind the objects just detached. Removing them
// from the group does not free anything, so every re-init — a restore, or
// a live geometry edit — otherwise leaks a set of buffers per surface.
// A Set dedupes mesh and wire, which deliberately share one geometry.
// Materials are module-level singletons and must not be disposed here.
// Anything handed back in as the new geometry is left alone.
const stale = new Set<THREE.BufferGeometry>();
for (const object of [this.mesh, this.wire, this.edges, this.vertexNormals]) {
const geometry = object?.geometry as THREE.BufferGeometry | undefined;
// Helpers such as VertexNormalsHelper are supplied by three/examples,
// so do not assume every attached geometry is disposable.
if (geometry && geometry !== props.geometry && typeof geometry.dispose === 'function') {
stale.add(geometry);
}
}
stale.forEach((geometry) => geometry.dispose());
src/objects/room-from-mesh.ts:129
Surface.initrebuilds every omitted option from defaults, so a geometry edit silently resets the surface's scattering coefficient to 0.1. This coefficient is solver input and can be configured per surface, so changing only the room height changes the acoustic model. Pass the current coefficient back intoinitalong with the material.
surface.init({
geometry: geometryForFace(mesh, face),
acousticMaterial: surface.acousticMaterial,
});
Draw a closed outline on the ground plane, set a height, and extrude it into a room. Previously geometry could only arrive by import (DXF/OBJ/ STL/DAE); this covers the common shoebox and polygonal cases directly. Architecture is layered so the geometry logic is testable without mocks: - compute/geometry/ is pure — no THREE, no csg, no store. Holds the editable topology, the floorplan generator, ear-clipping triangulation, the sketch-input state machine and reconciliation planning. Its tests use no mocks at all, in contrast to the object layer, where importing Surface requires stubbing compute/csg (its jscad bundle loads from an http: URL and cannot resolve under vitest). - objects/room-from-mesh.ts adapts a mesh into Room/Surface and is the only seam with the rest of the app. - render/floorplan-tool.ts takes its camera, element and parent by injection, so it can be driven in a test. Two properties are load-bearing and fail silently rather than loudly, so they are pinned hard: - Winding. The raytracer reads intersection.face.normal (ray-core.ts), so an inverted room looks correct and models wrong. Every face loop is CCW seen from inside, and raycast-contract.spec.ts verifies this end to end against real three.js: interior rays always meet a normal pointing back at them, and crossing parity is odd from inside, even from outside. - Welding. An n-point outline emits exactly 2n shared vertices, so a corner is one vertex belonging to three faces. Faces stay polygonal loops over shared indices and are only triangulated at the boundary, which is what makes later vertex manipulation possible. Rooms are reconciled rather than rebuilt. Surfaces are matched to faces by a stable id, so a height change updates geometry in place and the user's acoustic material assignments survive. One face becomes one Surface, so materials remain assignable per wall. Undo/redo needs no inverse operations: restoring a remembered mesh through the same reconciler recreates deleted walls and removes added ones. Mesh and face ids persist in Room/Surface save objects, so a reloaded project stays editable. Both fields are optional and dropped by JSON when absent, leaving existing save files byte-identical, and restore validates the mesh rather than trusting it. The panel uses the shared property-row components so it matches the other parameter panels. PropertyRowNumberInput gains optional name and disabled props (additive; no existing call site changes). Verified in project-varese against a live browser: draw, close, create, per-wall surfaces, live height edit, and Z-up orientation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight issues raised on #87, all verified against the code before acting. Two were masked by tests of mine that asserted the right outcome by the wrong route; those tests are corrected here and now fail without the fix. - Adoption never fired on a real project load. The panel subscribed to the container store's `version`, but addContainer/removeContainer replace `containers` without bumping it. Subscribes to `containers` instead. The test only passed because its fixture set `version` by hand; it no longer does. - Editing a point coordinate updated the preview but never reconciled the room, so rendered and acoustic geometry silently diverged from the outline until an unrelated height change flushed it. The test that claimed to cover this changed the height immediately afterwards, which is what actually triggered the reconcile. - Sketch tab was unreachable for existing users. Persisted layouts are restored verbatim and never consult DEFAULT_LAYOUT. Adds an idempotent ensureSketchTab migration applied on load. - Keyboard shortcuts are bound to window, so Backspace/Delete/Escape/Enter fired while a coordinate input had focus — deleting a digit deleted a sketch point. Events originating from inputs, textareas, selects and contenteditable elements are now ignored. - Non-finite coordinates and baseZ passed validation and reached BufferGeometry as NaN vertices. NaN slips through the shoelace and duplicate checks silently, since comparisons against it are false. - isRoomMesh accepted string/NaN vertex components and fractional, negative or out-of-range loop indices, marking such a save editable so triangulation could then dereference undefined — the crash the validator exists to prevent. - Geometry-only edits emitted no MARK_DIRTY, so a project could hold unsaved mesh changes with hasUnsavedChanges false, skipping the Open/New warning. Topology changes were covered incidentally via add/removeContainer; height and vertex edits were not. - Surface.init detached the old mesh, wire, edges and normal helper without freeing their GPU buffers, leaking a set per surface on every re-init — once per keystroke while dragging a height. Disposes them, deduping the geometry mesh and wire deliberately share, skipping any geometry handed back in, and leaving shared materials alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five further issues, all verified against the code before acting. - Reconciliation left Room's derived caches stale. surfaceMap is indexed for every ray hit by reflectionLossFunction (compute/raytracer/response-by-intensity.ts:25), so a ray meeting a newly added wall found undefined and failed the solve; volume and boundingBox also kept their pre-edit values, which quietly skews the statistical solvers. Room gains refreshDerivedGeometry, used by init and called after every sync. - The tool captured renderer.camera once, but Renderer.setOrtho replaces that object when the projection is toggled, leaving the tool raycasting through a camera the user can no longer see. It now accepts a getter and the panel passes one. - floorplanSource only checked shapes. `typeof NaN === 'number'` and unchecked entries meant `height: NaN` or `points: [null]` were treated as a valid plan, then reached draftFromPoints, which dereferences p.x and took the panel down — the opposite of leaving the room quietly non-editable. - move-vertex accepted a non-finite destination, poisoning every face touching that vertex and only surfacing later as NaN vertices. - A vertex dragged until a face is degenerate triangulated to nothing, and Surface.init then dereferenced _triangles[0] — after reconciliation had already begun mutating the room. All geometries are now built before anything is touched, so such an edit is refused with the room intact. This also removes the duplicate geometry build in the added-face path. - Restore stored a mesh without checking the restored surfaces could be reconciled against it. Duplicate faceIds are now rejected, leaving the room non-editable as the contract promises. Faces with no surface stay allowed on purpose: deleting a wall from the object tree is legitimate and the reconciler puts it back, so rejecting that would make a room permanently non-editable after an ordinary action. The consistency check lives in mesh-userdata rather than room.ts so it is testable without standing up the renderer and Surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3327a0d to
c222a35
Compare
Draw a closed outline on the ground plane, set a height, extrude it into a room. Geometry could previously only arrive by import (DXF/OBJ/STL/DAE); this covers the common shoebox and polygonal cases directly, in a new Sketch panel alongside Objects/Solvers/Renderer.
Layering
The geometry logic is deliberately kept where it can be tested without mocks.
compute/geometry/objects/room-from-mesh.ts,room-mesh-editor.tsrender/floorplan-tool.tscomponents/…/SketchPanel.tsxThis matters here specifically: importing
Surfacepulls incompute/csg, whose jscad bundle loads from anhttp:URL and cannot resolve under vitest —surface.spec.tsalready has to stub it. Keeping the geometry core import-free means the topology, triangulation, snapping and reconciliation logic is tested directly.Two properties that fail silently
Both would produce a room that looks right and models wrong, so they're pinned hard.
Winding. The raytracer reads
intersection.face.normal(ray-core.ts:70), so orientation is physically meaningful. Every face loop is CCW seen from inside.raycast-contract.spec.tsverifies this end to end against real three.js: interior rays always meet a normal pointing back at them, and crossing parity is odd from inside / even from outside — a watertightness check that a gap or T-junction breaks. Verified on a shoebox and a concave L-shape.Welding. An n-point outline emits exactly
2nshared vertices, so a corner is one vertex belonging to three faces. Faces stay polygonal loops over shared indices and are triangulated only at the boundary. Triangulation is ear-clipping, not a fan — room floors are routinely concave and a fan spills outside the outline.Reconciliation, not rebuilding
Surfaces are matched to faces by stable id, so a height change updates geometry in place via
Surface.initand the user's acoustic material assignments survive. One face becomes one Surface, so materials stay assignable per wall — the point of the exercise for a room-acoustics tool.Undo/redo needed no inverse operations: both directions are "re-sync against a remembered mesh", and the reconciler handles walls added or removed as a consequence.
Persistence
Mesh and face ids ride in
Room/Surfacesave objects, so a reloaded project stays editable and the panel re-adopts it. Both fields are optional and dropped byJSON.stringifywhen absent, so existing save files round-trip byte-identically. Restore validates the mesh rather than trusting it — a truncated or hand-edited save leaves the room non-editable instead of failing the whole restore.Notes for review
PropertyRowNumberInputgains optionalnameanddisabled(additive, defaulted — no existing call site changes).detached; the panel adopts it read-only rather than showing a stale outline and silently discarding those edits on the next re-extrude. Unreachable through today's UI, but it becomes reachable when direct vertex editing lands.flexlayout-smoke.test.tsxnow mocksSketchPanellike the other panels — unmocked it reachescompute/csg.dist/is intentionally not included; CI regenerates it.Testing
2293 tests / 105 files, lint and typecheck clean. ~440 tests are new.
Verified live in project-varese against a real browser: draw → close → create, per-wall surfaces (Floor, Ceiling, Wall 1–4) in the object tree, live height edit updating in place, correct Z-up orientation, no console errors.
End-to-end solve
A sketched room (4-point outline, 36.8 m perimeter, 2.5 m height) with a source and receiver placed inside was run through the Ray Tracer on CPU:
Rays stayed inside the room for the whole run, which is watertightness confirmed in the solver itself rather than only in the raycast tests - a leak would let rays escape and convergence would stall instead of climbing to 86%.
Review rounds
Two rounds of automated review raised 13 issues; all were verified against the code and fixed (commits
70caeda,3327a0d). Two were bugs my own tests had masked by asserting the right outcome via the wrong route - the adoption fixture setversionby hand, and the point-edit test changed the height immediately afterwards. Those tests now fail without their fixes.One prescription was deliberately narrowed: restore rejects duplicate faceIds rather than requiring exactly one Surface per mesh face. The strict form would make a room permanently non-editable after a user deletes a wall from the object tree - an ordinary action the reconciler already handles correctly - in exchange for guarding a state our own code cannot produce.
Known gaps
applyEdit'smove-vertexop is implemented and tested to prove the topology supports it; only the gizmo is missing.Pre-existing bugs found while testing
Both are outside this PR and filed separately:
RayTracer.dispose()throws when a solver is deleted before it has ever run.TransformInputdrops a leading minus sign, so negative coordinates cannot be typed.Generated with Claude Code