Skip to content

feat: floorplan sketch editor - #87

Open
rdmiller wants to merge 3 commits into
varese-devfrom
feature/floorplan-sketch-editor
Open

feat: floorplan sketch editor#87
rdmiller wants to merge 3 commits into
varese-devfrom
feature/floorplan-sketch-editor

Conversation

@rdmiller

@rdmiller rdmiller commented Aug 10, 2026

Copy link
Copy Markdown

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.

Layer Imports Tests
compute/geometry/ none no mocks
objects/room-from-mesh.ts, room-mesh-editor.ts THREE, Surface, Room light fakes
render/floorplan-tool.ts THREE (injected camera/element/parent) real THREE + real DOM events
components/…/SketchPanel.tsx shared property-row components real tool, mocked object layer

This matters here specifically: importing Surface pulls in compute/csg, whose jscad bundle loads from an http: URL and cannot resolve under vitestsurface.spec.ts already 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.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 — 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 2n shared 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.init and 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/Surface save objects, so a reloaded project stays editable and the panel re-adopts it. Both fields are optional and dropped by JSON.stringify when 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

  • PropertyRowNumberInput gains optional name and disabled (additive, defaulted — no existing call site changes).
  • A mesh edited past its floorplan is marked 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.tsx now mocks SketchPanel like the other panels — unmocked it reaches compute/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:

Metric Value
Valid rays 531 -> 2871
Convergence ratio 4.5% -> 86.3%
Est. T30, 125 Hz-8 kHz 0.62 / 0.60 / 0.30 / 0.19 / 0.32 / 0.34 / 0.29 s

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 set version by 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

  • A wall deleted and then restored by undo returns with the default material, not its own (the original Surface was disposed).
  • Direct 3D vertex manipulation is not included. applyEdit's move-vertex op 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.
  • TransformInput drops a leading minus sign, so negative coordinates cannot be typed.

Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 room pointing 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.allSurfaces and geometry but never refreshes Room's derived state. After adding a wall, room.surfaceMap lacks its UUID, so response-by-intensity.ts:25 dereferences undefined when a ray hits it; boundingBox and cached volume also 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.

Comment thread src/components/workbench/panels/SketchPanel.tsx Outdated
Comment thread src/render/floorplan-tool.ts
Comment thread src/compute/geometry/floorplan.ts
Comment thread src/components/workbench/defaultLayout.ts
Comment thread src/components/workbench/panels/SketchPanel.tsx Outdated
Comment thread src/objects/room-mesh-editor.ts
Comment thread src/objects/mesh-userdata.ts
Comment thread src/objects/room-from-mesh.ts
rdmiller added a commit that referenced this pull request Aug 10, 2026
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>
@rdmiller
rdmiller requested a balanced review from Copilot August 10, 2026 22:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 dereferences a.x/b.x before 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, room still points at the old disposed object, so subsequent height edits call setFloorplan on an object no longer in the store; adoptionDismissed likewise 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-3 becomes 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.init rebuilds 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 into init along with the material.
    surface.init({
      geometry: geometryForFace(mesh, face),
      acousticMaterial: surface.acousticMaterial,
    });

Comment thread src/objects/room-from-mesh.ts
Comment thread src/components/workbench/panels/SketchPanel.tsx
Comment thread src/compute/geometry/room-mesh.ts
Comment thread src/compute/geometry/room-mesh.ts
Comment thread src/objects/room.ts Outdated
rdmiller and others added 3 commits August 10, 2026 17:49
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>
@rdmiller
rdmiller force-pushed the feature/floorplan-sketch-editor branch from 3327a0d to c222a35 Compare August 10, 2026 22:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants