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..0387f668 100644 --- a/src/components/workbench/WorkbenchLayout.tsx +++ b/src/components/workbench/WorkbenchLayout.tsx @@ -19,11 +19,12 @@ 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'; import { RendererPanel } from './panels/RendererPanel'; +import { SketchPanel } from './panels/SketchPanel'; import { ResultsPanelWrapper } from './panels/ResultsPanelWrapper'; import storage from '../../lib/storage'; @@ -38,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); @@ -69,6 +72,8 @@ export function WorkbenchLayout() { return ; case 'RendererPanel': return ; + case 'SketchPanel': + return ; case 'ResultsPanel': return ; default: 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/__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..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 @@ -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,66 @@ export const PANEL_IDS = { OBJECTS: 'objects', SOLVERS: 'solvers', RENDERER: 'renderer', + 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 new file mode 100644 index 00000000..8a676df0 --- /dev/null +++ b/src/components/workbench/panels/SketchPanel.tsx @@ -0,0 +1,431 @@ +/** + * 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, + // 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) => { + 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. + // + // 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; + + 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; + } + }, [containers, 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)); + }, []); + + /** 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 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); + 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..72be2c63 --- /dev/null +++ b/src/components/workbench/panels/__tests__/SketchPanel.spec.tsx @@ -0,0 +1,621 @@ +/** + * 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 on a point edit alone, with no other change', () => { + createRoom(); + fireEvent.change(numberInput('point-1-x'), { target: { value: '6' } }); + + 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(); + 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 }; + // 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; +} + +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..1bee183b --- /dev/null +++ b/src/compute/geometry/__tests__/floorplan.spec.ts @@ -0,0 +1,399 @@ +/** + * 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.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 }]) + ); + 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..cdcc6084 --- /dev/null +++ b/src/compute/geometry/__tests__/room-mesh.spec.ts @@ -0,0 +1,269 @@ +/** + * 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'; +import { floorplanSource } from '../room-mesh'; + +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/); + }); +}); + +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/__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..d86f338b --- /dev/null +++ b/src/compute/geometry/floorplan.ts @@ -0,0 +1,262 @@ +/** + * 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: 'non-finite'; message: string; index?: number } + | { 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}`, + }); + } + + // 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++) { + 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..c0881009 --- /dev/null +++ b/src/compute/geometry/room-mesh.ts @@ -0,0 +1,186 @@ +/** + * 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})` + ); + } + // 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) + ); + 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)) 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 }; +} + +/** 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..9a7c7f22 --- /dev/null +++ b/src/objects/__tests__/mesh-userdata.spec.ts @@ -0,0 +1,242 @@ +/** + * 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, + faceIdsAreConsistent, +} 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('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', () => { + 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]); + }); +}); + +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 new file mode 100644 index 00000000..87731dbc --- /dev/null +++ b/src/objects/__tests__/room-from-mesh.spec.ts @@ -0,0 +1,335 @@ +/** + * 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); + }); +}); + +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/__tests__/room-mesh-editor.spec.ts b/src/objects/__tests__/room-mesh-editor.spec.ts new file mode 100644 index 00000000..ab44eaf2 --- /dev/null +++ b/src/objects/__tests__/room-mesh-editor.spec.ts @@ -0,0 +1,388 @@ +/** + * 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 { 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'; +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('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); + 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..b597628d 100644 --- a/src/objects/__tests__/surface.spec.ts +++ b/src/objects/__tests__/surface.spec.ts @@ -238,6 +238,100 @@ 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. + 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..222b135f --- /dev/null +++ b/src/objects/mesh-userdata.ts @@ -0,0 +1,108 @@ +/** + * 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; + + // 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.length >= 3 && + 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 new file mode 100644 index 00000000..b58d2914 --- /dev/null +++ b/src/objects/room-from-mesh.ts @@ -0,0 +1,174 @@ +/** + * 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) + ); + + // 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)!; + surface.init({ + geometry: geometries.get(id)!, + acousticMaterial: surface.acousticMaterial, + }); + setFaceId(surface, id); + } + + for (const id of plan.added) { + const face = findFace(mesh, id)!; + const surface = new Surface(face.name, { + geometry: geometries.get(id)!, + acousticMaterial: options.acousticMaterial, + }); + setFaceId(surface, id); + 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); + + // 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-mesh-editor.ts b/src/objects/room-mesh-editor.ts new file mode 100644 index 00000000..6005eacc --- /dev/null +++ b/src/objects/room-mesh-editor.ts @@ -0,0 +1,103 @@ +/** + * 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 { emit } from '../messenger'; +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); + + // 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); + }, + }); + + 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..2aa7f137 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 { faceIdsAreConsistent, 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 { @@ -78,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); @@ -107,6 +128,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 +156,12 @@ 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) && faceIdsAreConsistent(this.allSurfaces)) { + setRoomMesh(this, state.mesh); + } return this; } diff --git a/src/objects/surface.ts b/src/objects/surface.ts index cec7accd..ce51e24a 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 { @@ -247,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 @@ -436,7 +456,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 +481,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..0e422cd5 --- /dev/null +++ b/src/render/__tests__/floorplan-tool.spec.ts @@ -0,0 +1,426 @@ +/** + * 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('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(); + 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); + }); + + 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' })); + 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..3e518156 --- /dev/null +++ b/src/render/floorplan-tool.ts @@ -0,0 +1,302 @@ +/** + * 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; + /** + * 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. */ + 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; + +/** 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 getCamera: () => 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.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 }; + 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.getCamera()); + + 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 => { + // 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(); + 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..b0365f01 --- /dev/null +++ b/src/test-utils/room-fakes.ts @@ -0,0 +1,95 @@ +/** + * 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(); + surfaceMap: Record = {}; + derivedRefreshCount = 0; + + 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; + } + + /** 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); + } +}