From 930e1c929b3e4b8bb675b08cc2d59ae7ce3dea8a Mon Sep 17 00:00:00 2001 From: Rob Miller Date: Mon, 10 Aug 2026 14:08:46 -0500 Subject: [PATCH 1/3] feat: floorplan sketch editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../property-row/PropertyRowNumberInput.tsx | 10 +- src/components/workbench/WorkbenchLayout.tsx | 3 + .../__tests__/flexlayout-smoke.test.tsx | 3 + src/components/workbench/defaultLayout.ts | 9 + .../workbench/panels/SketchPanel.tsx | 421 ++++++++++++ .../panels/__tests__/SketchPanel.spec.tsx | 614 ++++++++++++++++++ .../geometry/__tests__/floorplan.spec.ts | 372 +++++++++++ .../__tests__/raycast-contract.spec.ts | 156 +++++ .../geometry/__tests__/room-mesh.spec.ts | 213 ++++++ .../geometry/__tests__/sketch-input.spec.ts | 379 +++++++++++ .../geometry/__tests__/sync-plan.spec.ts | 96 +++ .../geometry/__tests__/triangulate.spec.ts | 369 +++++++++++ src/compute/geometry/floorplan.ts | 240 +++++++ src/compute/geometry/room-mesh.ts | 173 +++++ src/compute/geometry/sketch-input.ts | 210 ++++++ src/compute/geometry/sync-plan.ts | 68 ++ src/compute/geometry/triangulate.ts | 205 ++++++ src/objects/__tests__/mesh-userdata.spec.ts | 178 +++++ src/objects/__tests__/room-from-mesh.spec.ts | 271 ++++++++ .../__tests__/room-mesh-editor.spec.ts | 346 ++++++++++ src/objects/__tests__/surface.spec.ts | 43 ++ src/objects/mesh-userdata.ts | 73 +++ src/objects/room-from-mesh.ts | 151 +++++ src/objects/room-mesh-editor.ts | 96 +++ src/objects/room.ts | 15 + src/objects/surface.ts | 9 +- src/render/__tests__/floorplan-tool.spec.ts | 356 ++++++++++ src/render/floorplan-tool.ts | 279 ++++++++ src/test-utils/room-fakes.ts | 87 +++ 29 files changed, 5443 insertions(+), 2 deletions(-) create mode 100644 src/components/workbench/panels/SketchPanel.tsx create mode 100644 src/components/workbench/panels/__tests__/SketchPanel.spec.tsx create mode 100644 src/compute/geometry/__tests__/floorplan.spec.ts create mode 100644 src/compute/geometry/__tests__/raycast-contract.spec.ts create mode 100644 src/compute/geometry/__tests__/room-mesh.spec.ts create mode 100644 src/compute/geometry/__tests__/sketch-input.spec.ts create mode 100644 src/compute/geometry/__tests__/sync-plan.spec.ts create mode 100644 src/compute/geometry/__tests__/triangulate.spec.ts create mode 100644 src/compute/geometry/floorplan.ts create mode 100644 src/compute/geometry/room-mesh.ts create mode 100644 src/compute/geometry/sketch-input.ts create mode 100644 src/compute/geometry/sync-plan.ts create mode 100644 src/compute/geometry/triangulate.ts create mode 100644 src/objects/__tests__/mesh-userdata.spec.ts create mode 100644 src/objects/__tests__/room-from-mesh.spec.ts create mode 100644 src/objects/__tests__/room-mesh-editor.spec.ts create mode 100644 src/objects/mesh-userdata.ts create mode 100644 src/objects/room-from-mesh.ts create mode 100644 src/objects/room-mesh-editor.ts create mode 100644 src/render/__tests__/floorplan-tool.spec.ts create mode 100644 src/render/floorplan-tool.ts create mode 100644 src/test-utils/room-fakes.ts diff --git a/src/components/parameter-config/property-row/PropertyRowNumberInput.tsx b/src/components/parameter-config/property-row/PropertyRowNumberInput.tsx index f8ce34cd..4e00f459 100644 --- a/src/components/parameter-config/property-row/PropertyRowNumberInput.tsx +++ b/src/components/parameter-config/property-row/PropertyRowNumberInput.tsx @@ -34,9 +34,12 @@ interface Props { step?: number; min?: number; max?: number; + /** Applied to the underlying input, for form semantics and testing. */ + name?: string; + disabled?: boolean; } -export const PropertyRowNumberInput = ({ value, onChange, step = 1, min, max }: Props) => { +export const PropertyRowNumberInput = ({ value, onChange, step = 1, min, max, name, disabled }: Props) => { const inputRef = useRef(null); // Store latest values in refs so the wheel handler always has current values @@ -45,6 +48,7 @@ export const PropertyRowNumberInput = ({ value, onChange, step = 1, min, max }: const minRef = useRef(min); const maxRef = useRef(max); const onChangeRef = useRef(onChange); + const disabledRef = useRef(disabled); // Keep refs in sync valueRef.current = value; @@ -52,6 +56,7 @@ export const PropertyRowNumberInput = ({ value, onChange, step = 1, min, max }: minRef.current = min; maxRef.current = max; onChangeRef.current = onChange; + disabledRef.current = disabled; // Use non-passive wheel listener to allow preventDefault useEffect(() => { @@ -59,6 +64,7 @@ export const PropertyRowNumberInput = ({ value, onChange, step = 1, min, max }: if (!input) return; const handleWheel = (e: WheelEvent) => { + if (disabledRef.current) return; e.preventDefault(); const delta = e.deltaY < 0 ? stepRef.current : -stepRef.current; let newValue = valueRef.current + delta; @@ -91,11 +97,13 @@ export const PropertyRowNumberInput = ({ value, onChange, step = 1, min, max }: variant="outlined" value={value} onChange={handleChange} + disabled={disabled} slotProps={{ htmlInput: { step, min, max, + name, }, }} sx={numberInputSx} diff --git a/src/components/workbench/WorkbenchLayout.tsx b/src/components/workbench/WorkbenchLayout.tsx index 0a22497e..51cd3673 100644 --- a/src/components/workbench/WorkbenchLayout.tsx +++ b/src/components/workbench/WorkbenchLayout.tsx @@ -24,6 +24,7 @@ import { CanvasPanel } from './panels/CanvasPanel'; import { ObjectsPanel } from './panels/ObjectsPanel'; import { SolversPanel } from './panels/SolversPanel'; import { RendererPanel } from './panels/RendererPanel'; +import { SketchPanel } from './panels/SketchPanel'; import { ResultsPanelWrapper } from './panels/ResultsPanelWrapper'; import storage from '../../lib/storage'; @@ -69,6 +70,8 @@ export function WorkbenchLayout() { return ; case 'RendererPanel': return ; + case 'SketchPanel': + return ; case 'ResultsPanel': return ; default: diff --git a/src/components/workbench/__tests__/flexlayout-smoke.test.tsx b/src/components/workbench/__tests__/flexlayout-smoke.test.tsx index 773dc347..591a60ef 100644 --- a/src/components/workbench/__tests__/flexlayout-smoke.test.tsx +++ b/src/components/workbench/__tests__/flexlayout-smoke.test.tsx @@ -18,6 +18,9 @@ vi.mock('../panels/RendererPanel', () => ({ RendererPanel: () =>
renderer-p vi.mock('../panels/ResultsPanelWrapper', () => ({ ResultsPanelWrapper: () =>
results-panel
, })); +// Mocked like the rest — unmocked it reaches Surface and therefore compute/csg, +// whose jscad bundle loads from an http: URL and cannot resolve under vitest. +vi.mock('../panels/SketchPanel', () => ({ SketchPanel: () =>
sketch-panel
})); describe('flexlayout 0.10.0 smoke', () => { beforeEach(() => localStorage.clear()); diff --git a/src/components/workbench/defaultLayout.ts b/src/components/workbench/defaultLayout.ts index b139c45d..0051918f 100644 --- a/src/components/workbench/defaultLayout.ts +++ b/src/components/workbench/defaultLayout.ts @@ -60,6 +60,14 @@ export const DEFAULT_LAYOUT: IJsonModel = { enableClose: false, enablePopout: false, }, + { + type: 'tab', + id: 'sketch', + name: 'Sketch', + component: 'SketchPanel', + enableClose: false, + enablePopout: false, + }, ], }, ], @@ -118,5 +126,6 @@ export const PANEL_IDS = { OBJECTS: 'objects', SOLVERS: 'solvers', RENDERER: 'renderer', + SKETCH: 'sketch', RESULTS: 'results', } as const; diff --git a/src/components/workbench/panels/SketchPanel.tsx b/src/components/workbench/panels/SketchPanel.tsx new file mode 100644 index 00000000..19940531 --- /dev/null +++ b/src/components/workbench/panels/SketchPanel.tsx @@ -0,0 +1,421 @@ +/** + * SketchPanel - draw a floorplan and extrude it into a room. + * + * Owns the drawing session: it mounts a FloorplanTool against the renderer's + * camera and canvas, mirrors the tool's draft into React state, and turns a + * closed outline into a Room via the geometry adapter. + * + * Once a room exists the panel keeps editing it: changing the height or nudging + * a point calls `setFloorplan`, which reconciles the existing Surfaces rather + * than rebuilding them, so acoustic material assignments survive. Those edits + * go through history, so ctrl-Z works on them like anything else. + * + * Note on modes: there is an `EditorModes.SKETCH` enum, but nothing reacts to + * it — the handlers are empty stubs and it lives on the legacy `cram.state` + * global. Drawing is gated on this panel's own toggle instead. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Alert from '@mui/material/Alert'; +import type { SxProps, Theme } from '@mui/material/styles'; + +import { renderer } from '../../../render/renderer'; +import { messenger } from '../../../messenger'; +import { FloorplanTool } from '../../../render/floorplan-tool'; + +// The shared properties-panel vocabulary, as used by RayTracerTab, RT60Tab, +// TransformTable and the rest of parameter-config. Reusing it keeps spacing, +// type scale and control styling identical across panels. +import PropertyRow from '../../parameter-config/property-row/PropertyRow'; +import PropertyRowLabel from '../../parameter-config/property-row/PropertyRowLabel'; +import PropertyRowButton from '../../parameter-config/property-row/PropertyRowButton'; +import PropertyRowCheckbox from '../../parameter-config/property-row/PropertyRowCheckbox'; +import PropertyRowNumberInput from '../../parameter-config/property-row/PropertyRowNumberInput'; +import SectionLabel from '../../parameter-config/property-row/SectionLabel'; + +import { + closeDraft, + draftFromPoints, + draftIssues, + draftPerimeter, + emptyDraft, + toFloorplanParams, + type SketchDraft, +} from '../../../compute/geometry/sketch-input'; +import { floorplanSource } from '../../../compute/geometry/room-mesh'; +import { floorplanToMesh } from '../../../compute/geometry/floorplan'; +import { addRoomFromMesh, getRoomMesh } from '../../../objects/room-from-mesh'; +import { setFloorplan } from '../../../objects/room-mesh-editor'; +import { useMaterial } from '../../../store/material-store'; +import { useContainer } from '../../../store'; +import type Room from '../../../objects/room'; + +const DEFAULT_HEIGHT = 2.5; +const DEFAULT_GRID = 0.25; + +const containerSx: SxProps = { + height: '100%', + overflow: 'auto', + bgcolor: 'background.paper', + pb: 1, +}; + +/** Matches PropertyRowLabel's type scale, for read-only values in a row. */ +const summaryTextSx: SxProps = { + fontSize: '0.75rem', + color: 'text.primary', + pl: 1, +}; + +const hintTextSx: SxProps = { + fontSize: '0.75rem', + color: 'text.secondary', + px: 1, + py: 0.5, +}; + +/** Two number fields sharing one property row, as Position x/y/z does. */ +const pointFieldsSx: SxProps = { + display: 'grid', + gridTemplateColumns: '1fr 1fr', +}; + +const alertSx: SxProps = { + mx: 1, + my: 0.5, + fontSize: '0.75rem', + py: 0, + '& .MuiAlert-message': { py: 0.75 }, +}; + +function defaultMaterial() { + return [...useMaterial.getState().materials.values()][0]; +} + +export function SketchPanel() { + const [ready, setReady] = useState(() => !!renderer.scene); + const [draft, setDraft] = useState(emptyDraft); + const [height, setHeight] = useState(DEFAULT_HEIGHT); + const [gridSize, setGridSize] = useState(DEFAULT_GRID); + const [ortho, setOrtho] = useState(false); + const [drawing, setDrawing] = useState(false); + const [error, setError] = useState(null); + // Set when the adopted room's mesh has been edited directly, so its floorplan + // no longer describes it. + const [detached, setDetached] = useState(false); + // Once the user explicitly starts a new room, stop re-adopting the old one. + const [adoptionDismissed, setAdoptionDismissed] = useState(false); + + const toolRef = useRef(null); + // State, not a ref: the "start a new room" section and the Create button's + // disabled state both depend on it, so it has to trigger a render. + const [room, setRoom] = useState(null); + + // The renderer is initialised on APP_MOUNTED; its camera and canvas do not + // exist before that, so the tool cannot be built yet. + useEffect(() => { + if (ready) return; + const [msg, id] = messenger.addMessageHandler('APP_MOUNTED', () => setReady(true)); + if (renderer.scene) setReady(true); + return () => messenger.removeMessageHandler(msg, id); + }, [ready]); + + useEffect(() => { + if (!ready || toolRef.current) return; + + const tool = new FloorplanTool({ + domElement: renderer.renderer.domElement, + camera: renderer.camera, + parent: renderer.workspace, + settings: { gridSize, ortho }, + onChange: (next) => { + setDraft(next); + renderer.needsToRender = true; + }, + }); + toolRef.current = tool; + + return () => { + tool.dispose(); + toolRef.current = null; + }; + // Settings are pushed separately; rebuilding the tool on every change would + // discard the outline in progress. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ready]); + + useEffect(() => { + toolRef.current?.setSettings({ gridSize, ortho }); + }, [gridSize, ortho]); + + /** Push an edited draft back into the tool so the preview follows. */ + const applyDraft = useCallback((next: SketchDraft) => { + const tool = toolRef.current; + if (tool) tool.setDraft(next); + else setDraft(next); + }, []); + + // Rooms arrive asynchronously — a project load replaces the whole container + // store — so this watches the store rather than running once on mount. + const containerVersion = useContainer((state) => state.version); + + useEffect(() => { + if (room || adoptionDismissed) return; + + const candidates = useContainer.getState().getRooms(); + for (const candidate of candidates) { + const mesh = getRoomMesh(candidate); + if (!mesh) continue; + const source = floorplanSource(mesh); + if (!source) continue; + + setRoom(candidate); + setDetached(source.detached); + // A detached mesh has been edited beyond its plan, so the stored points + // no longer describe the room. Showing them would misrepresent it, and + // re-extruding from them would silently discard those edits. + if (!source.detached) { + setHeight(source.params.height); + applyDraft(draftFromPoints(source.params.points)); + } + return; + } + }, [containerVersion, room, adoptionDismissed, applyDraft]); + + const toggleDrawing = useCallback(() => { + const tool = toolRef.current; + if (!tool) return; + if (tool.enabled) { + tool.disable(); + setDrawing(false); + } else { + tool.enable(); + setDrawing(true); + } + renderer.needsToRender = true; + }, []); + + const handleClear = useCallback(() => { + toolRef.current?.reset(); + setError(null); + }, []); + + const handleUndo = useCallback(() => toolRef.current?.undo(), []); + + const handleClose = useCallback(() => { + const tool = toolRef.current; + if (tool) tool.close(); + else setDraft((d) => closeDraft(d)); + }, []); + + const handlePointChange = useCallback( + (index: number, axis: 'x' | 'y', value: number) => { + applyDraft({ + ...draft, + points: draft.points.map((p, i) => (i === index ? { ...p, [axis]: value } : p)), + }); + }, + [draft, applyDraft] + ); + + /** Re-extrude the room already on screen, preserving its materials. */ + const reextrude = useCallback( + (nextHeight: number, nextDraft: SketchDraft) => { + const params = toFloorplanParams(nextDraft, nextHeight); + // Refuse to re-extrude a mesh that has been edited past its plan: doing + // so would throw those edits away without asking. + if (!room || !params || detached) return; + try { + setFloorplan(room, params, { acousticMaterial: defaultMaterial() }); + setError(null); + renderer.needsToRender = true; + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, + [room, detached] + ); + + const handleHeightChange = useCallback( + (value: number) => { + setHeight(value); + reextrude(value, draft); + }, + [draft, reextrude] + ); + + const handleCreate = useCallback(() => { + const params = toFloorplanParams(draft, height); + if (!params) return; + try { + const created = addRoomFromMesh(floorplanToMesh(params), { + acousticMaterial: defaultMaterial(), + name: 'sketched room', + }); + setRoom(created); + setError(null); + toolRef.current?.disable(); + setDrawing(false); + renderer.needsToRender = true; + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, [draft, height]); + + const handleStartNew = useCallback(() => { + setRoom(null); + setDetached(false); + setAdoptionDismissed(true); + toolRef.current?.reset(); + setError(null); + }, []); + + const issues = draftIssues(draft, height); + const canCreate = draft.closed && issues.length === 0 && !room; + const perimeter = draftPerimeter(draft); + + if (!ready) return null; + + return ( + + + + + + handleHeightChange(value)} + /> + + + + + setGridSize(value)} + /> + + + + + setOrtho(value)} /> + + + + + + + + + + + + {draft.points.length} + {draft.closed ? ' (closed)' : ''} + + + + + + + {perimeter.toFixed(2)} m + + + + {draft.points.map((point, index) => ( + + + + handlePointChange(index, 'x', value)} + /> + handlePointChange(index, 'y', value)} + /> + + + ))} + + + + + {issues.length > 0 && ( + + {issues.map((issue) => issue.message).join('; ')} + + )} + + {error && ( + + {error} + + )} + + {detached && ( + + This room has been edited directly, so its floorplan no longer describes it. Start a + new room to sketch again. + + )} + + {room && ( + <> + + + {detached + ? 'Plan editing is unavailable for this room.' + : 'Editing the height or a point updates the room in place.'} + + + + )} + + ); +} + +export default SketchPanel; diff --git a/src/components/workbench/panels/__tests__/SketchPanel.spec.tsx b/src/components/workbench/panels/__tests__/SketchPanel.spec.tsx new file mode 100644 index 00000000..c20577e5 --- /dev/null +++ b/src/components/workbench/panels/__tests__/SketchPanel.spec.tsx @@ -0,0 +1,614 @@ +/** + * SketchPanel tests. + * + * The renderer and the object layer are mocked, but the FloorplanTool is real + * and runs against a real three.js camera and a real DOM element — so clicking + * on the "canvas" genuinely drives the draft the way it will in the app. + * + * Camera setup matches floorplan-tool.spec.ts: a 10x10 orthographic frustum + * over a 100x100 element, looking down -Z. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, within, act } from '@testing-library/react'; +import * as THREE from 'three'; + +import { SketchPanel } from '../SketchPanel'; +import { addRoomFromMesh } from '../../../../objects/room-from-mesh'; +import { setFloorplan } from '../../../../objects/room-mesh-editor'; +import { useContainer } from '../../../../store'; +import { floorplanToMesh } from '../../../../compute/geometry/floorplan'; +import { applyEdit } from '../../../../compute/geometry/room-mesh'; +import { ROOM_MESH_KEY } from '../../../../objects/mesh-userdata'; + +const SIZE = 100; + +const { fakeRenderer } = vi.hoisted(() => { + return { fakeRenderer: { current: null as unknown } }; +}); + +vi.mock('../../../../render/renderer', () => ({ + get renderer() { + return fakeRenderer.current; + }, +})); + +vi.mock('../../../../messenger', () => ({ + messenger: { + addMessageHandler: vi.fn(() => ['APP_MOUNTED', 0]), + removeMessageHandler: vi.fn(), + }, + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), +})); + +vi.mock('../../../../objects/room-from-mesh', async () => { + // Only the Room-constructing half is stubbed; the userData accessor is real, + // so adoption is exercised against the actual storage the app uses. + const { getRoomMesh } = await import('../../../../objects/mesh-userdata'); + return { + addRoomFromMesh: vi.fn(() => ({ uuid: 'room-1', name: 'sketched room' })), + getRoomMesh, + }; +}); + +vi.mock('../../../../objects/room-mesh-editor', () => ({ + setFloorplan: vi.fn(), +})); + +vi.mock('../../../../store/material-store', () => ({ + useMaterial: { + getState: () => ({ materials: new Map([['m1', { uuid: 'm1', name: 'Default' }]]) }), + }, +})); + +let canvas: HTMLElement; + +function buildRenderer() { + const camera = new THREE.OrthographicCamera(-5, 5, 5, -5, 0.1, 100); + camera.position.set(0, 0, 10); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(true); + + canvas = document.createElement('div'); + canvas.getBoundingClientRect = () => + ({ left: 0, top: 0, width: SIZE, height: SIZE, right: SIZE, bottom: SIZE, x: 0, y: 0 }) as DOMRect; + document.body.appendChild(canvas); + + return { + scene: new THREE.Scene(), + camera, + workspace: new THREE.Object3D(), + renderer: { domElement: canvas }, + needsToRender: false, + }; +} + +/** Screen coords for a world ground point. */ +function screenFor(x: number, y: number) { + return { clientX: ((x / 5) * 0.5 + 0.5) * SIZE, clientY: (0.5 - (y / 5) * 0.5) * SIZE }; +} + +function clickGround(x: number, y: number) { + const { clientX, clientY } = screenFor(x, y); + fireEvent(canvas, new MouseEvent('pointerdown', { clientX, clientY, button: 0, bubbles: true })); +} + +const numberInput = (name: string) => + document.querySelector(`input[name="${name}"]`) as HTMLInputElement; + +const pointCount = () => screen.getByTestId('point-count').textContent?.trim() ?? ''; +const perimeter = () => screen.getByTestId('perimeter').textContent?.replace(/\s+/g, ' ').trim(); + +/** Draw a square, leaving the outline open. */ +function drawSquare() { + fireEvent.click(screen.getByTestId('toggle-drawing')); + for (const [x, y] of [[0, 0], [4, 0], [4, 4], [0, 4]]) clickGround(x, y); +} + +beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + fakeRenderer.current = buildRenderer(); + useContainer.setState({ containers: {}, version: 0 }); +}); + +describe('readiness', () => { + it('renders nothing until the renderer has a scene', () => { + fakeRenderer.current = { ...(buildRenderer() as object), scene: undefined }; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders once the renderer is up', () => { + render(); + expect(screen.getByText('Floorplan')).toBeTruthy(); + }); +}); + +describe('drawing', () => { + it('toggles the tool on and off', () => { + render(); + const toggle = screen.getByTestId('toggle-drawing'); + expect(toggle.textContent).toBe('Draw'); + + fireEvent.click(toggle); + expect(toggle.textContent).toBe('Stop drawing'); + + fireEvent.click(toggle); + expect(toggle.textContent).toBe('Draw'); + }); + + it('ignores canvas clicks until drawing is started', () => { + render(); + clickGround(2, 2); + expect(pointCount()).toBe('0'); + }); + + it('adds a point per click', () => { + render(); + drawSquare(); + expect(pointCount()).toBe('4'); + }); + + it('uses the singular for one point', () => { + render(); + fireEvent.click(screen.getByTestId('toggle-drawing')); + clickGround(1, 1); + expect(pointCount()).toBe('1'); + }); + + it('lists each point with its coordinates', () => { + render(); + drawSquare(); + expect(numberInput('point-1-x').value).toBe('4'); + expect(numberInput('point-1-y').value).toBe('0'); + }); + + it('reports the perimeter', () => { + render(); + drawSquare(); + expect(perimeter()).toBe('12.00 m'); + }); + + it('undoes the last point', () => { + render(); + drawSquare(); + fireEvent.click(screen.getByRole('button', { name: 'Undo last point' })); + expect(pointCount()).toBe('3'); + }); + + it('clears the outline', () => { + render(); + drawSquare(); + fireEvent.click(screen.getByRole('button', { name: 'Clear outline' })); + expect(pointCount()).toBe('0'); + }); +}); + +describe('closing the outline', () => { + it('disables Close until there are three points', () => { + render(); + fireEvent.click(screen.getByTestId('toggle-drawing')); + clickGround(0, 0); + clickGround(4, 0); + expect(screen.getByRole('button', { name: 'Close outline' })).toBeDisabled(); + }); + + it('closes via the button', () => { + render(); + drawSquare(); + fireEvent.click(screen.getByRole('button', { name: 'Close outline' })); + expect(pointCount()).toContain('(closed)'); + }); + + it('closes by clicking back on the start point', () => { + render(); + drawSquare(); + clickGround(0.1, 0.1); + expect(pointCount()).toContain('(closed)'); + }); +}); + +describe('creating a room', () => { + it('keeps Create disabled while the outline is open', () => { + render(); + drawSquare(); + expect(screen.getByRole('button', { name: 'Create room' })).toBeDisabled(); + }); + + it('enables Create once closed and valid', () => { + render(); + drawSquare(); + fireEvent.click(screen.getByRole('button', { name: 'Close outline' })); + expect(screen.getByRole('button', { name: 'Create room' })).toBeEnabled(); + }); + + it('builds the room from the drawn outline and height', () => { + render(); + drawSquare(); + fireEvent.click(screen.getByRole('button', { name: 'Close outline' })); + fireEvent.click(screen.getByRole('button', { name: 'Create room' })); + + expect(addRoomFromMesh).toHaveBeenCalledTimes(1); + const [mesh, options] = vi.mocked(addRoomFromMesh).mock.calls[0]; + expect(mesh.faces.map((f) => f.id)).toEqual([ + 'floor', + 'ceiling', + 'wall-0', + 'wall-1', + 'wall-2', + 'wall-3', + ]); + expect(options.acousticMaterial).toMatchObject({ uuid: 'm1' }); + }); + + it('extrudes to the height shown in the panel', () => { + render(); + drawSquare(); + fireEvent.click(screen.getByRole('button', { name: 'Close outline' })); + fireEvent.change(numberInput('height'), { target: { value: '4' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create room' })); + + const [mesh] = vi.mocked(addRoomFromMesh).mock.calls[0]; + expect(Math.max(...mesh.vertices.map((v) => v[2]))).toBeCloseTo(4); + }); + + it('stops drawing and offers a fresh start once a room exists', () => { + render(); + drawSquare(); + fireEvent.click(screen.getByRole('button', { name: 'Close outline' })); + fireEvent.click(screen.getByRole('button', { name: 'Create room' })); + + expect(screen.getByTestId('toggle-drawing').textContent).toBe('Draw'); + expect(screen.getByRole('button', { name: 'Start a new room' })).toBeTruthy(); + }); + + it('cannot create the same room twice', () => { + render(); + drawSquare(); + fireEvent.click(screen.getByRole('button', { name: 'Close outline' })); + fireEvent.click(screen.getByRole('button', { name: 'Create room' })); + expect(screen.getByRole('button', { name: 'Create room' })).toBeDisabled(); + }); + + it('surfaces a failure instead of throwing', () => { + vi.mocked(addRoomFromMesh).mockImplementationOnce(() => { + throw new Error('boom'); + }); + render(); + drawSquare(); + fireEvent.click(screen.getByRole('button', { name: 'Close outline' })); + fireEvent.click(screen.getByRole('button', { name: 'Create room' })); + + expect(within(screen.getByTestId('sketch-error')).getByText(/boom/)).toBeTruthy(); + }); +}); + +describe('editing an existing room', () => { + function createRoom() { + render(); + drawSquare(); + fireEvent.click(screen.getByRole('button', { name: 'Close outline' })); + fireEvent.click(screen.getByRole('button', { name: 'Create room' })); + } + + it('re-extrudes when the height changes', () => { + createRoom(); + fireEvent.change(numberInput('height'), { target: { value: '3.5' } }); + + expect(setFloorplan).toHaveBeenCalledTimes(1); + const [, params] = vi.mocked(setFloorplan).mock.calls[0]; + expect(params.height).toBe(3.5); + }); + + it('re-extrudes when a point is edited numerically', () => { + createRoom(); + fireEvent.change(numberInput('point-1-x'), { target: { value: '6' } }); + fireEvent.change(numberInput('height'), { target: { value: '3' } }); + + const [, params] = vi.mocked(setFloorplan).mock.calls.at(-1)!; + expect(params.points[1].x).toBe(6); + }); + + it('does not re-extrude before a room exists', () => { + render(); + drawSquare(); + fireEvent.change(numberInput('height'), { target: { value: '3' } }); + expect(setFloorplan).not.toHaveBeenCalled(); + }); + + it('reports a failed re-extrude', () => { + vi.mocked(setFloorplan).mockImplementationOnce(() => { + throw new Error('bad plan'); + }); + createRoom(); + fireEvent.change(numberInput('height'), { target: { value: '3' } }); + expect(within(screen.getByTestId('sketch-error')).getByText(/bad plan/)).toBeTruthy(); + }); + + it('detaches from the room when starting a new one', () => { + createRoom(); + fireEvent.click(screen.getByRole('button', { name: 'Start a new room' })); + fireEvent.change(numberInput('height'), { target: { value: '3' } }); + expect(setFloorplan).not.toHaveBeenCalled(); + }); +}); + +describe('numeric point entry', () => { + it('updates the listed coordinate', () => { + render(); + drawSquare(); + fireEvent.change(numberInput('point-0-x'), { target: { value: '-2' } }); + expect(numberInput('point-0-x').value).toBe('-2'); + }); + + it('feeds the edited outline into the created room', () => { + render(); + drawSquare(); + fireEvent.change(numberInput('point-2-x'), { target: { value: '8' } }); + fireEvent.click(screen.getByRole('button', { name: 'Close outline' })); + fireEvent.click(screen.getByRole('button', { name: 'Create room' })); + + const [mesh] = vi.mocked(addRoomFromMesh).mock.calls[0]; + expect(Math.max(...mesh.vertices.map((v) => v[0]))).toBeCloseTo(8); + }); +}); + +describe('snap settings', () => { + it('applies the grid to new clicks', () => { + render(); + fireEvent.change(numberInput('gridSize'), { target: { value: '1' } }); + fireEvent.click(screen.getByTestId('toggle-drawing')); + clickGround(2.4, 3.4); + + expect(numberInput('point-0-x').value).toBe('2'); + expect(numberInput('point-0-y').value).toBe('3'); + }); + + it('constrains to axes when ortho is enabled', () => { + render(); + fireEvent.change(numberInput('gridSize'), { target: { value: '0' } }); + fireEvent.click(screen.getByRole('checkbox')); + fireEvent.click(screen.getByTestId('toggle-drawing')); + clickGround(0, 0); + clickGround(4, 1); + + expect(Number(numberInput('point-1-y').value)).toBeCloseTo(0); + }); +}); + +describe('validation feedback', () => { + it('warns about a self-intersecting outline', () => { + render(); + fireEvent.change(numberInput('gridSize'), { target: { value: '0' } }); + fireEvent.click(screen.getByTestId('toggle-drawing')); + for (const [x, y] of [[0, 0], [4, 4], [4, 0], [0, 3]]) clickGround(x, y); + fireEvent.click(screen.getByRole('button', { name: 'Close outline' })); + + expect(within(screen.getByTestId('sketch-issues')).getByText(/cross each other/)).toBeTruthy(); + }); + + it('blocks creation while the outline is invalid', () => { + render(); + fireEvent.change(numberInput('gridSize'), { target: { value: '0' } }); + fireEvent.click(screen.getByTestId('toggle-drawing')); + for (const [x, y] of [[0, 0], [4, 4], [4, 0], [0, 3]]) clickGround(x, y); + fireEvent.click(screen.getByRole('button', { name: 'Close outline' })); + + expect(screen.getByRole('button', { name: 'Create room' })).toBeDisabled(); + }); + + it('shows no warning for a valid outline', () => { + render(); + drawSquare(); + fireEvent.click(screen.getByRole('button', { name: 'Close outline' })); + expect(screen.queryByTestId('sketch-issues')).toBeNull(); + }); +}); + +const SQUARE = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 0, y: 3 }, +]; + +/** A minimal stand-in for a Room restored from a save file. */ +function seedRoom(userData: Record, kind = 'room') { + const roomLike = { uuid: 'restored-room', name: 'sketched room', kind, userData }; + useContainer.setState({ + containers: { 'restored-room': roomLike } as never, + version: 1, + }); + return roomLike; +} + +const savedMesh = (height = 3) => + JSON.parse(JSON.stringify(floorplanToMesh({ points: SQUARE, height }))); + +describe('adopting a room from the project', () => { + it('picks up a sketched room already in the scene', () => { + seedRoom({ [ROOM_MESH_KEY]: savedMesh(3) }); + render(); + + expect(pointCount()).toContain('4'); + expect(pointCount()).toContain('(closed)'); + }); + + it('restores the saved height', () => { + seedRoom({ [ROOM_MESH_KEY]: savedMesh(3.75) }); + render(); + expect(numberInput('height').value).toBe('3.75'); + }); + + it('restores the outline coordinates', () => { + seedRoom({ [ROOM_MESH_KEY]: savedMesh() }); + render(); + expect(numberInput('point-1-x').value).toBe('4'); + expect(numberInput('point-2-y').value).toBe('3'); + }); + + it('offers to start a new room, showing it is adopted', () => { + seedRoom({ [ROOM_MESH_KEY]: savedMesh() }); + render(); + expect(screen.getByRole('button', { name: 'Start a new room' })).toBeTruthy(); + }); + + it('edits the adopted room rather than creating another', () => { + const roomLike = seedRoom({ [ROOM_MESH_KEY]: savedMesh(3) }); + render(); + + fireEvent.change(numberInput('height'), { target: { value: '5' } }); + + expect(setFloorplan).toHaveBeenCalledTimes(1); + const [target, params] = vi.mocked(setFloorplan).mock.calls[0]; + expect(target).toBe(roomLike); + expect(params.height).toBe(5); + expect(addRoomFromMesh).not.toHaveBeenCalled(); + }); + + it('adopts a room that appears after mount, as on project load', () => { + render(); + expect(pointCount()).toBe('0'); + + // Wrapped in act: the store update originates outside React, so the + // subscription-driven re-render has to be flushed before asserting. + act(() => { + seedRoom({ [ROOM_MESH_KEY]: savedMesh() }); + }); + + expect(pointCount()).toContain('4'); + }); + + it('ignores an imported room with no mesh', () => { + seedRoom({}); + render(); + expect(pointCount()).toBe('0'); + expect(screen.queryByRole('button', { name: 'Start a new room' })).toBeNull(); + }); + + it('ignores a mesh whose floorplan provenance is malformed', () => { + const mesh = savedMesh(); + mesh.source = { kind: 'manual' }; + seedRoom({ [ROOM_MESH_KEY]: mesh }); + render(); + expect(pointCount()).toBe('0'); + }); + + it('ignores containers that are not rooms', () => { + seedRoom({ [ROOM_MESH_KEY]: savedMesh() }, 'surface'); + render(); + expect(pointCount()).toBe('0'); + }); + + it('does not re-adopt after the user starts a new room', () => { + seedRoom({ [ROOM_MESH_KEY]: savedMesh() }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Start a new room' })); + + expect(pointCount()).toBe('0'); + expect(screen.queryByRole('button', { name: 'Start a new room' })).toBeNull(); + }); +}); + +describe('a room edited past its floorplan', () => { + const detachedMesh = () => { + const mesh = floorplanToMesh({ points: SQUARE, height: 3 }); + return JSON.parse( + JSON.stringify(applyEdit(mesh, { kind: 'move-vertex', id: 0, to: [-2, -2, 0] })) + ); + }; + + it('is adopted but flagged', () => { + seedRoom({ [ROOM_MESH_KEY]: detachedMesh() }); + render(); + expect(screen.getByTestId('sketch-detached')).toBeTruthy(); + }); + + it('does not show a floorplan that no longer describes it', () => { + seedRoom({ [ROOM_MESH_KEY]: detachedMesh() }); + render(); + expect(pointCount()).toBe('0'); + }); + + it('locks the height field', () => { + seedRoom({ [ROOM_MESH_KEY]: detachedMesh() }); + render(); + expect(numberInput('height').disabled).toBe(true); + }); + + it('refuses to re-extrude, which would discard the direct edits', () => { + seedRoom({ [ROOM_MESH_KEY]: detachedMesh() }); + render(); + + fireEvent.change(numberInput('height'), { target: { value: '5' } }); + + expect(setFloorplan).not.toHaveBeenCalled(); + }); + + it('can be set aside to sketch a new room', () => { + seedRoom({ [ROOM_MESH_KEY]: detachedMesh() }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Start a new room' })); + expect(screen.queryByTestId('sketch-detached')).toBeNull(); + }); +}); + +describe('number fields', () => { + it('adjusts on wheel and swallows the scroll, as every CRAM number field does', () => { + // Shared behaviour from PropertyRowNumberInput: the wheel nudges the value + // by one step and calls preventDefault, so the panel does not also scroll. + // Worth pinning because the panel can show a long list of coordinates. + render(); + drawSquare(); + + const input = numberInput('point-0-x'); + const before = Number(input.value); + const event = new WheelEvent('wheel', { deltaY: -1, cancelable: true, bubbles: true }); + + act(() => { + input.dispatchEvent(event); + }); + + expect(event.defaultPrevented).toBe(true); + expect(Number(numberInput('point-0-x').value)).toBeCloseTo(before + 0.1); + }); + + it('does not adjust on wheel when the field is disabled', () => { + const mesh = floorplanToMesh({ points: SQUARE, height: 3 }); + seedRoom({ + [ROOM_MESH_KEY]: JSON.parse( + JSON.stringify(applyEdit(mesh, { kind: 'move-vertex', id: 0, to: [-2, -2, 0] })) + ), + }); + render(); + + const input = numberInput('height'); + const before = input.value; + act(() => { + input.dispatchEvent(new WheelEvent('wheel', { deltaY: -1, cancelable: true, bubbles: true })); + }); + + expect(numberInput('height').value).toBe(before); + }); + + it('ignores a cleared field rather than reading it as zero', () => { + render(); + drawSquare(); + fireEvent.change(numberInput('point-0-x'), { target: { value: '' } }); + expect(numberInput('point-0-x').value).toBe('0'); + }); + + it('disables every field for a detached room', () => { + const mesh = floorplanToMesh({ points: SQUARE, height: 3 }); + seedRoom({ + [ROOM_MESH_KEY]: JSON.parse( + JSON.stringify(applyEdit(mesh, { kind: 'move-vertex', id: 0, to: [-2, -2, 0] })) + ), + }); + render(); + expect(numberInput('height').disabled).toBe(true); + }); +}); diff --git a/src/compute/geometry/__tests__/floorplan.spec.ts b/src/compute/geometry/__tests__/floorplan.spec.ts new file mode 100644 index 00000000..30f05bb6 --- /dev/null +++ b/src/compute/geometry/__tests__/floorplan.spec.ts @@ -0,0 +1,372 @@ +/** + * Floorplan generator tests. + * + * The two properties worth defending here are welding (an n-point outline must + * produce exactly 2n shared vertices) and winding (every face normal must point + * into the room, because the raytracer reads it). + */ + +import { describe, it, expect } from 'vitest'; +import { + signedArea, + normalizeWinding, + validateFloorplan, + floorplanToMesh, + type Point2, + type FloorplanParams, +} from '../floorplan'; +import { faceNormal, faceCentroid, facesTouchingVertex, type Vec3 } from '../room-mesh'; + +/** 4 x 3 room, drawn counter-clockwise. */ +const SHOEBOX: Point2[] = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 0, y: 3 }, +]; + +/** L-shaped room: a 4x4 square with a 2x2 bite taken out. Concave. */ +const L_SHAPE: Point2[] = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 2 }, + { x: 2, y: 2 }, + { x: 2, y: 4 }, + { x: 0, y: 4 }, +]; + +const plan = (points: Point2[], height = 2.5, baseZ?: number): FloorplanParams => ({ + points, + height, + ...(baseZ === undefined ? {} : { baseZ }), +}); + +function expectVec3Close(actual: Vec3, expected: Vec3, precision = 9) { + expect(actual[0]).toBeCloseTo(expected[0], precision); + expect(actual[1]).toBeCloseTo(expected[1], precision); + expect(actual[2]).toBeCloseTo(expected[2], precision); +} + +describe('signedArea', () => { + it('is positive for a counter-clockwise outline', () => { + expect(signedArea(SHOEBOX)).toBeCloseTo(12); + }); + + it('is negative for a clockwise outline', () => { + expect(signedArea([...SHOEBOX].reverse())).toBeCloseTo(-12); + }); + + it('computes the area of a concave outline', () => { + // 4x4 square less a 2x2 corner + expect(signedArea(L_SHAPE)).toBeCloseTo(12); + }); + + it('is zero for collinear points', () => { + expect(signedArea([{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 2 }])).toBeCloseTo(0); + }); +}); + +describe('normalizeWinding', () => { + it('leaves a counter-clockwise outline alone', () => { + expect(normalizeWinding(SHOEBOX)).toEqual(SHOEBOX); + }); + + it('flips a clockwise outline to counter-clockwise', () => { + expect(signedArea(normalizeWinding([...SHOEBOX].reverse()))).toBeGreaterThan(0); + }); + + it('keeps point 0 in place when flipping, so corner identity is stable', () => { + const clockwise = [...SHOEBOX].reverse(); + const fixed = normalizeWinding(clockwise); + expect(fixed[0]).toEqual(clockwise[0]); + }); + + it('does not mutate its input', () => { + const input = [...SHOEBOX].reverse(); + const snapshot = JSON.stringify(input); + normalizeWinding(input); + expect(JSON.stringify(input)).toBe(snapshot); + }); +}); + +describe('validateFloorplan', () => { + it('accepts a well-formed plan', () => { + expect(validateFloorplan(plan(SHOEBOX))).toEqual([]); + }); + + it('accepts a concave plan', () => { + expect(validateFloorplan(plan(L_SHAPE))).toEqual([]); + }); + + it('rejects fewer than 3 points', () => { + const issues = validateFloorplan(plan([{ x: 0, y: 0 }, { x: 1, y: 0 }])); + expect(issues.map((i) => i.code)).toContain('too-few-points'); + }); + + it.each([0, -1, NaN, Infinity])('rejects height %p', (height) => { + const issues = validateFloorplan(plan(SHOEBOX, height)); + expect(issues.map((i) => i.code)).toContain('invalid-height'); + }); + + it('rejects consecutive duplicate points', () => { + const issues = validateFloorplan( + plan([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 0 }, { x: 0, y: 3 }]) + ); + const dup = issues.find((i) => i.code === 'duplicate-point'); + expect(dup).toBeDefined(); + expect(dup).toMatchObject({ index: 1 }); + }); + + it('explains that the loop closes automatically when the first point is repeated', () => { + const issues = validateFloorplan( + plan([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 3 }, { x: 0, y: 0 }]) + ); + const dup = issues.find((i) => i.code === 'duplicate-point'); + expect(dup?.message).toMatch(/closes automatically/); + }); + + it('rejects a collinear outline as enclosing no area', () => { + const issues = validateFloorplan( + plan([{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 2 }]) + ); + expect(issues.map((i) => i.code)).toContain('zero-area'); + }); + + it('rejects a self-intersecting bowtie', () => { + const bowtie: Point2[] = [ + { x: 0, y: 0 }, + { x: 5, y: 4 }, + { x: 5, y: 0 }, + { x: 0, y: 3 }, + ]; + const issues = validateFloorplan(plan(bowtie)); + expect(issues.map((i) => i.code)).toContain('self-intersecting'); + }); + + it('calls a symmetric bowtie crossed rather than empty', () => { + // Its lobes cancel to zero signed area, so the area check would otherwise + // win the race and report a true but useless "encloses no area". + const symmetric: Point2[] = [ + { x: 0, y: 0 }, + { x: 4, y: 4 }, + { x: 4, y: 0 }, + { x: 0, y: 4 }, + ]; + expect(signedArea(symmetric)).toBeCloseTo(0); + expect(validateFloorplan(plan(symmetric)).map((i) => i.code)).toEqual(['self-intersecting']); + }); + + it('does not flag neighbouring walls for sharing a corner', () => { + expect(validateFloorplan(plan(SHOEBOX)).map((i) => i.code)).not.toContain( + 'self-intersecting' + ); + }); + + it('reports every problem at once rather than stopping at the first', () => { + const issues = validateFloorplan(plan([{ x: 0, y: 0 }, { x: 1, y: 0 }], 0)); + expect(issues.map((i) => i.code)).toEqual( + expect.arrayContaining(['too-few-points', 'invalid-height']) + ); + }); +}); + +describe('floorplanToMesh', () => { + it('throws on an invalid plan, naming the reason', () => { + expect(() => floorplanToMesh(plan(SHOEBOX, -1))).toThrow(/height must be a positive number/); + }); + + describe('welding', () => { + it('emits exactly 2n vertices for an n-point outline', () => { + expect(floorplanToMesh(plan(SHOEBOX)).vertices).toHaveLength(8); + expect(floorplanToMesh(plan(L_SHAPE)).vertices).toHaveLength(12); + }); + + it('shares each floor corner between the floor and two walls', () => { + const mesh = floorplanToMesh(plan(SHOEBOX)); + for (let v = 0; v < 4; v++) { + const ids = facesTouchingVertex(mesh, v).map((f) => f.id); + expect(ids).toHaveLength(3); + expect(ids).toContain('floor'); + } + }); + + it('shares each ceiling corner between the ceiling and two walls', () => { + const mesh = floorplanToMesh(plan(SHOEBOX)); + for (let v = 4; v < 8; v++) { + const ids = facesTouchingVertex(mesh, v).map((f) => f.id); + expect(ids).toHaveLength(3); + expect(ids).toContain('ceiling'); + } + }); + + it('places the ceiling ring at n + i for outline point i', () => { + const mesh = floorplanToMesh(plan(SHOEBOX, 2.5)); + const n = SHOEBOX.length; + for (let i = 0; i < n; i++) { + const floorV = mesh.vertices[i]; + const ceilV = mesh.vertices[n + i]; + expect(ceilV[0]).toBeCloseTo(floorV[0]); + expect(ceilV[1]).toBeCloseTo(floorV[1]); + expect(ceilV[2] - floorV[2]).toBeCloseTo(2.5); + } + }); + + it('never repeats a position across the vertex list', () => { + const mesh = floorplanToMesh(plan(L_SHAPE)); + const keys = mesh.vertices.map((v) => v.join(',')); + expect(new Set(keys).size).toBe(keys.length); + }); + }); + + describe('faces', () => { + it('produces one wall per outline segment, plus floor and ceiling', () => { + const mesh = floorplanToMesh(plan(L_SHAPE)); + expect(mesh.faces).toHaveLength(L_SHAPE.length + 2); + expect(mesh.faces.map((f) => f.id)).toEqual([ + 'floor', + 'ceiling', + 'wall-0', + 'wall-1', + 'wall-2', + 'wall-3', + 'wall-4', + 'wall-5', + ]); + }); + + it('gives walls 1-based display names', () => { + const mesh = floorplanToMesh(plan(SHOEBOX)); + expect(mesh.faces.find((f) => f.id === 'wall-0')?.name).toBe('Wall 1'); + }); + + it('keeps face ids stable when only the height changes', () => { + // This is the hook that lets acoustic material assignments survive an edit. + const a = floorplanToMesh(plan(SHOEBOX, 2.5)).faces.map((f) => f.id); + const b = floorplanToMesh(plan(SHOEBOX, 4.0)).faces.map((f) => f.id); + expect(b).toEqual(a); + }); + + it('gives every wall a 4-vertex loop', () => { + const mesh = floorplanToMesh(plan(L_SHAPE)); + for (const face of mesh.faces.filter((f) => f.id.startsWith('wall-'))) { + expect(face.loop).toHaveLength(4); + } + }); + + it('gives floor and ceiling one vertex per outline point', () => { + const mesh = floorplanToMesh(plan(L_SHAPE)); + expect(mesh.faces.find((f) => f.id === 'floor')!.loop).toHaveLength(6); + expect(mesh.faces.find((f) => f.id === 'ceiling')!.loop).toHaveLength(6); + }); + }); + + describe('winding — normals must point into the room', () => { + it('points the floor up', () => { + const mesh = floorplanToMesh(plan(SHOEBOX)); + expectVec3Close(faceNormal(mesh, mesh.faces[0]), [0, 0, 1]); + }); + + it('points the ceiling down', () => { + const mesh = floorplanToMesh(plan(SHOEBOX)); + expectVec3Close(faceNormal(mesh, mesh.faces[1]), [0, 0, -1]); + }); + + it('points every wall toward the interior of a convex room', () => { + const mesh = floorplanToMesh(plan(SHOEBOX)); + const interior: Vec3 = [2, 1.5, 1.25]; + for (const face of mesh.faces) { + const n = faceNormal(mesh, face); + const c = faceCentroid(mesh, face); + const toInterior: Vec3 = [interior[0] - c[0], interior[1] - c[1], interior[2] - c[2]]; + const dot = n[0] * toInterior[0] + n[1] * toInterior[1] + n[2] * toInterior[2]; + expect(dot, `face ${face.id} faces outward`).toBeGreaterThan(0); + } + }); + + it('applies the same winding rule on a concave room', () => { + // Containment reasoning breaks down on concave solids, so check each wall + // against the inward direction implied by its own edge: (-dy, dx). + const mesh = floorplanToMesh(plan(L_SHAPE)); + const pts = (mesh.source as { params: FloorplanParams }).params.points; + const n = pts.length; + for (let i = 0; i < n; i++) { + const a = pts[i]; + const b = pts[(i + 1) % n]; + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.hypot(dx, dy); + const expected: Vec3 = [-dy / len, dx / len, 0]; + const face = mesh.faces.find((f) => f.id === `wall-${i}`)!; + expectVec3Close(faceNormal(mesh, face), expected, 6); + } + }); + + it('produces inward normals even when the outline is drawn clockwise', () => { + const mesh = floorplanToMesh(plan([...SHOEBOX].reverse())); + expectVec3Close(faceNormal(mesh, mesh.faces[0]), [0, 0, 1]); + expectVec3Close(faceNormal(mesh, mesh.faces[1]), [0, 0, -1]); + }); + + it('yields an identical mesh when the same corner is drawn in either direction', () => { + const ccw = floorplanToMesh(plan(SHOEBOX)); + // Same starting corner, opposite direction. + const cw = floorplanToMesh(plan([SHOEBOX[0], ...SHOEBOX.slice(1).reverse()])); + expect(cw.vertices).toEqual(ccw.vertices); + expect(cw.faces).toEqual(ccw.faces); + }); + + it('rotates rather than reorders when drawing starts at a different corner', () => { + // Winding is normalised but the starting corner is preserved, so vertex 0 + // is wherever the user began. The room is the same shape; only the + // labelling differs. Worth pinning — it decides which wall is "wall-0". + const ccw = floorplanToMesh(plan(SHOEBOX)); + const started3 = floorplanToMesh(plan([...SHOEBOX].reverse())); + + expect(started3.vertices[0]).toEqual([0, 3, 0]); + expect(new Set(started3.vertices.map(String))).toEqual( + new Set(ccw.vertices.map(String)) + ); + // Still a sealed room with inward normals, just relabelled. + expectVec3Close(faceNormal(started3, started3.faces[0]), [0, 0, 1]); + }); + }); + + describe('elevation', () => { + it('puts the floor at z = 0 by default', () => { + const mesh = floorplanToMesh(plan(SHOEBOX, 2.5)); + expect(mesh.vertices.slice(0, 4).every((v) => v[2] === 0)).toBe(true); + expect(mesh.vertices.slice(4).every((v) => v[2] === 2.5)).toBe(true); + }); + + it('honours an explicit baseZ', () => { + const mesh = floorplanToMesh(plan(SHOEBOX, 2.5, 10)); + expect(mesh.vertices.slice(0, 4).every((v) => v[2] === 10)).toBe(true); + expect(mesh.vertices.slice(4).every((v) => v[2] === 12.5)).toBe(true); + }); + }); + + describe('source', () => { + it('records the plan it was generated from, not yet detached', () => { + const mesh = floorplanToMesh(plan(SHOEBOX, 2.5)); + expect(mesh.source).toMatchObject({ kind: 'floorplan', detached: false }); + }); + + it('stores the winding-normalised points, so regeneration is stable', () => { + const mesh = floorplanToMesh(plan([...SHOEBOX].reverse())); + const stored = (mesh.source as { params: FloorplanParams }).params.points; + expect(signedArea(stored)).toBeGreaterThan(0); + }); + + it('resolves baseZ to a concrete value', () => { + const mesh = floorplanToMesh(plan(SHOEBOX)); + expect((mesh.source as { params: FloorplanParams }).params.baseZ).toBe(0); + }); + + it('round-trips: regenerating from stored params reproduces the mesh', () => { + const mesh = floorplanToMesh(plan(SHOEBOX)); + const again = floorplanToMesh((mesh.source as { params: FloorplanParams }).params); + expect(again.vertices).toEqual(mesh.vertices); + expect(again.faces).toEqual(mesh.faces); + }); + }); +}); diff --git a/src/compute/geometry/__tests__/raycast-contract.spec.ts b/src/compute/geometry/__tests__/raycast-contract.spec.ts new file mode 100644 index 00000000..2672401e --- /dev/null +++ b/src/compute/geometry/__tests__/raycast-contract.spec.ts @@ -0,0 +1,156 @@ +/** + * Raycast contract — does generated geometry satisfy what the raytracer needs? + * + * The solver does not consume RoomMesh; it raycasts against three.js geometry + * and reads `intersection.face.normal` (see compute/raytracer/ray-core.ts, + * which takes `angleTo(face.normal)`). three.js derives that normal from + * triangle winding, so this is the test that closes the loop from our loops, + * through triangulation, into the value the physics actually uses. + * + * Two properties are checked, and they are the ones that would otherwise fail + * silently — producing a room that looks correct and models wrong: + * + * - **Inward normals.** A ray travelling inside the room must hit a face whose + * normal points back at it. + * - **Watertightness.** Crossing count parity: odd from inside, even from + * outside. A gap or a T-junction breaks this. + * + * Unlike its neighbours this spec imports three.js — it is verifying an + * integration. The geometry modules themselves stay dependency-free. + */ + +import { describe, it, expect } from 'vitest'; +import * as THREE from 'three'; +import { triangulatedPositions } from '../triangulate'; +import { floorplanToMesh, type Point2 } from '../floorplan'; +import type { RoomMesh } from '../room-mesh'; + +const SHOEBOX: Point2[] = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 0, y: 3 }, +]; + +const L_SHAPE: Point2[] = [ + { x: 4, y: 2 }, + { x: 2, y: 2 }, + { x: 2, y: 4 }, + { x: 0, y: 4 }, + { x: 0, y: 0 }, + { x: 4, y: 0 }, +]; + +const HEIGHT = 2.5; + +/** Every face welded into one mesh, the way a solver sees a whole room. */ +function meshToThree(mesh: RoomMesh): THREE.Mesh { + const positions: number[] = []; + for (const face of mesh.faces) positions.push(...triangulatedPositions(mesh, face)); + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3)); + geometry.computeVertexNormals(); + + // DoubleSide so back-facing hits still register — otherwise the parity check + // would be measuring material culling rather than watertightness. + return new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ side: THREE.DoubleSide })); +} + +/** Deterministic unit directions; a fixed seed keeps failures reproducible. */ +function directions(count: number): THREE.Vector3[] { + let seed = 12345; + const rand = () => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed / 0x7fffffff; + }; + const out: THREE.Vector3[] = []; + while (out.length < count) { + const v = new THREE.Vector3(rand() * 2 - 1, rand() * 2 - 1, rand() * 2 - 1); + if (v.lengthSq() < 1e-6) continue; + out.push(v.normalize()); + } + return out; +} + +describe.each([ + ['shoebox', SHOEBOX, new THREE.Vector3(2, 1.5, 1.25)], + ['L-shaped room', L_SHAPE, new THREE.Vector3(1, 1, 1.25)], +])('%s', (_name, points, interior) => { + const mesh = floorplanToMesh({ points, height: HEIGHT }); + const object = meshToThree(mesh); + + it('is hit from the inside in every direction', () => { + const raycaster = new THREE.Raycaster(); + for (const dir of directions(60)) { + raycaster.set(interior, dir); + expect(raycaster.intersectObject(object).length, `direction ${dir.toArray()}`) + .toBeGreaterThan(0); + } + }); + + it('presents an inward-facing normal to a ray leaving the room', () => { + // The property the raytracer depends on: the surface faces the sound. + const raycaster = new THREE.Raycaster(); + for (const dir of directions(60)) { + raycaster.set(interior, dir); + const hit = raycaster.intersectObject(object)[0]; + expect(hit?.face, `no face for ${dir.toArray()}`).toBeTruthy(); + expect(hit.face!.normal.dot(dir), `outward normal for ${dir.toArray()}`).toBeLessThan(0); + } + }); + + it('is watertight: an interior ray crosses the boundary an odd number of times', () => { + const raycaster = new THREE.Raycaster(); + for (const dir of directions(60)) { + raycaster.set(interior, dir); + const crossings = raycaster.intersectObject(object).length; + expect(crossings % 2, `even crossings from inside along ${dir.toArray()}`).toBe(1); + } + }); + + it('is watertight: an exterior ray crosses an even number of times', () => { + const raycaster = new THREE.Raycaster(); + const outside = new THREE.Vector3(-50, -50, -50); + for (const dir of directions(40)) { + raycaster.set(outside, dir); + const crossings = raycaster.intersectObject(object).length; + expect(crossings % 2, `odd crossings from outside along ${dir.toArray()}`).toBe(0); + } + }); + + it('encloses the volume implied by the floorplan', () => { + object.geometry.computeBoundingBox(); + const box = object.geometry.boundingBox!; + expect(box.min.z).toBeCloseTo(0); + expect(box.max.z).toBeCloseTo(HEIGHT); + }); +}); + +describe('normals after an edit', () => { + it('stays inward-facing when the height changes', () => { + const mesh = floorplanToMesh({ points: SHOEBOX, height: 6 }); + const object = meshToThree(mesh); + const raycaster = new THREE.Raycaster(); + const interior = new THREE.Vector3(2, 1.5, 3); + + for (const dir of directions(40)) { + raycaster.set(interior, dir); + const hit = raycaster.intersectObject(object)[0]; + expect(hit.face!.normal.dot(dir)).toBeLessThan(0); + } + }); + + it('stays inward-facing for a room drawn clockwise', () => { + const clockwise = [SHOEBOX[0], ...SHOEBOX.slice(1).reverse()]; + const object = meshToThree(floorplanToMesh({ points: clockwise, height: HEIGHT })); + const raycaster = new THREE.Raycaster(); + const interior = new THREE.Vector3(2, 1.5, 1.25); + + for (const dir of directions(40)) { + raycaster.set(interior, dir); + const hit = raycaster.intersectObject(object)[0]; + expect(hit.face!.normal.dot(dir)).toBeLessThan(0); + } + }); +}); diff --git a/src/compute/geometry/__tests__/room-mesh.spec.ts b/src/compute/geometry/__tests__/room-mesh.spec.ts new file mode 100644 index 00000000..c058dcb0 --- /dev/null +++ b/src/compute/geometry/__tests__/room-mesh.spec.ts @@ -0,0 +1,213 @@ +/** + * RoomMesh topology tests. + * + * These cover the part that exists specifically so 3D vertex editing can land + * later: shared vertices, immutable edits, and provenance tracking. + */ + +import { describe, it, expect } from 'vitest'; +import { + applyEdit, + faceCentroid, + faceNormal, + faceVertices, + facesTouchingVertex, + findFace, + type RoomMesh, + type Vec3, +} from '../room-mesh'; +import { floorplanToMesh, type Point2 } from '../floorplan'; + +const SHOEBOX: Point2[] = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 0, y: 3 }, +]; + +const shoebox = (height = 2.5): RoomMesh => floorplanToMesh({ points: SHOEBOX, height }); + +describe('faceVertices', () => { + it('resolves a loop to positions in loop order', () => { + const mesh = shoebox(); + const floor = findFace(mesh, 'floor')!; + expect(faceVertices(mesh, floor)).toEqual([ + [0, 0, 0], + [4, 0, 0], + [4, 3, 0], + [0, 3, 0], + ]); + }); +}); + +describe('faceCentroid', () => { + it('averages the loop vertices', () => { + const mesh = shoebox(); + expect(faceCentroid(mesh, findFace(mesh, 'floor')!)).toEqual([2, 1.5, 0]); + }); + + it('sits at mid-height for a wall', () => { + const mesh = shoebox(2.5); + expect(faceCentroid(mesh, findFace(mesh, 'wall-0')!)[2]).toBeCloseTo(1.25); + }); +}); + +describe('faceNormal', () => { + it('returns a unit vector', () => { + const mesh = shoebox(); + for (const face of mesh.faces) { + const n = faceNormal(mesh, face); + expect(Math.hypot(n[0], n[1], n[2])).toBeCloseTo(1); + } + }); + + it('returns a zero vector for a degenerate loop rather than NaN', () => { + const mesh: RoomMesh = { + vertices: [ + [0, 0, 0], + [1, 0, 0], + [2, 0, 0], + ], + faces: [{ id: 'flat', name: 'Flat', loop: [0, 1, 2] }], + source: { kind: 'manual' }, + }; + expect(faceNormal(mesh, mesh.faces[0])).toEqual([0, 0, 0]); + }); +}); + +describe('findFace', () => { + it('finds a face by id', () => { + expect(findFace(shoebox(), 'wall-2')?.name).toBe('Wall 3'); + }); + + it('returns undefined for an unknown id', () => { + expect(findFace(shoebox(), 'wall-99')).toBeUndefined(); + }); +}); + +describe('facesTouchingVertex', () => { + it('finds all three faces meeting at a corner', () => { + const mesh = shoebox(); + expect(facesTouchingVertex(mesh, 0).map((f) => f.id).sort()).toEqual([ + 'floor', + 'wall-0', + 'wall-3', + ]); + }); + + it('returns nothing for an unreferenced index', () => { + expect(facesTouchingVertex(shoebox(), 999)).toEqual([]); + }); +}); + +describe('applyEdit — move-vertex', () => { + it('moves the requested vertex', () => { + const mesh = shoebox(); + const next = applyEdit(mesh, { kind: 'move-vertex', id: 0, to: [-1, -2, 0] }); + expect(next.vertices[0]).toEqual([-1, -2, 0]); + }); + + it('moves the corner for every face that shares it', () => { + // The whole reason topology is the source of truth rather than triangles. + const mesh = shoebox(); + const moved: Vec3 = [-1, -2, 0]; + const next = applyEdit(mesh, { kind: 'move-vertex', id: 0, to: moved }); + + for (const face of facesTouchingVertex(next, 0)) { + expect(faceVertices(next, face)).toContainEqual(moved); + } + expect(facesTouchingVertex(next, 0)).toHaveLength(3); + }); + + it('leaves other vertices untouched', () => { + const mesh = shoebox(); + const next = applyEdit(mesh, { kind: 'move-vertex', id: 0, to: [-1, -2, 0] }); + expect(next.vertices.slice(1)).toEqual(mesh.vertices.slice(1)); + }); + + it('does not mutate the input mesh', () => { + const mesh = shoebox(); + const before = JSON.stringify(mesh); + applyEdit(mesh, { kind: 'move-vertex', id: 0, to: [9, 9, 9] }); + expect(JSON.stringify(mesh)).toBe(before); + }); + + it('preserves the face list', () => { + const mesh = shoebox(); + const next = applyEdit(mesh, { kind: 'move-vertex', id: 2, to: [5, 5, 0] }); + expect(next.faces).toEqual(mesh.faces); + }); + + it('marks a floorplan-sourced mesh as detached, keeping the params', () => { + const mesh = shoebox(); + const next = applyEdit(mesh, { kind: 'move-vertex', id: 0, to: [-1, -2, 0] }); + expect(next.source).toMatchObject({ kind: 'floorplan', detached: true }); + expect((next.source as { params: unknown }).params).toEqual( + (mesh.source as { params: unknown }).params + ); + }); + + it('leaves a manual mesh manual', () => { + const mesh: RoomMesh = { + vertices: [[0, 0, 0]], + faces: [], + source: { kind: 'manual' }, + }; + expect(applyEdit(mesh, { kind: 'move-vertex', id: 0, to: [1, 1, 1] }).source).toEqual({ + kind: 'manual', + }); + }); + + it.each([-1, 8, 1.5, NaN])('rejects out-of-range vertex id %p', (id) => { + expect(() => applyEdit(shoebox(), { kind: 'move-vertex', id, to: [0, 0, 0] })).toThrow( + RangeError + ); + }); + + it('can flip a normal if a vertex is dragged through the face', () => { + // Not a guarantee we make — just documenting that direct editing can break + // the inward-normal invariant that the generator establishes. + const mesh = shoebox(); + const before = faceNormal(mesh, findFace(mesh, 'floor')!); + const next = applyEdit(mesh, { kind: 'move-vertex', id: 0, to: [8, 6, 0] }); + const after = faceNormal(next, findFace(next, 'floor')!); + expect(after).not.toEqual(before); + }); +}); + +describe('applyEdit — set-floorplan', () => { + it('regenerates the mesh from new params', () => { + const mesh = shoebox(2.5); + const next = applyEdit(mesh, { + kind: 'set-floorplan', + params: { points: SHOEBOX, height: 4 }, + }); + expect(next.vertices[4][2]).toBeCloseTo(4); + }); + + it('keeps face ids, so material assignments can be carried over', () => { + const mesh = shoebox(2.5); + const next = applyEdit(mesh, { + kind: 'set-floorplan', + params: { points: SHOEBOX, height: 4 }, + }); + expect(next.faces.map((f) => f.id)).toEqual(mesh.faces.map((f) => f.id)); + }); + + it('clears the detached flag, reattaching the mesh to its plan', () => { + const detached = applyEdit(shoebox(), { kind: 'move-vertex', id: 0, to: [-1, -1, 0] }); + expect(detached.source).toMatchObject({ detached: true }); + + const reattached = applyEdit(detached, { + kind: 'set-floorplan', + params: { points: SHOEBOX, height: 3 }, + }); + expect(reattached.source).toMatchObject({ kind: 'floorplan', detached: false }); + }); + + it('propagates validation failures', () => { + expect(() => + applyEdit(shoebox(), { kind: 'set-floorplan', params: { points: SHOEBOX, height: 0 } }) + ).toThrow(/invalid floorplan/); + }); +}); diff --git a/src/compute/geometry/__tests__/sketch-input.spec.ts b/src/compute/geometry/__tests__/sketch-input.spec.ts new file mode 100644 index 00000000..1c3c7031 --- /dev/null +++ b/src/compute/geometry/__tests__/sketch-input.spec.ts @@ -0,0 +1,379 @@ +/** + * Sketch input tests. + * + * Most of the subtlety is in how the snapping rules interact — ortho constrains + * relative to the previous point, grid snap rounds, and close-loop has to beat + * both or the outline never quite shuts. + */ + +import { describe, it, expect } from 'vitest'; +import { + DEFAULT_SNAP, + addPoint, + canClose, + closeDraft, + draftIssues, + draftPerimeter, + emptyDraft, + moveCursor, + previewPath, + snap, + toFloorplanParams, + undoLastPoint, + type SketchDraft, + type SnapSettings, +} from '../sketch-input'; +import type { Point2 } from '../floorplan'; + +const NO_SNAP: SnapSettings = { gridSize: 0, ortho: false, closeDistance: 0.5 }; +const GRID: SnapSettings = { gridSize: 0.25, ortho: false, closeDistance: 0.5 }; +const ORTHO: SnapSettings = { gridSize: 0, ortho: true, closeDistance: 0.5 }; + +const p = (x: number, y: number): Point2 => ({ x, y }); + +/** Draft with the given points already committed. */ +function draftOf(points: Point2[], closed = false): SketchDraft { + return { points, cursor: null, closed }; +} + +const SQUARE = [p(0, 0), p(4, 0), p(4, 4), p(0, 4)]; + +describe('emptyDraft', () => { + it('starts empty and open', () => { + expect(emptyDraft()).toEqual({ points: [], cursor: null, closed: false }); + }); +}); + +describe('snap', () => { + describe('grid', () => { + it('rounds to the nearest grid multiple', () => { + expect(snap(p(1.1, 2.4), emptyDraft(), GRID).point).toEqual(p(1, 2.5)); + }); + + it('leaves points alone when the grid is disabled', () => { + expect(snap(p(1.1, 2.4), emptyDraft(), NO_SNAP).point).toEqual(p(1.1, 2.4)); + }); + + it('handles negative coordinates', () => { + expect(snap(p(-1.1, -2.4), emptyDraft(), GRID).point).toEqual(p(-1, -2.5)); + }); + }); + + describe('ortho', () => { + it('does nothing without a previous point', () => { + expect(snap(p(3, 7), emptyDraft(), ORTHO).point).toEqual(p(3, 7)); + }); + + it('locks to horizontal when x has moved further', () => { + const draft = draftOf([p(0, 0)]); + expect(snap(p(5, 1), draft, ORTHO).point).toEqual(p(5, 0)); + }); + + it('locks to vertical when y has moved further', () => { + const draft = draftOf([p(0, 0)]); + expect(snap(p(1, 5), draft, ORTHO).point).toEqual(p(0, 5)); + }); + + it('constrains relative to the most recent point, not the first', () => { + const draft = draftOf([p(0, 0), p(4, 0)]); + expect(snap(p(4.2, 3), draft, ORTHO).point).toEqual(p(4, 3)); + }); + + it('combines with grid snapping', () => { + const draft = draftOf([p(0, 0)]); + const both: SnapSettings = { gridSize: 0.5, ortho: true, closeDistance: 0.5 }; + expect(snap(p(3.3, 0.4), draft, both).point).toEqual(p(3.5, 0)); + }); + }); + + describe('close-loop', () => { + it('snaps exactly onto the first point when near it', () => { + const draft = draftOf(SQUARE); + const result = snap(p(0.2, 0.1), draft, GRID); + expect(result.closesLoop).toBe(true); + expect(result.point).toEqual(p(0, 0)); + }); + + it('does not trigger with fewer than three points', () => { + const draft = draftOf([p(0, 0), p(4, 0)]); + expect(snap(p(0.1, 0.1), draft, GRID).closesLoop).toBe(false); + }); + + it('does not trigger far from the start', () => { + expect(snap(p(2, 2), draftOf(SQUARE), GRID).closesLoop).toBe(false); + }); + + it('beats ortho, which would otherwise pull the point off the start', () => { + // From (0,4) an ortho constraint would lock x to 0 or y to 4; the start + // is at (0,0), so only an exact override actually closes the shape. + const draft = draftOf(SQUARE); + const result = snap(p(0.3, 0.3), draft, { ...ORTHO, closeDistance: 0.5 }); + expect(result.closesLoop).toBe(true); + expect(result.point).toEqual(p(0, 0)); + }); + + it('beats grid snap, which could round away from the start', () => { + const draft = draftOf([p(0.1, 0.1), p(4, 0), p(4, 4)]); + const result = snap(p(0.2, 0.2), draft, GRID); + expect(result.point).toEqual(p(0.1, 0.1)); + }); + + it('respects the configured tolerance', () => { + const draft = draftOf(SQUARE); + const tight: SnapSettings = { ...GRID, closeDistance: 0.05 }; + expect(snap(p(0.2, 0.2), draft, tight).closesLoop).toBe(false); + }); + + it('never triggers on an already closed draft', () => { + expect(snap(p(0, 0), draftOf(SQUARE, true), GRID).closesLoop).toBe(false); + }); + }); +}); + +describe('addPoint', () => { + it('commits a snapped point', () => { + const draft = addPoint(emptyDraft(), p(1.1, 2.4), GRID); + expect(draft.points).toEqual([p(1, 2.5)]); + }); + + it('accumulates points in click order', () => { + let draft = emptyDraft(); + for (const point of SQUARE) draft = addPoint(draft, point, NO_SNAP); + expect(draft.points).toEqual(SQUARE); + }); + + it('ignores a click that repeats the previous point', () => { + const draft = addPoint(draftOf([p(2, 2)]), p(2, 2), NO_SNAP); + expect(draft.points).toHaveLength(1); + }); + + it('ignores a near-repeat that grid snap collapses onto the previous point', () => { + const draft = addPoint(draftOf([p(2, 2)]), p(2.02, 2.02), GRID); + expect(draft.points).toHaveLength(1); + }); + + it('allows revisiting an earlier point that is not the previous one', () => { + const draft = addPoint(draftOf([p(0, 0), p(4, 0)]), p(0, 0), NO_SNAP); + expect(draft.points).toHaveLength(3); + }); + + it('closes the outline when the click lands on the start', () => { + const draft = addPoint(draftOf(SQUARE), p(0.1, 0.1), GRID); + expect(draft.closed).toBe(true); + expect(draft.points).toHaveLength(4); + }); + + it('does nothing once closed', () => { + const closed = draftOf(SQUARE, true); + expect(addPoint(closed, p(9, 9), NO_SNAP)).toBe(closed); + }); + + it('does not mutate the input draft', () => { + const draft = draftOf([p(0, 0)]); + const snapshot = JSON.stringify(draft); + addPoint(draft, p(4, 0), NO_SNAP); + expect(JSON.stringify(draft)).toBe(snapshot); + }); +}); + +describe('moveCursor', () => { + it('stores the snapped cursor', () => { + expect(moveCursor(emptyDraft(), p(1.1, 2.4), GRID).cursor).toEqual(p(1, 2.5)); + }); + + it('clears the cursor when the pointer leaves the plane', () => { + const draft = moveCursor(draftOf([p(0, 0)]), null, GRID); + expect(draft.cursor).toBeNull(); + }); + + it('applies the ortho constraint to the preview', () => { + expect(moveCursor(draftOf([p(0, 0)]), p(5, 1), ORTHO).cursor).toEqual(p(5, 0)); + }); + + it('is inert once closed', () => { + const closed = draftOf(SQUARE, true); + expect(moveCursor(closed, p(9, 9), GRID)).toBe(closed); + }); +}); + +describe('closeDraft / canClose', () => { + it.each([0, 1, 2])('cannot close with %i points', (n) => { + expect(canClose(draftOf(SQUARE.slice(0, n)))).toBe(false); + }); + + it('can close with three points', () => { + expect(canClose(draftOf(SQUARE.slice(0, 3)))).toBe(true); + }); + + it('cannot close twice', () => { + expect(canClose(draftOf(SQUARE, true))).toBe(false); + }); + + it('clears the cursor when closing', () => { + const draft = closeDraft({ points: SQUARE, cursor: p(1, 1), closed: false }); + expect(draft).toMatchObject({ closed: true, cursor: null }); + }); + + it('is a no-op with too few points', () => { + const draft = draftOf([p(0, 0), p(1, 0)]); + expect(closeDraft(draft)).toBe(draft); + }); +}); + +describe('undoLastPoint', () => { + it('removes the last point', () => { + expect(undoLastPoint(draftOf(SQUARE)).points).toEqual(SQUARE.slice(0, 3)); + }); + + it('reopens a closed outline without dropping a point', () => { + // Closing commits no point of its own, so undoing it must not remove one. + const draft = undoLastPoint(draftOf(SQUARE, true)); + expect(draft.closed).toBe(false); + expect(draft.points).toHaveLength(4); + }); + + it('is a no-op on an empty draft', () => { + const draft = emptyDraft(); + expect(undoLastPoint(draft)).toBe(draft); + }); + + it('unwinds an entire outline', () => { + let draft = draftOf(SQUARE, true); + for (let i = 0; i < 5; i++) draft = undoLastPoint(draft); + expect(draft.points).toEqual([]); + expect(draft.closed).toBe(false); + }); +}); + +describe('previewPath', () => { + it('is empty with nothing drawn and no cursor', () => { + expect(previewPath(emptyDraft())).toEqual([]); + }); + + it('is just the cursor before the first click', () => { + expect(previewPath({ points: [], cursor: p(1, 1), closed: false })).toEqual([p(1, 1)]); + }); + + it('rubber-bands from the committed points to the cursor', () => { + expect(previewPath({ points: [p(0, 0), p(4, 0)], cursor: p(4, 3), closed: false })).toEqual([ + p(0, 0), + p(4, 0), + p(4, 3), + ]); + }); + + it('omits the rubber band when the pointer is off the plane', () => { + expect(previewPath(draftOf([p(0, 0), p(4, 0)]))).toEqual([p(0, 0), p(4, 0)]); + }); + + it('returns to the start once closed', () => { + const path = previewPath(draftOf(SQUARE, true)); + expect(path).toHaveLength(5); + expect(path[4]).toEqual(path[0]); + }); + + it('copies the closing point rather than aliasing the first', () => { + const path = previewPath(draftOf(SQUARE, true)); + expect(path[4]).not.toBe(path[0]); + }); +}); + +describe('draftPerimeter', () => { + it('measures an open outline', () => { + expect(draftPerimeter(draftOf([p(0, 0), p(3, 0), p(3, 4)]))).toBeCloseTo(7); + }); + + it('includes the closing segment once closed', () => { + expect(draftPerimeter(draftOf([p(0, 0), p(3, 0), p(3, 4)], true))).toBeCloseTo(12); + }); + + it('is zero for an empty draft', () => { + expect(draftPerimeter(emptyDraft())).toBe(0); + }); +}); + +describe('toFloorplanParams', () => { + it('produces params from a closed draft', () => { + expect(toFloorplanParams(draftOf(SQUARE, true), 2.5)).toEqual({ + points: SQUARE, + height: 2.5, + baseZ: 0, + }); + }); + + it('honours an explicit baseZ', () => { + expect(toFloorplanParams(draftOf(SQUARE, true), 2.5, 10)?.baseZ).toBe(10); + }); + + it('returns null while the outline is open', () => { + expect(toFloorplanParams(draftOf(SQUARE), 2.5)).toBeNull(); + }); + + it('returns null with too few points', () => { + expect(toFloorplanParams(draftOf(SQUARE.slice(0, 2), true), 2.5)).toBeNull(); + }); + + it('copies the points, so later edits do not reach back into the draft', () => { + const draft = draftOf(SQUARE, true); + const params = toFloorplanParams(draft, 2.5)!; + params.points[0].x = 99; + expect(draft.points[0].x).toBe(0); + }); +}); + +describe('draftIssues', () => { + it('reports nothing while the outline is still open', () => { + expect(draftIssues(draftOf(SQUARE), 2.5)).toEqual([]); + }); + + it('reports nothing for a valid closed outline', () => { + expect(draftIssues(draftOf(SQUARE, true), 2.5)).toEqual([]); + }); + + it('surfaces a self-intersection for live feedback', () => { + const bowtie = [p(0, 0), p(5, 4), p(5, 0), p(0, 3)]; + expect(draftIssues(draftOf(bowtie, true), 2.5).map((i) => i.code)).toContain( + 'self-intersecting' + ); + }); + + it('surfaces an invalid height', () => { + expect(draftIssues(draftOf(SQUARE, true), 0).map((i) => i.code)).toContain('invalid-height'); + }); +}); + +describe('drawing a room end to end', () => { + it('clicks out a square, closes it, and yields usable params', () => { + let draft = emptyDraft(); + draft = moveCursor(draft, p(0.05, 0.05), GRID); + for (const point of SQUARE) draft = addPoint(draft, point, GRID); + draft = addPoint(draft, p(0.1, -0.1), GRID); // click back on the start + + expect(draft.closed).toBe(true); + expect(toFloorplanParams(draft, 3)).toEqual({ points: SQUARE, height: 3, baseZ: 0 }); + expect(draftIssues(draft, 3)).toEqual([]); + }); + + it('draws an L-shape with ortho engaged', () => { + const clicks = [p(0, 0), p(4.1, 0.2), p(4.05, 2.1), p(2.1, 2.05), p(1.9, 4.1), p(0.1, 4.05)]; + const settings: SnapSettings = { gridSize: 0.5, ortho: true, closeDistance: 0.5 }; + + let draft = emptyDraft(); + for (const click of clicks) draft = addPoint(draft, click, settings); + draft = closeDraft(draft); + + expect(draft.points).toEqual([ + p(0, 0), + p(4, 0), + p(4, 2), + p(2, 2), + p(2, 4), + p(0, 4), + ]); + expect(draftIssues(draft, 2.5)).toEqual([]); + }); + + it('uses sensible defaults', () => { + expect(DEFAULT_SNAP).toEqual({ gridSize: 0.25, ortho: false, closeDistance: 0.5 }); + }); +}); diff --git a/src/compute/geometry/__tests__/sync-plan.spec.ts b/src/compute/geometry/__tests__/sync-plan.spec.ts new file mode 100644 index 00000000..6724e541 --- /dev/null +++ b/src/compute/geometry/__tests__/sync-plan.spec.ts @@ -0,0 +1,96 @@ +/** + * Face reconciliation tests. + * + * The behaviour that matters: a face id present before and after an edit lands + * in `updated`, never in `removed` + `added`. That distinction is what keeps a + * Surface — and the acoustic material assigned to it — alive across edits. + */ + +import { describe, it, expect } from 'vitest'; +import { planFaceSync } from '../sync-plan'; +import { floorplanToMesh, type Point2 } from '../floorplan'; + +const SHOEBOX: Point2[] = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 0, y: 3 }, +]; + +const ids = (points: Point2[], height = 2.5) => + floorplanToMesh({ points, height }).faces.map((f) => f.id); + +describe('planFaceSync', () => { + it('treats everything as added when nothing exists yet', () => { + const plan = planFaceSync([], ['floor', 'ceiling', 'wall-0']); + expect(plan).toEqual({ + updated: [], + added: ['floor', 'ceiling', 'wall-0'], + removed: [], + }); + }); + + it('treats everything as removed when the mesh has no faces', () => { + const plan = planFaceSync(['floor', 'wall-0'], []); + expect(plan).toEqual({ updated: [], added: [], removed: ['floor', 'wall-0'] }); + }); + + it('classifies a mixed change', () => { + const plan = planFaceSync(['floor', 'wall-0', 'wall-1'], ['floor', 'wall-0', 'wall-2']); + expect(plan).toEqual({ + updated: ['floor', 'wall-0'], + added: ['wall-2'], + removed: ['wall-1'], + }); + }); + + it('follows incoming order for updated and added', () => { + const plan = planFaceSync(['b', 'a'], ['a', 'x', 'b', 'y']); + expect(plan.updated).toEqual(['a', 'b']); + expect(plan.added).toEqual(['x', 'y']); + }); + + it('follows existing order for removed', () => { + expect(planFaceSync(['z', 'y', 'x'], []).removed).toEqual(['z', 'y', 'x']); + }); + + it('collapses duplicate ids on either side', () => { + const plan = planFaceSync(['a', 'a'], ['a', 'a', 'b', 'b']); + expect(plan).toEqual({ updated: ['a'], added: ['b'], removed: [] }); + }); + + it('accepts any iterable, including a Map keys view', () => { + const existing = new Map([['floor', 1], ['wall-0', 2]]); + expect(planFaceSync(existing.keys(), ['floor']).removed).toEqual(['wall-0']); + }); + + describe('against real meshes', () => { + it('updates every face when only the height changes', () => { + // The material-preservation hook: nothing is torn down for a height edit. + const plan = planFaceSync(ids(SHOEBOX, 2.5), ids(SHOEBOX, 4)); + expect(plan.added).toEqual([]); + expect(plan.removed).toEqual([]); + expect(plan.updated).toHaveLength(6); + }); + + it('adds one wall when the outline gains a point', () => { + const pentagon = [...SHOEBOX, { x: -1, y: 1.5 }]; + const plan = planFaceSync(ids(SHOEBOX), ids(pentagon)); + expect(plan.added).toEqual(['wall-4']); + expect(plan.removed).toEqual([]); + expect(plan.updated).toHaveLength(6); + }); + + it('removes one wall when the outline loses a point', () => { + const triangle = SHOEBOX.slice(0, 3); + const plan = planFaceSync(ids(SHOEBOX), ids(triangle)); + expect(plan.removed).toEqual(['wall-3']); + expect(plan.added).toEqual([]); + }); + + it('never reports the same id as both added and removed', () => { + const plan = planFaceSync(ids(SHOEBOX), ids([...SHOEBOX, { x: -1, y: 1.5 }])); + expect(plan.added.filter((id) => plan.removed.includes(id))).toEqual([]); + }); + }); +}); diff --git a/src/compute/geometry/__tests__/triangulate.spec.ts b/src/compute/geometry/__tests__/triangulate.spec.ts new file mode 100644 index 00000000..0847b8af --- /dev/null +++ b/src/compute/geometry/__tests__/triangulate.spec.ts @@ -0,0 +1,369 @@ +/** + * Triangulation tests. + * + * Two properties do the heavy lifting: + * + * - **Winding.** Every emitted triangle must agree with its face's normal, + * because the raytracer reads that normal and an inverted room fails silently. + * - **Area conservation.** The triangles' areas must sum to the polygon's area. + * For a simple polygon that is a strong statement: it rules out both gaps and + * overlaps, which is exactly how a naive fan triangulation breaks on concave + * outlines like an L-shaped room. + */ + +import { describe, it, expect } from 'vitest'; +import { + triangulateFace, + triangulatedPositions, + triangulateMesh, + type Triangle, +} from '../triangulate'; +import { + faceNormal, + findFace, + type Face, + type RoomMesh, + type Vec3, +} from '../room-mesh'; +import { floorplanToMesh, validateFloorplan, type Point2 } from '../floorplan'; + +const SHOEBOX: Point2[] = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 0, y: 3 }, +]; + +/** + * 4x4 square with the top-right 2x2 removed. Area 12. + * + * Deliberately started at (4,2) rather than the origin. An L-shape is + * star-shaped from its corner vertices, so a fan from (0,0) happens to stay + * inside the outline and would pass the area check by luck; fanning from (4,2) + * escapes into the missing bite. Starting here makes the fixture discriminate. + */ +const L_SHAPE: Point2[] = [ + { x: 4, y: 2 }, + { x: 2, y: 2 }, + { x: 2, y: 4 }, + { x: 0, y: 4 }, + { x: 0, y: 0 }, + { x: 4, y: 0 }, +]; + +/** 6x4 rectangle with two 1x3 notches cut down from the top. Area 18. */ +const COMB: Point2[] = [ + { x: 0, y: 0 }, + { x: 6, y: 0 }, + { x: 6, y: 4 }, + { x: 5, y: 4 }, + { x: 5, y: 1 }, + { x: 4, y: 1 }, + { x: 4, y: 4 }, + { x: 3, y: 4 }, + { x: 3, y: 1 }, + { x: 2, y: 1 }, + { x: 2, y: 4 }, + { x: 0, y: 4 }, +]; + +const room = (points: Point2[], height = 2.5): RoomMesh => floorplanToMesh({ points, height }); + +function sub(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +function cross(a: Vec3, b: Vec3): Vec3 { + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +} + +function dot(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function triangleArea(mesh: RoomMesh, tri: Triangle): number { + const [a, b, c] = tri.map((i) => mesh.vertices[i]); + const n = cross(sub(b, a), sub(c, a)); + return Math.hypot(n[0], n[1], n[2]) / 2; +} + +function triangleNormal(mesh: RoomMesh, tri: Triangle): Vec3 { + const [a, b, c] = tri.map((i) => mesh.vertices[i]); + const n = cross(sub(b, a), sub(c, a)); + const len = Math.hypot(n[0], n[1], n[2]); + return len === 0 ? [0, 0, 0] : [n[0] / len, n[1] / len, n[2] / len]; +} + +/** Polygon area via the magnitude of Newell's vector. Works for any planar loop. */ +function polygonArea(mesh: RoomMesh, face: Face): number { + let nx = 0; + let ny = 0; + let nz = 0; + const pts = face.loop.map((i) => mesh.vertices[i]); + for (let i = 0; i < pts.length; i++) { + const a = pts[i]; + const b = pts[(i + 1) % pts.length]; + nx += (a[1] - b[1]) * (a[2] + b[2]); + ny += (a[2] - b[2]) * (a[0] + b[0]); + nz += (a[0] - b[0]) * (a[1] + b[1]); + } + return Math.hypot(nx, ny, nz) / 2; +} + +describe('fixtures', () => { + it.each([ + ['L_SHAPE', L_SHAPE], + ['COMB', COMB], + ])('%s is a valid floorplan', (_name, points) => { + expect(validateFloorplan({ points, height: 2.5 })).toEqual([]); + }); + + it('L_SHAPE floor has the expected area', () => { + const mesh = room(L_SHAPE); + expect(polygonArea(mesh, findFace(mesh, 'floor')!)).toBeCloseTo(12); + }); + + it('COMB floor has the expected area', () => { + const mesh = room(COMB); + expect(polygonArea(mesh, findFace(mesh, 'floor')!)).toBeCloseTo(18); + }); +}); + +describe('triangulateFace', () => { + describe('triangle count', () => { + it('emits n-2 triangles for a convex quad', () => { + const mesh = room(SHOEBOX); + expect(triangulateFace(mesh, findFace(mesh, 'floor')!)).toHaveLength(2); + }); + + it('emits n-2 triangles for a concave L-shape', () => { + const mesh = room(L_SHAPE); + expect(triangulateFace(mesh, findFace(mesh, 'floor')!)).toHaveLength(4); + }); + + it('emits n-2 triangles for a 12-sided comb', () => { + const mesh = room(COMB); + expect(triangulateFace(mesh, findFace(mesh, 'floor')!)).toHaveLength(10); + }); + + it('emits n-2 triangles for every face of every room', () => { + for (const points of [SHOEBOX, L_SHAPE, COMB]) { + const mesh = room(points); + for (const face of mesh.faces) { + expect(triangulateFace(mesh, face), face.id).toHaveLength(face.loop.length - 2); + } + } + }); + }); + + describe('winding', () => { + it('agrees with the face normal on a convex floor', () => { + const mesh = room(SHOEBOX); + const floor = findFace(mesh, 'floor')!; + for (const tri of triangulateFace(mesh, floor)) { + expect(dot(triangleNormal(mesh, tri), faceNormal(mesh, floor))).toBeCloseTo(1); + } + }); + + it('agrees with the face normal on a concave floor', () => { + const mesh = room(L_SHAPE); + const floor = findFace(mesh, 'floor')!; + for (const tri of triangulateFace(mesh, floor)) { + expect(dot(triangleNormal(mesh, tri), faceNormal(mesh, floor))).toBeCloseTo(1); + } + }); + + it('agrees with the face normal on every face of every room', () => { + // The end-to-end guarantee: inward normals established by the generator + // survive the trip through triangulation. + for (const points of [SHOEBOX, L_SHAPE, COMB]) { + const mesh = room(points); + for (const face of mesh.faces) { + const fn = faceNormal(mesh, face); + for (const tri of triangulateFace(mesh, face)) { + expect(dot(triangleNormal(mesh, tri), fn), `${face.id}`).toBeCloseTo(1); + } + } + } + }); + + it('keeps walls facing inward', () => { + const mesh = room(SHOEBOX); + const interior: Vec3 = [2, 1.5, 1.25]; + for (const face of mesh.faces) { + for (const tri of triangulateFace(mesh, face)) { + const [a] = tri.map((i) => mesh.vertices[i]); + expect(dot(triangleNormal(mesh, tri), sub(interior, a)), face.id).toBeGreaterThan(0); + } + } + }); + }); + + describe('area conservation', () => { + it.each([ + ['convex', SHOEBOX], + ['concave L', L_SHAPE], + ['comb', COMB], + ])('covers the %s floor exactly, with no gaps or overlaps', (_name, points) => { + const mesh = room(points); + const floor = findFace(mesh, 'floor')!; + const total = triangulateFace(mesh, floor).reduce( + (sum, tri) => sum + triangleArea(mesh, tri), + 0 + ); + expect(total).toBeCloseTo(polygonArea(mesh, floor)); + }); + + it('covers every face of a concave room exactly', () => { + const mesh = room(L_SHAPE); + for (const face of mesh.faces) { + const total = triangulateFace(mesh, face).reduce( + (sum, tri) => sum + triangleArea(mesh, tri), + 0 + ); + expect(total, face.id).toBeCloseTo(polygonArea(mesh, face)); + } + }); + + it('emits no zero-area triangles for a well-formed room', () => { + const mesh = room(COMB); + for (const face of mesh.faces) { + for (const tri of triangulateFace(mesh, face)) { + expect(triangleArea(mesh, tri), face.id).toBeGreaterThan(1e-9); + } + } + }); + }); + + describe('index integrity', () => { + it('only emits indices drawn from the face loop', () => { + const mesh = room(COMB); + for (const face of mesh.faces) { + const allowed = new Set(face.loop); + for (const tri of triangulateFace(mesh, face)) { + for (const id of tri) expect(allowed.has(id), `${face.id}/${id}`).toBe(true); + } + } + }); + + it('uses every vertex of the loop at least once', () => { + const mesh = room(L_SHAPE); + for (const face of mesh.faces) { + const used = new Set(triangulateFace(mesh, face).flat()); + expect(new Set(face.loop), face.id).toEqual(used); + } + }); + + it('never repeats an index within one triangle', () => { + const mesh = room(COMB); + for (const face of mesh.faces) { + for (const tri of triangulateFace(mesh, face)) { + expect(new Set(tri).size).toBe(3); + } + } + }); + }); + + describe('degenerate input', () => { + const manual = (vertices: Vec3[], loop: number[]): RoomMesh => ({ + vertices, + faces: [{ id: 'f', name: 'F', loop }], + source: { kind: 'manual' }, + }); + + it('returns nothing for a loop with fewer than 3 vertices', () => { + const mesh = manual([[0, 0, 0], [1, 0, 0]], [0, 1]); + expect(triangulateFace(mesh, mesh.faces[0])).toEqual([]); + }); + + it('returns nothing for a collinear 3-vertex loop', () => { + const mesh = manual([[0, 0, 0], [1, 0, 0], [2, 0, 0]], [0, 1, 2]); + expect(triangulateFace(mesh, mesh.faces[0])).toEqual([]); + }); + + it('returns nothing for a collinear 4-vertex loop', () => { + const mesh = manual([[0, 0, 0], [1, 0, 0], [2, 0, 0], [3, 0, 0]], [0, 1, 2, 3]); + expect(triangulateFace(mesh, mesh.faces[0])).toEqual([]); + }); + + it('passes a non-degenerate triangle straight through', () => { + const mesh = manual([[0, 0, 0], [1, 0, 0], [0, 1, 0]], [0, 1, 2]); + expect(triangulateFace(mesh, mesh.faces[0])).toEqual([[0, 1, 2]]); + }); + + it('terminates on a self-touching loop instead of hanging', () => { + // floorplanToMesh rejects this, but a hand-edited mesh could reach here. + const mesh = manual( + [[0, 0, 0], [4, 4, 0], [4, 0, 0], [0, 4, 0]], + [0, 1, 2, 3] + ); + const tris = triangulateFace(mesh, mesh.faces[0]); + expect(tris.length).toBeLessThanOrEqual(2); + }); + + it('handles a face in an arbitrary plane, not just axis-aligned', () => { + const mesh = manual( + [[0, 0, 0], [1, 1, 1], [0, 2, 2], [-1, 1, 1]], + [0, 1, 2, 3] + ); + const tris = triangulateFace(mesh, mesh.faces[0]); + expect(tris).toHaveLength(2); + const fn = faceNormal(mesh, mesh.faces[0]); + for (const tri of tris) { + expect(dot(triangleNormal(mesh, tri), fn)).toBeCloseTo(1); + } + }); + }); +}); + +describe('triangulatedPositions', () => { + it('emits 9 numbers per triangle', () => { + const mesh = room(L_SHAPE); + const floor = findFace(mesh, 'floor')!; + expect(triangulatedPositions(mesh, floor)).toHaveLength( + triangulateFace(mesh, floor).length * 9 + ); + }); + + it('emits the actual vertex coordinates in triangle order', () => { + const mesh = room(SHOEBOX); + const floor = findFace(mesh, 'floor')!; + const tris = triangulateFace(mesh, floor); + const expected = tris.flatMap((tri) => tri.flatMap((id) => mesh.vertices[id])); + expect(triangulatedPositions(mesh, floor)).toEqual(expected); + }); + + it('is empty for a degenerate face', () => { + const mesh: RoomMesh = { + vertices: [[0, 0, 0], [1, 0, 0]], + faces: [{ id: 'f', name: 'F', loop: [0, 1] }], + source: { kind: 'manual' }, + }; + expect(triangulatedPositions(mesh, mesh.faces[0])).toEqual([]); + }); +}); + +describe('triangulateMesh', () => { + it('keys results by stable face id', () => { + const mesh = room(SHOEBOX); + const result = triangulateMesh(mesh); + expect([...result.keys()]).toEqual(mesh.faces.map((f) => f.id)); + }); + + it('matches per-face triangulation', () => { + const mesh = room(L_SHAPE); + const result = triangulateMesh(mesh); + for (const face of mesh.faces) { + expect(result.get(face.id)).toEqual(triangulateFace(mesh, face)); + } + }); + + it('covers the whole room surface area', () => { + const mesh = room(L_SHAPE); + const total = [...triangulateMesh(mesh).values()] + .flat() + .reduce((sum, tri) => sum + triangleArea(mesh, tri), 0); + const expected = mesh.faces.reduce((sum, f) => sum + polygonArea(mesh, f), 0); + expect(total).toBeCloseTo(expected); + }); +}); diff --git a/src/compute/geometry/floorplan.ts b/src/compute/geometry/floorplan.ts new file mode 100644 index 00000000..0671ff69 --- /dev/null +++ b/src/compute/geometry/floorplan.ts @@ -0,0 +1,240 @@ +/** + * Floorplan — a parametric generator for {@link RoomMesh}. + * + * Takes a closed 2D outline on the ground plane plus a height, and extrudes it + * into a sealed room: one face per wall, plus a floor and a ceiling. + * + * Two things here are load-bearing and easy to get subtly wrong: + * + * 1. **Welding.** An n-point outline emits exactly 2n vertices — n at floor + * level, n at ceiling level — reused across walls, floor and ceiling. + * If every wall got its own four corners, dragging a corner later would + * tear the room open. + * + * 2. **Winding.** The raytracer reads `face.normal` (see raytracer/ray-core.ts, + * which takes `angleTo(face.normal)`), so orientation is physically + * meaningful and gets it *silently* wrong rather than loudly. Every loop + * emitted here is CCW as seen from inside the room, so normals point in. + * + * Coordinate system: CRAM is Z-up. Outline points are XY; height runs along +Z. + * + * Pure module — no THREE, no csg. See room-mesh.ts. + */ + +import type { Face, RoomMesh, Vec3 } from './room-mesh'; + +/** A point on the ground plane. */ +export interface Point2 { + x: number; + y: number; +} + +export interface FloorplanParams { + /** Outline vertices in order. The loop is implicit — do not repeat the first point. */ + points: Point2[]; + /** Extrusion height along +Z. Must be positive. */ + height: number; + /** Floor elevation. Defaults to 0. */ + baseZ?: number; +} + +export type FloorplanIssue = + | { code: 'too-few-points'; message: string } + | { code: 'invalid-height'; message: string } + | { code: 'duplicate-point'; message: string; index: number } + | { code: 'zero-area'; message: string } + | { code: 'self-intersecting'; message: string; edges: [number, number] }; + +const EPS = 1e-9; + +/** + * Shoelace area. Positive when the outline is counter-clockwise in XY. + */ +export function signedArea(points: Point2[]): number { + let sum = 0; + for (let i = 0; i < points.length; i++) { + const a = points[i]; + const b = points[(i + 1) % points.length]; + sum += a.x * b.y - b.x * a.y; + } + return sum / 2; +} + +/** + * Force counter-clockwise winding, keeping `points[0]` in place. + * + * Reversing the tail rather than the whole array means vertex 0 keeps its + * identity, so face ids stay pinned to the same physical corners whichever + * direction the user happened to draw in. + */ +export function normalizeWinding(points: Point2[]): Point2[] { + if (signedArea(points) >= 0) return points.slice(); + return [points[0], ...points.slice(1).reverse()]; +} + +/** Cross product sign of (b-a) x (c-a); 0 when collinear. */ +function orient(a: Point2, b: Point2, c: Point2): number { + const v = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + if (Math.abs(v) < EPS) return 0; + return v > 0 ? 1 : -1; +} + +/** Whether collinear point `p` lies within segment `a`-`b`. */ +function onSegment(a: Point2, b: Point2, p: Point2): boolean { + return ( + Math.min(a.x, b.x) - EPS <= p.x && + p.x <= Math.max(a.x, b.x) + EPS && + Math.min(a.y, b.y) - EPS <= p.y && + p.y <= Math.max(a.y, b.y) + EPS + ); +} + +/** + * Segment intersection including touching and collinear overlap. Non-adjacent + * outline edges that merely touch still make the polygon non-simple, so the + * degenerate cases matter here. + */ +function segmentsIntersect(p1: Point2, p2: Point2, p3: Point2, p4: Point2): boolean { + const d1 = orient(p3, p4, p1); + const d2 = orient(p3, p4, p2); + const d3 = orient(p1, p2, p3); + const d4 = orient(p1, p2, p4); + + if (d1 !== d2 && d3 !== d4) return true; + + if (d1 === 0 && onSegment(p3, p4, p1)) return true; + if (d2 === 0 && onSegment(p3, p4, p2)) return true; + if (d3 === 0 && onSegment(p1, p2, p3)) return true; + if (d4 === 0 && onSegment(p1, p2, p4)) return true; + + return false; +} + +/** + * Collect every reason this floorplan cannot be extruded. + * + * Returns all issues rather than throwing on the first, so the sketch UI can + * show the user everything that is wrong at once. + */ +export function validateFloorplan(params: FloorplanParams): FloorplanIssue[] { + const issues: FloorplanIssue[] = []; + const { points } = params; + + if (points.length < 3) { + issues.push({ + code: 'too-few-points', + message: `a room outline needs at least 3 points, got ${points.length}`, + }); + } + + if (!(params.height > 0) || !Number.isFinite(params.height)) { + issues.push({ + code: 'invalid-height', + message: `height must be a positive number, got ${params.height}`, + }); + } + + // Consecutive duplicates, including last-equals-first: the loop is implicit, + // so repeating the opening point is a common and confusing mistake. + 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) { + const wrapped = i === points.length - 1; + issues.push({ + code: 'duplicate-point', + index: i, + message: wrapped + ? 'the outline closes automatically — remove the repeated first point at the end' + : `points ${i} and ${i + 1} are in the same place`, + }); + } + } + + if (issues.length > 0) return issues; + + // Self-intersection is checked before area because a symmetric bowtie's lobes + // cancel to zero signed area — reporting "encloses no area" there would be + // technically true and completely unhelpful. "Walls cross" is the fixable one. + // + // Non-adjacent edge pairs only; neighbours legitimately share an endpoint. + const n = points.length; + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + const adjacent = j === i + 1 || (i === 0 && j === n - 1); + if (adjacent) continue; + if (segmentsIntersect(points[i], points[(i + 1) % n], points[j], points[(j + 1) % n])) { + issues.push({ + code: 'self-intersecting', + edges: [i, j], + message: `walls ${i + 1} and ${j + 1} cross each other`, + }); + } + } + } + + if (issues.length > 0) return issues; + + if (Math.abs(signedArea(points)) < EPS) { + issues.push({ + code: 'zero-area', + message: 'the outline encloses no area (all points are collinear)', + }); + } + + return issues; +} + +/** + * Extrude a validated outline into a sealed room. + * + * Vertex layout: indices `0..n-1` are the floor ring, `n..2n-1` the ceiling + * ring, so ceiling vertex for outline point `i` is always `n + i`. + * + * @throws if the plan does not pass {@link validateFloorplan}. + */ +export function floorplanToMesh(params: FloorplanParams): RoomMesh { + const issues = validateFloorplan(params); + if (issues.length > 0) { + throw new Error(`invalid floorplan: ${issues.map((i) => i.message).join('; ')}`); + } + + const baseZ = params.baseZ ?? 0; + const topZ = baseZ + params.height; + const points = normalizeWinding(params.points); + const n = points.length; + + const vertices: Vec3[] = []; + for (const p of points) vertices.push([p.x, p.y, baseZ]); + for (const p of points) vertices.push([p.x, p.y, topZ]); + + const floorRing = points.map((_, i) => i); + + const faces: Face[] = [ + // CCW in XY viewed from above gives +Z: up, into the room. + { id: 'floor', name: 'Floor', loop: floorRing }, + // Reversed gives -Z: down, into the room. + { id: 'ceiling', name: 'Ceiling', loop: floorRing.map((i) => n + i).reverse() }, + ]; + + 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], + }); + } + + return { + vertices, + faces, + source: { + kind: 'floorplan', + params: { points, height: params.height, baseZ }, + detached: false, + }, + }; +} diff --git a/src/compute/geometry/room-mesh.ts b/src/compute/geometry/room-mesh.ts new file mode 100644 index 00000000..df0089df --- /dev/null +++ b/src/compute/geometry/room-mesh.ts @@ -0,0 +1,173 @@ +/** + * RoomMesh — the editable topology that backs room geometry. + * + * This is the source of truth for a room's shape. It is deliberately *not* + * a triangle soup: vertices are shared and index-addressed, and faces are + * polygonal loops referencing those indices. Triangulation happens later, + * at the boundary where BufferGeometry is built. + * + * That distinction is what makes direct vertex manipulation possible: a room + * corner is one vertex shared by three faces, so moving it moves every face + * that touches it. Flatten to triangles early and that corner becomes six + * unrelated copies. + * + * Coordinate system: CRAM is Z-up (see `camera.up.set(0, 0, 1)` in + * render/renderer.ts). The ground plane is XY and height runs along +Z. + * + * This module is pure — no THREE, no csg, no store. Keep it that way; it is + * the only part of the geometry pipeline that can be tested without mocks. + */ + +import type { FloorplanParams } from './floorplan'; +import { floorplanToMesh } from './floorplan'; + +/** A point in 3D space as [x, y, z]. */ +export type Vec3 = [number, number, number]; + +/** Index into {@link RoomMesh.vertices}. */ +export type VertexId = number; + +/** + * Stable identifier for a face. Ids survive regeneration, which is what lets + * an acoustic material assignment outlive an edit — the adapter matches + * Surfaces to faces by this id rather than rebuilding them. + * + * Note that stability is positional, not semantic: adding a point to a + * floorplan can change which physical wall `wall-2` refers to. + */ +export type FaceId = string; + +/** A single planar face, wound counter-clockwise as seen from inside the room. */ +export interface Face { + id: FaceId; + name: string; + /** Vertex indices in CCW order viewed from the face's inward side. */ + loop: VertexId[]; +} + +/** + * Where a mesh came from, so parametric and manual editing can coexist. + * + * A floorplan-sourced mesh can be regenerated from its params until someone + * moves a vertex by hand; that flips `detached`, and the params are kept as + * reference rather than discarded. + */ +export type MeshSource = + | { kind: 'floorplan'; params: FloorplanParams; detached: boolean } + | { kind: 'manual' }; + +export interface RoomMesh { + /** Shared, index-addressed. Faces reference these by position. */ + vertices: Vec3[]; + faces: Face[]; + source: MeshSource; +} + +/** + * An edit to a mesh, expressed as data. + * + * Only `set-floorplan` is driven by UI today; `move-vertex` is implemented + * because it is what proves the topology actually supports 3D editing, and + * because it gives the eventual vertex gizmo something to call. + */ +export type MeshEdit = + | { kind: 'set-floorplan'; params: FloorplanParams } + | { kind: 'move-vertex'; id: VertexId; to: Vec3 }; + +/** Marks a floorplan-sourced mesh as hand-edited, keeping the params for reference. */ +function detach(source: MeshSource): MeshSource { + return source.kind === 'floorplan' ? { ...source, detached: true } : source; +} + +/** + * Apply an edit, returning a new mesh. Never mutates the input. + */ +export function applyEdit(mesh: RoomMesh, edit: MeshEdit): RoomMesh { + switch (edit.kind) { + case 'set-floorplan': + return floorplanToMesh(edit.params); + + case 'move-vertex': { + if (!Number.isInteger(edit.id) || edit.id < 0 || edit.id >= mesh.vertices.length) { + throw new RangeError( + `move-vertex: no vertex ${edit.id} (mesh has ${mesh.vertices.length})` + ); + } + const vertices = mesh.vertices.map( + (v, i): Vec3 => (i === edit.id ? [edit.to[0], edit.to[1], edit.to[2]] : v) + ); + return { ...mesh, vertices, source: detach(mesh.source) }; + } + } +} + +/** Resolve a face's loop to actual positions. */ +export function faceVertices(mesh: RoomMesh, face: Face): Vec3[] { + return face.loop.map((id) => mesh.vertices[id]); +} + +/** + * Unit normal via Newell's method, which handles non-planar and concave loops + * gracefully. Follows the right-hand rule, so a CCW loop yields a normal + * pointing back at the viewer — i.e. into the room, given our winding rule. + */ +export function faceNormal(mesh: RoomMesh, face: Face): Vec3 { + const pts = faceVertices(mesh, face); + let nx = 0; + let ny = 0; + let nz = 0; + for (let i = 0; i < pts.length; i++) { + const a = pts[i]; + const b = pts[(i + 1) % pts.length]; + nx += (a[1] - b[1]) * (a[2] + b[2]); + ny += (a[2] - b[2]) * (a[0] + b[0]); + nz += (a[0] - b[0]) * (a[1] + b[1]); + } + const len = Math.hypot(nx, ny, nz); + if (len === 0) return [0, 0, 0]; + return [nx / len, ny / len, nz / len]; +} + +/** Average of a face's vertices. */ +export function faceCentroid(mesh: RoomMesh, face: Face): Vec3 { + const pts = faceVertices(mesh, face); + const sum = pts.reduce( + (acc, p): Vec3 => [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]], + [0, 0, 0] as Vec3 + ); + return [sum[0] / pts.length, sum[1] / pts.length, sum[2] / pts.length]; +} + +/** + * The floorplan a mesh was generated from, if it still has one. + * + * Defensive about shape because a mesh revived from a save file is unvalidated + * JSON: `isRoomMesh` checks vertices and faces, not provenance, so `source` + * may be missing or malformed on an old or hand-edited project. + * + * @returns null when the mesh was not generated from a floorplan. + */ +export function floorplanSource( + mesh: RoomMesh +): { params: FloorplanParams; detached: boolean } | null { + const source = mesh.source as Partial> | undefined; + if (!source || source.kind !== 'floorplan') return null; + + const params = source.params; + if (!params || !Array.isArray(params.points) || typeof params.height !== 'number') return null; + + return { params, detached: source.detached === true }; +} + +/** Look up a face by its stable id. */ +export function findFace(mesh: RoomMesh, id: FaceId): Face | undefined { + return mesh.faces.find((f) => f.id === id); +} + +/** + * Every face index that references a given vertex. The eventual vertex gizmo + * needs this to know what to redraw; tests use it to prove welding worked. + */ +export function facesTouchingVertex(mesh: RoomMesh, id: VertexId): Face[] { + return mesh.faces.filter((f) => f.loop.includes(id)); +} diff --git a/src/compute/geometry/sketch-input.ts b/src/compute/geometry/sketch-input.ts new file mode 100644 index 00000000..119272db --- /dev/null +++ b/src/compute/geometry/sketch-input.ts @@ -0,0 +1,210 @@ +/** + * Sketch input — the state of a floorplan being drawn. + * + * All the decisions a drawing tool makes live here: where a click lands once + * snapping is applied, whether it closes the loop, what the rubber-band preview + * should look like, and when the outline is ready to extrude. The tool in + * render/floorplan-tool.ts is left with pointer plumbing and three.js objects. + * + * Splitting it this way means the fiddly parts — ortho constraint interacting + * with grid snap, close-loop tolerance, undo — are testable without a canvas, + * a camera, or a DOM event. + * + * Coordinates are ground-plane XY in world units (CRAM is Z-up). + * + * Pure module — no THREE, no DOM. See room-mesh.ts. + */ + +import { validateFloorplan, type FloorplanIssue, type FloorplanParams, type Point2 } from './floorplan'; + +export interface SketchDraft { + /** Committed outline points, in click order. */ + points: Point2[]; + /** Snapped cursor position for the rubber-band segment, if the pointer is over the plane. */ + cursor: Point2 | null; + /** True once the outline has been closed back to its first point. */ + closed: boolean; +} + +export interface SnapSettings { + /** Grid spacing in world units. 0 disables grid snapping. */ + gridSize: number; + /** Constrain each segment to horizontal or vertical from the previous point. */ + ortho: boolean; + /** How near the first point a click must land to close the loop, in world units. */ + closeDistance: number; +} + +export const DEFAULT_SNAP: SnapSettings = { + gridSize: 0.25, + ortho: false, + closeDistance: 0.5, +}; + +export interface SnapResult { + point: Point2; + /** The point landed on the outline's start and would close it. */ + closesLoop: boolean; +} + +const EPS = 1e-9; + +export function emptyDraft(): SketchDraft { + return { points: [], cursor: null, closed: false }; +} + +/** + * Rebuild a draft from an existing outline — used when the panel adopts a room + * that was drawn in an earlier session. Copies the points, so editing the draft + * cannot reach back into the mesh it came from. + */ +export function draftFromPoints(points: Point2[]): SketchDraft { + return { + points: points.map((p) => ({ x: p.x, y: p.y })), + cursor: null, + closed: points.length >= 3, + }; +} + +function samePoint(a: Point2, b: Point2): boolean { + return Math.abs(a.x - b.x) < EPS && Math.abs(a.y - b.y) < EPS; +} + +function distance(a: Point2, b: Point2): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +function snapToGrid(p: Point2, gridSize: number): Point2 { + if (!(gridSize > 0)) return p; + return { + x: Math.round(p.x / gridSize) * gridSize, + y: Math.round(p.y / gridSize) * gridSize, + }; +} + +/** Constrain to whichever axis the pointer has travelled further along. */ +function constrainOrtho(p: Point2, from: Point2): Point2 { + const dx = Math.abs(p.x - from.x); + const dy = Math.abs(p.y - from.y); + return dx >= dy ? { x: p.x, y: from.y } : { x: from.x, y: p.y }; +} + +/** Whether the outline has enough points to be closed. */ +export function canClose(draft: SketchDraft): boolean { + return !draft.closed && draft.points.length >= 3; +} + +/** + * Resolve a raw ground-plane position into the point that would actually be used. + * + * Close-loop is tested against the *raw* position and wins outright: it is an + * exact target the user is aiming at, and letting grid snap run first could + * nudge the point off the start and leave the outline stubbornly open. + */ +export function snap( + raw: Point2, + draft: SketchDraft, + settings: SnapSettings = DEFAULT_SNAP +): SnapResult { + if (canClose(draft) && distance(raw, draft.points[0]) <= settings.closeDistance) { + return { point: { ...draft.points[0] }, closesLoop: true }; + } + + const previous = draft.points[draft.points.length - 1]; + const constrained = settings.ortho && previous ? constrainOrtho(raw, previous) : raw; + + return { point: snapToGrid(constrained, settings.gridSize), closesLoop: false }; +} + +/** Update the rubber-band cursor. Pass null when the pointer leaves the plane. */ +export function moveCursor( + draft: SketchDraft, + raw: Point2 | null, + settings: SnapSettings = DEFAULT_SNAP +): SketchDraft { + if (draft.closed) return draft; + if (raw === null) return { ...draft, cursor: null }; + return { ...draft, cursor: snap(raw, draft, settings).point }; +} + +/** + * Commit a point. + * + * Ignored when the draft is already closed, or when the click repeats the + * previous point — a double click should not produce a zero-length wall that + * validation would later reject. + */ +export function addPoint( + draft: SketchDraft, + raw: Point2, + settings: SnapSettings = DEFAULT_SNAP +): SketchDraft { + if (draft.closed) return draft; + + const { point, closesLoop } = snap(raw, draft, settings); + if (closesLoop) return closeDraft(draft); + + const previous = draft.points[draft.points.length - 1]; + if (previous && samePoint(previous, point)) return draft; + + return { ...draft, points: [...draft.points, point], cursor: point }; +} + +/** Close the outline. No-op unless there are at least three points. */ +export function closeDraft(draft: SketchDraft): SketchDraft { + if (!canClose(draft)) return draft; + return { ...draft, closed: true, cursor: null }; +} + +/** + * Step backwards: reopen a closed outline, otherwise drop the last point. + * + * Closing adds no point of its own, so undoing it should not remove one either. + */ +export function undoLastPoint(draft: SketchDraft): SketchDraft { + if (draft.closed) return { ...draft, closed: false }; + if (draft.points.length === 0) return draft; + return { ...draft, points: draft.points.slice(0, -1) }; +} + +/** + * The polyline to draw, including the rubber-band segment to the cursor and, + * once closed, the segment back to the start. + */ +export function previewPath(draft: SketchDraft): Point2[] { + if (draft.points.length === 0) return draft.cursor ? [draft.cursor] : []; + if (draft.closed) return [...draft.points, { ...draft.points[0] }]; + return draft.cursor ? [...draft.points, draft.cursor] : [...draft.points]; +} + +/** Total length of the committed outline, closing segment included once closed. */ +export function draftPerimeter(draft: SketchDraft): number { + const path = draft.closed ? [...draft.points, draft.points[0]] : draft.points; + let total = 0; + for (let i = 1; i < path.length; i++) total += distance(path[i - 1], path[i]); + return total; +} + +/** + * Turn a closed draft into extrudable parameters. + * + * @returns null while the outline is still open — the caller has nothing to + * build yet, which is not an error worth throwing over. + */ +export function toFloorplanParams( + draft: SketchDraft, + height: number, + baseZ = 0 +): FloorplanParams | null { + if (!draft.closed || draft.points.length < 3) return null; + return { points: draft.points.map((p) => ({ ...p })), height, baseZ }; +} + +/** + * Problems that would stop the draft extruding, for live feedback while drawing. + * An open outline reports nothing: it is incomplete, not wrong. + */ +export function draftIssues(draft: SketchDraft, height: number): FloorplanIssue[] { + const params = toFloorplanParams(draft, height); + return params ? validateFloorplan(params) : []; +} diff --git a/src/compute/geometry/sync-plan.ts b/src/compute/geometry/sync-plan.ts new file mode 100644 index 00000000..e604b661 --- /dev/null +++ b/src/compute/geometry/sync-plan.ts @@ -0,0 +1,68 @@ +/** + * Reconciliation planning — which faces are new, which persist, which are gone. + * + * Kept separate from the adapter (objects/room-from-mesh.ts) because this is + * the part with actual decisions in it, and it can be tested without dragging + * in THREE, Surface, or the store. + * + * The reason any of this exists: a Surface carries the user's acoustic material + * assignment. Rebuilding a room from scratch on every edit would silently + * discard that, so faces are matched to existing Surfaces by their stable id + * and updated in place instead. + * + * Pure module — no THREE, no csg. See room-mesh.ts. + */ + +import type { FaceId } from './room-mesh'; + +export interface FaceSyncPlan { + /** Faces that already have a Surface: update its geometry, keep its identity. */ + updated: FaceId[]; + /** Faces with no Surface yet: create one. */ + added: FaceId[]; + /** Surfaces whose face no longer exists: dispose them. */ + removed: FaceId[]; +} + +/** + * Diff existing face ids against the ids a mesh now has. + * + * `updated` and `added` follow the incoming order so downstream Surfaces keep + * a stable, meaningful ordering; `removed` follows the existing order. + * Duplicate ids on either side are collapsed. + */ +export function planFaceSync( + existing: Iterable, + incoming: Iterable +): FaceSyncPlan { + // Materialised up front: both arguments are iterated twice, and a one-shot + // iterator (Map.keys(), a generator) would be spent after the first pass. + // syncRoomFromMesh passes exactly that, so consuming it lazily here would + // silently stop reporting removals. + const existingIds = [...existing]; + const incomingIds = [...incoming]; + + const have = new Set(existingIds); + const want = new Set(incomingIds); + + const updated: FaceId[] = []; + const added: FaceId[] = []; + const seen = new Set(); + + for (const id of incomingIds) { + if (seen.has(id)) continue; + seen.add(id); + if (have.has(id)) updated.push(id); + else added.push(id); + } + + const removed: FaceId[] = []; + const droppedSeen = new Set(); + for (const id of existingIds) { + if (droppedSeen.has(id)) continue; + droppedSeen.add(id); + if (!want.has(id)) removed.push(id); + } + + return { updated, added, removed }; +} diff --git a/src/compute/geometry/triangulate.ts b/src/compute/geometry/triangulate.ts new file mode 100644 index 00000000..1a759d85 --- /dev/null +++ b/src/compute/geometry/triangulate.ts @@ -0,0 +1,205 @@ +/** + * Triangulation — the boundary where topology becomes renderable geometry. + * + * Faces in a {@link RoomMesh} are polygonal loops, which is what makes shared + * vertices and later vertex editing possible. Solvers and BufferGeometry both + * want triangles, so the conversion happens here and nowhere earlier. + * + * Ear clipping is used rather than a fan because room floors are routinely + * concave — an L-shaped room is the common case, not an exotic one, and a fan + * would emit triangles that spill outside the outline. + * + * **Winding contract:** output triangles are wound consistently with the face + * loop they came from, so their normals agree with `faceNormal`. Since the + * raytracer reads face normals (raytracer/ray-core.ts), breaking this would + * silently invert a room's acoustics rather than fail loudly. + * + * Pure module — no THREE, no csg. See room-mesh.ts. + */ + +import { faceNormal, type Face, type RoomMesh, type Vec3, type VertexId } from './room-mesh'; + +/** A triangle as three indices into {@link RoomMesh.vertices}. */ +export type Triangle = [VertexId, VertexId, VertexId]; + +interface P2 { + x: number; + y: number; +} + +const EPS = 1e-10; + +function cross3(a: Vec3, b: Vec3): Vec3 { + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +} + +function normalize3(v: Vec3): Vec3 { + const len = Math.hypot(v[0], v[1], v[2]); + return len === 0 ? [0, 0, 0] : [v[0] / len, v[1] / len, v[2] / len]; +} + +/** + * An orthonormal basis (u, v) spanning the plane with u x v === n. + * + * Building a real basis rather than dropping the dominant axis keeps + * orientation exact: a loop wound CCW around `n` projects to a loop with + * positive shoelace area in (u, v), with no per-axis sign correction. + */ +function basisFor(n: Vec3): [Vec3, Vec3] { + const t: Vec3 = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]; + const u = normalize3(cross3(t, n)); + const v = cross3(n, u); + return [u, v]; +} + +/** Twice the signed area; positive when counter-clockwise. */ +function doubleSignedArea(pts: P2[]): number { + let sum = 0; + for (let i = 0; i < pts.length; i++) { + const a = pts[i]; + const b = pts[(i + 1) % pts.length]; + sum += a.x * b.y - b.x * a.y; + } + return sum; +} + +function sign(p: P2, a: P2, b: P2): number { + return (a.x - p.x) * (b.y - p.y) - (b.x - p.x) * (a.y - p.y); +} + +/** Inside or on the boundary. Conservative on purpose: a point on an edge blocks the ear. */ +function pointInTriangle(p: P2, a: P2, b: P2, c: P2): boolean { + const d1 = sign(p, a, b); + const d2 = sign(p, b, c); + const d3 = sign(p, c, a); + const hasNeg = d1 < -EPS || d2 < -EPS || d3 < -EPS; + const hasPos = d1 > EPS || d2 > EPS || d3 > EPS; + return !(hasNeg && hasPos); +} + +/** + * Ear clipping over a counter-clockwise simple polygon. + * + * Returns triples of indices into `pts`. The caller guarantees CCW input; + * see the invariant note in {@link triangulateFace}. + */ +function earClip(pts: P2[]): Triangle[] { + const n = pts.length; + if (n < 3) return []; + + const ring = pts.map((_, i) => i); + const tris: Triangle[] = []; + + // Bounded so malformed input degrades into a usable result instead of hanging. + let guard = n * n + 16; + + while (ring.length > 3 && guard-- > 0) { + let clipped = false; + let fallback = -1; + let fallbackArea = -Infinity; + + for (let k = 0; k < ring.length; k++) { + const i0 = ring[(k - 1 + ring.length) % ring.length]; + const i1 = ring[k]; + const i2 = ring[(k + 1) % ring.length]; + const a = pts[i0]; + const b = pts[i1]; + const c = pts[i2]; + + const convex = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + if (convex <= EPS) continue; // reflex or collinear: not an ear + + if (convex > fallbackArea) { + fallbackArea = convex; + fallback = k; + } + + let blocked = false; + for (const m of ring) { + if (m === i0 || m === i1 || m === i2) continue; + if (pointInTriangle(pts[m], a, b, c)) { + blocked = true; + break; + } + } + if (blocked) continue; + + tris.push([i0, i1, i2]); + ring.splice(k, 1); + clipped = true; + break; + } + + if (!clipped) { + // No valid ear: the polygon is degenerate or self-touching. Clip the + // largest convex corner regardless so we terminate with something usable. + const k = fallback >= 0 ? fallback : 0; + const i0 = ring[(k - 1 + ring.length) % ring.length]; + const i1 = ring[k]; + const i2 = ring[(k + 1) % ring.length]; + tris.push([i0, i1, i2]); + ring.splice(k, 1); + } + } + + if (ring.length === 3) tris.push([ring[0], ring[1], ring[2]]); + return tris; +} + +/** + * Triangulate one face into triangles indexed against `mesh.vertices`. + * + * Returns an empty array for degenerate faces (fewer than three vertices, or + * a loop with no well-defined normal) rather than throwing — a half-drawn + * sketch should render as nothing, not crash the viewport. + * + * Non-planar loops are flattened onto the Newell plane, so the result is an + * approximation for faces that have been hand-edited out of plane. + */ +export function triangulateFace(mesh: RoomMesh, face: Face): Triangle[] { + const loop = face.loop; + if (loop.length < 3) return []; + + // Checked before the triangle fast path so a collinear 3-vertex face is + // rejected too, rather than emitting a zero-area triangle downstream. + const n = faceNormal(mesh, face); + if (n[0] === 0 && n[1] === 0 && n[2] === 0) return []; + + if (loop.length === 3) return [[loop[0], loop[1], loop[2]]]; + + const [u, v] = basisFor(n); + const pts: P2[] = loop.map((id) => { + const p = mesh.vertices[id]; + return { + x: p[0] * u[0] + p[1] * u[1] + p[2] * u[2], + y: p[0] * v[0] + p[1] * v[1] + p[2] * v[2], + }; + }); + + // Invariant: `n` is Newell's normal of this very loop and u x v === n, so the + // projection is CCW by construction and its area cannot come out negative. + // Bail rather than emit inverted triangles if that ever stops holding. + if (doubleSignedArea(pts) < 0) return []; + + return earClip(pts).map(([a, b, c]): Triangle => [loop[a], loop[b], loop[c]]); +} + +/** + * Triangulate a face into a flat [x,y,z, x,y,z, ...] position list, the form + * BufferGeometry wants. Three vertices per triangle, no index buffer. + */ +export function triangulatedPositions(mesh: RoomMesh, face: Face): number[] { + const out: number[] = []; + for (const tri of triangulateFace(mesh, face)) { + for (const id of tri) { + const p = mesh.vertices[id]; + out.push(p[0], p[1], p[2]); + } + } + return out; +} + +/** Triangulate every face, keyed by the face's stable id. */ +export function triangulateMesh(mesh: RoomMesh): Map { + return new Map(mesh.faces.map((f) => [f.id, triangulateFace(mesh, f)])); +} diff --git a/src/objects/__tests__/mesh-userdata.spec.ts b/src/objects/__tests__/mesh-userdata.spec.ts new file mode 100644 index 00000000..1b23c7f8 --- /dev/null +++ b/src/objects/__tests__/mesh-userdata.spec.ts @@ -0,0 +1,178 @@ +/** + * Mesh userData tests. + * + * The load-bearing claim of step 7 is that a RoomMesh survives a save file + * intact — JSON is the only thing between a drawn room and an editable one + * after reload — and that a malformed mesh degrades to "not editable" rather + * than taking the restore down with it. + * + * No mocks: mesh-userdata imports types only. + */ + +import { describe, it, expect } from 'vitest'; +import { + FACE_ID_KEY, + ROOM_MESH_KEY, + getFaceId, + getRoomMesh, + isRoomMesh, + setFaceId, + setRoomMesh, +} from '../mesh-userdata'; +import { floorplanToMesh, type Point2 } from '../../compute/geometry/floorplan'; +import { applyEdit, faceNormal, findFace } from '../../compute/geometry/room-mesh'; +import { triangulateFace } from '../../compute/geometry/triangulate'; + +const SHOEBOX: Point2[] = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 0, y: 3 }, +]; + +const mesh = (height = 2.5) => floorplanToMesh({ points: SHOEBOX, height }); + +/** What a save/load cycle actually does to the value. */ +const roundTrip = (value: T): unknown => JSON.parse(JSON.stringify(value)); + +describe('face id', () => { + it('round-trips through userData', () => { + const object = { userData: {} as Record }; + setFaceId(object, 'wall-2'); + expect(getFaceId(object)).toBe('wall-2'); + expect(object.userData[FACE_ID_KEY]).toBe('wall-2'); + }); + + it('is undefined when unset', () => { + expect(getFaceId({ userData: {} })).toBeUndefined(); + }); + + it('is undefined when userData is missing entirely', () => { + expect(getFaceId({})).toBeUndefined(); + }); + + it('creates userData when absent', () => { + const object: { userData?: Record } = {}; + setFaceId(object, 'floor'); + expect(getFaceId(object)).toBe('floor'); + }); + + it('ignores a non-string value', () => { + expect(getFaceId({ userData: { [FACE_ID_KEY]: 42 } })).toBeUndefined(); + }); +}); + +describe('room mesh', () => { + it('round-trips through userData', () => { + const object = { userData: {} as Record }; + const m = mesh(); + setRoomMesh(object, m); + expect(getRoomMesh(object)).toBe(m); + expect(object.userData[ROOM_MESH_KEY]).toBe(m); + }); + + it('is undefined for an object that never had one', () => { + expect(getRoomMesh({ userData: {} })).toBeUndefined(); + }); + + it('creates userData when absent', () => { + const object: { userData?: Record } = {}; + setRoomMesh(object, mesh()); + expect(getRoomMesh(object)).toBeDefined(); + }); +}); + +describe('isRoomMesh', () => { + it('accepts a generated mesh', () => { + expect(isRoomMesh(mesh())).toBe(true); + }); + + it.each([ + ['null', null], + ['undefined', undefined], + ['a string', 'mesh'], + ['a number', 7], + ['an empty object', {}], + ['missing faces', { vertices: [[0, 0, 0]] }], + ['missing vertices', { faces: [] }], + ['vertices that are not triples', { vertices: [[0, 0]], faces: [] }], + ['vertices that are not arrays', { vertices: [{ x: 0 }], faces: [] }], + ['a face without an id', { vertices: [], faces: [{ loop: [0, 1, 2] }] }], + ['a face without a loop', { vertices: [], faces: [{ id: 'floor' }] }], + ['a loop of non-numbers', { vertices: [], faces: [{ id: 'f', loop: ['a'] }] }], + ])('rejects %s', (_name, value) => { + expect(isRoomMesh(value)).toBe(false); + }); + + it('accepts an empty but well-formed mesh', () => { + expect(isRoomMesh({ vertices: [], faces: [] })).toBe(true); + }); +}); + +describe('surviving a save file', () => { + it('is still recognised as a mesh after JSON', () => { + expect(isRoomMesh(roundTrip(mesh()))).toBe(true); + }); + + it('is structurally identical after JSON', () => { + const original = mesh(); + expect(roundTrip(original)).toEqual(original); + }); + + it('keeps vertex positions exactly', () => { + const original = mesh(2.5); + const revived = roundTrip(original) as typeof original; + expect(revived.vertices).toEqual(original.vertices); + }); + + it('keeps face ids and loops, which the reconciler matches on', () => { + const original = mesh(); + const revived = roundTrip(original) as typeof original; + expect(revived.faces.map((f) => f.id)).toEqual(original.faces.map((f) => f.id)); + expect(revived.faces.map((f) => f.loop)).toEqual(original.faces.map((f) => f.loop)); + }); + + it('keeps the floorplan provenance, so the room stays parametric', () => { + const original = mesh(); + const revived = roundTrip(original) as typeof original; + expect(revived.source).toEqual(original.source); + }); + + it('preserves the detached flag', () => { + const edited = applyEdit(mesh(), { kind: 'move-vertex', id: 0, to: [-1, -1, 0] }); + expect((roundTrip(edited) as typeof edited).source).toMatchObject({ detached: true }); + }); + + it('still triangulates to the same geometry', () => { + const original = mesh(); + const revived = roundTrip(original) as typeof original; + for (const face of original.faces) { + expect(triangulateFace(revived, findFace(revived, face.id)!)).toEqual( + triangulateFace(original, face) + ); + } + }); + + it('still has inward-facing normals', () => { + // Winding is the property that fails silently, so it is worth restating + // on the far side of serialisation. + const revived = roundTrip(mesh()) as ReturnType; + expect(faceNormal(revived, findFace(revived, 'floor')!)).toEqual([0, 0, 1]); + expect(faceNormal(revived, findFace(revived, 'ceiling')!)).toEqual([0, 0, -1]); + }); + + it('can still be edited after the round trip', () => { + const revived = roundTrip(mesh(2.5)) as ReturnType; + const taller = applyEdit(revived, { + kind: 'set-floorplan', + params: { points: SHOEBOX, height: 4 }, + }); + expect(Math.max(...taller.vertices.map((v) => v[2]))).toBeCloseTo(4); + }); + + it('can still move a vertex after the round trip', () => { + const revived = roundTrip(mesh()) as ReturnType; + const moved = applyEdit(revived, { kind: 'move-vertex', id: 0, to: [-2, -2, 0] }); + expect(moved.vertices[0]).toEqual([-2, -2, 0]); + }); +}); diff --git a/src/objects/__tests__/room-from-mesh.spec.ts b/src/objects/__tests__/room-from-mesh.spec.ts new file mode 100644 index 00000000..e4f6aee6 --- /dev/null +++ b/src/objects/__tests__/room-from-mesh.spec.ts @@ -0,0 +1,271 @@ +/** + * Adapter tests — RoomMesh into Room/Surface. + * + * Surface and Room are replaced with light fakes. The logic worth testing here + * is reconciliation: which faces update in place, which Surfaces are created or + * disposed, and above all whether a user's acoustic material survives an edit. + * Dragging in the real Surface would mean mocking csg, BRDF, the store and the + * renderer to end up testing none of that. + * + * three.js is real, so the BufferGeometry assertions mean something. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as THREE from 'three'; + +import { + FACE_ID_KEY, + addRoomFromMesh, + geometryForFace, + getFaceId, + roomFromMesh, + surfaceForFace, + syncRoomFromMesh, +} from '../room-from-mesh'; +import { emit } from '../../messenger'; +import { floorplanToMesh, type Point2 } from '../../compute/geometry/floorplan'; +import { findFace } from '../../compute/geometry/room-mesh'; +import { triangulateFace } from '../../compute/geometry/triangulate'; +import { + FakeRoom, + FakeSurface, + resetSurfaceCounter, +} from '../../test-utils/room-fakes'; + +vi.mock('../surface', async () => { + const { FakeSurface } = await import('../../test-utils/room-fakes'); + return { __esModule: true, default: FakeSurface }; +}); +vi.mock('../room', async () => { + const { FakeRoom } = await import('../../test-utils/room-fakes'); + return { __esModule: true, default: FakeRoom, Room: FakeRoom }; +}); +vi.mock('../../messenger', () => ({ emit: vi.fn(), on: vi.fn(), off: vi.fn() })); + +const SHOEBOX: Point2[] = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 0, y: 3 }, +]; + +const MATERIAL = { uuid: 'mat-default', name: 'Default' } as never; +const CARPET = { uuid: 'mat-carpet', name: 'Carpet' } as never; + +const mesh = (points = SHOEBOX, height = 2.5) => floorplanToMesh({ points, height }); +const opts = { acousticMaterial: MATERIAL }; + +const asRoom = (r: FakeRoom): any => r; + +beforeEach(() => { + vi.mocked(emit).mockClear(); + resetSurfaceCounter(); +}); + +describe('geometryForFace', () => { + it('produces three positions per triangle', () => { + const m = mesh(); + const floor = findFace(m, 'floor')!; + const geometry = geometryForFace(m, floor); + const position = geometry.getAttribute('position'); + expect(position.count).toBe(triangulateFace(m, floor).length * 3); + expect(position.itemSize).toBe(3); + }); + + it('names the geometry after the face', () => { + const m = mesh(); + expect(geometryForFace(m, findFace(m, 'wall-2')!).name).toBe('face-wall-2'); + }); + + it('computes vertex normals', () => { + const m = mesh(); + expect(geometryForFace(m, findFace(m, 'floor')!).getAttribute('normal')).toBeTruthy(); + }); + + it('places the floor at z=0 and the ceiling at the room height', () => { + const m = mesh(SHOEBOX, 2.5); + const floor = geometryForFace(m, findFace(m, 'floor')!); + floor.computeBoundingBox(); + expect(floor.boundingBox!.max.z).toBeCloseTo(0); + + const ceiling = geometryForFace(m, findFace(m, 'ceiling')!); + ceiling.computeBoundingBox(); + expect(ceiling.boundingBox!.min.z).toBeCloseTo(2.5); + }); +}); + +describe('surfaceForFace', () => { + it('tags the surface with its face id', () => { + const m = mesh(); + const surface = surfaceForFace(m, findFace(m, 'wall-1')!, MATERIAL); + expect(surface.userData[FACE_ID_KEY]).toBe('wall-1'); + expect(getFaceId(surface as never)).toBe('wall-1'); + }); + + it('names the surface after the face', () => { + const m = mesh(); + expect(surfaceForFace(m, findFace(m, 'wall-0')!, MATERIAL).name).toBe('Wall 1'); + }); + + it('applies the given acoustic material', () => { + const m = mesh(); + const surface = surfaceForFace(m, findFace(m, 'floor')!, CARPET) as unknown as FakeSurface; + expect(surface.acousticMaterial).toBe(CARPET); + }); +}); + +describe('getFaceId', () => { + it('returns undefined for an untagged object', () => { + expect(getFaceId({ userData: {} } as never)).toBeUndefined(); + }); + + it('returns undefined when userData is absent', () => { + expect(getFaceId({} as never)).toBeUndefined(); + }); +}); + +describe('roomFromMesh', () => { + it('creates one surface per face', () => { + const room = roomFromMesh(mesh(), opts) as unknown as FakeRoom; + expect(room.allSurfaces).toHaveLength(6); + }); + + it('tags every surface with a distinct face id', () => { + const room = roomFromMesh(mesh(), opts) as unknown as FakeRoom; + const ids = room.allSurfaces.map((s) => s.userData[FACE_ID_KEY]); + expect(ids).toEqual(['floor', 'ceiling', 'wall-0', 'wall-1', 'wall-2', 'wall-3']); + }); + + it('uses the supplied name', () => { + const room = roomFromMesh(mesh(), { ...opts, name: 'Studio A' }) as unknown as FakeRoom; + expect(room.name).toBe('Studio A'); + }); + + it('does not emit on its own', () => { + roomFromMesh(mesh(), opts); + expect(emit).not.toHaveBeenCalled(); + }); +}); + +describe('addRoomFromMesh', () => { + it('emits ADD_ROOM with the room', () => { + const room = addRoomFromMesh(mesh(), opts); + expect(emit).toHaveBeenCalledWith('ADD_ROOM', room); + }); +}); + +describe('syncRoomFromMesh', () => { + it('updates every face in place when only the height changes', () => { + const room = roomFromMesh(mesh(SHOEBOX, 2.5), opts); + const plan = syncRoomFromMesh(room, mesh(SHOEBOX, 4), opts); + + expect(plan.updated).toHaveLength(6); + expect(plan.added).toEqual([]); + expect(plan.removed).toEqual([]); + }); + + it('preserves the acoustic material a user assigned to a wall', () => { + // The hook this whole design exists for. Rebuilding surfaces instead of + // updating them would silently reset this on every height tweak. + const room = roomFromMesh(mesh(SHOEBOX, 2.5), opts); + const fake = asRoom(room) as FakeRoom; + const floor = fake.allSurfaces.find((s) => s.userData[FACE_ID_KEY] === 'floor')!; + floor.acousticMaterial = CARPET; + + syncRoomFromMesh(room, mesh(SHOEBOX, 4), opts); + + expect(floor.acousticMaterial).toBe(CARPET); + }); + + it('preserves surface identity across an edit', () => { + const room = roomFromMesh(mesh(SHOEBOX, 2.5), opts); + const fake = asRoom(room) as FakeRoom; + const before = fake.allSurfaces.map((s) => s.uuid); + + syncRoomFromMesh(room, mesh(SHOEBOX, 4), opts); + + expect(fake.allSurfaces.map((s) => s.uuid)).toEqual(before); + }); + + it('feeds new geometry through init rather than replacing the surface', () => { + const room = roomFromMesh(mesh(SHOEBOX, 2.5), opts); + const fake = asRoom(room) as FakeRoom; + const ceiling = fake.allSurfaces.find((s) => s.userData[FACE_ID_KEY] === 'ceiling')!; + + syncRoomFromMesh(room, mesh(SHOEBOX, 4), opts); + + expect(ceiling.initCalls).toHaveLength(1); + ceiling.geometry.computeBoundingBox(); + expect(ceiling.geometry.boundingBox!.min.z).toBeCloseTo(4); + }); + + it('creates a surface for a wall added to the outline', () => { + const room = roomFromMesh(mesh(), opts); + const pentagon = [...SHOEBOX, { x: -1, y: 1.5 }]; + const plan = syncRoomFromMesh(room, mesh(pentagon), opts); + + expect(plan.added).toEqual(['wall-4']); + const fake = asRoom(room) as FakeRoom; + expect(fake.allSurfaces).toHaveLength(7); + expect(emit).toHaveBeenCalledWith('ADD_SURFACE', expect.anything()); + }); + + it('disposes the surface for a wall removed from the outline', () => { + const room = roomFromMesh(mesh(), opts); + const fake = asRoom(room) as FakeRoom; + const doomed = fake.allSurfaces.find((s) => s.userData[FACE_ID_KEY] === 'wall-3')!; + + const plan = syncRoomFromMesh(room, mesh(SHOEBOX.slice(0, 3)), opts); + + expect(plan.removed).toEqual(['wall-3']); + expect(doomed.disposed).toBe(true); + expect(emit).toHaveBeenCalledWith('REMOVE_SURFACE', doomed.uuid); + expect(fake.allSurfaces).toHaveLength(5); + expect(fake.allSurfaces).not.toContain(doomed); + }); + + it('settles after a removal, so a repeat sync is a no-op', () => { + // Guards the case where a disposed surface lingers in the room and gets + // re-reported as removed on every subsequent edit. + const room = roomFromMesh(mesh(), opts); + const triangle = mesh(SHOEBOX.slice(0, 3)); + syncRoomFromMesh(room, triangle, opts); + + const second = syncRoomFromMesh(room, triangle, opts); + expect(second.removed).toEqual([]); + expect(second.added).toEqual([]); + expect(second.updated).toHaveLength(5); + }); + + it('gives newly added surfaces the option material, not a neighbour\'s', () => { + const room = roomFromMesh(mesh(), opts); + const fake = asRoom(room) as FakeRoom; + for (const s of fake.allSurfaces) s.acousticMaterial = CARPET; + + syncRoomFromMesh(room, mesh([...SHOEBOX, { x: -1, y: 1.5 }]), opts); + + const added = fake.allSurfaces.find((s) => s.userData[FACE_ID_KEY] === 'wall-4')!; + expect(added.acousticMaterial).toBe(MATERIAL); + }); + + it('ignores surfaces that carry no face id', () => { + const room = roomFromMesh(mesh(), opts); + const fake = asRoom(room) as FakeRoom; + fake.surfaces.add(new FakeSurface('stray', { geometry: new THREE.BufferGeometry(), acousticMaterial: MATERIAL })); + + const plan = syncRoomFromMesh(room, mesh(SHOEBOX, 4), opts); + + expect(plan.removed).toEqual([]); + expect(plan.updated).toHaveLength(6); + }); + + it('is idempotent when the mesh has not changed', () => { + const room = roomFromMesh(mesh(), opts); + const fake = asRoom(room) as FakeRoom; + const plan = syncRoomFromMesh(room, mesh(), opts); + + expect(plan.added).toEqual([]); + expect(plan.removed).toEqual([]); + expect(fake.allSurfaces).toHaveLength(6); + }); +}); diff --git a/src/objects/__tests__/room-mesh-editor.spec.ts b/src/objects/__tests__/room-mesh-editor.spec.ts new file mode 100644 index 00000000..96db8a7b --- /dev/null +++ b/src/objects/__tests__/room-mesh-editor.spec.ts @@ -0,0 +1,346 @@ +/** + * Mesh editing + undo/redo tests. + * + * The interesting claim is that undo/redo needs no inverse operations: because + * `syncRoomFromMesh` reconciles by stable face id, restoring a remembered mesh + * is enough to bring back walls an edit deleted, remove ones it created, and + * keep acoustic materials attached throughout. These tests exercise that + * through the real `history` singleton. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { + commitMeshEdit, + isEditable, + moveVertex, + setFloorplan, +} from '../room-mesh-editor'; +import { + FACE_ID_KEY, + getRoomMesh, + roomFromMesh, +} from '../room-from-mesh'; +import { history } from '../../history'; +import { floorplanToMesh, type Point2 } from '../../compute/geometry/floorplan'; +import { FakeRoom, FakeSurface, resetSurfaceCounter } from '../../test-utils/room-fakes'; +import * as THREE from 'three'; +import { getRoomMesh as readMesh, setRoomMesh } from '../mesh-userdata'; + +vi.mock('../surface', async () => { + const { FakeSurface } = await import('../../test-utils/room-fakes'); + return { __esModule: true, default: FakeSurface }; +}); +vi.mock('../room', async () => { + const { FakeRoom } = await import('../../test-utils/room-fakes'); + return { __esModule: true, default: FakeRoom, Room: FakeRoom }; +}); +vi.mock('../../messenger', () => ({ emit: vi.fn(), on: vi.fn(), off: vi.fn() })); + +const SHOEBOX: Point2[] = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 0, y: 3 }, +]; +const PENTAGON: Point2[] = [...SHOEBOX, { x: -1, y: 1.5 }]; +const TRIANGLE: Point2[] = SHOEBOX.slice(0, 3); + +const MATERIAL = { uuid: 'mat-default', name: 'Default' } as never; +const CARPET = { uuid: 'mat-carpet', name: 'Carpet' } as never; +const opts = { acousticMaterial: MATERIAL }; + +const mesh = (points = SHOEBOX, height = 2.5) => floorplanToMesh({ points, height }); + +/** A room plus a typed view of the fake standing in for it. */ +function makeRoom(points = SHOEBOX, height = 2.5) { + const room = roomFromMesh(mesh(points, height), opts); + return { room, fake: room as unknown as FakeRoom }; +} + +const ceilingZ = (fake: FakeRoom): number => { + const ceiling = fake.byFaceId(FACE_ID_KEY, 'ceiling')!; + ceiling.geometry.computeBoundingBox(); + return ceiling.geometry.boundingBox!.min.z; +}; + +beforeEach(() => { + history.clear(); + resetSurfaceCounter(); +}); + +describe('isEditable', () => { + it('is true for a room built from a mesh', () => { + expect(isEditable(makeRoom().room)).toBe(true); + }); + + it('is false for a room with no mesh, such as an import', () => { + expect(isEditable(new FakeRoom('imported') as never)).toBe(false); + }); +}); + +describe('commitMeshEdit', () => { + it('refuses a room that did not come from a floorplan', () => { + const imported = new FakeRoom('imported') as never; + expect(() => + commitMeshEdit(imported, { kind: 'set-floorplan', params: { points: SHOEBOX, height: 3 } }, opts) + ).toThrow(/no editable mesh/); + }); + + it('stores the new mesh on the room', () => { + const { room } = makeRoom(SHOEBOX, 2.5); + const after = setFloorplan(room, { points: SHOEBOX, height: 4 }, opts); + expect(getRoomMesh(room)).toBe(after); + }); + + it('records one moment per edit', () => { + const { room } = makeRoom(); + setFloorplan(room, { points: SHOEBOX, height: 3 }, opts); + setFloorplan(room, { points: SHOEBOX, height: 4 }, opts); + expect(history.timeline).toHaveLength(2); + }); + + it('categorises moments by edit kind', () => { + const { room } = makeRoom(); + setFloorplan(room, { points: SHOEBOX, height: 3 }, opts); + moveVertex(room, 0, [-1, -1, 0], opts); + expect(history.timeline.map((m) => m.category)).toEqual([ + 'ROOM_MESH_SET_FLOORPLAN', + 'ROOM_MESH_MOVE_VERTEX', + ]); + }); + + it('attributes the moment to the room', () => { + const { room, fake } = makeRoom(); + setFloorplan(room, { points: SHOEBOX, height: 3 }, opts); + expect(history.timeline[0].objectId).toBe(fake.uuid); + }); + + describe('rejected edits', () => { + it('propagates a validation failure', () => { + const { room } = makeRoom(); + expect(() => setFloorplan(room, { points: SHOEBOX, height: 0 }, opts)).toThrow( + /invalid floorplan/ + ); + }); + + it('leaves the room untouched when the edit is invalid', () => { + const { room, fake } = makeRoom(SHOEBOX, 2.5); + const before = ceilingZ(fake); + + expect(() => setFloorplan(room, { points: SHOEBOX, height: -1 }, opts)).toThrow(); + + expect(ceilingZ(fake)).toBeCloseTo(before); + expect(fake.allSurfaces).toHaveLength(6); + }); + + it('records no moment for a rejected edit', () => { + const { room } = makeRoom(); + expect(() => setFloorplan(room, { points: SHOEBOX, height: 0 }, opts)).toThrow(); + expect(history.timeline).toHaveLength(0); + }); + }); +}); + +describe('setFloorplan', () => { + it('applies a new height', () => { + const { room, fake } = makeRoom(SHOEBOX, 2.5); + setFloorplan(room, { points: SHOEBOX, height: 4 }, opts); + expect(ceilingZ(fake)).toBeCloseTo(4); + }); + + it('is undone by history.undo', () => { + const { room, fake } = makeRoom(SHOEBOX, 2.5); + setFloorplan(room, { points: SHOEBOX, height: 4 }, opts); + + history.undo(); + + expect(ceilingZ(fake)).toBeCloseTo(2.5); + }); + + it('is reapplied by history.redo', () => { + const { room, fake } = makeRoom(SHOEBOX, 2.5); + setFloorplan(room, { points: SHOEBOX, height: 4 }, opts); + + history.undo(); + history.redo(); + + expect(ceilingZ(fake)).toBeCloseTo(4); + }); + + it('unwinds several edits in order', () => { + const { room, fake } = makeRoom(SHOEBOX, 2.5); + setFloorplan(room, { points: SHOEBOX, height: 4 }, opts); + setFloorplan(room, { points: SHOEBOX, height: 6 }, opts); + + expect(ceilingZ(fake)).toBeCloseTo(6); + history.undo(); + expect(ceilingZ(fake)).toBeCloseTo(4); + history.undo(); + expect(ceilingZ(fake)).toBeCloseTo(2.5); + }); +}); + +describe('undo across topology changes', () => { + it('removes a wall that an edit added', () => { + const { room, fake } = makeRoom(SHOEBOX); + setFloorplan(room, { points: PENTAGON, height: 2.5 }, opts); + expect(fake.allSurfaces).toHaveLength(7); + + history.undo(); + + expect(fake.allSurfaces).toHaveLength(6); + expect(fake.byFaceId(FACE_ID_KEY, 'wall-4')).toBeUndefined(); + }); + + it('restores a wall that an edit removed', () => { + const { room, fake } = makeRoom(SHOEBOX); + setFloorplan(room, { points: TRIANGLE, height: 2.5 }, opts); + expect(fake.allSurfaces).toHaveLength(5); + + history.undo(); + + expect(fake.allSurfaces).toHaveLength(6); + expect(fake.byFaceId(FACE_ID_KEY, 'wall-3')).toBeDefined(); + }); + + it('redoes a topology change', () => { + const { room, fake } = makeRoom(SHOEBOX); + setFloorplan(room, { points: PENTAGON, height: 2.5 }, opts); + + history.undo(); + history.redo(); + + expect(fake.allSurfaces).toHaveLength(7); + expect(fake.byFaceId(FACE_ID_KEY, 'wall-4')).toBeDefined(); + }); + + it('keeps a material assignment through an undone topology change', () => { + // Undo restores the wall, but it is a new Surface — the material rides on + // the surviving faces, which is what the user actually notices. + const { room, fake } = makeRoom(SHOEBOX); + fake.byFaceId(FACE_ID_KEY, 'floor')!.acousticMaterial = CARPET; + + setFloorplan(room, { points: PENTAGON, height: 2.5 }, opts); + history.undo(); + + expect(fake.byFaceId(FACE_ID_KEY, 'floor')!.acousticMaterial).toBe(CARPET); + }); +}); + +describe('moveVertex', () => { + it('moves the vertex and every face sharing it', () => { + const { room, fake } = makeRoom(SHOEBOX, 2.5); + moveVertex(room, 0, [-2, -2, 0], opts); + + const floor = fake.byFaceId(FACE_ID_KEY, 'floor')!; + floor.geometry.computeBoundingBox(); + expect(floor.geometry.boundingBox!.min.x).toBeCloseTo(-2); + + const wall = fake.byFaceId(FACE_ID_KEY, 'wall-0')!; + wall.geometry.computeBoundingBox(); + expect(wall.geometry.boundingBox!.min.x).toBeCloseTo(-2); + }); + + it('marks the mesh detached from its floorplan', () => { + const { room } = makeRoom(); + moveVertex(room, 0, [-2, -2, 0], opts); + expect(getRoomMesh(room)!.source).toMatchObject({ kind: 'floorplan', detached: true }); + }); + + it('is undone by history.undo', () => { + const { room, fake } = makeRoom(SHOEBOX, 2.5); + moveVertex(room, 0, [-2, -2, 0], opts); + + history.undo(); + + const floor = fake.byFaceId(FACE_ID_KEY, 'floor')!; + floor.geometry.computeBoundingBox(); + expect(floor.geometry.boundingBox!.min.x).toBeCloseTo(0); + }); + + it('reattaches the mesh to its plan when undone', () => { + const { room } = makeRoom(); + moveVertex(room, 0, [-2, -2, 0], opts); + + history.undo(); + + expect(getRoomMesh(room)!.source).toMatchObject({ detached: false }); + }); + + it('changes no surface count', () => { + const { room, fake } = makeRoom(); + moveVertex(room, 2, [9, 9, 0], opts); + expect(fake.allSurfaces).toHaveLength(6); + }); + + it('rejects an out-of-range vertex without recording a moment', () => { + const { room } = makeRoom(); + expect(() => moveVertex(room, 99, [0, 0, 0], opts)).toThrow(RangeError); + expect(history.timeline).toHaveLength(0); + }); +}); + +describe('a room reloaded from a save file', () => { + /** + * Rebuild a Room the way Room.restore() does: fresh Surfaces carrying their + * saved face ids, and the mesh revived from JSON. This is the whole point of + * persisting them — without it a reloaded room renders but cannot be edited. + */ + function restoreRoom(materials: Record = {}) { + const original = roomFromMesh(mesh(SHOEBOX, 2.5), opts); + const savedMesh = JSON.parse(JSON.stringify(readMesh(original))); + + const restored = new FakeRoom('sketched room'); + for (const face of savedMesh.faces) { + const surface = new FakeSurface(face.name, { + geometry: new THREE.BufferGeometry(), + acousticMaterial: materials[face.id] ?? MATERIAL, + }); + surface.userData[FACE_ID_KEY] = face.id; + restored.surfaces.add(surface); + } + setRoomMesh(restored, savedMesh); + return restored as unknown as FakeRoom; + } + + it('is editable again', () => { + expect(isEditable(restoreRoom() as never)).toBe(true); + }); + + it('accepts a height change', () => { + const restored = restoreRoom(); + setFloorplan(restored as never, { points: SHOEBOX, height: 4 }, opts); + expect(ceilingZ(restored)).toBeCloseTo(4); + }); + + it('updates its surfaces in place rather than rebuilding them', () => { + const restored = restoreRoom(); + const before = restored.allSurfaces.map((s) => s.uuid); + + setFloorplan(restored as never, { points: SHOEBOX, height: 4 }, opts); + + expect(restored.allSurfaces.map((s) => s.uuid)).toEqual(before); + }); + + it('keeps materials assigned before the save', () => { + const restored = restoreRoom({ floor: CARPET }); + setFloorplan(restored as never, { points: SHOEBOX, height: 4 }, opts); + expect(restored.byFaceId(FACE_ID_KEY, 'floor')!.acousticMaterial).toBe(CARPET); + }); + + it('still supports topology changes', () => { + const restored = restoreRoom(); + setFloorplan(restored as never, { points: PENTAGON, height: 2.5 }, opts); + expect(restored.allSurfaces).toHaveLength(7); + }); + + it('records the edit in history, so undo works after a reload', () => { + const restored = restoreRoom(); + setFloorplan(restored as never, { points: SHOEBOX, height: 4 }, opts); + + history.undo(); + + expect(ceilingZ(restored)).toBeCloseTo(2.5); + }); +}); diff --git a/src/objects/__tests__/surface.spec.ts b/src/objects/__tests__/surface.spec.ts index c7291fc4..56d07180 100644 --- a/src/objects/__tests__/surface.spec.ts +++ b/src/objects/__tests__/surface.spec.ts @@ -238,6 +238,49 @@ describe('Surface', () => { }); }); + describe('mesh face id', () => { + // Persisted so a reloaded project can still be reconciled against its + // RoomMesh; without it a restored room renders but cannot be edited. + const build = () => + new Surface('Test', { + geometry: createMockGeometry(), + acousticMaterial: mockAcousticMaterial, + }); + + it('is omitted for a surface that has none', () => { + expect(build().save().faceId).toBeUndefined(); + }); + + it('is dropped by JSON when absent, leaving old saves unchanged', () => { + const saved = JSON.parse(JSON.stringify(build().save())); + expect('faceId' in saved).toBe(false); + }); + + it('is saved when the surface carries one', () => { + const surface = build(); + surface.userData.cramFaceId = 'wall-2'; + expect(surface.save().faceId).toBe('wall-2'); + }); + + it('is restored from a save object', () => { + const surface = build(); + surface.userData.cramFaceId = 'ceiling'; + const saved = JSON.parse(JSON.stringify(surface.save())); + + const revived = new Surface('Revived'); + revived.restore(saved); + + expect(revived.userData.cramFaceId).toBe('ceiling'); + }); + + it('leaves a restored surface untagged when the save has no faceId', () => { + const saved = JSON.parse(JSON.stringify(build().save())); + const revived = new Surface('Revived'); + revived.restore(saved); + expect(revived.userData.cramFaceId).toBeUndefined(); + }); + }); + describe('save', () => { it('returns a serializable object', () => { const geometry = createMockGeometry(); diff --git a/src/objects/mesh-userdata.ts b/src/objects/mesh-userdata.ts new file mode 100644 index 00000000..06e96b7c --- /dev/null +++ b/src/objects/mesh-userdata.ts @@ -0,0 +1,73 @@ +/** + * The link between CRAM objects and the editable geometry they came from. + * + * A Room remembers the {@link RoomMesh} it was generated from, and each Surface + * remembers which face of that mesh it represents. Both ride in `userData`, and + * both are persisted so a reloaded project stays editable. + * + * This lives in its own module, holding only key constants and accessors, + * because room.ts and surface.ts need it for save/restore while + * room-from-mesh.ts imports *them*. Putting the keys there instead would close + * an import cycle — the kind that already produced TDZ failures in this + * codebase (see the notes in compute/csg). + */ + +import type { FaceId, RoomMesh } from '../compute/geometry/room-mesh'; + +/** Key under which a Surface records its mesh face. */ +export const FACE_ID_KEY = 'cramFaceId'; + +/** Key under which a Room records the mesh it was generated from. */ +export const ROOM_MESH_KEY = 'cramRoomMesh'; + +/** Structural minimum, so these helpers do not depend on Container or THREE. */ +interface HasUserData { + userData?: Record; +} + +/** The face id a Surface was built for, if it has one. */ +export function getFaceId(object: HasUserData): FaceId | undefined { + const value = object.userData?.[FACE_ID_KEY]; + return typeof value === 'string' ? value : undefined; +} + +export function setFaceId(object: HasUserData, id: FaceId): void { + if (!object.userData) object.userData = {}; + object.userData[FACE_ID_KEY] = id; +} + +/** + * The mesh a Room was generated from, if it came from the sketch editor. + * + * Rooms imported from OBJ/DXF have none, which is what makes them + * non-editable rather than editable-but-broken. + */ +export function getRoomMesh(object: HasUserData): RoomMesh | undefined { + return object.userData?.[ROOM_MESH_KEY] as RoomMesh | undefined; +} + +export function setRoomMesh(object: HasUserData, mesh: RoomMesh): void { + if (!object.userData) object.userData = {}; + object.userData[ROOM_MESH_KEY] = mesh; +} + +/** + * Whether a parsed value is structurally a RoomMesh. + * + * Save files are user-supplied data and can be old, hand-edited or truncated. + * A malformed mesh should leave the room merely non-editable, not crash the + * restore and take the whole project down with it. + */ +export function isRoomMesh(value: unknown): value is RoomMesh { + if (!value || typeof value !== 'object') return false; + const mesh = value as Partial; + if (!Array.isArray(mesh.vertices) || !Array.isArray(mesh.faces)) return false; + if (!mesh.vertices.every((v) => Array.isArray(v) && v.length === 3)) return false; + return mesh.faces.every( + (f) => + !!f && + typeof f.id === 'string' && + Array.isArray(f.loop) && + f.loop.every((i) => typeof i === 'number') + ); +} diff --git a/src/objects/room-from-mesh.ts b/src/objects/room-from-mesh.ts new file mode 100644 index 00000000..6f06a2bb --- /dev/null +++ b/src/objects/room-from-mesh.ts @@ -0,0 +1,151 @@ +/** + * Adapter — turns an editable {@link RoomMesh} into CRAM's Room/Surface objects. + * + * This is the seam between the pure geometry layer and the rest of the app. + * Everything downstream (solvers, BVH, materials, the object tree, save files) + * consumes `Room { Surface[] }`, so this is the only file the editor needs in + * order to hand its work to the existing machinery. + * + * Deliberately thin: the decisions live in compute/geometry (triangulation and + * sync planning, both testable without mocks). What is left here is object + * construction and wiring. + * + * One face becomes one Surface, so acoustic materials can be assigned per wall + * — which is the entire point of the exercise for a room acoustics tool, and + * something a single extruded mesh could not support. + */ + +import * as THREE from 'three'; + +import Surface from './surface'; +import Room from './room'; +import type Container from './container'; +import { emit } from '../messenger'; +import type { AcousticMaterial } from '../db/acoustic-material'; +import { triangulatedPositions } from '../compute/geometry/triangulate'; +import { planFaceSync, type FaceSyncPlan } from '../compute/geometry/sync-plan'; +import { findFace, type Face, type FaceId, type RoomMesh } from '../compute/geometry/room-mesh'; +import { getFaceId, setFaceId, setRoomMesh } from './mesh-userdata'; + +// The keys and accessors live in mesh-userdata.ts so room.ts and surface.ts can +// use them for save/restore without importing this module and closing a cycle. +export { + FACE_ID_KEY, + ROOM_MESH_KEY, + getFaceId, + getRoomMesh, + setRoomMesh, +} from './mesh-userdata'; + +export interface RoomFromMeshOptions { + /** Material applied to faces that do not already have one. */ + acousticMaterial: AcousticMaterial; + name?: string; +} + +/** + * Build a non-indexed BufferGeometry for one face. + * + * Triangle winding comes from the geometry layer and is what gives three.js + * the face normals the raytracer later reads, so nothing here may reorder + * vertices. + */ +export function geometryForFace(mesh: RoomMesh, face: Face): THREE.BufferGeometry { + const positions = triangulatedPositions(mesh, face); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3)); + geometry.computeVertexNormals(); + geometry.name = `face-${face.id}`; + return geometry; +} + +/** Create a Surface for a single face, tagged with that face's id. */ +export function surfaceForFace( + mesh: RoomMesh, + face: Face, + acousticMaterial: AcousticMaterial +): Surface { + const surface = new Surface(face.name, { + geometry: geometryForFace(mesh, face), + acousticMaterial, + }); + setFaceId(surface, face.id); + return surface; +} + +/** + * Build a fresh Room from a mesh. Every face gets the same starting material; + * per-wall assignment is the user's job afterwards, and {@link syncRoomFromMesh} + * is what keeps those assignments alive across later edits. + */ +export function roomFromMesh(mesh: RoomMesh, options: RoomFromMeshOptions): Room { + const surfaces = mesh.faces.map((face) => + surfaceForFace(mesh, face, options.acousticMaterial) + ); + const room = new Room(options.name ?? 'new room', { surfaces }); + setRoomMesh(room, mesh); + return room; +} + +/** Build a Room and register it with the app. */ +export function addRoomFromMesh(mesh: RoomMesh, options: RoomFromMeshOptions): Room { + const room = roomFromMesh(mesh, options); + emit('ADD_ROOM', room); + return room; +} + +/** + * Reconcile an existing Room against an edited mesh. + * + * Faces that already have a Surface are updated *in place* via `Surface.init`, + * which preserves the Surface's uuid and — critically — its acoustic material. + * Rebuilding instead would wipe the user's assignments on every height tweak. + * + * `init` merges over module defaults, so the current material has to be passed + * back in explicitly or it would be reset. + */ +export function syncRoomFromMesh( + room: Room, + mesh: RoomMesh, + options: RoomFromMeshOptions +): FaceSyncPlan { + const byFaceId = new Map(); + for (const surface of room.allSurfaces) { + const id = getFaceId(surface); + if (id !== undefined && !byFaceId.has(id)) byFaceId.set(id, surface); + } + + const plan = planFaceSync( + byFaceId.keys(), + mesh.faces.map((f) => f.id) + ); + + for (const id of plan.updated) { + const surface = byFaceId.get(id)!; + const face = findFace(mesh, id)!; + surface.init({ + geometry: geometryForFace(mesh, face), + acousticMaterial: surface.acousticMaterial, + }); + setFaceId(surface, id); + } + + for (const id of plan.added) { + const face = findFace(mesh, id)!; + const surface = surfaceForFace(mesh, face, options.acousticMaterial); + room.surfaces.add(surface); + emit('ADD_SURFACE', surface); + } + + for (const id of plan.removed) { + const surface = byFaceId.get(id)!; + surface.dispose(); + emit('REMOVE_SURFACE', surface.uuid); + } + + // 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); + + return plan; +} diff --git a/src/objects/room-mesh-editor.ts b/src/objects/room-mesh-editor.ts new file mode 100644 index 00000000..f10ecaeb --- /dev/null +++ b/src/objects/room-mesh-editor.ts @@ -0,0 +1,96 @@ +/** + * Mesh editing with undo/redo. + * + * Applies a {@link MeshEdit} to a Room's mesh, reconciles the Surfaces, and + * records a Moment so the change can be undone. + * + * Undo and redo are deliberately not special-cased per operation: both are just + * "re-sync the Room against a remembered mesh". `syncRoomFromMesh` already + * diffs by stable face id, so restoring an earlier mesh recreates walls that an + * edit removed and disposes ones it added, with materials preserved throughout. + * That falls out of the step-3 reconciler rather than needing inverse ops. + * + * There is no 3D editing UI yet. `move-vertex` is wired here because it is what + * proves the topology supports direct manipulation — the eventual gizmo should + * only need to call {@link moveVertex}. + */ + +import type Room from './room'; +import { addMoment, type Directions } from '../history'; +import { + getRoomMesh, + syncRoomFromMesh, + type RoomFromMeshOptions, +} from './room-from-mesh'; +import { applyEdit, type MeshEdit, type RoomMesh, type Vec3, type VertexId } from '../compute/geometry/room-mesh'; +import type { FloorplanParams } from '../compute/geometry/floorplan'; + +/** Moment categories, one per edit kind, so history is legible when debugging. */ +const CATEGORY: Record = { + 'set-floorplan': 'ROOM_MESH_SET_FLOORPLAN', + 'move-vertex': 'ROOM_MESH_MOVE_VERTEX', +}; + +/** + * Apply an edit to a Room's mesh and record it in history. + * + * @returns the mesh the Room now holds. + * @throws if the Room has no mesh (it was imported rather than sketched), or if + * the edit itself is invalid — an invalid floorplan must not be + * half-applied, so validation failure propagates before anything + * touches the Room. + */ +export function commitMeshEdit( + room: Room, + edit: MeshEdit, + options: RoomFromMeshOptions +): RoomMesh { + const before = getRoomMesh(room); + if (!before) { + throw new Error( + `room "${room.name}" has no editable mesh — only rooms created from a floorplan can be edited` + ); + } + + // Computed before any mutation, so a rejected edit leaves the Room untouched. + const after = applyEdit(before, edit); + + syncRoomFromMesh(room, after, options); + + addMoment({ + category: CATEGORY[edit.kind], + objectId: room.uuid, + recallFunction: (direction?: keyof Directions) => { + syncRoomFromMesh(room, direction === 'UNDO' ? before : after, options); + }, + }); + + return after; +} + +/** Replace the room's outline and/or height. */ +export function setFloorplan( + room: Room, + params: FloorplanParams, + options: RoomFromMeshOptions +): RoomMesh { + return commitMeshEdit(room, { kind: 'set-floorplan', params }, options); +} + +/** + * Move a single vertex. Every face sharing it follows, because the mesh stores + * shared vertices rather than per-face copies. + */ +export function moveVertex( + room: Room, + id: VertexId, + to: Vec3, + options: RoomFromMeshOptions +): RoomMesh { + return commitMeshEdit(room, { kind: 'move-vertex', id, to }, options); +} + +/** Whether this Room came from the sketch editor and can accept mesh edits. */ +export function isEditable(room: Room): boolean { + return getRoomMesh(room) !== undefined; +} diff --git a/src/objects/room.ts b/src/objects/room.ts index f461548d..4c307735 100644 --- a/src/objects/room.ts +++ b/src/objects/room.ts @@ -10,6 +10,8 @@ import { emit, on } from "../messenger"; import { addContainer, removeContainer, setContainerProperty } from "../store"; import { renderer } from "../render/renderer"; import { TessellateModifier } from "../compute/radiance/TessellateModifier"; +import { getRoomMesh, isRoomMesh, setRoomMesh } from "./mesh-userdata"; +import type { RoomMesh } from "../compute/geometry/room-mesh"; export interface RoomProps extends ContainerProps { surfaces: (Surface|Container)[]; @@ -35,6 +37,12 @@ export interface RoomSaveObject { scale: number[]; temperature?: number; humidity?: number; + /** + * The editable mesh this room was generated from, when it came from the + * sketch editor. Absent for imported geometry, which is what makes such a + * room non-editable rather than editable-but-broken. + */ + mesh?: RoomMesh; } export class Room extends Container { @@ -107,6 +115,9 @@ export class Room extends Container { scale: this.scale.toArray(), temperature: this.temperature, humidity: this.humidity, + // Undefined for imported rooms; JSON.stringify drops it, so existing + // save files round-trip unchanged. + mesh: getRoomMesh(this), } as RoomSaveObject; } restore(state: RoomSaveObject) { @@ -132,6 +143,10 @@ export class Room extends Container { this.uuid = state.uuid; this.temperature = state.temperature ?? 20; this.humidity = state.humidity ?? 40; + // Validated rather than trusted: a save file is user-supplied and may be + // old or hand-edited. A malformed mesh leaves the room non-editable + // instead of failing the whole restore. + if (isRoomMesh(state.mesh)) setRoomMesh(this, state.mesh); return this; } diff --git a/src/objects/surface.ts b/src/objects/surface.ts index cec7accd..9b05b50e 100644 --- a/src/objects/surface.ts +++ b/src/objects/surface.ts @@ -17,6 +17,7 @@ import {scatteringFunction} from '../compute/acoustics/scattering-function'; import { TessellateModifier } from "../compute/radiance/TessellateModifier"; import { Float32BufferAttribute } from "three"; import SurfaceElement from "./surface-element"; +import { getFaceId, setFaceId } from "./mesh-userdata"; /** Vector3 as an array (i.e. [x,y,z]) */ export type Vector3A = [number, number, number]; @@ -184,6 +185,8 @@ export interface SurfaceSaveObject { fillSurface: boolean; displayVertexNormals: boolean; scatteringCoefficient: number; + /** Set when this Surface represents a face of an editable RoomMesh. */ + faceId?: string; } interface KeepLine { @@ -436,7 +439,10 @@ class Surface extends Container { position: this.position.toArray(), rotation: this.rotation.toArray().slice(0, 3) as [number, number, number], scale: this.scale.toArray(), - uuid: this.uuid + uuid: this.uuid, + // Undefined for imported surfaces; JSON.stringify drops it, so old and + // non-sketched save files are unaffected. + faceId: getFaceId(this) } as SurfaceSaveObject; } @@ -458,6 +464,7 @@ class Surface extends Container { this.position.set(surfaceState.position[0], surfaceState.position[1], surfaceState.position[2]); this.rotation.set(surfaceState.rotation[0], surfaceState.rotation[1], surfaceState.rotation[2], "XYZ"); this.scale.set(surfaceState.scale[0], surfaceState.scale[1], surfaceState.scale[2]); + if (surfaceState.faceId) setFaceId(this, surfaceState.faceId); return this; } select() { diff --git a/src/render/__tests__/floorplan-tool.spec.ts b/src/render/__tests__/floorplan-tool.spec.ts new file mode 100644 index 00000000..7dbffa3e --- /dev/null +++ b/src/render/__tests__/floorplan-tool.spec.ts @@ -0,0 +1,356 @@ +/** + * Floorplan tool tests. + * + * Real three.js and real DOM events. An orthographic camera looking straight + * down the -Z axis makes the screen-to-ground mapping exactly predictable: + * with a 10x10 frustum over a 100x100 element, NDC (1,1) is world (5,5). + * + * The drawing rules themselves are covered in sketch-input.spec.ts; what is + * tested here is the plumbing — projection, event wiring, preview buffers and + * teardown. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as THREE from 'three'; +import { FloorplanTool } from '../floorplan-tool'; + +const SIZE = 100; + +function makeCamera(): THREE.OrthographicCamera { + const camera = new THREE.OrthographicCamera(-5, 5, 5, -5, 0.1, 100); + camera.position.set(0, 0, 10); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(true); + return camera; +} + +function makeElement(): HTMLElement { + const el = document.createElement('div'); + el.getBoundingClientRect = () => + ({ left: 0, top: 0, width: SIZE, height: SIZE, right: SIZE, bottom: SIZE, x: 0, y: 0 }) as DOMRect; + document.body.appendChild(el); + return el; +} + +/** Screen coords for a world ground point, given the camera above. */ +function screenFor(x: number, y: number) { + return { clientX: ((x / 5) * 0.5 + 0.5) * SIZE, clientY: (0.5 - (y / 5) * 0.5) * SIZE }; +} + +function pointer(type: string, x: number, y: number, button = 0): MouseEvent { + const { clientX, clientY } = screenFor(x, y); + return new MouseEvent(type, { clientX, clientY, button, bubbles: true }); +} + +let element: HTMLElement; +let parent: THREE.Object3D; +let tool: FloorplanTool; + +function makeTool(options: Partial[0]> = {}) { + return new FloorplanTool({ + domElement: element, + camera: makeCamera(), + parent, + settings: { gridSize: 0, ortho: false, closeDistance: 0.5 }, + ...options, + }); +} + +const linePositions = (t: FloorplanTool) => + (t.group.children[0] as THREE.Line).geometry.getAttribute('position'); +const markerPositions = (t: FloorplanTool) => + (t.group.children[1] as THREE.Points).geometry.getAttribute('position'); + +beforeEach(() => { + element = makeElement(); + parent = new THREE.Object3D(); +}); + +afterEach(() => { + tool?.dispose(); + element.remove(); +}); + +describe('construction', () => { + it('parents its preview group', () => { + tool = makeTool(); + expect(parent.children).toContain(tool.group); + }); + + it('starts hidden, empty and disabled', () => { + tool = makeTool(); + expect(tool.group.visible).toBe(false); + expect(tool.enabled).toBe(false); + expect(tool.draft.points).toEqual([]); + }); + + it('merges partial settings over the defaults', () => { + tool = makeTool({ settings: { ortho: true } }); + expect(tool.getSettings()).toMatchObject({ ortho: true, gridSize: 0.25 }); + }); +}); + +describe('screenToGround', () => { + beforeEach(() => { + tool = makeTool(); + }); + + it('maps the element centre to the world origin', () => { + const point = tool.screenToGround({ clientX: 50, clientY: 50 })!; + expect(point.x).toBeCloseTo(0); + expect(point.y).toBeCloseTo(0); + }); + + it('maps the top-right corner to the frustum corner', () => { + const point = tool.screenToGround({ clientX: SIZE, clientY: 0 })!; + expect(point.x).toBeCloseTo(5); + expect(point.y).toBeCloseTo(5); + }); + + it('inverts the y axis, since screen y grows downward', () => { + const point = tool.screenToGround({ clientX: 50, clientY: SIZE })!; + expect(point.y).toBeCloseTo(-5); + }); + + it('round-trips the helper used by these tests', () => { + const point = tool.screenToGround(screenFor(2, -3))!; + expect(point.x).toBeCloseTo(2); + expect(point.y).toBeCloseTo(-3); + }); + + it('returns null when the element has no size', () => { + element.getBoundingClientRect = () => ({ left: 0, top: 0, width: 0, height: 0 }) as DOMRect; + expect(tool.screenToGround({ clientX: 1, clientY: 1 })).toBeNull(); + }); + + it('returns null when the ray runs parallel to the ground', () => { + // Held at z=5 looking along +Y: parallel to the plane and never touching it. + // (A camera at z=0 would be *coplanar*, and three.js reports the ray origin + // as an intersection in that case rather than a miss.) + const sideOn = new THREE.OrthographicCamera(-5, 5, 5, -5, 0.1, 100); + sideOn.position.set(0, -10, 5); + sideOn.up.set(0, 0, 1); + sideOn.lookAt(0, 0, 5); + sideOn.updateMatrixWorld(true); + + const parallel = makeTool({ camera: sideOn }); + expect(parallel.screenToGround({ clientX: 50, clientY: 50 })).toBeNull(); + parallel.dispose(); + }); + + it('projects onto a raised plane when baseZ is set', () => { + const raised = makeTool({ baseZ: 3 }); + expect(raised.screenToGround({ clientX: 50, clientY: 50 })).toEqual({ x: 0, y: 0 }); + raised.dispose(); + }); +}); + +describe('pointer interaction', () => { + beforeEach(() => { + tool = makeTool(); + tool.enable(); + }); + + it('commits a point on left click', () => { + element.dispatchEvent(pointer('pointerdown', 2, 3)); + expect(tool.draft.points).toHaveLength(1); + expect(tool.draft.points[0].x).toBeCloseTo(2); + expect(tool.draft.points[0].y).toBeCloseTo(3); + }); + + it('ignores right click', () => { + element.dispatchEvent(pointer('pointerdown', 2, 3, 2)); + expect(tool.draft.points).toEqual([]); + }); + + it('tracks the cursor on move', () => { + element.dispatchEvent(pointer('pointermove', 1, -1)); + expect(tool.draft.cursor!.x).toBeCloseTo(1); + expect(tool.draft.cursor!.y).toBeCloseTo(-1); + }); + + it('clears the cursor when the pointer leaves', () => { + element.dispatchEvent(pointer('pointermove', 1, 1)); + element.dispatchEvent(new MouseEvent('pointerleave', { bubbles: true })); + expect(tool.draft.cursor).toBeNull(); + }); + + it('ignores events once disabled', () => { + tool.disable(); + element.dispatchEvent(pointer('pointerdown', 2, 3)); + expect(tool.draft.points).toEqual([]); + }); + + it('closes the outline when clicking back on the start', () => { + const onClose = vi.fn(); + tool.dispose(); + tool = makeTool({ onClose }); + tool.enable(); + + for (const [x, y] of [[0, 0], [4, 0], [4, 4], [0, 4]]) { + element.dispatchEvent(pointer('pointerdown', x, y)); + } + element.dispatchEvent(pointer('pointerdown', 0.1, 0.1)); + + expect(tool.draft.closed).toBe(true); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('reports every change', () => { + const onChange = vi.fn(); + tool.dispose(); + tool = makeTool({ onChange }); + tool.enable(); + + element.dispatchEvent(pointer('pointerdown', 1, 1)); + element.dispatchEvent(pointer('pointermove', 2, 2)); + + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it('does not fire onChange when nothing actually changed', () => { + const onChange = vi.fn(); + tool.dispose(); + tool = makeTool({ onChange }); + tool.enable(); + + // Two identical clicks: the second is swallowed as a repeat. + element.dispatchEvent(pointer('pointerdown', 1, 1)); + element.dispatchEvent(pointer('pointerdown', 1, 1)); + + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); + +describe('keyboard', () => { + beforeEach(() => { + tool = makeTool(); + tool.enable(); + for (const [x, y] of [[0, 0], [4, 0], [4, 4]]) { + element.dispatchEvent(pointer('pointerdown', x, y)); + } + }); + + it('closes on Enter', () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(tool.draft.closed).toBe(true); + }); + + it('steps back on Backspace', () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' })); + expect(tool.draft.points).toHaveLength(2); + }); + + it('discards the outline on Escape', () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(tool.draft.points).toEqual([]); + expect(tool.draft.closed).toBe(false); + }); + + it('ignores unrelated keys', () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + expect(tool.draft.points).toHaveLength(3); + }); + + it('stops listening once disabled', () => { + tool.disable(); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(tool.draft.points).toHaveLength(3); + }); +}); + +describe('preview geometry', () => { + beforeEach(() => { + tool = makeTool(); + tool.enable(); + }); + + it('draws a rubber band from the committed points to the cursor', () => { + element.dispatchEvent(pointer('pointerdown', 0, 0)); + element.dispatchEvent(pointer('pointerdown', 4, 0)); + element.dispatchEvent(pointer('pointermove', 4, 3)); + + expect(linePositions(tool).count).toBe(3); + expect(markerPositions(tool).count).toBe(2); + }); + + it('places preview vertices on the drawing plane', () => { + const raised = makeTool({ baseZ: 2 }); + raised.enable(); + element.dispatchEvent(pointer('pointerdown', 1, 1)); + + expect(linePositions(raised).getZ(0)).toBeCloseTo(2); + raised.dispose(); + }); + + it('returns to the start once closed', () => { + for (const [x, y] of [[0, 0], [4, 0], [4, 4], [0, 4]]) { + element.dispatchEvent(pointer('pointerdown', x, y)); + } + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + + const positions = linePositions(tool); + expect(positions.count).toBe(5); + expect(positions.getX(4)).toBeCloseTo(positions.getX(0)); + expect(positions.getY(4)).toBeCloseTo(positions.getY(0)); + }); + + it('empties when reset', () => { + element.dispatchEvent(pointer('pointerdown', 1, 1)); + tool.reset(); + expect(linePositions(tool).count).toBe(0); + expect(markerPositions(tool).count).toBe(0); + }); +}); + +describe('lifecycle', () => { + it('shows and hides the preview with enable/disable', () => { + tool = makeTool(); + tool.enable(); + expect(tool.group.visible).toBe(true); + tool.disable(); + expect(tool.group.visible).toBe(false); + }); + + it('tolerates repeated enable and disable', () => { + tool = makeTool(); + tool.enable(); + tool.enable(); + tool.disable(); + tool.disable(); + expect(tool.enabled).toBe(false); + }); + + it('detaches from its parent on dispose', () => { + tool = makeTool(); + tool.dispose(); + expect(parent.children).not.toContain(tool.group); + }); + + it('stops handling events after dispose', () => { + tool = makeTool(); + tool.enable(); + tool.dispose(); + element.dispatchEvent(pointer('pointerdown', 2, 2)); + expect(tool.draft.points).toEqual([]); + }); +}); + +describe('snapping is delegated, not reimplemented', () => { + it('applies the grid setting to clicks', () => { + tool = makeTool({ settings: { gridSize: 1, ortho: false, closeDistance: 0.5 } }); + tool.enable(); + element.dispatchEvent(pointer('pointerdown', 2.4, 3.4)); + expect(tool.draft.points[0]).toEqual({ x: 2, y: 3 }); + }); + + it('applies an ortho setting changed at runtime', () => { + tool = makeTool(); + tool.enable(); + element.dispatchEvent(pointer('pointerdown', 0, 0)); + tool.setSettings({ ortho: true }); + element.dispatchEvent(pointer('pointerdown', 4, 1)); + + expect(tool.draft.points[1].y).toBeCloseTo(0); + }); +}); diff --git a/src/render/floorplan-tool.ts b/src/render/floorplan-tool.ts new file mode 100644 index 00000000..f6582bdf --- /dev/null +++ b/src/render/floorplan-tool.ts @@ -0,0 +1,279 @@ +/** + * Floorplan drawing tool — pointer plumbing and preview geometry. + * + * Deliberately holds no rules of its own. Where a click lands, whether it + * closes the loop, and what the preview polyline should be all come from + * compute/geometry/sketch-input.ts; this turns pointer events into ground-plane + * coordinates, feeds them in, and draws the result. + * + * Dependencies are injected rather than taken from the renderer singleton, so + * the tool can be constructed against a plain camera and element in a test. + * + * CRAM is Z-up: the drawing plane is XY at `baseZ`. + */ + +import * as THREE from 'three'; + +import { + DEFAULT_SNAP, + addPoint, + closeDraft, + emptyDraft, + moveCursor, + previewPath, + undoLastPoint, + type SketchDraft, + type SnapSettings, +} from '../compute/geometry/sketch-input'; +import type { Point2 } from '../compute/geometry/floorplan'; + +export interface FloorplanToolOptions { + domElement: HTMLElement; + camera: THREE.Camera; + /** Where preview objects are parented — typically the renderer's overlay group. */ + parent: THREE.Object3D; + /** Elevation of the drawing plane. Defaults to 0. */ + baseZ?: number; + settings?: Partial; + /** Called whenever the draft changes, for panel UI and re-rendering. */ + onChange?: (draft: SketchDraft) => void; + /** Called once the outline closes. */ + onClose?: (draft: SketchDraft) => void; +} + +const LINE_COLOR = 0xffc32a; +const MARKER_COLOR = 0xffffff; + +/** + * Reject rays grazing the ground plane. + * + * Exact parallelism is rare in floating point: a camera orbited to near ground + * level leaves a denominator around 1e-16, and the resulting "intersection" + * lands astronomically far away rather than reporting a miss. Anything this + * shallow is unusable for drawing, so treat it as no hit. + */ +const MIN_RAY_PLANE_COS = 1e-6; + +export class FloorplanTool { + private readonly domElement: HTMLElement; + private readonly camera: THREE.Camera; + private readonly parent: THREE.Object3D; + private readonly baseZ: number; + private readonly plane: THREE.Plane; + private readonly raycaster = new THREE.Raycaster(); + + private settings: SnapSettings; + private onChangeCallback?: (draft: SketchDraft) => void; + private onCloseCallback?: (draft: SketchDraft) => void; + + private _draft: SketchDraft = emptyDraft(); + private _enabled = false; + + readonly group = new THREE.Group(); + private readonly line: THREE.Line; + private readonly markers: THREE.Points; + + constructor(options: FloorplanToolOptions) { + this.domElement = options.domElement; + this.camera = options.camera; + this.parent = options.parent; + this.baseZ = options.baseZ ?? 0; + this.settings = { ...DEFAULT_SNAP, ...options.settings }; + this.onChangeCallback = options.onChange; + this.onCloseCallback = options.onClose; + + // Plane form is normal·p + constant = 0, so z = baseZ means constant = -baseZ. + this.plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), -this.baseZ); + + this.group.name = 'floorplan-tool-preview'; + this.group.visible = false; + + this.line = new THREE.Line( + new THREE.BufferGeometry(), + new THREE.LineBasicMaterial({ color: LINE_COLOR, depthTest: false, fog: false }) + ); + this.line.renderOrder = 999; + this.line.frustumCulled = false; + + this.markers = new THREE.Points( + new THREE.BufferGeometry(), + new THREE.PointsMaterial({ + color: MARKER_COLOR, + size: 6, + sizeAttenuation: false, + depthTest: false, + fog: false, + }) + ); + this.markers.renderOrder = 1000; + this.markers.frustumCulled = false; + + this.group.add(this.line, this.markers); + this.parent.add(this.group); + } + + get draft(): SketchDraft { + return this._draft; + } + + get enabled(): boolean { + return this._enabled; + } + + enable(): void { + if (this._enabled) return; + this._enabled = true; + this.group.visible = true; + this.domElement.addEventListener('pointerdown', this.handlePointerDown); + this.domElement.addEventListener('pointermove', this.handlePointerMove); + this.domElement.addEventListener('pointerleave', this.handlePointerLeave); + window.addEventListener('keydown', this.handleKeyDown); + } + + disable(): void { + if (!this._enabled) return; + this._enabled = false; + this.group.visible = false; + this.domElement.removeEventListener('pointerdown', this.handlePointerDown); + this.domElement.removeEventListener('pointermove', this.handlePointerMove); + this.domElement.removeEventListener('pointerleave', this.handlePointerLeave); + window.removeEventListener('keydown', this.handleKeyDown); + } + + setSettings(settings: Partial): void { + this.settings = { ...this.settings, ...settings }; + } + + getSettings(): SnapSettings { + return { ...this.settings }; + } + + /** Discard the outline in progress. */ + reset(): void { + this.commit(emptyDraft()); + } + + /** + * Replace the draft outright — for numeric entry, where the panel edits a + * point's coordinates directly rather than by clicking. + */ + setDraft(draft: SketchDraft): void { + this.commit(draft); + } + + /** Close the outline explicitly, as a button or Enter would. */ + close(): void { + this.commit(closeDraft(this._draft)); + } + + /** Step back one point, or reopen a closed outline. */ + undo(): void { + this.commit(undoLastPoint(this._draft)); + } + + /** + * Project a pointer position onto the drawing plane. + * + * @returns null when the ray misses — the camera can be below or parallel to + * the ground plane, and a miss must not be reported as the origin. + */ + screenToGround(event: { clientX: number; clientY: number }): Point2 | null { + const rect = this.domElement.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return null; + + const ndc = new THREE.Vector2( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1 + ); + + this.raycaster.setFromCamera(ndc, this.camera); + + const ray = this.raycaster.ray; + if (Math.abs(ray.direction.dot(this.plane.normal)) < MIN_RAY_PLANE_COS) return null; + + const hit = ray.intersectPlane(this.plane, new THREE.Vector3()); + return hit ? { x: hit.x, y: hit.y } : null; + } + + private handlePointerDown = (event: PointerEvent): void => { + if (event.button !== 0) return; + const ground = this.screenToGround(event); + if (!ground) return; + this.commit(addPoint(this._draft, ground, this.settings)); + }; + + private handlePointerMove = (event: PointerEvent): void => { + const ground = this.screenToGround(event); + this.commit(moveCursor(this._draft, ground, this.settings)); + }; + + private handlePointerLeave = (): void => { + this.commit(moveCursor(this._draft, null, this.settings)); + }; + + private handleKeyDown = (event: KeyboardEvent): void => { + switch (event.key) { + case 'Escape': + this.reset(); + break; + case 'Enter': + this.close(); + break; + case 'Backspace': + case 'Delete': + event.preventDefault(); + this.undo(); + break; + default: + return; + } + }; + + /** Adopt a new draft, refresh the preview, and notify listeners if it changed. */ + private commit(next: SketchDraft): void { + if (next === this._draft) return; + + const wasClosed = this._draft.closed; + this._draft = next; + this.refreshPreview(); + this.onChangeCallback?.(next); + if (!wasClosed && next.closed) this.onCloseCallback?.(next); + } + + private refreshPreview(): void { + const path = previewPath(this._draft); + const linePositions = new Float32Array(path.length * 3); + path.forEach((p, i) => { + linePositions[i * 3] = p.x; + linePositions[i * 3 + 1] = p.y; + linePositions[i * 3 + 2] = this.baseZ; + }); + this.line.geometry.setAttribute('position', new THREE.BufferAttribute(linePositions, 3)); + this.line.geometry.setDrawRange(0, path.length); + this.line.geometry.computeBoundingSphere(); + + const committed = this._draft.points; + const markerPositions = new Float32Array(committed.length * 3); + committed.forEach((p, i) => { + markerPositions[i * 3] = p.x; + markerPositions[i * 3 + 1] = p.y; + markerPositions[i * 3 + 2] = this.baseZ; + }); + this.markers.geometry.setAttribute('position', new THREE.BufferAttribute(markerPositions, 3)); + this.markers.geometry.setDrawRange(0, committed.length); + this.markers.geometry.computeBoundingSphere(); + } + + dispose(): void { + this.disable(); + this.parent.remove(this.group); + this.line.geometry.dispose(); + (this.line.material as THREE.Material).dispose(); + this.markers.geometry.dispose(); + (this.markers.material as THREE.Material).dispose(); + this.onChangeCallback = undefined; + this.onCloseCallback = undefined; + } +} + +export default FloorplanTool; diff --git a/src/test-utils/room-fakes.ts b/src/test-utils/room-fakes.ts new file mode 100644 index 00000000..29299ead --- /dev/null +++ b/src/test-utils/room-fakes.ts @@ -0,0 +1,87 @@ +/** + * Light stand-ins for Surface and Room, for testing the geometry adapter. + * + * The real Surface reaches csg, BRDF, the store and the renderer, so mocking it + * out properly costs more than it proves. These fakes keep the behaviour the + * adapter actually depends on — in-place `init`, self-detaching `dispose`, + * `allSurfaces`, and `userData` — and nothing else. + * + * Lives in test-utils rather than a `__tests__` folder so vitest does not try + * to collect it as a suite. + */ + +import type * as THREE from 'three'; + +let surfaceCounter = 0; + +export function resetSurfaceCounter(): void { + surfaceCounter = 0; +} + +export interface FakeSurfaceProps { + geometry: THREE.BufferGeometry; + acousticMaterial: unknown; +} + +export class FakeSurface { + uuid = `surface-${++surfaceCounter}`; + name: string; + userData: Record = {}; + geometry: THREE.BufferGeometry; + acousticMaterial: unknown; + initCalls: FakeSurfaceProps[] = []; + disposed = false; + parent: { remove(s: FakeSurface): void } | null = null; + + constructor(name: string, props?: FakeSurfaceProps) { + this.name = name; + this.geometry = props!.geometry; + this.acousticMaterial = props!.acousticMaterial; + } + + init(props: FakeSurfaceProps) { + this.initCalls.push(props); + this.geometry = props.geometry; + this.acousticMaterial = props.acousticMaterial; + } + + /** Mirrors the real Surface, which detaches itself from its parent. */ + dispose() { + this.disposed = true; + this.parent?.remove(this); + } +} + +export class FakeSurfaceChildren { + children: FakeSurface[] = []; + + add(s: FakeSurface) { + s.parent = this; + this.children.push(s); + } + + remove(s: FakeSurface) { + this.children = this.children.filter((c) => c !== s); + } +} + +export class FakeRoom { + name: string; + uuid = `room-${Math.random().toString(36).slice(2)}`; + userData: Record = {}; + surfaces = new FakeSurfaceChildren(); + + constructor(name: string, props?: { surfaces: FakeSurface[] }) { + this.name = name; + for (const s of props?.surfaces ?? []) this.surfaces.add(s); + } + + get allSurfaces() { + return this.surfaces.children; + } + + /** Find a surface by the face id the adapter tagged it with. */ + byFaceId(key: string, id: string): FakeSurface | undefined { + return this.allSurfaces.find((s) => s.userData[key] === id); + } +} From cce75f9a613addc0018f53a1b9206f96f3ea353c Mon Sep 17 00:00:00 2001 From: Rob Miller Date: Mon, 10 Aug 2026 16:50:29 -0500 Subject: [PATCH 2/3] fix: address review findings on the sketch editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/workbench/WorkbenchLayout.tsx | 6 +- .../workbench/__tests__/defaultLayout.spec.ts | 122 ++++++++++++++++++ src/components/workbench/defaultLayout.ts | 62 ++++++++- .../workbench/panels/SketchPanel.tsx | 32 +++-- .../panels/__tests__/SketchPanel.spec.tsx | 21 ++- .../geometry/__tests__/floorplan.spec.ts | 27 ++++ src/compute/geometry/floorplan.ts | 22 ++++ src/objects/__tests__/mesh-userdata.spec.ts | 36 ++++++ .../__tests__/room-mesh-editor.spec.ts | 42 ++++++ src/objects/__tests__/surface.spec.ts | 51 ++++++++ src/objects/mesh-userdata.ts | 16 ++- src/objects/room-mesh-editor.ts | 7 + src/objects/surface.ts | 19 ++- src/render/__tests__/floorplan-tool.spec.ts | 42 ++++++ src/render/floorplan-tool.ts | 14 ++ 15 files changed, 494 insertions(+), 25 deletions(-) create mode 100644 src/components/workbench/__tests__/defaultLayout.spec.ts diff --git a/src/components/workbench/WorkbenchLayout.tsx b/src/components/workbench/WorkbenchLayout.tsx index 51cd3673..0387f668 100644 --- a/src/components/workbench/WorkbenchLayout.tsx +++ b/src/components/workbench/WorkbenchLayout.tsx @@ -19,7 +19,7 @@ import { // Import FlexLayout base styles + our overrides import './workbenchTheme.css'; -import { DEFAULT_LAYOUT, PANEL_IDS } from './defaultLayout'; +import { DEFAULT_LAYOUT, PANEL_IDS, ensureSketchTab } from './defaultLayout'; import { CanvasPanel } from './panels/CanvasPanel'; import { ObjectsPanel } from './panels/ObjectsPanel'; import { SolversPanel } from './panels/SolversPanel'; @@ -39,7 +39,9 @@ function loadLayout(): IJsonModel { try { const stored = storage.getItem(STORAGE_KEY); if (stored) { - return JSON.parse(stored); + // Stored layouts are restored verbatim, so panels added after a user's + // layout was saved have to be patched in. + return ensureSketchTab(JSON.parse(stored)); } } catch (e) { console.warn('[WorkbenchLayout] Failed to parse stored layout:', e); diff --git a/src/components/workbench/__tests__/defaultLayout.spec.ts b/src/components/workbench/__tests__/defaultLayout.spec.ts new file mode 100644 index 00000000..10a18cb6 --- /dev/null +++ b/src/components/workbench/__tests__/defaultLayout.spec.ts @@ -0,0 +1,122 @@ +/** + * Layout migration tests. + * + * Persisted layouts are restored verbatim and never consult DEFAULT_LAYOUT, so + * a panel added after a user's layout was saved is invisible to them until the + * stored model is patched. + */ + +import { describe, it, expect } from 'vitest'; +import type { IJsonModel } from 'flexlayout-react'; +import { DEFAULT_LAYOUT, PANEL_IDS, SKETCH_TAB, ensureSketchTab } from '../defaultLayout'; + +/** A layout of the shape saved before the Sketch panel existed. */ +const legacyLayout = (): IJsonModel => ({ + global: {}, + borders: [ + { + type: 'border', + location: 'right', + size: 320, + selected: 1, + children: [ + { type: 'tab', id: 'objects', name: 'Objects', component: 'ObjectsPanel' }, + { type: 'tab', id: 'solvers', name: 'Solvers', component: 'SolversPanel' }, + { type: 'tab', id: 'renderer', name: 'Renderer', component: 'RendererPanel' }, + ], + }, + ], + layout: { + type: 'row', + children: [ + { + type: 'tabset', + id: 'main', + children: [{ type: 'tab', id: 'canvas', name: 'Canvas', component: 'CanvasPanel' }], + }, + ], + }, +}); + +const rightBorder = (layout: IJsonModel) => + (layout.borders ?? []).find((b) => b.location === 'right'); + +const tabIds = (layout: IJsonModel) => + (rightBorder(layout)?.children ?? []).map((c) => (c as { id?: string }).id); + +describe('ensureSketchTab', () => { + it('adds the Sketch tab to a layout saved before it existed', () => { + const migrated = ensureSketchTab(legacyLayout()); + expect(tabIds(migrated)).toEqual(['objects', 'solvers', 'renderer', PANEL_IDS.SKETCH]); + }); + + it('gives the added tab the same shape as the default', () => { + const migrated = ensureSketchTab(legacyLayout()); + const added = rightBorder(migrated)!.children!.at(-1); + expect(added).toEqual(SKETCH_TAB); + }); + + it('leaves the current tab selection alone', () => { + const migrated = ensureSketchTab(legacyLayout()); + expect(rightBorder(migrated)!.selected).toBe(1); + }); + + it('is a no-op when the tab is already present', () => { + const already = ensureSketchTab(legacyLayout()); + expect(ensureSketchTab(already)).toBe(already); + }); + + it('is a no-op on the default layout', () => { + expect(ensureSketchTab(DEFAULT_LAYOUT)).toBe(DEFAULT_LAYOUT); + }); + + it('is idempotent across repeated loads', () => { + let layout = legacyLayout(); + for (let i = 0; i < 3; i++) layout = ensureSketchTab(layout); + expect(tabIds(layout).filter((id) => id === PANEL_IDS.SKETCH)).toHaveLength(1); + }); + + it('does not mutate the stored layout', () => { + const original = legacyLayout(); + const snapshot = JSON.stringify(original); + ensureSketchTab(original); + expect(JSON.stringify(original)).toBe(snapshot); + }); + + it('finds the tab even when it sits in the main layout rather than a border', () => { + const layout = legacyLayout(); + (layout.layout.children[0] as { children: unknown[] }).children.push({ ...SKETCH_TAB }); + expect(ensureSketchTab(layout)).toBe(layout); + }); + + it('creates a right border when the saved layout has none', () => { + const layout = { ...legacyLayout(), borders: [] }; + const migrated = ensureSketchTab(layout); + expect(tabIds(migrated)).toEqual([PANEL_IDS.SKETCH]); + }); + + it('leaves a newly created border closed rather than forcing it open', () => { + const migrated = ensureSketchTab({ ...legacyLayout(), borders: [] }); + expect(rightBorder(migrated)!.selected).toBe(-1); + }); + + it('tolerates a layout with no borders key at all', () => { + const layout = legacyLayout(); + delete layout.borders; + expect(tabIds(ensureSketchTab(layout))).toEqual([PANEL_IDS.SKETCH]); + }); + + it('does not disturb borders in other locations', () => { + const layout = legacyLayout(); + layout.borders!.push({ + type: 'border', + location: 'bottom', + children: [{ type: 'tab', id: 'results', name: 'Results', component: 'ResultsPanel' }], + }); + + const migrated = ensureSketchTab(layout); + + const bottom = migrated.borders!.find((b) => b.location === 'bottom'); + expect(bottom!.children!.map((c) => (c as { id?: string }).id)).toEqual(['results']); + }); +}); diff --git a/src/components/workbench/defaultLayout.ts b/src/components/workbench/defaultLayout.ts index 0051918f..55da7b8d 100644 --- a/src/components/workbench/defaultLayout.ts +++ b/src/components/workbench/defaultLayout.ts @@ -1,4 +1,4 @@ -import type { IJsonModel } from 'flexlayout-react'; +import type { IJsonBorderNode, IJsonModel, IJsonTabNode } from 'flexlayout-react'; /** * Default workbench layout configuration for CRAM @@ -129,3 +129,63 @@ export const PANEL_IDS = { SKETCH: 'sketch', RESULTS: 'results', } as const; + +/** The Sketch tab, also used to patch layouts saved before it existed. */ +export const SKETCH_TAB: IJsonTabNode = { + type: 'tab', + id: PANEL_IDS.SKETCH, + name: 'Sketch', + component: 'SketchPanel', + enableClose: false, + enablePopout: false, +}; + +/** Depth-first search for a node id, over whatever shape the JSON happens to be. */ +function containsNode(node: unknown, id: string): boolean { + if (!node || typeof node !== 'object') return false; + const candidate = node as { id?: unknown; children?: unknown }; + if (candidate.id === id) return true; + return ( + Array.isArray(candidate.children) && candidate.children.some((child) => containsNode(child, id)) + ); +} + +/** + * Add the Sketch tab to a layout saved before it existed. + * + * Persisted layouts are restored verbatim and never consult DEFAULT_LAYOUT, so + * without this every existing user would be missing the panel with no way to + * reach it short of resetting their layout. + * + * Appends to the right border alongside Objects/Solvers/Renderer, leaving the + * user's current tab selection alone. Returns the input unchanged when the tab + * is already present, so it is safe to run on every load. + */ +export function ensureSketchTab(layout: IJsonModel): IJsonModel { + const borders = layout.borders ?? []; + const present = + containsNode(layout.layout, PANEL_IDS.SKETCH) || + borders.some((border) => containsNode(border, PANEL_IDS.SKETCH)); + if (present) return layout; + + const tab: IJsonTabNode = { ...SKETCH_TAB }; + const rightIndex = borders.findIndex((border) => border.location === 'right'); + + if (rightIndex === -1) { + const border = { + type: 'border', + location: 'right', + size: 320, + // Left closed: surfacing the tab is enough, forcing a panel open on + // upgrade would be intrusive. + selected: -1, + children: [tab], + } as IJsonBorderNode; + return { ...layout, borders: [...borders, border] }; + } + + const right = borders[rightIndex]; + const nextBorders = borders.slice(); + nextBorders[rightIndex] = { ...right, children: [...(right.children ?? []), tab] }; + return { ...layout, borders: nextBorders }; +} diff --git a/src/components/workbench/panels/SketchPanel.tsx b/src/components/workbench/panels/SketchPanel.tsx index 19940531..33630cc1 100644 --- a/src/components/workbench/panels/SketchPanel.tsx +++ b/src/components/workbench/panels/SketchPanel.tsx @@ -159,7 +159,11 @@ export function SketchPanel() { // Rooms arrive asynchronously — a project load replaces the whole container // store — so this watches the store rather than running once on mount. - const containerVersion = useContainer((state) => state.version); + // + // Subscribes to `containers` rather than `version`: addContainer and + // removeContainer replace the containers map without bumping version, so a + // version subscription would never see a room arrive. + const containers = useContainer((state) => state.containers); useEffect(() => { if (room || adoptionDismissed) return; @@ -182,7 +186,7 @@ export function SketchPanel() { } return; } - }, [containerVersion, room, adoptionDismissed, applyDraft]); + }, [containers, room, adoptionDismissed, applyDraft]); const toggleDrawing = useCallback(() => { const tool = toolRef.current; @@ -210,16 +214,6 @@ export function SketchPanel() { else setDraft((d) => closeDraft(d)); }, []); - const handlePointChange = useCallback( - (index: number, axis: 'x' | 'y', value: number) => { - applyDraft({ - ...draft, - points: draft.points.map((p, i) => (i === index ? { ...p, [axis]: value } : p)), - }); - }, - [draft, applyDraft] - ); - /** Re-extrude the room already on screen, preserving its materials. */ const reextrude = useCallback( (nextHeight: number, nextDraft: SketchDraft) => { @@ -238,6 +232,20 @@ export function SketchPanel() { [room, detached] ); + const handlePointChange = useCallback( + (index: number, axis: 'x' | 'y', value: number) => { + const next = { + ...draft, + points: draft.points.map((p, i) => (i === index ? { ...p, [axis]: value } : p)), + }; + applyDraft(next); + // Moving a point has to reach the room too, not just the preview. + // reextrude is a no-op while the outline is open or no room exists. + reextrude(height, next); + }, + [draft, applyDraft, reextrude, height] + ); + const handleHeightChange = useCallback( (value: number) => { setHeight(value); diff --git a/src/components/workbench/panels/__tests__/SketchPanel.spec.tsx b/src/components/workbench/panels/__tests__/SketchPanel.spec.tsx index c20577e5..72be2c63 100644 --- a/src/components/workbench/panels/__tests__/SketchPanel.spec.tsx +++ b/src/components/workbench/panels/__tests__/SketchPanel.spec.tsx @@ -303,15 +303,22 @@ describe('editing an existing room', () => { expect(params.height).toBe(3.5); }); - it('re-extrudes when a point is edited numerically', () => { + it('re-extrudes on a point edit alone, with no other change', () => { createRoom(); fireEvent.change(numberInput('point-1-x'), { target: { value: '6' } }); - fireEvent.change(numberInput('height'), { target: { value: '3' } }); - const [, params] = vi.mocked(setFloorplan).mock.calls.at(-1)!; + expect(setFloorplan).toHaveBeenCalledTimes(1); + const [, params] = vi.mocked(setFloorplan).mock.calls[0]; expect(params.points[1].x).toBe(6); }); + it('does not re-extrude a point edit while the outline is open', () => { + render(); + drawSquare(); + fireEvent.change(numberInput('point-1-x'), { target: { value: '6' } }); + expect(setFloorplan).not.toHaveBeenCalled(); + }); + it('does not re-extrude before a room exists', () => { render(); drawSquare(); @@ -418,10 +425,10 @@ const SQUARE = [ /** A minimal stand-in for a Room restored from a save file. */ function seedRoom(userData: Record, kind = 'room') { const roomLike = { uuid: 'restored-room', name: 'sketched room', kind, userData }; - useContainer.setState({ - containers: { 'restored-room': roomLike } as never, - version: 1, - }); + // Deliberately does not touch `version`: addContainer/removeContainer do not + // bump it either, so setting it here would hide a subscription that never + // observes real room arrivals. + useContainer.setState({ containers: { 'restored-room': roomLike } as never }); return roomLike; } diff --git a/src/compute/geometry/__tests__/floorplan.spec.ts b/src/compute/geometry/__tests__/floorplan.spec.ts index 30f05bb6..1bee183b 100644 --- a/src/compute/geometry/__tests__/floorplan.spec.ts +++ b/src/compute/geometry/__tests__/floorplan.spec.ts @@ -125,6 +125,33 @@ describe('validateFloorplan', () => { expect(dup?.message).toMatch(/closes automatically/); }); + it.each([ + ['NaN x', [{ x: NaN, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 3 }]], + ['Infinite y', [{ x: 0, y: Infinity }, { x: 4, y: 0 }, { x: 4, y: 3 }]], + ['a missing coordinate', [{ x: 0 } as Point2, { x: 4, y: 0 }, { x: 4, y: 3 }]], + ])('rejects %s', (_name, points) => { + expect(validateFloorplan(plan(points as Point2[])).map((i) => i.code)).toContain('non-finite'); + }); + + it('reports which point is non-finite', () => { + const issues = validateFloorplan( + plan([{ x: 0, y: 0 }, { x: NaN, y: 0 }, { x: 4, y: 3 }]) + ); + expect(issues.find((i) => i.code === 'non-finite')).toMatchObject({ index: 1 }); + }); + + it('rejects a non-finite baseZ', () => { + const issues = validateFloorplan({ points: SHOEBOX, height: 2.5, baseZ: NaN }); + expect(issues.map((i) => i.code)).toContain('non-finite'); + }); + + it('does not emit NaN geometry for a non-finite outline', () => { + // Without the finiteness check these flowed through to BufferGeometry. + expect(() => + floorplanToMesh(plan([{ x: NaN, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 3 }])) + ).toThrow(/numeric coordinates/); + }); + it('rejects a collinear outline as enclosing no area', () => { const issues = validateFloorplan( plan([{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 2 }]) diff --git a/src/compute/geometry/floorplan.ts b/src/compute/geometry/floorplan.ts index 0671ff69..d86f338b 100644 --- a/src/compute/geometry/floorplan.ts +++ b/src/compute/geometry/floorplan.ts @@ -43,6 +43,7 @@ export type FloorplanIssue = | { code: 'invalid-height'; message: string } | { code: 'duplicate-point'; message: string; index: number } | { code: 'zero-area'; message: string } + | { code: 'non-finite'; message: string; index?: number } | { code: 'self-intersecting'; message: string; edges: [number, number] }; const EPS = 1e-9; @@ -134,6 +135,27 @@ export function validateFloorplan(params: FloorplanParams): FloorplanIssue[] { }); } + // Checked before anything geometric. NaN propagates silently through the + // shoelace and duplicate tests — comparisons against NaN are simply false — + // and would reach BufferGeometry as NaN vertices rather than an error. + for (let i = 0; i < points.length; i++) { + const point = points[i]; + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + issues.push({ + code: 'non-finite', + index: i, + message: `point ${i + 1} does not have numeric coordinates`, + }); + } + } + + if (params.baseZ !== undefined && !Number.isFinite(params.baseZ)) { + issues.push({ + code: 'non-finite', + message: `floor elevation must be a number, got ${params.baseZ}`, + }); + } + // Consecutive duplicates, including last-equals-first: the loop is implicit, // so repeating the opening point is a common and confusing mistake. for (let i = 0; i < points.length; i++) { diff --git a/src/objects/__tests__/mesh-userdata.spec.ts b/src/objects/__tests__/mesh-userdata.spec.ts index 1b23c7f8..e9f510f4 100644 --- a/src/objects/__tests__/mesh-userdata.spec.ts +++ b/src/objects/__tests__/mesh-userdata.spec.ts @@ -107,6 +107,42 @@ describe('isRoomMesh', () => { it('accepts an empty but well-formed mesh', () => { expect(isRoomMesh({ vertices: [], faces: [] })).toBe(true); }); + + describe('malformed data that would crash downstream', () => { + const verts = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]; + + it.each([ + ['a string vertex component', { vertices: [['0', 0, 0]], faces: [] }], + ['a NaN vertex component', { vertices: [[NaN, 0, 0]], faces: [] }], + ['an infinite vertex component', { vertices: [[Infinity, 0, 0]], faces: [] }], + ])('rejects %s', (_name, value) => { + expect(isRoomMesh(value)).toBe(false); + }); + + it('rejects a loop index past the end of the vertex list', () => { + expect(isRoomMesh({ vertices: verts, faces: [{ id: 'f', loop: [0, 1, 99] }] })).toBe(false); + }); + + it('rejects a negative loop index', () => { + expect(isRoomMesh({ vertices: verts, faces: [{ id: 'f', loop: [0, 1, -1] }] })).toBe(false); + }); + + it('rejects a fractional loop index', () => { + expect(isRoomMesh({ vertices: verts, faces: [{ id: 'f', loop: [0, 1, 1.5] }] })).toBe(false); + }); + + it('rejects a NaN loop index', () => { + expect(isRoomMesh({ vertices: verts, faces: [{ id: 'f', loop: [0, 1, NaN] }] })).toBe(false); + }); + + it('rejects a face with fewer than three vertices', () => { + expect(isRoomMesh({ vertices: verts, faces: [{ id: 'f', loop: [0, 1] }] })).toBe(false); + }); + + it('still accepts a well-formed face', () => { + expect(isRoomMesh({ vertices: verts, faces: [{ id: 'f', loop: [0, 1, 2] }] })).toBe(true); + }); + }); }); describe('surviving a save file', () => { diff --git a/src/objects/__tests__/room-mesh-editor.spec.ts b/src/objects/__tests__/room-mesh-editor.spec.ts index 96db8a7b..ab44eaf2 100644 --- a/src/objects/__tests__/room-mesh-editor.spec.ts +++ b/src/objects/__tests__/room-mesh-editor.spec.ts @@ -22,6 +22,7 @@ import { roomFromMesh, } from '../room-from-mesh'; import { history } from '../../history'; +import { emit } from '../../messenger'; import { floorplanToMesh, type Point2 } from '../../compute/geometry/floorplan'; import { FakeRoom, FakeSurface, resetSurfaceCounter } from '../../test-utils/room-fakes'; import * as THREE from 'three'; @@ -142,6 +143,47 @@ describe('commitMeshEdit', () => { }); }); +describe('marking the project dirty', () => { + // A geometry-only edit changes no containers, so nothing else in the app + // would flag unsaved changes and the Open/New warning would not appear. + it('emits MARK_DIRTY after a height change', () => { + const { room } = makeRoom(SHOEBOX, 2.5); + vi.mocked(emit).mockClear(); + + setFloorplan(room, { points: SHOEBOX, height: 4 }, opts); + + expect(emit).toHaveBeenCalledWith('MARK_DIRTY', undefined); + }); + + it('emits MARK_DIRTY after a vertex move', () => { + const { room } = makeRoom(); + vi.mocked(emit).mockClear(); + + moveVertex(room, 0, [-2, -2, 0], opts); + + expect(emit).toHaveBeenCalledWith('MARK_DIRTY', undefined); + }); + + it('emits MARK_DIRTY on undo, which is also an unsaved change', () => { + const { room } = makeRoom(SHOEBOX, 2.5); + setFloorplan(room, { points: SHOEBOX, height: 4 }, opts); + vi.mocked(emit).mockClear(); + + history.undo(); + + expect(emit).toHaveBeenCalledWith('MARK_DIRTY', undefined); + }); + + it('does not emit for a rejected edit', () => { + const { room } = makeRoom(); + vi.mocked(emit).mockClear(); + + expect(() => setFloorplan(room, { points: SHOEBOX, height: 0 }, opts)).toThrow(); + + expect(emit).not.toHaveBeenCalledWith('MARK_DIRTY', undefined); + }); +}); + describe('setFloorplan', () => { it('applies a new height', () => { const { room, fake } = makeRoom(SHOEBOX, 2.5); diff --git a/src/objects/__tests__/surface.spec.ts b/src/objects/__tests__/surface.spec.ts index 56d07180..b597628d 100644 --- a/src/objects/__tests__/surface.spec.ts +++ b/src/objects/__tests__/surface.spec.ts @@ -238,6 +238,57 @@ describe('Surface', () => { }); }); + describe('geometry disposal on re-init', () => { + // init() detaches the old objects but previously never freed their GPU + // buffers, so every restore or live geometry edit leaked a set per surface. + const build = () => + new Surface('Test', { + geometry: createMockGeometry(), + acousticMaterial: mockAcousticMaterial, + }); + + it('disposes the replaced mesh geometry', () => { + const surface = build(); + const old = surface.mesh.geometry; + const spy = vi.spyOn(old, 'dispose'); + + surface.init({ geometry: createMockGeometry(), acousticMaterial: mockAcousticMaterial }); + + expect(spy).toHaveBeenCalled(); + }); + + it('disposes the shared mesh/wire geometry exactly once', () => { + const surface = build(); + const old = surface.mesh.geometry; + expect(surface.wire.geometry).toBe(old); + const spy = vi.spyOn(old, 'dispose'); + + surface.init({ geometry: createMockGeometry(), acousticMaterial: mockAcousticMaterial }); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('does not dispose the incoming geometry', () => { + const surface = build(); + const incoming = createMockGeometry(); + const spy = vi.spyOn(incoming, 'dispose'); + + surface.init({ geometry: incoming, acousticMaterial: mockAcousticMaterial }); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('survives being handed back its own geometry', () => { + const surface = build(); + const same = surface.mesh.geometry; + const spy = vi.spyOn(same, 'dispose'); + + surface.init({ geometry: same, acousticMaterial: mockAcousticMaterial }); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + describe('mesh face id', () => { // Persisted so a reloaded project can still be reconciled against its // RoomMesh; without it a restored room renders but cannot be edited. diff --git a/src/objects/mesh-userdata.ts b/src/objects/mesh-userdata.ts index 06e96b7c..226b1081 100644 --- a/src/objects/mesh-userdata.ts +++ b/src/objects/mesh-userdata.ts @@ -62,12 +62,24 @@ export function isRoomMesh(value: unknown): value is RoomMesh { if (!value || typeof value !== 'object') return false; const mesh = value as Partial; if (!Array.isArray(mesh.vertices) || !Array.isArray(mesh.faces)) return false; - if (!mesh.vertices.every((v) => Array.isArray(v) && v.length === 3)) return false; + + // Vertex components must actually be numbers. A stringified or NaN component + // survives a shape-only check and reaches BufferGeometry intact. + const vertexCount = mesh.vertices.length; + const verticesOk = mesh.vertices.every( + (v) => Array.isArray(v) && v.length === 3 && v.every((n) => Number.isFinite(n)) + ); + if (!verticesOk) return false; + + // Loop entries must be integer indices that exist, and a face needs at least + // three of them. Anything else makes triangulation and undo dereference + // undefined — the crash this validator exists to prevent. return mesh.faces.every( (f) => !!f && typeof f.id === 'string' && Array.isArray(f.loop) && - f.loop.every((i) => typeof i === 'number') + f.loop.length >= 3 && + f.loop.every((i) => Number.isInteger(i) && i >= 0 && i < vertexCount) ); } diff --git a/src/objects/room-mesh-editor.ts b/src/objects/room-mesh-editor.ts index f10ecaeb..6005eacc 100644 --- a/src/objects/room-mesh-editor.ts +++ b/src/objects/room-mesh-editor.ts @@ -17,6 +17,7 @@ import type Room from './room'; import { addMoment, type Directions } from '../history'; +import { emit } from '../messenger'; import { getRoomMesh, syncRoomFromMesh, @@ -57,11 +58,17 @@ export function commitMeshEdit( syncRoomFromMesh(room, after, options); + // A geometry-only edit changes no containers, so nothing downstream marks the + // project dirty and the unsaved-changes prompt would not appear. Topology + // changes happen to emit it via add/removeContainer; this covers the rest. + emit('MARK_DIRTY', undefined); + addMoment({ category: CATEGORY[edit.kind], objectId: room.uuid, recallFunction: (direction?: keyof Directions) => { syncRoomFromMesh(room, direction === 'UNDO' ? before : after, options); + emit('MARK_DIRTY', undefined); }, }); diff --git a/src/objects/surface.ts b/src/objects/surface.ts index 9b05b50e..ce51e24a 100644 --- a/src/objects/surface.ts +++ b/src/objects/surface.ts @@ -250,12 +250,29 @@ class Surface extends Container { init(props: SurfaceProps, fromConstructor: boolean = false) { // if the call isn't coming from the constructor it's probably being restored - if (!fromConstructor) { + if (!fromConstructor) { this.remove(this.mesh); this.remove(this.wire); this.remove(this.edges); this.remove(this.vertexNormals); this.destroyEvents(); + + // 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(); + 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()); } // merge the incoming props with the default props diff --git a/src/render/__tests__/floorplan-tool.spec.ts b/src/render/__tests__/floorplan-tool.spec.ts index 7dbffa3e..a2667e1d 100644 --- a/src/render/__tests__/floorplan-tool.spec.ts +++ b/src/render/__tests__/floorplan-tool.spec.ts @@ -252,6 +252,48 @@ describe('keyboard', () => { expect(tool.draft.points).toHaveLength(3); }); + describe('does not hijack typing', () => { + // The listener sits on window so shortcuts work with the viewport focused, + // which also puts it in front of every form field on the page. + const dispatchFrom = (el: HTMLElement, key: string) => { + document.body.appendChild(el); + el.focus(); + el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); + el.remove(); + }; + + it('ignores Backspace from a number input', () => { + const input = document.createElement('input'); + input.type = 'number'; + dispatchFrom(input, 'Backspace'); + expect(tool.draft.points).toHaveLength(3); + }); + + it('ignores Escape from a text input', () => { + const input = document.createElement('input'); + dispatchFrom(input, 'Escape'); + expect(tool.draft.points).toHaveLength(3); + }); + + it('ignores Enter from a textarea', () => { + dispatchFrom(document.createElement('textarea'), 'Enter'); + expect(tool.draft.closed).toBe(false); + }); + + it('ignores Delete from a contenteditable element', () => { + const div = document.createElement('div'); + div.contentEditable = 'true'; + Object.defineProperty(div, 'isContentEditable', { value: true }); + dispatchFrom(div, 'Delete'); + expect(tool.draft.points).toHaveLength(3); + }); + + it('still responds to keys from elsewhere on the page', () => { + dispatchFrom(document.createElement('div'), 'Enter'); + expect(tool.draft.closed).toBe(true); + }); + }); + it('stops listening once disabled', () => { tool.disable(); window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); diff --git a/src/render/floorplan-tool.ts b/src/render/floorplan-tool.ts index f6582bdf..e8965986 100644 --- a/src/render/floorplan-tool.ts +++ b/src/render/floorplan-tool.ts @@ -54,6 +54,14 @@ const MARKER_COLOR = 0xffffff; */ const MIN_RAY_PLANE_COS = 1e-6; +/** Whether an event came from somewhere the user is typing. */ +function isEditableTarget(target: EventTarget | null): boolean { + if (!target || !(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + const tag = target.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; +} + export class FloorplanTool { private readonly domElement: HTMLElement; private readonly camera: THREE.Camera; @@ -212,6 +220,12 @@ export class FloorplanTool { }; private handleKeyDown = (event: KeyboardEvent): void => { + // The listener is on window so shortcuts work with the viewport focused, + // which also puts it in front of every form field. Without this guard, + // backspacing a digit out of a coordinate input would delete a sketch + // point instead of editing the number. + if (isEditableTarget(event.target)) return; + switch (event.key) { case 'Escape': this.reset(); From c222a35f49d33c77c314901e35e7103f594ec000 Mon Sep 17 00:00:00 2001 From: Rob Miller Date: Mon, 10 Aug 2026 17:30:50 -0500 Subject: [PATCH 3/3] fix: address second review pass on the sketch editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../workbench/panels/SketchPanel.tsx | 4 +- .../geometry/__tests__/room-mesh.spec.ts | 56 ++++++++++++++++ src/compute/geometry/room-mesh.ts | 15 ++++- src/objects/__tests__/mesh-userdata.spec.ts | 28 ++++++++ src/objects/__tests__/room-from-mesh.spec.ts | 64 +++++++++++++++++++ src/objects/mesh-userdata.ts | 23 +++++++ src/objects/room-from-mesh.ts | 29 ++++++++- src/objects/room.ts | 21 +++++- src/render/__tests__/floorplan-tool.spec.ts | 28 ++++++++ src/render/floorplan-tool.ts | 17 +++-- src/test-utils/room-fakes.ts | 8 +++ 11 files changed, 281 insertions(+), 12 deletions(-) diff --git a/src/components/workbench/panels/SketchPanel.tsx b/src/components/workbench/panels/SketchPanel.tsx index 33630cc1..8a676df0 100644 --- a/src/components/workbench/panels/SketchPanel.tsx +++ b/src/components/workbench/panels/SketchPanel.tsx @@ -127,7 +127,9 @@ export function SketchPanel() { const tool = new FloorplanTool({ domElement: renderer.renderer.domElement, - camera: renderer.camera, + // Getter, not a snapshot: toggling ortho/perspective replaces + // renderer.camera with a new instance. + camera: () => renderer.camera, parent: renderer.workspace, settings: { gridSize, ortho }, onChange: (next) => { diff --git a/src/compute/geometry/__tests__/room-mesh.spec.ts b/src/compute/geometry/__tests__/room-mesh.spec.ts index c058dcb0..cdcc6084 100644 --- a/src/compute/geometry/__tests__/room-mesh.spec.ts +++ b/src/compute/geometry/__tests__/room-mesh.spec.ts @@ -17,6 +17,7 @@ import { type Vec3, } from '../room-mesh'; import { floorplanToMesh, type Point2 } from '../floorplan'; +import { floorplanSource } from '../room-mesh'; const SHOEBOX: Point2[] = [ { x: 0, y: 0 }, @@ -211,3 +212,58 @@ describe('applyEdit — set-floorplan', () => { ).toThrow(/invalid floorplan/); }); }); + +describe('floorplanSource', () => { + it('returns the plan for a floorplan-sourced mesh', () => { + const source = floorplanSource(shoebox(3))!; + expect(source.detached).toBe(false); + expect(source.params.height).toBe(3); + }); + + it('returns null for a manual mesh', () => { + expect(floorplanSource({ vertices: [], faces: [], source: { kind: 'manual' } })).toBeNull(); + }); + + describe('malformed provenance, which would crash the panel', () => { + // draftFromPoints dereferences p.x, so anything that slips through here + // takes the Sketch panel down instead of leaving the room non-editable. + const withParams = (params: unknown): RoomMesh => + ({ + vertices: [], + faces: [], + source: { kind: 'floorplan', params, detached: false }, + }) as unknown as RoomMesh; + + it.each([ + ['a NaN height', { points: SHOEBOX, height: NaN }], + ['an infinite height', { points: SHOEBOX, height: Infinity }], + ['a NaN baseZ', { points: SHOEBOX, height: 3, baseZ: NaN }], + ['a null point', { points: [null], height: 3 }], + ['a point missing y', { points: [{ x: 1 }], height: 3 }], + ['a NaN coordinate', { points: [{ x: NaN, y: 0 }], height: 3 }], + ['no params at all', undefined], + ['points that are not an array', { points: 'nope', height: 3 }], + ])('returns null for %s', (_name, params) => { + expect(floorplanSource(withParams(params))).toBeNull(); + }); + }); +}); + +describe('applyEdit — move-vertex destination validation', () => { + it.each([ + ['NaN', [NaN, 0, 0]], + ['Infinity', [0, Infinity, 0]], + ['too few components', [0, 0]], + ])('rejects a destination containing %s', (_name, to) => { + expect(() => + applyEdit(shoebox(), { kind: 'move-vertex', id: 0, to: to as Vec3 }) + ).toThrow(TypeError); + }); + + it('leaves the mesh untouched when the destination is rejected', () => { + const mesh = shoebox(); + const before = JSON.stringify(mesh); + expect(() => applyEdit(mesh, { kind: 'move-vertex', id: 0, to: [NaN, 0, 0] })).toThrow(); + expect(JSON.stringify(mesh)).toBe(before); + }); +}); diff --git a/src/compute/geometry/room-mesh.ts b/src/compute/geometry/room-mesh.ts index df0089df..c0881009 100644 --- a/src/compute/geometry/room-mesh.ts +++ b/src/compute/geometry/room-mesh.ts @@ -93,6 +93,11 @@ export function applyEdit(mesh: RoomMesh, edit: MeshEdit): RoomMesh { `move-vertex: no vertex ${edit.id} (mesh has ${mesh.vertices.length})` ); } + // A non-finite destination would poison every face touching this vertex + // and only surface much later, as NaN vertices in a BufferGeometry. + if (!edit.to || edit.to.length !== 3 || !edit.to.every((n) => Number.isFinite(n))) { + throw new TypeError(`move-vertex: destination must be three finite numbers`); + } const vertices = mesh.vertices.map( (v, i): Vec3 => (i === edit.id ? [edit.to[0], edit.to[1], edit.to[2]] : v) ); @@ -154,7 +159,15 @@ export function floorplanSource( if (!source || source.kind !== 'floorplan') return null; const params = source.params; - if (!params || !Array.isArray(params.points) || typeof params.height !== 'number') return null; + if (!params || !Array.isArray(params.points)) return null; + + // `typeof NaN === 'number'`, so a shape-only check lets NaN through, and + // unchecked entries let `points: [null]` through. Either reaches + // draftFromPoints, which dereferences `p.x` and takes the panel down — + // the opposite of leaving the room quietly non-editable. + if (!Number.isFinite(params.height)) return null; + if (params.baseZ !== undefined && !Number.isFinite(params.baseZ)) return null; + if (!params.points.every((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y))) return null; return { params, detached: source.detached === true }; } diff --git a/src/objects/__tests__/mesh-userdata.spec.ts b/src/objects/__tests__/mesh-userdata.spec.ts index e9f510f4..9a7c7f22 100644 --- a/src/objects/__tests__/mesh-userdata.spec.ts +++ b/src/objects/__tests__/mesh-userdata.spec.ts @@ -18,6 +18,7 @@ import { isRoomMesh, setFaceId, setRoomMesh, + faceIdsAreConsistent, } from '../mesh-userdata'; import { floorplanToMesh, type Point2 } from '../../compute/geometry/floorplan'; import { applyEdit, faceNormal, findFace } from '../../compute/geometry/room-mesh'; @@ -212,3 +213,30 @@ describe('surviving a save file', () => { expect(moved.vertices[0]).toEqual([-2, -2, 0]); }); }); + +describe('faceIdsAreConsistent', () => { + const tagged = (...ids: (string | undefined)[]) => + ids.map((id) => (id === undefined ? { userData: {} } : { userData: { [FACE_ID_KEY]: id } })); + + it('accepts one surface per face', () => { + expect(faceIdsAreConsistent(tagged('floor', 'ceiling', 'wall-0'))).toBe(true); + }); + + it('rejects two surfaces claiming the same face', () => { + // The reconciler would update one and leave the other overlapping it. + expect(faceIdsAreConsistent(tagged('floor', 'wall-0', 'floor'))).toBe(false); + }); + + it('tolerates untagged surfaces', () => { + expect(faceIdsAreConsistent(tagged('floor', undefined, 'wall-0'))).toBe(true); + }); + + it('tolerates faces with no surface, since deleting a wall is legitimate', () => { + // Fewer surfaces than mesh faces is fine — the reconciler restores them. + expect(faceIdsAreConsistent(tagged('floor'))).toBe(true); + }); + + it('accepts an empty room', () => { + expect(faceIdsAreConsistent([])).toBe(true); + }); +}); diff --git a/src/objects/__tests__/room-from-mesh.spec.ts b/src/objects/__tests__/room-from-mesh.spec.ts index e4f6aee6..87731dbc 100644 --- a/src/objects/__tests__/room-from-mesh.spec.ts +++ b/src/objects/__tests__/room-from-mesh.spec.ts @@ -269,3 +269,67 @@ describe('syncRoomFromMesh', () => { expect(fake.allSurfaces).toHaveLength(6); }); }); + +describe('keeping Room derived state current', () => { + // Room caches surfaceMap/boundingBox/volume at init. surfaceMap is indexed + // for every ray hit, so a surface missing from it fails a solve. + it('refreshes derived geometry after a sync', () => { + const room = roomFromMesh(mesh(), opts); + const fake = asRoom(room) as FakeRoom; + const before = fake.derivedRefreshCount; + + syncRoomFromMesh(room, mesh(SHOEBOX, 4), opts); + + expect(fake.derivedRefreshCount).toBeGreaterThan(before); + }); + + it('has every surface in surfaceMap after a wall is added', () => { + const room = roomFromMesh(mesh(), opts); + const fake = asRoom(room) as FakeRoom; + + syncRoomFromMesh(room, mesh([...SHOEBOX, { x: -1, y: 1.5 }]), opts); + + const mapped = Object.keys(fake.surfaceMap).sort(); + expect(mapped).toEqual(fake.allSurfaces.map((s) => s.uuid).sort()); + }); + + it('drops a removed surface from surfaceMap', () => { + const room = roomFromMesh(mesh(), opts); + const fake = asRoom(room) as FakeRoom; + const doomed = fake.byFaceId(FACE_ID_KEY, 'wall-3')!; + + syncRoomFromMesh(room, mesh(SHOEBOX.slice(0, 3)), opts); + + expect(Object.keys(fake.surfaceMap)).not.toContain(doomed.uuid); + }); +}); + +describe('degenerate faces', () => { + /** A mesh whose floor collapses to a line. */ + const degenerate = () => { + const m = mesh(); + const collapsed = JSON.parse(JSON.stringify(m)); + // Drag every floor corner onto one line so the floor has no area. + collapsed.vertices[0] = [0, 0, 0]; + collapsed.vertices[1] = [1, 0, 0]; + collapsed.vertices[2] = [2, 0, 0]; + collapsed.vertices[3] = [3, 0, 0]; + return collapsed; + }; + + it('refuses rather than letting Surface.init dereference an empty triangle list', () => { + const room = roomFromMesh(mesh(), opts); + expect(() => syncRoomFromMesh(room, degenerate(), opts)).toThrow(/no triangles/); + }); + + it('leaves the room untouched when it refuses', () => { + const room = roomFromMesh(mesh(), opts); + const fake = asRoom(room) as FakeRoom; + const before = fake.allSurfaces.map((s) => s.uuid); + + expect(() => syncRoomFromMesh(room, degenerate(), opts)).toThrow(); + + expect(fake.allSurfaces.map((s) => s.uuid)).toEqual(before); + expect(fake.allSurfaces.every((s) => s.initCalls.length === 0)).toBe(true); + }); +}); diff --git a/src/objects/mesh-userdata.ts b/src/objects/mesh-userdata.ts index 226b1081..222b135f 100644 --- a/src/objects/mesh-userdata.ts +++ b/src/objects/mesh-userdata.ts @@ -83,3 +83,26 @@ export function isRoomMesh(value: unknown): value is RoomMesh { f.loop.every((i) => Number.isInteger(i) && i >= 0 && i < vertexCount) ); } + +/** + * Whether a room's surfaces can be reconciled against a mesh. + * + * Two surfaces claiming the same face is corruption: the reconciler would + * update one and leave the other overlapping it, giving duplicate coincident + * geometry and wrong ray intersections. + * + * Faces with no surface are deliberately tolerated — deleting a wall from the + * object tree is a legitimate thing to have done, and the reconciler puts it + * back on the next edit. Rejecting that would make a room permanently + * non-editable after an ordinary action. + */ +export function faceIdsAreConsistent(surfaces: HasUserData[]): boolean { + const seen = new Set(); + for (const surface of surfaces) { + const id = getFaceId(surface); + if (id === undefined) continue; + if (seen.has(id)) return false; + seen.add(id); + } + return true; +} diff --git a/src/objects/room-from-mesh.ts b/src/objects/room-from-mesh.ts index 6f06a2bb..b58d2914 100644 --- a/src/objects/room-from-mesh.ts +++ b/src/objects/room-from-mesh.ts @@ -120,11 +120,25 @@ export function syncRoomFromMesh( mesh.faces.map((f) => f.id) ); + // Every geometry is built before anything is mutated. A face that + // triangulates to nothing — a vertex dragged until its face is degenerate — + // leaves Surface.init dereferencing `_triangles[0]`, and failing halfway + // through would leave the room in a half-reconciled state. + const geometries = new Map(); + for (const face of mesh.faces) { + const geometry = geometryForFace(mesh, face); + if ((geometry.getAttribute('position')?.count ?? 0) === 0) { + geometries.forEach((g) => g.dispose()); + geometry.dispose(); + throw new Error(`face "${face.id}" produced no triangles — the outline is degenerate`); + } + geometries.set(face.id, geometry); + } + for (const id of plan.updated) { const surface = byFaceId.get(id)!; - const face = findFace(mesh, id)!; surface.init({ - geometry: geometryForFace(mesh, face), + geometry: geometries.get(id)!, acousticMaterial: surface.acousticMaterial, }); setFaceId(surface, id); @@ -132,7 +146,11 @@ export function syncRoomFromMesh( for (const id of plan.added) { const face = findFace(mesh, id)!; - const surface = surfaceForFace(mesh, face, options.acousticMaterial); + const surface = new Surface(face.name, { + geometry: geometries.get(id)!, + acousticMaterial: options.acousticMaterial, + }); + setFaceId(surface, id); room.surfaces.add(surface); emit('ADD_SURFACE', surface); } @@ -147,5 +165,10 @@ export function syncRoomFromMesh( // actually on screen. This is also what lets undo/redo be a plain re-sync. setRoomMesh(room, mesh); + // Room caches surfaceMap, boundingBox and volume at init time. Reconciling + // surfaces behind its back leaves all three stale — surfaceMap fatally so, + // since the raytracer indexes it for every hit. + room.refreshDerivedGeometry(); + return plan; } diff --git a/src/objects/room.ts b/src/objects/room.ts index 4c307735..2aa7f137 100644 --- a/src/objects/room.ts +++ b/src/objects/room.ts @@ -10,7 +10,7 @@ import { emit, on } from "../messenger"; import { addContainer, removeContainer, setContainerProperty } from "../store"; import { renderer } from "../render/renderer"; import { TessellateModifier } from "../compute/radiance/TessellateModifier"; -import { getRoomMesh, isRoomMesh, setRoomMesh } from "./mesh-userdata"; +import { faceIdsAreConsistent, getRoomMesh, isRoomMesh, setRoomMesh } from "./mesh-userdata"; import type { RoomMesh } from "../compute/geometry/room-mesh"; export interface RoomProps extends ContainerProps { @@ -86,13 +86,26 @@ export class Room extends Container { this.surfaces.add(surface); }); this.add(this.surfaces); + this.refreshDerivedGeometry(); + renderer.add(this); + } + /** + * Recompute the fields derived from the current surface set. + * + * init() builds these once, but surfaces can be reconciled in place + * afterwards (see objects/room-from-mesh.ts). `surfaceMap` is indexed for + * every ray hit by reflectionLossFunction + * (compute/raytracer/response-by-intensity.ts), so a surface missing from it + * fails a solve outright; `volume` feeds the statistical solvers, so a stale + * one is quietly wrong rather than loud. + */ + refreshDerivedGeometry() { this.calculateBoundingBox(); this.volume = this.volumeOfMesh(); this.surfaceMap = this.allSurfaces.reduce((a, b) => { a[b.uuid] = b as Surface; return a; }, {} as KVP); - renderer.add(this); } dispose(){ renderer.remove(this); @@ -146,7 +159,9 @@ export class Room extends Container { // Validated rather than trusted: a save file is user-supplied and may be // old or hand-edited. A malformed mesh leaves the room non-editable // instead of failing the whole restore. - if (isRoomMesh(state.mesh)) setRoomMesh(this, state.mesh); + if (isRoomMesh(state.mesh) && faceIdsAreConsistent(this.allSurfaces)) { + setRoomMesh(this, state.mesh); + } return this; } diff --git a/src/render/__tests__/floorplan-tool.spec.ts b/src/render/__tests__/floorplan-tool.spec.ts index a2667e1d..0e422cd5 100644 --- a/src/render/__tests__/floorplan-tool.spec.ts +++ b/src/render/__tests__/floorplan-tool.spec.ts @@ -145,6 +145,34 @@ describe('screenToGround', () => { }); }); +describe('camera replacement', () => { + // Renderer.setOrtho swaps renderer.camera for a new instance, so a tool + // holding the original would keep raycasting through an invisible camera. + it('follows a camera getter when the camera is replaced', () => { + let current = makeCamera(); + const following = makeTool({ camera: () => current }); + + const before = following.screenToGround({ clientX: SIZE, clientY: 0 })!; + expect(before.x).toBeCloseTo(5); + + // A wider frustum, as a projection toggle would produce. + const wider = new THREE.OrthographicCamera(-10, 10, 10, -10, 0.1, 100); + wider.position.set(0, 0, 10); + wider.lookAt(0, 0, 0); + wider.updateMatrixWorld(true); + current = wider; + + const after = following.screenToGround({ clientX: SIZE, clientY: 0 })!; + expect(after.x).toBeCloseTo(10); + following.dispose(); + }); + + it('still accepts a plain camera', () => { + tool = makeTool({ camera: makeCamera() }); + expect(tool.screenToGround({ clientX: 50, clientY: 50 })!.x).toBeCloseTo(0); + }); +}); + describe('pointer interaction', () => { beforeEach(() => { tool = makeTool(); diff --git a/src/render/floorplan-tool.ts b/src/render/floorplan-tool.ts index e8965986..3e518156 100644 --- a/src/render/floorplan-tool.ts +++ b/src/render/floorplan-tool.ts @@ -29,7 +29,15 @@ import type { Point2 } from '../compute/geometry/floorplan'; export interface FloorplanToolOptions { domElement: HTMLElement; - camera: THREE.Camera; + /** + * The viewport camera, or a getter for it. + * + * Renderer.setOrtho *replaces* renderer.camera with a new instance when the + * projection is toggled, so a tool holding the original would keep + * raycasting through a camera the user can no longer see. Pass a getter to + * stay current. + */ + camera: THREE.Camera | (() => THREE.Camera); /** Where preview objects are parented — typically the renderer's overlay group. */ parent: THREE.Object3D; /** Elevation of the drawing plane. Defaults to 0. */ @@ -64,7 +72,7 @@ function isEditableTarget(target: EventTarget | null): boolean { export class FloorplanTool { private readonly domElement: HTMLElement; - private readonly camera: THREE.Camera; + private readonly getCamera: () => THREE.Camera; private readonly parent: THREE.Object3D; private readonly baseZ: number; private readonly plane: THREE.Plane; @@ -83,7 +91,8 @@ export class FloorplanTool { constructor(options: FloorplanToolOptions) { this.domElement = options.domElement; - this.camera = options.camera; + this.getCamera = + typeof options.camera === 'function' ? options.camera : () => options.camera as THREE.Camera; this.parent = options.parent; this.baseZ = options.baseZ ?? 0; this.settings = { ...DEFAULT_SNAP, ...options.settings }; @@ -194,7 +203,7 @@ export class FloorplanTool { -((event.clientY - rect.top) / rect.height) * 2 + 1 ); - this.raycaster.setFromCamera(ndc, this.camera); + this.raycaster.setFromCamera(ndc, this.getCamera()); const ray = this.raycaster.ray; if (Math.abs(ray.direction.dot(this.plane.normal)) < MIN_RAY_PLANE_COS) return null; diff --git a/src/test-utils/room-fakes.ts b/src/test-utils/room-fakes.ts index 29299ead..b0365f01 100644 --- a/src/test-utils/room-fakes.ts +++ b/src/test-utils/room-fakes.ts @@ -70,6 +70,8 @@ export class FakeRoom { uuid = `room-${Math.random().toString(36).slice(2)}`; userData: Record = {}; surfaces = new FakeSurfaceChildren(); + surfaceMap: Record = {}; + derivedRefreshCount = 0; constructor(name: string, props?: { surfaces: FakeSurface[] }) { this.name = name; @@ -80,6 +82,12 @@ export class FakeRoom { return this.surfaces.children; } + /** Mirrors the real Room, which caches fields derived from its surfaces. */ + refreshDerivedGeometry() { + this.derivedRefreshCount += 1; + this.surfaceMap = Object.fromEntries(this.allSurfaces.map((s) => [s.uuid, s])); + } + /** Find a surface by the face id the adapter tagged it with. */ byFaceId(key: string, id: string): FakeSurface | undefined { return this.allSurfaces.find((s) => s.userData[key] === id);