From 7d90b5fd43574a2cc636dbf4aa8e07a24fda1f7c Mon Sep 17 00:00:00 2001 From: Chintan Date: Tue, 1 Sep 2026 20:56:50 -0400 Subject: [PATCH 01/19] Rename roomplan to floorplan Directory, package name, app title, and the `app` field written into every .space manifest. Readers never checked that field, so files written by the old name still open. --- PLAN.md | 4 ++-- README.md | 2 +- e2e/shell.spec.ts | 2 +- index.html | 2 +- package.json | 2 +- src/core/migrations.ts | 4 ++-- src/core/space-file.test.ts | 2 +- src/core/space-file.ts | 4 ++-- src/main.tsx | 2 +- src/ui/TopBar.tsx | 2 +- 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/PLAN.md b/PLAN.md index ada5c1a..9ee4291 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,6 +1,6 @@ -# roomplan — Implementation Plan +# floorplan — Implementation Plan -**Location:** `Development/conquerorchin/roomplan/` +**Location:** `Development/conquerorchin/floorplan/` **Status:** Planning — nothing built yet **Date:** 2026-09-01 diff --git a/README.md b/README.md index fc0f12a..d003cb6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# roomplan +# floorplan Spatial planning for real rooms. Import a floor plan, build an inventory of what you own, place it, and walk through the result in 3D. diff --git a/e2e/shell.spec.ts b/e2e/shell.spec.ts index 7afbb9d..0e6da04 100644 --- a/e2e/shell.spec.ts +++ b/e2e/shell.spec.ts @@ -4,7 +4,7 @@ test.describe('app shell', () => { test('boots and renders both panels and the plan editor', async ({ page }) => { await page.goto('/'); - await expect(page.getByText('roomplan')).toBeVisible(); + await expect(page.getByText('floorplan')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Inventory' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Properties' })).toBeVisible(); diff --git a/index.html b/index.html index 45fc88d..7421e20 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - roomplan + floorplan
diff --git a/package.json b/package.json index af59d32..82d4bd3 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "roomplan", + "name": "floorplan", "version": "0.1.0", "description": "Spatial planning for real rooms — import a floor plan, build an inventory, place it in 3D, and walk through it.", "type": "module", diff --git a/src/core/migrations.ts b/src/core/migrations.ts index ef5946b..90fe6d6 100644 --- a/src/core/migrations.ts +++ b/src/core/migrations.ts @@ -56,7 +56,7 @@ export function migrateTo( throw new SchemaVersionError( typeof found === 'number' ? found : NaN, target, - 'This file is missing a valid schemaVersion and cannot be read as a roomplan space.', + 'This file is missing a valid schemaVersion and cannot be read as a floorplan space.', ); } @@ -64,7 +64,7 @@ export function migrateTo( throw new SchemaVersionError( found, target, - `This space was saved by a newer version of roomplan (schema ${found}; ` + + `This space was saved by a newer version of floorplan (schema ${found}; ` + `this build supports up to ${target}). Update the app to open it.`, ); } diff --git a/src/core/space-file.test.ts b/src/core/space-file.test.ts index 5c0ee56..253a30f 100644 --- a/src/core/space-file.test.ts +++ b/src/core/space-file.test.ts @@ -160,7 +160,7 @@ describe('.space container', () => { const bytes = writeSpace({ document: fixture(), assets: {} }, '0.1.0'); const manifest = JSON.parse(strFromU8(unzipSync(bytes)[MANIFEST_ENTRY]!)); expect(manifest).toMatchObject({ - app: 'roomplan', + app: 'floorplan', appVersion: '0.1.0', schemaVersion: SCHEMA_VERSION, title: 'Test Apartment', diff --git a/src/core/space-file.ts b/src/core/space-file.ts index 2235b8b..ba0b90e 100644 --- a/src/core/space-file.ts +++ b/src/core/space-file.ts @@ -16,7 +16,7 @@ import { unzipSync, zipSync, strToU8, strFromU8 } from 'fflate'; import { SCHEMA_VERSION, type SpaceDocument } from './document'; import { migrate } from './migrations'; -export const APP_NAME = 'roomplan'; +export const APP_NAME = 'floorplan'; export const DOCUMENT_ENTRY = 'document.json'; export const MANIFEST_ENTRY = 'manifest.json'; export const THUMBNAIL_ENTRY = 'thumbnail.png'; @@ -89,7 +89,7 @@ export function readSpace(bytes: Uint8Array): SpaceBundle { entries = unzipSync(bytes); } catch (cause) { const err = new SpaceFileError( - 'This file is not a readable .space container (it may be corrupt or not a roomplan file).', + 'This file is not a readable .space container (it may be corrupt or not a floorplan file).', ); err.cause = cause; throw err; diff --git a/src/main.tsx b/src/main.tsx index 95dd5a9..95e8578 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,7 +4,7 @@ import { App } from './App'; import './styles/global.css'; const rootEl = document.getElementById('root'); -if (!rootEl) throw new Error('roomplan: #root element not found in index.html'); +if (!rootEl) throw new Error('floorplan: #root element not found in index.html'); createRoot(rootEl).render( diff --git a/src/ui/TopBar.tsx b/src/ui/TopBar.tsx index 6a4b1be..4d89f77 100644 --- a/src/ui/TopBar.tsx +++ b/src/ui/TopBar.tsx @@ -59,7 +59,7 @@ export function TopBar() {
+ {/* The plan's width is the honest readout of a calibration: it is the number + that changes when a scale is applied, and the one a person can sanity-check + against a room they have stood in. `mmPerPx` is true but unreadable. */} + {floor.background ? ( + + {isCalibrated(floor.background) + ? `Plan ${formatLength(backgroundExtentMm(floor.background).width, unit)} wide` + : 'Plan uncalibrated'} + + ) : null} + diff --git a/src/ui/ToolPalette.tsx b/src/ui/ToolPalette.tsx index ec8cb79..a803ebe 100644 --- a/src/ui/ToolPalette.tsx +++ b/src/ui/ToolPalette.tsx @@ -17,16 +17,20 @@ import { useStore } from '../state/store'; * mode is why. */ export function ToolPalette() { - const { tool, shapeKind, editMode, gridEnabled } = useStore( + const { tool, shapeKind, editMode, gridEnabled, calibrating } = useStore( useShallow((s) => ({ tool: s.tool, shapeKind: s.shapeKind, editMode: s.editMode, gridEnabled: s.gridEnabled, + calibrating: s.calibrating, })), ); - const enabled = structureIsEditable(editMode); + // Disabled during the calibration gate as well as in furnish mode: a plan with no + // scale produces walls whose lengths mean nothing, and nothing downstream can + // correct them afterwards. See PLAN.md §6.1. + const enabled = structureIsEditable(editMode) && !calibrating; return (
@@ -38,7 +42,7 @@ export function ToolPalette() { className="seg" data-active={t === tool} aria-pressed={t === tool} - disabled={!enabled && t !== 'select'} + disabled={!enabled && (calibrating || t !== 'select')} title={`${PLAN_TOOL_LABELS[t]} (${PLAN_TOOL_KEYS[t].toUpperCase()})`} onClick={() => useStore.getState().setTool(t)} > diff --git a/src/ui/TopBar.tsx b/src/ui/TopBar.tsx index 4d89f77..5a37738 100644 --- a/src/ui/TopBar.tsx +++ b/src/ui/TopBar.tsx @@ -9,11 +9,12 @@ import { import { useStore } from '../state/store'; import { renameDocument } from '../state/actions'; import { openDocumentFile, saveDocument, SPACE_EXTENSION } from './file-io'; +import { ImportButton } from './ImportButton'; export function TopBar() { const fileInput = useRef(null); - const { doc, editMode, viewMode, dirty, canUndo, canRedo } = useStore( + const { doc, editMode, viewMode, dirty, canUndo, canRedo, calibrating } = useStore( useShallow((s) => ({ doc: s.doc, editMode: s.editMode, @@ -21,6 +22,7 @@ export function TopBar() { dirty: s.dirty, canUndo: s.past.length > 0, canRedo: s.future.length > 0, + calibrating: s.calibrating, })), ); @@ -46,7 +48,10 @@ export function TopBar() { const onOpen = async (file: File) => { try { - useStore.getState().loadDocument(await openDocumentFile(file)); + // The assets travel with the document into the runtime store; passing only + // `.document` here is what made a reopened background render as nothing. + const bundle = await openDocumentFile(file); + useStore.getState().loadDocument(bundle.document, bundle.assets); useStore.getState().zoomToFit(); } catch (err) { // A bad file is the user's problem to fix, not a crash to swallow: say what @@ -91,6 +96,7 @@ export function TopBar() { + useStore.getState().undo()} > @@ -119,7 +125,7 @@ export function TopBar() { + + setQuantityOwned(item.id, Number(e.target.value))} + /> + +
+ + )} + + ); + })} + + + {editing ? ( + setEditingId(null)} + /> + ) : adding ? ( + setAdding(false)} /> + ) : ( +
+ +
+ )} + + {!adding && !editing ? ( + + ) : null} + + {editMode === 'plan' && doc.catalog.length > 0 ? ( +

Switch to Arrange furniture to place these.

+ ) : null} ); } diff --git a/src/ui/ItemForm.tsx b/src/ui/ItemForm.tsx new file mode 100644 index 0000000..f1d0551 --- /dev/null +++ b/src/ui/ItemForm.tsx @@ -0,0 +1,232 @@ +import { useState } from 'react'; +import { CATEGORIES, CATEGORY_DEFAULTS, CATEGORY_LABELS, type ItemDraft } from '../core/catalog'; +import { SHAPE_KINDS, SHAPE_KIND_LABELS, type ShapeKind } from '../core/tools'; +import { formatLength, parseLength, type DisplayUnit } from '../core/units'; +import type { Category, MountKind } from '../core/document'; + +type Props = { + unit: DisplayUnit; + initial?: ItemDraft; + submitLabel: string; + onSubmit: (draft: ItemDraft) => void; + onCancel: () => void; +}; + +const EMPTY: ItemDraft = { + name: '', + category: 'other', + widthMm: 0, + depthMm: 0, + heightMm: 0, + shape: 'rect', +}; + +/** A length field: typed in any unit, held as text until it parses. */ +function LengthField({ + label, + value, + unit, + onChange, + hint, +}: { + label: string; + value: string; + unit: DisplayUnit; + onChange: (next: string) => void; + hint?: string; +}) { + const parsed = parseLength(value, unit); + return ( + + ); +} + +/** + * Manual entry — the primary path into the inventory (PLAN.md §7.1). + * + * Dimensions are typed in whatever unit is convenient and parsed through the same + * `parseLength` the rest of the app uses, so `30"`, `760`, `0.76m` and `2ft 6in` all + * work and the field echoes back what it understood. A number that will not parse is + * shown as unparsed rather than silently becoming zero. + * + * `voidBelowMm` is pre-filled from the category, not left at zero. It is the field + * that decides whether a rug under this table reads as a collision, and a user typing + * in a dining table should not have to know that before the app stops shouting at them. + */ +export function ItemForm({ unit, initial, submitLabel, onSubmit, onCancel }: Props) { + const base = initial ?? EMPTY; + const [name, setName] = useState(base.name); + const [category, setCategory] = useState(base.category); + const [shape, setShape] = useState(base.shape); + const [width, setWidth] = useState(base.widthMm ? String(base.widthMm) : ''); + const [depth, setDepth] = useState(base.depthMm ? String(base.depthMm) : ''); + const [height, setHeight] = useState(base.heightMm ? String(base.heightMm) : ''); + const [voidBelow, setVoidBelow] = useState( + base.voidBelowMm !== undefined ? String(base.voidBelowMm) : '', + ); + const [hosts, setHosts] = useState(base.canHostSurface ?? CATEGORY_DEFAULTS[base.category].canHostSurface); + const [quantity, setQuantity] = useState(String(base.quantityOwned ?? 1)); + const [mount, setMount] = useState(base.defaultMount ?? 'floor'); + const [error, setError] = useState(null); + + // Changing category re-suggests the two fields that follow from it, but only while + // the user has not already made a choice of their own. + const onCategory = (next: Category) => { + setCategory(next); + if (voidBelow === '' || voidBelow === String(CATEGORY_DEFAULTS[category].voidBelowMm)) { + setVoidBelow(String(CATEGORY_DEFAULTS[next].voidBelowMm)); + } + setHosts(CATEGORY_DEFAULTS[next].canHostSurface); + }; + + const submit = () => { + const w = parseLength(width, unit); + const d = parseLength(depth, unit); + const h = parseLength(height, unit); + if (w === null || d === null || h === null) { + setError('Width, depth and height all need a length — 30", 760, 0.76m all work.'); + return; + } + let v: number | undefined; + if (voidBelow.trim() !== '') { + const parsed = parseLength(voidBelow, unit); + if (parsed === null) { + setError('Open space beneath is not a length I can read.'); + return; + } + v = parsed; + } + + try { + onSubmit({ + name, + category, + shape, + widthMm: w, + depthMm: d, + heightMm: h, + ...(v !== undefined ? { voidBelowMm: v } : {}), + canHostSurface: hosts, + defaultMount: mount, + quantityOwned: Math.max(0, Math.round(Number(quantity) || 0)), + }); + } catch (err) { + setError(err instanceof Error ? err.message : 'That item could not be added.'); + } + }; + + return ( +
+ + + + + + + + + + + + + + + + + + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+ ); +} diff --git a/src/ui/PropertiesPanel.tsx b/src/ui/PropertiesPanel.tsx index 7b766d9..6521463 100644 --- a/src/ui/PropertiesPanel.tsx +++ b/src/ui/PropertiesPanel.tsx @@ -8,17 +8,41 @@ import { deleteSelection, nudgeBackgroundRotation, removeBackground, + rotatePlacementBy, setBackgroundLocked, setBackgroundOpacity, + setPlacementElevation, setRoomName, } from '../state/actions'; -import { - backgroundExtentMm, - isCalibrated, - placementBlockReason, -} from '../core/calibration'; +import { findItem, type Floor, type SpaceDocument } from '../core/document'; +import { resolveElevation, roomAt, surfaceHeight } from '../core/placement'; +import { ROTATION_STEP_DEG } from '../core/placement-snap'; +import { validateFloor, type Issue } from '../core/validation'; +import { backgroundExtentMm, isCalibrated } from '../core/calibration'; import { hasAsset } from '../state/assets'; +const MOUNT_LABELS: Record = { + floor: 'On the floor', + surface: 'On a surface', + wall: 'Wall-mounted', + ceiling: 'Hanging', +}; + +function mountLabel(kind: string): string { + return MOUNT_LABELS[kind] ?? kind; +} + +function hostName(doc: SpaceDocument, floor: Floor, hostId: string): string { + const host = floor.placements.find((p) => p.id === hostId); + const item = host ? findItem(doc, host.itemId) : undefined; + return item?.name ?? 'something that is gone'; +} + +/** Select what an issue points at, so the panel is a way to find the problem. */ +function selectIssue(issue: Issue): void { + useStore.getState().setSelection(issue.refs.map((ref) => ({ kind: ref.kind, id: ref.id }))); +} + /** A labelled read-only field. */ function Field({ label, value }: { label: string; value: string }) { return ( @@ -43,13 +67,13 @@ export function PropertiesPanel() { const only = selection.length === 1 ? selection[0] : null; const background = floor.background; - // The gate's consequence, stated where a consequence belongs. Shipping the reason - // rather than a bare refusal is the whole point: "no" with no explanation reads as - // a bug, and phase 4 will surface exactly this string when it rejects a placement. - const blocked = placementBlockReason(floor); const wall = only?.kind === 'wall' ? floor.walls.find((w) => w.id === only.id) : undefined; const room = only?.kind === 'room' ? floor.rooms.find((r) => r.id === only.id) : undefined; + const placement = + only?.kind === 'placement' ? floor.placements.find((p) => p.id === only.id) : undefined; + const placementItem = placement ? findItem(doc, placement.itemId) : undefined; + const issues = validateFloor(doc, floor); // Held locally while typing so a rename is one undo step, not one per keystroke. const [draftName, setDraftName] = useState(room?.name ?? ''); @@ -101,6 +125,76 @@ export function PropertiesPanel() {
) : null} + {placement && placementItem ? ( +
+ + + + + {/* Read back through `resolveElevation` rather than from the stored field: + that field is only authoritative for floor and wall mounts, and a + surface-mounted item takes its base from whatever it is sitting on. */} + + + {placement.mount.kind === 'surface' ? ( + + ) : null} + +
+ + + {Math.round(placement.rotation)}° + + +
+ + {placement.mount.kind === 'wall' ? ( + + ) : null} + + {placementItem.canHostSurface ? ( +

+ Other items can sit on this, at{' '} + {formatLength(surfaceHeight(placement, placementItem), unit)}. +

+ ) : null} +
+ ) : null} + {selection.length > 1 ? (

{selection.length} items selected. @@ -188,15 +282,33 @@ export function PropertiesPanel() { ) : null}

Validation

- {blocked ? ( -

- {blocked} + {issues.length === 0 ? ( +

+ No issues. Clearance and door-swing checks arrive in phases 6 and 7.

) : ( -

- No issues. Overlap, headroom and clearance checks arrive with the inventory - in phase 4. -

+
    + {issues.map((issue, i) => ( +
  • + {/* Clicking an issue selects what it points at, so the panel is a way + to find the problem rather than only a way to hear about it. */} + +
  • + ))} +
)} ); diff --git a/src/ui/plan/PlacementLayer.tsx b/src/ui/plan/PlacementLayer.tsx index 04ee173..47d45dd 100644 --- a/src/ui/plan/PlacementLayer.tsx +++ b/src/ui/plan/PlacementLayer.tsx @@ -1,13 +1,18 @@ import { useLayoutEffect, useRef } from 'react'; -import { Layer, Line } from 'react-konva'; +import { Circle, Layer, Line, Text } from 'react-konva'; import type Konva from 'konva'; -import type { Floor, SpaceDocument } from '../../core/document'; +import type { Floor, Id, Placement, SpaceDocument } from '../../core/document'; import { findItem } from '../../core/document'; import { worldOutline } from '../../core/placement'; -import { flattenToScreen, type Viewport } from '../../core/viewport'; -import type { SelectionRef } from '../../state/store'; +import { backOffset } from '../../core/placement-snap'; +import { rotate, toRadians } from '../../core/geometry/vec'; +import { docToScreen, flattenToScreen, pxToMm, type Viewport } from '../../core/viewport'; +import type { PlacementTransform, SelectionRef } from '../../state/store'; import type { PlanTheme } from './theme'; +/** How far the rotate handle sits beyond the item's back edge, in screen pixels. */ +const ROTATE_HANDLE_PX = 22; + type Props = { doc: SpaceDocument; floor: Floor; @@ -16,16 +21,23 @@ type Props = { selection: SelectionRef[]; /** True in furnish mode only — the other half of the layer toggle. */ interactive: boolean; + /** False while a placing or measuring tool is armed: the click is not a selection. */ + selectable: boolean; + /** The drag in flight, previewed here while the document still holds the original. */ + transform: PlacementTransform | null; + /** Placements the validation panel is reporting on — drawn in the warning colour. */ + flagged: ReadonlySet; onSelect: (ref: SelectionRef, additive: boolean) => void; + onGrab: (placementId: Id, mode: 'move' | 'rotate') => void; }; /** * Furniture footprints. * - * Phase 2 has no way to *create* a placement — that is phase 4 — but a `.space` file - * opened here may well contain them, and the mode toggle is only demonstrable if both - * halves of it render. Footprints come from `worldOutline`, which derives world - * geometry from the stored local footprint rather than reading baked vertices. + * Geometry comes from `worldOutline`, which derives the world polygon from the stored + * *local* footprint every time. Rotation is never baked into stored vertices: baking + * accumulates floating-point error across repeated turns and makes "reset rotation" + * impossible to implement correctly. */ export function PlacementLayer({ doc, @@ -34,39 +46,127 @@ export function PlacementLayer({ theme, selection, interactive, + selectable, + transform, + flagged, onSelect, + onGrab, }: Props) { // Same hazard as the structure layer: Konva refreshes hit geometry on draw, not on - // assignment, so a click in the frame the mode changed would be tested against the - // old state. See `useSyncHitGraph` in StructureLayer. + // assignment, so a click arriving in the frame the mode changed — or in the frame a + // placement first appeared — is tested against the old state. See `useSyncHitGraph` + // in StructureLayer for the full explanation. const layerRef = useRef(null); useLayoutEffect(() => { layerRef.current?.drawHit(); - }, [interactive]); + }, [interactive, floor.placements.length]); + + // A placement being dragged renders from the preview; the document still holds the + // original until the pointer is released, which is what makes the drag one undo step. + const placements: Placement[] = transform + ? floor.placements.map((p) => + p.id === transform.placementId + ? { + ...p, + position: transform.position, + rotation: transform.rotation, + mount: transform.mount, + } + : p, + ) + : floor.placements; + + const only = + selection.length === 1 && selection[0]!.kind === 'placement' ? selection[0]!.id : null; return ( - {floor.placements.map((placement) => { + {placements.map((placement) => { const item = findItem(doc, placement.itemId); - if (!item) return null; // dangling itemId — the validation panel's problem + if (!item) return null; // dangling itemId — the validation panel reports it const selected = selection.some((s) => s.kind === 'placement' && s.id === placement.id); + const warned = flagged.has(placement.id); + return ( { + // Same rule as the structure layer: a non-selectable click must not be + // cancelled here, or the tool that was actually armed never sees it. + if (!selectable) return; e.cancelBubble = true; onSelect({ kind: 'placement', id: placement.id }, e.evt.shiftKey); + // Selecting and grabbing are one gesture; a press that never moves + // commits nothing, because the commit skips an unchanged placement. + if (!e.evt.shiftKey) onGrab(placement.id, 'move'); }} /> ); })} + + {/* The rotate handle, on a single selected placement. It sticks out of the + item's back — the edge wall snap aligns — so "handle pointing up" and + "rotation 0" mean the same thing everywhere in the app. */} + {only && selectable + ? (() => { + const placement = placements.find((p) => p.id === only); + const item = placement ? findItem(doc, placement.itemId) : undefined; + if (!placement || !item) return null; + + const reach = backOffset(item.footprint) + pxToMm(viewport, ROTATE_HANDLE_PX); + const offset = rotate({ x: 0, y: -reach }, toRadians(placement.rotation)); + const centre = docToScreen(viewport, placement.position); + const handle = docToScreen(viewport, { + x: placement.position.x + offset.x, + y: placement.position.y + offset.y, + }); + + return ( + <> + + { + e.cancelBubble = true; + onGrab(placement.id, 'rotate'); + }} + /> + {transform?.mode === 'rotate' ? ( + + ) : null} + + ); + })() + : null} ); } diff --git a/src/ui/plan/PlanStage.tsx b/src/ui/plan/PlanStage.tsx index 9077be0..465f821 100644 --- a/src/ui/plan/PlanStage.tsx +++ b/src/ui/plan/PlanStage.tsx @@ -15,13 +15,21 @@ import { PLAN_TOOL_KEYS, PLAN_TOOLS, type PlanTool } from '../../core/tools'; import { panBy, pxToMm, screenToDoc, zoomAt } from '../../core/viewport'; import { activeFloor, documentGridMm, useStore, type SelectionRef } from '../../state/store'; import { + addPlacement, addRoomRect, addShapeRoom, addWallChain, + commitPlacementTransform, commitWallTransform, deleteSelection, + placementSnapContext, + previewPlacementTransform, previewWallTransform, + rotatePlacementBy, } from '../../state/actions'; +import { PlacementBlockedError } from '../../core/calibration'; +import { flaggedPlacements, validateFloor } from '../../core/validation'; +import { ROTATION_STEP_DEG, snapPlacement } from '../../core/placement-snap'; import { BackgroundLayer } from './BackgroundLayer'; import { DraftLayer } from './DraftLayer'; import { GridLayer } from './GridLayer'; @@ -81,6 +89,8 @@ export function PlanStage() { transform, calibrating, calibrationRef, + placementTransform, + placingItemId, } = useStore( useShallow((s) => ({ doc: s.doc, @@ -96,6 +106,8 @@ export function PlanStage() { transform: s.transform, calibrating: s.calibrating, calibrationRef: s.calibrationRef, + placementTransform: s.placementTransform, + placingItemId: s.placingItemId, })), ); @@ -121,6 +133,10 @@ export function PlanStage() { return () => ro.disconnect(); }, []); + // The validation pass is the only thing here that walks every placement against + // every other, so it is memoized on the document rather than run per render. + const flagged = useMemo(() => flaggedPlacements(validateFloor(doc, floor)), [doc, floor]); + // ---- snapping ---------------------------------------------------------- const snapCandidates = useMemo(() => { const pts: Vec2[] = wallEndpoints(floor.walls); @@ -189,6 +205,72 @@ export function PlanStage() { }); }; + /** The raw (unsnapped) document point under the pointer. */ + const rawAt = (stage: Konva.Stage): Vec2 | null => { + const pos = stage.getPointerPosition(); + return pos ? screenToDoc(useStore.getState().viewport, pos) : null; + }; + + /** The snap context for whichever item a placement gesture concerns. */ + const snapCtxFor = (itemId: string, excludePlacementId?: string) => + placementSnapContext(itemId, { + toleranceMm: pxToMm(useStore.getState().viewport, DEFAULT_SNAP_TOLERANCE_PX), + ...(excludePlacementId ? { excludePlacementId } : {}), + }); + + /** Begin a placement drag. The document is untouched until the pointer is released. */ + const beginPlacementTransform = (placementId: string, mode: 'move' | 'rotate') => { + const state = useStore.getState(); + const placement = activeFloor(state).placements.find((p) => p.id === placementId); + const grab = state.cursor; + if (!placement || !grab) return; + + state.setPlacementTransform({ + placementId, + mode, + grab, + origin: { position: placement.position, rotation: placement.rotation }, + position: placement.position, + rotation: placement.rotation, + mount: placement.mount, + hints: [], + }); + }; + + /** + * Drop the armed item where the pointer is. + * + * The gate is enforced here rather than in the UI so it cannot be routed around: + * `addPlacement` throws, and the message it throws is the same sentence the + * validation panel shows. + */ + const dropArmedItem = (stage: Konva.Stage) => { + const state = useStore.getState(); + const itemId = state.placingItemId; + if (!itemId) return; + + const raw = rawAt(stage); + if (!raw) return; + + const ctx = snapCtxFor(itemId); + const snapped = ctx ? snapPlacement(raw, 0, ctx) : null; + + try { + const placement = addPlacement(itemId, snapped?.position ?? raw, { + rotation: snapped?.rotation ?? 0, + ...(snapped ? { mount: snapped.mount } : {}), + }); + if (placement) state.setSelection([{ kind: 'placement', id: placement.id }]); + } catch (err) { + if (err instanceof PlacementBlockedError) { + window.alert(err.message); + state.setPlacingItem(null); + return; + } + throw err; + } + }; + // ---- pointer ----------------------------------------------------------- const onMouseDown = (e: Konva.KonvaEventObject) => { const stage = e.target.getStage(); @@ -212,6 +294,14 @@ export function PlanStage() { return; } + // An armed item drops on the next left click anywhere on the canvas. Checked + // before the pan branch, because a click on empty canvas with Select is + // otherwise read as the start of a pan. + if (e.evt.button === 0 && useStore.getState().placingItemId) { + dropArmedItem(stage); + return; + } + // Middle-drag always pans; so does a left-drag on empty canvas with Select, which // is the gesture most people reach for before finding a pan key. if (middle || (onEmptyCanvas && tool === 'select')) { @@ -299,6 +389,17 @@ export function PlanStage() { } if (store.calibrating) return; + const moving = store.placementTransform; + if (moving) { + const raw = rawAt(stage); + if (!raw) return; + const placement = activeFloor(store).placements.find((p) => p.id === moving.placementId); + const ctx = placement ? snapCtxFor(placement.itemId, placement.id) : null; + store.setCursor(raw); + store.setPlacementTransform(previewPlacementTransform(moving, raw, ctx)); + return; + } + const dragging = store.transform; const snapped = snapAt(stage, dragging && dragging.end !== 'both' ? undefined : draftAnchor()); if (!snapped) return; @@ -336,6 +437,13 @@ export function PlanStage() { } if (store.calibrating) return; + const moving = store.placementTransform; + if (moving) { + commitPlacementTransform(moving); + store.setPlacementTransform(null); + return; + } + const dragging = store.transform; if (dragging) { commitWallTransform(dragging); @@ -422,10 +530,23 @@ export function PlanStage() { if (e.key === 'Escape') { store.setDraft(null); store.setTransform(null); + store.setPlacementTransform(null); + store.setPlacingItem(null); store.clearSelection(); lastClickPx.current = null; return; } + // Rotating from the keyboard, because the handle is a fine gesture and a poor + // way to hit exactly 90°. + if (e.key === '[' || e.key === ']') { + const selected = store.selection.filter((s) => s.kind === 'placement'); + if (selected.length > 0) { + e.preventDefault(); + const step = e.key === ']' ? ROTATION_STEP_DEG : -ROTATION_STEP_DEG; + for (const ref of selected) rotatePlacementBy(ref.id, step); + } + return; + } if (e.key === 'Enter') { const current = store.draft; if (current?.tool === 'wall') { @@ -489,7 +610,7 @@ export function PlanStage() {
, listening: boolean): void { +function useSyncHitGraph( + ref: RefObject, + listening: boolean, + count: number, +): void { useLayoutEffect(() => { ref.current?.drawHit(); - }, [ref, listening]); + }, [ref, listening, count]); } function isSelected(selection: SelectionRef[], kind: SelectionRef['kind'], id: string): boolean { @@ -93,7 +104,7 @@ export function StructureLayer({ onGrabEndpoint, }: Props) { const layerRef = useRef(null); - useSyncHitGraph(layerRef, interactive); + useSyncHitGraph(layerRef, interactive, floor.walls.length + floor.rooms.length); // A wall being dragged renders from the preview; the document still has the // original until the pointer is released. diff --git a/src/ui/plan/theme.ts b/src/ui/plan/theme.ts index 9cfe2ac..328910f 100644 --- a/src/ui/plan/theme.ts +++ b/src/ui/plan/theme.ts @@ -26,6 +26,8 @@ export type PlanTheme = { selection: string; dimension: string; dimensionText: string; + /** Validation warnings — overlaps, headroom. Distinct from selection blue. */ + warning: string; }; const LIGHT: PlanTheme = { @@ -45,6 +47,7 @@ const LIGHT: PlanTheme = { selection: '#1f6fd0', dimension: '#c8541f', dimensionText: '#8a3a14', + warning: '#c0392b', }; const DARK: PlanTheme = { @@ -64,6 +67,7 @@ const DARK: PlanTheme = { selection: '#5aa2f0', dimension: '#e8834a', dimensionText: '#f0a878', + warning: '#e5645a', }; const QUERY = '(prefers-color-scheme: dark)'; From 9c27d851d054a332acf78fc3b9bbb29666e8545f Mon Sep 17 00:00:00 2001 From: Chintan Date: Wed, 2 Sep 2026 08:34:34 -0400 Subject: [PATCH 05/19] Do not offer mounts that cannot be reached, and cover the drag-to-wall path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The item form let you choose Wall or Ceiling, and addPlacement then dropped the item on the floor without saying so — the same offered-and-refused pattern the Place button was just fixed for. Both options are disabled until phase 5 gives them somewhere to attach. beginPlacementTransform now refuses a placement whose item is missing. Its snap context would be null, which silently ignores Alt for the whole drag; it was already unreachable because such a placement renders nothing to grab, and this makes that a rule rather than a coincidence. Every placement e2e dropped items into an empty document, so the composition of a real snap context with a drag that re-solves each frame had only synthetic unit coverage. Added a spec that drags a sofa across a room into a wall. The unreadable-file spec now asserts the message rather than a count that was already zero — the only way that assertion could fail was a blocked page, which reads as a mystery timeout rather than a bug. --- e2e/inventory.spec.ts | 28 ++++++++++++++++++++++++++++ e2e/plan-editor.spec.ts | 15 +++++++++++++-- src/ui/ItemForm.tsx | 12 ++++++++++-- src/ui/plan/PlanStage.tsx | 5 +++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/e2e/inventory.spec.ts b/e2e/inventory.spec.ts index 1084d53..cbb2702 100644 --- a/e2e/inventory.spec.ts +++ b/e2e/inventory.spec.ts @@ -52,6 +52,14 @@ test.describe('the catalog', () => { await page.getByLabel('Width').fill('1.8m'); await page.getByLabel('Depth').fill('30"'); await page.getByLabel('Height').fill('900'); + // Wall and ceiling mounts are stored but not reachable when placing yet, so the + // form does not offer them — the same rule as the Place button. + // Asserted on the attribute: Playwright reports an - - + {/* Stored, but not reachable when placing: a wall mount needs a wall to + host against and a ceiling mount a ceiling to hang from, which is + phase 5. Offering a choice that silently lands the item on the floor + is the same mistake as offering a Place button that will be refused. */} + + diff --git a/src/ui/plan/PlanStage.tsx b/src/ui/plan/PlanStage.tsx index 465f821..8565d0e 100644 --- a/src/ui/plan/PlanStage.tsx +++ b/src/ui/plan/PlanStage.tsx @@ -224,6 +224,11 @@ export function PlanStage() { const placement = activeFloor(state).placements.find((p) => p.id === placementId); const grab = state.cursor; if (!placement || !grab) return; + // A placement whose item is gone has no footprint to snap with, and its snap + // context would come back null — which would quietly ignore Alt for the whole + // drag. It also has nothing rendered to grab, so this is belt and braces; stating + // it here keeps that a rule rather than a coincidence. + if (!snapCtxFor(placement.itemId, placement.id)) return; state.setPlacementTransform({ placementId, From 9f91aeb2843c46405f100e6aa3d78cb5d8ea17dc Mon Sep 17 00:00:00 2001 From: Chintan Date: Wed, 2 Sep 2026 08:55:46 -0400 Subject: [PATCH 06/19] Openings: cut real doorways, and the wall geometry they leave behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An opening cuts a wall in elevation, not in plan, so `ExtrudeGeometry` holes were never going to work. `wallSegments` splits a wall into the solid boxes that remain instead — flank, sill wall, lintel, flank — which needs no CSG and gives the 3D view, the walker and the validation panel one geometry to share. A doorway becomes passable because the only solid above it starts at 2032mm, with no special case anywhere in traversal. Phase 5 opens three new ways for a reference to dangle, and each is closed here rather than tolerated downstream: - a wall-mounted placement keeps its stored elevation whether or not its wall exists, so deleting a wall now re-seats it instead of leaving a shelf in mid-air; - dragging a wall shorter leaves its openings hanging past the end. That is reported, not clamped — silently sliding somebody's front door along the wall to make it fit hides the mistake; - a ceiling mount resolves to `ceiling − drop − height`, which goes negative for anything tall enough and sinks the item through the floor. `solidSpan` will not object, so validation does. Opening sizes are the real published ones — a 32" x 80" door is 813 x 2032, not "about 800 x 2000" — for the same reason the furniture presets are. `LengthField` moves out of `ItemForm` so the properties panel can edit an opening in any unit, committing on blur rather than per keystroke. 393 unit tests, 50 e2e. --- e2e/openings.spec.ts | 124 ++++++++++++ src/core/document.ts | 11 +- src/core/geometry/wall.ts | 25 +++ src/core/openings.test.ts | 200 +++++++++++++++++++ src/core/openings.ts | 337 +++++++++++++++++++++++++++++++++ src/core/tools.ts | 4 +- src/core/validation.test.ts | 86 +++++++++ src/core/validation.ts | 91 ++++++++- src/state/actions.ts | 106 ++++++++++- src/state/openings.test.ts | 183 ++++++++++++++++++ src/state/store.ts | 10 +- src/ui/ItemForm.tsx | 35 +--- src/ui/LengthField.tsx | 103 ++++++++++ src/ui/PropertiesPanel.tsx | 88 +++++++++ src/ui/StatusBar.tsx | 3 + src/ui/ToolPalette.tsx | 21 +- src/ui/plan/PlanStage.tsx | 40 ++++ src/ui/plan/StructureLayer.tsx | 22 ++- 18 files changed, 1433 insertions(+), 56 deletions(-) create mode 100644 e2e/openings.spec.ts create mode 100644 src/core/openings.test.ts create mode 100644 src/core/openings.ts create mode 100644 src/state/openings.test.ts create mode 100644 src/ui/LengthField.tsx diff --git a/e2e/openings.spec.ts b/e2e/openings.spec.ts new file mode 100644 index 0000000..720beae --- /dev/null +++ b/e2e/openings.spec.ts @@ -0,0 +1,124 @@ +import { expect, test, type Page } from '@playwright/test'; +import { clickAt, dragBetween, selectTool } from './coords'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('plan-stage')).toBeVisible(); +}); + +/** A 5m × 4m room, whose north wall runs along y = 0 from x = 0 to x = 5000. */ +async function room(page: Page) { + const stage = page.getByTestId('plan-stage'); + await selectTool(page, 'Room'); + await dragBetween(page, stage, { x: 0, y: 0 }, { x: 5000, y: 4000 }); + await expect(page.getByTestId('count-walls')).toContainText('4'); + return stage; +} + +test.describe('the opening tool', () => { + test('puts a door in the wall you click', async ({ page }) => { + const stage = await room(page); + + await selectTool(page, 'Opening'); + await clickAt(page, stage, { x: 2500, y: 0 }); + + await expect(page.getByTestId('count-openings')).toContainText('1'); + // Selected on drop, so the panel is already showing what you just made. A 32" + // door is 813mm, which reads as 2' 8". + const panel = page.getByTestId('opening-properties'); + await expect(panel.getByLabel('Width')).toHaveValue(`2' 8"`); + await expect(panel.getByLabel('Sill')).toHaveValue(`0' 0"`); + }); + + test('does nothing when the click misses every wall', async ({ page }) => { + const stage = await room(page); + + await selectTool(page, 'Opening'); + await clickAt(page, stage, { x: 2500, y: 2000 }); // the middle of the room + + await expect(page.getByTestId('count-openings')).toContainText('0'); + }); + + test('drops a window at sill height, not a door', async ({ page }) => { + const stage = await room(page); + + await selectTool(page, 'Opening'); + await selectTool(page, 'Window'); + await clickAt(page, stage, { x: 1500, y: 0 }); + + // Sill 914mm is 3'0", head 914 + 1219 = 2133mm is 7'0". The sill is what makes + // this a window rather than a doorway you would trip over. + const panel = page.getByTestId('opening-properties'); + await expect(panel.getByLabel('Sill')).toHaveValue(`3' 0"`); + await expect(panel).toContainText(`7' 0"`); + }); + + test('is one undo step', async ({ page }) => { + const stage = await room(page); + + await selectTool(page, 'Opening'); + await clickAt(page, stage, { x: 2500, y: 0 }); + await expect(page.getByTestId('count-openings')).toContainText('1'); + + await page.keyboard.press('Control+z'); + await expect(page.getByTestId('count-openings')).toContainText('0'); + await expect(page.getByTestId('count-walls')).toContainText('4'); + }); + + test('selects the doorway rather than the wall behind it', async ({ page }) => { + const stage = await room(page); + + await selectTool(page, 'Opening'); + await clickAt(page, stage, { x: 2500, y: 0 }); + + await selectTool(page, 'Select'); + await clickAt(page, stage, { x: 2500, y: 0 }); + await expect(page.getByTestId('opening-properties')).toBeVisible(); + await expect(page.getByTestId('wall-properties')).toBeHidden(); + }); + + test('takes its openings with the wall when the wall is deleted', async ({ page }) => { + const stage = await room(page); + + await selectTool(page, 'Opening'); + await clickAt(page, stage, { x: 2500, y: 0 }); + await expect(page.getByTestId('count-openings')).toContainText('1'); + + await selectTool(page, 'Select'); + await clickAt(page, stage, { x: 4200, y: 0 }); // the wall, clear of the doorway + await expect(page.getByTestId('wall-properties')).toBeVisible(); + await page.keyboard.press('Delete'); + + await expect(page.getByTestId('count-walls')).toContainText('3'); + await expect(page.getByTestId('count-openings')).toContainText('0'); + }); + + test('warns when an edit pushes a door off the end of its wall, and never blocks it', async ({ + page, + }) => { + const stage = await room(page); + + await selectTool(page, 'Opening'); + await clickAt(page, stage, { x: 2500, y: 0 }); + + // Typed, not dragged: the panel does not clamp, because silently sliding + // somebody's front door along the wall would hide the mistake. + const offset = page.getByTestId('opening-properties').getByLabel('From wall start'); + await offset.fill('4800mm'); + await offset.press('Enter'); + + await expect(page.getByTestId('issue-list')).toContainText('past the end'); + await expect(page.getByTestId('count-openings')).toContainText('1'); + }); + + test('warns about two doors overlapping in the same wall', async ({ page }) => { + const stage = await room(page); + + await selectTool(page, 'Opening'); + await clickAt(page, stage, { x: 2500, y: 0 }); + await clickAt(page, stage, { x: 2800, y: 0 }); + + await expect(page.getByTestId('count-openings')).toContainText('2'); + await expect(page.getByTestId('issue-list')).toContainText('overlap in the same wall'); + }); +}); diff --git a/src/core/document.ts b/src/core/document.ts index 781fc50..eebb2dd 100644 --- a/src/core/document.ts +++ b/src/core/document.ts @@ -131,11 +131,18 @@ export type OpeningKind = 'door' | 'window' | 'cased' | 'pocket' | 'sliding'; export type Opening = { id: Id; wallId: Id; - /** Distance from `wall.a` along the centreline. */ + /** + * The opening's **leading edge**, in mm from `wall.a` along the centreline. + * + * The leading edge rather than the centre because it is the coordinate the wall + * split works in — `wallSegments` needs `[from, to]` — and because it makes + * "does this still fit?" a comparison against 0 and the wall length rather than + * against half-widths. Click-to-place converts a clicked centre into it once. + */ offsetMm: number; widthMm: number; heightMm: number; - /** 0 for doors, ~900 for windows. */ + /** Above the **floor datum**, not the wall base. 0 for doors, 914 for windows. */ sillMm: number; kind: OpeningKind; swing?: { diff --git a/src/core/geometry/wall.ts b/src/core/geometry/wall.ts index 5849b64..fdbe02f 100644 --- a/src/core/geometry/wall.ts +++ b/src/core/geometry/wall.ts @@ -104,6 +104,31 @@ export function hitsWall(wall: WallLine, p: Vec2, toleranceMm: number): boolean return distanceToSegment(p, wall.a, wall.b) <= wall.thicknessMm / 2 + toleranceMm; } +/** + * The wall whose body is under a point, nearest first. + * + * Nearest rather than first-match: walls overlap at every corner (joins are butt + * joins, so there is a small overlap on the inside of each), and clicking a corner + * should address the wall you are pointing at rather than whichever was drawn first. + */ +export function nearestWall( + walls: readonly T[], + p: Vec2, + toleranceMm: number, +): T | undefined { + let best: T | undefined; + let bestDistance = Infinity; + for (const wall of walls) { + if (isDegenerate(wall)) continue; + const d = distanceToSegment(p, wall.a, wall.b); + if (d <= wall.thicknessMm / 2 + toleranceMm && d < bestDistance) { + best = wall; + bestDistance = d; + } + } + return best; +} + /** Every distinct endpoint across a set of walls — the candidate set for snapping. */ export function wallEndpoints(walls: readonly WallLine[]): Vec2[] { const seen = new Set(); diff --git a/src/core/openings.test.ts b/src/core/openings.test.ts new file mode 100644 index 0000000..2af77f0 --- /dev/null +++ b/src/core/openings.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; +import { + OPENING_DEFAULTS, + OpeningError, + clampOffset, + createOpening, + openingCentre, + openingFitReason, + openingRange, + openingSpan, + segmentArea, + segmentOutline, + wallSegments, +} from './openings'; +import { area } from './geometry/polygon'; +import type { Opening, Wall } from './document'; + +/** A 5m wall running east from the origin, 2438 high, 114 thick. */ +const WALL: Wall = { + id: 'w1', + a: { x: 0, y: 0 }, + b: { x: 5000, y: 0 }, + thicknessMm: 114, + heightMm: 2438, + baseElevationMm: 0, +}; + +function opening(over: Partial = {}): Opening { + return { + id: 'o1', + wallId: 'w1', + offsetMm: 1000, + widthMm: 813, + heightMm: 2032, + sillMm: 0, + kind: 'door', + ...over, + }; +} + +describe('creating an opening', () => { + it('centres it on the clicked point', () => { + const o = createOpening({ id: 'o', wall: WALL, kind: 'door', centreMm: 2500 }); + expect(o.offsetMm).toBe(2094); // 2500 − 813/2, rounded up from 2093.5 + expect(openingCentre(WALL, o)).toEqual({ x: 2500.5, y: 0 }); + }); + + it('slides it along rather than refusing, when the click is near an end', () => { + // "Put a door here" near the corner means the nearest place it fits, which is + // what a person would do by hand anyway. + const o = createOpening({ id: 'o', wall: WALL, kind: 'door', centreMm: 50 }); + expect(o.offsetMm).toBe(0); + + const far = createOpening({ id: 'o', wall: WALL, kind: 'door', centreMm: 4990 }); + expect(openingRange(far).to).toBe(5000); + }); + + it('refuses a wall that physically cannot hold the opening', () => { + // Shrinking the patio door to fit would produce a door nobody asked for. + const stub: Wall = { ...WALL, b: { x: 900, y: 0 } }; + expect(() => createOpening({ id: 'o', wall: stub, kind: 'sliding', centreMm: 450 })).toThrow( + OpeningError, + ); + }); + + it('uses real published sizes, not friendly metric ones', () => { + // A 32" x 80" door is 813 x 2032. Rounding it makes every door in a traced US + // plan subtly wrong. + expect(OPENING_DEFAULTS.door).toEqual({ widthMm: 813, heightMm: 2032, sillMm: 0 }); + expect(OPENING_DEFAULTS.window.sillMm).toBe(914); + }); + + it('measures the sill from the floor, not from the wall base', () => { + expect(openingSpan(opening({ sillMm: 914, heightMm: 1219 }))).toEqual({ + bottom: 914, + top: 2133, + }); + }); +}); + +describe('clampOffset', () => { + it('gives up and returns 0 when the opening is wider than the wall', () => { + expect(clampOffset(500, 2000, 1000)).toBe(0); + }); +}); + +describe('wallSegments', () => { + it('returns exactly one segment for a wall with no openings', () => { + expect(wallSegments(WALL, [])).toEqual([ + { from: 0, to: 5000, bottom: 0, top: 2438 }, + ]); + }); + + it('leaves a flank, a lintel and a flank around a door', () => { + // The door has no sill wall, so there is no box below it — which is what makes + // the doorway passable without traversal knowing what a door is. + const segments = wallSegments(WALL, [opening()]); + expect(segments).toEqual([ + { from: 0, to: 1000, bottom: 0, top: 2438 }, + { from: 1000, to: 1813, bottom: 2032, top: 2438 }, + { from: 1813, to: 5000, bottom: 0, top: 2438 }, + ]); + }); + + it('leaves a sill wall below a window as well as a lintel above it', () => { + const segments = wallSegments(WALL, [ + opening({ kind: 'window', sillMm: 914, heightMm: 1219 }), + ]); + expect(segments).toHaveLength(4); + expect(segments[1]).toEqual({ from: 1000, to: 1813, bottom: 0, top: 914 }); + expect(segments[2]).toEqual({ from: 1000, to: 1813, bottom: 2133, top: 2438 }); + }); + + it('keeps the jamb between two adjacent openings', () => { + // Two doors 100mm apart are two doors, not one 1726mm gap. + const segments = wallSegments(WALL, [ + opening({ id: 'a', offsetMm: 1000 }), + opening({ id: 'b', offsetMm: 1913 }), + ]); + const jamb = segments.find((s) => s.from === 1813 && s.to === 1913); + expect(jamb).toEqual({ from: 1813, to: 1913, bottom: 0, top: 2438 }); + }); + + it('emits no leading segment for an opening flush with the start of the wall', () => { + // A zero-width box would extrude to nothing and collide with everything. + const segments = wallSegments(WALL, [opening({ offsetMm: 0 })]); + expect(segments.every((s) => s.to > s.from)).toBe(true); + expect(segments[0]).toEqual({ from: 0, to: 813, bottom: 2032, top: 2438 }); + }); + + it('merges overlapping openings instead of inventing a jamb between them', () => { + const segments = wallSegments(WALL, [ + opening({ id: 'a', offsetMm: 1000, widthMm: 1000 }), + opening({ id: 'b', offsetMm: 1500, widthMm: 1000 }), + ]); + expect(segments).toEqual([ + { from: 0, to: 1000, bottom: 0, top: 2438 }, + { from: 1000, to: 2500, bottom: 2032, top: 2438 }, + { from: 2500, to: 5000, bottom: 0, top: 2438 }, + ]); + }); + + it('conserves face area: solid = wall minus the openings', () => { + // The invariant that catches an off-by-one anywhere in the split. + const gap = opening({ kind: 'window', sillMm: 914, heightMm: 1219 }); + const segments = wallSegments(WALL, [gap]); + const wallFace = 5000 * 2438; + const hole = gap.widthMm * gap.heightMm; + expect(segmentArea(segments)).toBe(wallFace - hole); + }); + + it('drops an opening that a shortened wall has left hanging off the end', () => { + // Validation reports it; the geometry just does not build a door in mid-air. + const short: Wall = { ...WALL, b: { x: 900, y: 0 } }; + expect(wallSegments(short, [opening({ offsetMm: 1000 })])).toEqual([ + { from: 0, to: 900, bottom: 0, top: 2438 }, + ]); + }); + + it('clips an opening taller than its wall rather than removing more than there is', () => { + const segments = wallSegments(WALL, [opening({ heightMm: 4000 })]); + expect(segmentArea(segments)).toBe(5000 * 2438 - 813 * 2438); + }); + + it('folds a raised wall base into absolute elevations', () => { + const platform: Wall = { ...WALL, baseElevationMm: 300, heightMm: 1000 }; + expect(wallSegments(platform, [])).toEqual([ + { from: 0, to: 5000, bottom: 300, top: 1300 }, + ]); + }); +}); + +describe('segmentOutline', () => { + it('is the slice of the wall quad the segment runs over', () => { + const [segment] = wallSegments(WALL, [opening()]); + const poly = segmentOutline(WALL, segment!); + expect(area(poly)).toBeCloseTo(1000 * 114, 6); + }); + + it('follows a diagonal wall', () => { + const diagonal: Wall = { ...WALL, b: { x: 3000, y: 4000 } }; // 5000 long + const poly = segmentOutline(diagonal, { from: 0, to: 5000, bottom: 0, top: 2438 }); + expect(area(poly)).toBeCloseTo(5000 * 114, 4); + }); +}); + +describe('openingFitReason', () => { + it('is silent when the opening fits', () => { + expect(openingFitReason(WALL, opening())).toBeNull(); + }); + + it('reports an opening that runs past the end of its wall', () => { + const short: Wall = { ...WALL, b: { x: 1500, y: 0 } }; + expect(openingFitReason(short, opening())).toContain('past the end'); + }); + + it('reports an opening taller than its wall', () => { + expect(openingFitReason(WALL, opening({ heightMm: 3000 }))).toContain('taller'); + }); +}); diff --git a/src/core/openings.ts b/src/core/openings.ts new file mode 100644 index 0000000..d11db08 --- /dev/null +++ b/src/core/openings.ts @@ -0,0 +1,337 @@ +/** + * Openings, and the wall geometry they leave behind. See PLAN.md §4.4 and §10.1. + * + * An opening is stored against a wall as an offset along its centreline, a width, a + * height and a sill — not as a hole polygon. That is what a plan actually records, + * and it is what keeps a door attached to its wall when the wall is dragged. + * + * **`wallSegments` is the load-bearing function in this file**, and the reason phase + * 5 does not need a CSG library. An opening cuts a wall in *elevation*, not in plan: + * a doorway removes a rectangle from the wall's face, leaving the wall on either side + * of it, a lintel above it, and — for a window — a sill below it. Those pieces are + * all boxes. So instead of subtracting geometry, the wall is *split* into the boxes + * that remain: + * + * ┌──────────────────────────────────────┐ + * │ │ lintel │ │ ← above the opening + * │ flank ├─────────────┤ flank │ + * │ │ (gap) │ │ + * │ │ │ │ + * └──────────────────────────────────────┘ + * + * The same list then serves three consumers: the 3D view extrudes it, the walker + * collides against it, and the validation panel measures it. A doorway is passable + * because the only solid above it — the lintel — starts at 2032mm, which is above the + * walker's head. No special case, no "is this a door" check anywhere in traversal. + */ + +import type { Opening, OpeningKind, Wall } from './document'; +import type { Span } from './geometry/collision'; +import { ensureCounterClockwise, polygon, type Polygon } from './geometry/polygon'; +import { normalize, perp, sub, type Vec2 } from './geometry/vec'; +import { isDegenerate, wallLength } from './geometry/wall'; + +export const OPENING_KINDS: readonly OpeningKind[] = [ + 'door', + 'window', + 'cased', + 'pocket', + 'sliding', +] as const; + +export const OPENING_KIND_LABELS: Record = { + door: 'Door', + window: 'Window', + cased: 'Cased opening', + pocket: 'Pocket door', + sliding: 'Sliding door', +}; + +export type OpeningDefaults = { + widthMm: number; + heightMm: number; + sillMm: number; +}; + +/** + * Standard North American sizes, in the units they were actually specified in. + * + * A 32" × 80" door is 813 × 2032mm, not "about 800 × 2000" — the same reasoning as + * the preset library, where a queen bed is 1524 × 2032 because it is 60" × 80". + * Rounding these to friendly metric numbers would mean every door in a traced US + * plan is subtly the wrong size. + */ +export const OPENING_DEFAULTS: Record = { + door: { widthMm: 813, heightMm: 2032, sillMm: 0 }, // 32" × 80" + window: { widthMm: 914, heightMm: 1219, sillMm: 914 }, // 36" × 48", sill 36" + cased: { widthMm: 914, heightMm: 2032, sillMm: 0 }, // 36" × 80" + pocket: { widthMm: 813, heightMm: 2032, sillMm: 0 }, + sliding: { widthMm: 1829, heightMm: 2032, sillMm: 0 }, // 6'0" patio +}; + +/** Narrower than this and it is not an opening anyone could pass through or fit. */ +export const MIN_OPENING_WIDTH_MM = 50; +export const MIN_OPENING_HEIGHT_MM = 50; + +export class OpeningError extends Error { + constructor(message: string) { + super(message); + this.name = 'OpeningError'; + } +} + +// --------------------------------------------------------------------------- +// Along-wall ranges +// --------------------------------------------------------------------------- + +/** A span along a wall's centreline, in mm from `wall.a`. */ +export type Range = { from: number; to: number }; + +/** + * The opening's extent along the wall. + * + * `offsetMm` is the **leading edge**, measured from `wall.a` — the definition in + * `Opening`'s own comment, and what the plan layer already draws. + */ +export function openingRange(opening: Opening): Range { + return { from: opening.offsetMm, to: opening.offsetMm + opening.widthMm }; +} + +/** + * The opening's vertical extent. + * + * `sillMm` is measured from the **floor datum**, not from the wall's own base — the + * same reference `Placement.elevation` uses, so every height in the document is + * comparable without knowing which entity it came from. A window at 914mm is 914mm + * off the floor whether or not the wall it is in starts on a platform. + */ +export function openingSpan(opening: Opening): Span { + return { bottom: opening.sillMm, top: opening.sillMm + opening.heightMm }; +} + +export function rangesOverlap(a: Range, b: Range): boolean { + return a.from < b.to && b.from < a.to; +} + +/** + * The offset that centres an opening of `widthMm` on `centreMm`, clamped so the whole + * opening stays on the wall. + * + * Clamping rather than refusing: clicking near the end of a wall means "put a door + * here", and sliding it along until it fits is what a person would do anyway. An + * opening wider than the wall is a different problem and is reported by validation. + */ +export function clampOffset(centreMm: number, widthMm: number, wallLengthMm: number): number { + const max = wallLengthMm - widthMm; + if (max <= 0) return 0; + return Math.round(Math.max(0, Math.min(max, centreMm - widthMm / 2))); +} + +// --------------------------------------------------------------------------- +// Construction +// --------------------------------------------------------------------------- + +/** + * Build an opening centred on a point along a wall. + * + * Throws when the wall physically cannot hold it — a 1829mm patio door will not go in + * a 900mm wall, and silently shrinking it would produce a door nobody asked for. + */ +export function createOpening(params: { + id: string; + wall: Wall; + kind: OpeningKind; + centreMm: number; + size?: Partial; +}): Opening { + const base = OPENING_DEFAULTS[params.kind]; + const widthMm = Math.round(params.size?.widthMm ?? base.widthMm); + const heightMm = Math.round(params.size?.heightMm ?? base.heightMm); + const sillMm = Math.round(params.size?.sillMm ?? base.sillMm); + + if (isDegenerate(params.wall)) { + throw new OpeningError('That wall is too short to hold an opening.'); + } + const length = wallLength(params.wall); + if (widthMm > length) { + throw new OpeningError( + `A ${OPENING_KIND_LABELS[params.kind].toLowerCase()} is ${widthMm}mm wide and this wall is ` + + `only ${Math.round(length)}mm long.`, + ); + } + if (widthMm < MIN_OPENING_WIDTH_MM || heightMm < MIN_OPENING_HEIGHT_MM) { + throw new OpeningError('An opening needs to be at least 50mm in each direction.'); + } + + return { + id: params.id, + wallId: params.wall.id, + offsetMm: clampOffset(params.centreMm, widthMm, length), + widthMm, + heightMm, + sillMm, + kind: params.kind, + }; +} + +// --------------------------------------------------------------------------- +// The wall that remains +// --------------------------------------------------------------------------- + +/** + * A solid box of wall: a run along the centreline and a vertical span. + * + * Elevations are absolute above the floor datum, so `baseElevationMm` is already + * folded in and nothing downstream has to remember to add it. + */ +export type WallSegment = { + /** mm from `wall.a` along the centreline. */ + from: number; + to: number; + /** mm above the floor datum. */ + bottom: number; + top: number; +}; + +export function segmentIsEmpty(segment: WallSegment): boolean { + return segment.to - segment.from <= 0 || segment.top - segment.bottom <= 0; +} + +/** + * Split a wall into the solid boxes left once its openings are removed. + * + * Openings are clamped to the wall and processed left to right. Two that overlap are + * treated as one merged gap taking the *lower* sill and the *higher* head, because + * that is the void a builder would actually be left with — overlapping openings are + * reported separately by validation, and the geometry should not also pretend a jamb + * exists between them. + * + * Returns segments in order along the wall. A wall with no openings returns exactly + * one segment; the total area of the returned segments always equals the wall's face + * area minus the area of the (clamped, merged) openings, which is the invariant the + * tests hold this to. + */ +export function wallSegments(wall: Wall, openings: readonly Opening[]): WallSegment[] { + const length = wallLength(wall); + const base = wall.baseElevationMm; + const top = base + wall.heightMm; + if (length <= 0 || wall.heightMm <= 0) return []; + + // Clamp to the wall, drop anything that has fallen entirely off the end — a wall + // dragged shorter leaves openings hanging past `b`, which validation reports and + // the geometry simply does not build. + const gaps = openings + .filter((o) => o.wallId === wall.id) + .map((o) => { + const r = openingRange(o); + const s = openingSpan(o); + return { + from: Math.max(0, Math.min(length, r.from)), + to: Math.max(0, Math.min(length, r.to)), + // Clipped to the wall vertically as well as along its length: an opening + // taller than its wall removes what wall there is, not more. + bottom: Math.max(base, s.bottom), + top: Math.min(top, s.top), + }; + }) + .filter((g) => g.to - g.from > 0 && g.top - g.bottom > 0) + .sort((p, q) => p.from - q.from); + + // Merge overlaps so the walk below never sees a gap starting before the cursor. + const merged: typeof gaps = []; + for (const gap of gaps) { + const last = merged[merged.length - 1]; + if (last && gap.from < last.to) { + last.to = Math.max(last.to, gap.to); + last.bottom = Math.min(last.bottom, gap.bottom); + last.top = Math.max(last.top, gap.top); + } else { + merged.push({ ...gap }); + } + } + + const segments: WallSegment[] = []; + const push = (segment: WallSegment) => { + if (!segmentIsEmpty(segment)) segments.push(segment); + }; + + let cursor = 0; + for (const gap of merged) { + push({ from: cursor, to: gap.from, bottom: base, top }); + // Below the opening (a window's sill wall) and above it (the lintel). Either can + // be empty — a door has no sill wall, and an opening running to the ceiling has + // no lintel — which `push` drops rather than emitting a zero-height box. + push({ from: gap.from, to: gap.to, bottom: base, top: gap.bottom }); + push({ from: gap.from, to: gap.to, bottom: gap.top, top }); + cursor = gap.to; + } + push({ from: cursor, to: length, bottom: base, top }); + + return segments; +} + +/** Total solid face area of a wall, in mm² — the invariant `wallSegments` preserves. */ +export function segmentArea(segments: readonly WallSegment[]): number { + let total = 0; + for (const s of segments) total += (s.to - s.from) * (s.top - s.bottom); + return total; +} + +/** + * A segment as a plan polygon, `thicknessMm` wide about the wall's centreline. + * + * The same quad `wallOutline` produces, restricted to the segment's run — so a + * segment and the wall it came from are flush by construction rather than by + * agreeing on a formula in two places. + */ +export function segmentOutline(wall: Wall, segment: WallSegment): Polygon { + const dir = normalize(sub(wall.b, wall.a)); + const half = wall.thicknessMm / 2; + const n = perp(dir); + + const at = (t: number, side: number): Vec2 => ({ + x: wall.a.x + dir.x * t + n.x * half * side, + y: wall.a.y + dir.y * t + n.y * half * side, + }); + + return ensureCounterClockwise( + polygon([ + at(segment.from, 1), + at(segment.to, 1), + at(segment.to, -1), + at(segment.from, -1), + ]), + ); +} + +/** The point on the wall centreline at the middle of an opening. */ +export function openingCentre(wall: Wall, opening: Opening): Vec2 { + const dir = normalize(sub(wall.b, wall.a)); + const t = opening.offsetMm + opening.widthMm / 2; + return { x: wall.a.x + dir.x * t, y: wall.a.y + dir.y * t }; +} + +// --------------------------------------------------------------------------- +// Fit +// --------------------------------------------------------------------------- + +/** + * Why an opening does not fit its wall, or null when it does. + * + * Reported rather than corrected. Dragging a wall shorter is the usual cause, and + * silently sliding somebody's front door along the wall to make it fit would be a + * worse surprise than being told the door no longer fits. + */ +export function openingFitReason(wall: Wall, opening: Opening): string | null { + const length = wallLength(wall); + const range = openingRange(opening); + const label = OPENING_KIND_LABELS[opening.kind]; + + if (range.from < 0 || range.to > length) { + return `${label} runs past the end of its wall — the wall is ${Math.round(length)}mm long.`; + } + if (opening.sillMm + opening.heightMm > wall.baseElevationMm + wall.heightMm) { + return `${label} is taller than the wall it is in.`; + } + return null; +} diff --git a/src/core/tools.ts b/src/core/tools.ts index 3165009..6a810a6 100644 --- a/src/core/tools.ts +++ b/src/core/tools.ts @@ -23,13 +23,14 @@ import { area, ensureCounterClockwise, polygon, roundPolygon, translate } from ' import { isDegenerate } from './geometry/wall'; import type { Vec2 } from './geometry/vec'; -export const PLAN_TOOLS = ['select', 'wall', 'room', 'shape', 'dimension'] as const; +export const PLAN_TOOLS = ['select', 'wall', 'room', 'opening', 'shape', 'dimension'] as const; export type PlanTool = (typeof PLAN_TOOLS)[number]; export const PLAN_TOOL_LABELS: Record = { select: 'Select', wall: 'Wall', room: 'Room', + opening: 'Opening', shape: 'Shape', dimension: 'Measure', }; @@ -39,6 +40,7 @@ export const PLAN_TOOL_KEYS: Record = { select: 'v', wall: 'w', room: 'r', + opening: 'o', shape: 's', dimension: 'd', }; diff --git a/src/core/validation.test.ts b/src/core/validation.test.ts index 47f7c84..77b3f10 100644 --- a/src/core/validation.test.ts +++ b/src/core/validation.test.ts @@ -153,3 +153,89 @@ describe('flaggedPlacements', () => { expect(flaggedPlacements(validateFloor(d, d.floors[0]!)).size).toBe(0); }); }); + +describe('openings', () => { + const wall = { + id: 'w1', + a: { x: 0, y: 0 }, + b: { x: 3000, y: 0 }, + thicknessMm: 114, + heightMm: 2438, + baseElevationMm: 0, + }; + + const door = { + id: 'o1', + wallId: 'w1', + offsetMm: 1000, + widthMm: 813, + heightMm: 2032, + sillMm: 0, + kind: 'door' as const, + }; + + it('says nothing about an opening that fits', () => { + const d = doc(); + d.floors[0]!.walls = [wall]; + d.floors[0]!.openings = [door]; + expect(validateFloor(d, d.floors[0]!)).toEqual([]); + }); + + it('reports a door left hanging past the end of a shortened wall', () => { + // Dragging a wall shorter does not re-clamp its openings, deliberately — being + // told beats having your front door silently slid along the wall. + const d = doc(); + d.floors[0]!.walls = [{ ...wall, b: { x: 1500, y: 0 } }]; + d.floors[0]!.openings = [door]; + + const issues = validateFloor(d, d.floors[0]!); + expect(issues).toHaveLength(1); + expect(issues[0]!.kind).toBe('opening-fit'); + expect(issues[0]!.refs).toContainEqual({ kind: 'opening', id: 'o1' }); + }); + + it('reports two openings overlapping in the same wall', () => { + // The geometry merges them into one gap, so without this the user gets a wider + // doorway than either door they placed and no clue why. + const d = doc(); + d.floors[0]!.walls = [wall]; + d.floors[0]!.openings = [door, { ...door, id: 'o2', offsetMm: 1400 }]; + + const issues = validateFloor(d, d.floors[0]!); + expect(issues.map((i) => i.kind)).toEqual(['opening-overlap']); + }); + + it('does not report two openings that merely touch', () => { + const d = doc(); + d.floors[0]!.walls = [wall]; + d.floors[0]!.openings = [door, { ...door, id: 'o2', offsetMm: 1813 }]; + expect(validateFloor(d, d.floors[0]!)).toEqual([]); + }); + + it('reports a placement mounted on a wall that is gone', () => { + // Unlike a surface mount, a wall mount keeps its stored elevation whether or not + // the wall exists, so nothing else would ever notice. + const d = withItems(['bookcase']); + d.floors[0]!.placements = [ + { ...place('bookcase', { x: 0, y: 0 }), mount: { kind: 'wall', wallId: 'gone' }, elevation: 0 }, + ]; + + const issues = validateFloor(d, d.floors[0]!); + expect(issues.some((i) => i.kind === 'broken-mount' && i.message.includes('wall'))).toBe(true); + }); +}); + +describe('hanging from the ceiling', () => { + it('reports an item whose drop sinks it through the floor', () => { + // elevation = ceiling - drop - height, which goes negative for anything tall + // enough. `solidSpan` will not object, so nothing else would catch it. + const d = withItems(['bookcase']); // 3000 tall, in a 2438 room + d.floors[0]!.placements = [ + { ...place('bookcase', { x: 0, y: 0 }), mount: { kind: 'ceiling', drop: 0 } }, + ]; + + const issues = validateFloor(d, d.floors[0]!); + const sunk = issues.find((i) => i.kind === 'below-floor'); + expect(sunk?.message).toContain('562mm below the floor'); + }); +}); diff --git a/src/core/validation.ts b/src/core/validation.ts index d849a0b..8dc5a6d 100644 --- a/src/core/validation.ts +++ b/src/core/validation.ts @@ -18,6 +18,12 @@ import { placementBlockReason } from './calibration'; import { findItem, type Floor, type Id, type SpaceDocument } from './document'; +import { + OPENING_KIND_LABELS, + openingFitReason, + openingRange, + rangesOverlap, +} from './openings'; import { findCollisions, type Volume } from './geometry/collision'; import { MountCycleError, @@ -30,12 +36,15 @@ export type IssueKind = | 'uncalibrated' | 'overlap' | 'headroom' + | 'below-floor' | 'missing-item' - | 'broken-mount'; + | 'broken-mount' + | 'opening-fit' + | 'opening-overlap'; export type IssueSeverity = 'blocking' | 'warning'; -export type IssueRef = { kind: 'wall' | 'room' | 'placement'; id: Id }; +export type IssueRef = { kind: 'wall' | 'room' | 'opening' | 'placement'; id: Id }; export type Issue = { kind: IssueKind; @@ -73,6 +82,52 @@ export function validateFloor(doc: SpaceDocument, floor: Floor): Issue[] { issues.push({ kind: 'uncalibrated', severity: 'blocking', message: blocked, refs: [] }); } + // Openings. A wall dragged shorter leaves its doors hanging past the end, and + // nothing re-clamps them — deliberately, because silently sliding somebody's front + // door along the wall would hide the mistake rather than report it. + const wallsById = new Map(floor.walls.map((w) => [w.id, w])); + for (const opening of floor.openings) { + const wall = wallsById.get(opening.wallId); + if (!wall) continue; // deletion takes openings with the wall; nothing to report + + const reason = openingFitReason(wall, opening); + if (reason) { + issues.push({ + kind: 'opening-fit', + severity: 'warning', + message: reason, + refs: [ + { kind: 'opening', id: opening.id }, + { kind: 'wall', id: wall.id }, + ], + }); + } + } + + // Overlapping openings on the same wall. The geometry merges them into one gap, so + // without this the user gets a wider doorway than either door they placed and no + // indication of why. + for (let i = 0; i < floor.openings.length; i++) { + for (let j = i + 1; j < floor.openings.length; j++) { + const a = floor.openings[i]!; + const b = floor.openings[j]!; + if (a.wallId !== b.wallId) continue; + if (!rangesOverlap(openingRange(a), openingRange(b))) continue; + + issues.push({ + kind: 'opening-overlap', + severity: 'warning', + message: `${OPENING_KIND_LABELS[a.kind]} and ${OPENING_KIND_LABELS[ + b.kind + ].toLowerCase()} overlap in the same wall.`, + refs: [ + { kind: 'opening', id: a.id }, + { kind: 'opening', id: b.id }, + ], + }); + } + } + // Volumes, and the placements they belong to. A placement whose item or mount is // broken contributes an issue instead of a volume — colliding it against anything // would be asserting a position it does not really have. @@ -104,12 +159,37 @@ export function validateFloor(doc: SpaceDocument, floor: Floor): Issue[] { } } + // A wall mount keeps its stored elevation whether or not the wall is still + // there, so unlike a surface mount this one does not degrade to anything + // visible — the shelf simply hangs in mid-air until someone is told. + if (placement.mount.kind === 'wall' && !wallsById.has(placement.mount.wallId)) { + issues.push({ + kind: 'broken-mount', + severity: 'warning', + message: `${item.name} is mounted on a wall that no longer exists.`, + refs: [{ kind: 'placement', id: placement.id }], + }); + } + try { volumes.push(placementVolume(doc, placement, item)); owners.push(placement.id); const span = placementSpan(doc, placement, item); const ceiling = ceilingHeightAt(doc, placement); + + // A ceiling mount resolves to `ceiling − drop − height`, which goes negative + // for anything tall enough — a 2400mm pendant in a 2438mm room. `solidSpan` + // will not object, so the item silently sinks through the floor. + if (span.bottom < 0) { + issues.push({ + kind: 'below-floor', + severity: 'warning', + message: `${item.name} hangs ${Math.round(-span.bottom)}mm below the floor. Reduce the drop, or lower the item.`, + refs: [{ kind: 'placement', id: placement.id }], + }); + } + if (span.top > ceiling) { issues.push({ kind: 'headroom', @@ -150,8 +230,11 @@ export function validateFloor(doc: SpaceDocument, floor: Floor): Issue[] { uncalibrated: 0, 'broken-mount': 1, 'missing-item': 2, - headroom: 3, - overlap: 4, + 'below-floor': 3, + 'opening-fit': 4, + 'opening-overlap': 5, + headroom: 6, + overlap: 7, }; return issues.sort((x, y) => order[x.kind] - order[y.kind]); } diff --git a/src/state/actions.ts b/src/state/actions.ts index df21c1d..af854cb 100644 --- a/src/state/actions.ts +++ b/src/state/actions.ts @@ -6,7 +6,19 @@ * one committed from a test take exactly the same path. */ -import type { AssetRef, Background, CatalogItem, Id, Placement, Room, Wall } from '../core/document'; +import type { + AssetRef, + Background, + CatalogItem, + Id, + Opening, + OpeningKind, + Placement, + Room, + Wall, +} from '../core/document'; +import { createOpening, type OpeningDefaults } from '../core/openings'; +import { nearestWall, projectOntoWall } from '../core/geometry/wall'; import { createCatalogItem, type ItemDraft } from '../core/catalog'; import { snapPlacement, snapRotation, type PlacementSnapContext } from '../core/placement-snap'; import { worldOutline } from '../core/placement'; @@ -89,20 +101,28 @@ export function addShapeRoom(kind: ShapeKind, start: Vec2, end: Vec2): Room | nu /** * Delete a selection. * - * Openings hosted on a deleted wall go with it — an opening with a dangling `wallId` - * has no position, no host to cut and nothing that could render it. + * Deleting a wall takes three things with it, and every one of them is a reference + * that would otherwise dangle: * - * Anything surface-mounted on a deleted placement is re-seated on the floor in the - * same mutation. `resolveElevation` already degrades a dangling host to zero, which - * is the right *failure* but the wrong *result*: the lamp would sit at floor level - * while still claiming to be on a nightstand, and undo would have to put both back. - * Making it explicit means the document is never left referencing something gone. + * - **Openings on it** — an opening with a dangling `wallId` has no position, no + * host to cut and nothing that could render it. + * - **Wall-mounted placements** — a shelf whose wall is gone is re-seated on the + * floor. `resolveElevation` returns the stored elevation for a wall mount without + * checking the wall still exists, so leaving it would hang the shelf in mid-air. + * - **Surface children of deleted placements** — the same fix one level down. + * `resolveElevation` already degrades a dangling host to zero, which is the right + * *failure* but the wrong *result*: the lamp would sit at floor level while still + * claiming to be on a nightstand, and undo would have to put both back. + * + * Doing it here rather than tolerating it downstream means the document is never left + * referencing something that is gone. */ export function deleteSelection(selection: readonly SelectionRef[]): void { if (selection.length === 0) return; const wallIds = new Set(selection.filter((s) => s.kind === 'wall').map((s) => s.id)); const roomIds = new Set(selection.filter((s) => s.kind === 'room').map((s) => s.id)); + const openingIds = new Set(selection.filter((s) => s.kind === 'opening').map((s) => s.id)); const placementIds = new Set(selection.filter((s) => s.kind === 'placement').map((s) => s.id)); const label = selection.length === 1 ? `Delete ${selection[0]!.kind}` : `Delete ${selection.length} items`; @@ -110,12 +130,17 @@ export function deleteSelection(selection: readonly SelectionRef[]): void { useStore.getState().mutate(label, (draft) => { for (const floor of draft.floors) { floor.walls = floor.walls.filter((w) => !wallIds.has(w.id)); - floor.openings = floor.openings.filter((o) => !wallIds.has(o.wallId)); + floor.openings = floor.openings.filter( + (o) => !openingIds.has(o.id) && !wallIds.has(o.wallId), + ); floor.rooms = floor.rooms.filter((r) => !roomIds.has(r.id)); floor.placements = floor.placements.filter((p) => !placementIds.has(p.id)); for (const placement of floor.placements) { - if (placement.mount.kind === 'surface' && placementIds.has(placement.mount.hostId)) { + const orphanedHost = + placement.mount.kind === 'surface' && placementIds.has(placement.mount.hostId); + const orphanedWall = placement.mount.kind === 'wall' && wallIds.has(placement.mount.wallId); + if (orphanedHost || orphanedWall) { placement.mount = { kind: 'floor' }; placement.elevation = 0; } @@ -124,6 +149,67 @@ export function deleteSelection(selection: readonly SelectionRef[]): void { }); } +// --------------------------------------------------------------------------- +// Openings (PLAN.md §4.4) +// --------------------------------------------------------------------------- + +/** + * Put an opening in the wall nearest a clicked point. + * + * The click is projected onto the wall's centreline and taken as the *centre* of the + * opening, which is where a person pointing at a wall means the door to be. Returns + * null when the click was not on a wall; throws `OpeningError`, with a message meant + * to be shown, when the wall cannot hold the opening at all. + */ +export function addOpening( + at: Vec2, + kind: OpeningKind, + toleranceMm: number, + size?: Partial, +): Opening | null { + const state = useStore.getState(); + const floor = activeFloor(state); + const wall = nearestWall(floor.walls, at, toleranceMm); + if (!wall) return null; + + const opening = createOpening({ + id: newId(), + wall, + kind, + centreMm: projectOntoWall(wall, at), + ...(size ? { size } : {}), + }); + + const floorId = floor.id; + useStore.getState().mutate(`Add ${kind}`, (draft) => { + const target = draft.floors.find((f) => f.id === floorId); + if (target) target.openings.push(opening); + }); + return opening; +} + +/** + * Edit an opening's size or position along its wall. + * + * Deliberately does **not** clamp to the wall. A number typed into the panel is what + * the user meant, and quietly moving their front door to make it fit would hide the + * mistake; validation reports an opening that no longer fits and the geometry simply + * does not build it. + */ +export function updateOpening(openingId: Id, patch: Partial>): void { + useStore.getState().mutate('Edit opening', (draft) => { + for (const floor of draft.floors) { + const opening = floor.openings.find((o) => o.id === openingId); + if (!opening) continue; + if (patch.kind !== undefined) opening.kind = patch.kind; + if (patch.offsetMm !== undefined) opening.offsetMm = Math.round(patch.offsetMm); + if (patch.widthMm !== undefined) opening.widthMm = Math.max(1, Math.round(patch.widthMm)); + if (patch.heightMm !== undefined) opening.heightMm = Math.max(1, Math.round(patch.heightMm)); + if (patch.sillMm !== undefined) opening.sillMm = Math.max(0, Math.round(patch.sillMm)); + } + }); +} + export function renameDocument(title: string): void { useStore.getState().mutate('Rename', (draft) => { draft.title = title; diff --git a/src/state/openings.test.ts b/src/state/openings.test.ts new file mode 100644 index 0000000..d12128a --- /dev/null +++ b/src/state/openings.test.ts @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { activeFloor, useStore } from './store'; +import { addCatalogItem, addOpening, addWallChain, deleteSelection, updateOpening } from './actions'; +import { OpeningError } from '../core/openings'; +import type { ItemDraft } from '../core/catalog'; + +const SHELF: ItemDraft = { + name: 'Shelf', + category: 'storage', + shape: 'rect', + widthMm: 900, + depthMm: 250, + heightMm: 300, + voidBelowMm: 0, +}; + +function floor() { + return activeFloor(useStore.getState()); +} + +/** One 5m wall running east from the origin. */ +function wall() { + const [w] = addWallChain([ + { x: 0, y: 0 }, + { x: 5000, y: 0 }, + ]); + return w!; +} + +beforeEach(() => { + useStore.getState().newDocument(); +}); + +describe('adding an opening', () => { + it('centres it on the clicked point along the wall', () => { + wall(); + const opening = addOpening({ x: 2500, y: 20 }, 'door', 100)!; + + expect(opening.offsetMm).toBe(2094); + expect(opening.widthMm).toBe(813); + expect(floor().openings).toHaveLength(1); + }); + + it('is one undo step', () => { + wall(); + const before = useStore.getState().past.length; + addOpening({ x: 2500, y: 0 }, 'door', 100); + + expect(useStore.getState().past).toHaveLength(before + 1); + useStore.getState().undo(); + expect(floor().openings).toHaveLength(0); + }); + + it('does nothing when the click is not on a wall', () => { + wall(); + expect(addOpening({ x: 2500, y: 4000 }, 'door', 100)).toBeNull(); + expect(floor().openings).toHaveLength(0); + expect(useStore.getState().past).toHaveLength(1); // the wall, and nothing else + }); + + it('picks the wall you are pointing at where two meet', () => { + // Butt joins overlap on the inside of every corner, so "the first one that + // matches" would put the door in whichever wall happened to be drawn first. + addWallChain([ + { x: 0, y: 0 }, + { x: 5000, y: 0 }, + { x: 5000, y: 5000 }, + ]); + const walls = floor().walls; + const opening = addOpening({ x: 5000, y: 2000 }, 'door', 100)!; + + expect(opening.wallId).toBe(walls[1]!.id); + }); + + it('refuses a wall too short to hold the opening, without touching the document', () => { + addWallChain([ + { x: 0, y: 0 }, + { x: 900, y: 0 }, + ]); + const before = useStore.getState().past.length; + + expect(() => addOpening({ x: 450, y: 0 }, 'sliding', 100)).toThrow(OpeningError); + expect(floor().openings).toHaveLength(0); + expect(useStore.getState().past).toHaveLength(before); + }); +}); + +describe('editing an opening', () => { + it('does not clamp a hand-typed offset back onto the wall', () => { + // Silently sliding somebody's front door to make it fit hides the mistake. + // Validation reports it and the geometry declines to build it. + const w = wall(); + const opening = addOpening({ x: 1000, y: 0 }, 'door', 100)!; + updateOpening(opening.id, { offsetMm: 4800 }); + + expect(floor().openings[0]!.offsetMm).toBe(4800); + expect(floor().openings[0]!.offsetMm + floor().openings[0]!.widthMm).toBeGreaterThan( + w.b.x - w.a.x, + ); + }); + + it('keeps a width of at least a millimetre', () => { + wall(); + const opening = addOpening({ x: 1000, y: 0 }, 'door', 100)!; + updateOpening(opening.id, { widthMm: 0 }); + expect(floor().openings[0]!.widthMm).toBe(1); + }); +}); + +describe('deleting', () => { + it('takes a wall’s openings with it', () => { + const w = wall(); + addOpening({ x: 1000, y: 0 }, 'door', 100); + addOpening({ x: 3000, y: 0 }, 'window', 100); + + deleteSelection([{ kind: 'wall', id: w.id }]); + expect(floor().openings).toHaveLength(0); + }); + + it('deletes an opening on its own, leaving the wall', () => { + const w = wall(); + const opening = addOpening({ x: 1000, y: 0 }, 'door', 100)!; + + deleteSelection([{ kind: 'opening', id: opening.id }]); + expect(floor().openings).toHaveLength(0); + expect(floor().walls.map((x) => x.id)).toEqual([w.id]); + }); + + it('re-seats a wall-mounted item when its wall is deleted', () => { + // A wall mount keeps its stored elevation whether or not the wall exists, so + // without this the shelf hangs in mid-air and nothing downstream notices. + const w = wall(); + const item = addCatalogItem(SHELF); + useStore.getState().mutate('seed', (draft) => { + draft.floors[0]!.placements.push({ + id: 'p1', + itemId: item.id, + floorId: draft.floors[0]!.id, + position: { x: 1000, y: 0 }, + rotation: 0, + mount: { kind: 'wall', wallId: w.id }, + elevation: 1400, + }); + }); + + deleteSelection([{ kind: 'wall', id: w.id }]); + + const placement = floor().placements[0]!; + expect(placement.mount).toEqual({ kind: 'floor' }); + expect(placement.elevation).toBe(0); + }); + + it('undoes back to the shelf still on its wall', () => { + const w = wall(); + const item = addCatalogItem(SHELF); + useStore.getState().mutate('seed', (draft) => { + draft.floors[0]!.placements.push({ + id: 'p1', + itemId: item.id, + floorId: draft.floors[0]!.id, + position: { x: 1000, y: 0 }, + rotation: 0, + mount: { kind: 'wall', wallId: w.id }, + elevation: 1400, + }); + }); + + deleteSelection([{ kind: 'wall', id: w.id }]); + useStore.getState().undo(); + + expect(floor().placements[0]!.mount).toEqual({ kind: 'wall', wallId: w.id }); + expect(floor().placements[0]!.elevation).toBe(1400); + }); + + it('drops an opening out of the selection once it is gone', () => { + wall(); + const opening = addOpening({ x: 1000, y: 0 }, 'door', 100)!; + useStore.getState().setSelection([{ kind: 'opening', id: opening.id }]); + + deleteSelection([{ kind: 'opening', id: opening.id }]); + expect(useStore.getState().selection).toEqual([]); + }); +}); diff --git a/src/state/store.ts b/src/state/store.ts index 6b5c30c..00201be 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -40,7 +40,7 @@ import type { EditMode, ViewMode } from '../core/modes'; import { bounds, type Bounds } from '../core/geometry/polygon'; import { wallOutline } from '../core/geometry/wall'; import type { Vec2 } from '../core/geometry/vec'; -import type { Mount } from '../core/document'; +import type { Mount, OpeningKind } from '../core/document'; import type { PlacementSnapHint } from '../core/placement-snap'; import type { AssetMap } from '../core/space-file'; import { adoptAssets, clearAssets } from './assets'; @@ -49,7 +49,7 @@ import { adoptAssets, clearAssets } from './assets'; // throws at runtime — which neither typecheck nor lint can see. enablePatches(); -export type SelectionKind = 'wall' | 'room' | 'placement'; +export type SelectionKind = 'wall' | 'room' | 'opening' | 'placement'; export type SelectionRef = { kind: SelectionKind; id: Id }; export type MutateOptions = { @@ -147,6 +147,8 @@ export type StoreState = { viewMode: ViewMode; tool: PlanTool; shapeKind: ShapeKind; + /** Which kind of opening the opening tool drops. */ + openingKind: OpeningKind; viewport: Viewport; stageSize: Size; selection: SelectionRef[]; @@ -187,6 +189,7 @@ export type StoreState = { setViewMode: (mode: ViewMode) => void; setTool: (tool: PlanTool) => void; setShapeKind: (kind: ShapeKind) => void; + setOpeningKind: (kind: OpeningKind) => void; setViewport: (viewport: Viewport) => void; setStageSize: (size: Size) => void; setSelection: (selection: SelectionRef[]) => void; @@ -224,6 +227,7 @@ function pruneSelection(doc: SpaceDocument, selection: SelectionRef[]): Selectio for (const floor of doc.floors) { for (const w of floor.walls) live.add(`wall:${w.id}`); for (const r of floor.rooms) live.add(`room:${r.id}`); + for (const o of floor.openings) live.add(`opening:${o.id}`); for (const p of floor.placements) live.add(`placement:${p.id}`); } const kept = selection.filter((s) => live.has(`${s.kind}:${s.id}`)); @@ -375,6 +379,7 @@ export const useStore = create((set, get) => ({ viewMode: 'plan2d', tool: 'select', shapeKind: 'rect', + openingKind: 'door', viewport: DEFAULT_VIEWPORT, stageSize: { width: 800, height: 600 }, selection: [], @@ -409,6 +414,7 @@ export const useStore = create((set, get) => ({ setTool: (tool) => set({ tool, draft: null, transform: null, placementTransform: null, measurement: null }), setShapeKind: (shapeKind) => set({ shapeKind, tool: 'shape', draft: null }), + setOpeningKind: (openingKind) => set({ openingKind, tool: 'opening', draft: null }), setViewport: (viewport) => set({ viewport }), setStageSize: (stageSize) => set({ stageSize }), setSelection: (selection) => set({ selection }), diff --git a/src/ui/ItemForm.tsx b/src/ui/ItemForm.tsx index fdee67c..552b868 100644 --- a/src/ui/ItemForm.tsx +++ b/src/ui/ItemForm.tsx @@ -1,7 +1,9 @@ import { useState } from 'react'; import { CATEGORIES, CATEGORY_DEFAULTS, CATEGORY_LABELS, type ItemDraft } from '../core/catalog'; import { SHAPE_KINDS, SHAPE_KIND_LABELS, type ShapeKind } from '../core/tools'; -import { formatLength, parseLength, type DisplayUnit } from '../core/units'; +import type { DisplayUnit } from '../core/units'; +import { parseLength } from '../core/units'; +import { LengthField } from './LengthField'; import type { Category, MountKind } from '../core/document'; type Props = { @@ -21,37 +23,6 @@ const EMPTY: ItemDraft = { shape: 'rect', }; -/** A length field: typed in any unit, held as text until it parses. */ -function LengthField({ - label, - value, - unit, - onChange, - hint, -}: { - label: string; - value: string; - unit: DisplayUnit; - onChange: (next: string) => void; - hint?: string; -}) { - const parsed = parseLength(value, unit); - return ( - - ); -} - /** * Manual entry — the primary path into the inventory (PLAN.md §7.1). * diff --git a/src/ui/LengthField.tsx b/src/ui/LengthField.tsx new file mode 100644 index 0000000..b6b6db9 --- /dev/null +++ b/src/ui/LengthField.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react'; +import { formatLength, parseLength, type DisplayUnit } from '../core/units'; + +/** + * A length typed in any unit, held as text until it parses. + * + * Text rather than a number input, because `30"`, `0.76m` and `2ft 6in` are all + * lengths and `` accepts none of them. The hint echoes back what + * was understood, which is the only defence against a units mistake that produces a + * plausible-looking 45m table. + * + * Controlled: the caller owns the text. Use this inside a form that validates on + * submit. + */ +export function LengthField({ + label, + value, + unit, + onChange, + hint, +}: { + label: string; + value: string; + unit: DisplayUnit; + onChange: (next: string) => void; + hint?: string; +}) { + const parsed = parseLength(value, unit); + return ( + + ); +} + +/** + * A length bound to a value in the document, committed on blur or Enter. + * + * Committing per keystroke would put one undo entry on the stack per character — the + * same problem the room-name field solves the same way. Typing is local; the document + * hears about it once. + * + * Escape reverts to the stored value, so a half-typed number can be abandoned without + * having to remember what it was. + */ +export function LengthInput({ + label, + valueMm, + unit, + onCommit, + testId, +}: { + label: string; + valueMm: number; + unit: DisplayUnit; + onCommit: (mm: number) => void; + testId?: string; +}) { + const stored = formatLength(valueMm, unit); + const [text, setText] = useState(stored); + + // Follow the document when it changes underneath — an undo, or a drag that moved + // the thing this field is describing. + useEffect(() => setText(stored), [stored]); + + const commit = () => { + const parsed = parseLength(text, unit); + if (parsed === null) { + setText(stored); // unreadable input reverts rather than becoming zero + return; + } + if (parsed !== valueMm) onCommit(parsed); + }; + + return ( + + ); +} diff --git a/src/ui/PropertiesPanel.tsx b/src/ui/PropertiesPanel.tsx index 6521463..c8c5bf1 100644 --- a/src/ui/PropertiesPanel.tsx +++ b/src/ui/PropertiesPanel.tsx @@ -7,6 +7,7 @@ import { activeFloor, useStore } from '../state/store'; import { deleteSelection, nudgeBackgroundRotation, + updateOpening, removeBackground, rotatePlacementBy, setBackgroundLocked, @@ -19,7 +20,16 @@ import { resolveElevation, roomAt, surfaceHeight } from '../core/placement'; import { ROTATION_STEP_DEG } from '../core/placement-snap'; import { validateFloor, type Issue } from '../core/validation'; import { backgroundExtentMm, isCalibrated } from '../core/calibration'; +import { + OPENING_KINDS, + OPENING_KIND_LABELS, + OPENING_DEFAULTS, + openingRange, + openingSpan, +} from '../core/openings'; import { hasAsset } from '../state/assets'; +import { LengthInput } from './LengthField'; +import type { OpeningKind } from '../core/document'; const MOUNT_LABELS: Record = { floor: 'On the floor', @@ -70,6 +80,9 @@ export function PropertiesPanel() { const wall = only?.kind === 'wall' ? floor.walls.find((w) => w.id === only.id) : undefined; const room = only?.kind === 'room' ? floor.rooms.find((r) => r.id === only.id) : undefined; + const opening = + only?.kind === 'opening' ? floor.openings.find((o) => o.id === only.id) : undefined; + const openingWall = opening ? floor.walls.find((w) => w.id === opening.wallId) : undefined; const placement = only?.kind === 'placement' ? floor.placements.find((p) => p.id === only.id) : undefined; const placementItem = placement ? findItem(doc, placement.itemId) : undefined; @@ -125,6 +138,81 @@ export function PropertiesPanel() {
) : null} + {opening ? ( +
+ + + updateOpening(opening.id, { widthMm: mm })} + testId="opening-width" + /> + updateOpening(opening.id, { heightMm: mm })} + /> + updateOpening(opening.id, { sillMm: mm })} + /> + {/* Position along the wall, measured from its first end — the coordinate + `offsetMm` is actually stored in, so the number here is the number in + the file. */} + updateOpening(opening.id, { offsetMm: mm })} + /> + + {/* Head height — sill plus height. The number that decides whether you + can walk under it, and the one that is easiest to get wrong by editing + the sill of a window without touching its height. */} + + +
+ ) : null} + {placement && placementItem ? (
diff --git a/src/ui/StatusBar.tsx b/src/ui/StatusBar.tsx index 7961a46..3d6dd30 100644 --- a/src/ui/StatusBar.tsx +++ b/src/ui/StatusBar.tsx @@ -37,6 +37,9 @@ export function StatusBar() { Rooms {floor.rooms.length} + + Openings {floor.openings.length} + Items {floor.placements.length} diff --git a/src/ui/ToolPalette.tsx b/src/ui/ToolPalette.tsx index a803ebe..64c41bf 100644 --- a/src/ui/ToolPalette.tsx +++ b/src/ui/ToolPalette.tsx @@ -1,5 +1,6 @@ import { useShallow } from 'zustand/react/shallow'; import { structureIsEditable } from '../core/modes'; +import { OPENING_KINDS, OPENING_KIND_LABELS } from '../core/openings'; import { PLAN_TOOLS, PLAN_TOOL_KEYS, @@ -17,10 +18,11 @@ import { useStore } from '../state/store'; * mode is why. */ export function ToolPalette() { - const { tool, shapeKind, editMode, gridEnabled, calibrating } = useStore( + const { tool, shapeKind, openingKind, editMode, gridEnabled, calibrating } = useStore( useShallow((s) => ({ tool: s.tool, shapeKind: s.shapeKind, + openingKind: s.openingKind, editMode: s.editMode, gridEnabled: s.gridEnabled, calibrating: s.calibrating, @@ -51,6 +53,23 @@ export function ToolPalette() { ))}
+ {tool === 'opening' && enabled ? ( +
+ {OPENING_KINDS.map((k) => ( + + ))} +
+ ) : null} + {tool === 'shape' && enabled ? (
{SHAPE_KINDS.map((k) => ( diff --git a/src/ui/plan/PlanStage.tsx b/src/ui/plan/PlanStage.tsx index 8565d0e..ef11345 100644 --- a/src/ui/plan/PlanStage.tsx +++ b/src/ui/plan/PlanStage.tsx @@ -15,6 +15,7 @@ import { PLAN_TOOL_KEYS, PLAN_TOOLS, type PlanTool } from '../../core/tools'; import { panBy, pxToMm, screenToDoc, zoomAt } from '../../core/viewport'; import { activeFloor, documentGridMm, useStore, type SelectionRef } from '../../state/store'; import { + addOpening, addPlacement, addRoomRect, addShapeRoom, @@ -28,6 +29,7 @@ import { rotatePlacementBy, } from '../../state/actions'; import { PlacementBlockedError } from '../../core/calibration'; +import { OpeningError } from '../../core/openings'; import { flaggedPlacements, validateFloor } from '../../core/validation'; import { ROTATION_STEP_DEG, snapPlacement } from '../../core/placement-snap'; import { BackgroundLayer } from './BackgroundLayer'; @@ -276,6 +278,37 @@ export function PlanStage() { } }; + /** + * Put an opening in the wall under the pointer. + * + * Deliberately uses the *raw* point rather than the snapped one. Grid snapping + * moves a click by up to half a cell, which is enough to push it off a 114mm wall + * entirely — and the position along the wall is decided by projection onto the + * centreline anyway, so the grid has nothing useful to contribute here. + */ + const dropOpening = (stage: Konva.Stage) => { + const state = useStore.getState(); + const raw = rawAt(stage); + if (!raw) return; + + try { + const opening = addOpening( + raw, + state.openingKind, + pxToMm(state.viewport, DEFAULT_SNAP_TOLERANCE_PX), + ); + if (opening) state.setSelection([{ kind: 'opening', id: opening.id }]); + } catch (err) { + // A wall too short to hold the opening. The message names the sizes, and the + // document is untouched — `createOpening` throws before the mutation. + if (err instanceof OpeningError) { + window.alert(err.message); + return; + } + throw err; + } + }; + // ---- pointer ----------------------------------------------------------- const onMouseDown = (e: Konva.KonvaEventObject) => { const stage = e.target.getStage(); @@ -307,6 +340,13 @@ export function PlanStage() { return; } + // An opening is a single click on a wall rather than a draft, so it is handled + // before the draw-tool switch below. A click that lands on no wall does nothing. + if (e.evt.button === 0 && tool === 'opening') { + dropOpening(stage); + return; + } + // Middle-drag always pans; so does a left-drag on empty canvas with Select, which // is the gesture most people reach for before finding a pan key. if (middle || (onEmptyCanvas && tool === 'select')) { diff --git a/src/ui/plan/StructureLayer.tsx b/src/ui/plan/StructureLayer.tsx index 2be84bc..81e4a56 100644 --- a/src/ui/plan/StructureLayer.tsx +++ b/src/ui/plan/StructureLayer.tsx @@ -104,7 +104,11 @@ export function StructureLayer({ onGrabEndpoint, }: Props) { const layerRef = useRef(null); - useSyncHitGraph(layerRef, interactive, floor.walls.length + floor.rooms.length); + useSyncHitGraph( + layerRef, + interactive, + floor.walls.length + floor.rooms.length + floor.openings.length, + ); // A wall being dragged renders from the preview; the document still has the // original until the pointer is released. @@ -190,15 +194,25 @@ export function StructureLayer({ const p = docToScreen(viewport, { x: quad[i]!, y: quad[i + 1]! }); screen.push(p.x, p.y); } + const selected = isSelected(selection, 'opening', opening.id); return ( { + // Above the wall it sits in, so clicking a doorway addresses the + // doorway. Same rule as everywhere else in this layer: a + // non-selectable click is not cancelled, so the armed tool still + // sees it — which is what lets the opening tool drop a second door + // on a wall that already has one. + if (!selectable) return; + e.cancelBubble = true; + onSelect({ kind: 'opening', id: opening.id }, e.evt.shiftKey); + }} /> ); })} From 5a00740884b5f82d4c81cbb1edc6f89f1f6d7985 Mon Sep 17 00:00:00 2001 From: Chintan Date: Wed, 2 Sep 2026 09:18:44 -0400 Subject: [PATCH 07/19] Phase 5: the space view, and walking through it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document has been three-dimensional since phase 1; this is where that stops being a claim. Walls, floors, ceilings and placements extrude from the same geometry the plan view draws and the collision engine tests — there is no second scene graph to keep in sync — and you can walk through the result with the arrow keys. Two decisions carried the phase. **The walk simulation does not live inside three.js.** `useFrame` is the obvious home and the wrong one: it makes walking something that only happens when a WebGL context exists, and makes traversal impossible to test without a GPU. It is a plain rAF loop over pure functions instead, so the camera is a consumer of the walker rather than its owner, the position readout keeps working when the canvas does not, and 31 unit tests walk through doorways with no renderer in sight. The canvas sits behind an error boundary; the HUD and the loop sit outside it. **Step-up is not a key — it is the bottom of the body interval.** A body of [0, 1800] collides with a 5mm rug, because [0,5] and [0,1800] genuinely overlap, and the walker is stopped dead by a carpet. Starting the interval a stride above the floor gives [200, 1800]: the rug passes underneath, the dresser at [0,810] still blocks, the bed frame at [250,600] still blocks, and a doorway lintel at [2032,2438] still lets you through. One number, and every case falls out of it — the same shape of fix as `voidBelowMm`. Space raises the clearance for a deliberate step; C lowers the top so you can duck under a shelf. Arrow keys **turn** rather than strafe, departing from PLAN.md §10.2. The requirement is arrow-key traversal, and with turning bound only to Q/E someone using the arrows alone can never change direction. A/D strafe instead. Wall and ceiling mounts become reachable, which is what the item form was holding those options back for. An item that says it is wall-mounted and is dropped where there is no wall lands on the floor **and says so** — never on a wall the app guessed, because moving that wall would then move the item. Also here: a walker who has been built around can walk back out (every candidate move is blocked, and being frozen is worse than being briefly inside a wall); a ceiling drop that resolves below the floor is clamped to it rather than sunk out of sight; and saved views encode through a pinned key set with a total decode, so a record this version did not write cannot put a NaN into a matrix and blank the screen. three and drei are code-split like pdfjs — 888kB that most sessions never load. Main chunk 661kB / 209kB gzip, up 14kB from phase 4. Deferred and stated rather than claimed: instancing (§10.4's 500-at-60fps is unmeasured; one mesh per solid today) and a contact-normal collision resolver (moves retry per axis, so diagonal walls slide stickily). 463 unit tests, 64 e2e. --- PLAN.md | 29 ++- README.md | 27 ++- e2e/inventory.spec.ts | 9 +- e2e/shell.spec.ts | 9 +- e2e/space.spec.ts | 231 +++++++++++++++++++ src/core/geometry/collision.ts | 31 ++- src/core/geometry/vec.ts | 17 ++ src/core/geometry/wall.ts | 17 +- src/core/scene.test.ts | 197 ++++++++++++++++ src/core/scene.ts | 209 +++++++++++++++++ src/core/views.test.ts | 80 +++++++ src/core/views.ts | 95 ++++++++ src/core/walk.test.ts | 409 +++++++++++++++++++++++++++++++++ src/core/walk.ts | 330 ++++++++++++++++++++++++++ src/state/actions.ts | 142 +++++++++++- src/state/space.test.ts | 240 +++++++++++++++++++ src/state/store.ts | 90 +++++++- src/styles/global.css | 75 +++++- src/ui/InventoryPanel.tsx | 18 +- src/ui/ItemForm.tsx | 15 +- src/ui/PropertiesPanel.tsx | 72 ++++-- src/ui/TopBar.tsx | 2 +- src/ui/Viewport.tsx | 29 ++- src/ui/plan/PlanStage.tsx | 6 +- src/ui/space/CameraRig.tsx | 124 ++++++++++ src/ui/space/SpaceHud.tsx | 163 +++++++++++++ src/ui/space/SpaceScene.tsx | 88 +++++++ src/ui/space/SpaceView.tsx | 154 +++++++++++++ src/ui/space/geometry.ts | 64 ++++++ src/ui/space/scene-cache.ts | 38 +++ src/ui/space/useWalkLoop.ts | 151 ++++++++++++ 31 files changed, 3090 insertions(+), 71 deletions(-) create mode 100644 e2e/space.spec.ts create mode 100644 src/core/scene.test.ts create mode 100644 src/core/scene.ts create mode 100644 src/core/views.test.ts create mode 100644 src/core/views.ts create mode 100644 src/core/walk.test.ts create mode 100644 src/core/walk.ts create mode 100644 src/state/space.test.ts create mode 100644 src/ui/space/CameraRig.tsx create mode 100644 src/ui/space/SpaceHud.tsx create mode 100644 src/ui/space/SpaceScene.tsx create mode 100644 src/ui/space/SpaceView.tsx create mode 100644 src/ui/space/geometry.ts create mode 100644 src/ui/space/scene-cache.ts create mode 100644 src/ui/space/useWalkLoop.ts diff --git a/PLAN.md b/PLAN.md index 76dd217..e8d0f14 100644 --- a/PLAN.md +++ b/PLAN.md @@ -635,14 +635,33 @@ Two camera modes, toggled with `Tab`: ``` ↑ / W forward eye height 1650mm, follows floor datum ↓ / S back collision against walls + object volumes -← / → strafe capsule radius 250mm +← / → turn capsule radius 250mm A / D strafe -Q / E turn mouse look via PointerLockControls +Q / E turn drag-to-look for pitch and fine aim Shift run (2×) -Space step up — clears obstacles under 450mm (sitting height) -C crouch — eye height 1100mm, for looking under things +Space step up — raises the body interval's floor to 450mm +C crouch — eye height 1100mm, body top 1250mm ``` +**Built, with two deliberate departures.** The arrow keys **turn** rather than strafe: +the requirement is arrow-key traversal, and with turning bound only to `Q`/`E` someone +using the arrows alone can never change direction. `A`/`D` strafe instead. And look is +**drag-to-look**, not `PointerLockControls` — pointer lock takes over the cursor, +prompts, and cannot be driven by a test, and click-to-select needs the pointer anyway. + +**Step up is not a key that teleports you.** It is the *bottom of the body interval*: + +``` +body = [ feet + stepClearance , feet + standingHeight ] // [200, 1800] normally +``` + +With a body of `[0, 1800]` a 5mm rug is a collision — `[0,5]` and `[0,1800]` genuinely +overlap — and the walker is stopped dead by a carpet. Starting the interval at a +stride's clearance is what makes every case fall out of one number, the same shape of +fix as `voidBelowMm`. `Space` raises the clearance to 450 for a deliberate step; `C` +lowers the *top* to 1250 so you can duck. Ground height then follows whatever is +underfoot, rising at most a stride and falling as far as there is to fall. + Collision is 2D circle-vs-polygon against every object whose `solidSpan` overlaps the walker's body interval `[0, 1800]` — which is exactly why the vertical model has to be right. You walk *over* a rug, *under* a wall-mounted shelf, and *into* a dresser, with no @@ -700,7 +719,7 @@ isolated so neither blocks the core editor. | **2** | **Plan editor** — Konva stage, pan/zoom, wall/room/shape tools, dimension tool, grid + snapping, selection and transform (wall endpoint and body drag), mode toggle, undo/redo. Draw a floor plan by hand and save it. Room *reposition* is deliberately not included: rooms and their walls are separate entities, and moving one without the other desynchronises them — redraw instead until phase 8 relates them. | | **3** | **Import + calibration** — PDF via pdfjs (dynamically imported, so the 437kB renderer stays off first paint), image import, the blocking calibration gate, background transform/opacity/lock, tracing over a real plan. An uncalibrated background is shown at a nominal 6m width so the reference line is drawable *and* so the transform is invertible before a real scale exists; calibrating rescales about `refA` so the point the user anchored on does not move, and `transform.position` stays a float because rounding it would drift the anchor on every recalibration. Import deliberately does not re-fit the viewport. Deferred: thumbnails and File System Access (phase 9), vector path extraction (v2). | | **4** | **Inventory** — catalog/placement split, manual entry, preset library, quantity tracking, placement onto the plan with wall snap, surface snap, rotation, and 3D overlap warnings. **First genuinely useful build.** Wall snap seats the footprint's *back edge* (local −y) on the wall's near face and rotates to match, never the centre on the centreline. The calibration gate stops being decorative here: `addPlacement` throws `PlacementBlockedError` carrying the same sentence the validation panel shows, and the Place button is disabled rather than offered-and-refused. Deleting a placement re-seats anything surface-mounted on it, so the document never references a host that is gone. Headroom arrives early — `exceedsHeadroom` already existed — but clearance zones (7) and door swing (6) are still out. | -| **5** | **3D space view** — extrusion from document geometry, orbit mode, walk mode with arrow-key traversal and collision, mount types (floor/surface/wall/ceiling), elevation editing, headroom checks, saved views. **Includes opening *geometry*** — wall-hosted openings and the holes they cut in the extruded walls, without swing. A sealed walker who cannot leave the first room does not demonstrate traversal, so the doorways have to exist here. | +| **5** | **3D space view** — extrusion from document geometry, orbit mode, walk mode with arrow-key traversal and collision, mount types (floor/surface/wall/ceiling), elevation editing, headroom checks, saved views. **Includes opening *geometry*** — wall-hosted openings and the holes they cut in the extruded walls, without swing. A sealed walker who cannot leave the first room does not demonstrate traversal, so the doorways have to exist here. An opening cuts a wall in *elevation*, not in plan, so `ExtrudeGeometry` holes were never the answer: `wallSegments` **splits** the wall into the solid boxes that remain — flank, sill wall, lintel, flank — which needs no CSG and hands the same list to the renderer, the walker and the validation panel. A doorway is passable because the only solid above it starts at 2032mm, with no "is this a door" check anywhere in traversal. The walk simulation deliberately lives *outside* three.js: a plain rAF loop over pure functions, so the camera consumes the walker rather than owning it, the position readout survives a browser with no WebGL, and traversal is testable without a GPU. Deferred and stated rather than claimed: **instancing** (§10.4's 500-at-60fps target is unmeasured — one mesh per solid today), and a real contact-normal collision resolver (moves are retried per axis, so diagonal walls slide stickily). | | **6** | **Openings, complete** — swing arcs in 2D, hinged door panels and window panes in 3D, sliding/pocket/cased variants, swing-vs-object clearance. | | **7** | **Clearance and circulation** — clearance zones on catalog items, the standard preset library, walkway width probe, consolidated validation panel across overlap/headroom/clearance/swing. | | **8** | **Multi-room and multi-floor** — room detection and areas, per-room ceiling heights, floor stacking, ghost underlay, 3D floor toggles. | diff --git a/README.md b/README.md index d003cb6..60e448a 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,29 @@ elevation, so a rug under a table is not a collision, a wall shelf at 1400mm doe block a desk at 750mm, and a 2100mm bookcase under a 2050mm soffit is a violation the app catches. -> **Status: phase 2.** The plan editor works: draw walls, rooms and shapes by hand -> with grid/angle/endpoint snapping, select and delete, undo/redo, and save to a -> `.space` file another machine can open. Floor plan import (phase 3), the inventory -> (phase 4) and the 3D view (phase 5) are not built yet. See [PLAN.md](./PLAN.md). +> **Status: phase 5.** The whole loop works end to end: import a PDF or image and +> calibrate it, trace walls and rooms, cut doors and windows, build an inventory, +> place it with wall and surface snapping, then switch to the space view and walk +> through the result with the arrow keys. Door swing (phase 6), clearance zones +> (phase 7) and multi-floor (phase 8) are not built yet. See [PLAN.md](./PLAN.md). + +## Walking around + +Press **Space** in the view switcher, then **Tab** to cycle orbit → walk → fly. + +| Key | Does | +|-----|------| +| `↑` `↓` / `W` `S` | walk forward and back | +| `←` `→` / `Q` `E` | turn | +| `A` `D` | strafe | +| `Shift` | run | +| `C` | crouch — duck under a wall shelf | +| `Space` | step up — clear something knee-high | +| `R` `F` | rise and fall, in fly mode | +| drag | look around | + +Collision is genuinely three-dimensional: you walk *over* a rug, *under* a doorway's +lintel, and *into* a dresser, with no special case for any of them. ## Quick start diff --git a/e2e/inventory.spec.ts b/e2e/inventory.spec.ts index cbb2702..8984efa 100644 --- a/e2e/inventory.spec.ts +++ b/e2e/inventory.spec.ts @@ -52,11 +52,10 @@ test.describe('the catalog', () => { await page.getByLabel('Width').fill('1.8m'); await page.getByLabel('Depth').fill('30"'); await page.getByLabel('Height').fill('900'); - // Wall and ceiling mounts are stored but not reachable when placing yet, so the - // form does not offer them — the same rule as the Place button. - // Asserted on the attribute: Playwright reports an
{leaf.style === 'hinged' ? ( - + setOpeningSwing(opening.id, { angleDeg: deg })} + testId="swing-angle" + /> ) : null} ); From 69a991577e69b95c7926d10aed97218421145e97 Mon Sep 17 00:00:00 2001 From: Chintan Date: Wed, 2 Sep 2026 20:23:43 -0400 Subject: [PATCH 11/19] Phase 7: what has to stay clear, and whether a person fits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two checks that sound alike and are not. A clearance zone asks whether a drawer opens; the walkway probe asks whether a person gets past. They disagree about walls, and that disagreement is the design. Zones do not test against walls. Wall snap seats an item's back edge *on* the wall face, so a `back` zone tested against walls fires on every chair pushed against one — the default outcome of using the snap, not a corner case. That is the rule phase 6 settled for door swings, and the probe is what answers the other question, with walls very much included. Two more that would have been wrong if skipped: - **The step-over threshold belongs to the intruder, not the zone.** A rug in front of a dresser is not a blocked drawer, but lifting the zone's floor to fix that exempts a band of space and hides a 90mm shoe rack sitting in it. What makes the rug irrelevant is that you step over it. Same shape as `voidBelowMm`. - **A zone goes through `toWorld`**, the transform the outline itself uses, so rotation and flipping come out right by construction. Flipping really does move a left-hand drawer to the other side. ## The probe height in PLAN §9.3 was wrong It specified a single ray at 900mm — "hip height, where you actually squeeze past furniture". A standard sofa back is 840mm, so that ray passes straight over the one piece of furniture the spec names and reports a clear walkway through the middle of the couch. A dining table at 760 and a dresser at 810 go the same way; any single height is either low enough to catch table legs or high enough to miss the sofa. So the probe asks what traversal already asks: is anything solid inside `[STEP_OVER_MM, STAND_HEIGHT_MM]`, imported from `walk.ts` rather than restated. The walker's answer about a doorway and the plan's answer about a gap can now never disagree. Found by a test asserting a sofa was an obstruction and getting 4 walls back; PLAN.md records the correction rather than quietly matching the code. The route is editor state, like a measurement — a question asked of the plan, not part of it. Storing the *path* rather than the number is what makes it worth having: move the sofa and the answer follows. It survives a tool change, and the tool stays live in furnish mode, because "can I still get past?" is a question you ask while pushing furniture around. That last one needed the palette's `t !== 'select'` gate widened and the placement layer's `selectable` narrowed to Select, or a click meant for the route selected the sofa instead. Zones draw on the selected item only. A warning that says "the bookcase blocks the drawer pull" is an argument and the hatched rectangle is the evidence — but six dining chairs with pull-out zones would carpet the floor in hatching and say nothing. The panel groups by what you would do about a problem rather than by the issue enum, in one list, keeping `validateFloor`'s blocking-first order rather than forming a second opinion about severity in the component least qualified to have one. Stated rather than claimed: the medial-axis navmesh stays out of scope, so the probe reports the narrowest gap *at a sample* — vertices plus every 100mm — not the true infimum. 555 unit tests, 85 e2e, typecheck, lint, build. --- PLAN.md | 52 ++++++- e2e/clearance.spec.ts | 190 +++++++++++++++++++++++++ src/core/catalog.test.ts | 41 ++++++ src/core/catalog.ts | 10 +- src/core/clearance.test.ts | 242 +++++++++++++++++++++++++++++++ src/core/clearance.ts | 238 +++++++++++++++++++++++++++++++ src/core/placement.ts | 25 +++- src/core/presets.ts | 31 ++-- src/core/tools.ts | 9 +- src/core/validation.test.ts | 46 ++++++ src/core/validation.ts | 23 ++- src/core/walkway.test.ts | 223 +++++++++++++++++++++++++++++ src/core/walkway.ts | 253 +++++++++++++++++++++++++++++++++ src/state/store.test.ts | 32 +++++ src/state/store.ts | 17 +++ src/styles/global.css | 26 ++++ src/ui/PropertiesPanel.tsx | 163 +++++++++++++++++---- src/ui/ToolPalette.tsx | 13 +- src/ui/plan/DraftLayer.tsx | 55 +++++++ src/ui/plan/PlacementLayer.tsx | 33 +++++ src/ui/plan/PlanStage.tsx | 62 +++++++- src/ui/plan/theme.ts | 7 + src/ui/useWalkwayProbe.ts | 30 ++++ 23 files changed, 1769 insertions(+), 52 deletions(-) create mode 100644 e2e/clearance.spec.ts create mode 100644 src/core/clearance.test.ts create mode 100644 src/core/clearance.ts create mode 100644 src/core/walkway.test.ts create mode 100644 src/core/walkway.ts create mode 100644 src/ui/useWalkwayProbe.ts diff --git a/PLAN.md b/PLAN.md index 0883dca..41e6a4b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -608,10 +608,54 @@ vertical extent is a violation, listed with its reason. Presets ship with the st library: 900mm in front of dressers, 1067mm behind dining chairs, 1200mm at appliance doors. +Three things fell out of building it that are worth stating. + +**Walls are not obstructions for a zone.** Wall snap seats an item's back edge *on* +the wall face, so a `back` zone tested against walls fires on every chair pushed +against one — the default outcome of using the snap, not a corner case. This is the +same rule phase 6 settled for door swings. The question "is there room to get past +this" is a different question, and the walkway probe below is what answers it, with +walls very much included. Two checks, deliberately. + +**The step-over threshold belongs to the intruder, not to the zone.** A rug in front +of a dresser is not a blocked drawer. The fix is *not* to lift the zone's floor — +that exempts a band of space and hides a 90mm shoe rack sitting in it. What makes the +rug irrelevant is that you step over it, so the zone runs from the floor and anything +whose solid top is below `CLEARANCE_STEP_OVER_MM` is skipped: the same shape of rule +as `voidBelowMm` and the walker's `STEP_OVER_MM`. + +**A zone is a rectangle off the local bounding box.** "Attached to a footprint edge" +is the spec, and a bounding-box edge is the only edge a circular table has. It goes +through `toWorld` — the transform the outline itself uses — so rotation and flipping +come out right by construction rather than by two formulas agreeing. + **Walkway width probe.** The user draws a path polyline through the space; the app -reports the narrowest gap along it, measured at a configurable height (default 900mm — -hip height, where you actually squeeze past furniture, not floor level where a sofa base -is narrower than its arms). Flags anything below the threshold (default 762mm / 30"). +reports the narrowest gap along it. Flags anything below the threshold (762mm / 30"). + +**Measured against a body, not at a height.** This spec originally said 900mm — "hip +height, where you actually squeeze past furniture, not floor level where a sofa base +is narrower than its arms". The floor half of that is right and the fix is not: **a +standard sofa back is 840mm**, so a ray at 900 passes straight over this section's own +example and reports a clear walkway through the middle of the couch. A dining table at +760 and a dresser at 810 go the same way, and any single height is either low enough +to catch table legs or high enough to miss the furniture. So the probe asks what +traversal already asks — is anything solid inside `[STEP_OVER_MM, STAND_HEIGHT_MM]`, +imported from `walk.ts` rather than restated, so there is one definition of what a +body takes up. The walker's answer about a doorway and the plan's answer about a gap +can then never disagree. + +**The route is editor state, not document state.** It is a question asked of the +plan, like a measurement, not a part of it — nobody else opening the file drew it. +Storing the *path* rather than the answer is what makes it worth having: move the +sofa 100mm and the number moves with it. It survives a tool change, unlike a +measurement, and the tool stays available in furnish mode, because "can I still get +past?" is a question you ask while pushing furniture around. + +**What the sampling can miss.** The path is sampled at its vertices and every 100mm +between them, and each sample casts one ray to each side. The answer is the narrowest +gap *at a sample*, not the true infimum: a table leg between two samples is stepped +over, and a diagonal pinch is measured square to the path. Vertices are always +sampled because a path turns where a room pinches. Stated rather than implied. Full medial-axis navmesh analysis of the free space is explicitly out of scope. The probe answers the real question — "can I get from the door to the couch?" — at a @@ -748,7 +792,7 @@ isolated so neither blocks the core editor. | **4** | **Inventory** — catalog/placement split, manual entry, preset library, quantity tracking, placement onto the plan with wall snap, surface snap, rotation, and 3D overlap warnings. **First genuinely useful build.** Wall snap seats the footprint's *back edge* (local −y) on the wall's near face and rotates to match, never the centre on the centreline. The calibration gate stops being decorative here: `addPlacement` throws `PlacementBlockedError` carrying the same sentence the validation panel shows, and the Place button is disabled rather than offered-and-refused. Deleting a placement re-seats anything surface-mounted on it, so the document never references a host that is gone. Headroom arrives early — `exceedsHeadroom` already existed — but clearance zones (7) and door swing (6) are still out. | | **5** | **3D space view** — extrusion from document geometry, orbit mode, walk mode with arrow-key traversal and collision, mount types (floor/surface/wall/ceiling), elevation editing, headroom checks, saved views. **Includes opening *geometry*** — wall-hosted openings and the holes they cut in the extruded walls, without swing. A sealed walker who cannot leave the first room does not demonstrate traversal, so the doorways have to exist here. An opening cuts a wall in *elevation*, not in plan, so `ExtrudeGeometry` holes were never the answer: `wallSegments` **splits** the wall into the solid boxes that remain — flank, sill wall, lintel, flank — which needs no CSG and hands the same list to the renderer, the walker and the validation panel. A doorway is passable because the only solid above it starts at 2032mm, with no "is this a door" check anywhere in traversal. The walk simulation deliberately lives *outside* three.js: a plain rAF loop over pure functions, so the camera consumes the walker rather than owning it, the position readout survives a browser with no WebGL, and traversal is testable without a GPU. Deferred and stated rather than claimed: **instancing** (§10.4's 500-at-60fps target is unmeasured — one mesh per solid today), and a real contact-normal collision resolver (moves are retried per axis, so diagonal walls slide stickily). | | **6** | **Openings, complete** — swing arcs in 2D, hinged door panels and window panes in 3D, sliding/pocket/cased variants, swing-vs-object clearance. The five kinds behave in four different ways, and the difference is the reason the kinds exist: hinged doors need their swept sector clear, sliders need the wall they park over clear, pocket doors need **nothing** in the room clear and instead need a cavity that can exist, and cased openings and windows need nothing at all. The swept sector is computed once and serves three consumers — its boundary *is* the 2D door symbol (closed leaf, arc, open leaf), it is the clearance polygon, and it positions the 3D panel — so the drawing and the check cannot disagree about where the door goes. Hanging the leaf is done with **flip buttons, not selects**, because there is no honest label for the two sides of a wall; the arc in the drawing is what makes the choice legible. The angle field holds its text locally and commits on blur or Enter, like the room name and every length field: writing per keystroke to a value that is *clamped* means the `1` of `135` is stored as `15` and the rest of the number is typed against that, so no angle whose first digit falls below the floor can be entered at all. `fill()` in a test never sees it, because it delivers the whole value in one change event. Deferred and stated rather than claimed: **windows do not open** (a casement sash would swing like a door and is not built), and the 3D layer-toggle gate on a door leaf is covered by an exhaustive unit test over `refIsEditable` rather than end to end — in the orbit view a leaf is a slab a few pixels wide seen edge-on, and walk mode does not take selection clicks at all, so hunting for it with a grid of clicks would test where the camera happens to sit. | -| **7** | **Clearance and circulation** — clearance zones on catalog items, the standard preset library, walkway width probe, consolidated validation panel across overlap/headroom/clearance/swing. | +| **7** | **Clearance and circulation** — clearance zones on catalog items, the standard preset library, walkway width probe, consolidated validation panel across overlap/headroom/clearance/swing. Two checks that sound alike and are not: a zone asks whether a drawer opens, the probe asks whether a person fits, and they differ on whether walls count (see §9.3 — they do not for a zone, they do for the probe). Zones are drawn on the selected item only, for the reason the swing arc is drawn: a warning that says "the bookcase blocks the drawer pull" is an argument and the hatched rectangle is the evidence — but six dining chairs with pull-out zones would carpet the floor in hatching and say nothing. The probe stores the *route*, not the number, so it re-answers as furniture moves; the tool stays live in furnish mode for the same reason. The panel groups by what you would do about a problem rather than by the issue enum, keeping `validateFloor`'s blocking-first order rather than forming a second opinion about severity in the component least qualified to have one. Deferred and stated rather than claimed: **the 900mm probe height in §9.3 was wrong and is now a body interval** — a sofa back is 840mm, so the specified ray passed over the one piece of furniture the spec named; and the full medial-axis navmesh remains explicitly out of scope, so the probe reports the narrowest gap *at a sample*, not the true infimum. | | **8** | **Multi-room and multi-floor** — room detection and areas, per-room ceiling heights, floor stacking, ghost underlay, 3D floor toggles. | | **9** | **Polish and portability** — File System Access save-in-place, IndexedDB autosave and recovery, thumbnails, product URL lookup endpoint + confirm dialog, export/import e2e, migration tests. | diff --git a/e2e/clearance.spec.ts b/e2e/clearance.spec.ts new file mode 100644 index 0000000..bb6a7b3 --- /dev/null +++ b/e2e/clearance.spec.ts @@ -0,0 +1,190 @@ +import { expect, test, type Page } from '@playwright/test'; +import { clickAt, dragBetween, selectTool } from './coords'; + +/** + * Clearance zones and the walkway probe — PLAN.md §9.3. + * + * The screen-to-document mapping comes from `./coords`; nothing here zooms, pans or + * fits, so it holds throughout. + */ + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('plan-stage')).toBeVisible(); +}); + +/** A 5m × 4m room, whose north wall runs along y = 0. */ +async function drawRoom(page: Page) { + const stage = page.getByTestId('plan-stage'); + await selectTool(page, 'Room'); + await dragBetween(page, stage, { x: 0, y: 0 }, { x: 5000, y: 4000 }); + await expect(page.getByTestId('count-walls')).toContainText('4'); + return stage; +} + +async function addPreset(page: Page, label: string) { + await page.getByLabel('Add from the preset library').selectOption({ label }); + await expect(page.getByTestId('item-list')).toContainText(label.split(' — ')[1]!); +} + +/** Add a preset and drop one at `at`. */ +async function placePreset(page: Page, label: string, at: { x: number; y: number }) { + const stage = page.getByTestId('plan-stage'); + await addPreset(page, label); + await page + .getByRole('button', { name: 'Place', exact: true }) + .last() + .click(); + await clickAt(page, stage, at); + // The arm is a repeat-drop mode — "click the plan to place it, Esc to stop" — so + // without this the next click anywhere drops a second one. + await page.keyboard.press('Escape'); +} + +test.describe('clearance zones', () => { + test('reports a bookcase standing in a dresser drawer pull', async ({ page }) => { + await drawRoom(page); + // Against the north wall, so the drawers face into the room. + await placePreset(page, 'Storage — Dresser', { x: 2500, y: 300 }); + await placePreset(page, 'Storage — Bookcase', { x: 2500, y: 900 }); + + const issues = page.getByTestId('issue-list'); + await expect(issues).toContainText('blocks the drawer pull clearance in front of Dresser'); + }); + + test('stops reporting once the bookcase is somewhere else', async ({ page }) => { + await drawRoom(page); + await placePreset(page, 'Storage — Dresser', { x: 2500, y: 300 }); + await placePreset(page, 'Storage — Bookcase', { x: 2500, y: 3500 }); + + // Nothing left to report at all, so the list is gone rather than empty. + await expect(page.getByTestId('no-issues')).toBeVisible(); + }); + + test('says nothing about a rug in front of the drawers', async ({ page }) => { + // A rug is 10mm; you step over it. The threshold is a property of the thing in + // the way, not of the space, so a taller object in the same spot still reports. + await drawRoom(page); + await placePreset(page, 'Storage — Dresser', { x: 2500, y: 300 }); + // Clear of the dresser's own footprint, with its near edge well inside the + // 900mm the drawers need. + await placePreset(page, 'Other — Rug (5 x 8 ft)', { x: 2500, y: 1825 }); + + await expect(page.getByTestId('no-issues')).toBeVisible(); + }); + + test('ships the standard library on the presets that need it', async ({ page }) => { + // A dishwasher door needs 1200mm; the range beside it is inside that. + await drawRoom(page); + await placePreset(page, 'Appliances — Dishwasher', { x: 2500, y: 300 }); + await placePreset(page, 'Appliances — Range', { x: 2500, y: 1000 }); + + await expect(page.getByTestId('issue-list')).toContainText('dishwasher door'); + }); +}); + +test.describe('the walkway probe', () => { + /** Draw a route through the room and finish it with Enter. */ + async function probe(page: Page, points: { x: number; y: number }[]) { + const stage = page.getByTestId('plan-stage'); + await selectTool(page, 'Walkway'); + for (const p of points) await clickAt(page, stage, p); + await page.keyboard.press('Enter'); + } + + test('measures the narrowest point along a route', async ({ page }) => { + await drawRoom(page); + await probe(page, [ + { x: 500, y: 2000 }, + { x: 4500, y: 2000 }, + ]); + + // A clear 4m room: the gap is the room, wall face to wall face — 4000 less two + // half-thicknesses of 114 is 3886mm, which reads as 12' 9". + await expect(page.getByTestId('walkway-readout')).toContainText(`12' 9"`); + await expect(page.getByTestId('walkway-warning')).toHaveCount(0); + }); + + test('warns when the route pinches below 30 inches', async ({ page }) => { + await drawRoom(page); + // A sofa 910 deep centred 700 off the south wall leaves 245mm behind it. + await placePreset(page, 'Seating — Sofa (3-seat)', { x: 2500, y: 3243 }); + + await probe(page, [ + { x: 1000, y: 3800 }, + { x: 4000, y: 3800 }, + ]); + + await expect(page.getByTestId('walkway-warning')).toBeVisible(); + await expect(page.getByTestId('walkway-warning')).toContainText('Narrower than'); + }); + + test('re-answers when the furniture moves, because it stores the route not the number', async ({ + page, + }) => { + await drawRoom(page); + await placePreset(page, 'Seating — Sofa (3-seat)', { x: 2500, y: 3243 }); + await probe(page, [ + { x: 1000, y: 3800 }, + { x: 4000, y: 3800 }, + ]); + await expect(page.getByTestId('walkway-warning')).toBeVisible(); + + // Drag the sofa off the route. The probe is derived from the document, so the + // answer follows without redrawing anything. + const stage = page.getByTestId('plan-stage'); + await page.getByRole('button', { name: 'Arrange furniture', exact: true }).click(); + await dragBetween(page, stage, { x: 2500, y: 3243 }, { x: 2500, y: 1500 }); + + await expect(page.getByTestId('walkway-warning')).toHaveCount(0); + }); + + test('survives a tool change, unlike a measurement', async ({ page }) => { + // It is a route you work against while moving furniture. Losing it every time + // you pick up Select would make it useless for the one job it has. + await drawRoom(page); + await probe(page, [ + { x: 500, y: 2000 }, + { x: 4500, y: 2000 }, + ]); + await expect(page.getByTestId('walkway-readout')).toBeVisible(); + + await selectTool(page, 'Select'); + await expect(page.getByTestId('walkway-readout')).toBeVisible(); + }); + + test('clears on request', async ({ page }) => { + await drawRoom(page); + await probe(page, [ + { x: 500, y: 2000 }, + { x: 4500, y: 2000 }, + ]); + + await page.getByTestId('clear-walkway').click(); + await expect(page.getByTestId('walkway-empty')).toBeVisible(); + }); + + test('keeps nothing from a single click', async ({ page }) => { + await drawRoom(page); + await probe(page, [{ x: 500, y: 2000 }]); + + await expect(page.getByTestId('walkway-empty')).toBeVisible(); + }); +}); + +test.describe('the validation panel', () => { + test('heads each kind of check and counts the whole list', async ({ page }) => { + await drawRoom(page); + await placePreset(page, 'Storage — Dresser', { x: 2500, y: 300 }); + // An armchair is too deep to sit *on* the dresser, so it lands beside it — half + // inside its footprint and squarely in the space its drawers need. + await placePreset(page, 'Seating — Armchair', { x: 2500, y: 700 }); + + // Two distinct problems with one cause, and the panel says both, under their + // own headings. + const issues = page.getByTestId('issue-list'); + await expect(issues).toContainText('Clearance'); + await expect(issues).toContainText('Overlaps'); + await expect(page.getByTestId('issue-summary')).toContainText('issues'); + }); +}); diff --git a/src/core/catalog.test.ts b/src/core/catalog.test.ts index 49c9bb9..3951248 100644 --- a/src/core/catalog.test.ts +++ b/src/core/catalog.test.ts @@ -148,3 +148,44 @@ describe('the preset library', () => { expect(new Set(PRESETS.map((p) => p.key)).size).toBe(PRESETS.length); }); }); + +describe('clearance zones on an item', () => { + const ZONE = { edge: 'front' as const, depthMm: 900, reason: 'drawer pull' }; + + function draft(over: Partial = {}): ItemDraft { + return { + name: 'Dresser', + category: 'storage', + shape: 'rect', + widthMm: 1500, + depthMm: 500, + heightMm: 810, + ...over, + }; + } + + it('carries them onto the item', () => { + const item = createCatalogItem(draft({ clearances: [ZONE] }), 'i1'); + expect(item.clearances).toEqual([ZONE]); + }); + + it('leaves the field off entirely when there are none', () => { + // An empty array and an absent field mean the same thing; writing both into + // files would make them diff differently for no reason. + expect(createCatalogItem(draft(), 'i1').clearances).toBeUndefined(); + expect(createCatalogItem(draft({ clearances: [] }), 'i2').clearances).toBeUndefined(); + }); + + it('copies the zones rather than aliasing what it was handed', () => { + const zones = [{ ...ZONE }]; + const item = createCatalogItem(draft({ clearances: zones }), 'i1'); + zones[0]!.depthMm = 1; + + expect(item.clearances![0]!.depthMm).toBe(900); + }); + + it('round-trips through the edit form', () => { + const item = createCatalogItem(draft({ clearances: [ZONE] }), 'i1'); + expect(draftFromItem(item).clearances).toEqual([ZONE]); + }); +}); diff --git a/src/core/catalog.ts b/src/core/catalog.ts index bbfbf0b..4fbdc01 100644 --- a/src/core/catalog.ts +++ b/src/core/catalog.ts @@ -15,7 +15,7 @@ * Pure — no DOM, no store. */ -import type { Category, CatalogItem, Id, MountKind } from './document'; +import type { Category, CatalogItem, ClearanceZone, Id, MountKind } from './document'; import { makeFootprint, type Footprint } from './geometry/footprint'; import type { FootprintGenerator } from './geometry/generators'; import type { ShapeKind } from './tools'; @@ -92,6 +92,8 @@ export type ItemDraft = { surfaceHeightMm?: number; canHostSurface?: boolean; defaultMount?: MountKind; + /** Space this item needs kept clear around it. See `core/clearance.ts`. */ + clearances?: ClearanceZone[]; color?: string; quantityOwned?: number; notes?: string; @@ -189,6 +191,11 @@ export function createCatalogItem(draft: ItemDraft, id: Id): CatalogItem { canHostSurface: draft.canHostSurface ?? defaults.canHostSurface, footprint, defaultMount: draft.defaultMount ?? 'floor', + // Only when there are some: an empty array and an absent field mean the same + // thing and writing both into files makes them diff differently for no reason. + ...(draft.clearances && draft.clearances.length > 0 + ? { clearances: draft.clearances.map((z) => ({ ...z })) } + : {}), color: draft.color ?? defaults.color, quantityOwned, ...(draft.notes ? { notes: draft.notes } : {}), @@ -208,6 +215,7 @@ export function draftFromItem(item: CatalogItem, shape: ShapeKind = 'rect'): Ite ...(item.surfaceHeightMm !== undefined ? { surfaceHeightMm: item.surfaceHeightMm } : {}), canHostSurface: item.canHostSurface, defaultMount: item.defaultMount, + ...(item.clearances ? { clearances: item.clearances.map((z) => ({ ...z })) } : {}), color: item.color, quantityOwned: item.quantityOwned, ...(item.notes ? { notes: item.notes } : {}), diff --git a/src/core/clearance.test.ts b/src/core/clearance.test.ts new file mode 100644 index 0000000..05a8d18 --- /dev/null +++ b/src/core/clearance.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; +import { + CLEARANCE_STEP_OVER_MM, + findClearanceViolations, + floorZones, + zoneOutline, + zoneSpan, +} from './clearance'; +import { createCatalogItem, type ItemDraft } from './catalog'; +import { createDocument, type ClearanceZone, type Placement, type SpaceDocument } from './document'; +import { bounds } from './geometry/polygon'; + +const DRAWER: ClearanceZone = { edge: 'front', depthMm: 900, reason: 'drawer pull' }; + +/** A 1500 × 500 × 810 dresser that needs 900mm in front of it. */ +const DRESSER: ItemDraft = { + name: 'Dresser', + category: 'storage', + shape: 'rect', + widthMm: 1500, + depthMm: 500, + heightMm: 810, + voidBelowMm: 0, + clearances: [DRAWER], +}; + +const BOX: ItemDraft = { + name: 'Box', + category: 'other', + shape: 'rect', + widthMm: 400, + depthMm: 400, + heightMm: 400, + voidBelowMm: 0, +}; + +const RUG: ItemDraft = { + name: 'Rug', + category: 'rug', + shape: 'rect', + widthMm: 2000, + depthMm: 2000, + heightMm: 10, + voidBelowMm: 0, +}; + +let seq = 0; + +function doc(): SpaceDocument { + seq = 0; + return createDocument({ id: 'd', floorId: 'f', now: '2026-01-01T00:00:00.000Z' }); +} + +function place( + d: SpaceDocument, + draft: ItemDraft, + at: { x: number; y: number }, + over: Partial = {}, +): Placement { + const item = createCatalogItem(draft, `i-${seq++}`); + d.catalog.push(item); + const placement: Placement = { + id: `p-${seq++}`, + itemId: item.id, + floorId: 'f', + position: at, + rotation: 0, + mount: { kind: 'floor' }, + elevation: 0, + ...over, + }; + d.floors[0]!.placements.push(placement); + return placement; +} + +function violations(d: SpaceDocument) { + return findClearanceViolations(d, d.floors[0]!); +} + +describe('where a zone sits', () => { + it('extends from the front edge, into the room', () => { + // Local +y is the front: wall snap seats the back edge (local −y) on the wall, + // so the front is what faces away from it. + const d = doc(); + const dresser = place(d, DRESSER, { x: 0, y: 0 }); + const item = d.catalog[0]!; + + expect(bounds(zoneOutline(dresser, item, DRAWER)!)).toEqual({ + minX: -750, + maxX: 750, + minY: 250, // half the 500 depth + maxY: 1150, // + 900 + }); + }); + + it('turns with the item it belongs to', () => { + const d = doc(); + const dresser = place(d, DRESSER, { x: 0, y: 0 }, { rotation: 90 }); + const box = bounds(zoneOutline(dresser, d.catalog[0]!, DRAWER)!); + + // Turned a quarter, the drawer clearance now runs along −x. + expect(box.minX).toBe(-1150); + expect(box.maxX).toBe(-250); + expect(box.minY).toBe(-750); + expect(box.maxY).toBe(750); + }); + + it('mirrors a side zone when the item is flipped', () => { + // Mirroring an item really does move its left-hand drawer to the other side, and + // it falls out of putting the zone through the same transform as the outline. + const d = doc(); + const left: ClearanceZone = { edge: 'left', depthMm: 300, reason: 'side access' }; + const normal = place(d, DRESSER, { x: 0, y: 0 }); + const flipped = place(d, DRESSER, { x: 0, y: 0 }, { flipped: true }); + + expect(bounds(zoneOutline(normal, d.catalog[0]!, left)!).minX).toBe(-1050); + expect(bounds(zoneOutline(flipped, d.catalog[1]!, left)!).maxX).toBe(1050); + }); + + it('takes a circular item off its bounding box', () => { + // The only edge a round table has. Stated rather than approximated with an + // offset curve nobody is asking for. + const d = doc(); + const round = place(d, { ...DRESSER, shape: 'circle', widthMm: 1200, depthMm: 1200 }, { x: 0, y: 0 }); + const box = bounds(zoneOutline(round, d.catalog[0]!, DRAWER)!); + + expect(box.minY).toBe(600); + expect(box.maxY).toBe(1500); + }); + + it('runs from the floor to the height the zone declares', () => { + const d = doc(); + const dresser = place(d, DRESSER, { x: 0, y: 0 }); + + expect(zoneSpan(d, dresser, d.catalog[0]!, DRAWER)).toEqual({ bottom: 0, top: 810 }); + expect(zoneSpan(d, dresser, d.catalog[0]!, { ...DRAWER, heightMm: 400 })).toEqual({ + bottom: 0, + top: 400, + }); + }); + + it('is nothing at all for an item that declares none', () => { + const d = doc(); + place(d, BOX, { x: 0, y: 0 }); + expect(floorZones(d, d.floors[0]!)).toHaveLength(0); + }); +}); + +describe('what blocks a zone', () => { + it('reports a box standing in front of the drawers', () => { + const d = doc(); + const dresser = place(d, DRESSER, { x: 0, y: 0 }); + const box = place(d, BOX, { x: 0, y: 600 }); + + const found = violations(d); + expect(found).toHaveLength(1); + expect(found[0]!.placementId).toBe(dresser.id); + expect(found[0]!.intruderId).toBe(box.id); + expect(found[0]!.zone.reason).toBe('drawer pull'); + }); + + it('says nothing once the box is moved clear', () => { + const d = doc(); + place(d, DRESSER, { x: 0, y: 0 }); + place(d, BOX, { x: 0, y: 1500 }); // past the 1150 the zone reaches to + + expect(violations(d)).toEqual([]); + }); + + it('lets a rug lie in front of a dresser', () => { + // The threshold is a property of the intruder, not of the zone: what makes the + // rug irrelevant is that you step over it, and the zone still runs to the floor + // so a low box is caught. + const d = doc(); + place(d, DRESSER, { x: 0, y: 0 }); + place(d, RUG, { x: 0, y: 600 }); + + expect(violations(d)).toEqual([]); + }); + + it('still catches something low enough to trip over but too tall to step over', () => { + const d = doc(); + place(d, DRESSER, { x: 0, y: 0 }); + place(d, { ...BOX, name: 'Shoe rack', heightMm: CLEARANCE_STEP_OVER_MM + 50 }, { x: 0, y: 600 }); + + expect(violations(d)).toHaveLength(1); + }); + + it('does not report an item against its own zone', () => { + const d = doc(); + place(d, DRESSER, { x: 0, y: 0 }); + expect(violations(d)).toEqual([]); + }); + + it('ignores a wall the item is pushed against', () => { + // Walls are not obstructions here, the same rule door swing settled: wall snap + // seats the back edge *on* the wall face, so a back zone tested against walls + // would fire on every chair pushed against one. The walkway probe is the check + // that includes walls. + const d = doc(); + const chair: ItemDraft = { + ...BOX, + name: 'Dining chair', + heightMm: 900, + clearances: [{ edge: 'back', depthMm: 1067, reason: 'chair pull-out' }], + }; + d.floors[0]!.walls.push({ + id: 'w1', + a: { x: -2000, y: -300 }, + b: { x: 2000, y: -300 }, + thicknessMm: 114, + heightMm: 2438, + baseElevationMm: 0, + }); + place(d, chair, { x: 0, y: 0 }); + + expect(violations(d)).toEqual([]); + }); + + it('sorts the worst intrusion first', () => { + const d = doc(); + place(d, DRESSER, { x: 0, y: 0 }); + place(d, BOX, { x: -600, y: 1100 }); // clipping the far corner of the zone + const deep = place(d, BOX, { x: 0, y: 500 }); // squarely in it + + expect(violations(d)[0]!.intruderId).toBe(deep.id); + }); + + it('respects the zone height — a shelf above the drawers is not in their way', () => { + const d = doc(); + place(d, DRESSER, { x: 0, y: 0 }); + place( + d, + { ...BOX, name: 'Wall shelf', heightMm: 300 }, + { x: 0, y: 600 }, + { mount: { kind: 'wall', wallId: 'w1' }, elevation: 1200 }, + ); + + // The dresser's zone stops at 810; the shelf starts at 1200. + expect(violations(d)).toEqual([]); + }); +}); diff --git a/src/core/clearance.ts b/src/core/clearance.ts new file mode 100644 index 0000000..2673ced --- /dev/null +++ b/src/core/clearance.ts @@ -0,0 +1,238 @@ +/** + * Item clearance zones. See PLAN.md §9.3. + * + * A dresser is not just the box it occupies. It needs 900mm in front of it or the + * drawers do not open; a dining chair needs a metre behind it or nobody can get up; + * an oven door needs 1200mm or it fouls the island. None of that is visible in a + * footprint, and all of it is the difference between a plan that works and one that + * looks fine on paper. + * + * A zone is declared on the **catalog item**, not the placement, because it is a + * property of the thing: every dresser you own needs its drawers to open. It is + * attached to a footprint edge and travels with the placement — rotate the dresser + * and its drawer clearance rotates too, which falls out of putting the zone through + * `toWorld`, the same transform the outline goes through. + * + * ## Three decisions worth stating + * + * **Zones are rectangles off the local bounding box**, not offsets from the true + * outline. PLAN §9.3 says "attached to a footprint edge", and a bounding-box edge is + * the only edge a circular table has. An offset curve for a round item would be more + * faithful and would answer no question anyone is asking. + * + * **Walls are not obstructions.** This is the same rule phase 6 settled for door + * swings, and for the same reason: wall snap seats an item's back edge *on the wall + * face*, so a back zone tested against walls would fire on every chair pushed against + * one — the default outcome of using the snap, not a corner case. The question "is + * there room to get past this" is a different question, and the walkway probe is + * what answers it, with walls very much included. Two checks, as PLAN describes. + * + * **The threshold lives on the intruder, not on the zone.** A rug in front of a + * dresser is not a blocked drawer, but the fix is *not* to lift the zone's floor — + * that would exempt a band of space and hide a 90mm shoe rack sitting in it. What + * makes the rug irrelevant is that you step over it. So the zone runs from the floor + * and anything whose solid top is below `CLEARANCE_STEP_OVER_MM` is skipped: the same + * shape of rule as `voidBelowMm` and the walker's `STEP_OVER_MM`, and a property of + * the object rather than of the space. + * + * Pure — no DOM, no store. + */ + +import type { + CatalogItem, + ClearanceZone, + Floor, + Id, + Placement, + SpaceDocument, +} from './document'; +import { findItem } from './document'; +import { intersectionArea, spansOverlap, type Span, type Volume } from './geometry/collision'; +import { footprintBounds } from './geometry/footprint'; +import { polygon, type Polygon } from './geometry/polygon'; +import { MountCycleError, effectiveHeight, placementVolume, resolveElevation, toWorld } from './placement'; + +export const ZONE_EDGES = ['front', 'back', 'left', 'right'] as const; +export type ZoneEdge = (typeof ZONE_EDGES)[number]; + +/** + * How a zone reads in a sentence. "in front of the Dresser", "behind the Chair". + * + * Local **+y is the front**, because wall snap seats the footprint's back edge on + * local −y against the wall — so the front is the side facing into the room. + */ +export const EDGE_LABELS: Record = { + front: 'in front of', + back: 'behind', + left: 'to the left of', + right: 'to the right of', +}; + +/** + * What a stride clears, and therefore what cannot block a drawer. + * + * A rug, a threshold, a floor cable. Deliberately lower than the walker's 200mm: + * reaching past something to open a drawer is not the same as walking over it, and + * a 150mm box in front of a dresser is genuinely in the way. + */ +export const CLEARANCE_STEP_OVER_MM = 100; + +/** Below this a zone is not describing anything. */ +export const MIN_ZONE_DEPTH_MM = 1; + +// --------------------------------------------------------------------------- +// Geometry +// --------------------------------------------------------------------------- + +/** + * The zone as a plan polygon in document space. + * + * Built in the item's local frame off its bounding box, then put through the very + * transform the outline uses — so a rotated, flipped item's zones are rotated and + * flipped identically, by construction rather than by two formulas agreeing. + */ +export function zoneOutline( + placement: Placement, + item: CatalogItem, + zone: ClearanceZone, +): Polygon | null { + if (!Number.isFinite(zone.depthMm) || zone.depthMm < MIN_ZONE_DEPTH_MM) return null; + const b = footprintBounds(item.footprint); + const d = zone.depthMm; + + const rect = (minX: number, minY: number, maxX: number, maxY: number): Polygon => + polygon([ + { x: minX, y: minY }, + { x: maxX, y: minY }, + { x: maxX, y: maxY }, + { x: minX, y: maxY }, + ]); + + const local = + zone.edge === 'front' + ? rect(b.minX, b.maxY, b.maxX, b.maxY + d) + : zone.edge === 'back' + ? rect(b.minX, b.minY - d, b.maxX, b.minY) + : zone.edge === 'left' + ? rect(b.minX - d, b.minY, b.minX, b.maxY) + : rect(b.maxX, b.minY, b.maxX + d, b.maxY); + + return toWorld(placement, local); +} + +/** + * The zone's vertical extent, from the floor the item stands on. + * + * `heightMm` omitted means the full height of the host: a wardrobe's doors need the + * whole wardrobe's worth of space. Given, it is what the zone actually cares about — + * a drawer pull at 810mm is indifferent to a wall shelf hanging at 1500. + */ +export function zoneSpan( + doc: SpaceDocument, + placement: Placement, + item: CatalogItem, + zone: ClearanceZone, +): Span { + const bottom = resolveElevation(doc, placement); + return { bottom, top: bottom + (zone.heightMm ?? effectiveHeight(placement, item)) }; +} + +export function zoneVolume( + doc: SpaceDocument, + placement: Placement, + item: CatalogItem, + zone: ClearanceZone, +): Volume | null { + const outline = zoneOutline(placement, item, zone); + if (!outline) return null; + return { outline, span: zoneSpan(doc, placement, item, zone) }; +} + +/** Every zone on a floor, already in document space. Used by the plan overlay too. */ +export function floorZones( + doc: SpaceDocument, + floor: Floor, +): { placement: Placement; item: CatalogItem; zone: ClearanceZone; volume: Volume }[] { + const out: { placement: Placement; item: CatalogItem; zone: ClearanceZone; volume: Volume }[] = []; + + for (const placement of floor.placements) { + const item = findItem(doc, placement.itemId); + if (!item?.clearances) continue; + + for (const zone of item.clearances) { + let volume: Volume | null; + try { + volume = zoneVolume(doc, placement, item, zone); + } catch (err) { + // A broken mount has no position to hang a zone off. Validation reports the + // mount; a zone for it would be an assertion about a place that is not real. + if (err instanceof MountCycleError) continue; + throw err; + } + if (volume) out.push({ placement, item, zone, volume }); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Violations +// --------------------------------------------------------------------------- + +export type ZoneViolation = { + /** The item whose clearance is compromised. */ + placementId: Id; + /** What is standing in it. */ + intruderId: Id; + zone: ClearanceZone; + /** How much of the zone footprint is taken, in mm² — sorted worst-first. */ + overlapMm2: number; +}; + +/** + * Everything standing in a clearance zone it should not be. + * + * The host is skipped (a zone starts at its own bounding-box edge, so it could only + * ever catch itself on a rounding error) and anything a stride clears is skipped for + * the reason in the module comment. Everything else is a plain volume-vs-volume test: + * footprints overlap in plan *and* solid spans overlap. + */ +export function findClearanceViolations(doc: SpaceDocument, floor: Floor): ZoneViolation[] { + const zones = floorZones(doc, floor); + if (zones.length === 0) return []; + + const obstacles: { id: Id; volume: Volume }[] = []; + for (const placement of floor.placements) { + const item = findItem(doc, placement.itemId); + if (!item) continue; + try { + const volume = placementVolume(doc, placement, item); + // What you step over cannot block a drawer. + if (volume.span.top < CLEARANCE_STEP_OVER_MM) continue; + obstacles.push({ id: placement.id, volume }); + } catch (err) { + if (err instanceof MountCycleError) continue; + throw err; + } + } + + const violations: ZoneViolation[] = []; + for (const { placement, zone, volume } of zones) { + for (const obstacle of obstacles) { + if (obstacle.id === placement.id) continue; + if (!spansOverlap(volume.span, obstacle.volume.span)) continue; + + const overlapMm2 = intersectionArea(volume.outline, obstacle.volume.outline); + if (overlapMm2 <= 0) continue; + + violations.push({ + placementId: placement.id, + intruderId: obstacle.id, + zone, + overlapMm2, + }); + } + } + + return violations.sort((a, b) => b.overlapMm2 - a.overlapMm2); +} diff --git a/src/core/placement.ts b/src/core/placement.ts index 9cbcc2b..11098fe 100644 --- a/src/core/placement.ts +++ b/src/core/placement.ts @@ -26,17 +26,30 @@ import { type SpaceDocument, } from './document'; -/** The footprint in document space: mirrored, rotated, then translated. */ -export function worldOutline(placement: Placement, item: CatalogItem): Polygon { - let poly = item.footprint.outline; +/** + * Local footprint coordinates to document space: mirrored, rotated, then translated. + * + * Exported because a clearance zone has to land in exactly the same frame as the + * thing it belongs to. A zone built off the footprint's local bounding box and put + * through this cannot disagree with the item about which way it is facing — and it + * gets flipping for free, which is right: mirroring an item really does move its + * left-hand drawer to the other side. + */ +export function toWorld(placement: Placement, poly: Polygon): Polygon { + let out = poly; if (placement.flipped) { - poly = { ...poly, pts: poly.pts.map((p) => ({ x: -p.x, y: p.y })).reverse() }; + out = { ...out, pts: out.pts.map((p) => ({ x: -p.x, y: p.y })).reverse() }; } if (placement.rotation !== 0) { - poly = rotatePolygon(poly, toRadians(placement.rotation)); + out = rotatePolygon(out, toRadians(placement.rotation)); } - return roundPolygon(translate(poly, placement.position)); + return roundPolygon(translate(out, placement.position)); +} + +/** The footprint in document space. */ +export function worldOutline(placement: Placement, item: CatalogItem): Polygon { + return toWorld(placement, item.footprint.outline); } /** The effective height of a placement, honouring any per-placement override. */ diff --git a/src/core/presets.ts b/src/core/presets.ts index f6eb3c6..042e3d0 100644 --- a/src/core/presets.ts +++ b/src/core/presets.ts @@ -16,6 +16,21 @@ */ import type { ItemDraft } from './catalog'; +import type { ClearanceZone } from './document'; + +/** + * The standard clearance library, from PLAN.md §9.3: 900mm in front of dressers, + * 1067mm behind dining chairs, 1200mm at appliance doors. + * + * Named constants rather than inline literals because the same number means the same + * thing on four appliances, and because a reader should be able to see the library as + * a library. Zones are only on the presets that genuinely need one — a bookcase does + * not stop working when a chair is in front of it, and a zone that fires on something + * nobody would call a mistake makes the whole panel easier to ignore. + */ +const DRAWER_PULL: ClearanceZone = { edge: 'front', depthMm: 900, reason: 'drawer pull' }; +const CHAIR_PULL_OUT: ClearanceZone = { edge: 'back', depthMm: 1067, reason: 'chair pull-out' }; +const APPLIANCE_DOOR = (reason: string): ClearanceZone => ({ edge: 'front', depthMm: 1200, reason }); export type Preset = ItemDraft & { /** Stable key for lists and tests; never stored in a document. */ @@ -35,8 +50,8 @@ export const PRESETS: readonly Preset[] = [ { key: 'sofa-3', group: 'Seating', name: 'Sofa (3-seat)', category: 'seating', shape: 'rect', widthMm: 2130, depthMm: 910, heightMm: 840, voidBelowMm: 0 }, { key: 'loveseat', group: 'Seating', name: 'Loveseat', category: 'seating', shape: 'rect', widthMm: 1520, depthMm: 910, heightMm: 840, voidBelowMm: 0 }, { key: 'armchair', group: 'Seating', name: 'Armchair', category: 'seating', shape: 'rect', widthMm: 810, depthMm: 860, heightMm: 800, voidBelowMm: 0 }, - { key: 'dining-chair', group: 'Seating', name: 'Dining chair', category: 'seating', shape: 'rect', widthMm: 460, depthMm: 510, heightMm: 900, voidBelowMm: 0 }, - { key: 'office-chair', group: 'Seating', name: 'Office chair', category: 'seating', shape: 'circle', widthMm: 660, depthMm: 660, heightMm: 1100, voidBelowMm: 0 }, + { key: 'dining-chair', group: 'Seating', name: 'Dining chair', category: 'seating', shape: 'rect', widthMm: 460, depthMm: 510, heightMm: 900, voidBelowMm: 0, clearances: [CHAIR_PULL_OUT] }, + { key: 'office-chair', group: 'Seating', name: 'Office chair', category: 'seating', shape: 'circle', widthMm: 660, depthMm: 660, heightMm: 1100, voidBelowMm: 0, clearances: [{ edge: 'back', depthMm: 900, reason: 'chair roll-back' }] }, // -- Tables -------------------------------------------------------------- // 720mm of apron clearance is what lets chairs tuck under and a rug lie beneath. @@ -47,16 +62,16 @@ export const PRESETS: readonly Preset[] = [ { key: 'desk', group: 'Tables', name: 'Desk', category: 'table', shape: 'rect', widthMm: 1520, depthMm: 760, heightMm: 750, voidBelowMm: 700, canHostSurface: true }, // -- Storage ------------------------------------------------------------- - { key: 'dresser', group: 'Storage', name: 'Dresser', category: 'storage', shape: 'rect', widthMm: 1520, depthMm: 510, heightMm: 810, voidBelowMm: 0, canHostSurface: true }, + { key: 'dresser', group: 'Storage', name: 'Dresser', category: 'storage', shape: 'rect', widthMm: 1520, depthMm: 510, heightMm: 810, voidBelowMm: 0, canHostSurface: true, clearances: [DRAWER_PULL] }, { key: 'bookcase', group: 'Storage', name: 'Bookcase', category: 'storage', shape: 'rect', widthMm: 810, depthMm: 300, heightMm: 1830, voidBelowMm: 0, canHostSurface: false }, - { key: 'wardrobe', group: 'Storage', name: 'Wardrobe', category: 'storage', shape: 'rect', widthMm: 1200, depthMm: 600, heightMm: 2000, voidBelowMm: 0, canHostSurface: false }, + { key: 'wardrobe', group: 'Storage', name: 'Wardrobe', category: 'storage', shape: 'rect', widthMm: 1200, depthMm: 600, heightMm: 2000, voidBelowMm: 0, canHostSurface: false, clearances: [{ edge: 'front', depthMm: 900, reason: 'wardrobe doors' }] }, { key: 'tv-stand', group: 'Storage', name: 'TV stand', category: 'storage', shape: 'rect', widthMm: 1520, depthMm: 400, heightMm: 500, voidBelowMm: 0, canHostSurface: true }, // -- Appliances ---------------------------------------------------------- - { key: 'fridge', group: 'Appliances', name: 'Refrigerator', category: 'appliance', shape: 'rect', widthMm: 910, depthMm: 760, heightMm: 1780, voidBelowMm: 0 }, - { key: 'dishwasher', group: 'Appliances', name: 'Dishwasher', category: 'appliance', shape: 'rect', widthMm: 610, depthMm: 610, heightMm: 850, voidBelowMm: 0 }, - { key: 'range', group: 'Appliances', name: 'Range', category: 'appliance', shape: 'rect', widthMm: 760, depthMm: 660, heightMm: 920, voidBelowMm: 0 }, - { key: 'washer', group: 'Appliances', name: 'Washer', category: 'appliance', shape: 'rect', widthMm: 690, depthMm: 760, heightMm: 970, voidBelowMm: 0 }, + { key: 'fridge', group: 'Appliances', name: 'Refrigerator', category: 'appliance', shape: 'rect', widthMm: 910, depthMm: 760, heightMm: 1780, voidBelowMm: 0, clearances: [APPLIANCE_DOOR('fridge door')] }, + { key: 'dishwasher', group: 'Appliances', name: 'Dishwasher', category: 'appliance', shape: 'rect', widthMm: 610, depthMm: 610, heightMm: 850, voidBelowMm: 0, clearances: [APPLIANCE_DOOR('dishwasher door')] }, + { key: 'range', group: 'Appliances', name: 'Range', category: 'appliance', shape: 'rect', widthMm: 760, depthMm: 660, heightMm: 920, voidBelowMm: 0, clearances: [APPLIANCE_DOOR('oven door')] }, + { key: 'washer', group: 'Appliances', name: 'Washer', category: 'appliance', shape: 'rect', widthMm: 690, depthMm: 760, heightMm: 970, voidBelowMm: 0, clearances: [APPLIANCE_DOOR('washer door')] }, // -- Everything else ----------------------------------------------------- // Mounted at 400mm by default, which is a wall-hung screen above a stand. diff --git a/src/core/tools.ts b/src/core/tools.ts index 6a810a6..7087fa9 100644 --- a/src/core/tools.ts +++ b/src/core/tools.ts @@ -23,7 +23,7 @@ import { area, ensureCounterClockwise, polygon, roundPolygon, translate } from ' import { isDegenerate } from './geometry/wall'; import type { Vec2 } from './geometry/vec'; -export const PLAN_TOOLS = ['select', 'wall', 'room', 'opening', 'shape', 'dimension'] as const; +export const PLAN_TOOLS = ['select', 'wall', 'room', 'opening', 'shape', 'dimension', 'walkway'] as const; export type PlanTool = (typeof PLAN_TOOLS)[number]; export const PLAN_TOOL_LABELS: Record = { @@ -33,6 +33,7 @@ export const PLAN_TOOL_LABELS: Record = { opening: 'Opening', shape: 'Shape', dimension: 'Measure', + walkway: 'Walkway', }; /** Single-key shortcuts, matching the first letter where it is free. */ @@ -43,6 +44,7 @@ export const PLAN_TOOL_KEYS: Record = { opening: 'o', shape: 's', dimension: 'd', + walkway: 'p', // path }; /** @@ -81,7 +83,10 @@ export type Draft = | { tool: 'wall'; points: Vec2[]; cursor: Vec2 | null } | { tool: 'room'; start: Vec2; cursor: Vec2 } | { tool: 'shape'; kind: ShapeKind; start: Vec2; cursor: Vec2 } - | { tool: 'dimension'; start: Vec2; cursor: Vec2 }; + | { tool: 'dimension'; start: Vec2; cursor: Vec2 } + /** The walkway probe's path. Click-to-place like a wall chain, but it commits to + editor state rather than to the document — see `StoreState.walkway`. */ + | { tool: 'walkway'; points: Vec2[]; cursor: Vec2 | null }; export type IdFactory = () => Id; diff --git a/src/core/validation.test.ts b/src/core/validation.test.ts index 81e9406..5fbc3a3 100644 --- a/src/core/validation.test.ts +++ b/src/core/validation.test.ts @@ -343,3 +343,49 @@ describe('what a leaf needs kept clear', () => { expect(validateFloor(d, floor).map((i) => i.kind)).toContain('pocket-blocked'); }); }); + +describe('clearance zones', () => { + const DRAWER = { edge: 'front' as const, depthMm: 900, reason: 'drawer pull' }; + + function withZoned() { + const d = withItems(['dresser', 'bookcase', 'rug']); + d.catalog[0]!.clearances = [DRAWER]; + return d; + } + + it('reports what is standing in front of the drawers', () => { + const d = withZoned(); + d.floors[0]!.placements = [ + place('dresser', { x: 0, y: 0 }, 'dresser'), + place('bookcase', { x: 0, y: 600 }, 'bookcase'), + ]; + + const issue = validateFloor(d, d.floors[0]!).find((i) => i.kind === 'clearance')!; + expect(issue.message).toBe('Bookcase blocks the drawer pull clearance in front of Dresser.'); + expect(issue.refs.map((r) => r.id)).toEqual(['dresser', 'bookcase']); + }); + + it('leaves a rug alone', () => { + const d = withZoned(); + d.floors[0]!.placements = [ + place('dresser', { x: 0, y: 0 }, 'dresser'), + place('rug', { x: 0, y: 600 }, 'rug'), + ]; + + expect(validateFloor(d, d.floors[0]!).map((i) => i.kind)).not.toContain('clearance'); + }); + + it('flags both the overlap and the clearance when something is properly in the way', () => { + // Two distinct problems with one cause, and the panel says both — a bookcase + // half inside the dresser is also blocking its drawers. + const d = withZoned(); + d.floors[0]!.placements = [ + place('dresser', { x: 0, y: 0 }, 'dresser'), + place('bookcase', { x: 0, y: 250 }, 'bookcase'), + ]; + + const kinds = validateFloor(d, d.floors[0]!).map((i) => i.kind); + expect(kinds).toContain('clearance'); + expect(kinds).toContain('overlap'); + }); +}); diff --git a/src/core/validation.ts b/src/core/validation.ts index f118300..7373ce8 100644 --- a/src/core/validation.ts +++ b/src/core/validation.ts @@ -25,6 +25,7 @@ import { rangesOverlap, } from './openings'; import { clearanceVolume, leafOf, pocketFitReason } from './swing'; +import { EDGE_LABELS, findClearanceViolations } from './clearance'; import { findCollisions, volumesCollide, type Volume } from './geometry/collision'; import { MountCycleError, @@ -43,7 +44,8 @@ export type IssueKind = | 'opening-fit' | 'opening-overlap' | 'swing-blocked' - | 'pocket-blocked'; + | 'pocket-blocked' + | 'clearance'; export type IssueSeverity = 'blocking' | 'warning'; @@ -259,6 +261,22 @@ export function validateFloor(doc: SpaceDocument, floor: Floor): Issue[] { } } + // Clearance zones. Walls are deliberately not tested — see `clearance.ts`; the + // walkway probe is the check that includes them. + for (const violation of findClearanceViolations(doc, floor)) { + issues.push({ + kind: 'clearance', + severity: 'warning', + message: + `${label(doc, violation.intruderId, floor)} blocks the ${violation.zone.reason} ` + + `clearance ${EDGE_LABELS[violation.zone.edge]} ${label(doc, violation.placementId, floor)}.`, + refs: [ + { kind: 'placement', id: violation.placementId }, + { kind: 'placement', id: violation.intruderId }, + ], + }); + } + for (const [i, j] of findCollisions(volumes)) { const a = owners[i]!; const b = owners[j]!; @@ -283,7 +301,8 @@ export function validateFloor(doc: SpaceDocument, floor: Floor): Issue[] { 'pocket-blocked': 6, 'swing-blocked': 7, headroom: 8, - overlap: 9, + clearance: 9, + overlap: 10, }; return issues.sort((x, y) => order[x.kind] - order[y.kind]); } diff --git a/src/core/walkway.test.ts b/src/core/walkway.test.ts new file mode 100644 index 0000000..4d95d71 --- /dev/null +++ b/src/core/walkway.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest'; +import { + WALKWAY_MIN_MM, + isTooNarrow, + narrowestGap, + samplePath, + walkwayObstructions, +} from './walkway'; +import { createCatalogItem, type ItemDraft } from './catalog'; +import { createOpening } from './openings'; +import { commitRoomRect } from './tools'; +import { createDocument, type Placement, type SpaceDocument } from './document'; +import { polygon } from './geometry/polygon'; +import type { Volume } from './geometry/collision'; + +/** A solid block, floor to 2m, as a plain obstruction. */ +function block(minX: number, minY: number, maxX: number, maxY: number): Volume { + return { + outline: polygon([ + { x: minX, y: minY }, + { x: maxX, y: minY }, + { x: maxX, y: maxY }, + { x: minX, y: maxY }, + ]), + span: { bottom: 0, top: 2000 }, + }; +} + +let seq = 0; +const id = () => `id-${seq++}`; + +/** A 5m × 4m room with four walls. */ +function room(): SpaceDocument { + seq = 0; + const doc = createDocument({ id: 'd', floorId: 'f', now: '2026-01-01T00:00:00.000Z' }); + const built = commitRoomRect({ x: 0, y: 0 }, { x: 5000, y: 4000 }, { name: 'Room', makeId: id })!; + doc.floors[0]!.rooms.push(built.room); + doc.floors[0]!.walls.push(...built.walls); + return doc; +} + +function place(doc: SpaceDocument, draft: ItemDraft, at: { x: number; y: number }): Placement { + const item = createCatalogItem(draft, id()); + doc.catalog.push(item); + const placement: Placement = { + id: id(), + itemId: item.id, + floorId: 'f', + position: at, + rotation: 0, + mount: { kind: 'floor' }, + elevation: 0, + }; + doc.floors[0]!.placements.push(placement); + return placement; +} + +const SOFA: ItemDraft = { + name: 'Sofa', + category: 'seating', + shape: 'rect', + widthMm: 2130, + depthMm: 910, + heightMm: 840, + voidBelowMm: 0, +}; + +describe('sampling the path', () => { + it('samples both ends of a segment, whatever the step', () => { + // A path turns where the room pinches, so a vertex that went unsampled would be + // the answer that went unreported. + const samples = samplePath([{ x: 0, y: 0 }, { x: 250, y: 0 }], 100); + + expect(samples.map((s) => Math.round(s.at.x))).toEqual([0, 100, 200, 250]); + }); + + it('skips a segment that goes nowhere', () => { + expect(samplePath([{ x: 0, y: 0 }, { x: 0, y: 0 }], 100)).toEqual([]); + }); + + it('has nothing to sample on a single point', () => { + expect(samplePath([{ x: 0, y: 0 }], 100)).toEqual([]); + expect(narrowestGap([{ x: 0, y: 0 }], [])).toBeNull(); + }); +}); + +describe('the narrowest gap', () => { + it('measures a plain corridor square to the path', () => { + // Two blocks 800 apart, path down the middle. + const gap = narrowestGap( + [{ x: 0, y: -1000 }, { x: 0, y: 1000 }], + [block(-2000, -2000, -400, 2000), block(400, -2000, 2000, 2000)], + )!; + + expect(gap.widthMm).toBe(800); + expect(gap.blocked).toBe(false); + }); + + it('finds the pinch rather than the average', () => { + // A corridor that narrows to 500 for part of its length. + const gap = narrowestGap( + [{ x: 0, y: -1000 }, { x: 0, y: 1000 }], + [ + block(-2000, -2000, -400, 2000), + block(400, -2000, 2000, 2000), + block(-250, -100, -100, 100), // a pillar jutting in + ], + )!; + + expect(gap.widthMm).toBe(500); // 100 to the pillar, 400 to the right wall + expect(Math.abs(gap.at.y)).toBeLessThanOrEqual(100); + }); + + it('reports the far side of a doorway rather than the wall behind it', () => { + // The ray takes the first crossing, so walking out through a door measures the + // opening, not whatever is beyond it. + const gap = narrowestGap( + [{ x: 0, y: 0 }, { x: 0, y: 500 }], + [block(-2000, -2000, -400, 2000), block(400, -2000, 2000, 2000)], + )!; + + expect(gap.widthMm).toBe(800); + }); + + it('says a path through a solid is blocked', () => { + const gap = narrowestGap([{ x: 0, y: 0 }, { x: 1000, y: 0 }], [block(400, -500, 600, 500)])!; + + expect(gap.blocked).toBe(true); + expect(gap.widthMm).toBe(0); + }); + + it('caps at the reach when there is nothing to either side', () => { + const gap = narrowestGap([{ x: 0, y: 0 }, { x: 1000, y: 0 }], [], { maxReachMm: 1500 })!; + expect(gap.widthMm).toBe(3000); + }); + + it('flags anything under 30 inches', () => { + const wide = narrowestGap( + [{ x: 0, y: -500 }, { x: 0, y: 500 }], + [block(-2000, -2000, -450, 2000), block(450, -2000, 2000, 2000)], + )!; + const tight = narrowestGap( + [{ x: 0, y: -500 }, { x: 0, y: 500 }], + [block(-2000, -2000, -300, 2000), block(300, -2000, 2000, 2000)], + )!; + + expect(wide.widthMm).toBe(900); + expect(isTooNarrow(wide)).toBe(false); + expect(tight.widthMm).toBe(600); + expect(isTooNarrow(tight)).toBe(true); + expect(WALKWAY_MIN_MM).toBe(762); + }); +}); + +describe('what counts as an obstruction', () => { + it('includes the walls', () => { + const doc = room(); + expect(walkwayObstructions(doc, doc.floors[0]!)).toHaveLength(4); + }); + + it('leaves a doorway open to a body, and a window shut', () => { + // The wall is split around its openings, so this needs no "is this a door" check: + // a doorway's only solid starts at the lintel, a window's sill wall does not. + const doc = room(); + const [north, , , west] = doc.floors[0]!.walls; + doc.floors[0]!.openings.push( + createOpening({ id: 'door', wall: north!, kind: 'door', centreMm: 2500 }), + createOpening({ id: 'win', wall: west!, kind: 'window', centreMm: 2000 }), + ); + + const solid = walkwayObstructions(doc, doc.floors[0]!); + // The north wall is now two flanks (the lintel is above 900); the west wall is + // whole again because its window's sill wall reaches 914. + const northPieces = solid.filter((v) => v.outline.pts.every((p) => Math.abs(p.y) < 200)); + expect(northPieces).toHaveLength(2); + + const gap = narrowestGap([{ x: 2500, y: 1000 }, { x: 2500, y: -1000 }], solid)!; + expect(gap.blocked).toBe(false); + }); + + it('includes a sofa, which a single 900mm ray would have passed straight over', () => { + // A sofa back is 840. This is the case that decided the band: at PLAN's stated + // probe height the couch is not there at all, and the probe reports a clear + // walkway through the middle of it. + const doc = room(); + place(doc, SOFA, { x: 2500, y: 1000 }); + + expect(walkwayObstructions(doc, doc.floors[0]!)).toHaveLength(5); + expect(walkwayObstructions(doc, doc.floors[0]!, { bottom: 900, top: 901 })).toHaveLength(4); + }); + + it('steps over a rug and ducks under a high shelf', () => { + const doc = room(); + place(doc, { ...SOFA, name: 'Rug', heightMm: 10, voidBelowMm: 0 }, { x: 2500, y: 2000 }); + doc.floors[0]!.placements.push({ + id: 'shelf', + itemId: doc.catalog[0]!.id, + floorId: 'f', + position: { x: 2500, y: 2500 }, + rotation: 0, + mount: { kind: 'wall', wallId: 'w' }, + elevation: 1900, + }); + + expect(walkwayObstructions(doc, doc.floors[0]!)).toHaveLength(4); // the walls only + }); + + it('measures the real gap between a sofa and the wall', () => { + // The sofa is 910 deep, centred 700 from the south wall's inner face, so the gap + // behind it is 700 − 455 = 245mm. Too tight to walk, and only visible at all + // because the band reaches the sofa's 840mm back. + const doc = room(); + place(doc, SOFA, { x: 2500, y: 4000 - 57 - 700 }); + + const gap = narrowestGap( + [{ x: 1000, y: 3800 }, { x: 4000, y: 3800 }], + walkwayObstructions(doc, doc.floors[0]!), + )!; + + expect(gap.widthMm).toBeGreaterThan(0); + expect(isTooNarrow(gap)).toBe(true); + }); +}); diff --git a/src/core/walkway.ts b/src/core/walkway.ts new file mode 100644 index 0000000..84ae3ea --- /dev/null +++ b/src/core/walkway.ts @@ -0,0 +1,253 @@ +/** + * The walkway width probe. See PLAN.md §9.3. + * + * You draw a path through the space and it tells you the narrowest point along it. + * That answers the question people actually have — "can I get from the door to the + * couch?" — and it is the second half of the clearance story: zones ask whether a + * drawer opens, this asks whether a person fits. + * + * **Walls are obstructions here**, unlike in `clearance.ts`. That is the division of + * labour: a chair pushed against a wall is fine, and a 500mm gap between that chair + * and the table is not. Walls arrive already split around their openings, so a + * doorway is a gap you can walk through rather than a wall you cannot — no "is this + * a door" check, the same property phase 5 relied on for traversal. + * + * ## Measured against a body, not at a height + * + * PLAN §9.3 specifies a single probe height, 900mm — "hip height, where you actually + * squeeze past furniture, not floor level where a sofa base is narrower than its + * arms". The floor half of that is right and the fix is not: **a standard sofa back + * is 840mm**, so a ray at 900 passes straight over PLAN's own example and reports a + * clear walkway through the middle of the couch. A dining table at 760 and a dresser + * at 810 go the same way. Any single height is either low enough to catch table legs + * or high enough to miss the furniture. + * + * So the probe asks what traversal already asks: is anything solid inside the + * interval a person's body occupies? That is `[STEP_OVER_MM, STAND_HEIGHT_MM]` — + * `[200, 1800]` — imported from `walk.ts` rather than restated, so there is one + * definition of "what a body takes up" in the application. A rug is stepped over, a + * sofa arm at 600 and a sofa back at 840 both obstruct, and a shelf at 1900 does not. + * The band is still configurable; it is a band rather than a plane. + * + * ## What the sampling can miss + * + * The path is sampled at its vertices and every `WALKWAY_SAMPLE_MM` along each + * segment, and each sample casts one ray to each side. So the answer is the narrowest + * gap **at a sample**, not the true infimum: a table leg 40mm wide sitting between two + * samples is stepped over, and a gap that pinches diagonally is measured square to + * the path rather than at its true narrowest. Vertices are always sampled because a + * path turns where the room pinches, which is where the narrowest point usually is. + * Stated rather than implied — this is a probe, not a medial-axis analysis, and PLAN + * §9.3 puts the full navmesh explicitly out of scope. + * + * Pure — no DOM, no store. + */ + +import type { Floor, SpaceDocument } from './document'; +import { findItem } from './document'; +import { containsPoint } from './geometry/polygon'; +import { spansOverlap, type Span, type Volume } from './geometry/collision'; +import { STAND_HEIGHT_MM, STEP_OVER_MM } from './walk'; +import { cross, distance, normalize, perp, scale, sub, type Vec2 } from './geometry/vec'; +import { segmentOutline, wallSegments } from './openings'; +import { MountCycleError, placementVolume } from './placement'; + +/** + * What a person's body occupies, taken from the walker rather than restated. + * + * The same interval that decides whether the walker fits through a doorway decides + * whether a gap on the plan is passable, which is the point: the two answers should + * never disagree. + */ +export const WALKWAY_BAND: Span = { bottom: STEP_OVER_MM, top: STAND_HEIGHT_MM }; + +/** 30 inches — the usual minimum for a main circulation route. */ +export const WALKWAY_MIN_MM = 762; + +/** Distance between samples along the path. See the module comment. */ +export const WALKWAY_SAMPLE_MM = 100; + +/** How far a ray looks before calling the space open. 4m is wider than any corridor. */ +export const WALKWAY_MAX_REACH_MM = 4000; + +export type WalkwayOptions = { + /** The vertical slice a body takes up. Defaults to `WALKWAY_BAND`. */ + band?: Span; + sampleMm?: number; + maxReachMm?: number; +}; + +export type WalkwayProbe = { + /** The narrowest gap found, in mm. Capped at twice the reach when nothing is near. */ + widthMm: number; + /** The sample it was measured at. */ + at: Vec2; + /** Where the two rays landed, for drawing the measurement. */ + left: Vec2; + right: Vec2; + /** The path runs through something solid here — width is 0 and so is the advice. */ + blocked: boolean; +}; + +// --------------------------------------------------------------------------- +// What is in the way +// --------------------------------------------------------------------------- + +/** + * Everything solid within the body band: walls split around their openings, plus any + * placement whose solid span reaches into it. + * + * A window with a 914 sill is solid across the band and a doorway is not, which is + * exactly right and needed no special case — the lintel starts at 2032, above a + * standing body, the same property traversal relies on. + */ +export function walkwayObstructions( + doc: SpaceDocument, + floor: Floor, + band: Span = WALKWAY_BAND, +): Volume[] { + const out: Volume[] = []; + + for (const wall of floor.walls) { + for (const segment of wallSegments(wall, floor.openings)) { + if (!spansOverlap(band, { bottom: segment.bottom, top: segment.top })) continue; + try { + out.push({ + outline: segmentOutline(wall, segment), + span: { bottom: segment.bottom, top: segment.top }, + }); + } catch { + // Degenerate wall — the validation panel's problem, not the probe's. + } + } + } + + for (const placement of floor.placements) { + const item = findItem(doc, placement.itemId); + if (!item) continue; + try { + const volume = placementVolume(doc, placement, item); + if (!spansOverlap(band, volume.span)) continue; + out.push(volume); + } catch (err) { + if (err instanceof MountCycleError) continue; + throw err; + } + } + + return out; +} + +// --------------------------------------------------------------------------- +// Casting +// --------------------------------------------------------------------------- + +/** + * Distance from `from` along `dir` to the nearest obstruction edge, or `maxMm`. + * + * Returns the *first* crossing rather than testing containment, so a ray that starts + * in open space and leaves the room through a doorway reports the far jamb rather + * than the wall behind it. + */ +function castRay( + from: Vec2, + dir: Vec2, + obstructions: readonly Volume[], + maxMm: number, +): number { + let nearest = maxMm; + + for (const obstruction of obstructions) { + const pts = obstruction.outline.pts; + for (let i = 0; i < pts.length; i++) { + const a = pts[i]!; + const b = pts[(i + 1) % pts.length]!; + const edge = sub(b, a); + const denom = cross(dir, edge); + if (denom === 0) continue; // parallel + + const ap = sub(a, from); + const t = cross(ap, edge) / denom; + const s = cross(ap, dir) / denom; + if (t > 0 && t < nearest && s >= 0 && s <= 1) nearest = t; + } + } + + return nearest; +} + +function isInside(point: Vec2, obstructions: readonly Volume[]): boolean { + return obstructions.some((o) => containsPoint(o.outline, point)); +} + +/** The sample points along a path: every vertex, and every `step` mm between them. */ +export function samplePath(path: readonly Vec2[], step: number): { at: Vec2; dir: Vec2 }[] { + const samples: { at: Vec2; dir: Vec2 }[] = []; + + for (let i = 0; i < path.length - 1; i++) { + const a = path[i]!; + const b = path[i + 1]!; + const length = distance(a, b); + if (length === 0) continue; + + const dir = normalize(sub(b, a)); + const count = Math.max(1, Math.ceil(length / step)); + // `<= count` so the segment's far end is always sampled — a path turns where the + // room pinches, and skipping the vertex would skip the answer. + for (let k = 0; k <= count; k++) { + const t = Math.min(k * step, length); + samples.push({ at: { x: a.x + dir.x * t, y: a.y + dir.y * t }, dir }); + } + } + + return samples; +} + +/** + * The narrowest gap across the path, measured square to it at each sample. + * + * Null for a path with no length — there is nothing to be narrow. + */ +export function narrowestGap( + path: readonly Vec2[], + obstructions: readonly Volume[], + options: WalkwayOptions = {}, +): WalkwayProbe | null { + const step = options.sampleMm ?? WALKWAY_SAMPLE_MM; + const reach = options.maxReachMm ?? WALKWAY_MAX_REACH_MM; + const samples = samplePath(path, step); + if (samples.length === 0) return null; + + let best: WalkwayProbe | null = null; + + for (const { at, dir } of samples) { + const n = perp(dir); + + if (isInside(at, obstructions)) { + // Standing in a wall. Nothing further along can be narrower than this, but keep + // the first one found so the reported point is where the path first fails. + const blocked: WalkwayProbe = { widthMm: 0, at, left: at, right: at, blocked: true }; + return blocked; + } + + const leftMm = castRay(at, n, obstructions, reach); + const rightMm = castRay(at, scale(n, -1), obstructions, reach); + const widthMm = leftMm + rightMm; + if (best && widthMm >= best.widthMm) continue; + + best = { + widthMm, + at, + left: { x: at.x + n.x * leftMm, y: at.y + n.y * leftMm }, + right: { x: at.x - n.x * rightMm, y: at.y - n.y * rightMm }, + blocked: false, + }; + } + + return best; +} + +/** Whether a probe is worth warning about. */ +export function isTooNarrow(probe: WalkwayProbe, minMm: number = WALKWAY_MIN_MM): boolean { + return probe.widthMm < minMm; +} diff --git a/src/state/store.test.ts b/src/state/store.test.ts index 8cd29a4..795c27f 100644 --- a/src/state/store.test.ts +++ b/src/state/store.test.ts @@ -352,3 +352,35 @@ describe('zoomToFit', () => { expect(box.maxY).toBeCloseTo(57, 6); }); }); + +describe('the walkway route', () => { + const ROUTE = [ + { x: 0, y: 0 }, + { x: 3000, y: 0 }, + ]; + + it('survives a tool change, unlike a measurement', () => { + // It is a route you work against while moving furniture. A measurement is a + // number you just took; losing that on a tool change costs nothing. + const store = useStore.getState(); + store.setWalkway(ROUTE); + store.setMeasurement({ from: ROUTE[0]!, to: ROUTE[1]! }); + + useStore.getState().setTool('select'); + expect(useStore.getState().walkway).toEqual(ROUTE); + expect(useStore.getState().measurement).toBeNull(); + }); + + it('records no history — it is a question, not an edit', () => { + const before = useStore.getState().past.length; + useStore.getState().setWalkway(ROUTE); + expect(useStore.getState().past).toHaveLength(before); + expect(useStore.getState().dirty).toBe(false); + }); + + it('goes away with the document it was drawn on', () => { + useStore.getState().setWalkway(ROUTE); + useStore.getState().newDocument(); + expect(useStore.getState().walkway).toBeNull(); + }); +}); diff --git a/src/state/store.ts b/src/state/store.ts index 2ec6266..9d154eb 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -174,6 +174,15 @@ export type StoreState = { wallDefaults: WallDefaults; /** The last completed measurement, held until the next one or a tool change. */ measurement: Measurement | null; + /** + * The walkway probe's path, in document mm. + * + * Editor state, not document state, for the same reason a measurement is: it is a + * *question* asked of the plan, not a part of it. Storing the path rather than the + * answer means moving a chair re-answers it, because the narrowest gap is derived + * from the document every time it is read. + */ + walkway: Vec2[] | null; /** The wall drag in flight, if any. */ transform: WallTransform | null; /** @@ -238,6 +247,7 @@ export type StoreState = { setGridEnabled: (on: boolean) => void; setSnapSuppressed: (on: boolean) => void; setMeasurement: (m: Measurement | null) => void; + setWalkway: (path: Vec2[] | null) => void; setTransform: (t: WallTransform | null) => void; setPlacementTransform: (t: PlacementTransform | null) => void; setPlacingItem: (itemId: Id | null) => void; @@ -390,6 +400,7 @@ export const useStore = create((set, get) => ({ draft: null, transform: null, measurement: null, + walkway: null, cursor: null, snapHints: [], calibrating: false, @@ -413,6 +424,7 @@ export const useStore = create((set, get) => ({ draft: null, transform: null, measurement: null, + walkway: null, cursor: null, snapHints: [], calibrating: false, @@ -445,6 +457,7 @@ export const useStore = create((set, get) => ({ snapSuppressed: false, wallDefaults: DEFAULT_WALL_DEFAULTS, measurement: null, + walkway: null, transform: null, calibrating: false, calibrationRef: null, @@ -472,6 +485,9 @@ export const useStore = create((set, get) => ({ setViewMode: (viewMode) => set({ viewMode, notice: null }), setTool: (tool) => + // The walkway survives a tool change, unlike a measurement: it is a route you + // are working against while you move furniture, and losing it every time you + // pick up the select tool would make it useless for the one job it has. set({ tool, draft: null, transform: null, placementTransform: null, measurement: null }), setShapeKind: (shapeKind) => set({ shapeKind, tool: 'shape', draft: null }), setOpeningKind: (openingKind) => set({ openingKind, tool: 'opening', draft: null }), @@ -495,6 +511,7 @@ export const useStore = create((set, get) => ({ setGridEnabled: (gridEnabled) => set({ gridEnabled }), setSnapSuppressed: (snapSuppressed) => set({ snapSuppressed }), setMeasurement: (measurement) => set({ measurement }), + setWalkway: (walkway) => set({ walkway }), setTransform: (transform) => set({ transform }), setPlacementTransform: (placementTransform) => set({ placementTransform }), // Arming an item cancels a selection drag and vice versa: the next click cannot diff --git a/src/styles/global.css b/src/styles/global.css index e47a9c0..91b6e29 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -647,6 +647,32 @@ body { /* ---------- validation ---------- */ +.issues__group { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + margin: 8px 0 2px; +} + +.issues__group:first-child { + margin-top: 0; +} + +.issues__count { + font-variant-numeric: tabular-nums; +} + +.panel__count { + font-size: 12px; + color: var(--text-dim); + margin: 0; +} + .issues { list-style: none; margin: 8px 0 0; diff --git a/src/ui/PropertiesPanel.tsx b/src/ui/PropertiesPanel.tsx index 0335b48..e80837f 100644 --- a/src/ui/PropertiesPanel.tsx +++ b/src/ui/PropertiesPanel.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { structureIsEditable } from '../core/modes'; -import { formatArea, formatLength } from '../core/units'; +import { formatArea, formatLength, type DisplayUnit } from '../core/units'; import { wallAngleDeg, wallLength } from '../core/geometry/wall'; import { activeFloor, useStore } from '../state/store'; import { @@ -21,7 +21,9 @@ import { import { findItem, type Floor, type SpaceDocument } from '../core/document'; import { resolveElevation, roomAt, surfaceHeight } from '../core/placement'; import { ROTATION_STEP_DEG } from '../core/placement-snap'; -import { validateFloor, type Issue } from '../core/validation'; +import { validateFloor, type Issue, type IssueKind } from '../core/validation'; +import { WALKWAY_MIN_MM, isTooNarrow } from '../core/walkway'; +import { useWalkwayProbe } from './useWalkwayProbe'; import { backgroundExtentMm, isCalibrated } from '../core/calibration'; import { OPENING_KINDS, @@ -41,6 +43,47 @@ import { hasAsset } from '../state/assets'; import { LengthInput, NumberInput } from './LengthField'; import type { MountKind, Opening, OpeningKind } from '../core/document'; +/** + * What each check is called, for the group headings. + * + * Several kinds share a heading on purpose: "a mount is broken" is one thing to a + * reader whether the cause was a missing item, a deleted host or a cycle. The + * grouping is by *what you would do about it*, not by the enum. + */ +const ISSUE_GROUPS: Record = { + uncalibrated: 'Calibration', + 'broken-mount': 'Mounts', + 'missing-item': 'Mounts', + 'below-floor': 'Mounts', + 'opening-fit': 'Openings', + 'opening-overlap': 'Openings', + 'pocket-blocked': 'Openings', + 'swing-blocked': 'Door swing', + headroom: 'Headroom', + clearance: 'Clearance', + overlap: 'Overlaps', +}; + +/** + * Issues in their existing order, cut into runs by heading. + * + * `validateFloor` already sorts blocking-first then by kind, so walking it in order + * and starting a group whenever the heading changes preserves that priority — the + * top group is still the thing most worth doing something about. Sorting again here + * would be a second opinion about severity, in the component least qualified to have + * one. + */ +function groupIssues(issues: readonly Issue[]): { label: string; issues: Issue[] }[] { + const groups: { label: string; issues: Issue[] }[] = []; + for (const issue of issues) { + const label = ISSUE_GROUPS[issue.kind]; + const last = groups[groups.length - 1]; + if (last && last.label === label) last.issues.push(issue); + else groups.push({ label, issues: [issue] }); + } + return groups; +} + const MOUNT_LABELS: Record = { floor: 'On the floor', surface: 'On a surface', @@ -114,6 +157,55 @@ function OpeningSwing({ opening }: { opening: Opening }) { ); } +/** + * The walkway probe's answer, and a way to be rid of it. + * + * Not a validation issue, because the route is not part of the document — nobody + * else opening this file drew it, and a warning that survives into their copy would + * be about a question they never asked. It reads as what it is: a measurement you + * took, sitting next to the plan until you take another. + */ +function WalkwayReadout({ unit }: { unit: DisplayUnit }) { + const { probe } = useWalkwayProbe(); + + if (!probe) { + return ( +

+ Pick the Walkway tool and click a route through the space. Enter finishes it. +

+ ); + } + + return ( +
+ + {probe.blocked ? ( +

+ The route runs through something solid. +

+ ) : isTooNarrow(probe) ? ( +

+ Narrower than {formatLength(WALKWAY_MIN_MM, unit)}, the usual minimum for a + route you use every day. +

+ ) : null} +
+ +
+
+ ); +} + function Field({ label, value }: { label: string; value: string }) { return (
@@ -475,34 +567,55 @@ export function PropertiesPanel() {
) : null} +

Walkway

+ +

Validation

{issues.length === 0 ? (

- No issues. Item clearance zones and the walkway probe arrive in phase 7. + No issues.

) : ( -
    - {issues.map((issue, i) => ( -
  • - {/* Clicking an issue selects what it points at, so the panel is a way - to find the problem rather than only a way to hear about it. */} - -
  • - ))} -
+ <> +

+ {issues.length === 1 ? '1 issue' : `${issues.length} issues`} +

+ {/* One list, headed in place. + + Deliberately not one
    per group: the panel is read top to bottom as + a single ordered worklist, and the headings are signposts along it + rather than sections you would ever want to reorder or collapse + independently. */} +
      + {groupIssues(issues).flatMap((group) => [ +
    • + {group.label} + {group.issues.length} +
    • , + ...group.issues.map((issue, i) => ( +
    • + {/* Clicking an issue selects what it points at, so the panel is a + way to find the problem rather than only a way to hear about + it. */} + +
    • + )), + ])} +
    + )} ); diff --git a/src/ui/ToolPalette.tsx b/src/ui/ToolPalette.tsx index 64c41bf..b808042 100644 --- a/src/ui/ToolPalette.tsx +++ b/src/ui/ToolPalette.tsx @@ -7,6 +7,7 @@ import { PLAN_TOOL_LABELS, SHAPE_KINDS, SHAPE_KIND_LABELS, + type PlanTool, } from '../core/tools'; import { useStore } from '../state/store'; @@ -34,6 +35,16 @@ export function ToolPalette() { // correct them afterwards. See PLAN.md §6.1. const enabled = structureIsEditable(editMode) && !calibrating; + /** + * Tools that stay available while structure is locked. + * + * Select, and the walkway probe — which edits nothing at all, and whose whole + * purpose is to be running while you push furniture around. Disabling it in + * furnish mode would mean the one question it answers ("can I still get past?") + * could only be asked in the mode where you cannot move anything. + */ + const alwaysAvailable = (t: PlanTool) => t === 'select' || t === 'walkway'; + return (
    @@ -44,7 +55,7 @@ export function ToolPalette() { className="seg" data-active={t === tool} aria-pressed={t === tool} - disabled={!enabled && (calibrating || t !== 'select')} + disabled={!enabled && (calibrating || !alwaysAvailable(t))} title={`${PLAN_TOOL_LABELS[t]} (${PLAN_TOOL_KEYS[t].toUpperCase()})`} onClick={() => useStore.getState().setTool(t)} > diff --git a/src/ui/plan/DraftLayer.tsx b/src/ui/plan/DraftLayer.tsx index f38513b..bd0e7d5 100644 --- a/src/ui/plan/DraftLayer.tsx +++ b/src/ui/plan/DraftLayer.tsx @@ -5,11 +5,15 @@ import { formatLength, type DisplayUnit } from '../../core/units'; import { docToScreen, flattenToScreen, type Viewport } from '../../core/viewport'; import type { SnapHint } from '../../core/snapping'; import type { CalibrationRef, Measurement } from '../../state/store'; +import { isTooNarrow, type WalkwayProbe } from '../../core/walkway'; import type { PlanTheme } from './theme'; type Props = { draft: Draft | null; measurement: Measurement | null; + /** The committed walkway route, and the narrowest gap found along it. */ + walkway: readonly Vec2[] | null; + probe: WalkwayProbe | null; /** The calibration reference line, drawn while the gate is open. */ calibrationRef: CalibrationRef | null; snapHints: SnapHint[]; @@ -66,6 +70,8 @@ function SegmentLabel({ export function DraftLayer({ draft, measurement, + walkway, + probe, calibrationRef, snapHints, cursor, @@ -162,6 +168,55 @@ export function DraftLayer({ ) : null} + {/* The walkway gesture in flight — the same dashes as a wall chain, in the + measurement colour, because it measures rather than builds. */} + {draft?.tool === 'walkway' ? ( + <> + + {draft.points.map((p, i) => { + const s = docToScreen(viewport, p); + return ; + })} + + ) : null} + + {/* The committed route, and a tick across it at the narrowest point. The tick + is the answer: a number in a panel says 610mm, and this says *where*. */} + {walkway && walkway.length > 1 && draft?.tool !== 'walkway' ? ( + <> + + {probe ? ( + <> + + + + ) : null} + + ) : null} + {measurement && !draft ? ( <> { + const placement = placements.find((p) => p.id === only); + const item = placement ? findItem(doc, placement.itemId) : undefined; + if (!placement || !item?.clearances) return null; + + return item.clearances.map((zone, i) => { + const outline = zoneOutline(placement, item, zone); + if (!outline) return null; + return ( + + ); + }); + })() + : null} + {/* The rotate handle, on a single selected placement. It sticks out of the item's back — the edge wall snap aligns — so "handle pointing up" and "rotation 0" mean the same thing everywhere in the app. */} diff --git a/src/ui/plan/PlanStage.tsx b/src/ui/plan/PlanStage.tsx index 7cb03ae..63911be 100644 --- a/src/ui/plan/PlanStage.tsx +++ b/src/ui/plan/PlanStage.tsx @@ -38,11 +38,18 @@ import { GridLayer } from './GridLayer'; import { PlacementLayer } from './PlacementLayer'; import { StructureLayer } from './StructureLayer'; import { usePlanTheme } from './theme'; +import { useWalkwayProbe } from '../useWalkwayProbe'; /** Wheel notch → zoom factor. 1.0015^deltaY tracks a trackpad as smoothly as a mouse. */ const ZOOM_SENSITIVITY = 1.0015; -const DRAW_TOOLS: ReadonlySet = new Set(['wall', 'room', 'shape', 'dimension']); +const DRAW_TOOLS: ReadonlySet = new Set([ + 'wall', + 'room', + 'shape', + 'dimension', + 'walkway', +]); /** * How close two consecutive clicks must be, in screen pixels, to read as "click the @@ -76,6 +83,8 @@ export function PlanStage() { const lastClickPx = useRef<{ x: number; y: number } | null>(null); const calDrag = useRef(false); const theme = usePlanTheme(); + // Derived from the document, not stored: move the sofa and the gap moves with it. + const { path, probe } = useWalkwayProbe(); const { doc, @@ -173,7 +182,9 @@ export function PlanStage() { // loop only works when the final click happens to land in the same grid cell // as the start — and never at all with the grid off or Alt held. const inFlight = - state.draft?.tool === 'wall' ? [...snapCandidates, ...state.draft.points] : snapCandidates; + state.draft?.tool === 'wall' || state.draft?.tool === 'walkway' + ? [...snapCandidates, ...state.draft.points] + : snapCandidates; const ctx: SnapContext = { gridMm: documentGridMm(state.doc), @@ -415,11 +426,50 @@ export function PlanStage() { store.setMeasurement(null); store.setDraft({ tool: 'dimension', start: p, cursor: p }); return; + case 'walkway': { + // Click-to-place, exactly like the wall chain — the same gesture, because it + // is the same shape of thing and learning two would be one too many. It + // commits to editor state rather than to the document: a route through the + // room is a question you ask of the plan, not part of it. + const pos = stage.getPointerPosition(); + const previous = lastClickPx.current; + if (pos) lastClickPx.current = { x: pos.x, y: pos.y }; + + if (!draft || draft.tool !== 'walkway') { + store.setWalkway(null); + store.setDraft({ tool: 'walkway', points: [p], cursor: p }); + return; + } + + const repeated = + pos && previous && Math.hypot(pos.x - previous.x, pos.y - previous.y) <= REPEAT_CLICK_PX; + if (repeated) { + finishWalkway(draft.points); + return; + } + + store.setDraft({ tool: 'walkway', points: [...draft.points, p], cursor: p }); + return; + } default: return; } }; + /** + * End the walkway gesture, keeping the path only if there is a path. + * + * A single click and a stray Enter both land here, and a one-point route has no + * width to measure — clearing is better than leaving a dot on the plan that + * reports nothing. + */ + const finishWalkway = (points: Vec2[]) => { + const store = useStore.getState(); + store.setWalkway(points.length >= 2 ? points : null); + store.setDraft(null); + lastClickPx.current = null; + }; + const onMouseMove = (e: Konva.KonvaEventObject) => { const stage = e.target.getStage(); if (!stage) return; @@ -606,6 +656,7 @@ export function PlanStage() { addWallChain(current.points); store.setDraft(null); } + if (current?.tool === 'walkway') finishWalkway(current.points); lastClickPx.current = null; return; } @@ -732,7 +783,10 @@ export function PlanStage() { theme={theme} selection={selection} interactive={placementsInteractive} - selectable={placementsInteractive && !placingItemId} + // Same rule as the structure layer: only the Select tool selects. With the + // walkway armed a click on the sofa belongs to the route being drawn, and + // a selectable layer would swallow it. + selectable={placementsInteractive && !placingItemId && tool === 'select'} transform={placementTransform} flagged={flagged} onSelect={onSelect} @@ -742,6 +796,8 @@ export function PlanStage() { ({ doc: s.doc, walkway: s.walkway })), + ); + + const probe = useMemo(() => { + if (!walkway || walkway.length < 2) return null; + const floor = activeFloor({ doc }); + return narrowestGap(walkway, walkwayObstructions(doc, floor)); + }, [doc, walkway]); + + return { path: walkway, probe }; +} From d7aee04962a35bd09d46bbaf0f1ec3d00f178a07 Mon Sep 17 00:00:00 2001 From: Chintan Date: Wed, 2 Sep 2026 20:26:58 -0400 Subject: [PATCH 12/19] A lamp on the dresser is not blocking the dresser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `findClearanceViolations` skipped the zone's owner and anything a stride clears, but not what was stacked *on* the owner. A lamp surface-mounted on a dresser rides on it and cannot be in the way of its drawers, whatever its footprint does. It was quiet only by coincidence. A zone runs from the floor to `heightMm`, whose default is the host's own height — which is also the elevation a surface-mounted child resolves to. The two spans meet exactly and `spansOverlap` is strict, so nothing fired. Set a zone's `heightMm` explicitly, which is the documented reason the field exists ("a drawer pull at 810mm is indifferent to a shelf at 1500"), and the coincidence goes: the lamp reports that it blocks the dresser. Nothing in the shipped preset library triggers it, so this is a latent trap rather than a live bug — confirmed by writing the test first and watching it fail. The walk is by chain rather than one level, so a tray on a lamp on a dresser is still on the dresser, and it terminates on a cycle instead of hanging. Paired with a test that something stacked on a *different* nearby item still reports, so the exemption is "it rides on the host", not "it is off the floor". Also: `narrowestGap` now says it assumes pre-filtered obstructions — it consults no spans, and `walkwayObstructions` is what filters by band. And a test that the walkway route does not survive `loadDocument`, which it already did not. 558 unit tests, 85 e2e. --- src/core/clearance.test.ts | 34 ++++++++++++++++++++++++++++ src/core/clearance.ts | 45 +++++++++++++++++++++++++++++++++----- src/core/walkway.ts | 4 ++++ src/state/store.test.ts | 11 ++++++++++ 4 files changed, 88 insertions(+), 6 deletions(-) diff --git a/src/core/clearance.test.ts b/src/core/clearance.test.ts index 05a8d18..f154592 100644 --- a/src/core/clearance.test.ts +++ b/src/core/clearance.test.ts @@ -240,3 +240,37 @@ describe('what blocks a zone', () => { expect(violations(d)).toEqual([]); }); }); + +describe('something sitting on the zoned item itself', () => { + const LAMP: ItemDraft = { + name: 'Lamp', + category: 'lighting', + shape: 'circle', + widthMm: 300, + depthMm: 300, + heightMm: 500, + voidBelowMm: 0, + }; + + it('does not report a lamp on the dresser as blocking the dresser drawers', () => { + // The lamp rides on the host. It cannot be in the way of the host opening, + // whatever its footprint does — and the zone runs to the floor, so the only + // thing keeping this quiet by accident is the zone top and the surface height + // being the same number. + const d = doc(); + const dresser = place(d, { ...DRESSER, clearances: [{ ...DRAWER, heightMm: 900 }] }, { x: 0, y: 0 }); + place(d, LAMP, { x: 0, y: 400 }, { mount: { kind: 'surface', hostId: dresser.id } }); + + expect(violations(d)).toEqual([]); + }); + + it('still reports something stacked on a different item nearby', () => { + // The exemption is "it rides on the host", not "it is off the floor". + const d = doc(); + place(d, DRESSER, { x: 0, y: 0 }); + const table = place(d, { ...DRESSER, name: 'Side table', heightMm: 500 }, { x: 0, y: 900 }); + place(d, LAMP, { x: 0, y: 600 }, { mount: { kind: 'surface', hostId: table.id } }); + + expect(violations(d).length).toBeGreaterThan(0); + }); +}); diff --git a/src/core/clearance.ts b/src/core/clearance.ts index 2673ced..e125291 100644 --- a/src/core/clearance.ts +++ b/src/core/clearance.ts @@ -179,6 +179,27 @@ export function floorZones( // Violations // --------------------------------------------------------------------------- +/** + * Everything a placement is stacked on, however many items deep. + * + * Walked rather than looked up one level: a tray on a lamp on a dresser is still on + * the dresser. A cycle terminates the walk instead of hanging — `placementVolume` + * has already thrown `MountCycleError` for those, so this only guards the traversal. + */ +function hostsAbove(byId: Map, placement: Placement): Set { + const hosts = new Set(); + let current = placement; + while (current.mount.kind === 'surface') { + const hostId = current.mount.hostId; + if (hosts.has(hostId)) break; + hosts.add(hostId); + const next = byId.get(hostId); + if (!next) break; + current = next; + } + return hosts; +} + export type ZoneViolation = { /** The item whose clearance is compromised. */ placementId: Id; @@ -192,16 +213,27 @@ export type ZoneViolation = { /** * Everything standing in a clearance zone it should not be. * - * The host is skipped (a zone starts at its own bounding-box edge, so it could only - * ever catch itself on a rounding error) and anything a stride clears is skipped for - * the reason in the module comment. Everything else is a plain volume-vs-volume test: - * footprints overlap in plan *and* solid spans overlap. + * Three things are skipped. The host itself, because a zone starts at its own + * bounding-box edge. Anything a stride clears, for the reason in the module comment. + * And **anything stacked on the host**, at any depth: a lamp on the dresser rides on + * the dresser and cannot be in the way of it opening, whatever its footprint does. + * + * That last one is not theoretical. A zone runs from the floor to `heightMm`, and the + * default `heightMm` is the host's own height — which is also the elevation a + * surface-mounted child resolves to, so the two spans meet exactly and `spansOverlap` + * is strict enough to stay quiet. Give a zone the explicit height the field exists for + * ("a drawer pull at 810mm is indifferent to a shelf at 1500") and the coincidence + * disappears: the lamp on the dresser starts reporting that it blocks the dresser. + * + * Everything else is a plain volume-vs-volume test: footprints overlap in plan *and* + * solid spans overlap. */ export function findClearanceViolations(doc: SpaceDocument, floor: Floor): ZoneViolation[] { const zones = floorZones(doc, floor); if (zones.length === 0) return []; - const obstacles: { id: Id; volume: Volume }[] = []; + const byId = new Map(floor.placements.map((p) => [p.id, p])); + const obstacles: { id: Id; volume: Volume; ridesOn: Set }[] = []; for (const placement of floor.placements) { const item = findItem(doc, placement.itemId); if (!item) continue; @@ -209,7 +241,7 @@ export function findClearanceViolations(doc: SpaceDocument, floor: Floor): ZoneV const volume = placementVolume(doc, placement, item); // What you step over cannot block a drawer. if (volume.span.top < CLEARANCE_STEP_OVER_MM) continue; - obstacles.push({ id: placement.id, volume }); + obstacles.push({ id: placement.id, volume, ridesOn: hostsAbove(byId, placement) }); } catch (err) { if (err instanceof MountCycleError) continue; throw err; @@ -220,6 +252,7 @@ export function findClearanceViolations(doc: SpaceDocument, floor: Floor): ZoneV for (const { placement, zone, volume } of zones) { for (const obstacle of obstacles) { if (obstacle.id === placement.id) continue; + if (obstacle.ridesOn.has(placement.id)) continue; if (!spansOverlap(volume.span, obstacle.volume.span)) continue; const overlapMm2 = intersectionArea(volume.outline, obstacle.volume.outline); diff --git a/src/core/walkway.ts b/src/core/walkway.ts index 84ae3ea..8149853 100644 --- a/src/core/walkway.ts +++ b/src/core/walkway.ts @@ -206,6 +206,10 @@ export function samplePath(path: readonly Vec2[], step: number): { at: Vec2; dir /** * The narrowest gap across the path, measured square to it at each sample. * + * **Assumes every obstruction is already in the band you care about.** Nothing here + * consults spans; `walkwayObstructions` is what filters by height, and passing it an + * unfiltered list would measure gaps against rugs and ceiling pendants. + * * Null for a path with no length — there is nothing to be narrow. */ export function narrowestGap( diff --git a/src/state/store.test.ts b/src/state/store.test.ts index 795c27f..dbee0f6 100644 --- a/src/state/store.test.ts +++ b/src/state/store.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; +import { createDocument } from '../core/document'; import { activeFloor, floorBounds, useStore, HISTORY_LIMIT } from './store'; import { addRoomRect, @@ -383,4 +384,14 @@ describe('the walkway route', () => { useStore.getState().newDocument(); expect(useStore.getState().walkway).toBeNull(); }); + + it('does not survive opening a file', () => { + // A route drawn on one plan means nothing on another, and would draw a line + // across a room it was never measured in. + useStore.getState().setWalkway(ROUTE); + useStore.getState().loadDocument( + createDocument({ id: 'other', floorId: 'f2', now: '2026-01-01T00:00:00.000Z' }), + ); + expect(useStore.getState().walkway).toBeNull(); + }); }); From d3133761437ac2c8338ae74fe9d03fb83011fad7 Mon Sep 17 00:00:00 2001 From: Chintan Date: Wed, 2 Sep 2026 20:44:18 -0400 Subject: [PATCH 13/19] Phase 8a: which of these walls enclose a room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detection walks the planar graph of wall centrelines and keeps the faces that wind positive. By sign, not by magnitude: a courtyard's outer face is smaller than the room around it, so "discard the biggest" gets it backwards. Walls are split at crossings *and* at T-junctions. The T-junction pass is the one that matters — a partition butting into the middle of a wall has its endpoint on that wall's interior, and without a node there the graph has no branch and the walk hands back the single loop around the outside. Boundaries are centrelines, matching commitRoomRect, so a drawn room and its detected twin compare equal — asserted, along with the ring canonicalisation that makes a second run a genuine no-op rather than a rewrite of every boundary in the document. Detection adds and updates but never deletes. An Area-tool room has no walls by design; removing what detection cannot see would delete a legitimate room on every run. Unmatched rooms are reported and left alone. Matching is by overlap area, so the larger half of a partitioned room keeps its name. Ceiling height is now editable, which is what makes it worth having: the headroom check reads it through ceilingHeightAt, so lowering a room to 1900mm immediately reports the wardrobe that no longer fits. The three mechanisms were each verified by reverting them and watching the right tests go red. --- e2e/plan-editor.spec.ts | 4 +- e2e/rooms.spec.ts | 135 ++++++++++++ src/core/rooms.test.ts | 310 ++++++++++++++++++++++++++ src/core/rooms.ts | 437 +++++++++++++++++++++++++++++++++++++ src/state/actions.ts | 43 ++++ src/ui/PropertiesPanel.tsx | 65 +++++- 6 files changed, 991 insertions(+), 3 deletions(-) create mode 100644 e2e/rooms.spec.ts create mode 100644 src/core/rooms.test.ts create mode 100644 src/core/rooms.ts diff --git a/e2e/plan-editor.spec.ts b/e2e/plan-editor.spec.ts index 506b1aa..8335af4 100644 --- a/e2e/plan-editor.spec.ts +++ b/e2e/plan-editor.spec.ts @@ -66,7 +66,7 @@ test.describe('drawing', () => { test('picks up tools by keyboard shortcut', async ({ page }) => { await page.keyboard.press('w'); - await expect(page.getByRole('button', { name: 'Wall' })).toHaveAttribute( + await expect(page.getByRole('button', { name: 'Wall', exact: true })).toHaveAttribute( 'aria-pressed', 'true', ); @@ -126,7 +126,7 @@ test.describe('mode toggle', () => { await expect(page.getByTestId('count-walls')).toContainText('4'); await page.getByRole('button', { name: 'Arrange furniture' }).click(); - await expect(page.getByRole('button', { name: 'Wall' })).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Wall', exact: true })).toBeDisabled(); // A click that would have hit a wall in plan mode selects nothing here. await clickAt(page, stage, { x: 2000, y: 0 }); diff --git a/e2e/rooms.spec.ts b/e2e/rooms.spec.ts new file mode 100644 index 0000000..a34f3bc --- /dev/null +++ b/e2e/rooms.spec.ts @@ -0,0 +1,135 @@ +import { expect, test } from '@playwright/test'; +import { clickAt, dragBetween, selectTool } from './coords'; + +/** + * Room detection and per-room ceiling heights — PLAN.md §11. + * + * The screen-to-document mapping comes from `./coords`; nothing here zooms, pans or + * fits, so it holds throughout. + */ + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('plan-stage')).toBeVisible(); +}); + +test.describe('detecting rooms from walls', () => { + test('finds the two rooms a partitioned rectangle encloses', async ({ page }) => { + const stage = page.getByTestId('plan-stage'); + + // A 6 x 4 shell drawn as a wall loop, so nothing has traced a room yet. + await selectTool(page, 'Wall'); + await clickAt(page, stage, { x: 0, y: 0 }); + await clickAt(page, stage, { x: 6000, y: 0 }); + await clickAt(page, stage, { x: 6000, y: 4000 }); + await clickAt(page, stage, { x: 0, y: 4000 }); + await clickAt(page, stage, { x: 0, y: 0 }); + await expect(page.getByTestId('count-walls')).toContainText('4'); + + // A partition butting into the middle of the top and bottom walls — the + // T-junction case, where the endpoints land on another wall's interior. + await selectTool(page, 'Wall'); + await clickAt(page, stage, { x: 4000, y: 0 }); + await clickAt(page, stage, { x: 4000, y: 4000 }); + await page.keyboard.press('Enter'); + + await expect(page.getByTestId('count-rooms')).toContainText('0'); + await page.getByTestId('detect-rooms').click(); + await expect(page.getByTestId('count-rooms')).toContainText('2'); + await expect(page.getByTestId('detect-report')).toContainText('2 new'); + }); + + test('changes nothing the second time it is run', async ({ page }) => { + const stage = page.getByTestId('plan-stage'); + await selectTool(page, 'Room'); + await dragBetween(page, stage, { x: 0, y: 0 }, { x: 4000, y: 3000 }); + + // The Room tool draws its boundary on the wall centrelines, which is exactly + // what detection derives — so there is nothing to find and nothing to change. + await page.getByTestId('detect-rooms').click(); + await expect(page.getByTestId('detect-report')).toContainText('No change'); + await expect(page.getByTestId('count-rooms')).toContainText('1'); + }); + + test('keeps the name of a room it reshapes', async ({ page }) => { + const stage = page.getByTestId('plan-stage'); + await selectTool(page, 'Room'); + await dragBetween(page, stage, { x: 0, y: 0 }, { x: 6000, y: 4000 }); + + await selectTool(page, 'Select'); + await clickAt(page, stage, { x: 3000, y: 2000 }); + const name = page.getByTestId('room-properties').getByRole('textbox').first(); + await name.fill('Living room'); + await name.press('Enter'); + + // Partition it. The larger half keeps the name; the smaller becomes a new room. + await selectTool(page, 'Wall'); + await clickAt(page, stage, { x: 4000, y: 0 }); + await clickAt(page, stage, { x: 4000, y: 4000 }); + await page.keyboard.press('Enter'); + await page.getByTestId('detect-rooms').click(); + + await expect(page.getByTestId('count-rooms')).toContainText('2'); + await expect(page.getByTestId('detect-report')).toContainText('1 new'); + await selectTool(page, 'Select'); + await clickAt(page, stage, { x: 2000, y: 2000 }); + await expect(page.getByTestId('room-properties').getByRole('textbox').first()).toHaveValue( + 'Living room', + ); + }); + + test('is one undo step', async ({ page }) => { + const stage = page.getByTestId('plan-stage'); + await selectTool(page, 'Wall'); + await clickAt(page, stage, { x: 0, y: 0 }); + await clickAt(page, stage, { x: 4000, y: 0 }); + await clickAt(page, stage, { x: 4000, y: 3000 }); + await clickAt(page, stage, { x: 0, y: 3000 }); + await clickAt(page, stage, { x: 0, y: 0 }); + + await page.getByTestId('detect-rooms').click(); + await expect(page.getByTestId('count-rooms')).toContainText('1'); + + await page.getByRole('button', { name: 'Undo', exact: true }).click(); + await expect(page.getByTestId('count-rooms')).toContainText('0'); + // And the walls it was derived from are still there — undo reversed the + // detection, not the drawing. + await expect(page.getByTestId('count-walls')).toContainText('4'); + }); + + test('is locked in furnish mode, like every other structure edit', async ({ page }) => { + await page.getByRole('button', { name: 'Arrange furniture', exact: true }).click(); + await expect(page.getByTestId('detect-rooms')).toBeDisabled(); + }); +}); + +test.describe('per-room ceiling height', () => { + test('lowering a ceiling reports the furniture that no longer fits', async ({ page }) => { + const stage = page.getByTestId('plan-stage'); + await selectTool(page, 'Room'); + await dragBetween(page, stage, { x: 0, y: 0 }, { x: 5000, y: 4000 }); + + // A 2000mm-tall wardrobe clears a standard 2438 ceiling with room to spare. + await page.getByLabel('Add from the preset library').selectOption({ label: 'Storage — Wardrobe' }); + await page.getByRole('button', { name: 'Place', exact: true }).last().click(); + await clickAt(page, stage, { x: 2500, y: 2000 }); + await page.keyboard.press('Escape'); + await expect(page.getByTestId('no-issues')).toBeVisible(); + + // Back to the plan: placing a preset arms furnish mode, and a ceiling height is + // a structure property like every other thing a room owns. + await page.getByRole('button', { name: 'Edit floor plan', exact: true }).click(); + + // Drop the ceiling below it. `ceilingHeightAt` reads the room the placement + // stands in, so the headroom check answers differently with no other change. + await selectTool(page, 'Select'); + await clickAt(page, stage, { x: 1000, y: 3500 }); + const ceiling = page.getByTestId('room-ceiling'); + // Suffixed, because the document displays feet and inches and a bare number is + // read in the display unit — 1900 would be 1900 *inches*. + await ceiling.fill('1900mm'); + await ceiling.press('Enter'); + + await expect(page.getByTestId('issue-list')).toContainText('Headroom'); + }); +}); diff --git a/src/core/rooms.test.ts b/src/core/rooms.test.ts new file mode 100644 index 0000000..aa4d109 --- /dev/null +++ b/src/core/rooms.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from 'vitest'; +import { + JOIN_TOLERANCE_MM, + detectLoops, + detectRooms, + sameRing, + uniqueRoomName, +} from './rooms'; +import { commitRoomRect } from './tools'; +import { createDocument, type Floor, type Room, type Wall } from './document'; +import { area, bounds, isCounterClockwise, polygon } from './geometry/polygon'; +import type { Vec2 } from './geometry/vec'; + +let seq = 0; +const nextId = () => `x${seq++}`; + +function wall(a: Vec2, b: Vec2): Wall { + return { id: nextId(), a, b, thicknessMm: 114, heightMm: 2438, baseElevationMm: 0 }; +} + +/** The four walls of an axis-aligned rectangle, on its centrelines. */ +function rectWalls(minX: number, minY: number, maxX: number, maxY: number): Wall[] { + const c: Vec2[] = [ + { x: minX, y: minY }, + { x: maxX, y: minY }, + { x: maxX, y: maxY }, + { x: minX, y: maxY }, + ]; + return c.map((p, i) => wall(p, c[(i + 1) % 4]!)); +} + +function floorWith(walls: Wall[], rooms: Room[] = []): Floor { + const doc = createDocument({ id: 'd', floorId: 'f', now: '2026-01-01T00:00:00.000Z' }); + const floor = doc.floors[0]!; + floor.walls = walls; + floor.rooms = rooms; + return floor; +} + +const areas = (loops: { pts: Vec2[] }[]) => + loops.map((l) => Math.round(area(polygon(l.pts)))).sort((a, b) => a - b); + +describe('finding loops in the wall graph', () => { + it('finds the one room a closed rectangle encloses', () => { + const loops = detectLoops(rectWalls(0, 0, 4000, 3000)); + + expect(loops).toHaveLength(1); + expect(area(loops[0]!)).toBe(12_000_000); + }); + + it('finds two rooms that share a wall, not one room around both', () => { + // The discriminating fixture. A single rectangle's interior and outer faces have + // the same |area| and opposite signs, so a test on one room passes even if the + // outer face was kept. Two rooms is where keeping the wrong one shows up: it + // gives one loop of 24m² instead of two of 12m². + const loops = detectLoops([ + ...rectWalls(0, 0, 4000, 3000), + ...rectWalls(4000, 0, 8000, 3000), + ]); + + expect(loops).toHaveLength(2); + expect(areas(loops)).toEqual([12_000_000, 12_000_000]); + }); + + it('winds every loop counter-clockwise, the convention every other polygon uses', () => { + const loops = detectLoops(rectWalls(0, 0, 4000, 3000)); + expect(isCounterClockwise(loops[0]!)).toBe(true); + }); + + it('splits a wall at a T-junction so a partition makes two rooms', () => { + // A partition butting into the middle of a wall has its endpoint on that wall's + // *interior*. Without splitting there is no node there, the graph has no branch, + // and the walk hands back the single loop around the outside. + const loops = detectLoops([ + ...rectWalls(0, 0, 4000, 3000), + wall({ x: 1500, y: 0 }, { x: 1500, y: 3000 }), + ]); + + expect(areas(loops)).toEqual([4_500_000, 7_500_000]); + }); + + it('still splits when the partition lands a couple of millimetres off', () => { + // Traced by hand over an imported plan, an endpoint does not land on the + // centreline. Two millimetres out is inside the join tolerance and must still + // close the cycle. + const loops = detectLoops([ + ...rectWalls(0, 0, 4000, 3000), + wall({ x: 1500, y: 2 }, { x: 1502, y: 2998 }), + ]); + + expect(loops).toHaveLength(2); + }); + + it('does not weld a partition that misses by more than the tolerance', () => { + // A gap wider than the tolerance is a doorway-sized hole, and the two "rooms" + // really are one space. Reporting two would be inventing a wall. + const loops = detectLoops([ + ...rectWalls(0, 0, 4000, 3000), + wall({ x: 1500, y: JOIN_TOLERANCE_MM * 5 }, { x: 1500, y: 3000 }), + ]); + + expect(loops).toHaveLength(1); + expect(area(loops[0]!)).toBe(12_000_000); + }); + + it('cuts both walls where two cross in the middle', () => { + const loops = detectLoops([ + ...rectWalls(0, 0, 4000, 4000), + wall({ x: 2000, y: 0 }, { x: 2000, y: 4000 }), + wall({ x: 0, y: 2000 }, { x: 4000, y: 2000 }), + ]); + + expect(areas(loops)).toEqual([4_000_000, 4_000_000, 4_000_000, 4_000_000]); + }); + + it('ignores a spur that encloses nothing', () => { + const loops = detectLoops([ + ...rectWalls(0, 0, 4000, 3000), + wall({ x: 2000, y: 3000 }, { x: 2000, y: 5000 }), + ]); + + expect(loops).toHaveLength(1); + expect(area(loops[0]!)).toBe(12_000_000); + }); + + it('finds nothing in walls that do not close', () => { + expect( + detectLoops([ + wall({ x: 0, y: 0 }, { x: 4000, y: 0 }), + wall({ x: 4000, y: 0 }, { x: 4000, y: 3000 }), + ]), + ).toEqual([]); + }); + + it('keeps a courtyard as its own room, inside the ring around it', () => { + // Polygons here have no holes, so an island of walls does not punch one. The + // outer room's area includes the courtyard, which is stated in the module rather + // than quietly wrong. + const loops = detectLoops([ + ...rectWalls(0, 0, 6000, 6000), + ...rectWalls(2000, 2000, 4000, 4000), + ]); + + expect(areas(loops)).toEqual([4_000_000, 36_000_000]); + }); + + it('drops a sliver between two near-parallel walls', () => { + const loops = detectLoops([ + wall({ x: 0, y: 0 }, { x: 4000, y: 0 }), + wall({ x: 4000, y: 0 }, { x: 4000, y: 2 }), + wall({ x: 4000, y: 2 }, { x: 0, y: 2 }), + wall({ x: 0, y: 2 }, { x: 0, y: 0 }), + ]); + + expect(loops).toEqual([]); + }); + + it('starts every ring at the same corner however the walk entered it', () => { + // Canonicalising the start is what makes a second run a no-op instead of a + // rewrite of every boundary in the document. + const a = detectLoops(rectWalls(0, 0, 4000, 3000)); + const b = detectLoops([...rectWalls(0, 0, 4000, 3000)].reverse()); + + expect(sameRing(a[0]!, b[0]!)).toBe(true); + expect(a[0]!.pts[0]).toEqual({ x: 0, y: 0 }); + }); +}); + +describe('agreeing with a room drawn by hand', () => { + it('detects exactly the boundary and area the Room tool committed', () => { + // The two paths have to describe the same walls with the same number, or a plan + // half drawn and half detected reports two different areas for two identical + // rooms. Boundaries are centrelines in both. + const drawn = commitRoomRect({ x: 0, y: 0 }, { x: 4000, y: 3000 }, { + name: 'Living', + makeId: nextId, + })!; + const loops = detectLoops(drawn.walls); + + expect(loops).toHaveLength(1); + expect(sameRing(loops[0]!, drawn.room.boundary)).toBe(true); + expect(Math.round(area(loops[0]!))).toBe(drawn.room.areaMm2); + }); +}); + +describe('reconciling with the rooms already there', () => { + it('is a no-op the second time, once the boundaries match', () => { + const drawn = commitRoomRect({ x: 0, y: 0 }, { x: 4000, y: 3000 }, { + name: 'Living', + makeId: nextId, + })!; + const floor = floorWith(drawn.walls, [drawn.room]); + + expect(detectRooms(floor, { makeId: nextId })).toEqual({ + updated: [], + added: [], + unmatched: [], + }); + }); + + it('adds a room for a loop nothing covers', () => { + const floor = floorWith(rectWalls(0, 0, 4000, 3000)); + const result = detectRooms(floor, { makeId: nextId }); + + expect(result.added).toHaveLength(1); + expect(result.added[0]!.name).toBe('Room 1'); + expect(result.added[0]!.areaMm2).toBe(12_000_000); + expect(result.added[0]!.ceilingHeightMm).toBe(floor.defaultCeilingHeightMm); + }); + + it('keeps the name and ceiling height of the room it matched', () => { + // The boundary is derived; everything a person chose is not. + const floor = floorWith(rectWalls(0, 0, 4000, 3000), [ + { + id: 'r1', + name: 'Living room', + boundary: polygon([ + { x: 100, y: 100 }, + { x: 3900, y: 100 }, + { x: 3900, y: 2900 }, + { x: 100, y: 2900 }, + ]), + ceilingHeightMm: 3200, + areaMm2: 10_640_000, + }, + ]); + + const result = detectRooms(floor, { makeId: nextId }); + expect(result.added).toEqual([]); + expect(result.updated).toEqual([ + { roomId: 'r1', boundary: expect.anything(), areaMm2: 12_000_000 }, + ]); + // Nothing in the plan touches the name or the ceiling: they are not in it. + expect(Object.keys(result.updated[0]!)).toEqual(['roomId', 'boundary', 'areaMm2']); + }); + + it('gives the larger half of a partitioned room the old name', () => { + // Overlap area rather than centroid containment, because a partition can leave + // the old centroid in either half — or inside the partition itself. + const existing: Room = { + id: 'r1', + name: 'Living room', + boundary: polygon([ + { x: 0, y: 0 }, + { x: 4000, y: 0 }, + { x: 4000, y: 3000 }, + { x: 0, y: 3000 }, + ]), + ceilingHeightMm: 2438, + areaMm2: 12_000_000, + }; + const floor = floorWith( + [...rectWalls(0, 0, 4000, 3000), wall({ x: 1500, y: 0 }, { x: 1500, y: 3000 })], + [existing], + ); + + const result = detectRooms(floor, { makeId: nextId }); + expect(result.updated).toHaveLength(1); + expect(result.updated[0]!.roomId).toBe('r1'); + expect(result.updated[0]!.areaMm2).toBe(7_500_000); // the 2500-wide half + expect(result.added).toHaveLength(1); + expect(result.added[0]!.areaMm2).toBe(4_500_000); + }); + + it('reports a room no loop supports, and leaves it alone', () => { + // An Area-tool room has no walls at all. Deleting unmatched rooms would remove a + // legitimate one on every run. + const floor = floorWith(rectWalls(0, 0, 4000, 3000), [ + { + id: 'circle', + name: 'Terrace', + boundary: polygon([ + { x: 10_000, y: 0 }, + { x: 12_000, y: 0 }, + { x: 11_000, y: 2000 }, + ]), + ceilingHeightMm: 2438, + areaMm2: 2_000_000, + }, + ]); + + const result = detectRooms(floor, { makeId: nextId }); + expect(result.unmatched).toEqual(['circle']); + expect(result.added).toHaveLength(1); + }); + + it('names new rooms around the ones already named', () => { + expect(uniqueRoomName('Room 1', [{ name: 'Room 1' }])).toBe('Room 1 2'); + expect(uniqueRoomName('Room 1', [])).toBe('Room 1'); + }); +}); + +describe('sameRing', () => { + it('is false for the same shape started at a different corner', () => { + // Which is the whole reason rings are canonicalised before they are compared. + const a = polygon([ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + ]); + const b = polygon([ + { x: 10, y: 0 }, + { x: 10, y: 10 }, + { x: 0, y: 0 }, + ]); + + expect(sameRing(a, b)).toBe(false); + expect(bounds(a)).toEqual(bounds(b)); + }); +}); diff --git a/src/core/rooms.ts b/src/core/rooms.ts new file mode 100644 index 0000000..5a7080b --- /dev/null +++ b/src/core/rooms.ts @@ -0,0 +1,437 @@ +/** + * Room detection from the wall graph. See PLAN.md §11. + * + * A room is a named polygon. You can trace one directly — that is what the Room and + * Area tools do — or derive one from the walls that already enclose it, which is what + * this module is. Deriving is the useful half once a plan has been traced over an + * imported raster: you drew forty walls, and asking "which of these enclose a space?" + * is a question the geometry can answer. + * + * ## The algorithm + * + * Wall centrelines form a planar graph once they are **split at every crossing and + * every T-junction**. Its faces are found by the standard half-edge walk: arriving at + * a node along `u → v`, leave by the first edge encountered rotating *clockwise* from + * the direction back towards `u`. Every directed edge belongs to exactly one face, so + * walking each unvisited one enumerates the faces exhaustively. + * + * That rule makes interior faces come out with **positive signed area** and each + * connected component's outer face negative — which is the test used here, rather + * than "discard the biggest". Magnitude fails on a courtyard, where the outer face of + * an inner ring of walls is smaller than the room around it. + * + * ## Four decisions + * + * **Boundaries are centrelines, not inner faces.** `commitRoomRect` already draws its + * boundary on the wall centrelines, and matching it is what lets a drawn room and a + * detected one describe the same walls with the same number. The alternative — insetting + * by half a thickness — is more nearly the floor area you could carpet, and would make + * the two paths disagree about every room you drew by hand. + * + * **Detection adds and updates; it never deletes.** A room traced with the Area tool + * has no walls at all — that is the whole point of `commitShapeRoom` — so removing + * rooms with no supporting loop would delete a legitimate one every single run. An + * unmatched room is reported and left alone. (Reporting it as a *validation issue* + * was considered and rejected for the same reason: it would flag every shape room + * forever, which is noise, not a finding.) + * + * **A detected room is a simple ring.** An island of walls standing inside a room does + * not punch a hole in it — `Polygon` has no holes — so a courtyard is detected as its + * own room *and* left inside the ring around it. Stated rather than silently wrong. + * + * **Matching is by area overlap, greedily, worst case first.** Centroid containment is + * cheaper and breaks exactly where it matters: put a partition down the middle of a + * room and the old centroid may land in either half, or in the partition. Overlap area + * gives the larger half the old name, which is the one a person would still call the + * living room. The smaller half becomes a new room with a fresh name. + * + * Pure — no DOM, no store. + */ + +import type { Floor, Id, Room, Wall } from './document'; +import { DEFAULT_CEILING_HEIGHT_MM } from './document'; +import { intersectionArea } from './geometry/collision'; +import { + area, + bounds, + boundsOverlap, + polygon, + signedArea, + type Polygon, +} from './geometry/polygon'; +import { distanceToSegment, type Vec2 } from './geometry/vec'; + +/** + * How close two points have to be to be the same corner. + * + * Endpoints that were snapped together are exact, but a wall traced by hand over an + * imported plan lands a millimetre or two out, and two nodes a millimetre apart break + * the cycle they were meant to close. Also the reach for a T-junction: a partition + * drawn *at* another wall rather than exactly onto its centreline still splits it. + */ +export const JOIN_TOLERANCE_MM = 20; + +/** + * Below this a face is a sliver between near-parallel walls, not a room. + * + * 0.01 m² — a 100mm square. Deliberately its own threshold rather than a reuse of the + * Room tool's minimum side: that one rejects a stray click, this one rejects geometry. + */ +export const MIN_DETECTED_AREA_MM2 = 10_000; + +// --------------------------------------------------------------------------- +// Splitting +// --------------------------------------------------------------------------- + +type Segment = { a: Vec2; b: Vec2 }; + +/** Parameter along `seg` of the point on it closest to `p`, unclamped. */ +function projectParam(seg: Segment, p: Vec2): number { + const dx = seg.b.x - seg.a.x; + const dy = seg.b.y - seg.a.y; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) return 0; + return ((p.x - seg.a.x) * dx + (p.y - seg.a.y) * dy) / lenSq; +} + +function at(seg: Segment, t: number): Vec2 { + return { x: seg.a.x + (seg.b.x - seg.a.x) * t, y: seg.a.y + (seg.b.y - seg.a.y) * t }; +} + +function segmentLength(seg: Segment): number { + return Math.hypot(seg.b.x - seg.a.x, seg.b.y - seg.a.y); +} + +/** + * Where two segments properly cross, as a parameter on each. Null when they do not. + * + * Only true crossings — an intersection at either segment's endpoint is already a + * shared node once the endpoints weld, and touching ends are the common case at every + * corner of every room. + */ +function crossingParams(p: Segment, q: Segment): { tp: number; tq: number } | null { + const rx = p.b.x - p.a.x; + const ry = p.b.y - p.a.y; + const sx = q.b.x - q.a.x; + const sy = q.b.y - q.a.y; + const denom = rx * sy - ry * sx; + if (denom === 0) return null; // parallel or collinear — the T-junction pass covers it + + const tp = ((q.a.x - p.a.x) * sy - (q.a.y - p.a.y) * sx) / denom; + const tq = ((q.a.x - p.a.x) * ry - (q.a.y - p.a.y) * rx) / denom; + + const edgeP = JOIN_TOLERANCE_MM / Math.max(1, segmentLength(p)); + const edgeQ = JOIN_TOLERANCE_MM / Math.max(1, segmentLength(q)); + if (tp <= edgeP || tp >= 1 - edgeP) return null; + if (tq <= edgeQ || tq >= 1 - edgeQ) return null; + return { tp, tq }; +} + +/** + * Every wall cut at every crossing and every T-junction. + * + * The T-junction pass is what makes detection work on a real plan. A partition drawn + * to butt into the middle of another wall has an endpoint on that wall's *interior*, + * so without a node there the graph has no branch at all and the walk returns the + * single loop around the outside — the "detection does not see my partition" report. + */ +function splitWalls(walls: readonly Wall[], tolerance: number): Segment[] { + const segments: Segment[] = walls + .map((w) => ({ a: w.a, b: w.b })) + .filter((s) => segmentLength(s) > tolerance); + + const cuts: Set[] = segments.map(() => new Set()); + + for (let i = 0; i < segments.length; i++) { + for (let j = i + 1; j < segments.length; j++) { + const hit = crossingParams(segments[i]!, segments[j]!); + if (hit) { + cuts[i]!.add(hit.tp); + cuts[j]!.add(hit.tq); + } + } + } + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]!; + const edge = tolerance / Math.max(1, segmentLength(seg)); + for (let j = 0; j < segments.length; j++) { + if (i === j) continue; + for (const end of [segments[j]!.a, segments[j]!.b]) { + if (distanceToSegment(end, seg.a, seg.b) > tolerance) continue; + const t = projectParam(seg, end); + if (t > edge && t < 1 - edge) cuts[i]!.add(t); + } + } + } + + const out: Segment[] = []; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]!; + const ts = [0, ...[...cuts[i]!].sort((x, y) => x - y), 1]; + for (let k = 0; k < ts.length - 1; k++) { + const piece = { a: at(seg, ts[k]!), b: at(seg, ts[k + 1]!) }; + if (segmentLength(piece) > tolerance) out.push(piece); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// The graph +// --------------------------------------------------------------------------- + +type Graph = { + nodes: Vec2[]; + /** Neighbours of each node, already sorted by the angle of the edge leaving it. */ + adjacency: number[][]; +}; + +function buildGraph(segments: readonly Segment[], tolerance: number): Graph { + const nodes: Vec2[] = []; + + const nodeAt = (p: Vec2): number => { + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]!; + if (Math.hypot(n.x - p.x, n.y - p.y) <= tolerance) return i; + } + nodes.push(p); + return nodes.length - 1; + }; + + const edges = new Set(); + for (const seg of segments) { + const u = nodeAt(seg.a); + const v = nodeAt(seg.b); + if (u === v) continue; // welded to nothing — a wall shorter than the tolerance + edges.add(u < v ? `${u}:${v}` : `${v}:${u}`); + } + + const adjacency: number[][] = nodes.map(() => []); + for (const key of edges) { + const [u, v] = key.split(':').map(Number) as [number, number]; + adjacency[u]!.push(v); + adjacency[v]!.push(u); + } + + for (let i = 0; i < nodes.length; i++) { + const from = nodes[i]!; + adjacency[i]!.sort( + (a, b) => + Math.atan2(nodes[a]!.y - from.y, nodes[a]!.x - from.x) - + Math.atan2(nodes[b]!.y - from.y, nodes[b]!.x - from.x), + ); + } + + return { nodes, adjacency }; +} + +const TWO_PI = Math.PI * 2; + +/** + * Leaving `v` having arrived from `u`: the first edge clockwise from the way back. + * + * Turning back the way you came is `2π` and therefore last, so it happens only at a + * dead end — which is right, because a spur wall is walked out and back and + * contributes no area either way. + */ +function nextNode(graph: Graph, u: number, v: number): number { + const from = graph.nodes[v]!; + const back = Math.atan2(graph.nodes[u]!.y - from.y, graph.nodes[u]!.x - from.x); + + let best = u; + let bestTurn = Infinity; + for (const w of graph.adjacency[v]!) { + const ang = Math.atan2(graph.nodes[w]!.y - from.y, graph.nodes[w]!.x - from.x); + let turn = back - ang; + while (turn <= 0) turn += TWO_PI; + while (turn > TWO_PI) turn -= TWO_PI; + if (turn < bestTurn) { + bestTurn = turn; + best = w; + } + } + return best; +} + +// --------------------------------------------------------------------------- +// Rings +// --------------------------------------------------------------------------- + +/** Drop vertices that sit on the line between their neighbours. */ +function dropCollinear(pts: Vec2[], tolerance: number): Vec2[] { + if (pts.length < 4) return pts; + const out: Vec2[] = []; + for (let i = 0; i < pts.length; i++) { + const prev = pts[(i - 1 + pts.length) % pts.length]!; + const here = pts[i]!; + const next = pts[(i + 1) % pts.length]!; + if (distanceToSegment(here, prev, next) > tolerance) out.push(here); + } + return out.length >= 3 ? out : pts; +} + +/** + * Rotate the ring to start at its lowest vertex, x then y. + * + * The walk can enter a face at any of its corners, so without this the same square + * detected twice is two different point lists describing one shape — and re-running + * detection would rewrite every boundary in the document with an identical one, + * dirtying the file and costing an undo step to say nothing. `commitRoomRect` already + * starts its rectangle at the minimum corner, so this is also what makes a drawn room + * and its detected twin compare equal. + */ +function canonicalRing(pts: Vec2[]): Vec2[] { + let start = 0; + for (let i = 1; i < pts.length; i++) { + const a = pts[i]!; + const b = pts[start]!; + if (a.x < b.x || (a.x === b.x && a.y < b.y)) start = i; + } + return [...pts.slice(start), ...pts.slice(0, start)]; +} + +export function sameRing(a: Polygon, b: Polygon): boolean { + if (a.pts.length !== b.pts.length) return false; + return a.pts.every((p, i) => p.x === b.pts[i]!.x && p.y === b.pts[i]!.y); +} + +/** + * Every enclosed loop in a floor's walls, as canonical counter-clockwise rings. + * + * Exported on its own because it is worth testing without a document around it, and + * because the plan view may one day want to preview what detection would find. + */ +export function detectLoops( + walls: readonly Wall[], + tolerance = JOIN_TOLERANCE_MM, +): Polygon[] { + const graph = buildGraph(splitWalls(walls, tolerance), tolerance); + const seen = new Set(); + const loops: Polygon[] = []; + + for (let u = 0; u < graph.nodes.length; u++) { + for (const v0 of graph.adjacency[u]!) { + if (seen.has(`${u}>${v0}`)) continue; + + const ring: number[] = [u]; + let a = u; + let b = v0; + // The walk closes on the directed edge it started from. The node bound is a + // guard against a malformed graph, not an expected exit. + for (let guard = 0; guard <= graph.nodes.length * 4; guard++) { + seen.add(`${a}>${b}`); + const c = nextNode(graph, a, b); + if (b === u && c === v0) break; + ring.push(b); + a = b; + b = c; + } + + if (ring.length < 3) continue; + const pts = dropCollinear( + ring.map((i) => graph.nodes[i]!), + tolerance, + ); + if (pts.length < 3) continue; + + const poly = polygon(pts); + // Interior faces wind positive under the clockwise-next rule; a component's + // outer face is the one that comes back negative. + if (signedArea(poly) <= 0) continue; + if (area(poly) < MIN_DETECTED_AREA_MM2) continue; + + loops.push({ pts: canonicalRing(pts).map((p) => ({ x: Math.round(p.x), y: Math.round(p.y) })) }); + } + } + + return loops; +} + +// --------------------------------------------------------------------------- +// Reconciling against the rooms already there +// --------------------------------------------------------------------------- + +export type RoomDetection = { + /** Rooms whose boundary genuinely moved. Unchanged rooms are omitted, so a second run is a no-op. */ + updated: { roomId: Id; boundary: Polygon; areaMm2: number }[]; + added: Room[]; + /** Rooms no loop supports — an Area-tool room, or one whose walls were removed. Left alone. */ + unmatched: Id[]; +}; + +export function uniqueRoomName(base: string, existing: readonly { name: string }[]): string { + const taken = new Set(existing.map((r) => r.name)); + if (!taken.has(base)) return base; + for (let i = 2; ; i++) { + const candidate = `${base} ${i}`; + if (!taken.has(candidate)) return candidate; + } +} + +/** + * What detection would do to a floor's rooms. + * + * Returns a plan rather than a new room list so the caller can apply the minimum: a + * whole-array replacement would record a patch even when every room came back + * identical, which is exactly the case a second run produces. + */ +export function detectRooms( + floor: Floor, + options: { makeId: () => Id; tolerance?: number } , +): RoomDetection { + const loops = detectLoops(floor.walls, options.tolerance ?? JOIN_TOLERANCE_MM); + + // Every (loop, room) pair that overlaps at all, worst case first, then claimed + // greedily — so the larger half of a room that has just been partitioned keeps the + // name, and the smaller half becomes a new room. + const pairs: { loop: number; room: number; overlap: number }[] = []; + loops.forEach((loop, li) => { + const lb = bounds(loop); + floor.rooms.forEach((room, ri) => { + if (!boundsOverlap(lb, bounds(room.boundary))) return; + const overlap = intersectionArea(loop, room.boundary); + if (overlap > 0) pairs.push({ loop: li, room: ri, overlap }); + }); + }); + pairs.sort((x, y) => y.overlap - x.overlap); + + const loopToRoom = new Map(); + const claimed = new Set(); + for (const pair of pairs) { + if (loopToRoom.has(pair.loop) || claimed.has(pair.room)) continue; + loopToRoom.set(pair.loop, pair.room); + claimed.add(pair.room); + } + + const updated: RoomDetection['updated'] = []; + const added: Room[] = []; + const names = [...floor.rooms.map((r) => ({ name: r.name }))]; + + loops.forEach((boundary, li) => { + const areaMm2 = Math.round(area(boundary)); + const ri = loopToRoom.get(li); + if (ri === undefined) { + const name = uniqueRoomName(`Room ${names.length + 1}`, names); + names.push({ name }); + added.push({ + id: options.makeId(), + name, + boundary, + ceilingHeightMm: floor.defaultCeilingHeightMm || DEFAULT_CEILING_HEIGHT_MM, + areaMm2, + }); + return; + } + + const room = floor.rooms[ri]!; + if (sameRing(room.boundary, boundary) && room.areaMm2 === areaMm2) return; + updated.push({ roomId: room.id, boundary, areaMm2 }); + }); + + const unmatched = floor.rooms + .filter((_, ri) => !claimed.has(ri)) + .map((r) => r.id); + + return { updated, added, unmatched }; +} diff --git a/src/state/actions.ts b/src/state/actions.ts index 1a71a88..85b91eb 100644 --- a/src/state/actions.ts +++ b/src/state/actions.ts @@ -18,6 +18,7 @@ import type { Wall, } from '../core/document'; import { createOpening, type OpeningDefaults } from '../core/openings'; +import { detectRooms, type RoomDetection } from '../core/rooms'; import { DEFAULT_SWING, clampSwingAngle, type Swing } from '../core/swing'; import { nearestWall, projectOntoWall } from '../core/geometry/wall'; import { createSavedView, uniqueViewName, type SpaceCamera } from '../core/views'; @@ -259,6 +260,48 @@ export function setRoomName(roomId: string, name: string): void { }); } +export function setRoomCeilingHeight(roomId: string, heightMm: number): void { + useStore.getState().mutate('Ceiling height', (draft) => { + for (const floor of draft.floors) { + const room = floor.rooms.find((r) => r.id === roomId); + if (room) room.ceilingHeightMm = Math.max(1, Math.round(heightMm)); + } + }); +} + +/** + * Derive rooms from the walls that enclose them. + * + * One undo step for the whole sweep, and a no-op when nothing changed — `detectRooms` + * omits rooms whose boundary already matches, so re-running on a settled plan writes + * no patches at all and `mutate` drops it before it reaches the history stack. + * + * Returns what it found so the caller can say so. Unmatched rooms are **left alone**: + * an Area-tool room has no walls by design, and deleting what detection cannot see + * would remove a legitimate room on every run. + */ +export function detectFloorRooms(): RoomDetection { + const { doc } = useStore.getState(); + const floorId = doc.activeFloorId; + const floor = doc.floors.find((f) => f.id === floorId); + if (!floor) return { updated: [], added: [], unmatched: [] }; + + const result = detectRooms(floor, { makeId: newId }); + if (result.updated.length === 0 && result.added.length === 0) return result; + + withActiveFloor('Detect rooms', (f) => { + for (const change of result.updated) { + const room = f.rooms.find((r) => r.id === change.roomId); + if (!room) continue; + room.boundary = change.boundary; + room.areaMm2 = change.areaMm2; + } + f.rooms.push(...result.added.map((room) => ({ ...room }))); + }); + + return result; +} + /** * Commit a wall drag. Called once on release, never during the drag. * diff --git a/src/ui/PropertiesPanel.tsx b/src/ui/PropertiesPanel.tsx index e80837f..140f8a1 100644 --- a/src/ui/PropertiesPanel.tsx +++ b/src/ui/PropertiesPanel.tsx @@ -17,6 +17,8 @@ import { setBackgroundOpacity, setPlacementElevation, setRoomName, + setRoomCeilingHeight, + detectFloorRooms, } from '../state/actions'; import { findItem, type Floor, type SpaceDocument } from '../core/document'; import { resolveElevation, roomAt, surfaceHeight } from '../core/placement'; @@ -157,6 +159,54 @@ function OpeningSwing({ opening }: { opening: Opening }) { ); } +/** + * Derive rooms from the walls that enclose them. + * + * A button rather than something that runs on every wall edit. Detection renames and + * re-shapes rooms, and doing that continuously while a wall chain is half drawn would + * fight the person drawing it — a partition is briefly a spur, and a room briefly two. + * Asking is also what makes it one undo step you can reverse. + * + * The report is worth showing rather than swallowing, because "found nothing" and + * "found what was already there" are different answers to the same click. + */ +function RoomDetection({ editable }: { editable: boolean }) { + const [report, setReport] = useState(null); + const roomCount = useStore((s) => activeFloor(s).rooms.length); + + const run = () => { + const result = detectFloorRooms(); + const parts: string[] = []; + if (result.added.length > 0) parts.push(`${result.added.length} new`); + if (result.updated.length > 0) parts.push(`${result.updated.length} reshaped`); + if (result.unmatched.length > 0) parts.push(`${result.unmatched.length} with no walls`); + setReport(parts.length > 0 ? parts.join(', ') : 'No change — every enclosed loop is already a room.'); + }; + + return ( +
    + +
    + +
    + {report ? ( +

    + {report} +

    + ) : null} +
    + ); +} + /** * The walkway probe's answer, and a way to be rid of it. * @@ -286,7 +336,17 @@ export function PropertiesPanel() { /> - + {/* Editable, because it is the one room property with a consequence: the + headroom check reads it through `ceilingHeightAt`, so lowering a + basement to 2100 immediately reports the wardrobe that no longer + fits. */} + setRoomCeilingHeight(room.id, mm)} + />
    ) : null} @@ -567,6 +627,9 @@ export function PropertiesPanel() {
    ) : null} +

    Rooms

    + +

    Walkway

    From 95cb86b8de5adcd41d8c5a010f68cc9e2debe01d Mon Sep 17 00:00:00 2001 From: Chintan Date: Wed, 2 Sep 2026 21:01:31 -0400 Subject: [PATCH 14/19] Phase 8b: floors that stack, and a floor below showing through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index is the stacking order and the array is insertion order. orderedFloors is the only thing that sorts, because the two disagree permanently the first time someone adds a basement: it takes index -1 and is appended. Switching floors is a silent document write. Undo walks back the edits you made; having it teleport you between storeys would make the stack unusable. It still dirties the document, because reopening a house on the floor you left it is why activeFloorId is in the document at all. The switch also clears selection, draft and route — pruneSelection would keep every one of them, since a wall on the floor below still exists. Moving a placement carries everything standing on it, however deep, or a surface mount is left naming a host on another floor that findPlacement resolves into an elevation against the wrong datum. A wall mount is reseated and reported. Floor deletion does the same repair and refuses the last floor. The ghost underlay participates in nothing: not the hit graph, not the counts, not floorBounds and so not zoom-to-fit — which is what keeps the viewport still across a floor change, the only reason the underlay is worth having. listening={false} is load-bearing rather than tidy: PlanStage reads empty canvas as e.target === stage, and that is what clears the selection and starts a pan. My first test for that could not fail; the replacement was verified against a listening ghost before the guard went back. Collision stays on the active floor whatever the 3D toggle says. Floors default to elevationMm 0, so feeding the stack to the walker would have you inside the walls of a storey you were only looking at. PLAN row 2 promised phase 8 would relate rooms to their walls. Detection is that mechanism, so room-boundary dragging is now dropped for v1 rather than left dangling — recorded in both rows. --- PLAN.md | 99 ++++++++++++++- e2e/floors.spec.ts | 208 ++++++++++++++++++++++++++++++ e2e/plan-editor.spec.ts | 2 +- src/core/floors.test.ts | 197 +++++++++++++++++++++++++++++ src/core/floors.ts | 208 ++++++++++++++++++++++++++++++ src/core/scene.test.ts | 95 +++++++++++++- src/core/scene.ts | 76 +++++++++++ src/state/actions.ts | 138 ++++++++++++++++++++ src/state/floors.test.ts | 245 ++++++++++++++++++++++++++++++++++++ src/state/store.ts | 62 +++++++++ src/styles/global.css | 5 + src/ui/PropertiesPanel.tsx | 141 +++++++++++++++++++++ src/ui/TopBar.tsx | 22 ++++ src/ui/plan/GhostLayer.tsx | 59 +++++++++ src/ui/plan/PlanStage.tsx | 5 + src/ui/plan/theme.ts | 4 + src/ui/space/SpaceHud.tsx | 19 ++- src/ui/space/SpaceScene.tsx | 8 +- src/ui/space/SpaceView.tsx | 8 +- src/ui/space/scene-cache.ts | 25 +++- 20 files changed, 1613 insertions(+), 13 deletions(-) create mode 100644 e2e/floors.spec.ts create mode 100644 src/core/floors.test.ts create mode 100644 src/core/floors.ts create mode 100644 src/state/floors.test.ts create mode 100644 src/ui/plan/GhostLayer.tsx diff --git a/PLAN.md b/PLAN.md index 41e6a4b..dd550fb 100644 --- a/PLAN.md +++ b/PLAN.md @@ -275,8 +275,9 @@ type Room = { type Floor = { id: string; name: string; // "Ground", "Upstairs", "Basement" - index: number; // stacking order - elevationMm: number; // datum of this floor above building zero + index: number; // stacking order — see §11.2; array order is insertion order + elevationMm: number; // datum of this floor above building zero, signed + defaultCeilingHeightMm: number; // for placements outside every traced room walls: Wall[]; openings: Opening[]; rooms: Room[]; @@ -776,6 +777,96 @@ a toggle for showing all floors, the active floor only, or a cutaway. The catalog is shared document-wide; placements reference their `floorId`. Moving a placement between floors is an explicit action, not a drag. +### 11.1 Detection + +Wall centrelines form a planar graph once they are split at every crossing **and every +T-junction**; its faces are found by the standard half-edge walk, keeping the first edge +clockwise from the way back. Interior faces come out with positive signed area and each +connected component's outer face negative, and **that sign is the test** — not "discard +the biggest", which gets a courtyard backwards, because the outer face of an inner ring +of walls is smaller than the room around it. + +The T-junction split is the one that decides whether detection works on a real plan. A +partition drawn to butt into the middle of another wall has its endpoint on that wall's +*interior*; with no node there the graph has no branch and the walk hands back the +single loop around the outside. + +**Boundaries are centrelines**, matching `commitRoomRect`, so a drawn room and a +detected one describe the same walls with the same number. Insetting by half a +thickness would be nearer the floor area you could carpet and would make the two paths +disagree about every room drawn by hand. Rings are canonicalised to start at their +lowest corner, which is what makes a second run a genuine no-op rather than a rewrite +of every boundary in the document. + +**Detection adds and updates; it never deletes.** An Area-tool room has no walls at all +— that is the point of `commitShapeRoom` — so removing rooms with no supporting loop +would delete a legitimate one on every run. Unmatched rooms are reported and left +alone. Reporting them as a *validation issue* was considered and rejected for the same +reason: it would flag every shape room forever, which is noise, not a finding. + +Matching is by **overlap area**, greedily, largest first. Centroid containment is +cheaper and breaks where it matters: partition a room and the old centroid may land in +either half, or inside the partition. Overlap gives the larger half the old name — the +one a person would still call the living room — and the smaller half becomes a new room. + +A detected room is a **simple ring**. `Polygon` has no holes, so an island of walls +inside a room does not punch one: a courtyard is detected as its own room *and* left +inside the ring around it. + +### 11.2 The stack + +`index` is the stacking order and the array is insertion order. Two places to look for +one answer, so `orderedFloors` is the only thing that sorts and nothing else reads array +position — which matters the first time someone adds a basement, because it takes index +−1 and is appended, and the two orders disagree permanently from then on. + +A new floor's elevation is **suggested, not derived**: the floor below it plus that +floor's tallest ceiling plus `FLOOR_ASSEMBLY_MM`. It is then an ordinary editable signed +number, because a split level, a mezzanine and a garage half a storey down are all real +and none of them survive a formula. Floors are added at the ends only; inserting between +two would renumber every floor above and make one gesture an edit to the whole building. + +Switching floors writes `activeFloorId` **without recording history**. Undo has to walk +back the edits you made; having it teleport you between storeys instead would make the +stack unusable. It still marks the document dirty, because reopening a three-storey +house on the floor you left it is why the field is in the document rather than in the +editor. The same switch clears the selection, the draft, any drag in progress and the +walkway route — `pruneSelection` would keep all of them, since a wall on the floor below +still exists perfectly well. + +Moving a placement between floors carries **everything standing on it**, however deep, +or a surface mount is left pointing at a host on another floor — which `findPlacement` +resolves happily, into an elevation measured against the wrong datum. A `wall` mount +names a wall that does not exist over there and is re-seated on the floor, reported +rather than discovered. Deleting a floor performs the same repair for anything elsewhere +mounted onto it, and refuses the last floor outright. + +### 11.3 What each view does with the stack + +The plan view draws the floor below as an underlay that **participates in nothing**: +`listening={false}`, absent from the wall and room counts, and absent from `floorBounds` +and therefore from zoom-to-fit. That last one is what keeps the viewport still across a +floor change, without which aligning an upstairs wall over the one holding it up would +be impossible. The `listening` flag is load-bearing and not merely tidy: PlanStage reads +a click as empty canvas by `e.target === stage`, and that is what clears the selection +and starts a pan. + +The space view stacks floors for real, each built by `buildScene` in its own frame and +shifted by its elevation relative to the active floor's datum — so the active floor +keeps the coordinates the rest of the application uses. `active` shows it alone, `all` +shows the building, and `cutaway` is named for what it removes: the floors above, which +are otherwise a lid. Other floors are dimmed, lose their ceilings (the slab above is the +ceiling) and **do not take clicks**, since selecting one would put something in the +panel that the plan view cannot show and the delete key would then remove from a storey +you are not on. + +**Collision always comes from the active floor**, whatever the display toggle says. +Feeding the stack to the walker would make traversal depend on a view setting — and +since floors default to `elevationMm: 0`, a second floor added before its elevation is +set would put you inside the walls of a storey you were only looking at. Two questions, +one of which includes things the other does not; the same shape of split as clearance +and the walkway probe in §9.3. + --- ## 12. Phasing @@ -787,13 +878,13 @@ isolated so neither blocks the core editor. |-------|-------------| | **0** | Scaffold: Vite + React + TS, vitest, playwright, lint, CI. Empty app shell. | | **1** | **Geometry core** — units, mm integers, polygon primitive, all generators, rotation/transform, area, SAT + clipping overlap, vertical intervals. Pure functions, no UI, heavily tested. Document model + `.space` read/write + migration hook. Round-trips a hand-authored fixture. | -| **2** | **Plan editor** — Konva stage, pan/zoom, wall/room/shape tools, dimension tool, grid + snapping, selection and transform (wall endpoint and body drag), mode toggle, undo/redo. Draw a floor plan by hand and save it. Room *reposition* is deliberately not included: rooms and their walls are separate entities, and moving one without the other desynchronises them — redraw instead until phase 8 relates them. | +| **2** | **Plan editor** — Konva stage, pan/zoom, wall/room/shape tools, dimension tool, grid + snapping, selection and transform (wall endpoint and body drag), mode toggle, undo/redo. Draw a floor plan by hand and save it. Room *reposition* is deliberately not included: rooms and their walls are separate entities, and moving one without the other desynchronises them — redraw instead until phase 8 relates them. **Phase 8 kept half of this.** Detection is the relating mechanism: a boundary derived from the wall graph re-derives when the walls move, so the way to reposition a room is now to move its walls and press Detect rooms, which reshapes the room in place and keeps its name and ceiling. Dragging a room boundary directly is still not implemented and is no longer planned for v1 — it is the gesture that desynchronises, and the one that does not now exists. | | **3** | **Import + calibration** — PDF via pdfjs (dynamically imported, so the 437kB renderer stays off first paint), image import, the blocking calibration gate, background transform/opacity/lock, tracing over a real plan. An uncalibrated background is shown at a nominal 6m width so the reference line is drawable *and* so the transform is invertible before a real scale exists; calibrating rescales about `refA` so the point the user anchored on does not move, and `transform.position` stays a float because rounding it would drift the anchor on every recalibration. Import deliberately does not re-fit the viewport. Deferred: thumbnails and File System Access (phase 9), vector path extraction (v2). | | **4** | **Inventory** — catalog/placement split, manual entry, preset library, quantity tracking, placement onto the plan with wall snap, surface snap, rotation, and 3D overlap warnings. **First genuinely useful build.** Wall snap seats the footprint's *back edge* (local −y) on the wall's near face and rotates to match, never the centre on the centreline. The calibration gate stops being decorative here: `addPlacement` throws `PlacementBlockedError` carrying the same sentence the validation panel shows, and the Place button is disabled rather than offered-and-refused. Deleting a placement re-seats anything surface-mounted on it, so the document never references a host that is gone. Headroom arrives early — `exceedsHeadroom` already existed — but clearance zones (7) and door swing (6) are still out. | | **5** | **3D space view** — extrusion from document geometry, orbit mode, walk mode with arrow-key traversal and collision, mount types (floor/surface/wall/ceiling), elevation editing, headroom checks, saved views. **Includes opening *geometry*** — wall-hosted openings and the holes they cut in the extruded walls, without swing. A sealed walker who cannot leave the first room does not demonstrate traversal, so the doorways have to exist here. An opening cuts a wall in *elevation*, not in plan, so `ExtrudeGeometry` holes were never the answer: `wallSegments` **splits** the wall into the solid boxes that remain — flank, sill wall, lintel, flank — which needs no CSG and hands the same list to the renderer, the walker and the validation panel. A doorway is passable because the only solid above it starts at 2032mm, with no "is this a door" check anywhere in traversal. The walk simulation deliberately lives *outside* three.js: a plain rAF loop over pure functions, so the camera consumes the walker rather than owning it, the position readout survives a browser with no WebGL, and traversal is testable without a GPU. Deferred and stated rather than claimed: **instancing** (§10.4's 500-at-60fps target is unmeasured — one mesh per solid today), and a real contact-normal collision resolver (moves are retried per axis, so diagonal walls slide stickily). | | **6** | **Openings, complete** — swing arcs in 2D, hinged door panels and window panes in 3D, sliding/pocket/cased variants, swing-vs-object clearance. The five kinds behave in four different ways, and the difference is the reason the kinds exist: hinged doors need their swept sector clear, sliders need the wall they park over clear, pocket doors need **nothing** in the room clear and instead need a cavity that can exist, and cased openings and windows need nothing at all. The swept sector is computed once and serves three consumers — its boundary *is* the 2D door symbol (closed leaf, arc, open leaf), it is the clearance polygon, and it positions the 3D panel — so the drawing and the check cannot disagree about where the door goes. Hanging the leaf is done with **flip buttons, not selects**, because there is no honest label for the two sides of a wall; the arc in the drawing is what makes the choice legible. The angle field holds its text locally and commits on blur or Enter, like the room name and every length field: writing per keystroke to a value that is *clamped* means the `1` of `135` is stored as `15` and the rest of the number is typed against that, so no angle whose first digit falls below the floor can be entered at all. `fill()` in a test never sees it, because it delivers the whole value in one change event. Deferred and stated rather than claimed: **windows do not open** (a casement sash would swing like a door and is not built), and the 3D layer-toggle gate on a door leaf is covered by an exhaustive unit test over `refIsEditable` rather than end to end — in the orbit view a leaf is a slab a few pixels wide seen edge-on, and walk mode does not take selection clicks at all, so hunting for it with a grid of clicks would test where the camera happens to sit. | | **7** | **Clearance and circulation** — clearance zones on catalog items, the standard preset library, walkway width probe, consolidated validation panel across overlap/headroom/clearance/swing. Two checks that sound alike and are not: a zone asks whether a drawer opens, the probe asks whether a person fits, and they differ on whether walls count (see §9.3 — they do not for a zone, they do for the probe). Zones are drawn on the selected item only, for the reason the swing arc is drawn: a warning that says "the bookcase blocks the drawer pull" is an argument and the hatched rectangle is the evidence — but six dining chairs with pull-out zones would carpet the floor in hatching and say nothing. The probe stores the *route*, not the number, so it re-answers as furniture moves; the tool stays live in furnish mode for the same reason. The panel groups by what you would do about a problem rather than by the issue enum, keeping `validateFloor`'s blocking-first order rather than forming a second opinion about severity in the component least qualified to have one. Deferred and stated rather than claimed: **the 900mm probe height in §9.3 was wrong and is now a body interval** — a sofa back is 840mm, so the specified ray passed over the one piece of furniture the spec named; and the full medial-axis navmesh remains explicitly out of scope, so the probe reports the narrowest gap *at a sample*, not the true infimum. | -| **8** | **Multi-room and multi-floor** — room detection and areas, per-room ceiling heights, floor stacking, ghost underlay, 3D floor toggles. | +| **8** | **Multi-room and multi-floor** — room detection and areas, per-room ceiling heights, floor stacking, ghost underlay, 3D floor toggles. Detection keeps planar-graph faces **by sign** rather than by magnitude, because a courtyard's outer face is smaller than the room around it; and it splits walls at T-junctions as well as crossings, which is the pass that decides whether it works on a real plan at all. Boundaries are centrelines, matching the Room tool, and rings are canonicalised so a second run is a genuine no-op — both asserted, because two paths that describe the same walls with different numbers is the failure this phase exists to avoid. Detection **never deletes**: an Area-tool room has no walls by design, so removing what detection cannot see would delete a legitimate room every run; unmatched rooms are reported and left. Ceiling height became editable, which is what makes it worth having — the headroom check reads it through `ceilingHeightAt`. On the stack: `index` is the ordering and nothing reads array position, a floor switch is silent in history but dirty on disk, and moving a placement carries everything standing on it while re-seating what named a wall it left behind. The plan ghost participates in nothing — not the hit graph, not the counts, not `floorBounds` — and the `listening` flag is load-bearing rather than tidy, since PlanStage reads empty canvas by `e.target === stage`. **Collision stays on the active floor whatever the 3D toggle says**, the same two-questions split as §9.3. Deferred and stated rather than claimed: room-boundary dragging is dropped for v1 in favour of move-the-walls-and-re-detect (see row 2), floors can only be added at the ends of the stack, and a detected room is a simple ring — an island of walls inside one does not punch a hole in it. | | **9** | **Polish and portability** — File System Access save-in-place, IndexedDB autosave and recovery, thumbnails, product URL lookup endpoint + confirm dialog, export/import e2e, migration tests. | Phases 4 and 5 together are the point at which the application does what it exists to diff --git a/e2e/floors.spec.ts b/e2e/floors.spec.ts new file mode 100644 index 0000000..a81f068 --- /dev/null +++ b/e2e/floors.spec.ts @@ -0,0 +1,208 @@ +import { expect, test, type Page } from '@playwright/test'; +import { clickAt, dragBetween, selectTool } from './coords'; + +/** + * Multiple floors — PLAN.md §11. + * + * The screen-to-document mapping comes from `./coords`. Switching floors deliberately + * does not re-fit the viewport, so the mapping survives one — which is itself asserted + * below, because a viewport that jumped on every floor change would make aligning a + * staircase against the ghost impossible. + */ + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('plan-stage')).toBeVisible(); +}); + +async function drawRoom(page: Page, to = { x: 5000, y: 4000 }) { + const stage = page.getByTestId('plan-stage'); + await selectTool(page, 'Room'); + await dragBetween(page, stage, { x: 0, y: 0 }, to); + await expect(page.getByTestId('count-walls')).toContainText('4'); + return stage; +} + +test.describe('the stack', () => { + test('adds a floor above and opens it empty', async ({ page }) => { + await drawRoom(page); + await page.getByTestId('add-floor-above').click(); + + await expect(page.getByTestId('count-walls')).toContainText('0'); + await expect(page.getByTestId('count-rooms')).toContainText('0'); + // Stacked to clear the ground floor's ceiling plus the structure between: 2438 + // plus 300 is 2738mm, which reads as 8' 11.75". + await expect(page.getByTestId('floor-elevation')).toHaveValue(`8' 11.75"`); + }); + + test('goes back to the floor you drew on, with everything still on it', async ({ page }) => { + await drawRoom(page); + await page.getByTestId('add-floor-above').click(); + await expect(page.getByTestId('count-walls')).toContainText('0'); + + await page.getByTestId('floor-picker').selectOption({ label: 'Ground' }); + await expect(page.getByTestId('count-walls')).toContainText('4'); + }); + + test('lists the top of the building at the top, the way a lift panel reads', async ({ page }) => { + await page.getByTestId('add-floor-above').click(); + await page.getByTestId('add-floor-below').click(); + + const labels = await page.getByTestId('floor-picker').locator('option').allTextContents(); + expect(labels).toEqual(['Level 2', 'Ground', 'Basement']); + }); + + test('refuses to delete the only floor, and says why', async ({ page }) => { + await page.getByTestId('delete-floor').click(); + await expect(page.getByTestId('floor-error')).toContainText('at least one floor'); + await expect(page.getByTestId('floor-picker').locator('option')).toHaveCount(1); + }); + + test('does not put a floor change on the undo stack', async ({ page }) => { + // Undo walks back edits. Having it teleport you between storeys instead would + // make the stack unusable. + await drawRoom(page); + await page.getByTestId('add-floor-above').click(); + await page.getByTestId('floor-picker').selectOption({ label: 'Ground' }); + + // One press undoes the *add*, not the two switches around it. + await page.getByRole('button', { name: 'Undo', exact: true }).click(); + await expect(page.getByTestId('floor-picker').locator('option')).toHaveCount(1); + await expect(page.getByTestId('count-walls')).toContainText('4'); + }); + + test('carries every floor into the file and back out', async ({ page }) => { + await drawRoom(page); + await page.getByTestId('add-floor-above').click(); + await drawRoom(page, { x: 3000, y: 3000 }); + + const download = page.waitForEvent('download'); + await page.getByTestId('save-file').click(); + const path = await (await download).path(); + + await page.goto('/'); + await page.getByLabel('Open a .space file').setInputFiles(path); + + await expect(page.getByTestId('floor-picker').locator('option')).toHaveCount(2); + // Reopened on the floor it was left on, which is why `activeFloorId` lives in the + // document rather than in the editor — and the upstairs room came back with it. + await expect(page.getByTestId('floor-picker').locator('option:checked')).toHaveText('Level 2'); + await expect(page.getByTestId('count-rooms')).toContainText('1'); + + await page.getByTestId('floor-picker').selectOption({ label: 'Ground' }); + await expect(page.getByTestId('count-walls')).toContainText('4'); + }); +}); + +test.describe('the ghost underlay', () => { + test('counts nothing and moves nothing', async ({ page }) => { + // The regression this layer invites: ghost geometry leaking into the wall and + // room counts, or into floorBounds and therefore zoom-to-fit. + await drawRoom(page); + await page.getByTestId('add-floor-above').click(); + + await expect(page.getByTestId('count-walls')).toContainText('0'); + await expect(page.getByTestId('count-rooms')).toContainText('0'); + }); + + test('is not in the hit graph, so empty canvas over it is still empty', async ({ page }) => { + // `listening={false}` is load-bearing here. PlanStage reads a click as empty + // canvas by `e.target === stage`, and that is what clears the selection and + // starts a pan — so a listening ghost shape would silently break both, on every + // floor above the ground one, only where a wall happens to sit underneath. + const stage = await drawRoom(page); + await page.getByTestId('add-floor-above').click(); + + // A wall upstairs, clear of the ground floor's walls, and select it. + await selectTool(page, 'Wall'); + await clickAt(page, stage, { x: 1000, y: 1000 }); + await clickAt(page, stage, { x: 3000, y: 1000 }); + await page.keyboard.press('Enter'); + await selectTool(page, 'Select'); + await clickAt(page, stage, { x: 2000, y: 1000 }); + await expect(page.getByTestId('wall-properties')).toBeVisible(); + + // Now click straight onto a ghost wall — the ground floor's south wall, where + // nothing on this floor exists. It is empty canvas, and the selection goes. + await clickAt(page, stage, { x: 2500, y: 4000 }); + await expect(page.getByTestId('wall-properties')).toHaveCount(0); + }); + + test('leaves the floor below alone when you edit the one above', async ({ page }) => { + const stage = await drawRoom(page); + await page.getByTestId('add-floor-above').click(); + + await selectTool(page, 'Wall'); + await clickAt(page, stage, { x: 0, y: 0 }); + await clickAt(page, stage, { x: 5000, y: 0 }); + await page.keyboard.press('Enter'); + + // Traced directly over a ghost wall, then deleted: the ground floor keeps its four. + await selectTool(page, 'Select'); + await clickAt(page, stage, { x: 2500, y: 0 }); + await page.keyboard.press('Delete'); + await expect(page.getByTestId('count-walls')).toContainText('0'); + await page.getByTestId('floor-picker').selectOption({ label: 'Ground' }); + await expect(page.getByTestId('count-walls')).toContainText('4'); + }); + + test('keeps the viewport still across a floor change', async ({ page }) => { + // Aligning an upstairs wall over the one holding it up is the whole point of the + // underlay, and it only works if the two floors are drawn at the same place. + const stage = await drawRoom(page); + await page.getByTestId('add-floor-above').click(); + + await selectTool(page, 'Wall'); + await clickAt(page, stage, { x: 0, y: 0 }); + await clickAt(page, stage, { x: 5000, y: 0 }); + await page.keyboard.press('Enter'); + + await selectTool(page, 'Select'); + await clickAt(page, stage, { x: 2500, y: 0 }); + // 5000mm is 16' 4.875". Drawn at the same document coordinates as the ghost wall + // it was traced over, which is only true if nothing re-fitted the viewport. + await expect(page.getByTestId('wall-properties')).toContainText(`16' 4.875"`); + }); +}); + +test.describe('moving furniture between floors', () => { + test('moves a placement to another floor, and takes it off this one', async ({ page }) => { + const stage = await drawRoom(page); + await page.getByLabel('Add from the preset library').selectOption({ label: 'Storage — Dresser' }); + await page.getByRole('button', { name: 'Place', exact: true }).last().click(); + await clickAt(page, stage, { x: 2500, y: 2000 }); + await page.keyboard.press('Escape'); + + // A floor to move it to, then back to select it. + await page.getByRole('button', { name: 'Edit floor plan', exact: true }).click(); + await page.getByTestId('add-floor-above').click(); + await page.getByTestId('floor-picker').selectOption({ label: 'Ground' }); + + await page.getByRole('button', { name: 'Arrange furniture', exact: true }).click(); + await selectTool(page, 'Select'); + await clickAt(page, stage, { x: 2500, y: 2000 }); + await expect(page.getByTestId('placement-properties')).toBeVisible(); + + await page.getByTestId('move-to-floor').selectOption({ label: 'Level 2' }); + await expect(page.getByTestId('count-placements')).toContainText('0'); + + await page.getByTestId('floor-picker').selectOption({ label: 'Level 2' }); + await expect(page.getByTestId('count-placements')).toContainText('1'); + }); +}); + +test.describe('the space view', () => { + test('shows one floor, then the stack', async ({ page }) => { + await drawRoom(page); + await page.getByTestId('add-floor-above').click(); + await drawRoom(page, { x: 3000, y: 3000 }); + + await page.getByRole('button', { name: 'Space', exact: true }).click(); + await expect(page.getByTestId('space-view')).toBeVisible(); + + await expect(page.getByTestId('floors-active')).toHaveAttribute('aria-pressed', 'true'); + await page.getByTestId('floors-all').click(); + await expect(page.getByTestId('floors-all')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByTestId('floors-active')).toHaveAttribute('aria-pressed', 'false'); + }); +}); diff --git a/e2e/plan-editor.spec.ts b/e2e/plan-editor.spec.ts index 8335af4..cd68a26 100644 --- a/e2e/plan-editor.spec.ts +++ b/e2e/plan-editor.spec.ts @@ -195,7 +195,7 @@ test.describe('selection', () => { await selectTool(page, 'Select'); await clickAt(page, stage, { x: 2000, y: 0 }); - await page.getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Delete', exact: true }).click(); await expect(page.getByTestId('count-walls')).toContainText('3'); await expect(page.getByTestId('count-rooms')).toContainText('1'); diff --git a/src/core/floors.test.ts b/src/core/floors.test.ts new file mode 100644 index 0000000..01a1a66 --- /dev/null +++ b/src/core/floors.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest'; +import { + FLOOR_ASSEMBLY_MM, + createStackedFloor, + descendantsOf, + floorAbove, + floorBelow, + floorHeight, + orderedFloors, + remountForFloor, + suggestedElevation, + uniqueFloorName, + visibleFloors, +} from './floors'; +import { + DEFAULT_CEILING_HEIGHT_MM, + createDocument, + createFloor, + type Floor, + type Placement, + type SpaceDocument, +} from './document'; +import { polygon } from './geometry/polygon'; + +function doc(): SpaceDocument { + return createDocument({ id: 'd', floorId: 'ground', now: '2026-01-01T00:00:00.000Z' }); +} + +/** Add a floor at `index` without going through the stacking helper. */ +function push(d: SpaceDocument, id: string, name: string, index: number, elevationMm: number): Floor { + const floor = createFloor(id, name, index); + floor.elevationMm = elevationMm; + d.floors.push(floor); + return floor; +} + +function placement(id: string, over: Partial = {}): Placement { + return { + id, + itemId: 'i', + floorId: 'ground', + position: { x: 0, y: 0 }, + rotation: 0, + mount: { kind: 'floor' }, + elevation: 0, + ...over, + }; +} + +describe('stacking order', () => { + it('orders by index, not by position in the array', () => { + // The two disagree the first time someone adds a basement: it takes index −1 and + // is appended, so array order says it is on top. + const d = doc(); + push(d, 'up', 'Upstairs', 1, 2738); + push(d, 'down', 'Basement', -1, -2738); + + expect(orderedFloors(d).map((f) => f.id)).toEqual(['down', 'ground', 'up']); + }); + + it('finds the floor immediately below and above', () => { + const d = doc(); + push(d, 'up', 'Upstairs', 1, 2738); + push(d, 'down', 'Basement', -1, -2738); + + expect(floorBelow(d, 'ground')?.id).toBe('down'); + expect(floorAbove(d, 'ground')?.id).toBe('up'); + expect(floorBelow(d, 'down')).toBeUndefined(); + expect(floorAbove(d, 'up')).toBeUndefined(); + }); +}); + +describe('where a new floor lands', () => { + it('clears the tallest ceiling on the floor below, plus the structure between', () => { + const d = doc(); + d.floors[0]!.rooms.push({ + id: 'r', + name: 'Vaulted living room', + boundary: polygon([ + { x: 0, y: 0 }, + { x: 1000, y: 0 }, + { x: 1000, y: 1000 }, + ]), + ceilingHeightMm: 3600, + areaMm2: 500_000, + }); + + // The vault, not the 2438 default — stacking a storey on top of a room it does + // not clear is the one number that has to come from the document. + expect(floorHeight(d.floors[0]!)).toBe(3600); + expect(suggestedElevation(d, 1)).toBe(3600 + FLOOR_ASSEMBLY_MM); + }); + + it('hangs a basement below the floor above it', () => { + const d = doc(); + expect(suggestedElevation(d, -1)).toBe(-(DEFAULT_CEILING_HEIGHT_MM + FLOOR_ASSEMBLY_MM)); + }); + + it('is zero when there is nothing to stack against', () => { + const d = doc(); + d.floors = []; + expect(suggestedElevation(d, 0)).toBe(0); + }); + + it('adds at the ends only, above and below', () => { + const d = doc(); + const up = createStackedFloor(d, 'above', 'f1'); + expect(up.index).toBe(1); + expect(up.elevationMm).toBe(DEFAULT_CEILING_HEIGHT_MM + FLOOR_ASSEMBLY_MM); + + const down = createStackedFloor(d, 'below', 'f2'); + expect(down.index).toBe(-1); + expect(down.name).toBe('Basement'); + }); + + it('does not reuse a name already taken', () => { + expect(uniqueFloorName('Basement', [{ name: 'Basement' }])).toBe('Basement 2'); + }); +}); + +describe('moving between floors', () => { + it('carries everything standing on a placement, however deep', () => { + // A tray on a lamp on a dresser goes upstairs when the dresser does. Leaving it + // strands a surface mount pointing at a host on another floor. + const floor = createFloor('ground', 'Ground', 0); + floor.placements = [ + placement('tray', { mount: { kind: 'surface', hostId: 'lamp' } }), + placement('dresser'), + placement('lamp', { mount: { kind: 'surface', hostId: 'dresser' } }), + ]; + + expect(descendantsOf(floor, 'dresser')).toEqual(new Set(['dresser', 'lamp', 'tray'])); + }); + + it('does not carry something standing on a different item', () => { + const floor = createFloor('ground', 'Ground', 0); + floor.placements = [ + placement('dresser'), + placement('table'), + placement('vase', { mount: { kind: 'surface', hostId: 'table' } }), + ]; + + expect(descendantsOf(floor, 'dresser')).toEqual(new Set(['dresser'])); + }); + + it('re-seats a wall mount, because the wall is not over there', () => { + const shelf = placement('shelf', { mount: { kind: 'wall', wallId: 'w1' }, elevation: 1200 }); + expect(remountForFloor(shelf, new Set(['shelf']))).toEqual({ + mount: { kind: 'floor' }, + elevation: 0, + reseated: true, + }); + }); + + it('keeps a surface mount whose host is travelling too', () => { + const lamp = placement('lamp', { mount: { kind: 'surface', hostId: 'dresser' }, elevation: 810 }); + expect(remountForFloor(lamp, new Set(['dresser', 'lamp']))).toEqual({ + mount: { kind: 'surface', hostId: 'dresser' }, + elevation: 810, + reseated: false, + }); + }); + + it('re-seats a surface mount whose host stayed behind', () => { + const lamp = placement('lamp', { mount: { kind: 'surface', hostId: 'dresser' }, elevation: 810 }); + expect(remountForFloor(lamp, new Set(['lamp'])).reseated).toBe(true); + }); + + it('leaves a ceiling mount alone — it references nothing floor-local', () => { + const pendant = placement('pendant', { mount: { kind: 'ceiling', drop: 400 } }); + expect(remountForFloor(pendant, new Set(['pendant'])).reseated).toBe(false); + }); +}); + +describe('what the space view shows', () => { + function threeStorey(): SpaceDocument { + const d = doc(); + push(d, 'up', 'Upstairs', 1, 2738); + push(d, 'down', 'Basement', -1, -2738); + return d; + } + + it('shows the active floor alone', () => { + const d = threeStorey(); + expect(visibleFloors(d, 'active').map((f) => f.id)).toEqual(['ground']); + }); + + it('shows the whole building, bottom to top', () => { + const d = threeStorey(); + expect(visibleFloors(d, 'all').map((f) => f.id)).toEqual(['down', 'ground', 'up']); + }); + + it('cuts away the floors above, which are otherwise a lid', () => { + const d = threeStorey(); + expect(visibleFloors(d, 'cutaway').map((f) => f.id)).toEqual(['down', 'ground']); + }); +}); diff --git a/src/core/floors.ts b/src/core/floors.ts new file mode 100644 index 0000000..09df6d2 --- /dev/null +++ b/src/core/floors.ts @@ -0,0 +1,208 @@ +/** + * Floors, and how they stack. See PLAN.md §11. + * + * Floors stack along the document's Z axis at their `elevationMm`. One is active: the + * plan view edits it, the walker walks it, and the floor below it shows through as a + * ghost so a staircase can be made to land in the right place. + * + * ## `index` is the stacking order; the array is insertion order + * + * `Floor` carries both a position in `doc.floors` and an `index`, which is two places + * a reader could look for the same answer. `index` is the one that means it — + * `orderedFloors` is the only thing that sorts, and nothing else reads array position + * for stacking. That matters the first time someone adds a basement: it takes index + * −1 and is appended to the array, so the two orders disagree immediately and + * permanently. + * + * ## Elevation is suggested, not derived + * + * A new floor is put a storey above the one below it, which is a guess made from the + * ceiling heights actually in the document. It is then an ordinary editable number: + * a split level, a mezzanine and a garage half a storey down are all real, and none + * of them survive a formula. + * + * Pure — no DOM, no store. + */ + +import { createFloor, type Floor, type Id, type Placement, type SpaceDocument } from './document'; + +/** + * Structure between one floor's ceiling and the next floor's datum. + * + * Joists, subfloor and finish. 300mm is an ordinary domestic build-up; it is only the + * opening guess for a new floor's elevation, which is editable the moment it exists. + */ +export const FLOOR_ASSEMBLY_MM = 300; + +/** Floors bottom to top. The only ordering in the application. */ +export function orderedFloors(doc: SpaceDocument): Floor[] { + return [...doc.floors].sort((a, b) => a.index - b.index || a.name.localeCompare(b.name)); +} + +/** The tallest ceiling anywhere on a floor — what has to be cleared to stack above it. */ +export function floorHeight(floor: Floor): number { + return floor.rooms.reduce( + (h, r) => Math.max(h, r.ceilingHeightMm), + floor.defaultCeilingHeightMm, + ); +} + +/** The floor immediately below this one, by stacking order. */ +export function floorBelow(doc: SpaceDocument, floorId: Id): Floor | undefined { + const ordered = orderedFloors(doc); + const i = ordered.findIndex((f) => f.id === floorId); + return i > 0 ? ordered[i - 1] : undefined; +} + +/** The floor immediately above this one, by stacking order. */ +export function floorAbove(doc: SpaceDocument, floorId: Id): Floor | undefined { + const ordered = orderedFloors(doc); + const i = ordered.findIndex((f) => f.id === floorId); + return i >= 0 && i < ordered.length - 1 ? ordered[i + 1] : undefined; +} + +/** + * Where a floor at `index` would sit. + * + * Above the nearest floor below it, clearing that floor's tallest ceiling and the + * structure between them. With nothing below, it hangs the same distance under the + * nearest floor above — which is how a basement gets a sensible negative datum + * instead of sitting inside the ground floor. + */ +export function suggestedElevation(doc: SpaceDocument, index: number): number { + const ordered = orderedFloors(doc); + const below = [...ordered].reverse().find((f) => f.index < index); + if (below) return below.elevationMm + floorHeight(below) + FLOOR_ASSEMBLY_MM; + + const above = ordered.find((f) => f.index > index); + if (above) return above.elevationMm - (floorHeight(above) + FLOOR_ASSEMBLY_MM); + + return 0; +} + +export function uniqueFloorName(base: string, existing: readonly { name: string }[]): string { + const taken = new Set(existing.map((f) => f.name)); + if (!taken.has(base)) return base; + for (let i = 2; ; i++) { + const candidate = `${base} ${i}`; + if (!taken.has(candidate)) return candidate; + } +} + +/** + * A new empty floor, above or below everything already there. + * + * Only the ends, deliberately. Inserting between two floors means renumbering the + * ones above, which rewrites `index` on floors nobody touched and makes one gesture + * an edit to the whole building. Adding at an end and then editing the elevation does + * everything inserting would, and says what it did. + */ +export function createStackedFloor( + doc: SpaceDocument, + where: 'above' | 'below', + id: Id, +): Floor { + const ordered = orderedFloors(doc); + const index = + where === 'above' + ? (ordered[ordered.length - 1]?.index ?? -1) + 1 + : (ordered[0]?.index ?? 1) - 1; + + const name = uniqueFloorName(where === 'above' ? `Level ${index + 1}` : 'Basement', doc.floors); + const floor = createFloor(id, name, index); + floor.elevationMm = suggestedElevation(doc, index); + return floor; +} + +// --------------------------------------------------------------------------- +// Moving things between floors +// --------------------------------------------------------------------------- + +/** + * Everything riding on a placement, however many items deep. + * + * The mirror of `clearance.ts`'s `hostsAbove`. A tray on a lamp on a dresser goes + * upstairs when the dresser does — anything else strands a surface mount pointing at + * a host on another floor, which `findPlacement` would happily resolve and every + * elevation calculation would then answer against the wrong datum. + */ +export function descendantsOf(floor: Floor, placementId: Id): Set { + const found = new Set([placementId]); + // Repeat until nothing new appears: `placements` is in no particular order, so one + // pass would miss a tray listed before the lamp it stands on. + for (let grew = true; grew; ) { + grew = false; + for (const p of floor.placements) { + if (found.has(p.id)) continue; + if (p.mount.kind === 'surface' && found.has(p.mount.hostId)) { + found.add(p.id); + grew = true; + } + } + } + return found; +} + +/** + * What a mount becomes on the way to another floor. + * + * A `wall` mount names a wall that does not exist over there, and a `surface` mount + * whose host stayed behind names a host that does not either. Both fall back to the + * floor. A `ceiling` mount references nothing and survives; a `surface` mount whose + * host is travelling too survives, because the pair stays intact. + * + * Returned rather than applied so the caller can say what it did — silently dropping + * a wall-hung shelf to the floor of another storey is the kind of thing that should + * come with a sentence. + */ +export function remountForFloor( + placement: Placement, + moving: ReadonlySet, +): { mount: Placement['mount']; elevation: number; reseated: boolean } { + const { mount } = placement; + if (mount.kind === 'wall') return { mount: { kind: 'floor' }, elevation: 0, reseated: true }; + if (mount.kind === 'surface' && !moving.has(mount.hostId)) { + return { mount: { kind: 'floor' }, elevation: 0, reseated: true }; + } + return { mount, elevation: placement.elevation, reseated: false }; +} + +// --------------------------------------------------------------------------- +// What the 3D view shows +// --------------------------------------------------------------------------- + +/** + * Which floors the space view draws. + * + * `active` is the floor you are editing, alone. `all` is the building. `cutaway` is + * named for what it removes — the floors above, which are otherwise a lid you cannot + * see past — so it shows the active floor and everything under it. + * + * This is a **display** setting and nothing else reads it. Collision always comes from + * the active floor, or walking would change depending on what you had chosen to look + * at. Two questions, as with clearance and the walkway probe. + */ +export type FloorVisibility = 'active' | 'all' | 'cutaway'; + +export const FLOOR_VISIBILITY: FloorVisibility[] = ['active', 'all', 'cutaway']; + +export const FLOOR_VISIBILITY_LABELS: Record = { + active: 'This floor', + all: 'All floors', + cutaway: 'Cutaway', +}; + +export function visibleFloors(doc: SpaceDocument, visibility: FloorVisibility): Floor[] { + const ordered = orderedFloors(doc); + const active = ordered.find((f) => f.id === doc.activeFloorId); + if (!active) return ordered.slice(0, 1); + + switch (visibility) { + case 'active': + return [active]; + case 'all': + return ordered; + case 'cutaway': + return ordered.filter((f) => f.index <= active.index); + } +} diff --git a/src/core/scene.test.ts b/src/core/scene.test.ts index 2f4ec9d..f5833af 100644 --- a/src/core/scene.test.ts +++ b/src/core/scene.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { blockersOf, buildScene, defaultStandpoint } from './scene'; +import { OTHER_FLOOR_OPACITY, blockersOf, buildScene, buildStack, defaultStandpoint } from './scene'; import { createOpening } from './openings'; import { createCatalogItem, type ItemDraft } from './catalog'; import { commitRoomRect } from './tools'; -import { createDocument, type Placement, type SpaceDocument } from './document'; +import { createDocument, createFloor, type Placement, type SpaceDocument } from './document'; let seq = 0; const id = () => `id-${seq++}`; @@ -237,3 +237,94 @@ describe('leaves in the scene', () => { expect(leaf!.blocking).toBe(false); }); }); + +describe('stacking floors', () => { + /** The 5 x 4 room, plus an identical one on a floor 2738mm up. */ + function twoStorey(): SpaceDocument { + const doc = room(); + const upstairs = createFloor('up', 'Upstairs', 1); + upstairs.elevationMm = 2738; + const built = commitRoomRect({ x: 0, y: 0 }, { x: 5000, y: 4000 }, { + name: 'Bedroom', + makeId: id, + })!; + upstairs.rooms.push(built.room); + upstairs.walls.push(...built.walls); + doc.floors.push(upstairs); + return doc; + } + + it('shifts a floor by its elevation relative to the active datum', () => { + const doc = twoStorey(); + const stack = buildStack(doc, doc.floors, 'f'); + + const ground = stack.solids.filter((s) => s.floorId === 'f'); + const up = stack.solids.filter((s) => s.floorId === 'up'); + expect(ground[0]!.span.bottom).toBe(0); + expect(up[0]!.span.bottom).toBe(2738); + }); + + it('leaves the active floor where the rest of the application put it', () => { + // Whichever floor is active keeps the coordinates the plan view, the walker and + // every collision test already use — the stack shifts the others around it. + const doc = twoStorey(); + const stack = buildStack(doc, doc.floors, 'up'); + + expect(stack.solids.find((s) => s.floorId === 'up')!.span.bottom).toBe(0); + expect(stack.solids.find((s) => s.floorId === 'f')!.span.bottom).toBe(-2738); + }); + + it('dims every floor that is not the one being edited', () => { + const doc = twoStorey(); + const stack = buildStack(doc, doc.floors, 'f'); + + expect(stack.solids.find((s) => s.floorId === 'f')!.opacity).toBe(1); + expect(stack.solids.find((s) => s.floorId === 'up')!.opacity).toBe(OTHER_FLOOR_OPACITY); + }); + + it('drops the ceiling of a floor that has another floor over it', () => { + // The slab above is the ceiling. A lid on every storey hides the stack, which is + // the whole point of looking at more than one. + const doc = twoStorey(); + const stack = buildStack(doc, doc.floors, 'f'); + + expect(stack.slabs.filter((s) => s.floorId === 'up' && s.kind === 'ceiling')).toEqual([]); + expect(stack.slabs.filter((s) => s.floorId === 'f' && s.kind === 'ceiling')).toHaveLength(1); + }); + + it('frames every floor it shows, not just the active one', () => { + // `bounds` feeds the orbit camera. Fitting one storey clips the rest. + const doc = twoStorey(); + const upstairs = doc.floors[1]!; + for (const wall of upstairs.walls) { + wall.a = { x: wall.a.x + 9000, y: wall.a.y }; + wall.b = { x: wall.b.x + 9000, y: wall.b.y }; + } + upstairs.rooms = []; + + expect(buildStack(doc, doc.floors, 'f').bounds!.maxX).toBeGreaterThan(13_000); + }); + + it('keeps ids unique across floors', () => { + // Two floors traced from the same template can carry the same wall ids; React + // keys and three.js meshes both need them distinct. + const doc = twoStorey(); + doc.floors[1]!.walls = doc.floors[0]!.walls.map((w) => ({ ...w })); + const stack = buildStack(doc, doc.floors, 'f'); + + expect(new Set(stack.solids.map((s) => s.id)).size).toBe(stack.solids.length); + }); + + it('is only what you see — the walker is fed the active floor alone', () => { + // Floors default to elevationMm 0, so a second floor added before its elevation + // is set puts both storeys' walls in the same band. Feeding the stack to + // collision would have you walking into walls you are only looking at. + const doc = twoStorey(); + doc.floors[1]!.elevationMm = 0; + + const active = buildScene(doc, doc.floors[0]!); + expect(blockersOf(active).length).toBeLessThan( + blockersOf(buildStack(doc, doc.floors, 'f')).length, + ); + }); +}); diff --git a/src/core/scene.ts b/src/core/scene.ts index 7b3e04b..276dd8e 100644 --- a/src/core/scene.ts +++ b/src/core/scene.ts @@ -39,6 +39,8 @@ export type SceneRef = { kind: 'wall' | 'placement' | 'opening'; id: Id }; export type SceneSolid = { /** Unique within the scene. A wall contributes several, suffixed by index. */ id: string; + /** Which floor it came from. In a stacked scene, only the active floor's solids take clicks. */ + floorId: Id; ref: SceneRef; outline: Polygon; span: Span; @@ -52,6 +54,7 @@ export type SceneSolid = { /** A horizontal slab — a room's floor or its ceiling. */ export type SceneSlab = { id: string; + floorId: Id; kind: 'floor' | 'ceiling'; roomId: Id; boundary: Polygon; @@ -98,6 +101,7 @@ export function buildScene(doc: SpaceDocument, floor: Floor): SceneModel { } solids.push({ id: `${wall.id}:${i}`, + floorId: floor.id, ref: { kind: 'wall', id: wall.id }, outline, span: { bottom: segment.bottom, top: segment.top }, @@ -120,6 +124,7 @@ export function buildScene(doc: SpaceDocument, floor: Floor): SceneModel { const glazing = leafOf(opening).style === 'pane'; solids.push({ id: `${opening.id}:leaf`, + floorId: floor.id, ref: { kind: 'opening', id: opening.id }, outline: panel, span: openingSpan(opening), @@ -155,6 +160,7 @@ export function buildScene(doc: SpaceDocument, floor: Floor): SceneModel { solids.push({ id: placement.id, + floorId: floor.id, ref: { kind: 'placement', id: placement.id }, outline: worldOutline(placement, item), span: clamped, @@ -167,6 +173,7 @@ export function buildScene(doc: SpaceDocument, floor: Floor): SceneModel { for (const room of floor.rooms) { slabs.push({ id: `${room.id}:floor`, + floorId: floor.id, kind: 'floor', roomId: room.id, boundary: room.boundary, @@ -175,6 +182,7 @@ export function buildScene(doc: SpaceDocument, floor: Floor): SceneModel { }); slabs.push({ id: `${room.id}:ceiling`, + floorId: floor.id, kind: 'ceiling', roomId: room.id, boundary: room.boundary, @@ -194,6 +202,74 @@ export function buildScene(doc: SpaceDocument, floor: Floor): SceneModel { }; } +/** + * Opacity for a floor that is not the one being edited. + * + * Enough to read as structure, little enough to see the active floor through. Every + * floor drawn opaque is a building with a roof on it, which answers no question. + */ +export const OTHER_FLOOR_OPACITY = 0.22; + +/** + * Several floors stacked at their real elevations, for the space view. + * + * Each floor is built by `buildScene` in its own frame and then shifted by its + * elevation relative to the active floor's datum — so the active floor keeps the + * coordinates everything else in the application uses, and the storeys above and + * below arrive where they belong without a second geometry path. + * + * **Display only.** The walker is fed `blockersOf(buildScene(doc, activeFloor))` and + * always has been; feeding it this would make traversal depend on a view setting, and + * would have you colliding with the walls of a floor you are only looking at. Floors + * default to `elevationMm: 0`, so before an elevation is set that collision would be + * with invisible walls in the same band as your own. + * + * Non-active floors lose their ceilings — the slab of the floor above is the ceiling, + * and a lid over every storey would hide the stack that is the point of the view. + */ +export function buildStack( + doc: SpaceDocument, + floors: readonly Floor[], + activeFloorId: Id, +): SceneModel { + const active = floors.find((f) => f.id === activeFloorId) ?? floors[0]; + const datum = active?.elevationMm ?? 0; + + const solids: SceneSolid[] = []; + const slabs: SceneSlab[] = []; + let ceilingHeightMm = 0; + + for (const floor of floors) { + const scene = buildScene(doc, floor); + const dz = floor.elevationMm - datum; + const isActive = floor.id === activeFloorId; + + for (const solid of scene.solids) { + solids.push({ + ...solid, + id: `${floor.id}/${solid.id}`, + span: { bottom: solid.span.bottom + dz, top: solid.span.top + dz }, + opacity: isActive ? solid.opacity : Math.min(solid.opacity, OTHER_FLOOR_OPACITY), + }); + } + + for (const slab of scene.slabs) { + if (!isActive && slab.kind === 'ceiling') continue; + slabs.push({ ...slab, id: `${floor.id}/${slab.id}`, elevationMm: slab.elevationMm + dz }); + } + + ceilingHeightMm = Math.max(ceilingHeightMm, dz + scene.ceilingHeightMm); + } + + return { + solids, + slabs, + // Framed across every floor shown, or the camera fits one storey and clips the rest. + bounds: sceneBounds(solids, floors.flatMap((f) => f.rooms)), + ceilingHeightMm: Math.max(ceilingHeightMm, 1), + }; +} + function sceneBounds(solids: readonly SceneSolid[], rooms: readonly Room[]): Bounds | null { const boxes: Bounds[] = []; for (const solid of solids) boxes.push(bounds(solid.outline)); diff --git a/src/state/actions.ts b/src/state/actions.ts index 85b91eb..1136c54 100644 --- a/src/state/actions.ts +++ b/src/state/actions.ts @@ -19,6 +19,13 @@ import type { } from '../core/document'; import { createOpening, type OpeningDefaults } from '../core/openings'; import { detectRooms, type RoomDetection } from '../core/rooms'; +import { + createStackedFloor, + descendantsOf, + floorAbove, + floorBelow, + remountForFloor, +} from '../core/floors'; import { DEFAULT_SWING, clampSwingAngle, type Swing } from '../core/swing'; import { nearestWall, projectOntoWall } from '../core/geometry/wall'; import { createSavedView, uniqueViewName, type SpaceCamera } from '../core/views'; @@ -153,6 +160,137 @@ export function deleteSelection(selection: readonly SelectionRef[]): void { }); } + +// --------------------------------------------------------------------------- +// Floors (PLAN.md §11) +// --------------------------------------------------------------------------- + +/** Add an empty floor at the top or bottom of the stack, and switch to it. */ +export function addFloor(where: 'above' | 'below'): Id { + const { doc } = useStore.getState(); + const floor = createStackedFloor(doc, where, newId()); + + useStore.getState().mutate(`Add floor ${where}`, (draft) => { + draft.floors.push(floor); + }); + useStore.getState().setActiveFloor(floor.id); + return floor.id; +} + +export function renameFloor(floorId: Id, name: string): void { + useStore.getState().mutate('Rename floor', (draft) => { + const floor = draft.floors.find((f) => f.id === floorId); + if (floor) floor.name = name; + }); +} + +/** + * Set a floor's datum above building zero. + * + * Signed and unclamped on purpose: a basement's datum is negative, and so is a garage + * half a storey down. + */ +export function setFloorElevation(floorId: Id, elevationMm: number): void { + useStore.getState().mutate('Floor elevation', (draft) => { + const floor = draft.floors.find((f) => f.id === floorId); + if (floor) floor.elevationMm = Math.round(elevationMm); + }); +} + +export function setFloorCeilingHeight(floorId: Id, heightMm: number): void { + useStore.getState().mutate('Default ceiling', (draft) => { + const floor = draft.floors.find((f) => f.id === floorId); + if (floor) floor.defaultCeilingHeightMm = Math.max(1, Math.round(heightMm)); + }); +} + +/** + * Remove a floor and everything on it. + * + * Refuses the last floor: a document with no floors has nowhere to draw, and every + * caller of `activeFloor` would be falling back forever. Returns a sentence when it + * refuses rather than failing quietly. + * + * Anything on *another* floor surface-mounted onto something here is re-seated on its + * own floor, the same repair deletion has done since phase 4. Nothing in the editor + * can create a cross-floor surface mount, but the model can express one and a file + * can contain one, and a dangling `hostId` resolves through `findPlacement` — which + * searches every floor — into an elevation measured against the wrong datum. + */ +export function deleteFloor(floorId: Id): string | null { + const { doc } = useStore.getState(); + if (doc.floors.length <= 1) return 'A space needs at least one floor.'; + + const going = doc.floors.find((f) => f.id === floorId); + if (!going) return null; + + const orphanedHosts = new Set(going.placements.map((p) => p.id)); + const next = floorBelow(doc, floorId) ?? floorAbove(doc, floorId) ?? doc.floors[0]!; + + useStore.getState().mutate(`Delete floor ${going.name}`, (draft) => { + draft.floors = draft.floors.filter((f) => f.id !== floorId); + for (const floor of draft.floors) { + for (const placement of floor.placements) { + if (placement.mount.kind === 'surface' && orphanedHosts.has(placement.mount.hostId)) { + placement.mount = { kind: 'floor' }; + placement.elevation = 0; + } + } + } + if (draft.activeFloorId === floorId) draft.activeFloorId = next.id; + }); + + useStore.getState().setActiveFloor(next.id); + return null; +} + +/** + * Move a placement — and everything standing on it — to another floor. + * + * An explicit action rather than a drag, per PLAN §11: the plan view shows one floor, + * so there is nowhere to drag *to*, and a gesture that silently changed storeys would + * be indistinguishable from a nudge. + * + * Two things travel or break. Anything surface-mounted on it goes too, or it would be + * left pointing at a host on another floor. And a `wall` mount names a wall that does + * not exist over there, so it is re-seated on the floor — reported, because a shelf + * that was on the wall and is now on the ground is worth a sentence rather than a + * discovery. + */ +export function movePlacementToFloor(placementId: Id, floorId: Id): string | null { + const { doc } = useStore.getState(); + const from = doc.floors.find((f) => f.placements.some((p) => p.id === placementId)); + if (!from || from.id === floorId) return null; + if (!doc.floors.some((f) => f.id === floorId)) return null; + + const moving = descendantsOf(from, placementId); + let reseated = 0; + + useStore.getState().mutate('Move to floor', (draft) => { + const source = draft.floors.find((f) => f.id === from.id); + const target = draft.floors.find((f) => f.id === floorId); + if (!source || !target) return; + + const travelling = source.placements.filter((p) => moving.has(p.id)); + source.placements = source.placements.filter((p) => !moving.has(p.id)); + + for (const placement of travelling) { + const next = remountForFloor(placement, moving); + if (next.reseated) reseated++; + placement.floorId = floorId; + placement.mount = next.mount; + placement.elevation = next.elevation; + target.placements.push(placement); + } + }); + + const carried = moving.size - 1; + const parts: string[] = []; + if (carried > 0) parts.push(`${carried} item${carried === 1 ? '' : 's'} on it moved too`); + if (reseated > 0) parts.push(`${reseated} wall mount${reseated === 1 ? '' : 's'} reseated on the floor`); + return parts.length > 0 ? `${parts.join('; ')}.` : null; +} + // --------------------------------------------------------------------------- // Openings (PLAN.md §4.4) // --------------------------------------------------------------------------- diff --git a/src/state/floors.test.ts b/src/state/floors.test.ts new file mode 100644 index 0000000..e65b7db --- /dev/null +++ b/src/state/floors.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { activeFloor, useStore } from './store'; +import { + addCatalogItem, + addFloor, + addPlacement, + addRoomRect, + deleteFloor, + movePlacementToFloor, + setFloorElevation, + renameFloor, +} from './actions'; +import type { ItemDraft } from '../core/catalog'; +import { orderedFloors } from '../core/floors'; + +const DRESSER: ItemDraft = { + name: 'Dresser', + category: 'storage', + shape: 'rect', + widthMm: 1500, + depthMm: 500, + heightMm: 810, + voidBelowMm: 0, +}; + +const LAMP: ItemDraft = { + name: 'Lamp', + category: 'lighting', + shape: 'circle', + widthMm: 300, + depthMm: 300, + heightMm: 500, + voidBelowMm: 0, +}; + +const SHELF: ItemDraft = { + name: 'Wall shelf', + category: 'storage', + shape: 'rect', + widthMm: 900, + depthMm: 250, + heightMm: 300, + voidBelowMm: 0, + defaultMount: 'wall', +}; + +beforeEach(() => { + useStore.getState().newDocument(); + // Every placement needs somewhere to stand; a room also calibrates the plan. + addRoomRect({ x: 0, y: 0 }, { x: 5000, y: 4000 }); +}); + +const groundId = () => useStore.getState().doc.floors[0]!.id; + +describe('adding and removing floors', () => { + it('adds a floor above and makes it the one being edited', () => { + const upstairs = addFloor('above'); + + expect(useStore.getState().doc.floors).toHaveLength(2); + expect(useStore.getState().doc.activeFloorId).toBe(upstairs); + expect(activeFloor(useStore.getState()).walls).toEqual([]); + }); + + it('refuses to remove the only floor, and says why', () => { + // Nothing downstream survives a document with no floors: every caller of + // `activeFloor` would be falling through to a fallback that is not there. + expect(deleteFloor(groundId())).toBe('A space needs at least one floor.'); + expect(useStore.getState().doc.floors).toHaveLength(1); + }); + + it('moves you somewhere real when the floor you were on is removed', () => { + const ground = groundId(); + const upstairs = addFloor('above'); + expect(deleteFloor(upstairs)).toBeNull(); + + expect(useStore.getState().doc.activeFloorId).toBe(ground); + }); + + it('keeps a basement below the ground floor however the array is ordered', () => { + const ground = groundId(); + const basement = addFloor('below'); + + expect(orderedFloors(useStore.getState().doc).map((f) => f.id)).toEqual([basement, ground]); + // Appended, so array order says the opposite. `index` is the one that means it. + expect(useStore.getState().doc.floors.map((f) => f.id)).toEqual([ground, basement]); + }); + + it('re-seats a cross-floor surface mount when its host floor is deleted', () => { + // Nothing in the editor can create one, but the model expresses it and a file can + // contain it — and `findPlacement` searches every floor, so a dangling hostId + // resolves into an elevation measured against the wrong datum. + const dresser = addCatalogItem(DRESSER); + const host = addPlacement(dresser.id, { x: 2000, y: 2000 })!; + + const upstairs = addFloor('above'); + addRoomRect({ x: 0, y: 0 }, { x: 5000, y: 4000 }); + const lamp = addCatalogItem(LAMP); + const rider = addPlacement(lamp.id, { x: 2000, y: 2000 })!; + useStore.getState().mutate('cross-floor mount', (draft) => { + const p = draft.floors + .find((f) => f.id === upstairs)! + .placements.find((x) => x.id === rider.id)!; + p.mount = { kind: 'surface', hostId: host.id }; + p.elevation = 810; + }); + + deleteFloor(groundId()); + + const moved = useStore + .getState() + .doc.floors.find((f) => f.id === upstairs)! + .placements.find((p) => p.id === rider.id)!; + expect(moved.mount).toEqual({ kind: 'floor' }); + expect(moved.elevation).toBe(0); + }); +}); + +describe('switching floors', () => { + it('is not an undo step', () => { + // Undo walks back the edits you made. Having it teleport you between storeys + // instead would make the stack unusable. + const upstairs = addFloor('above'); + const depth = useStore.getState().past.length; + + useStore.getState().setActiveFloor(groundId()); + expect(useStore.getState().past.length).toBe(depth); + + useStore.getState().setActiveFloor(upstairs); + expect(useStore.getState().doc.activeFloorId).toBe(upstairs); + }); + + it('still marks the document dirty, because it has to be saved', () => { + // Reopening a three-storey house on the floor you left it is why the field is in + // the document rather than in the editor. + addFloor('above'); + useStore.getState().markSaved(); + + useStore.getState().setActiveFloor(groundId()); + expect(useStore.getState().dirty).toBe(true); + }); + + it('drops a selection that names something on the floor you left', () => { + // `pruneSelection` alone would keep it: a wall on the floor below still exists. + const wallId = activeFloor(useStore.getState()).walls[0]!.id; + useStore.getState().setSelection([{ kind: 'wall', id: wallId }]); + + addFloor('above'); + expect(useStore.getState().selection).toEqual([]); + }); + + it('does nothing for a floor that is not in the document', () => { + const before = useStore.getState().doc; + useStore.getState().setActiveFloor('nowhere'); + expect(useStore.getState().doc).toBe(before); + }); +}); + +describe('moving a placement to another floor', () => { + it('takes everything standing on it along', () => { + const dresser = addCatalogItem(DRESSER); + const host = addPlacement(dresser.id, { x: 2000, y: 2000 })!; + const lamp = addCatalogItem(LAMP); + // Mounted explicitly: a lamp's default mount is the floor, so dropping one on a + // dresser in a unit test does not stand it on the dresser. + const rider = addPlacement(lamp.id, { x: 2000, y: 2000 }, { + mount: { kind: 'surface', hostId: host.id }, + })!; + + const ground = groundId(); + const upstairs = addFloor('above'); + const notice = movePlacementToFloor(host.id, upstairs); + + const up = useStore.getState().doc.floors.find((f) => f.id === upstairs)!; + const down = useStore.getState().doc.floors.find((f) => f.id === ground)!; + expect(up.placements.map((p) => p.id).sort()).toEqual([host.id, rider.id].sort()); + expect(down.placements).toEqual([]); + expect(up.placements.every((p) => p.floorId === upstairs)).toBe(true); + expect(notice).toContain('1 item on it moved too'); + }); + + it('re-seats a wall mount, and says so', () => { + const shelf = addCatalogItem(SHELF); + const placed = addPlacement(shelf.id, { x: 2500, y: 60 })!; + expect(placed.mount.kind).toBe('wall'); + + const upstairs = addFloor('above'); + const notice = movePlacementToFloor(placed.id, upstairs); + + const moved = useStore + .getState() + .doc.floors.find((f) => f.id === upstairs)! + .placements[0]!; + expect(moved.mount).toEqual({ kind: 'floor' }); + expect(notice).toContain('reseated'); + }); + + it('leaves a lamp behind when only the lamp moves', () => { + // The exemption is "the host is travelling too", not "it is surface-mounted". + const dresser = addCatalogItem(DRESSER); + const host = addPlacement(dresser.id, { x: 2000, y: 2000 })!; + const lamp = addCatalogItem(LAMP); + const rider = addPlacement(lamp.id, { x: 2000, y: 2000 }, { + mount: { kind: 'surface', hostId: host.id }, + })!; + expect(rider.mount.kind).toBe('surface'); + + const upstairs = addFloor('above'); + movePlacementToFloor(rider.id, upstairs); + + const moved = useStore + .getState() + .doc.floors.find((f) => f.id === upstairs)! + .placements[0]!; + expect(moved.mount).toEqual({ kind: 'floor' }); + }); + + it('is one undo step for the whole group', () => { + const dresser = addCatalogItem(DRESSER); + const host = addPlacement(dresser.id, { x: 2000, y: 2000 })!; + const lamp = addCatalogItem(LAMP); + addPlacement(lamp.id, { x: 2000, y: 2000 }, { mount: { kind: 'surface', hostId: host.id } }); + + const ground = groundId(); + const upstairs = addFloor('above'); + movePlacementToFloor(host.id, upstairs); + useStore.getState().undo(); + + expect( + useStore.getState().doc.floors.find((f) => f.id === ground)!.placements, + ).toHaveLength(2); + }); +}); + +describe('floor properties', () => { + it('takes a negative elevation for a basement', () => { + const id = groundId(); + setFloorElevation(id, -2738); + expect(useStore.getState().doc.floors[0]!.elevationMm).toBe(-2738); + }); + + it('renames a floor', () => { + renameFloor(groundId(), 'Ground'); + expect(useStore.getState().doc.floors[0]!.name).toBe('Ground'); + }); +}); diff --git a/src/state/store.ts b/src/state/store.ts index 9d154eb..7e826ef 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -37,6 +37,7 @@ import { type WallDefaults, } from '../core/tools'; import type { EditMode, ViewMode } from '../core/modes'; +import type { FloorVisibility } from '../core/floors'; import { bounds, type Bounds } from '../core/geometry/polygon'; import { wallOutline } from '../core/geometry/wall'; import type { Vec2 } from '../core/geometry/vec'; @@ -68,6 +69,22 @@ export type MutateOptions = { * set it on anything that splices an array. */ coalesce?: boolean; + /** + * Write the document without recording history. + * + * For a document field that records *where you are*, not what the space is — + * `activeFloorId` is the only one. Undo has to walk back the edits you made; having + * it teleport you between storeys instead would make the stack unusable. The change + * still marks the document dirty, because it still has to be saved: reopening a + * three-storey house on the floor you left it is the whole reason the field is in + * the document rather than in the editor. + * + * Only safe for an **absolute write at a fixed path**, the same constraint + * `coalesce` carries, and for the same reason: the patches already on the stack are + * replayed against whatever the document is now, so a silent write that spliced an + * array would leave every one of them pointing at the wrong index. + */ + silent?: boolean; }; export type HistoryEntry = { @@ -214,6 +231,8 @@ export type StoreState = { walker: Walker | null; /** Ceilings hide by default — a dollhouse you cannot see into is not useful. */ showCeilings: boolean; + /** Which floors the space view draws. Display only — collision is always the active floor. */ + floorVisibility: FloorVisibility; /** * A camera pose the 3D view should jump to, consumed once and cleared. * @@ -254,6 +273,8 @@ export type StoreState = { setCameraMode: (mode: CameraMode) => void; setWalker: (walker: Walker | null) => void; setShowCeilings: (on: boolean) => void; + setFloorVisibility: (visibility: FloorVisibility) => void; + setActiveFloor: (floorId: Id) => void; setPendingCamera: (camera: SpaceCamera | null) => void; setNotice: (notice: string | null) => void; applySavedView: (view: SavedView) => void; @@ -327,6 +348,11 @@ export const useStore = create((set, get) => ({ // happen" — a recipe that turned out to be a no-op must not land on the stack. if (patches.every((p) => p.path[0] === 'modifiedAt')) return; + if (options?.silent) { + set({ doc: next, dirty: true, selection: pruneSelection(next, state.selection) }); + return; + } + const previous = state.past[state.past.length - 1]; // Keep the older entry's inverse: undo has to reach the state before the whole // gesture, not before its last frame. @@ -466,6 +492,7 @@ export const useStore = create((set, get) => ({ cameraMode: 'orbit', walker: null, showCeilings: false, + floorVisibility: 'active', pendingCamera: null, notice: null, @@ -521,6 +548,41 @@ export const useStore = create((set, get) => ({ setCameraMode: (cameraMode) => set({ cameraMode }), setWalker: (walker) => set({ walker }), setShowCeilings: (showCeilings) => set({ showCeilings }), + setFloorVisibility: (floorVisibility) => set({ floorVisibility }), + + /** + * Change which floor everything is addressing. + * + * Silent, so undo walks back edits rather than storeys. Everything that names + * something on the old floor goes with it: a selection, a half-drawn wall chain, a + * drag in progress, a walkway route measured through rooms you are no longer + * looking at. `pruneSelection` alone would not do it — it drops what no longer + * exists, and a wall on the floor below still exists perfectly well. + */ + setActiveFloor: (floorId) => { + const { doc } = get(); + if (doc.activeFloorId === floorId) return; + if (!doc.floors.some((f) => f.id === floorId)) return; + + get().mutate( + 'Active floor', + (draft) => { + draft.activeFloorId = floorId; + }, + { silent: true }, + ); + set({ + selection: [], + draft: null, + transform: null, + placementTransform: null, + placingItemId: null, + walkway: null, + walker: null, + cursor: null, + snapHints: [], + }); + }, setPendingCamera: (pendingCamera) => set({ pendingCamera }), setNotice: (notice) => set({ notice }), diff --git a/src/styles/global.css b/src/styles/global.css index 91b6e29..13013c1 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -667,6 +667,11 @@ body { font-variant-numeric: tabular-nums; } +.seg--select { + min-width: 0; + max-width: 140px; +} + .panel__count { font-size: 12px; color: var(--text-dim); diff --git a/src/ui/PropertiesPanel.tsx b/src/ui/PropertiesPanel.tsx index 140f8a1..7ed6604 100644 --- a/src/ui/PropertiesPanel.tsx +++ b/src/ui/PropertiesPanel.tsx @@ -19,7 +19,14 @@ import { setRoomName, setRoomCeilingHeight, detectFloorRooms, + addFloor, + deleteFloor, + movePlacementToFloor, + renameFloor, + setFloorCeilingHeight, + setFloorElevation, } from '../state/actions'; +import { floorBelow, orderedFloors } from '../core/floors'; import { findItem, type Floor, type SpaceDocument } from '../core/document'; import { resolveElevation, roomAt, surfaceHeight } from '../core/placement'; import { ROTATION_STEP_DEG } from '../core/placement-snap'; @@ -159,6 +166,116 @@ function OpeningSwing({ opening }: { opening: Opening }) { ); } +/** + * The active floor's own properties, and the shape of the stack. + * + * Switching floors is in the top bar with the view controls; this is where a floor is + * named, given a datum and removed. The split is between navigation and property, not + * between two views of the same thing. + * + * Elevation is editable and signed rather than derived from the stack. A split level, + * a mezzanine and a garage half a storey down are all real buildings, and none of them + * survive a formula — so the formula only supplies the opening guess when a floor is + * created. + */ +function FloorProperties({ editable }: { editable: boolean }) { + const { doc, floorId } = useStore( + useShallow((s) => ({ doc: s.doc, floorId: s.doc.activeFloorId })), + ); + const floor = activeFloor({ doc }); + const unit = doc.displayUnit; + const [error, setError] = useState(null); + + const [draft, setDraft] = useState(floor.name); + useEffect(() => setDraft(floor.name), [floorId, floor.name]); + + const below = floorBelow(doc, floorId); + + return ( +
    + + setFloorElevation(floorId, mm)} + /> + {/* Used by every placement that falls outside a traced room, and the height a + new floor above this one is stacked to clear. */} + setFloorCeilingHeight(floorId, mm)} + /> + + +
    + + + +
    + {error ? ( +

    + {error} +

    + ) : null} +
    + ); +} + /** * Derive rooms from the walls that enclose them. * @@ -487,6 +604,27 @@ export function PropertiesPanel() { /> ) : null} + {/* Explicit, per PLAN §11: the plan view shows one floor, so there is + nowhere to drag to, and a gesture that silently changed storeys would be + indistinguishable from a nudge. */} + {doc.floors.length > 1 ? ( + + ) : null} +
    + {/* Switching floors is navigation, not a property, so it lives with the view + controls rather than in the properties panel — where the floor's own name, + elevation and ceiling are edited. */} +
    + +
    +
    {VIEW_MODES.map((mode) => ( ))} + {/* Display only. Collision always comes from the active floor, or walking + would change depending on what you had chosen to look at. */} + {FLOOR_VISIBILITY.map((v) => ( + + ))} + +
    + ); +} diff --git a/src/ui/TopBar.tsx b/src/ui/TopBar.tsx index 7eb82e2..63a464a 100644 --- a/src/ui/TopBar.tsx +++ b/src/ui/TopBar.tsx @@ -9,7 +9,9 @@ import { import { orderedFloors } from '../core/floors'; import { useStore } from '../state/store'; import { renameDocument } from '../state/actions'; -import { openDocumentFile, saveDocument, SPACE_EXTENSION } from './file-io'; +import { saveDocument, SPACE_EXTENSION } from './file-io'; +import { openSpace } from './file-actions'; +import { forgetAutosave } from '../state/autosave'; import { ImportButton } from './ImportButton'; export function TopBar() { @@ -38,28 +40,53 @@ export function TopBar() { else setDraftTitle(doc.title); }; - const onSave = () => { + const [saving, setSaving] = useState(false); + // A ref, not the state above: the Ctrl+S handler is registered once and would + // otherwise close over whatever `saving` was on the render that installed it. + const inFlight = useRef(false); + + /** + * Save, and only then say it is saved. + * + * Three outcomes, and they are not the same event. A write that resolves marks the + * document clean and drops its autosave — that drop is what keeps the recovery + * prompt meaningful. A **cancelled** picker does neither and shows nothing: the + * user changed their mind, and a dialog saying so, or a cleared dirty flag, would + * both be lies. Only a genuine failure gets an alert. + */ + const onSave = async (chooseTarget = false) => { + if (inFlight.current) return; + inFlight.current = true; + setSaving(true); try { - saveDocument(doc); + // Read the document at call time: the keyboard handler outlives this render. + const current = useStore.getState().doc; + const outcome = await saveDocument(current, { chooseTarget }); + if (outcome.kind === 'cancelled') return; useStore.getState().markSaved(); + forgetAutosave(current.id); } catch (err) { window.alert(err instanceof Error ? err.message : 'Could not save this space.'); + } finally { + inFlight.current = false; + setSaving(false); } }; - const onOpen = async (file: File) => { - try { - // The assets travel with the document into the runtime store; passing only - // `.document` here is what made a reopened background render as nothing. - const bundle = await openDocumentFile(file); - useStore.getState().loadDocument(bundle.document, bundle.assets); - useStore.getState().zoomToFit(); - } catch (err) { - // A bad file is the user's problem to fix, not a crash to swallow: say what - // went wrong and leave the document they already have untouched. - window.alert(err instanceof Error ? err.message : 'Could not open that file.'); - } - }; + // Ctrl+S lives here rather than in the plan stage because it has to work in the 3D + // view too, where that stage is not mounted. Shift is "save as" — the only way back + // to the picker once a handle is held. + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 's') return; + // Without this the browser's own "save page" dialog opens over the app. + e.preventDefault(); + void onSave(e.shiftKey); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + // Registered once. Everything it reads comes from `getState()` at fire time. + }, []); return (
    @@ -94,8 +121,25 @@ export function TopBar() { - + diff --git a/src/ui/file-actions.ts b/src/ui/file-actions.ts new file mode 100644 index 0000000..304aa22 --- /dev/null +++ b/src/ui/file-actions.ts @@ -0,0 +1,52 @@ +/** + * What happens to a file, once something has one. + * + * The button in the top bar and the window drop target hand files to the same two + * functions, so a dropped `.space` and a picked one cannot come to differ in what + * they load, what they clear, or what they say when the file is bad. + * + * Routing is by extension rather than by content. A `.space` is a zip and so is a + * `.docx`; sniffing would tell them apart, but the honest signal for "the user meant + * this to be a space" is what they named it. Everything else goes to the plan + * importer, which sniffs properly and refuses what it cannot read. + */ + +import { SPACE_EXTENSION, openDocumentFile } from './file-io'; +import { beginImport } from './import/plan-import'; +import { useStore } from '../state/store'; + +export function isSpaceFile(file: File): boolean { + return file.name.toLowerCase().endsWith(SPACE_EXTENSION); +} + +/** + * Open a `.space`, replacing what is loaded. + * + * A bad file is the user's problem to fix, not a crash to swallow: say what went + * wrong and leave the document they already have untouched. + */ +export async function openSpace(file: File): Promise { + try { + // The assets travel with the document into the runtime store; passing only + // `.document` here is what made a reopened background render as nothing. + const bundle = await openDocumentFile(file); + useStore.getState().loadDocument(bundle.document, bundle.assets); + useStore.getState().zoomToFit(); + } catch (err) { + window.alert(err instanceof Error ? err.message : 'Could not open that file.'); + } +} + +/** Import a floor plan (PDF or image), opening the calibration gate. */ +export async function importPlan(file: File): Promise { + try { + await beginImport(file); + } catch (err) { + window.alert(err instanceof Error ? err.message : 'That file could not be imported.'); + } +} + +/** Route a file by what it is called. */ +export async function acceptFile(file: File): Promise { + return isSpaceFile(file) ? openSpace(file) : importPlan(file); +} diff --git a/src/ui/file-io.ts b/src/ui/file-io.ts index e2ae6b2..9ec2551 100644 --- a/src/ui/file-io.ts +++ b/src/ui/file-io.ts @@ -1,31 +1,56 @@ /** - * Saving and opening `.space` files. + * Saving and opening `.space` files. See PLAN.md §5. * - * Phase 3 uses a download and a file input — universally supported, and enough to - * prove the portability requirement end to end: import a plan, calibrate it, draw, - * save, reload the page, open, and get the same space back with its background - * intact. Save-in-place through the File System Access API and IndexedDB autosave - * are phase 9. + * Two ways out, one pipeline. The bytes are built identically for both — document, + * assets, thumbnail — and only the last step differs: a retained File System Access + * handle where the browser has one, an anchor and a blob URL where it does not. + * + * The download path is not a degraded mode to apologise for. It is what Firefox and + * Safari get, it proved the portability requirement end to end through phases 3 to 8, + * and it is still the fallback when a retained handle has lost permission. */ import { readSpace, writeSpace, SpaceFileError, type SpaceBundle } from '../core/space-file'; import type { SpaceDocument } from '../core/document'; import { assetMapFor } from '../state/assets'; +import { + SaveCancelled, + currentTarget, + ensureWritable, + pickSaveTarget, + setSaveTarget, + supportsSaveInPlace, + writeToTarget, + type SaveHandle, +} from '../state/save-target'; +import { renderThumbnail } from './thumbnail'; export const SPACE_EXTENSION = '.space'; +export type SaveOutcome = + | { kind: 'in-place'; name: string } + | { kind: 'download'; name: string } + | { kind: 'cancelled' }; + /** A filesystem-safe filename derived from the document title. */ export function fileNameFor(doc: SpaceDocument): string { const base = doc.title.trim().replace(/[^\w\-. ]+/g, '').replace(/\s+/g, '-') || 'untitled'; return `${base}${SPACE_EXTENSION}`; } -export function saveDocument(doc: SpaceDocument, appVersion?: string): void { - // Asset bytes come from the runtime store, not from the document — the document - // carries only the manifest. `assetMapFor` throws rather than writing a container - // short of a file the document references, because that produces a `.space` which - // opens with a blank background on the recipient's machine, which is the one - // failure this format exists to prevent. +/** + * The container bytes, thumbnail included. + * + * Asset bytes come from the runtime store, not from the document — the document + * carries only the manifest. `assetMapFor` throws rather than writing a container + * short of a file the document references, because that produces a `.space` which + * opens with a blank background on the recipient's machine, which is the one failure + * this format exists to prevent. + */ +export async function buildSpaceBytes( + doc: SpaceDocument, + appVersion?: string, +): Promise { let assets; try { assets = assetMapFor(doc); @@ -33,18 +58,28 @@ export function saveDocument(doc: SpaceDocument, appVersion?: string): void { throw new SpaceFileError(err instanceof Error ? err.message : 'Could not collect this space.'); } - const bytes = writeSpace({ document: doc, assets }, appVersion); + // A thumbnail is a convenience for other people's file browsers and never a reason + // to fail a save — `renderThumbnail` returns undefined rather than throwing, and + // `writeSpace` treats the entry as optional. + const thumbnail = await renderThumbnail(doc); + const bundle: SpaceBundle = thumbnail + ? { document: doc, assets, thumbnail } + : { document: doc, assets }; + return writeSpace(bundle, appVersion); +} + +function download(doc: SpaceDocument, bytes: Uint8Array): void { // `bytes.buffer` may be a pooled ArrayBuffer larger than the data; slice to the // exact range so the blob is not padded with whatever followed it. const blob = new Blob([bytes.slice()], { type: 'application/zip' }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); + const a = globalThis.document.createElement('a'); a.href = url; a.download = fileNameFor(doc); a.style.display = 'none'; - document.body.appendChild(a); + globalThis.document.body.appendChild(a); a.click(); a.remove(); @@ -52,6 +87,48 @@ export function saveDocument(doc: SpaceDocument, appVersion?: string): void { setTimeout(() => URL.revokeObjectURL(url), 10_000); } +/** + * Save, in place where the browser allows it. + * + * `chooseTarget` forces the picker — "Save as…". Without it a document that already + * has a target writes straight to it, which is the entire point of holding the handle. + * + * Cancelling is a decision, not a failure: it returns `cancelled`, the caller shows + * nothing, and the document stays dirty because it genuinely was not saved. Nothing + * here marks the document clean — that is the caller's job and only on a resolved + * write, or a dismissed dialog would quietly clear the dirty flag. + */ +export async function saveDocument( + doc: SpaceDocument, + options: { chooseTarget?: boolean } = {}, +): Promise { + const bytes = await buildSpaceBytes(doc); + + if (supportsSaveInPlace()) { + let handle: SaveHandle | null = options.chooseTarget ? null : currentTarget(); + if (!handle) { + try { + handle = await pickSaveTarget(fileNameFor(doc)); + } catch (err) { + if (err instanceof SaveCancelled) return { kind: 'cancelled' }; + throw err; + } + } + + // Permission can be revoked between sessions or from the omnibox mid-session. + // Refusing it drops to a download rather than failing: the work still lands + // somewhere the user can find it, which is what saving is for. + if (await ensureWritable(handle)) { + await writeToTarget(handle, bytes); + setSaveTarget(handle); + return { kind: 'in-place', name: handle.name }; + } + } + + download(doc, bytes); + return { kind: 'download', name: fileNameFor(doc) }; +} + /** * Read a `.space` file. * diff --git a/src/ui/import/plan-import.ts b/src/ui/import/plan-import.ts index d6136b3..6e7137f 100644 --- a/src/ui/import/plan-import.ts +++ b/src/ui/import/plan-import.ts @@ -26,6 +26,7 @@ import { isRaster, sniffMime, type ImportMime, + type Inspection, type RasterMime, } from '../../core/media'; import { putAsset } from '../../state/assets'; @@ -41,9 +42,7 @@ import { decodeImageSize } from './raster'; */ const pdfModule = () => import('./pdf'); -export type Inspection = - | { kind: 'image'; fileName: string; mime: RasterMime; bytes: Uint8Array } - | { kind: 'pdf'; fileName: string; bytes: Uint8Array; pageCount: number }; +export type { Inspection }; export class ImportError extends Error { constructor(message: string) { @@ -123,3 +122,24 @@ export async function attachPlan(inspection: Inspection, pageIndex = 0): Promise setBackground(background, assets); useStore.getState().beginCalibration(); } + +/** + * The one import path, whether the file was picked or dropped on the window. + * + * A multi-page PDF stops here and parks in the store; the page picker renders from + * that and calls `attachPlan` once a page is chosen. Everything else attaches + * immediately — asking which page to use when there is exactly one is a dialog whose + * only correct answer is the one already selected. + * + * Two entry points each calling `inspectFile` and then deciding for themselves is how + * a dropped file quietly grows different behaviour from a picked one, and §6's "both + * paths produce identical structures, one code path" is a claim about this function. + */ +export async function beginImport(file: File): Promise { + const inspection = await inspectFile(file); + if (inspection.kind === 'pdf' && inspection.pageCount > 1) { + useStore.getState().setPendingImport(inspection); + return; + } + await attachPlan(inspection); +} diff --git a/src/ui/thumbnail.ts b/src/ui/thumbnail.ts new file mode 100644 index 0000000..d62f713 --- /dev/null +++ b/src/ui/thumbnail.ts @@ -0,0 +1,104 @@ +/** + * Turning `core/thumbnail`'s numbers into a PNG. + * + * The thin half of the split: every decision about what is drawn and where it lands is + * in the core module and tested there. What is left here is `ctx.fill()`, a canvas and + * an encoder — the parts a unit test could only assert by re-implementing them. + * + * ## Fixed colours, not the plan theme + * + * The thumbnail is read by someone else's file browser, on a machine whose dark-mode + * setting has nothing to do with yours. A plan that renders as light-on-dark because + * of how the *author's* laptop was configured is a worse picture, not a personalised + * one. + * + * ## A thumbnail never blocks a save + * + * Every failure path returns `undefined` and the container is written without the + * entry, which `writeSpace` already treats as optional. Losing a preview is a + * cosmetic loss; losing the save because the preview failed is not a trade worth + * making, and there is no environment where the fix is "give up on the document". + */ + +import { findFloor, type SpaceDocument } from '../core/document'; +import { + THUMBNAIL_PX, + thumbnailBounds, + thumbnailFit, + thumbnailShapes, +} from '../core/thumbnail'; +import type { Vec2 } from '../core/geometry/vec'; + +const PAPER = '#f6f5f2'; +const ROOM = '#e4e6ea'; +const WALL = '#3a3f4b'; +const PLACEMENT = '#7c8aa5'; + +function path(ctx: CanvasRenderingContext2D, ring: readonly Vec2[]): void { + const first = ring[0]; + if (!first) return; + ctx.beginPath(); + ctx.moveTo(first.x, first.y); + for (let i = 1; i < ring.length; i++) { + const p = ring[i]; + if (p) ctx.lineTo(p.x, p.y); + } + ctx.closePath(); +} + +/** + * A 512px PNG of the active floor, or `undefined` when there is nothing to draw or + * no canvas to draw it on. + */ +export async function renderThumbnail(doc: SpaceDocument): Promise { + try { + if (typeof document === 'undefined') return undefined; + + const floor = findFloor(doc, doc.activeFloorId) ?? doc.floors[0]; + if (!floor) return undefined; + + const box = thumbnailBounds(doc, floor); + if (!box) return undefined; + + const fit = thumbnailFit(box, THUMBNAIL_PX); + const shapes = thumbnailShapes(doc, floor, fit); + + const canvas = document.createElement('canvas'); + canvas.width = THUMBNAIL_PX; + canvas.height = THUMBNAIL_PX; + const ctx = canvas.getContext('2d'); + if (!ctx) return undefined; + + ctx.fillStyle = PAPER; + ctx.fillRect(0, 0, THUMBNAIL_PX, THUMBNAIL_PX); + + // Painting order is the plan view's: rooms are the ground, furniture sits on it, + // walls are on top of both so a wall is never hidden by what stands against it. + ctx.fillStyle = ROOM; + for (const ring of shapes.rooms) { + path(ctx, ring); + ctx.fill(); + } + + ctx.fillStyle = PLACEMENT; + for (const ring of shapes.placements) { + path(ctx, ring); + ctx.fill(); + } + + ctx.fillStyle = WALL; + for (const ring of shapes.walls) { + path(ctx, ring); + ctx.fill(); + } + + const blob = await new Promise((resolve) => { + canvas.toBlob((b) => resolve(b), 'image/png'); + }); + if (!blob) return undefined; + + return new Uint8Array(await blob.arrayBuffer()); + } catch { + return undefined; + } +} From d44f86e2b3f685add8fc3bc71c1670c4fb8ed09d Mon Sep 17 00:00:00 2001 From: Chintan Date: Thu, 3 Sep 2026 03:06:30 -0400 Subject: [PATCH 17/19] The autosave deadline a review pass found never firing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit schedule() returned early while a timer was pending, so the debounce was armed by the first change after a write and never re-armed. The deadline term in autosaveDelay is lastWriteAt + 20000 - now, and lastWriteAt is always recent at the moment schedule() runs, so the min always picked the quiet period: a two-second throttle wearing a debounce's documentation. Every delay autosaveDelay returned was individually correct, which is why an exhaustive unit test over it stayed green. The behaviours differ only in how many writes a burst produces, so the new test drives mutate every 100ms for 20s and counts them — one at the cap, against nine before. The burst that reaches this is a slider, not a drag: the two-slice store means a wall drag touches the document once, on release. --- src/state/autosave.test.ts | 97 +++++++++++++++++++++++++++++++++++++- src/state/autosave.ts | 10 ++-- 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/src/state/autosave.test.ts b/src/state/autosave.test.ts index 8dcb3f8..d0650d3 100644 --- a/src/state/autosave.test.ts +++ b/src/state/autosave.test.ts @@ -1,5 +1,22 @@ -import { describe, expect, it } from 'vitest'; -import { AUTOSAVE_MAX_INTERVAL_MS, AUTOSAVE_QUIET_MS, autosaveDelay } from './autosave'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + AUTOSAVE_MAX_INTERVAL_MS, + AUTOSAVE_QUIET_MS, + autosaveDelay, + resetAutosaveTiming, + startAutosave, +} from './autosave'; +import { useStore } from './store'; + +const writes = vi.hoisted(() => ({ count: 0 })); + +vi.mock('./autosave-db', () => ({ + writeAutosave: () => { + writes.count++; + return Promise.resolve(true); + }, + dropAutosave: () => Promise.resolve(true), +})); /** * §5 asks for "every 20s **and** on every meaningful mutation". Taken literally those @@ -43,3 +60,79 @@ describe('when the next autosave runs', () => { } }); }); + +/** + * The scheduling rule, wired up. + * + * `autosaveDelay` can be exhaustively correct while nothing ever hands it the input it + * is proudest of. An earlier version of `schedule()` left a pending timer standing + * instead of re-arming it, so the deadline term never bound and the whole thing was a + * two-second throttle — with every delay it returned still individually correct. + * + * So the assertion here is a **count of writes under sustained change**, which is the + * only thing the two behaviours disagree about. + */ +describe('the schedule, driven', () => { + let stop: () => void; + + beforeEach(() => { + writes.count = 0; + resetAutosaveTiming(); + vi.useFakeTimers(); + useStore.getState().newDocument(); + stop = startAutosave(); + }); + + afterEach(() => { + stop(); + resetAutosaveTiming(); + vi.useRealTimers(); + }); + + /** One ordinary document change. */ + function touch(n: number): void { + useStore.getState().mutate('Grid', (draft) => { + draft.gridMm = n; + }); + } + + it('writes once, shortly after a single edit stops', async () => { + touch(10); + await vi.advanceTimersByTimeAsync(AUTOSAVE_QUIET_MS - 100); + expect(writes.count).toBe(0); + + await vi.advanceTimersByTimeAsync(200); + expect(writes.count).toBe(1); + }); + + it('does not write again while nothing changes', async () => { + touch(10); + await vi.advanceTimersByTimeAsync(AUTOSAVE_MAX_INTERVAL_MS * 3); + expect(writes.count).toBe(1); + }); + + it('writes once at the deadline, not every quiet period, under sustained change', async () => { + // A slider sweep: `mutate` coalesces the history entry but still produces a new + // document on every input event. With the debounce re-armed each time, the only + // thing that can fire is the deadline. + for (let elapsed = 0; elapsed < AUTOSAVE_MAX_INTERVAL_MS - 500; elapsed += 100) { + touch(10 + (elapsed % 7)); + await vi.advanceTimersByTimeAsync(100); + } + + expect(writes.count).toBe(0); + + await vi.advanceTimersByTimeAsync(600); + expect(writes.count).toBe(1); + }); + + it('stops writing once the document is saved', async () => { + touch(10); + useStore.getState().markSaved(); + await vi.advanceTimersByTimeAsync(AUTOSAVE_MAX_INTERVAL_MS * 2); + // Re-checked at fire time, not only when scheduled: a save landing inside the + // debounce window would otherwise write a record for a document now on disk, and + // a surviving record is supposed to mean there is unsaved work. + expect(writes.count).toBe(0); + }); +}); diff --git a/src/state/autosave.ts b/src/state/autosave.ts index dcc6831..3278c62 100644 --- a/src/state/autosave.ts +++ b/src/state/autosave.ts @@ -67,9 +67,13 @@ async function write(): Promise { } function schedule(): void { - if (timer !== null) return; - const delay = autosaveDelay(Date.now(), lastWriteAt); - timer = setTimeout(() => void write(), delay); + // Re-armed on every change, not left standing from the first one. Leaving it alone + // while a timer is pending turns the whole thing into a two-second throttle: the + // deadline term in `autosaveDelay` only ever binds when the debounce is actually + // reset, so nothing would ever wait longer than the quiet period and the twenty + // seconds would be decoration. + cancel(); + timer = setTimeout(() => void write(), autosaveDelay(Date.now(), lastWriteAt)); } /** From c3d3677094529e1a9389cda03ec694d9552addbd Mon Sep 17 00:00:00 2001 From: Chintan Date: Thu, 3 Sep 2026 03:29:00 -0400 Subject: [PATCH 18/19] Phase 9b: reading a product page, and not trusting what it says MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four parser tiers — JSON-LD, microdata, OpenGraph, page text — with the dimensions tracked apart from the name and image, because they are the only part that becomes geometry and a page can publish a clean name while leaving the measurements to a paragraph. Scraped numbers do not go through parseLength. It reads a bare number in the document's display unit, which is right for a field with the unit on screen beside it and exactly wrong for a spec table: 84 x 38 x 32 in a millimetre document is an 84mm sofa. Every number here needs its own unit, a QuantitativeValue with no unitCode included. The client decides the endpoint is absent by content type, never response.ok. A static host answers an unknown POST with a 200 carrying index.html; ok is true and the JSON parse then throws inside a catch written for network failures. vite preview is such a host, which is why the degradation test runs against a real absence. The endpoint fetches a URL a stranger supplied from inside the server's network. It resolves before connecting, revalidates every redirect hop, and caps the read as it goes rather than trusting content-length. Fixtures are synthetic and §13 now says so. Building real ones means fetching third-party pages for this repo's convenience and committing someone else's markup. Also fixes an old one this work walked into: the item form prefilled raw millimetres into fields parsed in the display unit, so opening a 2'7" armchair and pressing Save with nothing changed made it 67'6" wide. The existing edit test only renamed, so nothing caught it. --- PLAN.md | 74 +++- api/product-lookup.ts | 42 +++ e2e/inventory.spec.ts | 18 + e2e/product-url.spec.ts | 163 +++++++++ package.json | 1 + pnpm-lock.yaml | 83 +++++ src/core/api.ts | 9 + src/core/catalog.ts | 15 +- src/core/dimensions.test.ts | 135 +++++++ src/core/dimensions.ts | 274 ++++++++++++++ src/core/fixtures/product/README.md | 29 ++ src/core/fixtures/product/json-ld-graph.html | 24 ++ src/core/fixtures/product/json-ld.html | 27 ++ src/core/fixtures/product/messy.html | 22 ++ src/core/fixtures/product/microdata.html | 14 + src/core/fixtures/product/opengraph.html | 12 + src/core/fixtures/product/spec-table.html | 13 + src/core/product-import.test.ts | 116 ++++++ src/core/product-import.ts | 79 +++++ src/core/product.test.ts | 154 ++++++++ src/core/product.ts | 354 +++++++++++++++++++ src/server/endpoint.ts | 34 ++ src/server/lookup.test.ts | 292 +++++++++++++++ src/server/lookup.ts | 285 +++++++++++++++ src/styles/global.css | 19 + src/ui/InventoryPanel.tsx | 124 +++++++ src/ui/ItemForm.tsx | 22 +- src/ui/product-lookup.test.ts | 97 +++++ src/ui/product-lookup.ts | 78 ++++ vite.config.ts | 51 ++- 30 files changed, 2637 insertions(+), 23 deletions(-) create mode 100644 api/product-lookup.ts create mode 100644 e2e/product-url.spec.ts create mode 100644 src/core/api.ts create mode 100644 src/core/dimensions.test.ts create mode 100644 src/core/dimensions.ts create mode 100644 src/core/fixtures/product/README.md create mode 100644 src/core/fixtures/product/json-ld-graph.html create mode 100644 src/core/fixtures/product/json-ld.html create mode 100644 src/core/fixtures/product/messy.html create mode 100644 src/core/fixtures/product/microdata.html create mode 100644 src/core/fixtures/product/opengraph.html create mode 100644 src/core/fixtures/product/spec-table.html create mode 100644 src/core/product-import.test.ts create mode 100644 src/core/product-import.ts create mode 100644 src/core/product.test.ts create mode 100644 src/core/product.ts create mode 100644 src/server/endpoint.ts create mode 100644 src/server/lookup.test.ts create mode 100644 src/server/lookup.ts create mode 100644 src/ui/product-lookup.test.ts create mode 100644 src/ui/product-lookup.ts diff --git a/PLAN.md b/PLAN.md index 8430469..19122d2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,8 +1,8 @@ # floorplan — Implementation Plan **Location:** `Development/conquerorchin/floorplan/` -**Status:** Planning — nothing built yet -**Date:** 2026-09-01 +**Status:** Phases 0-9 built; see the phasing table in §12 +**Date:** 2026-09-03 --- @@ -557,14 +557,53 @@ Width: 84 in → labeled rows in a spec table **The result always lands in a confirm-before-add dialog** with every field editable, the source URL shown, and the raw text snippet the numbers came from displayed -alongside. A scraped dimension never becomes geometry unconfirmed. Confidence is stored -on the item (`parsed` vs `confirmed`) and parsed-but-unconfirmed items are flagged in -the inventory list. - -Deployment: Vite dev middleware in development, one serverless function in production. -**The app remains fully functional as a static build with the endpoint absent** — URL -import degrades to a message pointing at manual entry. This is a convenience layer, not -a dependency. +alongside. A scraped dimension never becomes geometry unconfirmed. + +Name, image and price come from the first tier that has them; **dimensions are tracked +separately**, because they are the only part that becomes geometry. A page can publish +a clean JSON-LD name and leave the measurements to a paragraph, and reporting `json-ld` +for the whole reading would overstate where the numbers came from. + +**Every scraped number needs its own unit.** `parseLength` reads a bare number in the +document's *display* unit, which is right for a field someone is typing into and +exactly wrong for a spec table: `84 x 38 x 32` in a millimetre document would silently +become an 84mm sofa. `core/dimensions.ts` refuses a unitless number outright — a +`QuantitativeValue` with no `unitCode` included, because a structured field is not more +trustworthy than a paragraph when the thing that makes a number a length is missing +from both. + +`parsed` versus `confirmed` needs a definition, because every item added this way has +passed a confirm dialog and "the user saw it" would make `confirmed` universal and the +flag meaningless. The distinction stored is narrower: **was any dimension accepted +exactly as scraped?** A number someone typed or corrected has been checked against +something; a number they left alone has not. `parsed` marks an item as carrying at +least one measurement nobody verified, which is the item you want flagged when a sofa +turns out not to fit. + +### 7.3 The endpoint + +Deployment: Vite dev middleware in development (`configureServer` only — **not** +preview, so preview stays an honest rehearsal of a static deploy), one serverless +function in production. **The app remains fully functional as a static build with the +endpoint absent** — URL import degrades to a message pointing at manual entry. This is +a convenience layer, not a dependency. + +`response.ok` is **not** how the client finds out whether the endpoint exists. A static +host answering an unknown POST returns a 404, a 405, or — on any host with an SPA +fallback, `vite preview` included — a **200 carrying `index.html`**. That last one makes +`ok` true and then throws inside a `catch` written for network failures. The test is the +**content type**: anything that is not JSON means absent, whatever the status line says. + +This endpoint fetches a URL a stranger supplied from inside the server's network, which +is SSRF by construction rather than by accident. https only; no credentials in the URL; +public addresses only, with the hostname **resolved first** because `evil.example.com` +is free to publish an A record of `169.254.169.254`; redirects followed by hand, at most +three, revalidating every hop, because a redirect is the standard way past a check that +only looks at what the user typed; a response size cap enforced *while reading* rather +than by trusting `content-length`; and a hard timeout. The residual hole is stated +rather than papered over: between the DNS check and the connection a record can change, +and closing that needs the socket pinned to the address that was checked, which `fetch` +does not expose. --- @@ -980,7 +1019,7 @@ isolated so neither blocks the core editor. | **6** | **Openings, complete** — swing arcs in 2D, hinged door panels and window panes in 3D, sliding/pocket/cased variants, swing-vs-object clearance. The five kinds behave in four different ways, and the difference is the reason the kinds exist: hinged doors need their swept sector clear, sliders need the wall they park over clear, pocket doors need **nothing** in the room clear and instead need a cavity that can exist, and cased openings and windows need nothing at all. The swept sector is computed once and serves three consumers — its boundary *is* the 2D door symbol (closed leaf, arc, open leaf), it is the clearance polygon, and it positions the 3D panel — so the drawing and the check cannot disagree about where the door goes. Hanging the leaf is done with **flip buttons, not selects**, because there is no honest label for the two sides of a wall; the arc in the drawing is what makes the choice legible. The angle field holds its text locally and commits on blur or Enter, like the room name and every length field: writing per keystroke to a value that is *clamped* means the `1` of `135` is stored as `15` and the rest of the number is typed against that, so no angle whose first digit falls below the floor can be entered at all. `fill()` in a test never sees it, because it delivers the whole value in one change event. Deferred and stated rather than claimed: **windows do not open** (a casement sash would swing like a door and is not built), and the 3D layer-toggle gate on a door leaf is covered by an exhaustive unit test over `refIsEditable` rather than end to end — in the orbit view a leaf is a slab a few pixels wide seen edge-on, and walk mode does not take selection clicks at all, so hunting for it with a grid of clicks would test where the camera happens to sit. | | **7** | **Clearance and circulation** — clearance zones on catalog items, the standard preset library, walkway width probe, consolidated validation panel across overlap/headroom/clearance/swing. Two checks that sound alike and are not: a zone asks whether a drawer opens, the probe asks whether a person fits, and they differ on whether walls count (see §9.3 — they do not for a zone, they do for the probe). Zones are drawn on the selected item only, for the reason the swing arc is drawn: a warning that says "the bookcase blocks the drawer pull" is an argument and the hatched rectangle is the evidence — but six dining chairs with pull-out zones would carpet the floor in hatching and say nothing. The probe stores the *route*, not the number, so it re-answers as furniture moves; the tool stays live in furnish mode for the same reason. The panel groups by what you would do about a problem rather than by the issue enum, keeping `validateFloor`'s blocking-first order rather than forming a second opinion about severity in the component least qualified to have one. Deferred and stated rather than claimed: **the 900mm probe height in §9.3 was wrong and is now a body interval** — a sofa back is 840mm, so the specified ray passed over the one piece of furniture the spec named; and the full medial-axis navmesh remains explicitly out of scope, so the probe reports the narrowest gap *at a sample*, not the true infimum. | | **8** | **Multi-room and multi-floor** — room detection and areas, per-room ceiling heights, floor stacking, ghost underlay, 3D floor toggles. Detection keeps planar-graph faces **by sign** rather than by magnitude, because a courtyard's outer face is smaller than the room around it; and it splits walls at T-junctions as well as crossings, which is the pass that decides whether it works on a real plan at all. Boundaries are centrelines, matching the Room tool, and rings are canonicalised so a second run is a genuine no-op — both asserted, because two paths that describe the same walls with different numbers is the failure this phase exists to avoid. Detection **never deletes**: an Area-tool room has no walls by design, so removing what detection cannot see would delete a legitimate room every run; unmatched rooms are reported and left. Ceiling height became editable, which is what makes it worth having — the headroom check reads it through `ceilingHeightAt`. On the stack: `index` is the ordering and nothing reads array position, a floor switch is silent in history but dirty on disk, and moving a placement carries everything standing on it while re-seating what named a wall it left behind. The plan ghost participates in nothing — not the hit graph, not the counts, not `floorBounds` — and the `listening` flag is load-bearing rather than tidy, since PlanStage reads empty canvas by `e.target === stage`. **Collision stays on the active floor whatever the 3D toggle says**, the same two-questions split as §9.3 — and in the space view a solid on another floor declines the click *before* stopping propagation, or the top storey would swallow every click meant for the floor below it. Deferred and stated rather than claimed: room-boundary dragging is dropped for v1 in favour of move-the-walls-and-re-detect (see row 2), floors can only be added at the ends of the stack, and a detected room is a simple ring — an island of walls inside one does not punch a hole in it. | -| **9** | **Polish and portability** — File System Access save-in-place, IndexedDB autosave and recovery, thumbnails, product URL lookup endpoint + confirm dialog, export/import e2e, migration tests. | +| **9** | **Polish and portability** — File System Access save-in-place, IndexedDB autosave and recovery, thumbnails, product URL lookup endpoint + confirm dialog, export/import e2e, migration tests. Two decisions carry the persistence half. `supportsSaveInPlace()` reads `window` **at call time**: headless Chromium has the API, so a module-load snapshot would leave the download path with no end-to-end coverage and make the in-place path undrivable from a stub — the discriminating test is a *count* of picker openings across two saves, since a wiring that re-prompts still writes the right bytes. And autosave keeps assets in their own IndexedDB store: document-only would recover a space with no background, which is the failure `assetMapFor` throws to prevent by another door, while rewriting the raster every tick is absurd — an asset is immutable once stored, so it is written by id once. Recovery has two offers because §5's "newer than the opened file" misses the case that matters after a crash, where there is no opened file; a record is deleted when its document is saved, which is what stops the prompt becoming a nag you dismiss unread. Thumbnails render offscreen from document geometry — the Konva stage is unmounted in 3D and would capture the current pan — and frame placements as well as structure, unlike `floorBounds`. On the lookup half: scraped numbers go through a parser that **refuses a unitless number**, including a `QuantitativeValue` with no `unitCode`; the client decides the endpoint is absent by **content type**, not `response.ok`, because a static host answers an unknown POST with a 200 carrying `index.html`; and the endpoint treats itself as SSRF by construction — resolving before connecting, revalidating every redirect hop, and capping the read as it goes. Four defects found on the way, each with a test watched failing first: the autosave debounce was never re-armed, so the twenty-second deadline could not bind and the mechanism in the comments was not the one running; `parseTargetUrl` waved through a literal private address, leaning on a resolver step an edge runtime skips; the labelled dimension matcher stopped at `6 ft` and dropped the inches, because a flattened table cell reads `Height6 ft 2 in` and has no word boundary for the row matcher; and — oldest and worst — the item form prefilled raw millimetres into fields parsed in the display unit, so opening a 2'7" armchair and pressing Save with no other change made it 67'6" wide. Deferred and stated rather than claimed: the product fixtures are **synthetic** and §13 now says so, DNS rebinding between the check and the connect is open because `fetch` will not pin a socket, and the confidence flag records "was any dimension accepted exactly as scraped" rather than anything about whether the page was right. | Phases 4 and 5 together are the point at which the application does what it exists to do. Everything after is depth. @@ -1005,8 +1044,17 @@ do. Everything after is depth. exercised through the `migrateTo` seam with temporarily registered fakes, plus the three error paths. When a schema 2 arrives, the v1 tests do not change: the correct response to a failure is a migration, not a new fixture. -- **Product parsers** — saved HTML fixtures from real retailer pages checked into the - repo. **No network in CI.** Each fixture asserts extracted dimensions and confidence. +- **Product parsers** — HTML fixtures checked into the repo, one per tier. **No network + in CI.** Each fixture asserts extracted dimensions and confidence. + + These fixtures are **synthetic**, not saved copies of real retailer pages, and that is + a change from what this section originally specified. Fetching those pages to build + fixtures is an outward-facing action against third parties taken for this + repository's convenience, and checking the result in would commit someone else's + markup — stale within a month — into a repository that is not theirs. What is lost is + real: a synthetic fixture cannot surprise the parser the way a live page does, so + these prove the tiers work as designed rather than that they work on a given + retailer today. See `src/core/fixtures/product/README.md`. - **e2e (playwright)** — import PDF → calibrate → trace walls → add item → place → enter 3D → walk → export → reimport → assert identical document. diff --git a/api/product-lookup.ts b/api/product-lookup.ts new file mode 100644 index 0000000..8782f05 --- /dev/null +++ b/api/product-lookup.ts @@ -0,0 +1,42 @@ +/** + * The production deployment of the product-lookup endpoint. See PLAN.md §7.2. + * + * A Web-standard `Request` → `Response` handler, which is what Vercel, Netlify, + * Cloudflare Workers and Deno Deploy all accept in their current runtimes. All the + * behaviour is in `src/server/endpoint.ts`, shared with the Vite dev middleware, so + * the endpoint you develop against and the one you deploy cannot answer differently. + * + * Nothing in the application requires this file to be deployed. With it absent the + * client sees a non-JSON answer or a network error and URL import degrades to a + * message pointing at manual entry — which is the stated contract, not a fallback that + * happens to work. + */ + +import { handleProductLookup } from '../src/server/endpoint'; + +const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8' }; + +export default async function handler(request: Request): Promise { + if (request.method !== 'POST') { + return new Response(JSON.stringify({ message: 'POST a { url } to look up a product.' }), { + status: 405, + headers: JSON_HEADERS, + }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify({ message: 'That request body is not JSON.' }), { + status: 400, + headers: JSON_HEADERS, + }); + } + + const result = await handleProductLookup(body); + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: JSON_HEADERS, + }); +} diff --git a/e2e/inventory.spec.ts b/e2e/inventory.spec.ts index 8984efa..8c41870 100644 --- a/e2e/inventory.spec.ts +++ b/e2e/inventory.spec.ts @@ -111,6 +111,24 @@ test.describe('the catalog', () => { await expect(page.getByTestId('item-list')).toContainText('Reading chair'); }); + test('does not resize an item that was opened and saved unchanged', async ({ page }) => { + // The form holds lengths as text and parses them in the document's display unit, + // where a bare number means inches. Prefilling `String(item.widthMm)` therefore + // re-read a 762mm armchair as 762 inches on save — an edit that only changed the + // name multiplied every dimension by 25.4, and nothing in the flow said so. + await addPreset(page, 'Seating — Armchair'); + const before = await page.getByTestId('item-list').innerText(); + + await page.getByRole('button', { name: 'Edit Armchair' }).click(); + await page.getByTestId('item-form').getByRole('button', { name: 'Save' }).click(); + await expect(page.getByTestId('item-form')).toBeHidden(); + + // innerText on both sides: `toHaveText` normalises the list's line breaks away, + // and comparing a normalised value against a raw one fails on whitespace while + // saying nothing about the dimensions. + expect(await page.getByTestId('item-list').innerText()).toBe(before); + }); + test('removes an item together with everything placed from it', async ({ page }) => { await addPreset(page, 'Seating — Dining chair'); await armFirstItem(page); diff --git a/e2e/product-url.spec.ts b/e2e/product-url.spec.ts new file mode 100644 index 0000000..b7d878b --- /dev/null +++ b/e2e/product-url.spec.ts @@ -0,0 +1,163 @@ +import { expect, test, type Page } from '@playwright/test'; + +/** + * Product URL import — PLAN.md §7.2. + * + * This suite runs against `vite preview`, which serves the production build and has + * **no** `/api/product-lookup`: the dev middleware is registered in `configureServer` + * only, and production deploys the endpoint as a separate serverless function. So the + * degradation case here is a real absence rather than a stub of one, which is the + * point — §7.2 requires the app to stay fully functional as a static build with the + * endpoint gone. + * + * The success path is driven by stubbing `window.fetch`, the same shape as the save + * picker stub, and for the same reason: the client reads `fetch` off `globalThis` at + * call time rather than capturing it at module load. + */ + +const SOFA = { + url: 'https://shop.example.com/p/harlow-sofa', + draft: { + name: 'Harlow Sofa', + widthMm: 2134, + depthMm: 965, + heightMm: 813, + confidence: 'labelled', + rawSnippet: 'Width: 84 in · Depth: 38 in · Height: 32 in', + dimensionSource: 'json-ld', + }, +}; + +async function stubLookup(page: Page, body: unknown, status = 200): Promise { + await page.evaluate( + ({ body: payload, status: code }) => { + window.fetch = () => + Promise.resolve( + new Response(JSON.stringify(payload), { + status: code, + headers: { 'content-type': 'application/json' }, + }), + ); + }, + { body, status }, + ); +} + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('plan-stage')).toBeVisible(); +}); + +test.describe('with no lookup service', () => { + test('says so and points at manual entry', async ({ page }) => { + // No stub: this is the production build talking to a host that has no endpoint. + // A static deploy answers an unknown POST with the SPA shell and a 200, which is + // exactly the case `response.ok` would get wrong. + await page.getByTestId('add-from-url').click(); + await page.getByTestId('lookup-url-input').fill('https://shop.example.com/p/sofa'); + await page.getByTestId('lookup-go').click(); + + const error = page.getByTestId('inventory-error'); + await expect(error).toBeVisible(); + await expect(error).toContainText('by hand'); + // No half-open dialog left behind claiming a product was found. + await expect(page.getByTestId('lookup-confirm')).toBeHidden(); + }); + + test('leaves manual entry working', async ({ page }) => { + // "The app remains fully functional as a static build with the endpoint absent." + await page.getByTestId('add-from-url').click(); + await page.getByTestId('lookup-url-input').fill('https://shop.example.com/p/sofa'); + await page.getByTestId('lookup-go').click(); + await expect(page.getByTestId('inventory-error')).toBeVisible(); + + await page.getByRole('button', { name: 'Cancel' }).click(); + await page.getByTestId('add-item').click(); + await page.getByLabel('Item name').fill('Hand-typed sofa'); + await page.getByLabel('Width').fill('2000mm'); + await page.getByLabel('Depth').fill('900mm'); + await page.getByLabel('Height').fill('800mm'); + await page.getByTestId('item-form').getByRole('button', { name: 'Add', exact: true }).click(); + + await expect(page.getByText('Hand-typed sofa')).toBeVisible(); + }); +}); + +test.describe('confirm before add', () => { + test('shows the page it read and the text the numbers came from', async ({ page }) => { + await stubLookup(page, SOFA); + await page.getByTestId('add-from-url').click(); + await page.getByTestId('lookup-url-input').fill(SOFA.url); + await page.getByTestId('lookup-go').click(); + + await expect(page.getByTestId('lookup-confirm')).toBeVisible(); + await expect(page.getByTestId('lookup-url')).toHaveText(SOFA.url); + // §7.2: a scraped dimension a person cannot check against the page is one they + // have to take on trust, which is what this dialog exists to prevent. + await expect(page.getByTestId('lookup-evidence')).toContainText('84 in'); + + // Every field editable, and pre-filled with what was found — formatted in the + // document's display unit, never as bare millimetres, or the form would read + // "2134" back as 2134 inches. 2134mm is 84", which is 7' 0". + await expect(page.getByLabel('Item name')).toHaveValue('Harlow Sofa'); + await expect(page.getByLabel('Width')).toHaveValue(`7' 0"`); + }); + + test('adds the item, flagged as carrying a number nobody checked', async ({ page }) => { + await stubLookup(page, SOFA); + await page.getByTestId('add-from-url').click(); + await page.getByTestId('lookup-url-input').fill(SOFA.url); + await page.getByTestId('lookup-go').click(); + await page.getByTestId('item-form').getByRole('button', { name: 'Add', exact: true }).click(); + + await expect(page.getByText('Harlow Sofa')).toBeVisible(); + await expect(page.getByTestId('lookup-confirm')).toBeHidden(); + await expect(page.getByText('unverified')).toBeVisible(); + }); + + test('drops the flag once the dimensions have been typed over', async ({ page }) => { + // The flag means "this item carries a measurement nobody checked". A number the + // user corrected has been checked against something. + await stubLookup(page, SOFA); + await page.getByTestId('add-from-url').click(); + await page.getByTestId('lookup-url-input').fill(SOFA.url); + await page.getByTestId('lookup-go').click(); + + await page.getByLabel('Width').fill('2100mm'); + await page.getByLabel('Depth').fill('950mm'); + await page.getByLabel('Height').fill('820mm'); + await page.getByTestId('item-form').getByRole('button', { name: 'Add', exact: true }).click(); + + await expect(page.getByText('Harlow Sofa')).toBeVisible(); + await expect(page.getByText('unverified')).toBeHidden(); + }); + + test('says when a page had no dimensions at all', async ({ page }) => { + // An OpenGraph-only page. Half a draft is still worth having — the form opens + // named, and the measurements are left to the user rather than invented. + await stubLookup(page, { + url: 'https://shop.example.com/p/wren', + draft: { name: 'Wren Armchair' }, + }); + await page.getByTestId('add-from-url').click(); + await page.getByTestId('lookup-url-input').fill('https://shop.example.com/p/wren'); + await page.getByTestId('lookup-go').click(); + + await expect(page.getByTestId('lookup-evidence')).toContainText('did not state any dimensions'); + await expect(page.getByLabel('Item name')).toHaveValue('Wren Armchair'); + await expect(page.getByLabel('Width')).toHaveValue(''); + }); + + test('shows the endpoint refusal, not the absent-endpoint advice', async ({ page }) => { + // Two different things: "your URL is wrong" is the user's to fix, and telling them + // to type it in by hand instead would be answering a question they did not ask. + await stubLookup(page, { message: 'Only https product pages can be looked up.' }, 400); + await page.getByTestId('add-from-url').click(); + await page.getByTestId('lookup-url-input').fill('http://shop.example.com/p/sofa'); + await page.getByTestId('lookup-go').click(); + + const error = page.getByTestId('inventory-error'); + await expect(error).toContainText('Only https'); + await expect(error).not.toContainText('by hand'); + }); +}); diff --git a/package.json b/package.json index 82d4bd3..eacb058 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "fflate": "^0.8.3", "immer": "^10.1.1", "konva": "^10.0.0", + "node-html-parser": "^9.0.2", "pdfjs-dist": "^6.3.289", "polygon-clipping": "^0.15.7", "react": "^19.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc8598f..2fff329 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: konva: specifier: ^10.0.0 version: 10.3.2 + node-html-parser: + specifier: ^9.0.2 + version: 9.0.2 pdfjs-dist: specifier: ^6.3.289 version: 6.3.289 @@ -892,6 +895,9 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} @@ -958,6 +964,13 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -980,12 +993,33 @@ packages: detect-gpu@5.0.70: resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + draco3d@1.5.7: resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==} electron-to-chromium@1.5.417: resolution: {integrity: sha512-4T+DTDWuMPM4aHlHwWdAVCVWwp7LDilnhzkj+c/Lbj91XSQrLuOmZSLtS9Q4iIqjlPUbPOnC624zDVVHCHaolQ==} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -1279,10 +1313,16 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-html-parser@9.0.2: + resolution: {integrity: sha512-XhM0CTeF4Hkw3el7VCyasGJCEvp500Xf0s1J3ZigYfTOkVqZRQRY3EiAw1CbJzNrxaKSDthXiT9c/4CBwIJyfw==} + node-releases@2.0.54: resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2433,6 +2473,8 @@ snapshots: dependencies: require-from-string: 2.0.2 + boolbase@1.0.0: {} + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 @@ -2500,6 +2542,16 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-what@6.2.2: {} + csstype@3.2.3: {} debug@4.4.3: @@ -2514,10 +2566,32 @@ snapshots: dependencies: webgl-constants: 1.1.1 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + draco3d@1.5.7: {} electron-to-chromium@1.5.417: {} + entities@4.5.0: {} + + entities@8.0.0: {} + es-module-lexer@1.7.0: {} esbuild@0.28.2: @@ -2797,8 +2871,17 @@ snapshots: natural-compare@1.4.0: {} + node-html-parser@9.0.2: + dependencies: + css-select: 5.2.2 + entities: 8.0.0 + node-releases@2.0.54: {} + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 diff --git a/src/core/api.ts b/src/core/api.ts new file mode 100644 index 0000000..0b47fe6 --- /dev/null +++ b/src/core/api.ts @@ -0,0 +1,9 @@ +/** + * The one path the client and the server both have to agree on. + * + * Its own module, with no imports, on purpose. The obvious home is + * `server/endpoint.ts`, and importing a constant from there would drag + * `node-html-parser`, the whole parser and a `node:dns` import into the browser + * bundle — for a string. A value shared across the wire belongs to neither side. + */ +export const PRODUCT_LOOKUP_PATH = '/api/product-lookup'; diff --git a/src/core/catalog.ts b/src/core/catalog.ts index 4fbdc01..5489848 100644 --- a/src/core/catalog.ts +++ b/src/core/catalog.ts @@ -15,7 +15,14 @@ * Pure — no DOM, no store. */ -import type { Category, CatalogItem, ClearanceZone, Id, MountKind } from './document'; +import type { + Category, + CatalogItem, + ClearanceZone, + Id, + MountKind, + ProductSource, +} from './document'; import { makeFootprint, type Footprint } from './geometry/footprint'; import type { FootprintGenerator } from './geometry/generators'; import type { ShapeKind } from './tools'; @@ -97,6 +104,8 @@ export type ItemDraft = { color?: string; quantityOwned?: number; notes?: string; + /** Where the numbers came from, when they were not typed. See PLAN.md §7.2. */ + source?: ProductSource; }; /** @@ -199,6 +208,7 @@ export function createCatalogItem(draft: ItemDraft, id: Id): CatalogItem { color: draft.color ?? defaults.color, quantityOwned, ...(draft.notes ? { notes: draft.notes } : {}), + ...(draft.source ? { source: { ...draft.source } } : {}), }; } @@ -212,6 +222,9 @@ export function draftFromItem(item: CatalogItem, shape: ShapeKind = 'rect'): Ite heightMm: item.heightMm, shape: generatorShape(item.footprint.generator) ?? shape, voidBelowMm: item.voidBelowMm, + // Carried back, or editing the height of an imported item would quietly erase the + // record of where its width came from. + ...(item.source ? { source: { ...item.source } } : {}), ...(item.surfaceHeightMm !== undefined ? { surfaceHeightMm: item.surfaceHeightMm } : {}), canHostSurface: item.canHostSurface, defaultMount: item.defaultMount, diff --git a/src/core/dimensions.test.ts b/src/core/dimensions.test.ts new file mode 100644 index 0000000..6958c59 --- /dev/null +++ b/src/core/dimensions.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { parseExplicitLength, readDimensions } from './dimensions'; + +describe('a scraped length needs its own unit', () => { + it('refuses a bare number', () => { + // The trap this module exists for. `parseLength` would read this in the document's + // display unit, so `84` in a metric document becomes an 84mm sofa — plausible, + // silent, and wrong. There is no display unit on a retailer's page to appeal to. + expect(parseExplicitLength('84')).toBeNull(); + expect(parseExplicitLength(' 1800 ')).toBeNull(); + }); + + it('reads inches, however they are written', () => { + expect(parseExplicitLength('84"')).toBe(2134); + expect(parseExplicitLength('84 in')).toBe(2134); + expect(parseExplicitLength('84 inches')).toBe(2134); + expect(parseExplicitLength('84”')).toBe(2134); + }); + + it('reads metric', () => { + expect(parseExplicitLength('213 cm')).toBe(2130); + expect(parseExplicitLength('2.13m')).toBe(2130); + expect(parseExplicitLength('810 mm')).toBe(810); + expect(parseExplicitLength('2,13 m')).toBe(2130); + }); + + it('reads a thousands separator as one', () => { + expect(parseExplicitLength('1,524 mm')).toBe(1524); + }); + + it('reads feet and inches together', () => { + expect(parseExplicitLength('6 ft 2 in')).toBe(1880); + expect(parseExplicitLength(`6' 2"`)).toBe(1880); + // The inch mark is optional, because half the internet leaves it off. + expect(parseExplicitLength(`5'11`)).toBe(1803); + }); + + it('reads the fractions a furniture page actually prints', () => { + expect(parseExplicitLength('38 1/2 in')).toBe(978); + }); + + it('refuses a zero or negative dimension', () => { + expect(parseExplicitLength('0 in')).toBeNull(); + expect(parseExplicitLength('-4 in')).toBeNull(); + }); + + it('refuses a unit it does not know', () => { + expect(parseExplicitLength('84 cubits')).toBeNull(); + }); +}); + +describe('finding three numbers on a page', () => { + it('reads suffixed labels', () => { + const found = readDimensions('84"W x 38"D x 32"H'); + expect(found).toMatchObject({ + widthMm: 2134, + depthMm: 965, + heightMm: 813, + confidence: 'labelled', + }); + }); + + it('reads prefixed labels', () => { + expect(readDimensions('W 84 in x D 38 in x H 32 in')).toMatchObject({ + widthMm: 2134, + depthMm: 965, + heightMm: 813, + confidence: 'labelled', + }); + }); + + it('reads spec-table rows', () => { + const text = 'Width: 84 in\nDepth: 38 in\nHeight: 32 in\nWeight: 90 lb'; + expect(readDimensions(text)).toMatchObject({ + widthMm: 2134, + depthMm: 965, + heightMm: 813, + confidence: 'labelled', + }); + }); + + it('treats length as the depth axis', () => { + // A sofa is 84"W x 38"D and a dining table is 84"L x 38"W. The axis the second + // calls width is the one the first calls depth, and a table entered 84 deep by 38 + // wide is rotated ninety degrees in every room it is placed in. + expect(readDimensions('84"L x 38"W x 30"H')).toMatchObject({ + depthMm: 2134, + widthMm: 965, + heightMm: 762, + }); + }); + + it('guesses W x D x H for a bare triple, and says it guessed', () => { + expect(readDimensions('213 cm x 96 cm x 81 cm')).toMatchObject({ + widthMm: 2130, + depthMm: 960, + heightMm: 810, + confidence: 'ordered', + }); + }); + + it('prefers a label over an order when the page has both', () => { + // `confidence` describes how the answer was reached. Reading the labels and then + // reporting `ordered` because a triple also matched would understate what is known; + // reading the triple and reporting `labelled` would overstate it. + const found = readDimensions('Dimensions: 32"H x 84"W x 38"D'); + expect(found?.confidence).toBe('labelled'); + expect(found?.heightMm).toBe(813); + expect(found?.widthMm).toBe(2134); + }); + + it('takes a partial reading rather than nothing', () => { + // A page that states a height and leaves the rest to a diagram. Two fields + // pre-filled is better than an empty form, and the third is editable anyway. + const found = readDimensions('Height: 32 in. Seat height 18 in.'); + expect(found?.heightMm).toBe(813); + expect(found?.widthMm).toBeUndefined(); + }); + + it('finds nothing in a page with no dimensions', () => { + expect(readDimensions('A very comfortable sofa. Free delivery.')).toBeNull(); + }); + + it('is not fooled by a price or a product code', () => { + expect(readDimensions('$1,299.00 — model 84523')).toBeNull(); + }); + + it('carries the text the numbers came from', () => { + // §7.2: a dimension a person cannot check against the page is one they have to + // take on trust, which is what the confirm dialog exists to avoid. + const found = readDimensions('Overall dimensions: 84"W x 38"D x 32"H. Solid oak frame.'); + expect(found?.snippet).toContain('84'); + expect(found?.snippet.length).toBeLessThanOrEqual(162); + }); +}); diff --git a/src/core/dimensions.ts b/src/core/dimensions.ts new file mode 100644 index 0000000..c4c6235 --- /dev/null +++ b/src/core/dimensions.ts @@ -0,0 +1,274 @@ +/** + * Reading dimensions out of retailer prose. See PLAN.md §7.2. + * + * ## Why this is not `parseLength` + * + * `parseLength` reads a bare number in the **document's display unit**. That is right + * for a field a person is typing into, where the unit is on screen next to the cursor, + * and it is exactly wrong here. A spec table saying `84 x 38 x 32` in a document set to + * millimetres would silently become an 84mm sofa; the same trap, in the other + * direction, made a `1800` typed into a ft-in ceiling field mean 1800 inches. + * + * So **every number here needs its own unit**. A bare number is not a dimension, it is + * a number, and it is refused. The one exception is a run like `84" x 38" x 32"`, where + * the units are stated once per value — and even there each value carries its own. + * + * ## What it is allowed to be wrong about + * + * Nothing scraped becomes geometry unconfirmed (§7.2), so the bar here is "good enough + * to put in a form the user is about to read", not "certain". Where the order of a bare + * triple is unknowable it takes W × D × H, says so through `confidence`, and hands over + * the text it read so the user can check it against the page. + * + * Pure — no DOM, no network. + */ + +/** Millimetres per unit, keyed by every spelling seen in the wild. */ +const UNITS: Record = { + mm: 1, + millimeter: 1, + millimeters: 1, + millimetre: 1, + millimetres: 1, + cm: 10, + centimeter: 10, + centimeters: 10, + centimetre: 10, + centimetres: 10, + m: 1000, + meter: 1000, + meters: 1000, + metre: 1000, + metres: 1000, + in: 25.4, + inch: 25.4, + inches: 25.4, + '"': 25.4, + '”': 25.4, + '″': 25.4, + "'": 304.8, + '’': 304.8, + '′': 304.8, + ft: 304.8, + foot: 304.8, + feet: 304.8, +}; + +const UNIT_PATTERN = + '(?:mm|millimet(?:er|re)s?|cm|centimet(?:er|re)s?|m|met(?:er|re)s?|in(?:ch(?:es)?)?|["”″]|ft|feet|foot|[\'’′])'; + +const NUMBER = String.raw`\d+(?:[.,]\d+)?(?:\s*\d+\/\d+)?`; + +/** + * One length as it appears in prose, a feet-and-inches compound included. + * + * The optional tail is what makes `6 ft 2 in` one value rather than two, and it has to + * be in **every** matcher rather than only the spec-table one. Flattening + * `Height6 ft 2 in` gives `Height6 ft 2 in` with no space, so there + * is no word boundary for the row matcher to find and the labelled matcher is what + * actually reads it — which, without the tail, stopped at `6 ft` and produced a + * bookcase two inches short. + */ +const VALUE = `(?:${NUMBER})\\s*(?:${UNIT_PATTERN})(?:\\s*(?:${NUMBER})\\s*(?:${UNIT_PATTERN}))?`; + +function toNumber(raw: string): number | null { + // "38 1/2" — retailers write mixed fractions and a plain `Number()` gives NaN. + const mixed = /^(\d+)\s+(\d+)\/(\d+)$/.exec(raw.trim()); + if (mixed) { + const whole = Number(mixed[1]); + const num = Number(mixed[2]); + const den = Number(mixed[3]); + if (den === 0) return null; + return whole + num / den; + } + + const fraction = /^(\d+)\/(\d+)$/.exec(raw.trim()); + if (fraction) { + const den = Number(fraction[2]); + return den === 0 ? null : Number(fraction[1]) / den; + } + + // A comma is a thousands separator far more often than a decimal point on the + // English-language pages these fixtures are drawn from, but `2,5 cm` exists. Treat + // it as a decimal only when exactly one or two digits follow. + const normalised = /,\d{1,2}$/.test(raw.trim()) ? raw.replace(',', '.') : raw.replace(/,/g, ''); + const value = Number(normalised); + return Number.isFinite(value) ? value : null; +} + +function unitFactor(raw: string): number | null { + return UNITS[raw.trim().toLowerCase()] ?? null; +} + +/** + * One length, in millimetres, rounded to the integer the document stores. + * + * Returns null for a bare number. That is the whole point — see the header. + */ +export function parseExplicitLength(text: string): number | null { + const trimmed = text.trim(); + if (!trimmed) return null; + + // Feet-and-inches compound: `6 ft 2 in`, `6' 2"`, `5'11`. The trailing inch unit is + // optional because `5'11` is how half the internet writes it. + const compound = new RegExp( + String.raw`^(${NUMBER})\s*(ft|feet|foot|['’′])\s*(?:(${NUMBER})\s*(in|inch|inches|["”″])?)?$`, + 'i', + ).exec(trimmed); + if (compound) { + const feet = toNumber(compound[1] ?? ''); + if (feet === null) return null; + const inches = compound[3] ? toNumber(compound[3]) : 0; + if (inches === null) return null; + return Math.round(feet * 304.8 + inches * 25.4); + } + + const single = new RegExp(String.raw`^(${NUMBER})\s*(${UNIT_PATTERN})$`, 'i').exec(trimmed); + if (!single) return null; + + const value = toNumber(single[1] ?? ''); + const factor = unitFactor(single[2] ?? ''); + if (value === null || factor === null) return null; + if (!(value > 0)) return null; + + return Math.round(value * factor); +} + +export type Triple = { + widthMm?: number; + depthMm?: number; + heightMm?: number; +}; + +/** How much to trust the mapping from numbers to axes. */ +export type DimensionConfidence = 'labelled' | 'ordered'; + +export type DimensionReading = Triple & { + confidence: DimensionConfidence; + /** The text the numbers came from, kept so a person can check it. */ + snippet: string; +}; + +const AXIS_WORDS: Record = { + w: 'widthMm', + width: 'widthMm', + d: 'depthMm', + depth: 'depthMm', + l: 'depthMm', + length: 'depthMm', + h: 'heightMm', + height: 'heightMm', +}; + +function axisOf(word: string): keyof Triple | null { + return AXIS_WORDS[word.trim().toLowerCase()] ?? null; +} + +/** + * `84"W x 38"D x 32"H` and `W 84 in x D 38 in x H 32 in`. + * + * Both orders of label and value, because retailers use both and the label is the only + * thing that makes the reading trustworthy. Depth also answers to *length*: a sofa is + * `84"W x 38"D` and a dining table is `84"L x 38"W`, and the axis the second one calls + * width is the one the first calls depth. + */ +function readLabelled(text: string): Triple | null { + const found: Triple = {}; + const word = '(W|D|L|H|Width|Depth|Length|Height)'; + + const suffixed = new RegExp(`(${VALUE})\\s*${word}\\b`, 'gi'); + const prefixed = new RegExp(`${word}\\s*[:\\-]?\\s*(${VALUE})`, 'gi'); + + for (const m of text.matchAll(suffixed)) { + const axis = axisOf(m[2] ?? ''); + const mm = parseExplicitLength(m[1] ?? ''); + if (axis && mm !== null && found[axis] === undefined) found[axis] = mm; + } + for (const m of text.matchAll(prefixed)) { + const axis = axisOf(m[1] ?? ''); + const mm = parseExplicitLength(m[2] ?? ''); + if (axis && mm !== null && found[axis] === undefined) found[axis] = mm; + } + + return Object.keys(found).length > 0 ? found : null; +} + +/** + * `213 cm x 96 cm x 81 cm`, with nothing saying which is which. + * + * Order is assumed W × D × H, which is the dominant convention and is still a guess — + * `confidence: 'ordered'` is how the caller knows to say so. + */ +function readTriple(text: string): Triple | null { + const value = `(${NUMBER})\\s*(${UNIT_PATTERN})`; + const sep = String.raw`\s*[x×]\s*`; + const m = new RegExp(`${value}${sep}${value}${sep}${value}`, 'i').exec(text); + if (!m) return null; + + const w = parseExplicitLength(`${m[1]}${m[2]}`); + const d = parseExplicitLength(`${m[3]}${m[4]}`); + const h = parseExplicitLength(`${m[5]}${m[6]}`); + if (w === null || d === null || h === null) return null; + + return { widthMm: w, depthMm: d, heightMm: h }; +} + +/** Rows in a spec table: `Width: 84 in`, one per line. */ +function readRows(text: string): Triple | null { + const found: Triple = {}; + const pattern = new RegExp(`\\b(Width|Depth|Length|Height)\\b\\s*[:\\-]?\\s*(${VALUE})`, 'gi'); + + for (const m of text.matchAll(pattern)) { + const axis = axisOf(m[1] ?? ''); + const mm = parseExplicitLength(m[2] ?? ''); + if (axis && mm !== null && found[axis] === undefined) found[axis] = mm; + } + + return Object.keys(found).length > 0 ? found : null; +} + +/** + * Read whatever dimensions a blob of text contains. + * + * Labelled forms are tried first and win outright: a label is evidence, and an + * unlabelled triple in the same paragraph is a guess about the same numbers. Falling + * back to the triple only when nothing was labelled is what keeps `confidence` + * honest — it describes how the answer was reached, not how many fields it filled. + */ +export function readDimensions(text: string): DimensionReading | null { + const flat = text.replace(/\s+/g, ' ').trim(); + if (!flat) return null; + + const rows = readRows(flat); + const labelled = readLabelled(flat); + const merged = rows || labelled ? { ...labelled, ...rows } : null; + if (merged) return { ...merged, confidence: 'labelled', snippet: snippetAround(text, merged) }; + + const triple = readTriple(flat); + if (triple) return { ...triple, confidence: 'ordered', snippet: snippetAround(text, triple) }; + + return null; +} + +/** + * A short piece of the source text, for the confirm dialog to show. + * + * §7.2 requires the raw text the numbers came from to be displayed alongside them. A + * dimension a person cannot check against the page is a dimension they have to take on + * trust, which is the thing the dialog exists to avoid. + */ +function snippetAround(text: string, found: Triple): string { + const first = Object.values(found)[0]; + const flat = text.replace(/\s+/g, ' ').trim(); + if (first === undefined) return flat.slice(0, 160); + + // Anchor on any number, then widen — the exact digits in the source are in whatever + // unit the page used, and are not the millimetres we converted them to. + const at = flat.search(new RegExp(NUMBER)); + if (at < 0) return flat.slice(0, 160); + + const start = Math.max(0, at - 60); + return `${start > 0 ? '…' : ''}${flat.slice(start, start + 160)}${ + flat.length > start + 160 ? '…' : '' + }`; +} diff --git a/src/core/fixtures/product/README.md b/src/core/fixtures/product/README.md new file mode 100644 index 0000000..eacd20d --- /dev/null +++ b/src/core/fixtures/product/README.md @@ -0,0 +1,29 @@ +# Product page fixtures + +**These are synthetic.** They are hand-authored to exercise one parser tier each, not +saved copies of real retailer pages. + +PLAN.md §13 originally asked for "saved HTML fixtures from real retailer pages checked +into the repo". Two reasons that is not what is here: + +1. Fetching those pages to build fixtures is an outward-facing action against third + parties, done for the convenience of this repository rather than at a user's request. +2. Checking the result in would commit someone else's markup — thousands of lines of + minified page furniture — into a repository that is not theirs, where it would also + be stale within a month. + +What is lost is real: a synthetic fixture cannot surprise the parser the way a real page +does, so these prove the tiers work as designed rather than that they work on +`article.com` today. That is a coverage limitation and is stated rather than implied. +The tiers themselves — JSON-LD, microdata, OpenGraph, text — are drawn from the +schema.org vocabulary those retailers publish, so the shapes are real even when the +pages are not. + +| File | Tier it exercises | +|------|-------------------| +| `json-ld.html` | schema.org/Product in `application/ld+json`, with `additionalProperty` rows | +| `json-ld-graph.html` | the same, buried in an `@graph` alongside other node types | +| `microdata.html` | inline `itemprop` attributes, no JSON-LD | +| `opengraph.html` | `og:title` and `og:image` only — name and image, no dimensions | +| `spec-table.html` | no structured data at all; the numbers are in a spec table | +| `messy.html` | a broken JSON-LD block followed by a good one, and a unitless number | diff --git a/src/core/fixtures/product/json-ld-graph.html b/src/core/fixtures/product/json-ld-graph.html new file mode 100644 index 0000000..e098e0e --- /dev/null +++ b/src/core/fixtures/product/json-ld-graph.html @@ -0,0 +1,24 @@ + + + + Dover Dresser + + +

    Dover Dresser

    + diff --git a/src/core/fixtures/product/json-ld.html b/src/core/fixtures/product/json-ld.html new file mode 100644 index 0000000..09b4ee6 --- /dev/null +++ b/src/core/fixtures/product/json-ld.html @@ -0,0 +1,27 @@ + + + + Harlow Sofa | Synthetic Furniture Co + + + + +

    Harlow Sofa

    +

    Overall: 999 cm x 999 cm x 999 cm — deliberately wrong, so a reading that fell + through to the text tier is visible in the test rather than plausible.

    + + diff --git a/src/core/fixtures/product/messy.html b/src/core/fixtures/product/messy.html new file mode 100644 index 0000000..14661f3 --- /dev/null +++ b/src/core/fixtures/product/messy.html @@ -0,0 +1,22 @@ + + + + Marlow Bed + + + + +

    Marlow Bed

    +

    Queen. 60"W x 80"D x 24"H.

    + + diff --git a/src/core/fixtures/product/microdata.html b/src/core/fixtures/product/microdata.html new file mode 100644 index 0000000..edb9b42 --- /dev/null +++ b/src/core/fixtures/product/microdata.html @@ -0,0 +1,14 @@ + + +Ash Dining Table + +

    Ash Dining Table

    + + +
      +
    • Width: 180 cm
    • +
    • Depth: 90 cm
    • +
    • Height: 75 cm
    • +
    + + diff --git a/src/core/fixtures/product/opengraph.html b/src/core/fixtures/product/opengraph.html new file mode 100644 index 0000000..b2984c8 --- /dev/null +++ b/src/core/fixtures/product/opengraph.html @@ -0,0 +1,12 @@ + + + + Some Shop + + + + +

    Wren Armchair

    +

    Beautifully made. Free delivery on orders over $500.

    + + diff --git a/src/core/fixtures/product/spec-table.html b/src/core/fixtures/product/spec-table.html new file mode 100644 index 0000000..6f18b1d --- /dev/null +++ b/src/core/fixtures/product/spec-table.html @@ -0,0 +1,13 @@ + + +Kepler Bookcase + +

    Kepler Bookcase

    + + + + + +
    Width31 1/2 in
    Depth13 in
    Height6 ft 2 in
    Weight68 lb
    + + diff --git a/src/core/product-import.test.ts b/src/core/product-import.test.ts new file mode 100644 index 0000000..563845d --- /dev/null +++ b/src/core/product-import.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { acceptedUnchanged, evidenceFor, itemDraftFrom, sourceFor } from './product-import'; +import type { ItemDraft } from './catalog'; +import type { ProductDraft } from './product'; + +const SCRAPED: ProductDraft = { + name: 'Harlow Sofa', + widthMm: 2134, + depthMm: 965, + heightMm: 813, + confidence: 'labelled', + rawSnippet: 'Width: 84 in · Depth: 38 in · Height: 32 in', + dimensionSource: 'json-ld', +}; + +function submitted(over: Partial = {}): ItemDraft { + return { ...itemDraftFrom(SCRAPED), ...over }; +} + +describe('opening the form', () => { + it('prefills what was found', () => { + expect(itemDraftFrom(SCRAPED)).toMatchObject({ + name: 'Harlow Sofa', + widthMm: 2134, + depthMm: 965, + heightMm: 813, + shape: 'rect', + }); + }); + + it('leaves a dimension the page did not give at zero', () => { + expect(itemDraftFrom({ name: 'Wren Armchair' })).toMatchObject({ + name: 'Wren Armchair', + widthMm: 0, + depthMm: 0, + heightMm: 0, + }); + }); + + it('does not guess a category', () => { + // Category drives `voidBelowMm`, which decides whether a rug under this thing is a + // collision. Too load-bearing to infer from a marketing title, and it is one + // select on a form the user is already reading. + expect(itemDraftFrom({ name: 'Harlow Dining Table' }).category).toBe('other'); + }); + + it('gives a nameless page a name that can be corrected', () => { + // An empty name is refused by `createCatalogItem`. A placeholder is easier to fix + // than an error about a field the user never saw. + expect(itemDraftFrom({}).name).toBe('Imported item'); + }); +}); + +describe('parsed against confirmed', () => { + it('is parsed when the numbers went in as they came off the page', () => { + expect(sourceFor({ url: 'https://x/p', scraped: SCRAPED, submitted: submitted() })).toMatchObject({ + confidence: 'parsed', + }); + }); + + it('is confirmed once every scraped dimension has been changed', () => { + const edited = submitted({ widthMm: 2000, depthMm: 900, heightMm: 800 }); + expect(sourceFor({ url: 'https://x/p', scraped: SCRAPED, submitted: edited })).toMatchObject({ + confidence: 'confirmed', + }); + }); + + it('stays parsed while any one scraped dimension is untouched', () => { + // The flag means "this item carries a measurement nobody checked". One survivor + // is enough for that to be true, and it is the one that makes the sofa not fit. + const edited = submitted({ widthMm: 2000, depthMm: 900 }); + expect(acceptedUnchanged(SCRAPED, edited)).toBe(true); + }); + + it('is confirmed when the page gave no dimensions at all', () => { + // An OpenGraph-only page. Every number was typed by a person, so there is nothing + // unverified to flag. + const scraped: ProductDraft = { name: 'Wren Armchair' }; + const typed: ItemDraft = { ...itemDraftFrom(scraped), widthMm: 800, depthMm: 850, heightMm: 900 }; + expect(sourceFor({ url: 'https://x/p', scraped, submitted: typed })).toMatchObject({ + confidence: 'confirmed', + }); + }); + + it('keeps the URL and the text the numbers came from', () => { + const source = sourceFor({ + url: 'https://shop.example.com/p/harlow', + scraped: SCRAPED, + submitted: submitted(), + now: '2026-09-03T10:00:00.000Z', + }); + expect(source.url).toBe('https://shop.example.com/p/harlow'); + expect(source.retrievedAt).toBe('2026-09-03T10:00:00.000Z'); + expect(source.rawSnippet).toContain('84 in'); + }); + + it('omits the snippet rather than storing an empty one', () => { + const source = sourceFor({ url: 'https://x/p', scraped: { widthMm: 1 }, submitted: submitted() }); + expect('rawSnippet' in source).toBe(false); + }); +}); + +describe('what the dialog says about the numbers', () => { + it('says when the order was assumed', () => { + const evidence = evidenceFor({ ...SCRAPED, confidence: 'ordered' }); + expect(evidence).toContain('usual width × depth × height order'); + }); + + it('says when they were labelled', () => { + expect(evidenceFor(SCRAPED)).toContain('labelled'); + }); + + it('says nothing when there is nothing to show', () => { + expect(evidenceFor({ name: 'Thing' })).toBeNull(); + }); +}); diff --git a/src/core/product-import.ts b/src/core/product-import.ts new file mode 100644 index 0000000..0669a74 --- /dev/null +++ b/src/core/product-import.ts @@ -0,0 +1,79 @@ +/** + * Turning a scraped product into something the item form can open. See PLAN.md §7.2. + * + * The bridge between `core/product.ts` (which reads a page) and `core/catalog.ts` + * (which builds an item). Deliberately imports `ProductDraft` **as a type only**: the + * parser pulls in `node-html-parser`, and a value import here would drag the whole + * server-side parser into the browser bundle for the sake of a shape. + * + * ## What `parsed` versus `confirmed` actually means + * + * §7.2 stores confidence on the item and flags parsed-but-unconfirmed items in the + * inventory list. Every item added through this path has passed a confirm dialog, so + * "the user saw it" cannot be the distinction — it would make `confirmed` universal + * and the flag meaningless. + * + * The distinction that is worth recording is narrower and more useful: **was any + * dimension accepted exactly as scraped?** A number a person typed or corrected has + * been checked against something. A number they left alone has not — they may have + * read it, or they may have pressed Add. `parsed` marks the item as carrying at least + * one measurement nobody verified, which is exactly the item you want flagged when a + * sofa turns out not to fit. + */ + +import type { ItemDraft } from './catalog'; +import type { ProductSource } from './document'; +import type { ProductDraft } from './product'; + +/** The item form's starting point. Missing dimensions stay at zero for the user. */ +export function itemDraftFrom(product: ProductDraft): ItemDraft { + return { + // A page with no name at all still gives a form worth opening; an empty name is + // refused by `createCatalogItem`, and a placeholder is easier to correct than an + // error message about a field the user never filled in. + name: product.name?.trim() || 'Imported item', + // Not guessed from the name. A category drives `voidBelowMm`, which decides + // whether a rug under this thing reads as a collision — too load-bearing to infer + // from a marketing title, and one select the user is already looking at. + category: 'other', + widthMm: product.widthMm ?? 0, + depthMm: product.depthMm ?? 0, + heightMm: product.heightMm ?? 0, + shape: 'rect', + }; +} + +const AXES = ['widthMm', 'depthMm', 'heightMm'] as const; + +/** Whether any dimension went in exactly as it came off the page. */ +export function acceptedUnchanged(scraped: ProductDraft, submitted: ItemDraft): boolean { + return AXES.some((axis) => scraped[axis] !== undefined && scraped[axis] === submitted[axis]); +} + +export function sourceFor(params: { + url: string; + scraped: ProductDraft; + submitted: ItemDraft; + now?: string; +}): ProductSource { + const { url, scraped, submitted } = params; + + return { + url, + retrievedAt: params.now ?? new Date().toISOString(), + confidence: acceptedUnchanged(scraped, submitted) ? 'parsed' : 'confirmed', + ...(scraped.rawSnippet ? { rawSnippet: scraped.rawSnippet } : {}), + }; +} + +/** The sentence shown beside the form, so the numbers can be checked against the page. */ +export function evidenceFor(scraped: ProductDraft): string | null { + if (!scraped.rawSnippet) return null; + + const how = + scraped.confidence === 'ordered' + ? 'read in the usual width × depth × height order, which the page did not state' + : 'read from labelled dimensions'; + + return `${how}: ${scraped.rawSnippet}`; +} diff --git a/src/core/product.test.ts b/src/core/product.test.ts new file mode 100644 index 0000000..22345de --- /dev/null +++ b/src/core/product.test.ts @@ -0,0 +1,154 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { parseProduct, resolveUrl } from './product'; + +/** + * Parser fixtures. **Synthetic** — see `fixtures/product/README.md` for why, and for + * what that costs. Each one exercises a single tier, and several are built so that a + * reading which fell through to a *lower* tier is visible as a wrong number rather + * than as a plausible one. + */ + +const DIR = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'product'); +const PAGE = 'https://shop.example.com/p/thing'; + +function fixture(name: string): string { + return readFileSync(join(DIR, name), 'utf8'); +} + +describe('tier 1 — JSON-LD', () => { + it('reads name, price, image and dimensions', () => { + const draft = parseProduct(fixture('json-ld.html'), PAGE); + + expect(draft.name).toBe('Harlow Sofa'); + expect(draft.price).toBe('1299.00'); + expect(draft.imageUrl).toBe('https://shop.example.com/img/harlow-1.jpg'); + expect(draft.widthMm).toBe(2134); + expect(draft.depthMm).toBe(965); + expect(draft.heightMm).toBe(813); + expect(draft.dimensionSource).toBe('json-ld'); + expect(draft.confidence).toBe('labelled'); + }); + + it('does not fall through to the page text when the structured data is good', () => { + // The fixture's body says 999cm on every axis. Falling through would be a + // perfectly plausible-looking sofa, which is the failure mode worth catching. + const draft = parseProduct(fixture('json-ld.html'), PAGE); + expect(draft.widthMm).not.toBe(9990); + }); + + it('prefers the structured name over the OpenGraph one', () => { + // Both are present in that fixture, and og:title carries a "(og)" marker. + expect(parseProduct(fixture('json-ld.html'), PAGE).name).toBe('Harlow Sofa'); + }); + + it('finds a Product buried in an @graph beside other node types', () => { + const draft = parseProduct(fixture('json-ld-graph.html'), PAGE); + expect(draft.name).toBe('Dover Dresser'); + expect(draft.widthMm).toBe(1200); + expect(draft.depthMm).toBe(450); + expect(draft.heightMm).toBe(810); + }); + + it('reads unitCode and unitText alike', () => { + // `CMT`, `MMT` and a plain `"cm"` all appear in that one fixture. + const draft = parseProduct(fixture('json-ld-graph.html'), PAGE); + expect(draft.widthMm).toBe(1200); + expect(draft.price).toBe('899'); + }); + + it('recovers from a broken JSON-LD block and reads the next one', () => { + // A page with one malformed block and one good one is common enough that giving + // up on the first parse error would cost real coverage. + expect(parseProduct(fixture('messy.html'), PAGE).name).toBe('Marlow Bed'); + }); + + it('refuses a structured number with no unit, and falls through for that axis', () => { + // `{ value: 1524 }` with no unitCode. A structured field is not more trustworthy + // than a paragraph when the thing that makes a number a length is missing from + // both — so the width comes from the text tier instead, at 60". + const draft = parseProduct(fixture('messy.html'), PAGE); + expect(draft.heightMm).toBe(610); + expect(draft.dimensionSource).toBe('json-ld'); + expect(draft.widthMm).toBeUndefined(); + }); +}); + +describe('tier 2 — microdata', () => { + it('reads a page with itemprop and no JSON-LD', () => { + const draft = parseProduct(fixture('microdata.html'), PAGE); + + expect(draft.name).toBe('Ash Dining Table'); + expect(draft.imageUrl).toBe('https://cdn.example.com/ash.jpg'); + expect(draft.widthMm).toBe(1800); + expect(draft.depthMm).toBe(900); + expect(draft.heightMm).toBe(750); + expect(draft.dimensionSource).toBe('microdata'); + }); + + it('takes the machine value from a meta itemprop, not the formatted text', () => { + expect(parseProduct(fixture('microdata.html'), PAGE).price).toBe('749.00'); + }); +}); + +describe('tier 3 — OpenGraph', () => { + it('gets a name and an image and admits it has no dimensions', () => { + // A social-sharing card is not a spec sheet. Half a draft is still worth having: + // the form opens named, with the measurements left to the user. + const draft = parseProduct(fixture('opengraph.html'), PAGE); + + expect(draft.name).toBe('Wren Armchair'); + expect(draft.imageUrl).toBe('https://shop.example.com/media/wren.png'); + expect(draft.widthMm).toBeUndefined(); + expect(draft.dimensionSource).toBeUndefined(); + expect(draft.confidence).toBeUndefined(); + }); +}); + +describe('tier 4 — the page text', () => { + it('reads a spec table on a page with no structured data at all', () => { + const draft = parseProduct(fixture('spec-table.html'), PAGE); + + expect(draft.name).toBe('Kepler Bookcase'); + expect(draft.widthMm).toBe(800); // 31 1/2" + expect(draft.depthMm).toBe(330); + expect(draft.heightMm).toBe(1880); // 6' 2" + expect(draft.dimensionSource).toBe('text'); + }); + + it('keeps the text the numbers came from', () => { + // §7.2: a scraped dimension a person cannot check against the page is one they + // have to take on trust, and the dialog exists precisely to avoid that. + const draft = parseProduct(fixture('spec-table.html'), PAGE); + expect(draft.rawSnippet).toBeTruthy(); + expect(draft.rawSnippet).toContain('31'); + }); + + it('does not read the JSON-LD block as page text', () => { + // Script content is stripped before the text pass. Without that, a page whose + // structured data failed to parse would have its raw JSON scanned for numbers. + const html = ` +

    Thing

    No dimensions here.

    `; + expect(parseProduct(html, PAGE).widthMm).toBeUndefined(); + }); +}); + +describe('anything else', () => { + it('falls back to the document title for a name', () => { + const draft = parseProduct('Plain Page', PAGE); + expect(draft.name).toBe('Plain Page'); + }); + + it('returns an empty draft rather than throwing on rubbish', () => { + expect(parseProduct('not html at all', PAGE)).toEqual({}); + }); + + it('makes a relative image absolute against the page it came from', () => { + expect(resolveUrl('/img/a.jpg', PAGE)).toBe('https://shop.example.com/img/a.jpg'); + expect(resolveUrl('../b.jpg', PAGE)).toBe('https://shop.example.com/b.jpg'); + expect(resolveUrl(undefined, PAGE)).toBeUndefined(); + expect(resolveUrl('http://[bad', PAGE)).toBeUndefined(); + }); +}); diff --git a/src/core/product.ts b/src/core/product.ts new file mode 100644 index 0000000..2af51f7 --- /dev/null +++ b/src/core/product.ts @@ -0,0 +1,354 @@ +/** + * Reading a product out of a retailer's page. See PLAN.md §7.2. + * + * Four tiers, tried in order of how much the page is actually telling us: + * + * 1. `application/ld+json` — schema.org/Product. Structured, intended for machines, + * and published cleanly by most furniture retailers. + * 2. microdata `itemprop` attributes — the same vocabulary, inline. + * 3. OpenGraph — name and image only. It is a social-sharing card, not a spec sheet. + * 4. regex over the page text — the fallback, and the least trustworthy. + * + * Name, image and price are taken from the first tier that has them. **Dimensions are + * tracked separately**, because they are the only part that becomes geometry: a page + * can publish a clean JSON-LD name and leave the measurements to a paragraph, and + * reporting `json-ld` for the whole reading would overstate where the numbers came + * from. `dimensionSource` says which tier the numbers are from, and `confidence` says + * whether they were labelled or merely in the usual order. + * + * ## Nothing here decides anything + * + * Every result lands in a confirm-before-add dialog with every field editable, the + * source URL shown, and the text the numbers came from displayed alongside (§7.2). So + * this module is allowed to guess, provided it says that it guessed. What it is not + * allowed to do is produce a number with no unit behind it — see `dimensions.ts`. + * + * Pure: HTML in, a draft out. No network. `server/lookup.ts` does the fetching. + */ + +import { parse, type HTMLElement } from 'node-html-parser'; +import { parseExplicitLength, readDimensions, type DimensionConfidence } from './dimensions'; + +export type ProductTier = 'json-ld' | 'microdata' | 'opengraph' | 'text'; + +export type ProductDraft = { + name?: string; + widthMm?: number; + depthMm?: number; + heightMm?: number; + imageUrl?: string; + price?: string; + /** Which tier the dimensions came from, or undefined when none were found. */ + dimensionSource?: ProductTier; + confidence?: DimensionConfidence; + /** The text the numbers were read from — keeps a scraped dimension auditable. */ + rawSnippet?: string; +}; + +type Dimensions = { + widthMm?: number; + depthMm?: number; + heightMm?: number; + confidence: DimensionConfidence; + rawSnippet?: string; +}; + +// --------------------------------------------------------------------------- +// Units as schema.org spells them +// --------------------------------------------------------------------------- + +/** UN/CEFACT codes, which is what `unitCode` carries when a page bothers with it. */ +const UNIT_CODES: Record = { + INH: 'in', + FOT: 'ft', + CMT: 'cm', + MMT: 'mm', + MTR: 'm', +}; + +/** + * A schema.org `QuantitativeValue`, or a plain string, into millimetres. + * + * `{ value: 84, unitCode: 'INH' }` and `"84 inches"` are both common, and so is + * `{ value: 84 }` with no unit at all — which is refused, like every other unitless + * number. A structured field is not more trustworthy than a paragraph when the one + * thing that makes a number a length is missing from both. + */ +function quantityToMm(raw: unknown): number | null { + if (typeof raw === 'string') return parseExplicitLength(raw); + if (typeof raw !== 'object' || raw === null) return null; + + const q = raw as Record; + const value = q['value']; + if (typeof value !== 'number' && typeof value !== 'string') return null; + + const code = typeof q['unitCode'] === 'string' ? UNIT_CODES[q['unitCode']] : undefined; + const text = typeof q['unitText'] === 'string' ? q['unitText'] : undefined; + const unit = code ?? text; + if (!unit) return null; + + return parseExplicitLength(`${value} ${unit}`); +} + +// --------------------------------------------------------------------------- +// Tier 1 — JSON-LD +// --------------------------------------------------------------------------- + +function isProduct(node: unknown): node is Record { + if (typeof node !== 'object' || node === null) return false; + const type = (node as Record)['@type']; + const types = Array.isArray(type) ? type : [type]; + return types.some((t) => typeof t === 'string' && t.toLowerCase() === 'product'); +} + +/** Every Product node anywhere in a JSON-LD blob, `@graph` and arrays included. */ +function findProducts(node: unknown, found: Record[] = []): Record[] { + if (Array.isArray(node)) { + for (const child of node) findProducts(child, found); + return found; + } + if (typeof node !== 'object' || node === null) return found; + + if (isProduct(node)) found.push(node as Record); + for (const value of Object.values(node as Record)) { + if (typeof value === 'object' && value !== null) findProducts(value, found); + } + return found; +} + +function firstString(raw: unknown): string | undefined { + if (typeof raw === 'string') return raw; + if (Array.isArray(raw)) { + for (const item of raw) { + const found = firstString(item); + if (found) return found; + } + return undefined; + } + if (typeof raw === 'object' && raw !== null) { + const url = (raw as Record)['url']; + return typeof url === 'string' ? url : undefined; + } + return undefined; +} + +const AXIS_BY_PROPERTY: Record = { + width: 'widthMm', + depth: 'depthMm', + length: 'depthMm', + height: 'heightMm', +}; + +function jsonLdDimensions(product: Record): Dimensions | null { + const found: Dimensions = { confidence: 'labelled' }; + const parts: string[] = []; + + for (const [property, axis] of Object.entries(AXIS_BY_PROPERTY)) { + const mm = quantityToMm(product[property]); + if (mm !== null && found[axis] === undefined) { + found[axis] = mm; + parts.push(`${property}: ${JSON.stringify(product[property])}`); + } + } + + // `additionalProperty: [{ name: 'Width', value: '84 inches' }]` — where most + // retailers actually put the numbers, because schema.org's own width/depth/height + // are singular and a sofa has three. + const extras = product['additionalProperty']; + for (const extra of Array.isArray(extras) ? extras : [extras]) { + if (typeof extra !== 'object' || extra === null) continue; + const row = extra as Record; + const name = typeof row['name'] === 'string' ? row['name'].trim().toLowerCase() : ''; + const axis = AXIS_BY_PROPERTY[name]; + if (!axis || found[axis] !== undefined) continue; + + const mm = + quantityToMm(row['value']) ?? + (typeof row['value'] === 'string' ? parseExplicitLength(row['value']) : null); + if (mm !== null) { + found[axis] = mm; + parts.push(`${row['name']}: ${String(row['value'])}`); + } + } + + if (found.widthMm === undefined && found.depthMm === undefined && found.heightMm === undefined) { + return null; + } + return { ...found, rawSnippet: parts.join(' · ') }; +} + +// --------------------------------------------------------------------------- +// Tier 2 — microdata +// --------------------------------------------------------------------------- + +function itemprop(root: HTMLElement, name: string): HTMLElement | null { + return root.querySelector(`[itemprop="${name}"]`); +} + +/** + * The value of a microdata property. + * + * `content` first: `` carries the machine + * value, and the visible text next to it is formatted for a human ("$1,299"). + */ +function propValue(el: HTMLElement | null): string | undefined { + if (!el) return undefined; + const content = el.getAttribute('content'); + if (content?.trim()) return content.trim(); + const src = el.getAttribute('src') ?? el.getAttribute('href'); + if (src?.trim()) return src.trim(); + const text = el.text.trim(); + return text || undefined; +} + +function microdataDimensions(root: HTMLElement): Dimensions | null { + const found: Dimensions = { confidence: 'labelled' }; + const parts: string[] = []; + + for (const [property, axis] of Object.entries(AXIS_BY_PROPERTY)) { + const el = itemprop(root, property); + const raw = propValue(el); + if (!raw) continue; + + const unit = el?.getAttribute('data-unit') ?? el?.getAttribute('unitText') ?? ''; + const mm = parseExplicitLength(unit ? `${raw} ${unit}` : raw); + if (mm !== null && found[axis] === undefined) { + found[axis] = mm; + parts.push(`${property}: ${raw}${unit ? ` ${unit}` : ''}`); + } + } + + if (found.widthMm === undefined && found.depthMm === undefined && found.heightMm === undefined) { + return null; + } + return { ...found, rawSnippet: parts.join(' · ') }; +} + +// --------------------------------------------------------------------------- +// Assembly +// --------------------------------------------------------------------------- + +function meta(root: HTMLElement, property: string): string | undefined { + const el = + root.querySelector(`meta[property="${property}"]`) ?? + root.querySelector(`meta[name="${property}"]`); + return el?.getAttribute('content')?.trim() || undefined; +} + +/** Absolute, so an image the page names relatively is still fetchable elsewhere. */ +export function resolveUrl(raw: string | undefined, base: string): string | undefined { + if (!raw) return undefined; + try { + return new URL(raw, base).toString(); + } catch { + return undefined; + } +} + +/** Page text with script and style content removed, for the tier-4 fallback. */ +function visibleText(root: HTMLElement): string { + for (const el of root.querySelectorAll('script, style, noscript')) el.remove(); + return root.text.replace(/\s+/g, ' ').trim(); +} + +/** + * Assign only when there is something to assign. + * + * `exactOptionalPropertyTypes` draws the distinction this whole file depends on: + * "absent" and "present but undefined" are not the same, and a tier that found + * nothing must leave the field for the next tier rather than filling it with + * undefined and stopping the chain. + */ +function set( + draft: ProductDraft, + key: K, + value: ProductDraft[K] | undefined, +): void { + if (value !== undefined) draft[key] = value; +} + +export function parseProduct(html: string, pageUrl: string): ProductDraft { + const root = parse(html); + + // --- tier 1 ------------------------------------------------------------- + let product: Record | undefined; + for (const script of root.querySelectorAll('script[type="application/ld+json"]')) { + try { + const found = findProducts(JSON.parse(script.text)); + if (found.length > 0) { + product = found[0]; + break; + } + } catch { + // A page with one broken JSON-LD block and one good one is common enough that + // giving up on the first parse error would cost real coverage. + } + } + + const draft: ProductDraft = {}; + let dimensions: Dimensions | null = null; + let dimensionSource: ProductTier | undefined; + + if (product) { + const name = firstString(product['name']); + if (name) draft.name = name; + const image = resolveUrl(firstString(product['image']), pageUrl); + if (image) draft.imageUrl = image; + + const offers = product['offers']; + const offer = Array.isArray(offers) ? offers[0] : offers; + if (typeof offer === 'object' && offer !== null) { + const price = (offer as Record)['price']; + if (typeof price === 'string' || typeof price === 'number') draft.price = String(price); + } + + dimensions = jsonLdDimensions(product); + if (dimensions) dimensionSource = 'json-ld'; + } + + // --- tier 2 ------------------------------------------------------------- + if (!draft.name) set(draft, 'name', propValue(itemprop(root, 'name'))); + if (!draft.imageUrl) set(draft, 'imageUrl', resolveUrl(propValue(itemprop(root, 'image')), pageUrl)); + if (!draft.price) set(draft, 'price', propValue(itemprop(root, 'price'))); + + if (!dimensions) { + dimensions = microdataDimensions(root); + if (dimensions) dimensionSource = 'microdata'; + } + + // --- tier 3 ------------------------------------------------------------- + if (!draft.name) set(draft, 'name', meta(root, 'og:title')); + if (!draft.imageUrl) set(draft, 'imageUrl', resolveUrl(meta(root, 'og:image'), pageUrl)); + + // --- tier 4 ------------------------------------------------------------- + // `visibleText` strips script tags, so it must run after the JSON-LD pass above. + if (!dimensions) { + const reading = readDimensions(visibleText(root)); + if (reading) { + dimensions = { + ...(reading.widthMm !== undefined ? { widthMm: reading.widthMm } : {}), + ...(reading.depthMm !== undefined ? { depthMm: reading.depthMm } : {}), + ...(reading.heightMm !== undefined ? { heightMm: reading.heightMm } : {}), + confidence: reading.confidence, + rawSnippet: reading.snippet, + }; + dimensionSource = 'text'; + } + } + + if (!draft.name) { + const title = root.querySelector('title')?.text.trim(); + if (title) draft.name = title; + } + + if (dimensions) { + if (dimensions.widthMm !== undefined) draft.widthMm = dimensions.widthMm; + if (dimensions.depthMm !== undefined) draft.depthMm = dimensions.depthMm; + if (dimensions.heightMm !== undefined) draft.heightMm = dimensions.heightMm; + draft.confidence = dimensions.confidence; + if (dimensions.rawSnippet) draft.rawSnippet = dimensions.rawSnippet; + if (dimensionSource) draft.dimensionSource = dimensionSource; + } + + return draft; +} diff --git a/src/server/endpoint.ts b/src/server/endpoint.ts new file mode 100644 index 0000000..0a29608 --- /dev/null +++ b/src/server/endpoint.ts @@ -0,0 +1,34 @@ +/** + * The endpoint, independent of what is hosting it. + * + * Vite dev middleware in development and one serverless function in production call + * the same function with the same parsed body, so the two deployments cannot drift + * into answering differently — which would be invisible until production, since the + * dev server is the only one anybody runs while building the feature. + */ + +import { lookupProduct } from './lookup'; + +export { PRODUCT_LOOKUP_PATH } from '../core/api'; + +export type EndpointResponse = { + status: number; + body: Record; +}; + +/** + * `POST { url }` → a product draft, or a message saying why not. + * + * Every failure is a JSON body with a `message` the UI can show verbatim. An endpoint + * that answers a bad URL with an HTML error page would reach the client as "the + * endpoint is absent", which is a different thing and would send the user to manual + * entry without telling them their URL was the problem. + */ +export async function handleProductLookup(body: unknown): Promise { + const url = typeof body === 'object' && body !== null ? (body as Record)['url'] : undefined; + + const result = await lookupProduct(url); + if (!result.ok) return { status: result.status, body: { message: result.message } }; + + return { status: 200, body: { url: result.url, draft: result.draft } }; +} diff --git a/src/server/lookup.test.ts b/src/server/lookup.test.ts new file mode 100644 index 0000000..9ebdfcc --- /dev/null +++ b/src/server/lookup.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from 'vitest'; +import { + BlockedUrlError, + MAX_RESPONSE_BYTES, + USER_AGENT, + isPrivateAddress, + lookupProduct, + parseTargetUrl, + type LookupDeps, +} from './lookup'; + +/** + * The guards on an endpoint that fetches a URL a stranger supplied. + * + * Nothing here touches a network — DNS included, which is the point: the resolver is + * injected precisely so the guard that matters most is the one a test can drive. A + * hostname resolving to `169.254.169.254` is not something you can arrange with a real + * lookup, and it is the attack this endpoint exists inside of. + */ + +const PAGE = 'ThingWidth: 84 in'; + +function html(body = PAGE, status = 200, headers: Record = {}): Response { + return new Response(body, { + status, + headers: { 'content-type': 'text/html; charset=utf-8', ...headers }, + }); +} + +/** Everything public, nothing fetched unless the test says so. */ +function deps(over: Partial = {}): LookupDeps { + return { + resolve: () => Promise.resolve(['93.184.216.34']), + fetch: () => Promise.resolve(html()), + ...over, + }; +} + +describe('which addresses are private', () => { + it('knows the ranges that matter', () => { + for (const address of [ + '127.0.0.1', + '10.0.0.1', + '172.16.5.4', + '172.31.255.255', + '192.168.1.1', + '169.254.169.254', // the cloud metadata endpoint, and the reason this list exists + '100.64.0.1', + '0.0.0.0', + '::1', + 'fe80::1', + 'fd00::1', + '::ffff:127.0.0.1', // an IPv4 loopback wearing an IPv6 hat + ]) { + expect(isPrivateAddress(address), address).toBe(true); + } + }); + + it('lets a public address through', () => { + for (const address of ['93.184.216.34', '8.8.8.8', '172.32.0.1', '2606:2800:220::1']) { + expect(isPrivateAddress(address), address).toBe(false); + } + }); +}); + +describe('which URLs are even considered', () => { + it('refuses anything that is not https', () => { + expect(() => parseTargetUrl('http://shop.example.com/p')).toThrow(BlockedUrlError); + expect(() => parseTargetUrl('file:///etc/passwd')).toThrow(BlockedUrlError); + expect(() => parseTargetUrl('gopher://shop.example.com')).toThrow(BlockedUrlError); + }); + + it('refuses a URL carrying credentials', () => { + // A way of smuggling something past a log or a naive host check. + expect(() => parseTargetUrl('https://user:pass@shop.example.com/p')).toThrow(BlockedUrlError); + }); + + it('refuses names that mean "this machine"', () => { + for (const url of [ + 'https://localhost/p', + 'https://api.localhost/p', + 'https://printer.local/p', + 'https://vault.internal/p', + ]) { + expect(() => parseTargetUrl(url), url).toThrow(BlockedUrlError); + } + }); + + it('refuses a literal private address', () => { + expect(() => parseTargetUrl('https://169.254.169.254/latest/meta-data/')).toThrow( + BlockedUrlError, + ); + }); + + it('refuses nothing at all', () => { + expect(() => parseTargetUrl('')).toThrow(BlockedUrlError); + expect(() => parseTargetUrl(undefined)).toThrow(BlockedUrlError); + expect(() => parseTargetUrl('not a url')).toThrow(BlockedUrlError); + }); + + it('allows an ordinary product page', () => { + expect(parseTargetUrl('https://shop.example.com/p/sofa').hostname).toBe('shop.example.com'); + }); +}); + +describe('resolving before connecting', () => { + it('refuses a public name that points at a private address', () => { + // The guard the literal check cannot make. `evil.example.com` is free to publish + // an A record of 169.254.169.254, and a URL check alone would wave it through. + return expect( + lookupProduct('https://evil.example.com/p', deps({ resolve: () => Promise.resolve(['169.254.169.254']) })), + ).resolves.toMatchObject({ ok: false, status: 400 }); + }); + + it('refuses when any one of several addresses is private', async () => { + const result = await lookupProduct( + 'https://mixed.example.com/p', + deps({ resolve: () => Promise.resolve(['93.184.216.34', '10.0.0.5']) }), + ); + expect(result.ok).toBe(false); + }); + + it('carries on where there is no resolver at all', async () => { + // An edge runtime with no `node:dns`. The literal checks still apply; refusing + // every lookup would take the feature out on a runtime where it otherwise works. + const result = await lookupProduct('https://shop.example.com/p', deps({ resolve: null })); + expect(result.ok).toBe(true); + }); +}); + +describe('redirects', () => { + it('revalidates every hop, not just the URL that was typed', async () => { + // The standard way past a check that only looks at the first URL: answer the + // request with a 302 to somewhere internal. + let hop = 0; + const result = await lookupProduct( + 'https://shop.example.com/p', + deps({ + fetch: () => { + hop++; + return Promise.resolve( + new Response(null, { status: 302, headers: { location: 'https://169.254.169.254/' } }), + ); + }, + }), + ); + + expect(result).toMatchObject({ ok: false }); + expect(hop).toBe(1); + }); + + it('follows an ordinary redirect and reads the page it lands on', async () => { + let hop = 0; + const result = await lookupProduct( + 'https://shop.example.com/p', + deps({ + fetch: (url) => { + hop++; + if (url === 'https://shop.example.com/p') { + return Promise.resolve( + new Response(null, { + status: 301, + headers: { location: 'https://shop.example.com/p/sofa' }, + }), + ); + } + return Promise.resolve(html()); + }, + }), + ); + + expect(result.ok).toBe(true); + expect(result.ok && result.url).toBe('https://shop.example.com/p/sofa'); + expect(hop).toBe(2); + }); + + it('gives up on a redirect loop', async () => { + const result = await lookupProduct( + 'https://shop.example.com/a', + deps({ + fetch: (url) => + Promise.resolve( + new Response(null, { + status: 302, + headers: { location: url.endsWith('/a') ? '/b' : '/a' }, + }), + ), + }), + ); + expect(result).toMatchObject({ ok: false, status: 502 }); + }); + + it('does not follow a redirect on its own', async () => { + // `redirect: 'manual'` is what makes the revalidation above possible at all — with + // 'follow', fetch lands on the private address before this code ever sees it. + let init: RequestInit | undefined; + await lookupProduct( + 'https://shop.example.com/p', + deps({ + fetch: (_url, i) => { + init = i; + return Promise.resolve(html()); + }, + }), + ); + expect(init?.redirect).toBe('manual'); + }); +}); + +describe('what comes back', () => { + it('identifies itself', async () => { + let init: RequestInit | undefined; + await lookupProduct( + 'https://shop.example.com/p', + deps({ + fetch: (_url, i) => { + init = i; + return Promise.resolve(html()); + }, + }), + ); + expect((init?.headers as Record)['user-agent']).toBe(USER_AGENT); + }); + + it('parses the page it fetched', async () => { + const result = await lookupProduct('https://shop.example.com/p', deps()); + expect(result.ok && result.draft.name).toBe('Thing'); + expect(result.ok && result.draft.widthMm).toBe(2134); + }); + + it('refuses something that is not a web page', async () => { + // A 40MB PDF, or a tarball. Parsing it as HTML is pointless and reading it is not. + const result = await lookupProduct( + 'https://shop.example.com/p', + deps({ fetch: () => Promise.resolve(new Response('%PDF', { headers: { 'content-type': 'application/pdf' } })) }), + ); + expect(result).toMatchObject({ ok: false, status: 415 }); + }); + + it('reports an error status as one, with the number in it', async () => { + const result = await lookupProduct( + 'https://shop.example.com/p', + deps({ fetch: () => Promise.resolve(html('nope', 404)) }), + ); + expect(result.ok).toBe(false); + expect(!result.ok && result.message).toContain('404'); + }); + + it('stops reading an enormous page instead of buffering all of it', async () => { + // The cap has to be enforced while reading. Checking `content-length` afterwards + // is advice a hostile server is free to ignore. + const chunk = new TextEncoder().encode('x'.repeat(100_000)); + let sent = 0; + + const body = new ReadableStream({ + pull(controller) { + sent += chunk.byteLength; + // Far past the cap. If nothing stops, this test does not finish. + if (sent > MAX_RESPONSE_BYTES * 20) controller.close(); + else controller.enqueue(chunk); + }, + }); + + const result = await lookupProduct( + 'https://shop.example.com/p', + deps({ + fetch: () => + Promise.resolve(new Response(body, { headers: { 'content-type': 'text/html' } })), + }), + ); + + expect(result.ok).toBe(true); + expect(sent).toBeLessThan(MAX_RESPONSE_BYTES * 2); + }); + + it('says a page took too long rather than hanging', async () => { + const abort = Object.assign(new Error('aborted'), { name: 'AbortError' }); + const result = await lookupProduct( + 'https://shop.example.com/p', + deps({ fetch: () => Promise.reject(abort) }), + ); + expect(result).toMatchObject({ ok: false, status: 504 }); + }); + + it('says it could not reach a page rather than throwing', async () => { + const result = await lookupProduct( + 'https://shop.example.com/p', + deps({ fetch: () => Promise.reject(new Error('ECONNREFUSED')) }), + ); + expect(result).toMatchObject({ ok: false, status: 502 }); + }); +}); diff --git a/src/server/lookup.ts b/src/server/lookup.ts new file mode 100644 index 0000000..6ff809d --- /dev/null +++ b/src/server/lookup.ts @@ -0,0 +1,285 @@ +/** + * The server half of product URL import. See PLAN.md §7.2. + * + * A browser cannot fetch a retailer's page — CORS — so this runs server-side: Vite dev + * middleware in development, one serverless function in production. **The app stays + * fully functional as a static build with this absent**; URL import degrades to a + * message pointing at manual entry. It is a convenience layer, not a dependency. + * + * ## This endpoint fetches a URL the user supplied, which is SSRF by construction + * + * That is not a hypothetical: the whole feature is "give us a URL and we will make a + * request to it from our network". Every guard below exists because the request comes + * from inside somewhere the caller cannot otherwise reach. + * + * - **https only.** `file:`, `gopher:` and friends are not retailer pages. + * - **No credentials in the URL** — `https://user:pass@host` is a way of smuggling + * something past a log or a naive host check. + * - **Public addresses only.** Loopback, link-local, every RFC 1918 range, carrier + * NAT, unique-local IPv6 and `.localhost`/`.local`/`.internal` names are refused — + * and the hostname is *resolved* first where DNS is available, because + * `evil.example.com` is free to have an A record of `169.254.169.254`. + * - **Redirects are followed by hand**, at most three, revalidating every hop. A + * redirect to a private address is the standard way past a check that only looks + * at the URL the user typed. + * - **A response size cap and a hard timeout**, so a hostile or merely enormous page + * cannot hold a function open or exhaust its memory. + * + * The residual hole is stated rather than papered over: between the DNS check and the + * connection there is a window in which a record can change (DNS rebinding). Closing it + * needs the socket to be pinned to the address that was checked, which `fetch` does not + * expose. For a self-hosted planning tool with no internal network worth reaching, the + * trade is deliberate. + */ + +import { parseProduct, type ProductDraft } from '../core/product'; + +export const MAX_RESPONSE_BYTES = 2_000_000; +export const REQUEST_TIMEOUT_MS = 8_000; +export const MAX_REDIRECTS = 3; + +/** Identified, per §7.2. A scraper that lies about who it is deserves what it gets. */ +export const USER_AGENT = + 'floorplan/0.1 (+https://github.com/chntnm/floorplan) product-dimension-lookup'; + +export type LookupResult = + | { ok: true; url: string; draft: ProductDraft } + | { ok: false; status: number; message: string }; + +export class BlockedUrlError extends Error { + constructor(message: string) { + super(message); + this.name = 'BlockedUrlError'; + } +} + +const BLOCKED_SUFFIXES = ['.localhost', '.local', '.internal', '.home.arpa']; + +function isPrivateIPv4(host: string): boolean { + const parts = host.split('.'); + if (parts.length !== 4) return false; + + const octets = parts.map((p) => Number(p)); + if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false; + + const [a = 0, b = 0] = octets; + if (a === 0 || a === 10 || a === 127) return true; // this network, private, loopback + if (a === 169 && b === 254) return true; // link-local, and 169.254.169.254 in particular + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT + if (a >= 224) return true; // multicast and reserved + return false; +} + +function isPrivateIPv6(host: string): boolean { + const h = host.replace(/^\[|\]$/g, '').toLowerCase(); + if (h === '::1' || h === '::') return true; + if (h.startsWith('fe80')) return true; // link-local + if (/^f[cd]/.test(h)) return true; // unique-local + // `::ffff:127.0.0.1` — an IPv4 address wearing an IPv6 hat. + const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(h); + return mapped ? isPrivateIPv4(mapped[1]!) : false; +} + +export function isPrivateAddress(host: string): boolean { + return isPrivateIPv4(host) || isPrivateIPv6(host); +} + +/** Hostname to addresses. Injected so the guard can be tested without a network. */ +export type Resolver = (hostname: string) => Promise; + +/** + * The default resolver, or null on a runtime with no `node:dns`. + * + * Best effort by design: on a bundled or edge runtime the literal-address checks still + * apply and the DNS step is skipped. Refusing every lookup because `node:dns` is + * missing would take the feature out entirely on a runtime where it otherwise works. + */ +export async function systemResolver(hostname: string): Promise { + const dns = await import('node:dns/promises'); + const found = await dns.lookup(hostname, { all: true }); + return found.map((entry) => entry.address); +} + +/** Resolve a hostname and refuse it if it points anywhere private. */ +async function assertPublicHost(hostname: string, resolve: Resolver | null): Promise { + if (isPrivateAddress(hostname)) { + throw new BlockedUrlError('That address is not a public web address.'); + } + if (!resolve) return; + + let addresses: string[]; + try { + addresses = await resolve(hostname); + } catch (err) { + // A runtime with no `node:dns` at all: skip the step rather than refusing every + // lookup. A hostname that genuinely will not resolve fails at the fetch instead. + if (err instanceof Error && /Cannot find module|ERR_MODULE_NOT_FOUND/.test(err.message)) { + return; + } + throw new BlockedUrlError(`Could not resolve ${hostname}.`); + } + + for (const address of addresses) { + if (isPrivateAddress(address)) { + throw new BlockedUrlError('That address resolves to a private network.'); + } + } +} + +/** Validate the shape of a URL. Throws `BlockedUrlError` with a message to show. */ +export function parseTargetUrl(raw: unknown): URL { + if (typeof raw !== 'string' || !raw.trim()) { + throw new BlockedUrlError('Give a product page URL.'); + } + + let url: URL; + try { + url = new URL(raw.trim()); + } catch { + throw new BlockedUrlError('That is not a URL I can read.'); + } + + if (url.protocol !== 'https:') { + throw new BlockedUrlError('Only https product pages can be looked up.'); + } + if (url.username || url.password) { + throw new BlockedUrlError('A URL carrying credentials will not be fetched.'); + } + + const host = url.hostname.toLowerCase(); + if ( + host === 'localhost' || + BLOCKED_SUFFIXES.some((s) => host.endsWith(s)) || + // A literal address is refused here as well as in `assertPublicHost`. Both are + // reached on every hop, so this is redundant by design: the URL gate should be + // able to say no to `https://169.254.169.254/latest/meta-data/` on its own, rather + // than depending on a resolver step that a runtime without `node:dns` skips. + isPrivateAddress(host) + ) { + throw new BlockedUrlError('That address is not a public web address.'); + } + + return url; +} + +type FetchLike = (url: string, init: RequestInit) => Promise; + +export type LookupDeps = { + fetch?: FetchLike; + /** Pass `null` to skip the DNS check — for a runtime that has no resolver. */ + resolve?: Resolver | null; +}; + +/** Read at most `MAX_RESPONSE_BYTES`, then stop — a cap that is not merely advisory. */ +async function readCapped(response: Response): Promise { + const body = response.body; + if (!body) return (await response.text()).slice(0, MAX_RESPONSE_BYTES); + + const reader = body.getReader(); + const decoder = new TextDecoder(); + let size = 0; + let text = ''; + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + text += decoder.decode(value, { stream: true }); + if (size >= MAX_RESPONSE_BYTES) { + await reader.cancel(); + break; + } + } + return text; +} + +/** + * Fetch a product page and read what it says. + * + * Both dependencies are injected so the guards can be tested without touching a + * network — including DNS, which is the guard that matters most and the one a test + * cannot otherwise reach. They are read here rather than captured at module load, for + * the same reason the save picker is. + */ +export async function lookupProduct( + rawUrl: unknown, + deps: LookupDeps = {}, +): Promise { + const fetchImpl = deps.fetch ?? ((url, init) => globalThis.fetch(url, init)); + const resolve = deps.resolve === undefined ? systemResolver : deps.resolve; + let url: URL; + try { + url = parseTargetUrl(rawUrl); + } catch (err) { + return { ok: false, status: 400, message: message(err) }; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + let response: Response | undefined; + + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + await assertPublicHost(url.hostname, resolve); + + response = await fetchImpl(url.toString(), { + // Manual, so every hop is revalidated. `redirect: 'follow'` would let a + // retailer's shortlink land on a private address without this code seeing it. + redirect: 'manual', + signal: controller.signal, + headers: { 'user-agent': USER_AGENT, accept: 'text/html,application/xhtml+xml' }, + }); + + if (response.status < 300 || response.status >= 400) break; + + const location = response.headers.get('location'); + if (!location) break; + url = new URL(location, url); + + try { + url = parseTargetUrl(url.toString()); + } catch (err) { + return { ok: false, status: 400, message: message(err) }; + } + + if (hop === MAX_REDIRECTS) { + return { ok: false, status: 502, message: 'That page redirected too many times.' }; + } + response = undefined; + } + + if (!response) { + return { ok: false, status: 502, message: 'That page redirected too many times.' }; + } + if (!response.ok) { + return { + ok: false, + status: 502, + message: `That page returned ${response.status}. Check the URL, or enter the item by hand.`, + }; + } + + const type = response.headers.get('content-type') ?? ''; + if (!/text\/html|application\/xhtml/i.test(type)) { + return { ok: false, status: 415, message: 'That URL is not a web page.' }; + } + + const html = await readCapped(response); + return { ok: true, url: url.toString(), draft: parseProduct(html, url.toString()) }; + } catch (err) { + if (err instanceof BlockedUrlError) return { ok: false, status: 400, message: err.message }; + if (err instanceof Error && err.name === 'AbortError') { + return { ok: false, status: 504, message: 'That page took too long to answer.' }; + } + return { ok: false, status: 502, message: 'Could not reach that page.' }; + } finally { + clearTimeout(timer); + } +} + +function message(err: unknown): string { + return err instanceof Error ? err.message : 'That URL cannot be looked up.'; +} diff --git a/src/styles/global.css b/src/styles/global.css index cfb7a90..beeff7c 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -829,3 +829,22 @@ body { background: var(--surface); color: var(--text); } + +/* ---------- product URL lookup ---------- */ + +.lookup { + border-top: 1px solid var(--border); + padding-top: 8px; +} + +.lookup__source, +.lookup__evidence { + margin: 0 0 6px; + color: var(--text-dim); + word-break: break-word; +} + +.item__flag { + color: var(--text-dim); + font-style: italic; +} diff --git a/src/ui/InventoryPanel.tsx b/src/ui/InventoryPanel.tsx index 40e93be..55548a6 100644 --- a/src/ui/InventoryPanel.tsx +++ b/src/ui/InventoryPanel.tsx @@ -13,6 +13,9 @@ import { updateCatalogItem, } from '../state/actions'; import { ItemForm } from './ItemForm'; +import { NO_ENDPOINT_MESSAGE, lookUpProduct } from './product-lookup'; +import { evidenceFor, itemDraftFrom, sourceFor } from '../core/product-import'; +import type { ProductDraft } from '../core/product'; /** * The inventory (PLAN.md §7). @@ -39,6 +42,12 @@ export function InventoryPanel() { const [editingId, setEditingId] = useState(null); const [error, setError] = useState(null); + // URL import (PLAN.md §7.2). `pendingUrl` is the form; `found` is what came back and + // is what turns the item form into a confirm-before-add dialog. + const [urlEntry, setUrlEntry] = useState(null); + const [lookingUp, setLookingUp] = useState(false); + const [found, setFound] = useState<{ url: string; draft: ProductDraft } | null>(null); + const floor = activeFloor({ doc }); const unit = doc.displayUnit; const blocked = placementBlockReason(floor); @@ -60,6 +69,39 @@ export function InventoryPanel() { setAdding(false); }); + /** + * Confirm a looked-up product. + * + * The source is attached here rather than in the form, because whether the item + * counts as `parsed` or `confirmed` depends on what the user did to the numbers + * between the lookup and this call — which is exactly the thing the form does not + * know and this component does. + */ + const onConfirmFound = (draft: ItemDraft) => + guarded(() => { + if (!found) return; + addCatalogItem({ ...draft, source: sourceFor({ url: found.url, scraped: found.draft, submitted: draft }) }); + setFound(null); + setUrlEntry(null); + }); + + const onLookUp = async (url: string) => { + setLookingUp(true); + setError(null); + try { + const outcome = await lookUpProduct(url); + if (outcome.kind === 'ok') { + setFound({ url: outcome.url, draft: outcome.draft }); + return; + } + // "Unavailable" is not a failure to report as one — it is the stated + // degradation, and the message says what to do instead. + setError(outcome.kind === 'unavailable' ? NO_ENDPOINT_MESSAGE : outcome.message); + } finally { + setLookingUp(false); + } + }; + const onEdit = (draft: ItemDraft) => guarded(() => { if (editingId) updateCatalogItem(editingId, draft); @@ -135,6 +177,19 @@ export function InventoryPanel() { {CATEGORY_LABELS[item.category]} ·{' '} {formatLength(item.widthMm, unit)} × {formatLength(item.depthMm, unit)} ×{' '} {formatLength(item.heightMm, unit)} + {/* PLAN.md §7.2: an item still carrying a measurement nobody + checked. Shown here rather than only in the file, because the + moment it matters is when something does not fit and you are + looking down this list wondering which number to doubt. */} + {item.source?.confidence === 'parsed' ? ( + + {' '}· unverified + + ) : null}
    {/* Disabled rather than allowed-and-refused: the gate is going @@ -192,8 +247,65 @@ export function InventoryPanel() { onSubmit={onEdit} onCancel={() => setEditingId(null)} /> + ) : found ? ( + /* The confirm-before-add dialog (§7.2). It is the ordinary item form, with + every field editable, plus the URL and the text the numbers were read + from — a scraped dimension a person cannot check against the page is one + they have to take on trust. */ +
    +

    + From {found.url} +

    + {evidenceFor(found.draft) ? ( +

    + {evidenceFor(found.draft)} +

    + ) : ( +

    + That page did not state any dimensions — they need typing in. +

    + )} + { + setFound(null); + setUrlEntry(null); + }} + /> +
    ) : adding ? ( setAdding(false)} /> + ) : urlEntry !== null ? ( +
    + setUrlEntry(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && urlEntry.trim()) void onLookUp(urlEntry.trim()); + if (e.key === 'Escape') setUrlEntry(null); + }} + /> + + +
    ) : (
    +
    )} diff --git a/src/ui/ItemForm.tsx b/src/ui/ItemForm.tsx index 8c0dd97..1c9e9ff 100644 --- a/src/ui/ItemForm.tsx +++ b/src/ui/ItemForm.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { CATEGORIES, CATEGORY_DEFAULTS, CATEGORY_LABELS, type ItemDraft } from '../core/catalog'; import { SHAPE_KINDS, SHAPE_KIND_LABELS, type ShapeKind } from '../core/tools'; import type { DisplayUnit } from '../core/units'; -import { parseLength } from '../core/units'; +import { formatLength, parseLength } from '../core/units'; import { LengthField } from './LengthField'; import type { Category, MountKind } from '../core/document'; @@ -40,11 +40,15 @@ export function ItemForm({ unit, initial, submitLabel, onSubmit, onCancel }: Pro const [name, setName] = useState(base.name); const [category, setCategory] = useState(base.category); const [shape, setShape] = useState(base.shape); - const [width, setWidth] = useState(base.widthMm ? String(base.widthMm) : ''); - const [depth, setDepth] = useState(base.depthMm ? String(base.depthMm) : ''); - const [height, setHeight] = useState(base.heightMm ? String(base.heightMm) : ''); + // Formatted, never `String(mm)`. A bare number is read back in the document's + // display unit, so a 1524mm bed prefilled as "1524" is re-read as 1524 *inches* the + // moment the form is submitted — an edit that only changed the name would multiply + // every dimension by 25.4. `formatLength` writes a value that parses back to itself. + const [width, setWidth] = useState(base.widthMm ? formatLength(base.widthMm, unit) : ''); + const [depth, setDepth] = useState(base.depthMm ? formatLength(base.depthMm, unit) : ''); + const [height, setHeight] = useState(base.heightMm ? formatLength(base.heightMm, unit) : ''); const [voidBelow, setVoidBelow] = useState( - base.voidBelowMm !== undefined ? String(base.voidBelowMm) : '', + base.voidBelowMm !== undefined ? formatLength(base.voidBelowMm, unit) : '', ); const [hosts, setHosts] = useState(base.canHostSurface ?? CATEGORY_DEFAULTS[base.category].canHostSurface); const [quantity, setQuantity] = useState(String(base.quantityOwned ?? 1)); @@ -55,8 +59,12 @@ export function ItemForm({ unit, initial, submitLabel, onSubmit, onCancel }: Pro // the user has not already made a choice of their own. const onCategory = (next: Category) => { setCategory(next); - if (voidBelow === '' || voidBelow === String(CATEGORY_DEFAULTS[category].voidBelowMm)) { - setVoidBelow(String(CATEGORY_DEFAULTS[next].voidBelowMm)); + // Formatted for the same reason as the fields above: a suggestion written as a + // bare number would be read back in the display unit, so the category default for + // a dining table would arrive as 720 inches. + const suggested = formatLength(CATEGORY_DEFAULTS[category].voidBelowMm, unit); + if (voidBelow === '' || voidBelow === suggested) { + setVoidBelow(formatLength(CATEGORY_DEFAULTS[next].voidBelowMm, unit)); } setHosts(CATEGORY_DEFAULTS[next].canHostSurface); }; diff --git a/src/ui/product-lookup.test.ts b/src/ui/product-lookup.test.ts new file mode 100644 index 0000000..256c10d --- /dev/null +++ b/src/ui/product-lookup.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { NO_ENDPOINT_MESSAGE, lookUpProduct } from './product-lookup'; + +/** + * The client's one real job: telling "the endpoint said no" apart from "there is no + * endpoint". Those lead to different sentences, and only one of them is the user's + * problem to fix. + */ + +const realFetch = globalThis.fetch; + +function respond(body: string, init: ResponseInit = {}): void { + globalThis.fetch = () => Promise.resolve(new Response(body, init)); +} + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8' }; + +describe('when the endpoint is there', () => { + it('returns the draft', async () => { + respond(JSON.stringify({ url: 'https://shop.example.com/p', draft: { name: 'Sofa', widthMm: 2134 } }), { + headers: JSON_HEADERS, + }); + + const outcome = await lookUpProduct('https://shop.example.com/p'); + expect(outcome).toMatchObject({ kind: 'ok', draft: { name: 'Sofa', widthMm: 2134 } }); + }); + + it('passes a refusal through as something to show', async () => { + respond(JSON.stringify({ message: 'Only https product pages can be looked up.' }), { + status: 400, + headers: JSON_HEADERS, + }); + + const outcome = await lookUpProduct('http://shop.example.com/p'); + expect(outcome).toEqual({ kind: 'error', message: 'Only https product pages can be looked up.' }); + }); +}); + +describe('when the endpoint is not there', () => { + it('does not mistake an SPA fallback for a successful lookup', async () => { + // The trap. A static host answering an unknown POST with `index.html` and a 200 + // makes `response.ok` true; the JSON parse then throws somewhere deep in a catch + // written for network failures. The content type is the only honest signal. + respond('the app', { + status: 200, + headers: { 'content-type': 'text/html' }, + }); + + expect(await lookUpProduct('https://shop.example.com/p')).toEqual({ kind: 'unavailable' }); + }); + + it('treats a 404 as absent, not as an error to explain', async () => { + respond('Not found', { status: 404, headers: { 'content-type': 'text/plain' } }); + expect(await lookUpProduct('https://shop.example.com/p')).toEqual({ kind: 'unavailable' }); + }); + + it('treats a network failure as absent', async () => { + globalThis.fetch = () => Promise.reject(new Error('Failed to fetch')); + expect(await lookUpProduct('https://shop.example.com/p')).toEqual({ kind: 'unavailable' }); + }); + + it('treats JSON that is not ours as absent', async () => { + // A proxy, or a different service on the same path. An error with no message is + // not a refusal this application can explain. + respond(JSON.stringify({ error: 'nope' }), { status: 502, headers: JSON_HEADERS }); + expect(await lookUpProduct('https://shop.example.com/p')).toEqual({ kind: 'unavailable' }); + }); + + it('treats a 200 with no draft as absent', async () => { + respond(JSON.stringify({ ok: true }), { status: 200, headers: JSON_HEADERS }); + expect(await lookUpProduct('https://shop.example.com/p')).toEqual({ kind: 'unavailable' }); + }); + + it('has a sentence that says what to do instead', () => { + // §7.2: URL import degrades to a message pointing at manual entry. "Lookup failed" + // tells someone nothing they can act on. + expect(NO_ENDPOINT_MESSAGE).toContain('by hand'); + }); +}); + +describe('the request', () => { + it('posts the URL as JSON', async () => { + let seen: RequestInit | undefined; + globalThis.fetch = (_input, init) => { + seen = init; + return Promise.resolve(new Response('{}', { status: 200, headers: JSON_HEADERS })); + }; + + await lookUpProduct('https://shop.example.com/p'); + expect(seen?.method).toBe('POST'); + expect(JSON.parse(String(seen?.body))).toEqual({ url: 'https://shop.example.com/p' }); + }); +}); diff --git a/src/ui/product-lookup.ts b/src/ui/product-lookup.ts new file mode 100644 index 0000000..4096132 --- /dev/null +++ b/src/ui/product-lookup.ts @@ -0,0 +1,78 @@ +/** + * Asking the server about a product URL. See PLAN.md §7.2. + * + * ## `response.ok` is not how you find out whether the endpoint exists + * + * A static build has no `/api/product-lookup`. Depending on the host, a POST to it + * comes back as a 404, a 405, or — on any host with an SPA fallback, which is most of + * them, `vite preview` included — a **200 carrying `index.html`**. That last one is the + * dangerous case: `response.ok` is true, and `response.json()` throws somewhere deep + * in a `catch` that was written for network failures. + * + * So the test is the **content type**. Anything that is not JSON means the endpoint is + * absent, whatever the status line says, and the UI points at manual entry — which is + * §7.2's stated degradation rather than an error nobody can act on. + * + * `fetch` is read from `globalThis` at call time, never captured at module load, for + * the same reason the save picker is: a snapshot cannot be replaced from a test. + */ + +import { PRODUCT_LOOKUP_PATH } from '../core/api'; +import type { ProductDraft } from '../core/product'; + +export type LookupOutcome = + | { kind: 'ok'; url: string; draft: ProductDraft } + /** No endpoint deployed. The app is a static build and this is expected. */ + | { kind: 'unavailable' } + /** The endpoint answered, and the answer was no. `message` is safe to show. */ + | { kind: 'error'; message: string }; + +export const NO_ENDPOINT_MESSAGE = + 'Looking up a URL needs the lookup service, which this build does not have. ' + + 'Enter the item by hand — name, then width, depth and height.'; + +function isJson(response: Response): boolean { + return /application\/json/i.test(response.headers.get('content-type') ?? ''); +} + +export async function lookUpProduct(url: string): Promise { + let response: Response; + try { + response = await globalThis.fetch(PRODUCT_LOOKUP_PATH, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ url }), + }); + } catch { + // No network, or no server at all. Indistinguishable from here, and the same + // advice either way. + return { kind: 'unavailable' }; + } + + if (!isJson(response)) return { kind: 'unavailable' }; + + let body: unknown; + try { + body = await response.json(); + } catch { + return { kind: 'unavailable' }; + } + + const payload = (typeof body === 'object' && body !== null ? body : {}) as Record; + + if (!response.ok) { + const message = typeof payload['message'] === 'string' ? payload['message'] : null; + // A JSON body with no message is an endpoint we do not recognise — a proxy, or a + // different service on the same path — not a refusal we can explain. + return message ? { kind: 'error', message } : { kind: 'unavailable' }; + } + + const draft = payload['draft']; + if (typeof draft !== 'object' || draft === null) return { kind: 'unavailable' }; + + return { + kind: 'ok', + url: typeof payload['url'] === 'string' ? payload['url'] : url, + draft: draft as ProductDraft, + }; +} diff --git a/vite.config.ts b/vite.config.ts index fea4e97..3dd3cb4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,57 @@ /// import { fileURLToPath, URL } from 'node:url'; -import { defineConfig } from 'vite'; +import { defineConfig, type Plugin } from 'vite'; import react from '@vitejs/plugin-react'; +import { PRODUCT_LOOKUP_PATH, handleProductLookup } from './src/server/endpoint'; + +/** + * The product-lookup endpoint in development. See PLAN.md §7.2. + * + * `configureServer` only — deliberately **not** `configurePreviewServer`. Preview + * serves the production build, where this endpoint is a serverless function that is + * not part of the bundle, so leaving it out is what makes preview an honest rehearsal + * of a static deployment. It is also what lets the end-to-end suite assert the stated + * degradation — "the app remains fully functional with the endpoint absent" — against + * a real absence rather than a stub of one. + */ +function productLookup(): Plugin { + return { + name: 'floorplan:product-lookup', + configureServer(server) { + server.middlewares.use(PRODUCT_LOOKUP_PATH, (req, res) => { + void (async () => { + if (req.method !== 'POST') { + res.statusCode = 405; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ message: 'POST a { url } to look up a product.' })); + return; + } + + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + + let body: unknown; + try { + body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); + } catch { + res.statusCode = 400; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ message: 'That request body is not JSON.' })); + return; + } + + const result = await handleProductLookup(body); + res.statusCode = result.status; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(result.body)); + })(); + }); + }, + }; +} export default defineConfig({ - plugins: [react()], + plugins: [react(), productLookup()], resolve: { alias: { From ab3b8eee94de31965b7989aab8c68b6e7c045268 Mon Sep 17 00:00:00 2001 From: Chintan Date: Thu, 3 Sep 2026 03:32:12 -0400 Subject: [PATCH 19/19] Two gaps a review pass found around the lookup endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ::ffff:7f00:1 is 127.0.0.1 written in hex, and the mapped-address check only recognised the dotted form — which is not the spelling anyone probing the guard would use. Addresses are now expanded to their eight groups and tested, rather than matched by prefix string. The numeric host forms turn out to be handled a layer down: the WHATWG URL parser normalises https://2130706433/ and 0x7f.0.0.1 to a dotted quad before parseTargetUrl sees the hostname. That is load-bearing and invisible, so it is asserted rather than assumed — the literal guard would otherwise have a hole covered only by the DNS step, which a runtime with no resolver skips entirely. Second gap: handleProductLookup had no coverage at all. Everything else tests the parser or the guards, and the e2e suite runs against preview, which has no endpoint by design — so the wrapper both deployments call had never been executed. Now tested, and the dev middleware was run by hand: 400 with a JSON message for a bad URL, 400 for a private address, 405 for a GET. --- PLAN.md | 9 +++++ src/server/endpoint.test.ts | 43 ++++++++++++++++++++++ src/server/lookup.test.ts | 46 ++++++++++++++++++++++++ src/server/lookup.ts | 71 +++++++++++++++++++++++++++++++++---- 4 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 src/server/endpoint.test.ts diff --git a/PLAN.md b/PLAN.md index 19122d2..ebe03fe 100644 --- a/PLAN.md +++ b/PLAN.md @@ -605,6 +605,15 @@ rather than papered over: between the DNS check and the connection a record can and closing that needs the socket pinned to the address that was checked, which `fetch` does not expose. +Addresses are **expanded, not prefix-matched**. `::ffff:127.0.0.1` and `::ffff:7f00:1` +are the same address written two ways, and a check looking for a dotted quad sees only +the first — which is not the spelling anyone testing the claim would use. The numeric +IPv4 forms (`https://2130706433/`, `0x7f.0.0.1`) are handled a layer down: the WHATWG +URL parser normalises them to a dotted quad before this code sees the hostname. That is +load-bearing and not obvious, so it is asserted rather than assumed — without it the +literal guard would have a hole covered only by the DNS step, which a runtime with no +resolver skips. + --- ## 8. Editing: Modes and Layers diff --git a/src/server/endpoint.test.ts b/src/server/endpoint.test.ts new file mode 100644 index 0000000..be72e90 --- /dev/null +++ b/src/server/endpoint.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { PRODUCT_LOOKUP_PATH, handleProductLookup } from './endpoint'; + +/** + * The shape both deployments answer with. + * + * `lookup.ts` is thoroughly covered and this wrapper is four lines, which is exactly + * the kind of code that ships unexecuted: every other test in this feature exercises + * either the pure parser or the guards, and the end-to-end suite runs against + * `vite preview`, which has no endpoint by design. Nothing else runs this function. + */ +describe('the endpoint contract', () => { + it('answers a bad URL with a status and a message, not an exception', async () => { + // The message reaches the UI verbatim. An endpoint that threw here would be + // rendered by the host as an HTML error page, which the client reads as "no + // endpoint" — sending the user to manual entry without telling them their URL was + // the problem. + const result = await handleProductLookup({ url: 'http://shop.example.com/p' }); + + expect(result.status).toBe(400); + expect(String(result.body['message'])).toContain('https'); + }); + + it('answers a missing body the same way', async () => { + for (const body of [undefined, null, {}, 'not an object', { url: 42 }]) { + const result = await handleProductLookup(body); + expect(result.status, JSON.stringify(body)).toBe(400); + expect(typeof result.body['message']).toBe('string'); + } + }); + + it('refuses a private address rather than fetching it', async () => { + const result = await handleProductLookup({ url: 'https://169.254.169.254/latest/meta-data/' }); + expect(result.status).toBe(400); + }); + + it('agrees with the client about where it lives', () => { + // The path is the one thing the two halves have to share, and it is why it lives + // in `core/api.ts` rather than here — importing it from this module would drag the + // HTML parser into the browser bundle for a string. + expect(PRODUCT_LOOKUP_PATH).toBe('/api/product-lookup'); + }); +}); diff --git a/src/server/lookup.test.ts b/src/server/lookup.test.ts index 9ebdfcc..b5888c4 100644 --- a/src/server/lookup.test.ts +++ b/src/server/lookup.test.ts @@ -61,6 +61,52 @@ describe('which addresses are private', () => { expect(isPrivateAddress(address), address).toBe(false); } }); + + it('sees through the other spellings of a mapped IPv4 loopback', () => { + // `::ffff:127.0.0.1` and `::ffff:7f00:1` are the same address, the second written + // in hex. A check that looks for a dotted quad only recognises the first, which is + // exactly the spelling an attacker does not use. + for (const address of [ + '::ffff:127.0.0.1', + '::ffff:7f00:1', + '[::ffff:7f00:1]', + '::ffff:a00:1', // 10.0.0.1 + '0:0:0:0:0:ffff:7f00:0001', + ]) { + expect(isPrivateAddress(address), address).toBe(true); + } + }); + + it('does not refuse everything that merely looks v6', () => { + // `::ffff:5db8:d822` is a mapped 93.184.216.34 — a public address, and blocking it + // would refuse a real retailer. `fe00::1` is one bit outside fe80::/10, which is + // the kind of edge a prefix-string check gets wrong in the permissive direction. + for (const address of ['::ffff:5db8:d822', '2001:db8::1', 'fe00::1', 'not-an-address']) { + expect(isPrivateAddress(address), address).toBe(false); + } + }); +}); + +describe('the spellings of an address', () => { + it('refuses a loopback written as a number', () => { + // `https://2130706433/` is 127.0.0.1 in decimal, and `0x7f.0.0.1` in hex. The + // WHATWG URL parser normalises both to a dotted quad before this code sees them — + // load-bearing and not obvious, so it is asserted rather than assumed. Without it + // the literal guard would have a hole that only the DNS step covers, and that step + // is skipped on a runtime with no resolver. + for (const url of [ + 'https://2130706433/p', + 'https://0x7f.0.0.1/p', + 'https://017700000001/p', + 'https://127.1/p', + ]) { + expect(() => parseTargetUrl(url), url).toThrow(BlockedUrlError); + } + }); + + it('refuses a mapped loopback in a URL', () => { + expect(() => parseTargetUrl('https://[::ffff:7f00:1]/p')).toThrow(BlockedUrlError); + }); }); describe('which URLs are even considered', () => { diff --git a/src/server/lookup.ts b/src/server/lookup.ts index 6ff809d..8ac9f8f 100644 --- a/src/server/lookup.ts +++ b/src/server/lookup.ts @@ -72,14 +72,71 @@ function isPrivateIPv4(host: string): boolean { return false; } +/** + * An IPv6 literal as its eight 16-bit groups, or null if it is not one. + * + * Expanded rather than matched by prefix, because the same address has many + * spellings and a string test only recognises the ones you thought of. + * `::ffff:127.0.0.1` and `::ffff:7f00:1` are the *same address* — the second written + * in hex — and a regex looking for dotted quads sees only the first. + */ +function parseIPv6(raw: string): number[] | null { + let h = raw.replace(/^\[|\]$/g, '').toLowerCase(); + if (!h.includes(':')) return null; + + // A trailing dotted quad (`::ffff:127.0.0.1`) becomes two hex groups, so + // everything below works on one representation. + const tail = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(h); + if (tail) { + const octets = tail[2]!.split('.').map(Number); + if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null; + const [a = 0, b = 0, c = 0, d = 0] = octets; + h = `${tail[1]}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`; + } + + const halves = h.split('::'); + if (halves.length > 2) return null; + + const toGroups = (part: string): number[] | null => { + if (part === '') return []; + const out: number[] = []; + for (const piece of part.split(':')) { + if (!/^[0-9a-f]{1,4}$/.test(piece)) return null; + out.push(parseInt(piece, 16)); + } + return out; + }; + + const head = toGroups(halves[0] ?? ''); + const rest = halves.length === 2 ? toGroups(halves[1] ?? '') : null; + if (!head) return null; + if (halves.length === 1) return head.length === 8 ? head : null; + if (!rest) return null; + + const gap = 8 - head.length - rest.length; + if (gap < 1) return null; + return [...head, ...new Array(gap).fill(0), ...rest]; +} + function isPrivateIPv6(host: string): boolean { - const h = host.replace(/^\[|\]$/g, '').toLowerCase(); - if (h === '::1' || h === '::') return true; - if (h.startsWith('fe80')) return true; // link-local - if (/^f[cd]/.test(h)) return true; // unique-local - // `::ffff:127.0.0.1` — an IPv4 address wearing an IPv6 hat. - const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(h); - return mapped ? isPrivateIPv4(mapped[1]!) : false; + const g = parseIPv6(host); + if (!g) return false; + + // `::` (unspecified) and `::1` (loopback). + if (g.slice(0, 7).every((n) => n === 0) && (g[7] === 0 || g[7] === 1)) return true; + // Link-local fe80::/10 and unique-local fc00::/7. + if ((g[0]! & 0xffc0) === 0xfe80) return true; + if ((g[0]! & 0xfe00) === 0xfc00) return true; + + // IPv4-mapped ::ffff:0:0/96 — an IPv4 address wearing an IPv6 hat, and the way a + // loopback gets past a check that only knows what a dotted quad looks like. + if (g.slice(0, 5).every((n) => n === 0) && g[5] === 0xffff) { + const hi = g[6]!; + const lo = g[7]!; + return isPrivateIPv4(`${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`); + } + + return false; } export function isPrivateAddress(host: string): boolean {