diff --git a/.gitignore b/.gitignore index 8610a54..5071c3f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ Thumbs.db # Working documents produced by the app itself *.space !e2e/fixtures/**/*.space +# The frozen schema-1 container. Checked in on purpose: a migration fixture that +# is regenerated from current code proves nothing. +!src/core/fixtures/**/*.space diff --git a/PLAN.md b/PLAN.md index ada5c1a..ebe03fe 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,8 +1,8 @@ -# roomplan — Implementation Plan +# floorplan — Implementation Plan -**Location:** `Development/conquerorchin/roomplan/` -**Status:** Planning — nothing built yet -**Date:** 2026-09-01 +**Location:** `Development/conquerorchin/floorplan/` +**Status:** Phases 0-9 built; see the phasing table in §12 +**Date:** 2026-09-03 --- @@ -256,8 +256,8 @@ type Opening = { heightMm: number; sillMm: number; // 0 for doors, ~900 for windows kind: 'door' | 'window' | 'cased' | 'pocket' | 'sliding'; - swing?: { // doors only - hinge: 'a' | 'b'; // which end of the opening + swing?: { // read only where the kind has a leaf + hinge: 'a' | 'b'; // hinged there, or parked there when open into: 'front' | 'back'; // which side of the wall angleDeg: number; // 90 typical; drives the swing arc + 3D door panel }; @@ -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[]; @@ -291,6 +292,16 @@ moves, is constrained to that wall's length, and its swing arc participates in c checks. In 3D, an opening cuts a real hole in the extruded wall mesh and — for doors — renders a panel at the swing angle. +**One stored field, read according to the kind.** `swing` is the only thing recorded, +and `leafOf` reads it into a shape named for the *leaf*: hinged doors get an angle, +sliding doors get a park end and a face but structurally **no angle to read**, pocket +doors get only a park end, and cased openings and windows get no leaf at all. That is +what stops a slider quietly acquiring a swing angle nobody set, without a second +stored field or a migration. The field is deliberately **retained across a kind +change** — a door turned into a cased opening and back is the door you had, hinged +where you hung it — so a cased opening in a saved file may carry a `swing` that +nothing reads. Losing a choice the user made is the worse trade. + ### 4.5 The document ```ts @@ -339,14 +350,101 @@ offered when a document has no assets — useful for diffing and version control no-op entry, so the machinery exists before it is needed. Loading a newer version than the app understands produces a clear message, not a crash or a silent partial parse. -### Save mechanics +### 5.1 Save mechanics + +Two ways out, one pipeline. The bytes are built identically for both — document, +assets, thumbnail — and only the last step differs. - **File System Access API** where available (Chrome, Edge) — `showSaveFilePicker`, the handle retained in memory so Ctrl+S is a true save-in-place, no download prompt. -- **Download fallback** everywhere else — anchor with a blob URL. -- **IndexedDB autosave** every 20s and on every meaningful mutation, keyed by document - id. On load, if an autosave is newer than the opened file, offer recovery. -- **Import** by file picker or drag-and-drop onto the window. +- **Download fallback** everywhere else (Firefox, Safari) — anchor with a blob URL. Not + a degraded mode to apologise for: it is what 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** by file picker or drag-and-drop onto the window. One routing function, so + a dropped `.space` and a picked one cannot come to differ — including the page picker + a multi-page PDF raises, which is why the pending inspection lives in the store + rather than inside the import button. + +`supportsSaveInPlace()` reads `window` **at call time**, never at module load. A +snapshot decides for the life of the page from whatever was true during the first +import, which makes the branch unreachable from a test — and headless Chromium *has* +the API, so without the call-time read the download path would ship with no end-to-end +coverage at all and the in-place path could not be driven by a stub either. + +A handle is **session state, not document state**: it does not serialize, it does not +survive a reload, and it belongs to one document, so it lives beside the asset store +and `loadDocument`/`newDocument` clear it. A handle that outlived its document would +send the next Ctrl+S into the previous space's file, with no warning and no undo. +"Save as…" exists because otherwise the retained handle is a trap — once a document +has a file there would be no way to write it anywhere else. + +**Cancelling is a decision, not a failure.** A dismissed picker throws `AbortError`; +reported as an error it becomes an alert about a save the user chose not to make, and +it is then indistinguishable from a real write failure. It is translated to +`SaveCancelled`, nothing is shown, and the document stays dirty because it genuinely +was not saved. Nothing marks the document clean until a write resolves. + +### 5.2 Autosave and recovery + +**IndexedDB, two object stores**: documents keyed by document id, assets keyed by asset +id. An autosave that keeps only `document.json` recovers a space whose background is +gone — the same failure `assetMapFor` throws to prevent, reached through a different +door — but rewriting a megabyte of raster every twenty seconds is absurd. The way out +is that an asset is **immutable once stored**: `putAsset` mints a new id for new bytes +and never rewrites an existing one, so assets are written by id exactly once and every +tick after that writes the document record alone. The cost of a tick does not depend on +how big the background is. + +§5's "every 20s **and** on every meaningful mutation" is two schedules as written, one +of which writes on every frame of a drag. The reading that satisfies both is a +**debounce with a deadline**: two seconds after the last change, but never more than +twenty seconds since the last write, so continuous activity cannot postpone the write +indefinitely by resetting the debounce on every frame. + +**A record is deleted the moment its document is saved to a file.** That rule is what +keeps the prompt worth reading: a surviving record means that document had unsaved +changes when the tab went away. Without it every clean reload offers to recover work +already saved, and the prompt is trained out of the user long before the one time it +matters. + +Recovery has **two offers**, because §5's "newer than the opened file" answers the easy +half and the case that matters after a crash is the one it does not describe — there is +*no* opened file. The tab died with an hour of drawing in it and comes back on a fresh +empty document whose id the record has never heard of. + +- `newer` — an autosave for *this* document holding a later edit than the file that was + opened. Compared on the document's own `modifiedAt`, not on when the autosave ran: + the question is which state is further along, not which write happened last. +- `orphan` — the current document is structurally untouched, so there is nothing to + lose, and an autosave for another document exists. + +A restored document arrives **dirty**. It has never been written to a file, and opening +it clean would let the user close the tab a second time on the same unsaved work. There +is no handle either, so the next save asks where to put it — which is why recovery does +not pretend to restore "the file you were working on". + +Every database call resolves rather than rejecting when IndexedDB is unavailable, +blocked by a privacy setting, or over quota. A safety net that can take down the +application it is protecting is a worse bargain than no net. + +### 5.3 Thumbnail + +Rendered **offscreen from document geometry**, not from `stage.toDataURL()`. The stage +version couples saving to the plan view being mounted — there is no stage in 3D mode — +and captures the current pan and zoom, so the picture is whatever corner of the plan you +were looking at rather than the plan. + +Split so the seam is testable: the fit transform and the ring extraction are pure, and +what is left is `ctx.fill()`. The active floor only, framed on its own extent — the +ghost underlay is excluded for the same reason it is excluded from `floorBounds`. The +extent covers **placements as well as rooms and walls**, unlike `floorBounds`: an +inventory-first document with furniture and no structure yet has a perfectly good +picture and would otherwise produce none. + +An empty floor gets **no thumbnail** rather than a blank square, and no failure in this +path can fail a save — losing a preview is cosmetic, and there is no environment where +the fix is to give up on the document. --- @@ -459,14 +557,62 @@ 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. + +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. --- @@ -505,6 +651,24 @@ points quickly — which is how anyone draws — ends the chain at the second po Clicking the same spot twice is the gesture people actually mean, and it needs no timer; Enter and closing the loop also finish. +**The hit graph does not take effect until the next draw.** Konva keeps hit-test +geometry in a separate canvas that is only refreshed when the layer is drawn, so +anything changing what is hittable is invisible to a click arriving in the same +frame. Two triggers, both load-bearing: a layer switched on is still deaf (pick +Select and click a wall fast enough and nothing happens), and a shape that first +appeared this frame is not in the hit canvas yet (place an item, click it straight +away, and it does not select). Three consequences: the *tool* is checked inside the +shape handlers rather than by toggling `listening`; the *mode* switch calls +`drawHit()` from a layout effect; and so does a change in the number of shapes on a +layer. + +**Nothing above the canvas may change height during a gesture.** The calibration gate +is a band directly above the stage; an early version swapped a one-line prompt for the +length form on mousedown, the band grew, the stage shifted down under the pointer, and +a reference drawn as 3000mm committed as 3048mm — with every dimension traced +afterwards inheriting the error. Controls there are rendered disabled, not absent, and +the error line reserves its space. + --- ## 9. Placement Quality @@ -544,7 +708,14 @@ than one that tells you and gets out of the way. Additional 3D-only checks: - **Headroom** — `elevation + heightMm > room.ceilingHeightMm` -- **Door swing** — the swept swing arc, from sill to door height, against object volumes +- **Door swing** — the swept arc, from sill to head, against **object volumes only**. + Walls are not tested: a door swinging back to rest against the adjacent wall is how + doors are hung, and flagging it would fire on nearly every door in a corner. The + sector is polygonised at one vertex per 5°, because it is a collision polygon and a + chord cuts *inside* the true arc — too coarse and a narrow object at the outer edge + falls into the gap and is never reported. A slider is checked against the wall it + parks over instead; a pocket door is checked against nothing in the room, which is + the entire argument for fitting one, and instead has to prove its cavity exists. - **Wall-mount validity** — a wall-mounted item whose span exceeds its host wall, or which overlaps an opening @@ -573,10 +744,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 @@ -600,11 +815,21 @@ sync. - **Floors and ceilings** — room boundary polygons triangulated and placed at the floor datum and at `ceilingHeightMm`. Ceilings hide when the camera is inside the room, or render single-sided so you can look in from above in orbit mode. + **Shipped as a global toggle, default off**, rather than as camera-aware hiding. + Deciding "is the camera inside this room" per frame per room is a point-in-polygon + test against a moving target, and getting it wrong flickers the ceiling on and off + as you cross a threshold. A switch is legible and always right; the cost is that + walk mode has no ceiling overhead until it is turned on. - **Placements** — extruded from the same `outline` polygon that the 2D view draws and the collision engine tests, raised to `elevation`, height `heightMm`. Circles and ellipses build true cylinders from their generator. One primitive, three consumers. -- **Doors** — a real panel at the swing angle, hinged correctly. Windows get a - transparent pane at the sill height. +- **Doors** — a real panel at the swing angle, hinged on the *face* it opens onto + rather than on the centreline (half a wall thickness, invisible in a drawing and a + systematic bias in every clearance answer if skipped). Windows get a transparent + pane. A pocket door draws no leaf at all, because its leaf is inside the wall. + **Glazing blocks the walker and an open door does not** — glass is something you + cannot walk through, while a leaf drawn open would otherwise narrow its own doorway + by however far it happens to have been swung, and there is no way to push it. - **Appearance** — v1 is flat category colors with soft shading, plus the product thumbnail applied to the top face for identification from above. `modelAssetId` is reserved in the format for GLTF models in v2; nothing else changes when they land. @@ -617,14 +842,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 @@ -668,6 +912,104 @@ 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. + +**Validation is active-floor scoped**, and the panel says so once there is more than +one floor. Overlaps, headroom, clearance and swing all run against the floor you are +editing, so a problem upstairs is invisible while you are downstairs — including in a +file handed to someone else. Widening `validateFloor` to the whole document is not the +fix on its own: every issue would then need to name a floor and clicking one would have +to change floors before it could select anything, which is a phase 9 job. Naming the +scope is what stops "No issues" reading as a claim about the building. + +**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 @@ -679,14 +1021,14 @@ 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. | -| **3** | **Import + calibration** — PDF via pdfjs, image import, the blocking calibration gate, background transform/opacity/lock, tracing over a real plan. | -| **4** | **Inventory** — catalog/placement split, manual entry, preset library, quantity tracking, placement onto the plan with wall snap and overlap warnings. **First genuinely useful build.** | -| **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. | -| **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. | -| **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. | +| **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. 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. 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. @@ -703,9 +1045,25 @@ do. Everything after is depth. transforms, surface-mount cycle rejection. - **Format round-trip** — document → `.space` → document deep-equals, with assets. Fixture files checked in per schema version; every migration tested against a real - fixture from the previous version. -- **Product parsers** — saved HTML fixtures from real retailer pages checked into the - repo. **No network in CI.** Each fixture asserts extracted dimensions and confidence. + fixture from the previous version. `src/core/fixtures/schema-v1.space` is that file + for schema 1: written once, hand-authored rather than produced by `createDocument`, + and committed. Nothing regenerates it, because a fixture produced by the same build + that reads it asserts only that today's writer agrees with today's reader. There is + no migration to test yet — `MIGRATIONS` is empty at schema 1 — so the chain itself is + 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** — 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/README.md b/README.md index fc0f12a..60e448a 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. @@ -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/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/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/e2e/coords.ts b/e2e/coords.ts new file mode 100644 index 0000000..227184d --- /dev/null +++ b/e2e/coords.ts @@ -0,0 +1,68 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +/** + * The screen-to-document contract every canvas spec depends on. + * + * These specs click screen pixels and assert document millimetres, which only works + * because a fresh document opens at a fixed viewport (see `DEFAULT_VIEWPORT`): scale + * 0.05 px/mm, document origin at 120,100 inside the stage. Keep the two in step. + * + * One screen pixel is 20mm at that scale, so a half-pixel of rounding is 10mm — under + * half the 25mm snap grid, which is what makes the coordinates in the specs land + * exactly. + * + * The mapping holds for a fresh page only. Opening a file calls `zoomToFit`, and + * anything that zooms, pans or fits invalidates it — do not click document + * coordinates after one of those without re-deriving the transform. Importing a plan + * deliberately does *not* re-fit, so the mapping survives an import. + * + * It lives here rather than in one spec because two files need it: when + * `DEFAULT_SCALE` or `DEFAULT_ORIGIN_PX` moves, a second copy would go stale and fail + * in a way that looks exactly like a product bug. + */ +export const SCALE = 0.05; +export const ORIGIN = { x: 120, y: 100 }; + +export async function docToPage(stage: Locator, mm: { x: number; y: number }) { + const box = await stage.boundingBox(); + if (!box) throw new Error('plan stage has no bounding box'); + return { + x: box.x + ORIGIN.x + mm.x * SCALE, + y: box.y + ORIGIN.y + mm.y * SCALE, + }; +} + +export async function clickAt(page: Page, stage: Locator, mm: { x: number; y: number }) { + const p = await docToPage(stage, mm); + await page.mouse.click(p.x, p.y); +} + +/** + * Pick a tool and wait for it to be current. + * + * A canvas click sent immediately after the button click can arrive before React has + * committed the render that arms the tool — Playwright waits for the DOM click, not + * for the frame after it. Asserting the pressed state gates on that render without a + * sleep. + */ +export async function selectTool(page: Page, name: string) { + const button = page.getByRole('button', { name, exact: true }); + await button.click(); + await expect(button).toHaveAttribute('aria-pressed', 'true'); +} + +export async function dragBetween( + page: Page, + stage: Locator, + from: { x: number; y: number }, + to: { x: number; y: number }, +) { + const a = await docToPage(stage, from); + const b = await docToPage(stage, to); + await page.mouse.move(a.x, a.y); + await page.mouse.down(); + // Two intermediate moves: one to start the rubber band, one to prove it tracks. + await page.mouse.move((a.x + b.x) / 2, (a.y + b.y) / 2); + await page.mouse.move(b.x, b.y); + await page.mouse.up(); +} diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..168d1e9 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,136 @@ +/** + * Fixtures generated at test time rather than checked in as binaries. + * + * A committed PNG is a file nobody can read in a diff and nobody remembers the + * dimensions of — and the dimensions are exactly what these tests assert against. + * Building them here keeps the numbers in the spec that depends on them. + */ + +import { deflateSync } from 'node:zlib'; + +// --------------------------------------------------------------------------- +// PNG +// --------------------------------------------------------------------------- + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buf: Buffer): number { + let c = 0xffffffff; + for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 0xff]! ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type: string, data: Buffer): Buffer { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'latin1'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([length, body, crc]); +} + +/** + * A real, decodable PNG of exactly `width` × `height`. + * + * White with a black border and a diagonal, so a human looking at a failed test + * screenshot can see the plan is actually there and which way up it is. + */ +export function makePng(width: number, height: number): Buffer { + const raw = Buffer.alloc(height * (1 + width * 3), 0xff); + const rowStride = 1 + width * 3; + + const set = (x: number, y: number) => { + if (x < 0 || y < 0 || x >= width || y >= height) return; + const at = y * rowStride + 1 + x * 3; + raw[at] = 0; + raw[at + 1] = 0; + raw[at + 2] = 0; + }; + + for (let y = 0; y < height; y++) { + raw[y * rowStride] = 0; // filter: none + set(0, y); + set(width - 1, y); + } + for (let x = 0; x < width; x++) { + set(x, 0); + set(x, height - 1); + set(x, Math.floor((x * height) / width)); + } + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // colour type: truecolour + // 10..12: compression, filter, interlace — all zero. + + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +// --------------------------------------------------------------------------- +// PDF +// --------------------------------------------------------------------------- + +/** + * A minimal multi-page PDF with a real cross-reference table. + * + * Hand-assembled because the alternative — committing a binary — hides the one + * property the page-picker test cares about, which is how many pages it has. Each + * page draws a diagonal so a render that produced a blank raster is distinguishable + * from one that worked. + */ +export function makePdf(pageCount: number, width = 400, height = 300): Buffer { + const objects: string[] = []; + const push = (body: string) => objects.push(body) && objects.length; + + const catalogId = 1; + const pagesId = 2; + objects.push(''); // 1: catalog, filled in below + objects.push(''); // 2: pages + + const pageIds: number[] = []; + for (let i = 0; i < pageCount; i++) { + const content = `1 w 0 0 0 RG 10 10 m ${width - 10} ${height - 10} l S BT /F1 24 Tf 40 ${ + height / 2 + } Td (Page ${i + 1}) Tj ET`; + const streamId = push(`<< /Length ${content.length} >>\nstream\n${content}\nendstream`); + const pageId = push( + `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${width} ${height}] ` + + `/Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> ` + + `/Contents ${streamId} 0 R >>`, + ); + pageIds.push(pageId); + } + + objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`; + objects[pagesId - 1] = + `<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] /Count ${pageCount} >>`; + + let pdf = '%PDF-1.4\n'; + const offsets: number[] = []; + objects.forEach((body, i) => { + offsets.push(pdf.length); + pdf += `${i + 1} 0 obj\n${body}\nendobj\n`; + }); + + const xrefAt = pdf.length; + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + for (const offset of offsets) pdf += `${String(offset).padStart(10, '0')} 00000 n \n`; + pdf += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefAt}\n%%EOF\n`; + + return Buffer.from(pdf, 'latin1'); +} diff --git a/e2e/floors.spec.ts b/e2e/floors.spec.ts new file mode 100644 index 0000000..bb54734 --- /dev/null +++ b/e2e/floors.spec.ts @@ -0,0 +1,242 @@ +import { expect, test, type Page } from '@playwright/test'; +import { clickAt, dragBetween, selectTool } from './coords'; +import { disableSaveInPlace } from './save'; + +/** + * 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 }); + + // Headless Chromium has File System Access, so the app would open a picker and + // this would wait for a download that never comes. These round-trip tests are + // about the container, not about which of the two ways out wrote it — the save + // paths themselves are covered in `persistence.spec.ts`. + await disableSaveInPlace(page); + + 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'); + }); +}); + +test.describe('clicking through the stack in 3D', () => { + test('selects the wall on the floor you are editing, not the storey in front of it', async ({ + page, + }) => { + // A solid on another floor must decline the click *before* stopping propagation. + // R3F calls every intersected mesh in distance order until one stops it, so a + // scenery solid that stopped first and declined second would eat the click on the + // wall behind it — and looking at a building with every floor shown, the top + // storey would swallow everything. + await drawRoom(page, { x: 6000, y: 5000 }); + await page.getByTestId('add-floor-above').click(); + // Directly over the ground floor, so an upstairs wall really is in the way. + await drawRoom(page, { x: 6000, y: 5000 }); + await page.getByTestId('floor-picker').selectOption({ label: 'Ground' }); + + await page.getByRole('button', { name: 'Space', exact: true }).click(); + await expect(page.getByTestId('space-view')).toBeVisible(); + await page.getByTestId('floors-all').click(); + + const canvas = page.locator('.space__canvas canvas'); + const box = (await canvas.boundingBox())!; + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + + await expect(page.getByTestId('wall-properties')).toBeVisible(); + }); +}); diff --git a/e2e/inventory.spec.ts b/e2e/inventory.spec.ts new file mode 100644 index 0000000..8c41870 --- /dev/null +++ b/e2e/inventory.spec.ts @@ -0,0 +1,303 @@ +import { expect, test, type Page } from '@playwright/test'; +import { clickAt, dragBetween, selectTool } from './coords'; +import { makePng } from './fixtures'; + +/** + * Inventory and placement — PLAN.md §4.3, §7, §9. + * + * The screen-to-document mapping comes from `./coords`; nothing here zooms, pans or + * fits, so it holds throughout. + */ + +/** Draw a 4m × 3m room, which also gives us four walls to snap against. */ +async function drawRoom(page: Page) { + const stage = page.getByTestId('plan-stage'); + await selectTool(page, 'Room'); + await dragBetween(page, stage, { x: 0, y: 0 }, { x: 4000, y: 3000 }); + await expect(page.getByTestId('count-rooms')).toContainText('1'); + await expect(page.getByTestId('count-walls')).toContainText('4'); +} + +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]!); +} + +/** Arm an item for placing. This also switches into furnish mode. */ +async function armFirstItem(page: Page) { + const place = page.getByRole('button', { name: 'Place', exact: true }).first(); + await place.click(); + await expect(page.getByTestId('placing-note')).toBeVisible(); +} + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('plan-stage')).toBeVisible(); +}); + +test.describe('the catalog', () => { + test('adds a standard size in one click', async ({ page }) => { + await addPreset(page, 'Beds — Queen bed'); + + const row = page.getByTestId('item-row').first(); + // A US queen is 60" x 80", and it starts unplaced. + await expect(row).toContainText(`5' 0"`); + await expect(row).toContainText(`6' 8"`); + await expect(page.getByTestId('item-count')).toHaveText('0/1'); + }); + + test('accepts a hand-entered item in any unit', async ({ page }) => { + await page.getByTestId('add-item').click(); + await page.getByLabel('Item name').fill('Workbench'); + await page.getByLabel('Width').fill('1.8m'); + await page.getByLabel('Depth').fill('30"'); + await page.getByLabel('Height').fill('900'); + // Every mount is reachable now that walls can host and there is a ceiling to + // hang from. An item that says "wall" lands on a wall when dropped near one, and + // on the floor with a note when there is none — never on a wall it guessed. + await expect(page.getByLabel('Mount').locator('option[value="wall"]')).not.toHaveAttribute( + 'disabled', + '', + ); + await page.getByRole('button', { name: 'Add', exact: true }).click(); + + await expect(page.getByTestId('item-list')).toContainText('Workbench'); + // 1.8m and 30in, echoed back in the document's own unit. + await expect(page.getByTestId('item-row').first()).toContainText(`5' 10.875"`); + }); + + test('refuses an item with no solid part left, and says why', async ({ page }) => { + // A void as tall as the object inverts its span, so it would collide with + // nothing at all and quietly stop being checked. + await page.getByTestId('add-item').click(); + await page.getByLabel('Item name').fill('Impossible table'); + await page.getByLabel('Width').fill('1800mm'); + await page.getByLabel('Depth').fill('900mm'); + await page.getByLabel('Height').fill('760mm'); + await page.getByLabel('Open below').fill('900mm'); + await page.getByRole('button', { name: 'Add', exact: true }).click(); + + await expect(page.getByTestId('inventory-error')).toContainText('less than the height'); + await expect(page.getByTestId('item-list')).toBeEmpty(); + }); + + test('catches a units mistake instead of building a 45m table', async ({ page }) => { + // A bare number follows the document's display unit, so in a ft-in space "1800" + // is 1800 inches. The field echoes back what it understood, and the size guard + // is the backstop when nobody reads it. + await page.getByTestId('add-item').click(); + await page.getByLabel('Item name').fill('Table'); + await page.getByLabel('Width').fill('1800'); + await page.getByLabel('Depth').fill('900'); + await page.getByLabel('Height').fill('760'); + await page.getByRole('button', { name: 'Add', exact: true }).click(); + + await expect(page.getByTestId('inventory-error')).toContainText('check the units'); + }); + + test('keeps placements when the item they point at is edited', async ({ page }) => { + await addPreset(page, 'Seating — Armchair'); + await armFirstItem(page); + await clickAt(page, page.getByTestId('plan-stage'), { x: 2000, y: 1500 }); + await expect(page.getByTestId('count-placements')).toContainText('1'); + + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: 'Edit Armchair' }).click(); + await page.getByLabel('Item name').fill('Reading chair'); + await page.getByTestId('item-form').getByRole('button', { name: 'Save' }).click(); + + // The id survives an edit, so the placement is not orphaned. + await expect(page.getByTestId('count-placements')).toContainText('1'); + 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); + const stage = page.getByTestId('plan-stage'); + await clickAt(page, stage, { x: 1000, y: 1000 }); + await clickAt(page, stage, { x: 2000, y: 1000 }); + await expect(page.getByTestId('count-placements')).toContainText('2'); + + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: 'Remove Dining chair' }).click(); + await expect(page.getByTestId('count-placements')).toContainText('0'); + }); +}); + +test.describe('placing', () => { + test('tracks owned against placed', async ({ page }) => { + // "I own 6, 4 are placed" is the whole reason the catalog and the placements are + // separate things. + await addPreset(page, 'Seating — Dining chair'); + await armFirstItem(page); + + const stage = page.getByTestId('plan-stage'); + for (const x of [500, 1500, 2500, 3500]) await clickAt(page, stage, { x, y: 2500 }); + + await expect(page.getByTestId('count-placements')).toContainText('4'); + await expect(page.getByTestId('item-count')).toHaveText('4/1'); + + await page.getByLabel('Quantity of Dining chair owned').fill('6'); + await expect(page.getByTestId('item-count')).toHaveText('4/6'); + }); + + test('snaps flush to a wall and turns to face it', async ({ page }) => { + await drawRoom(page); + await addPreset(page, 'Seating — Sofa (3-seat)'); + await armFirstItem(page); + + // Just inside the room's left wall, which runs vertically. A sofa dropped here + // without a wall snap would stay at rotation 0; only the snap turns it. + await clickAt(page, page.getByTestId('plan-stage'), { x: 300, y: 1500 }); + + await expect(page.getByTestId('placement-properties')).toBeVisible(); + await expect(page.getByTestId('placement-rotation')).toHaveText('270°'); + }); + + test('re-snaps to a wall when dragged toward one', async ({ page }) => { + // The composition the unit tests cannot reach: a real snap context built from + // the document, fed through a drag that re-solves the snap every frame. + await drawRoom(page); + await addPreset(page, 'Seating — Sofa (3-seat)'); + await armFirstItem(page); + + const stage = page.getByTestId('plan-stage'); + // Drop it in open floor, well away from every wall. + await clickAt(page, stage, { x: 2000, y: 1500 }); + await expect(page.getByTestId('placement-rotation')).toHaveText('0°'); + await page.keyboard.press('Escape'); + + // Now drag it against the left wall, which runs vertically. + await dragBetween(page, stage, { x: 2000, y: 1500 }, { x: 300, y: 1500 }); + + await expect(page.getByTestId('placement-properties')).toBeVisible(); + await expect(page.getByTestId('placement-rotation')).toHaveText('270°'); + }); + + test('rotates from the properties panel and the keyboard', async ({ page }) => { + await addPreset(page, 'Storage — Dresser'); + await armFirstItem(page); + await clickAt(page, page.getByTestId('plan-stage'), { x: 2000, y: 2000 }); + await page.keyboard.press('Escape'); + + // Escape cleared the selection too, so reselect by clicking it. + await clickAt(page, page.getByTestId('plan-stage'), { x: 2000, y: 2000 }); + await expect(page.getByTestId('placement-rotation')).toHaveText('0°'); + + await page.getByRole('button', { name: 'Rotate right' }).click(); + await expect(page.getByTestId('placement-rotation')).toHaveText('15°'); + + await page.keyboard.press(']'); + await expect(page.getByTestId('placement-rotation')).toHaveText('30°'); + + await page.keyboard.press('['); + await page.keyboard.press('['); + await expect(page.getByTestId('placement-rotation')).toHaveText('0°'); + }); + + test('a drag across the plan is one undo step', async ({ page }) => { + await addPreset(page, 'Storage — Dresser'); + await armFirstItem(page); + const stage = page.getByTestId('plan-stage'); + await clickAt(page, stage, { x: 2000, y: 2000 }); + await page.keyboard.press('Escape'); + + const before = await page.getByTestId('history-readout').textContent(); + await dragBetween(page, stage, { x: 2000, y: 2000 }, { x: 3500, y: 500 }); + + // One entry for the whole drag, not one per mousemove. + const undos = (n: string) => Number(n.split(' ')[0]); + expect(undos((await page.getByTestId('history-readout').textContent())!)).toBe( + undos(before!) + 1, + ); + }); +}); + +test.describe('validation', () => { + test('warns about two solid things in the same place, and never blocks it', async ({ page }) => { + // Armchairs, not dressers: a dresser can host things on top, so a second one + // dropped on it would surface-mount rather than collide. + await addPreset(page, 'Seating — Armchair'); + await armFirstItem(page); + + const stage = page.getByTestId('plan-stage'); + await clickAt(page, stage, { x: 2000, y: 2000 }); + await clickAt(page, stage, { x: 2100, y: 2000 }); + + // Both were placed. The app said so and got out of the way. + await expect(page.getByTestId('count-placements')).toContainText('2'); + await expect(page.getByTestId('issue-list')).toContainText('overlaps'); + }); + + test('stacks onto something that can host, rather than reporting a collision', async ({ + page, + }) => { + await addPreset(page, 'Storage — Dresser'); + await armFirstItem(page); + const stage = page.getByTestId('plan-stage'); + await clickAt(page, stage, { x: 2000, y: 2000 }); + await clickAt(page, stage, { x: 2000, y: 2000 }); + + await expect(page.getByTestId('count-placements')).toContainText('2'); + await expect(page.getByTestId('placement-properties')).toContainText('On a surface'); + await expect(page.getByTestId('no-issues')).toBeVisible(); + }); + + test('does not call a rug under a table a collision', async ({ page }) => { + // Footprints overlap completely; solid spans do not. This is the case the + // vertical axis exists for. + await addPreset(page, 'Other — Rug (8 x 10 ft)'); + await armFirstItem(page); + const stage = page.getByTestId('plan-stage'); + await clickAt(page, stage, { x: 2000, y: 2000 }); + await page.keyboard.press('Escape'); + + await addPreset(page, 'Tables — Dining table (6)'); + await page.getByRole('button', { name: 'Place', exact: true }).nth(1).click(); + await clickAt(page, stage, { x: 2000, y: 2000 }); + + await expect(page.getByTestId('count-placements')).toContainText('2'); + await expect(page.getByTestId('no-issues')).toBeVisible(); + }); +}); + +test.describe('the calibration gate', () => { + test('will not let anything be placed on an unscaled plan', async ({ page }) => { + await addPreset(page, 'Storage — Dresser'); + await expect(page.getByRole('button', { name: 'Place', exact: true })).toBeEnabled(); + + await page.getByLabel('Import a floor plan').setInputFiles({ + name: 'plan.png', + mimeType: 'image/png', + buffer: makePng(400, 300), + }); + await expect(page.getByTestId('calibration-gate')).toBeVisible(); + + // The reason is stated in both places a person might look, and the action that + // would fail is not offered. `addPlacement` still throws underneath — that is + // the enforcement, and it is pinned by the unit tests. + await expect(page.getByTestId('inventory-blocked')).toContainText('calibrated'); + await expect(page.getByTestId('placement-blocked')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Place', exact: true })).toBeDisabled(); + await expect(page.getByTestId('count-placements')).toContainText('0'); + }); +}); diff --git a/e2e/openings.spec.ts b/e2e/openings.spec.ts new file mode 100644 index 0000000..24e410b --- /dev/null +++ b/e2e/openings.spec.ts @@ -0,0 +1,256 @@ +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'); + }); +}); + +test.describe('hanging the leaf', () => { + /** A room with a door in the middle of its north wall, left selected. */ + async function roomWithDoor(page: Page) { + const stage = await room(page); + await selectTool(page, 'Opening'); + await clickAt(page, stage, { x: 2500, y: 0 }); + await expect(page.getByTestId('opening-swing')).toBeVisible(); + return stage; + } + + /** Put a chest of drawers in the middle of the room, 400mm off the north wall. */ + async function dresser(page: Page, stage: ReturnType) { + await page.getByTestId('add-item').click(); + // Scoped to the form: the selected door's panel has a Width field too. + const form = page.getByTestId('item-form'); + await form.getByLabel('Item name').fill('Dresser'); + await form.getByLabel('Width').fill('1500mm'); + await form.getByLabel('Depth').fill('500mm'); + await form.getByLabel('Height').fill('810mm'); + await form.getByRole('button', { name: 'Add', exact: true }).click(); + await page.getByRole('button', { name: 'Place', exact: true }).click(); + await clickAt(page, stage, { x: 2500, y: 400 }); + } + + test('hangs a new door the ordinary way, with a swing to edit', async ({ page }) => { + await roomWithDoor(page); + + const panel = page.getByTestId('opening-properties'); + await expect(panel).toContainText('Hinged'); + await expect(panel).toContainText('at wall start'); + await expect(page.getByTestId('swing-angle')).toHaveValue('90'); + }); + + test('flips the hinge to the other jamb', async ({ page }) => { + await roomWithDoor(page); + + await page.getByTestId('flip-hinge').click(); + await expect(page.getByTestId('opening-properties')).toContainText('at wall end'); + + // One undo step, like every other edit. + await page.keyboard.press('Control+z'); + await expect(page.getByTestId('opening-properties')).toContainText('at wall start'); + }); + + test('takes an angle typed a digit at a time', async ({ page }) => { + // Typed rather than filled. The stored angle is clamped at 15, so a field that + // wrote per keystroke would turn the `1` of `135` into `15` and swallow the rest + // — and `fill` is the one input path that never notices, because it delivers the + // whole value in a single change event. + await roomWithDoor(page); + + const angle = page.getByTestId('swing-angle'); + await angle.selectText(); + await angle.pressSequentially('135'); + await angle.press('Enter'); + await expect(angle).toHaveValue('135'); + + // And one undo step for the whole edit, not one per character. + await page.keyboard.press('Control+z'); + await expect(angle).toHaveValue('90'); + }); + + test('clamps an angle no door could open to', async ({ page }) => { + await roomWithDoor(page); + + const angle = page.getByTestId('swing-angle'); + await angle.fill('500'); + await angle.press('Enter'); + await expect(angle).toHaveValue('180'); + }); + + test('reports furniture standing in the swing, and stops once the door is turned around', async ({ + page, + }) => { + const stage = await roomWithDoor(page); + await dresser(page, stage); + + await expect(page.getByTestId('issue-list')).toContainText('cannot open fully'); + await expect(page.getByTestId('issue-list')).toContainText('Dresser'); + + // Hang the door to open the other way and the dresser is no longer in it. + await page.getByRole('button', { name: 'Edit floor plan', exact: true }).click(); + await selectTool(page, 'Select'); + await clickAt(page, stage, { x: 2500, y: 0 }); + await page.getByTestId('flip-side').click(); + + // Nothing left to report at all, so the list is gone rather than empty. + await expect(page.getByTestId('no-issues')).toBeVisible(); + }); + + test('asks nothing of the room once the door slides into the wall', async ({ page }) => { + // The whole argument for a pocket door: the leaf goes inside the wall, so the + // dresser beside it stops being a problem without moving anything. + const stage = await roomWithDoor(page); + await dresser(page, stage); + await expect(page.getByTestId('issue-list')).toContainText('cannot open fully'); + + await page.getByRole('button', { name: 'Edit floor plan', exact: true }).click(); + await selectTool(page, 'Select'); + await clickAt(page, stage, { x: 2500, y: 0 }); + await page.getByLabel('Opening kind').selectOption('pocket'); + + await expect(page.getByTestId('no-issues')).toBeVisible(); + await expect(page.getByTestId('swing-angle')).toHaveCount(0); // nothing to swing + }); + + test('reports a pocket door with no wall to slide into', async ({ page }) => { + const stage = await room(page); + await selectTool(page, 'Opening'); + await clickAt(page, stage, { x: 400, y: 0 }); // hard against the corner + + await page.getByLabel('Opening kind').selectOption('pocket'); + await expect(page.getByTestId('issue-list')).toContainText('to slide into'); + }); + + test('offers no swing at all on a cased opening, and gives it back on the way out', async ({ + page, + }) => { + await roomWithDoor(page); + await page.getByTestId('flip-hinge').click(); + + await page.getByLabel('Opening kind').selectOption('cased'); + await expect(page.getByTestId('opening-swing')).toHaveCount(0); + await expect(page.getByTestId('opening-properties')).toContainText('No leaf'); + + // The hinge you chose survives the round trip — the document keeps the field + // even while the kind does not read it. + await page.getByLabel('Opening kind').selectOption('door'); + await expect(page.getByTestId('opening-properties')).toContainText('at wall end'); + }); +}); diff --git a/e2e/persistence.spec.ts b/e2e/persistence.spec.ts new file mode 100644 index 0000000..c0b6227 --- /dev/null +++ b/e2e/persistence.spec.ts @@ -0,0 +1,297 @@ +import { expect, test, type Page } from '@playwright/test'; +import { unzipSync } from 'fflate'; +import { clickAt, selectTool } from './coords'; +import { + disableSaveInPlace, + pickerCount, + savedBytes, + savedName, + stubSaveInPlace, + writeCount, +} from './save'; + +/** + * Saving, autosaving and getting work back — PLAN.md §5. + * + * The claim under test is behavioural and not visible in a screenshot: "the handle is + * retained in memory so Ctrl+S is a true save-in-place, no download prompt". A unit + * test with a fake handle passes even when the wiring re-prompts every time, so the + * discriminating assertion is the *count* of picker openings across two saves. + */ + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('plan-stage')).toBeVisible(); +}); + +async function drawWall(page: Page, y = 0) { + const stage = page.getByTestId('plan-stage'); + await selectTool(page, 'Wall'); + await clickAt(page, stage, { x: 0, y }); + await clickAt(page, stage, { x: 4000, y }); + await page.keyboard.press('Enter'); +} + +/** + * Save, and wait for the write to land. + * + * The click resolves when the handler is *called*; building the container and + * encoding the thumbnail are both async. Reading the bytes straight after the click + * races that, and the cleared dirty flag is the app's own signal that the write + * resolved — there is nothing to poll that is closer to the truth. + */ +async function saveAndSettle(page: Page, testId = 'save-file') { + await page.getByTestId(testId).click(); + await expect(page.getByTestId('dirty-flag')).toBeEmpty(); +} + +async function nameIt(page: Page, name: string) { + const title = page.getByLabel('Space name'); + await title.fill(name); + await title.press('Enter'); +} + +test.describe('saving in place', () => { + test('asks where to save once, and never again for that document', async ({ page }) => { + await stubSaveInPlace(page); + await drawWall(page); + await nameIt(page, 'Maple Street'); + + await saveAndSettle(page); + expect(await pickerCount(page)).toBe(1); + expect(await savedName(page)).toBe('Maple-Street.space'); + + // The second save is the whole feature. A wiring that re-prompted here would + // still write the right bytes, and a unit test with a fake handle would still + // pass — the count is the only thing that can tell the two apart. + await drawWall(page, 2000); + await expect(page.getByTestId('dirty-flag')).toHaveText('•'); + await page.keyboard.press('Control+s'); + + await expect(page.getByTestId('dirty-flag')).toBeEmpty(); + expect(await pickerCount(page)).toBe(1); + expect(await writeCount(page)).toBe(2); + }); + + test('asks again when you explicitly say save as', async ({ page }) => { + await stubSaveInPlace(page); + await drawWall(page); + await saveAndSettle(page); + expect(await pickerCount(page)).toBe(1); + + // Without this the retained handle is a trap: once a document has a file there + // would be no way to write it anywhere else. + // + // Polled rather than read once: the document is already clean, so the dirty flag + // settles instantly and says nothing about this save — and the picker opens only + // after the container bytes and the thumbnail have been built. + await page.getByTestId('save-file-as').click(); + await expect.poll(() => pickerCount(page)).toBe(2); + }); + + test('a cancelled save leaves the document unsaved and says nothing', async ({ page }) => { + await stubSaveInPlace(page, { cancel: true }); + await drawWall(page); + + let dialogs = 0; + page.on('dialog', (d) => { + dialogs++; + void d.dismiss(); + }); + + await page.getByTestId('save-file').click(); + + // Dismissing the picker is a decision, not a failure. An alert would report a + // problem that did not happen, and a cleared dirty flag would claim a file + // exists that does not. + await expect(page.getByTestId('dirty-flag')).toHaveText('•'); + expect(dialogs).toBe(0); + expect(await writeCount(page)).toBe(0); + }); + + test('falls back to a download where there is no File System Access', async ({ page }) => { + // Firefox and Safari. Not a degraded mode to apologise for — it is what proved + // the portability requirement through every phase before this one. + await disableSaveInPlace(page); + await drawWall(page); + await nameIt(page, 'Maple Street'); + + const downloadPromise = page.waitForEvent('download'); + await page.getByTestId('save-file').click(); + const download = await downloadPromise; + + expect(download.suggestedFilename()).toBe('Maple-Street.space'); + await expect(page.getByTestId('dirty-flag')).toBeEmpty(); + }); +}); + +test.describe('the container', () => { + test('round-trips through the bytes a save-in-place wrote', async ({ page }) => { + await stubSaveInPlace(page); + await drawWall(page); + await drawWall(page, 2000); + await nameIt(page, 'Two Walls'); + await saveAndSettle(page); + + const bytes = await savedBytes(page); + + await page.goto('/'); + await expect(page.getByTestId('count-walls')).toContainText('0'); + await page.getByLabel('Open a .space file').setInputFiles({ + name: 'Two-Walls.space', + mimeType: 'application/zip', + buffer: bytes, + }); + + await expect(page.getByTestId('count-walls')).toContainText('2'); + await expect(page.getByLabel('Space name')).toHaveValue('Two Walls'); + }); + + test('carries a thumbnail', async ({ page }) => { + await stubSaveInPlace(page); + await drawWall(page); + await saveAndSettle(page); + + const entries = unzipSync(new Uint8Array(await savedBytes(page))); + expect(Object.keys(entries)).toContain('thumbnail.png'); + // A PNG header alone would satisfy "present". Something was actually drawn. + expect(entries['thumbnail.png']!.byteLength).toBeGreaterThan(200); + expect(Array.from(entries['thumbnail.png']!.slice(0, 4))).toEqual([0x89, 0x50, 0x4e, 0x47]); + }); + + test('an empty document saves without a thumbnail rather than a blank square', async ({ + page, + }) => { + await stubSaveInPlace(page); + await nameIt(page, 'Nothing Yet'); + await saveAndSettle(page); + + const entries = unzipSync(new Uint8Array(await savedBytes(page))); + expect(Object.keys(entries)).not.toContain('thumbnail.png'); + expect(Object.keys(entries)).toContain('document.json'); + }); +}); + +test.describe('autosave and recovery', () => { + test('offers work back after the tab dies', async ({ page }) => { + await drawWall(page); + await nameIt(page, 'Unsaved Flat'); + + // The quiet debounce is 2s; wait for the write rather than for a wall-clock + // guess, so a slower machine does not turn this into a flake. + await expect + .poll(async () => page.evaluate(autosaveCount), { timeout: 15_000 }) + .toBeGreaterThan(0); + + await page.reload(); + await expect(page.getByTestId('plan-stage')).toBeVisible(); + // A reload is a genuinely empty editor — nothing is carried in memory. + await expect(page.getByTestId('count-walls')).toContainText('0'); + + const banner = page.getByTestId('recovery-banner'); + await expect(banner).toBeVisible(); + await expect(banner).toContainText('Unsaved Flat'); + + await page.getByTestId('recovery-restore').click(); + await expect(page.getByTestId('count-walls')).toContainText('1'); + // Never written to a file, so it arrives with unsaved changes. Opening it clean + // would let the user close the tab a second time on the same work. + await expect(page.getByTestId('dirty-flag')).toHaveText('•'); + }); + + test('does not offer work that was saved to a file', async ({ page }) => { + // The rule that keeps the prompt worth reading. Without it every clean reload + // greets you with an offer to recover something you already saved, and the + // prompt is trained out of you long before the one time it matters. + await stubSaveInPlace(page); + await drawWall(page); + await expect + .poll(async () => page.evaluate(autosaveCount), { timeout: 15_000 }) + .toBeGreaterThan(0); + + await saveAndSettle(page); + await expect.poll(async () => page.evaluate(autosaveCount), { timeout: 15_000 }).toBe(0); + + await page.reload(); + await expect(page.getByTestId('plan-stage')).toBeVisible(); + await expect(page.getByTestId('recovery-banner')).toBeHidden(); + }); + + test('a discarded offer does not come back', async ({ page }) => { + await drawWall(page); + await expect + .poll(async () => page.evaluate(autosaveCount), { timeout: 15_000 }) + .toBeGreaterThan(0); + + await page.reload(); + await page.getByTestId('recovery-discard').click(); + await expect(page.getByTestId('recovery-banner')).toBeHidden(); + + await page.reload(); + await expect(page.getByTestId('plan-stage')).toBeVisible(); + await expect(page.getByTestId('recovery-banner')).toBeHidden(); + }); +}); + +test.describe('dropping a file on the window', () => { + test('opens a dropped .space', async ({ page }) => { + await stubSaveInPlace(page); + await drawWall(page); + await nameIt(page, 'Dropped Space'); + await saveAndSettle(page); + const bytes = await savedBytes(page); + + await page.goto('/'); + await expect(page.getByTestId('count-walls')).toContainText('0'); + + const transfer = await page.evaluateHandle((data) => { + const dt = new DataTransfer(); + dt.items.add( + new File([new Uint8Array(data)], 'Dropped-Space.space', { type: 'application/zip' }), + ); + return dt; + }, Array.from(bytes)); + + await page.dispatchEvent('body', 'drop', { dataTransfer: transfer }); + + await expect(page.getByTestId('count-walls')).toContainText('1'); + await expect(page.getByLabel('Space name')).toHaveValue('Dropped Space'); + }); + + test('shows an overlay while a file is over the window', async ({ page }) => { + const transfer = await page.evaluateHandle(() => { + const dt = new DataTransfer(); + dt.items.add(new File(['x'], 'plan.png', { type: 'image/png' })); + return dt; + }); + + await expect(page.getByTestId('drop-overlay')).toBeHidden(); + await page.dispatchEvent('body', 'dragover', { dataTransfer: transfer }); + await expect(page.getByTestId('drop-overlay')).toBeVisible(); + }); +}); + +/** How many autosave records the database holds. Runs in the page. */ +function autosaveCount(): Promise { + return new Promise((resolve) => { + const open = indexedDB.open('floorplan', 1); + open.onerror = () => resolve(0); + open.onsuccess = () => { + const db = open.result; + if (!db.objectStoreNames.contains('documents')) { + db.close(); + resolve(0); + return; + } + const request = db.transaction('documents', 'readonly').objectStore('documents').count(); + request.onsuccess = () => { + db.close(); + resolve(request.result); + }; + request.onerror = () => { + db.close(); + resolve(0); + }; + }; + }); +} diff --git a/e2e/plan-editor.spec.ts b/e2e/plan-editor.spec.ts index 32939a0..b5b29c8 100644 --- a/e2e/plan-editor.spec.ts +++ b/e2e/plan-editor.spec.ts @@ -1,63 +1,6 @@ -import { expect, test, type Locator, type Page } from '@playwright/test'; - -/** - * These specs click screen pixels and assert document millimetres, which only works - * because a fresh document opens at a fixed viewport (see `DEFAULT_VIEWPORT`): scale - * 0.05 px/mm, document origin at 120,100 inside the stage. Keep the two in step. - * - * One screen pixel is 20mm at that scale, so a half-pixel of rounding is 10mm — under - * half the 25mm snap grid, which is what makes the coordinates below land exactly. - * - * The mapping holds for a fresh page only. Opening a file calls `zoomToFit`, and - * anything that zooms, pans or fits invalidates it — do not click document - * coordinates after one of those without re-deriving the transform. - */ -const SCALE = 0.05; -const ORIGIN = { x: 120, y: 100 }; - -async function docToPage(stage: Locator, mm: { x: number; y: number }) { - const box = await stage.boundingBox(); - if (!box) throw new Error('plan stage has no bounding box'); - return { - x: box.x + ORIGIN.x + mm.x * SCALE, - y: box.y + ORIGIN.y + mm.y * SCALE, - }; -} - -async function clickAt(page: Page, stage: Locator, mm: { x: number; y: number }) { - const p = await docToPage(stage, mm); - await page.mouse.click(p.x, p.y); -} - -/** - * Pick a tool and wait for it to be current. - * - * A canvas click sent immediately after the button click can arrive before React has - * committed the render that makes the structure layer listen — Playwright waits for - * the DOM click, not for the frame after it. Asserting the pressed state gates on - * that render without a sleep. - */ -async function selectTool(page: Page, name: string) { - const button = page.getByRole('button', { name, exact: true }); - await button.click(); - await expect(button).toHaveAttribute('aria-pressed', 'true'); -} - -async function dragBetween( - page: Page, - stage: Locator, - from: { x: number; y: number }, - to: { x: number; y: number }, -) { - const a = await docToPage(stage, from); - const b = await docToPage(stage, to); - await page.mouse.move(a.x, a.y); - await page.mouse.down(); - // Two intermediate moves: one to start the rubber band, one to prove it tracks. - await page.mouse.move((a.x + b.x) / 2, (a.y + b.y) / 2); - await page.mouse.move(b.x, b.y); - await page.mouse.up(); -} +import { expect, test } from '@playwright/test'; +import { clickAt, docToPage, dragBetween, selectTool } from './coords'; +import { disableSaveInPlace } from './save'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -124,7 +67,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', ); @@ -184,7 +127,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 }); @@ -253,7 +196,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'); @@ -280,8 +223,15 @@ test.describe('portability', () => { await title.fill('Maple Street'); await title.press('Enter'); + // Headless Chromium has File System Access, so the app would open a picker and + // this would wait for a download that never comes. These round-trip tests are + // about the container, not about which of the two ways out wrote it — the save + // paths themselves are covered in `persistence.spec.ts`. + await disableSaveInPlace(page); + const downloadPromise = page.waitForEvent('download'); - await page.getByRole('button', { name: 'Save' }).click(); + // Exact: "Save as…" is also a button, and a substring match takes both. + await page.getByRole('button', { name: 'Save', exact: true }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe('Maple-Street.space'); @@ -301,7 +251,15 @@ test.describe('portability', () => { }); test('reports an unreadable file instead of failing silently', async ({ page }) => { - page.on('dialog', (d) => void d.accept()); + // Collected rather than just accepted: asserting the count is unchanged proves + // nothing on its own, since it was already zero — the only thing that could fail + // it is a dialog left open blocking the page, which reads as a mystery timeout. + // What this test is actually about is that the app *said something*. + const dialogs: string[] = []; + page.on('dialog', (d) => { + dialogs.push(d.message()); + void d.accept(); + }); await page.getByLabel('Open a .space file').setInputFiles({ name: 'broken.space', @@ -309,7 +267,10 @@ test.describe('portability', () => { buffer: Buffer.from('this is not a zip'), }); - // The document already open is left exactly as it was. + await expect.poll(() => dialogs.length).toBe(1); + expect(dialogs[0]).toContain('not a readable .space container'); + + // And the document already open is left exactly as it was. await expect(page.getByTestId('count-walls')).toContainText('0'); }); }); diff --git a/e2e/plan-import.spec.ts b/e2e/plan-import.spec.ts new file mode 100644 index 0000000..01ba301 --- /dev/null +++ b/e2e/plan-import.spec.ts @@ -0,0 +1,248 @@ +import { expect, test, type Locator, type Page } from '@playwright/test'; +import { docToPage } from './coords'; +import { disableSaveInPlace } from './save'; +import { makePdf, makePng } from './fixtures'; + +/** + * Import and the calibration gate — PLAN.md §6.1. + * + * The numbers here are load-bearing and worth stating once: + * + * The fixture is **400 × 300 px**. An uncalibrated plan is shown at the nominal + * width of 6000mm, so its provisional scale is 6000/400 = **15 mm/px**, and the + * raster's left edge sits at the document origin. + * + * A reference dragged from document 0mm to 3000mm therefore spans image pixels + * 0…200. Declaring that 50ft (15240mm) gives 15240/200 = **76.2 mm/px**, and the + * plan becomes 400 × 76.2 = 30480mm wide — exactly 100 feet, which is what the + * status bar is asserted to read. + * + * The screen-to-document mapping comes from `./coords`; import deliberately does not + * touch the viewport, so it still holds after one. + */ +const PLAN_PNG = { width: 400, height: 300 }; + +async function importPng(page: Page, name = 'plan.png') { + await page.getByLabel('Import a floor plan').setInputFiles({ + name, + mimeType: 'image/png', + buffer: makePng(PLAN_PNG.width, PLAN_PNG.height), + }); + await expect(page.getByTestId('calibration-gate')).toBeVisible(); +} + +/** Drag the reference line across a known span of the plan, in document mm. */ +async function drawReference( + page: Page, + stage: Locator, + from: { x: number; y: number }, + to: { x: number; y: number }, +) { + const a = await docToPage(stage, from); + const b = await docToPage(stage, to); + await page.mouse.move(a.x, a.y); + await page.mouse.down(); + await page.mouse.move((a.x + b.x) / 2, (a.y + b.y) / 2); + await page.mouse.move(b.x, b.y); + await page.mouse.up(); + await expect(page.getByTestId('calibration-ref')).toBeVisible(); +} + +async function calibrate(page: Page, stage: Locator, length: string) { + await drawReference(page, stage, { x: 0, y: 0 }, { x: 3000, y: 0 }); + await page.getByTestId('calibration-length').fill(length); + await page.getByRole('button', { name: 'Set scale' }).click(); + await expect(page.getByTestId('calibration-gate')).toBeHidden(); +} + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('plan-stage')).toBeVisible(); +}); + +test.describe('the calibration gate', () => { + test('blocks the editor until the plan has a scale', async ({ page }) => { + await importPng(page); + + // Blocking is the point. A plan with no scale produces walls whose lengths mean + // nothing, and nothing downstream can correct them afterwards. + await expect(page.getByRole('button', { name: 'Wall', exact: true })).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Room', exact: true })).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Select', exact: true })).toBeDisabled(); + await expect(page.getByTestId('background-readout')).toHaveText('Plan uncalibrated'); + await expect(page.getByTestId('placement-blocked')).toBeVisible(); + }); + + test('turns a drawn reference into a real scale', async ({ page }) => { + const stage = page.getByTestId('plan-stage'); + await importPng(page); + + // 3000mm of document under the provisional scale is 200 image pixels. Calling + // that 50ft makes the 400px plan exactly 100ft wide. + await calibrate(page, stage, '50ft'); + + await expect(page.getByTestId('background-readout')).toHaveText(`Plan 100' 0" wide`); + await expect(page.getByTestId('placement-blocked')).toBeHidden(); + await expect(page.getByRole('button', { name: 'Wall', exact: true })).toBeEnabled(); + }); + + test('refuses a reference too short to have been drawn deliberately', async ({ page }) => { + const stage = page.getByTestId('plan-stage'); + await importPng(page); + + // 40mm of document is under three image pixels — a slip, not a measurement. + await drawReference(page, stage, { x: 0, y: 0 }, { x: 40, y: 0 }); + await page.getByTestId('calibration-length').fill('3m'); + await page.getByRole('button', { name: 'Set scale' }).click(); + + await expect(page.getByTestId('calibration-error')).toBeVisible(); + await expect(page.getByTestId('calibration-gate')).toBeVisible(); + await expect(page.getByTestId('background-readout')).toHaveText('Plan uncalibrated'); + }); + + test('rejects a length it cannot parse rather than guessing', async ({ page }) => { + const stage = page.getByTestId('plan-stage'); + await importPng(page); + await drawReference(page, stage, { x: 0, y: 0 }, { x: 3000, y: 0 }); + + await page.getByTestId('calibration-length').fill('about a metre'); + await page.getByRole('button', { name: 'Set scale' }).click(); + + await expect(page.getByTestId('calibration-error')).toBeVisible(); + await expect(page.getByTestId('background-readout')).toHaveText('Plan uncalibrated'); + }); + + test('discarding at the gate leaves no half-imported plan behind', async ({ page }) => { + await importPng(page); + await page.getByRole('button', { name: 'Discard plan' }).click(); + + // Keeping an uncalibrated plan would leave the document permanently unable to + // accept placements, with nothing on screen explaining why. + await expect(page.getByTestId('calibration-gate')).toBeHidden(); + await expect(page.getByTestId('background-readout')).toBeHidden(); + await expect(page.getByTestId('placement-blocked')).toBeHidden(); + await expect(page.getByRole('button', { name: 'Wall', exact: true })).toBeEnabled(); + }); + + test('reopens for a recalibration, and cancelling keeps the old scale', async ({ page }) => { + const stage = page.getByTestId('plan-stage'); + await importPng(page); + await calibrate(page, stage, '50ft'); + + await page.getByTestId('recalibrate').click(); + await expect(page.getByTestId('calibration-gate')).toBeVisible(); + await page.getByRole('button', { name: 'Cancel' }).click(); + + await expect(page.getByTestId('background-readout')).toHaveText(`Plan 100' 0" wide`); + }); +}); + +test.describe('importing', () => { + test('says so when the file is not a format it can read', async ({ page }) => { + const dialogs: string[] = []; + page.on('dialog', (d) => { + dialogs.push(d.message()); + void d.accept(); + }); + + await page.getByLabel('Import a floor plan').setInputFiles({ + name: 'notes.txt', + mimeType: 'image/png', // the browser lies; the magic bytes do not + buffer: Buffer.from('this is not an image'), + }); + + await expect.poll(() => dialogs.length).toBe(1); + expect(dialogs[0]).toContain('PDF, PNG, JPEG or WebP'); + await expect(page.getByTestId('calibration-gate')).toBeHidden(); + }); + + test('asks which page of a multi-page PDF to trace', async ({ page }) => { + await page.getByLabel('Import a floor plan').setInputFiles({ + name: 'floors.pdf', + mimeType: 'application/pdf', + buffer: makePdf(3), + }); + + const picker = page.getByTestId('page-picker'); + await expect(picker).toBeVisible(); + await expect(picker).toContainText('3 pages'); + + await page.getByTestId('page-number').fill('2'); + await page.getByRole('button', { name: 'Import page' }).click(); + + // The gate opening is the proof the render reached a raster — which also proves + // the pdfjs worker resolved in the production build the e2e suite runs against. + await expect(page.getByTestId('calibration-gate')).toBeVisible({ timeout: 20_000 }); + await expect(page.getByTestId('background-readout')).toHaveText('Plan uncalibrated'); + }); + + test('does not ask which page when there is only one', async ({ page }) => { + await page.getByLabel('Import a floor plan').setInputFiles({ + name: 'plan.pdf', + mimeType: 'application/pdf', + buffer: makePdf(1), + }); + + await expect(page.getByTestId('calibration-gate')).toBeVisible({ timeout: 20_000 }); + await expect(page.getByTestId('page-picker')).toBeHidden(); + }); + + test('replaces an existing plan rather than stacking one on top', async ({ page }) => { + const stage = page.getByTestId('plan-stage'); + await importPng(page); + await calibrate(page, stage, '50ft'); + + await importPng(page, 'second.png'); + await expect(page.getByTestId('background-readout')).toHaveText('Plan uncalibrated'); + }); +}); + +test.describe('portability', () => { + test('carries the plan into the saved file and back out', async ({ page }) => { + const stage = page.getByTestId('plan-stage'); + await importPng(page); + await calibrate(page, stage, '50ft'); + + // Trace something over it, so the reopened document has to line up as well as + // merely exist. + const wall = page.getByRole('button', { name: 'Wall', exact: true }); + await wall.click(); + await expect(wall).toHaveAttribute('aria-pressed', 'true'); + const a = await docToPage(stage, { x: 1000, y: 2000 }); + const b = await docToPage(stage, { x: 5000, y: 2000 }); + await page.mouse.click(a.x, a.y); + await page.mouse.click(b.x, b.y); + await page.keyboard.press('Enter'); + await expect(page.getByTestId('count-walls')).toContainText('1'); + + const title = page.getByLabel('Space name'); + await title.fill('Traced Plan'); + await title.press('Enter'); + + // Headless Chromium has File System Access, so the app would open a picker and + // this would wait for a download that never comes. These round-trip tests are + // about the container, not about which of the two ways out wrote it — the save + // paths themselves are covered in `persistence.spec.ts`. + await disableSaveInPlace(page); + + const downloadPromise = page.waitForEvent('download'); + // Exact: "Save as…" is also a button, and a substring match takes both. + await page.getByRole('button', { name: 'Save', exact: true }).click(); + const download = await downloadPromise; + const file = await download.path(); + await expect(page.getByTestId('dirty-flag')).toBeEmpty(); + + // A reload is a genuinely empty editor — the asset store is memory, not storage. + await page.reload(); + await expect(page.getByTestId('background-readout')).toBeHidden(); + + await page.getByLabel('Open a .space file').setInputFiles(file); + + // The scale survived, the walls survived, and the raster came back with them: + // `background-missing` is the panel's warning for a manifest with no bytes. + await expect(page.getByTestId('background-readout')).toHaveText(`Plan 100' 0" wide`); + await expect(page.getByTestId('count-walls')).toContainText('1'); + await expect(page.getByTestId('background-missing')).toBeHidden(); + await expect(page.getByTestId('placement-blocked')).toBeHidden(); + }); +}); 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/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/e2e/save.ts b/e2e/save.ts new file mode 100644 index 0000000..d92a914 --- /dev/null +++ b/e2e/save.ts @@ -0,0 +1,95 @@ +import type { Page } from '@playwright/test'; + +/** + * Driving the two save paths from a test. + * + * Headless Chromium *has* `showSaveFilePicker`, so the app takes the in-place path + * there by default and no download ever fires. Both helpers below work only because + * `supportsSaveInPlace()` reads `window` at call time rather than snapshotting it at + * module load — with a snapshot neither of these could reach the branch it wants, and + * one of the two paths would ship with no end-to-end coverage at all. + * + * `showSaveFilePicker` lives on `Window.prototype`, so `delete window.showSaveFilePicker` + * is a no-op; shadowing it with an own property is what actually hides it. + */ + +/** Make the page look like Firefox or Safari: no File System Access. */ +export async function disableSaveInPlace(page: Page): Promise { + await page.evaluate(() => { + Object.defineProperty(window, 'showSaveFilePicker', { + value: undefined, + configurable: true, + writable: true, + }); + }); +} + +/** + * Install a fake picker that keeps the bytes in the page. + * + * Counts how many times it was asked, which is the only way to tell a genuine + * save-in-place from a re-prompt that happens to write the right file. + */ +export async function stubSaveInPlace( + page: Page, + options: { cancel?: boolean } = {}, +): Promise { + await page.evaluate((cancel) => { + const w = window as unknown as Record; + w['__picks'] = 0; + w['__saved'] = null; + w['__savedName'] = null; + w['__writes'] = 0; + + Object.defineProperty(window, 'showSaveFilePicker', { + configurable: true, + writable: true, + value: async (opts: { suggestedName?: string }) => { + w['__picks'] = (w['__picks'] as number) + 1; + if (cancel) { + const err = new Error('The user aborted a request.'); + err.name = 'AbortError'; + throw err; + } + const name = opts?.suggestedName ?? 'untitled.space'; + return { + name, + queryPermission: async () => 'granted', + requestPermission: async () => 'granted', + createWritable: async () => ({ + write: async (blob: Blob) => { + w['__saved'] = Array.from(new Uint8Array(await blob.arrayBuffer())); + w['__savedName'] = name; + w['__writes'] = (w['__writes'] as number) + 1; + }, + close: async () => undefined, + }), + }; + }, + }); + }, options.cancel ?? false); +} + +/** How many times the picker has been opened. */ +export function pickerCount(page: Page): Promise { + return page.evaluate(() => (window as unknown as Record)['__picks'] ?? 0); +} + +export function writeCount(page: Page): Promise { + return page.evaluate(() => (window as unknown as Record)['__writes'] ?? 0); +} + +export function savedName(page: Page): Promise { + return page.evaluate( + () => (window as unknown as Record)['__savedName'] ?? null, + ); +} + +/** The bytes the last in-place save wrote. */ +export async function savedBytes(page: Page): Promise { + const bytes = await page.evaluate( + () => (window as unknown as Record)['__saved'], + ); + if (!bytes) throw new Error('nothing has been saved in place yet'); + return Buffer.from(bytes); +} diff --git a/e2e/shell.spec.ts b/e2e/shell.spec.ts index 7afbb9d..d9e4a0a 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(); @@ -37,8 +37,13 @@ test.describe('app shell', () => { await page.getByRole('button', { name: 'Space', exact: true }).click(); - await expect(page.getByText('Space view')).toBeVisible(); - await expect(page.getByText(/arrow-key traversal/)).toBeVisible(); + // The 3D chunk is loaded on demand, so the view arrives a moment after the click. + await expect(page.getByTestId('space-view')).toBeVisible(); + await expect(page.getByTestId('hud-help')).toBeVisible(); await expect(page.getByTestId('plan-stage')).toHaveCount(0); + + await page.getByRole('button', { name: 'Plan', exact: true }).click(); + await expect(page.getByTestId('plan-stage')).toBeVisible(); + await expect(page.getByTestId('space-view')).toHaveCount(0); }); }); diff --git a/e2e/space.spec.ts b/e2e/space.spec.ts new file mode 100644 index 0000000..c4e2bd6 --- /dev/null +++ b/e2e/space.spec.ts @@ -0,0 +1,274 @@ +import { expect, test, type Page } from '@playwright/test'; +import { clickAt, dragBetween, selectTool } from './coords'; +import { disableSaveInPlace } from './save'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('plan-stage')).toBeVisible(); +}); + +/** A 5m × 4m room with a door in its north wall, then switch to the space view. */ +async function roomWithDoor(page: Page, { door = true } = {}) { + 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'); + + if (door) { + await selectTool(page, 'Opening'); + await clickAt(page, stage, { x: 2500, y: 0 }); + await expect(page.getByTestId('count-openings')).toContainText('1'); + } + + await page.getByRole('button', { name: 'Space', exact: true }).click(); + await expect(page.getByTestId('space-view')).toBeVisible(); +} + +/** + * Hold a key for a while, the way a person walking does. + * + * `press` is a down and an immediate up, which the walk loop reads as a single frame + * of input — a few millimetres. Walking anywhere needs the key actually held. + */ +async function hold(page: Page, key: string, ms: number) { + await page.keyboard.down(key); + await page.waitForTimeout(ms); + await page.keyboard.up(key); +} + +/** The walker's position, parsed back out of the HUD readout. */ +async function walkerAt(page: Page) { + const text = (await page.getByTestId('walker-readout').textContent()) ?? ''; + return text.trim(); +} + +test.describe('the space view', () => { + test('shows where you are standing before you have moved', async ({ page }) => { + await roomWithDoor(page); + await page.getByTestId('camera-walk').click(); + + // Seeded at the middle of the largest room, not at the document origin. + await expect(page.getByTestId('walker-room')).toHaveText('Room 1'); + }); + + test('walks forward when the arrow key is held', async ({ page }) => { + await roomWithDoor(page); + await page.getByTestId('camera-walk').click(); + + await hold(page, 'ArrowUp', 60); // seed the walker + const before = await walkerAt(page); + await hold(page, 'ArrowUp', 400); + + expect(await walkerAt(page)).not.toBe(before); + }); + + test('turns with the left and right arrows', async ({ page }) => { + // Arrows alone have to be enough to get around; without turning you can only + // ever slide along one axis. + await roomWithDoor(page); + await page.getByTestId('camera-walk').click(); + await hold(page, 'ArrowUp', 60); + + const start = await walkerAt(page); + await hold(page, 'ArrowRight', 500); + await hold(page, 'ArrowUp', 400); + + // Having turned, walking forward moves along a different axis than it did. + expect(await walkerAt(page)).not.toBe(start); + }); + + test('gets out through the doorway', async ({ page }) => { + await roomWithDoor(page); + await page.getByTestId('camera-walk').click(); + + await hold(page, 'ArrowUp', 2500); + await expect(page.getByTestId('walker-room')).toHaveText('Unbounded'); + }); + + test('does not get out when the wall is solid', async ({ page }) => { + await roomWithDoor(page, { door: false }); + await page.getByTestId('camera-walk').click(); + + await hold(page, 'ArrowUp', 2500); + await expect(page.getByTestId('walker-room')).toHaveText('Room 1'); + }); + + test('reports crouching, and stops when the key is released', async ({ page }) => { + await roomWithDoor(page); + await page.getByTestId('camera-walk').click(); + await hold(page, 'ArrowUp', 60); + + await page.keyboard.down('c'); + await expect(page.getByTestId('walker-crouching')).toBeVisible(); + await page.keyboard.up('c'); + await expect(page.getByTestId('walker-crouching')).toBeHidden(); + }); + + test('cycles camera modes with Tab', async ({ page }) => { + await roomWithDoor(page); + await expect(page.getByTestId('camera-orbit')).toHaveAttribute('aria-pressed', 'true'); + + await page.keyboard.press('Tab'); + await expect(page.getByTestId('camera-walk')).toHaveAttribute('aria-pressed', 'true'); + await page.keyboard.press('Tab'); + await expect(page.getByTestId('camera-fly')).toHaveAttribute('aria-pressed', 'true'); + await page.keyboard.press('Tab'); + await expect(page.getByTestId('camera-orbit')).toHaveAttribute('aria-pressed', 'true'); + }); + + test('flies through a wall that walking cannot pass', async ({ page }) => { + await roomWithDoor(page, { door: false }); + await page.getByTestId('camera-fly').click(); + + await hold(page, 'ArrowUp', 2500); + await expect(page.getByTestId('walker-room')).toHaveText('Unbounded'); + }); + + test('shows the keys for the mode you are in', async ({ page }) => { + await roomWithDoor(page); + await expect(page.getByTestId('hud-help')).toContainText('orbit'); + + await page.getByTestId('camera-walk').click(); + await expect(page.getByTestId('hud-help')).toContainText('crouch'); + }); +}); + +test.describe('saved views', () => { + test('bookmarks where you are standing and travels back to it', async ({ page }) => { + await roomWithDoor(page); + await page.getByTestId('camera-walk').click(); + await hold(page, 'ArrowUp', 60); + + await page.getByTestId('save-view').click(); + await page.getByLabel('Name for this view').fill('By the door'); + await page.getByTestId('confirm-view').click(); + + const bookmark = page.getByRole('button', { name: 'By the door', exact: true }); + await expect(bookmark).toBeVisible(); + + // Walk away, then travel back. + await hold(page, 'ArrowUp', 800); + const away = await walkerAt(page); + await bookmark.click(); + await expect(page.getByTestId('walker-readout')).not.toHaveText(away); + }); + + test('carries a saved view into the file and back out', async ({ page }) => { + await roomWithDoor(page); + await page.getByTestId('camera-walk').click(); + await hold(page, 'ArrowUp', 60); + + await page.getByTestId('save-view').click(); + await page.getByLabel('Name for this view').fill('Doorway'); + await page.getByTestId('confirm-view').click(); + + // Headless Chromium has File System Access, so the app would open a picker and + // this would wait for a download that never comes. These round-trip tests are + // about the container, not about which of the two ways out wrote it — the save + // paths themselves are covered in `persistence.spec.ts`. + await disableSaveInPlace(page); + + const download = page.waitForEvent('download'); + await page.getByTestId('save-file').click(); + const file = await download; + const path = await file.path(); + + await page.goto('/'); + await page.getByLabel('Open a .space file').setInputFiles(path); + await page.getByRole('button', { name: 'Space', exact: true }).click(); + + await expect(page.getByRole('button', { name: 'Doorway', exact: true })).toBeVisible(); + }); + + test('removes a bookmark', async ({ page }) => { + await roomWithDoor(page); + await page.getByTestId('camera-walk').click(); + await hold(page, 'ArrowUp', 60); + + await page.getByTestId('save-view').click(); + await page.getByTestId('confirm-view').click(); + + await page.getByRole('button', { name: /^Remove view/ }).click(); + await expect(page.getByTestId('save-view')).toBeVisible(); + await expect(page.getByRole('button', { name: /^Remove view/ })).toHaveCount(0); + }); +}); + +test.describe('the layer toggle', () => { + test('does not let a locked wall be selected in 3D either', async ({ page }) => { + // The toggle is a property of the document, not of the renderer. Without this, + // clicking a wall in furnish mode selects it and the panel offers to delete it — + // structure the plan view is refusing to let you touch. + // + // A door leaf is gated by the same rule, but is not asserted here: in the orbit + // view it is a slab a few pixels wide seen edge-on, and hunting for it by + // clicking a grid would be a test of where the camera happens to sit. The rule + // itself is covered exhaustively over every kind in `modes.test.ts`. + await roomWithDoor(page); + await page.getByRole('button', { name: 'Arrange furniture', exact: true }).click(); + + const canvas = page.locator('.space__canvas canvas'); + const box = (await canvas.boundingBox())!; + // The orbit view frames the whole room, so the middle of the canvas is a wall or + // the floor either way — and neither may select while structure is locked. + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + + await expect(page.getByTestId('wall-properties')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Delete', exact: true })).toHaveCount(0); + }); + + test('still selects a wall in 3D when structure is editable', async ({ page }) => { + await roomWithDoor(page); + + const canvas = page.locator('.space__canvas canvas'); + const box = (await canvas.boundingBox())!; + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + + // Something got selected — the click reaches the scene, so the test above is + // asserting a real refusal rather than a raycast that never hit anything. + await expect(page.getByRole('button', { name: 'Delete', exact: true })).toBeVisible(); + }); +}); + +test.describe('mounts', () => { + test('hangs a wall-mounted item on the wall it was dropped against', async ({ page }) => { + const stage = page.getByTestId('plan-stage'); + await selectTool(page, 'Room'); + await dragBetween(page, stage, { x: 0, y: 0 }, { x: 5000, y: 4000 }); + + await page.getByTestId('add-item').click(); + await page.getByLabel('Item name').fill('Wall shelf'); + await page.getByLabel('Width').fill('900mm'); + await page.getByLabel('Depth').fill('250mm'); + await page.getByLabel('Height').fill('300mm'); + await page.getByLabel('Mount').selectOption('wall'); + await page.getByRole('button', { name: 'Add', exact: true }).click(); + + await page.getByRole('button', { name: 'Place', exact: true }).click(); + await clickAt(page, stage, { x: 2500, y: 100 }); // right against the north wall + + const panel = page.getByTestId('placement-properties'); + await expect(panel.getByLabel('Mount')).toHaveValue('wall'); + await expect(panel.getByTestId('placement-elevation')).toBeVisible(); + }); + + test('says so when there is no wall to mount on, instead of guessing one', async ({ page }) => { + const stage = page.getByTestId('plan-stage'); + await selectTool(page, 'Room'); + await dragBetween(page, stage, { x: 0, y: 0 }, { x: 5000, y: 4000 }); + + await page.getByTestId('add-item').click(); + await page.getByLabel('Item name').fill('Wall shelf'); + await page.getByLabel('Width').fill('900mm'); + await page.getByLabel('Depth').fill('250mm'); + await page.getByLabel('Height').fill('300mm'); + await page.getByLabel('Mount').selectOption('wall'); + await page.getByRole('button', { name: 'Add', exact: true }).click(); + + await page.getByRole('button', { name: 'Place', exact: true }).click(); + await clickAt(page, stage, { x: 2500, y: 2000 }); // the middle of the room + + await expect(page.getByTestId('inventory-notice')).toContainText('no wall here'); + await expect(page.getByTestId('placement-properties').getByLabel('Mount')).toHaveValue('floor'); + }); +}); diff --git a/eslint.config.js b/eslint.config.js index 5702627..1703c7a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,6 +11,17 @@ export default tseslint.config( js.configs.recommended, ...tseslint.configs.recommended, + { + // Plain node scripts — the fixture generator. Not part of the bundle and not + // typechecked, so they need the node globals the browser config does not give. + files: ['**/*.mjs'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { ...globals.node }, + }, + }, + { files: ['**/*.{ts,tsx}'], languageOptions: { 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..eacb058 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", @@ -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/App.tsx b/src/App.tsx index 8d6a124..45fd4b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,21 +1,31 @@ +import { useEffect } from 'react'; import { TopBar } from './ui/TopBar'; import { InventoryPanel } from './ui/InventoryPanel'; import { PropertiesPanel } from './ui/PropertiesPanel'; import { Viewport } from './ui/Viewport'; +import { DropZone } from './ui/DropZone'; +import { RecoveryBanner } from './ui/RecoveryBanner'; +import { startAutosave } from './state/autosave'; /** - * Layout only. Every panel reads what it needs from the store directly, so mode and - * document changes do not re-render the shell. + * Layout, plus the two things that have to be alive for the whole session: the + * autosave subscription and the window-wide drop target. Every panel reads what it + * needs from the store directly, so mode and document changes do not re-render the + * shell. */ export function App() { + useEffect(() => startAutosave(), []); + return (
+
+
); } 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/calibration.test.ts b/src/core/calibration.test.ts new file mode 100644 index 0000000..0c4a255 --- /dev/null +++ b/src/core/calibration.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from 'vitest'; +import { createFloor, type Background } from './document'; +import { + CalibrationError, + DEFAULT_BACKGROUND_OPACITY, + NOMINAL_PLAN_WIDTH_MM, + applyCalibration, + backgroundCentre, + backgroundExtentMm, + clampOpacity, + createBackground, + docToImage, + effectiveMmPerPx, + floorAcceptsPlacements, + imageToDoc, + isCalibrated, + mmPerPxFrom, + placementBlockReason, + provisionalMmPerPx, + rotateBackground, +} from './calibration'; + +function bg(over: Partial = {}): Background { + return { + ...createBackground({ assetId: 'a1', pixelSize: { width: 1200, height: 900 } }), + ...over, + }; +} + +describe('provisionalMmPerPx', () => { + it('shows any raster at the nominal plan width', () => { + expect(provisionalMmPerPx(1200)).toBe(NOMINAL_PLAN_WIDTH_MM / 1200); + expect(1200 * provisionalMmPerPx(1200)).toBe(NOMINAL_PLAN_WIDTH_MM); + }); + + it('refuses a non-positive width rather than returning Infinity', () => { + // Infinity here would propagate silently into every coordinate on the plan. + expect(() => provisionalMmPerPx(0)).toThrow(RangeError); + expect(() => provisionalMmPerPx(-10)).toThrow(RangeError); + expect(() => provisionalMmPerPx(Number.NaN)).toThrow(RangeError); + }); +}); + +describe('createBackground', () => { + it('starts uncalibrated, locked, and translucent', () => { + const background = createBackground({ assetId: 'a1', pixelSize: { width: 800, height: 600 } }); + expect(isCalibrated(background)).toBe(false); + expect(background.locked).toBe(true); + expect(background.opacity).toBe(DEFAULT_BACKGROUND_OPACITY); + expect(background.transform).toEqual({ position: { x: 0, y: 0 }, rotationDeg: 0 }); + }); + + it('rejects an empty raster', () => { + expect(() => createBackground({ assetId: 'a', pixelSize: { width: 0, height: 10 } })).toThrow( + RangeError, + ); + }); +}); + +describe('the image-to-document map', () => { + it('exists before calibration, at the provisional scale', () => { + const b = bg(); + expect(effectiveMmPerPx(b)).toBe(NOMINAL_PLAN_WIDTH_MM / 1200); + // This is the whole point of a provisional scale: the reference line the user + // draws has to be convertible *before* the real scale is known. + expect(imageToDoc(b, { x: 1200, y: 0 })).toEqual({ x: NOMINAL_PLAN_WIDTH_MM, y: 0 }); + }); + + it('puts the raster origin at the transform position', () => { + const b = bg({ transform: { position: { x: 500, y: -250 }, rotationDeg: 0 } }); + expect(imageToDoc(b, { x: 0, y: 0 })).toEqual({ x: 500, y: -250 }); + }); + + it('round-trips through the inverse, including a rotation', () => { + const b = bg({ transform: { position: { x: 1234, y: -567 }, rotationDeg: 37 } }); + for (const p of [ + { x: 0, y: 0 }, + { x: 1200, y: 900 }, + { x: 431, y: 88 }, + ]) { + const back = docToImage(b, imageToDoc(b, p)); + expect(back.x).toBeCloseTo(p.x, 6); + expect(back.y).toBeCloseTo(p.y, 6); + } + }); + + it('reports the extent the status bar shows', () => { + expect(backgroundExtentMm(bg())).toEqual({ + width: NOMINAL_PLAN_WIDTH_MM, + height: 900 * (NOMINAL_PLAN_WIDTH_MM / 1200), + }); + }); +}); + +describe('mmPerPxFrom', () => { + it('divides the stated length by the pixels drawn', () => { + expect(mmPerPxFrom({ x: 100, y: 100 }, { x: 500, y: 100 }, 3000)).toBe(7.5); + }); + + it('measures the line, not its x extent', () => { + // 3-4-5: 300px across, 400px down, 500px of line. + expect(mmPerPxFrom({ x: 0, y: 0 }, { x: 300, y: 400 }, 5000)).toBe(10); + }); + + it('refuses a line too short to have been drawn deliberately', () => { + expect(() => mmPerPxFrom({ x: 0, y: 0 }, { x: 3, y: 0 }, 3000)).toThrow(CalibrationError); + expect(() => mmPerPxFrom({ x: 0, y: 0 }, { x: 0, y: 0 }, 3000)).toThrow(CalibrationError); + }); + + it('refuses a real length that is not a length', () => { + expect(() => mmPerPxFrom({ x: 0, y: 0 }, { x: 400, y: 0 }, 0)).toThrow(CalibrationError); + expect(() => mmPerPxFrom({ x: 0, y: 0 }, { x: 400, y: 0 }, -3000)).toThrow(CalibrationError); + expect(() => mmPerPxFrom({ x: 0, y: 0 }, { x: 400, y: 0 }, Number.NaN)).toThrow( + CalibrationError, + ); + }); +}); + +describe('applyCalibration', () => { + const refA = { x: 200, y: 300 }; + const refB = { x: 600, y: 300 }; + + it('makes the reference line measure what the user said it was', () => { + const next = applyCalibration(bg(), refA, refB, 3000); + const a = imageToDoc(next, refA); + const b = imageToDoc(next, refB); + expect(Math.hypot(b.x - a.x, b.y - a.y)).toBeCloseTo(3000, 6); + }); + + it('anchors refA, so the point the user pointed at does not move', () => { + const before = bg(); + const anchorBefore = imageToDoc(before, refA); + const after = applyCalibration(before, refA, refB, 3000); + const anchorAfter = imageToDoc(after, refA); + + expect(anchorAfter.x).toBeCloseTo(anchorBefore.x, 6); + expect(anchorAfter.y).toBeCloseTo(anchorBefore.y, 6); + }); + + it('anchors refA under a rotated background too', () => { + const before = bg({ transform: { position: { x: 900, y: 120 }, rotationDeg: 22.5 } }); + const anchorBefore = imageToDoc(before, refA); + const after = applyCalibration(before, refA, refB, 3000); + + expect(imageToDoc(after, refA).x).toBeCloseTo(anchorBefore.x, 6); + expect(imageToDoc(after, refA).y).toBeCloseTo(anchorBefore.y, 6); + expect(after.transform.rotationDeg).toBe(22.5); + }); + + it('does not drift when someone recalibrates repeatedly', () => { + // People get the first attempt wrong; three corrections must not walk the plan + // across the document. This is why `transform.position` is not rounded to mm. + let b = bg(); + const anchor = imageToDoc(b, refA); + for (const length of [3000, 2750, 3048]) b = applyCalibration(b, refA, refB, length); + + expect(imageToDoc(b, refA).x).toBeCloseTo(anchor.x, 6); + expect(imageToDoc(b, refA).y).toBeCloseTo(anchor.y, 6); + expect(b.calibration?.mmPerPx).toBeCloseTo(3048 / 400, 9); + }); + + it('records the reference so the gate can be reopened with it', () => { + const next = applyCalibration(bg(), refA, refB, 3000); + expect(next.calibration).toEqual({ + refA: { x: 200, y: 300 }, + refB: { x: 600, y: 300 }, + realLengthMm: 3000, + mmPerPx: 7.5, + }); + }); + + it('throws before touching the background when the numbers are unusable', () => { + const before = bg(); + expect(() => applyCalibration(before, refA, { x: 202, y: 300 }, 3000)).toThrow( + CalibrationError, + ); + expect(isCalibrated(before)).toBe(false); + }); +}); + +describe('rotateBackground', () => { + it('holds the pivot still', () => { + const b = bg(); + const pivot = backgroundCentre(b); + const turned = rotateBackground(b, 3, pivot); + const centreAfter = backgroundCentre(turned); + + expect(centreAfter.x).toBeCloseTo(pivot.x, 6); + expect(centreAfter.y).toBeCloseTo(pivot.y, 6); + expect(turned.transform.rotationDeg).toBe(3); + }); + + it('accumulates, so nudges add up', () => { + let b = bg(); + for (let i = 0; i < 4; i++) b = rotateBackground(b, 0.5, backgroundCentre(b)); + expect(b.transform.rotationDeg).toBeCloseTo(2, 9); + }); +}); + +describe('clampOpacity', () => { + it('keeps the slider inside the range and survives nonsense', () => { + expect(clampOpacity(0.5)).toBe(0.5); + expect(clampOpacity(-1)).toBe(0); + expect(clampOpacity(4)).toBe(1); + expect(clampOpacity(Number.NaN)).toBe(DEFAULT_BACKGROUND_OPACITY); + }); +}); + +describe('the placement gate', () => { + it('lets a floor with no background through', () => { + const floor = createFloor('f1', 'Ground', 0); + expect(placementBlockReason(floor)).toBeNull(); + expect(floorAcceptsPlacements(floor)).toBe(true); + }); + + it('blocks an uncalibrated plan, and says why', () => { + const floor = createFloor('f1', 'Ground', 0); + floor.background = bg(); + const reason = placementBlockReason(floor); + + expect(reason).toBeTruthy(); + // A refusal with no explanation reads as a bug, so the reason is part of the + // contract, not a nicety. + expect(reason).toMatch(/calibrat/i); + expect(floorAcceptsPlacements(floor)).toBe(false); + }); + + it('opens up the moment a scale exists', () => { + const floor = createFloor('f1', 'Ground', 0); + floor.background = applyCalibration(bg(), { x: 0, y: 0 }, { x: 400, y: 0 }, 3000); + expect(placementBlockReason(floor)).toBeNull(); + }); +}); diff --git a/src/core/calibration.ts b/src/core/calibration.ts new file mode 100644 index 0000000..5f0e3a2 --- /dev/null +++ b/src/core/calibration.ts @@ -0,0 +1,264 @@ +/** + * Background rasters and the calibration gate. See PLAN.md §6.1. + * + * A floor plan raster has no intrinsic real-world scale. Vector CAD exports use + * arbitrary drawing units; a scan is just pixels. Until someone says "this line is + * 3 metres", every dimension traced over it is wrong — and wrong in a way that + * looks entirely plausible, which is worse than obviously broken. That is why + * calibration is a gate and not a setting. + * + * Two spaces are in play: + * + * **image px** — the raster's own pixels, origin top-left, y down. `refA`/`refB` + * live here, and so does everything the user points at on the background. + * + * **document mm** — the canonical space (PLAN.md §3), also y-down. + * + * The map between them is `position + rotate(px * mmPerPx, rotationDeg)`. Both + * spaces are y-down and share a handedness, so there is no flip: `rotationDeg` uses + * the same sign convention as `Placement.rotation` and the same `rotate` helper. + * + * **Before calibration the map still exists**, at a provisional scale. It has to: + * the user draws the reference line on the stage, and turning that drag into image + * pixels requires the very transform calibration is about to produce. A provisional + * scale breaks that circle — the drawn line converts through it, and calibration + * then rescales about `refA` so the point the user anchored on stays exactly where + * they put it. + * + * Pure — no DOM, no store, no pdfjs. + */ + +import type { Background, Floor } from './document'; +import { add, distance, rotate, scale, sub, toRadians, type Vec2 } from './geometry/vec'; + +/** + * The apparent width an uncalibrated background is shown at. + * + * Any provisional scale keeps the transform invertible; this one is chosen so the + * image lands at a size a reference line can actually be drawn across at the default + * zoom. 6m is a plausible room, so an uncalibrated plan looks roughly like a plan + * rather than a postage stamp or a wall of pixels. + */ +export const NOMINAL_PLAN_WIDTH_MM = 6000; + +export const DEFAULT_BACKGROUND_OPACITY = 0.45; + +/** Reference lines shorter than this cannot be drawn precisely enough to trust. */ +export const MIN_REFERENCE_PX = 8; + +/** Below a millimetre there is nothing to calibrate against. */ +export const MIN_REAL_LENGTH_MM = 1; + +export class CalibrationError extends Error { + constructor(message: string) { + super(message); + this.name = 'CalibrationError'; + } +} + +export type Calibration = NonNullable; + +// --------------------------------------------------------------------------- +// Scale +// --------------------------------------------------------------------------- + +/** The scale an uncalibrated raster is displayed at, from its pixel width. */ +export function provisionalMmPerPx(pixelWidth: number): number { + if (!(pixelWidth > 0) || !Number.isFinite(pixelWidth)) { + throw new RangeError(`calibration: pixel width must be positive, got ${pixelWidth}`); + } + return NOMINAL_PLAN_WIDTH_MM / pixelWidth; +} + +export function isCalibrated(bg: Background | undefined): boolean { + return bg?.calibration !== undefined; +} + +/** Millimetres per image pixel — the real value once calibrated, provisional before. */ +export function effectiveMmPerPx(bg: Background): number { + return bg.calibration?.mmPerPx ?? provisionalMmPerPx(bg.pixelSize.width); +} + +// --------------------------------------------------------------------------- +// The transform +// --------------------------------------------------------------------------- + +/** A point on the raster, in image pixels, expressed in document millimetres. */ +export function imageToDoc(bg: Background, p: Vec2): Vec2 { + const k = effectiveMmPerPx(bg); + const rad = toRadians(bg.transform.rotationDeg); + return add(bg.transform.position, rotate(scale(p, k), rad)); +} + +/** The inverse: a document point as a pixel on the raster. */ +export function docToImage(bg: Background, p: Vec2): Vec2 { + const k = effectiveMmPerPx(bg); + const rad = toRadians(bg.transform.rotationDeg); + return scale(rotate(sub(p, bg.transform.position), -rad), 1 / k); +} + +/** The raster's size in document millimetres, at its current scale. */ +export function backgroundExtentMm(bg: Background): { width: number; height: number } { + const k = effectiveMmPerPx(bg); + return { width: bg.pixelSize.width * k, height: bg.pixelSize.height * k }; +} + +/** The centre of the raster, in document millimetres. */ +export function backgroundCentre(bg: Background): Vec2 { + return imageToDoc(bg, { x: bg.pixelSize.width / 2, y: bg.pixelSize.height / 2 }); +} + +// --------------------------------------------------------------------------- +// Construction +// --------------------------------------------------------------------------- + +export function createBackground(params: { + assetId: string; + pixelSize: { width: number; height: number }; + sourceAssetId?: string; + pageIndex?: number; + /** Document position of the raster's top-left corner. Defaults to the origin. */ + position?: Vec2; +}): Background { + if (!(params.pixelSize.width > 0) || !(params.pixelSize.height > 0)) { + throw new RangeError('calibration: background pixel size must be positive'); + } + return { + assetId: params.assetId, + ...(params.sourceAssetId ? { sourceAssetId: params.sourceAssetId } : {}), + ...(params.pageIndex !== undefined ? { pageIndex: params.pageIndex } : {}), + pixelSize: { ...params.pixelSize }, + transform: { position: params.position ?? { x: 0, y: 0 }, rotationDeg: 0 }, + opacity: DEFAULT_BACKGROUND_OPACITY, + locked: true, + }; +} + +// --------------------------------------------------------------------------- +// Calibrating +// --------------------------------------------------------------------------- + +/** + * Solve `mmPerPx` from a reference line and the real length it spans. + * + * Throws rather than returning a sentinel: every caller is a user action with a + * message to show, and a silently bad scale is exactly the failure this module + * exists to prevent. + */ +export function mmPerPxFrom(refA: Vec2, refB: Vec2, realLengthMm: number): number { + const pixels = distance(refA, refB); + if (!Number.isFinite(pixels) || pixels < MIN_REFERENCE_PX) { + throw new CalibrationError( + 'That reference line is too short to measure. Draw it across the longest dimension you know.', + ); + } + if (!Number.isFinite(realLengthMm) || realLengthMm < MIN_REAL_LENGTH_MM) { + throw new CalibrationError( + 'Enter the real length of the line you drew — for example 3m, 10 ft, 813mm.', + ); + } + return realLengthMm / pixels; +} + +/** + * Apply a calibration, rescaling about `refA`. + * + * The user drew the reference across a feature they recognise, so that feature is + * the one thing that must not move when the scale changes. Anchoring `refA` is the + * same instinct as `zoomAt` holding the point under the cursor — and it is what + * makes recalibration usable, since people do get the first attempt wrong. + * + * `transform.position` stays a float. Rounding it onto the integer-millimetre grid + * would move the anchor by up to half a millimetre *per recalibration*, so a plan + * corrected three times would visibly drift; and unlike a wall, nobody measures the + * corner of a raster. + */ +export function applyCalibration( + bg: Background, + refA: Vec2, + refB: Vec2, + realLengthMm: number, +): Background { + const mmPerPx = mmPerPxFrom(refA, refB, realLengthMm); + + // Where the anchor sits today, under whatever scale is currently in force. + const anchorDoc = imageToDoc(bg, refA); + const rad = toRadians(bg.transform.rotationDeg); + const position = sub(anchorDoc, rotate(scale(refA, mmPerPx), rad)); + + return { + ...bg, + calibration: { + refA: { ...refA }, + refB: { ...refB }, + realLengthMm, + mmPerPx, + }, + transform: { ...bg.transform, position }, + }; +} + +/** + * Rotate the background about a document point, keeping that point fixed. + * + * Used to square a scan that went through the scanner crooked; callers pass the + * raster centre so the plan does not swing off screen. + */ +export function rotateBackground(bg: Background, degrees: number, pivot: Vec2): Background { + const rad = toRadians(degrees); + const position = add(pivot, rotate(sub(bg.transform.position, pivot), rad)); + return { + ...bg, + transform: { + position, + rotationDeg: bg.transform.rotationDeg + degrees, + }, + }; +} + +export function clampOpacity(value: number): number { + if (!Number.isFinite(value)) return DEFAULT_BACKGROUND_OPACITY; + return Math.min(1, Math.max(0, value)); +} + +// --------------------------------------------------------------------------- +// The gate +// --------------------------------------------------------------------------- + +/** + * Why this floor cannot accept placements, or `null` when it can. + * + * A document with an uncalibrated background has no trustworthy scale, so anything + * placed on it is placed at a size that means nothing. Returning the reason rather + * than a bare boolean is deliberate: the UI has to be able to say *why*, and a + * refusal without an explanation reads as a bug. + */ +export function placementBlockReason(floor: Floor): string | null { + const bg = floor.background; + if (!bg || isCalibrated(bg)) return null; + return 'This floor plan has not been calibrated, so its scale is unknown. Calibrate it before placing anything.'; +} + +export function floorAcceptsPlacements(floor: Floor): boolean { + return placementBlockReason(floor) === null; +} + +/** + * Thrown when something tries to place an item on a floor that has no scale. + * + * The gate shipped in phase 3 as a predicate and a panel string because nothing could + * create a placement yet. This is what makes it real: the message is the same string + * the panel shows, so the refusal and the explanation can never drift apart. + */ +export class PlacementBlockedError extends Error { + constructor(reason: string) { + super(reason); + this.name = 'PlacementBlockedError'; + } +} + +/** Throw if this floor cannot accept placements. Call before every insert or move. */ +export function assertAcceptsPlacements(floor: Floor): void { + const reason = placementBlockReason(floor); + if (reason) throw new PlacementBlockedError(reason); +} diff --git a/src/core/catalog.test.ts b/src/core/catalog.test.ts new file mode 100644 index 0000000..3951248 --- /dev/null +++ b/src/core/catalog.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; +import { + CATEGORY_DEFAULTS, + CatalogError, + MAX_DIMENSION_MM, + createCatalogItem, + draftFromItem, + generatorShape, + itemFootprint, + itemGenerator, + type ItemDraft, +} from './catalog'; +import { PRESETS, findPreset } from './presets'; +import { footprintDepth, footprintWidth } from './geometry/footprint'; + +const TABLE: ItemDraft = { + name: 'Dining table', + category: 'table', + widthMm: 1830, + depthMm: 910, + heightMm: 760, + shape: 'rect', +}; + +describe('itemGenerator', () => { + it('builds every shape from a bounding box', () => { + for (const shape of ['rect', 'rounded', 'circle', 'ellipse', 'lshape', 'ushape', 'trapezoid'] as const) { + const footprint = itemFootprint(shape, 1200, 600); + expect(footprint.outline.pts.length, shape).toBeGreaterThan(2); + } + }); + + it('fits a rectangle to its stated size exactly', () => { + const footprint = itemFootprint('rect', 1830, 910); + expect(footprintWidth(footprint)).toBe(1830); + expect(footprintDepth(footprint)).toBe(910); + }); + + it('inscribes a circle in the shorter side, so it never exceeds the box', () => { + const footprint = itemFootprint('circle', 1200, 600); + expect(footprintWidth(footprint)).toBeLessThanOrEqual(600); + }); + + it('refuses a dimension that is not one', () => { + expect(() => itemGenerator('rect', 0, 600)).toThrow(CatalogError); + expect(() => itemGenerator('rect', -5, 600)).toThrow(CatalogError); + expect(() => itemGenerator('rect', Number.NaN, 600)).toThrow(CatalogError); + }); + + it('catches a decimal-point typo instead of building a 30m table', () => { + expect(() => itemGenerator('rect', MAX_DIMENSION_MM + 1, 600)).toThrow(/check the units/); + }); +}); + +describe('createCatalogItem', () => { + it('defaults the two fields people get wrong, from the category', () => { + const item = createCatalogItem(TABLE, 'i1'); + expect(item.voidBelowMm).toBe(CATEGORY_DEFAULTS.table.voidBelowMm); + expect(item.canHostSurface).toBe(true); + expect(item.quantityOwned).toBe(1); + expect(item.defaultMount).toBe('floor'); + }); + + it('rounds every dimension onto the integer-millimetre grid', () => { + const item = createCatalogItem({ ...TABLE, widthMm: 1830.4, heightMm: 760.6 }, 'i1'); + expect(item.widthMm).toBe(1830); + expect(item.heightMm).toBe(761); + }); + + it('rejects a void as tall as the object', () => { + // This is the one that matters: an inverted solid span makes the item collide + // with nothing at all, so it quietly stops being checked. + expect(() => createCatalogItem({ ...TABLE, voidBelowMm: 760 }, 'i1')).toThrow(CatalogError); + expect(() => createCatalogItem({ ...TABLE, voidBelowMm: 900 }, 'i1')).toThrow(/less than the height/); + }); + + it('accepts a void just under the height', () => { + expect(createCatalogItem({ ...TABLE, voidBelowMm: 759 }, 'i1').voidBelowMm).toBe(759); + }); + + it('rejects a negative void and a surface above the top', () => { + expect(() => createCatalogItem({ ...TABLE, voidBelowMm: -1 }, 'i1')).toThrow(CatalogError); + expect(() => createCatalogItem({ ...TABLE, surfaceHeightMm: 900 }, 'i1')).toThrow(CatalogError); + }); + + it('requires a name', () => { + expect(() => createCatalogItem({ ...TABLE, name: ' ' }, 'i1')).toThrow(/name/); + }); + + it('rejects a fractional quantity', () => { + expect(() => createCatalogItem({ ...TABLE, quantityOwned: 1.5 }, 'i1')).toThrow(CatalogError); + expect(() => createCatalogItem({ ...TABLE, quantityOwned: -1 }, 'i1')).toThrow(CatalogError); + }); + + it('allows owning zero — a shopping list is a list of things you do not have', () => { + expect(createCatalogItem({ ...TABLE, quantityOwned: 0 }, 'i1').quantityOwned).toBe(0); + }); +}); + +describe('draftFromItem', () => { + it('round-trips an item back into the form that built it', () => { + const item = createCatalogItem({ ...TABLE, shape: 'ellipse', voidBelowMm: 690 }, 'i1'); + const draft = draftFromItem(item); + + expect(draft.shape).toBe('ellipse'); + expect(draft.voidBelowMm).toBe(690); + expect(createCatalogItem(draft, 'i1')).toEqual(item); + }); + + it('reports no shape for a hand-authored polygon', () => { + expect(generatorShape({ kind: 'poly', pts: [] })).toBeNull(); + }); + + it('distinguishes a rounded rectangle from a square one', () => { + expect(generatorShape({ kind: 'rect', w: 10, d: 10 })).toBe('rect'); + expect(generatorShape({ kind: 'rect', w: 10, d: 10, cornerRadius: 1 })).toBe('rounded'); + }); +}); + +describe('the preset library', () => { + it('every preset builds a valid item', () => { + for (const preset of PRESETS) { + const { key: _key, group: _group, ...draft } = preset; + expect(() => createCatalogItem(draft, preset.key), preset.key).not.toThrow(); + } + }); + + it('states voidBelowMm explicitly on every entry, including the zeroes', () => { + // Inheriting it from the category default would put it one refactor away from + // silently disappearing, and it is what stops a rug under a table warning. + for (const preset of PRESETS) { + expect(preset.voidBelowMm, preset.key).toBeTypeOf('number'); + } + }); + + it('gives tables enough clearance for a chair to tuck under', () => { + const table = findPreset('dining-table-6'); + expect(table?.voidBelowMm).toBeGreaterThanOrEqual(650); + }); + + it('uses real published sizes', () => { + // A US queen is 60 x 80 inches; a dishwasher bay is 24 inches. + expect(findPreset('bed-queen')).toMatchObject({ widthMm: 1524, depthMm: 2032 }); + expect(findPreset('dishwasher')?.widthMm).toBe(610); + }); + + it('has unique keys', () => { + 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 new file mode 100644 index 0000000..5489848 --- /dev/null +++ b/src/core/catalog.ts @@ -0,0 +1,257 @@ +/** + * Building and validating catalog items. See PLAN.md §4.3 and §7.1. + * + * The catalog is what you *own*; placements are where those things *are*. Six + * identical dining chairs are one catalog entry and six placements, which is the only + * way "I own 6, 4 are placed, 2 unplaced" stays expressible. + * + * The interesting field is `voidBelowMm`. A table is not a solid prism — it is a top + * and four legs with ~720mm of open air beneath, and without that number a rug under + * a table registers as a collision, so does a bin under a desk, and the validation + * panel is noise from the first real room. Manual entry defaults it per category + * rather than leaving it at zero, because a user typing in a dining table should not + * have to know why the app is about to shout at them. + * + * Pure — no DOM, no store. + */ + +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'; + +export const CATEGORIES: readonly Category[] = [ + 'seating', + 'table', + 'storage', + 'bed', + 'appliance', + 'fixture', + 'lighting', + 'decor', + 'rug', + 'other', +] as const; + +export const CATEGORY_LABELS: Record = { + seating: 'Seating', + table: 'Table', + storage: 'Storage', + bed: 'Bed', + appliance: 'Appliance', + fixture: 'Fixture', + lighting: 'Lighting', + decor: 'Decor', + rug: 'Rug', + other: 'Other', +}; + +/** + * Per-category defaults for the two fields people get wrong. + * + * `voidBelow` is the open air beneath the object and `hosts` is whether other things + * can sit on top. Both are guesses the form pre-fills and the user can override; both + * are far better guesses than zero and false. + */ +export const CATEGORY_DEFAULTS: Record< + Category, + { voidBelowMm: number; canHostSurface: boolean; color: string } +> = { + seating: { voidBelowMm: 0, canHostSurface: false, color: '#7a6a58' }, + table: { voidBelowMm: 700, canHostSurface: true, color: '#8a6f4e' }, + storage: { voidBelowMm: 0, canHostSurface: true, color: '#6f6152' }, + bed: { voidBelowMm: 250, canHostSurface: false, color: '#5f6b7a' }, + appliance: { voidBelowMm: 0, canHostSurface: false, color: '#6b7078' }, + fixture: { voidBelowMm: 0, canHostSurface: false, color: '#6e7a76' }, + lighting: { voidBelowMm: 0, canHostSurface: false, color: '#9a8b52' }, + decor: { voidBelowMm: 0, canHostSurface: false, color: '#7d6a72' }, + rug: { voidBelowMm: 0, canHostSurface: false, color: '#8a7a6a' }, + other: { voidBelowMm: 0, canHostSurface: false, color: '#70707a' }, +}; + +export class CatalogError extends Error { + constructor(message: string) { + super(message); + this.name = 'CatalogError'; + } +} + +/** Nothing real is smaller than this, and it keeps degenerate footprints out. */ +export const MIN_DIMENSION_MM = 1; +/** 30m — larger than any single object in a home, and a decimal-point typo catcher. */ +export const MAX_DIMENSION_MM = 30_000; + +export type ItemDraft = { + name: string; + category: Category; + widthMm: number; + depthMm: number; + heightMm: number; + shape: ShapeKind; + voidBelowMm?: number; + 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; + /** Where the numbers came from, when they were not typed. See PLAN.md §7.2. */ + source?: ProductSource; +}; + +/** + * A footprint generator from a bounding box and a shape choice. + * + * The same `ShapeKind` vocabulary the plan's shape tool uses — "account for all + * common shapes" is one list, not two — parameterised by W × D rather than by a drag, + * because that is how a person describes furniture. + */ +export function itemGenerator(shape: ShapeKind, widthMm: number, depthMm: number): FootprintGenerator { + requireDimension('width', widthMm); + requireDimension('depth', depthMm); + + switch (shape) { + case 'rect': + return { kind: 'rect', w: widthMm, d: depthMm }; + case 'rounded': + // A tenth of the short side reads as "rounded" at any size without turning a + // small object into a lozenge. + return { kind: 'rect', w: widthMm, d: depthMm, cornerRadius: Math.min(widthMm, depthMm) / 10 }; + case 'circle': + return { kind: 'circle', r: Math.min(widthMm, depthMm) / 2 }; + case 'ellipse': + return { kind: 'ellipse', rx: widthMm / 2, ry: depthMm / 2 }; + case 'lshape': + return { kind: 'lshape', w: widthMm, d: depthMm, cutW: widthMm / 2, cutD: depthMm / 2, corner: 'ne' }; + case 'ushape': + return { kind: 'ushape', w: widthMm, d: depthMm, armW: widthMm / 4, openSide: 'n' }; + case 'trapezoid': + return { kind: 'trapezoid', wTop: widthMm / 2, wBottom: widthMm, d: depthMm }; + } +} + +export function itemFootprint(shape: ShapeKind, widthMm: number, depthMm: number): Footprint { + return makeFootprint(itemGenerator(shape, widthMm, depthMm)); +} + +function requireDimension(name: string, value: number): void { + if (!Number.isFinite(value) || value < MIN_DIMENSION_MM) { + throw new CatalogError(`${name} must be at least ${MIN_DIMENSION_MM}mm.`); + } + if (value > MAX_DIMENSION_MM) { + throw new CatalogError(`${name} of ${value}mm is larger than anything in a home — check the units.`); + } +} + +/** + * Build a catalog item, defaulting the fields people should not have to think about + * and rejecting the combinations that would produce nonsense downstream. + * + * A void taller than the object is the one that matters: it inverts the solid span, + * so the item would collide with nothing at all and quietly stop being checked. + */ +export function createCatalogItem(draft: ItemDraft, id: Id): CatalogItem { + const name = draft.name.trim(); + if (!name) throw new CatalogError('Give the item a name.'); + + requireDimension('height', draft.heightMm); + const footprint = itemFootprint(draft.shape, draft.widthMm, draft.depthMm); + + const defaults = CATEGORY_DEFAULTS[draft.category]; + const voidBelowMm = draft.voidBelowMm ?? defaults.voidBelowMm; + if (voidBelowMm < 0) throw new CatalogError('Open space beneath cannot be negative.'); + if (voidBelowMm >= draft.heightMm) { + throw new CatalogError( + `Open space beneath (${voidBelowMm}mm) must be less than the height (${draft.heightMm}mm), ` + + `or the object has no solid part left to collide with.`, + ); + } + + const surfaceHeightMm = draft.surfaceHeightMm; + if (surfaceHeightMm !== undefined) { + if (surfaceHeightMm <= 0 || surfaceHeightMm > draft.heightMm) { + throw new CatalogError('The usable surface must be above the floor and no higher than the item.'); + } + } + + const quantityOwned = draft.quantityOwned ?? 1; + if (!Number.isInteger(quantityOwned) || quantityOwned < 0) { + throw new CatalogError('Quantity owned must be a whole number, zero or more.'); + } + + return { + id, + name, + category: draft.category, + widthMm: Math.round(draft.widthMm), + depthMm: Math.round(draft.depthMm), + heightMm: Math.round(draft.heightMm), + voidBelowMm: Math.round(voidBelowMm), + ...(surfaceHeightMm !== undefined ? { surfaceHeightMm: Math.round(surfaceHeightMm) } : {}), + 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 } : {}), + ...(draft.source ? { source: { ...draft.source } } : {}), + }; +} + +/** The draft an existing item edits from — the inverse of `createCatalogItem`. */ +export function draftFromItem(item: CatalogItem, shape: ShapeKind = 'rect'): ItemDraft { + return { + name: item.name, + category: item.category, + widthMm: item.widthMm, + depthMm: item.depthMm, + 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, + ...(item.clearances ? { clearances: item.clearances.map((z) => ({ ...z })) } : {}), + color: item.color, + quantityOwned: item.quantityOwned, + ...(item.notes ? { notes: item.notes } : {}), + }; +} + +/** Which shape choice produced a generator, for round-tripping the edit form. */ +export function generatorShape(gen: FootprintGenerator): ShapeKind | null { + switch (gen.kind) { + case 'rect': + return gen.cornerRadius ? 'rounded' : 'rect'; + case 'circle': + return 'circle'; + case 'ellipse': + return 'ellipse'; + case 'lshape': + return 'lshape'; + case 'ushape': + return 'ushape'; + case 'trapezoid': + return 'trapezoid'; + case 'poly': + // An imported or hand-authored polygon has no parametric shape to edit back to. + return null; + } +} diff --git a/src/core/clearance.test.ts b/src/core/clearance.test.ts new file mode 100644 index 0000000..f154592 --- /dev/null +++ b/src/core/clearance.test.ts @@ -0,0 +1,276 @@ +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([]); + }); +}); + +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 new file mode 100644 index 0000000..e125291 --- /dev/null +++ b/src/core/clearance.ts @@ -0,0 +1,271 @@ +/** + * 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 +// --------------------------------------------------------------------------- + +/** + * 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; + /** 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. + * + * 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 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; + 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, ridesOn: hostsAbove(byId, placement) }); + } 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 (obstacle.ridesOn.has(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/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/document.ts b/src/core/document.ts index 7e24695..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?: { @@ -160,6 +167,15 @@ export type Background = { /** The original PDF, retained alongside the render. */ sourceAssetId?: Id; pageIndex?: number; + /** + * The raster's intrinsic size in pixels. + * + * This is the domain `calibration.refA/refB` live in, and it is what makes the + * image-pixel to document-millimetre map invertible before calibration exists. + * Without it a reopened document could render the background but could not + * convert a click on it back to a pixel, so recalibration would be impossible. + */ + pixelSize: { width: number; height: number }; /** * Absent until the user completes the calibration gate. A floor plan has no * intrinsic scale, so a document with an uncalibrated background refuses diff --git a/src/core/fixtures/make-schema-v1.mjs b/src/core/fixtures/make-schema-v1.mjs new file mode 100644 index 0000000..bf261d3 --- /dev/null +++ b/src/core/fixtures/make-schema-v1.mjs @@ -0,0 +1,261 @@ +/** + * Generates `schema-v1.space`. **Run once, in September 2026. Do not run it again.** + * + * The file it produces is a frozen artifact: a schema-1 container as this application + * shipped it, checked into the repository so that every future build can be made to + * open it. That is the whole value. A fixture regenerated from today's code proves + * only that today's writer agrees with today's reader, which is a tautology and + * catches nothing — and regenerating it is exactly what a failing migration test will + * tempt someone into doing. + * + * If `schema-v1.test.ts` fails, the answer is a migration, not a new fixture. + * + * The document below is hand-authored rather than produced by `createDocument` and + * friends, for the same reason: it has to be able to disagree with the code. It + * deliberately covers the parts of the model most likely to drift — a second floor at + * a real elevation, a wall with an opening in it, a room with its own ceiling, a + * catalog item with a cached footprint outline, a surface-mounted placement, a saved + * 3D camera, a calibrated background, and an asset carried in the container. + * + * node src/core/fixtures/make-schema-v1.mjs + */ + +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { zipSync, strToU8 } from 'fflate'; + +const here = dirname(fileURLToPath(import.meta.url)); + +// A 1x1 transparent PNG — the smallest thing that is genuinely an image. +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + 'base64', +); + +const document = { + schemaVersion: 1, + id: 'doc-v1-fixture', + title: 'Schema 1 fixture', + createdAt: '2026-09-01T09:00:00.000Z', + modifiedAt: '2026-09-01T09:30:00.000Z', + displayUnit: 'ft-in', + gridMm: 25, + catalog: [ + { + id: 'item-bed', + name: 'Queen bed', + category: 'bed', + widthMm: 1524, + depthMm: 2032, + heightMm: 610, + voidBelowMm: 250, + canHostSurface: false, + footprint: { + generator: { kind: 'rect', w: 1524, d: 2032 }, + outline: { + pts: [ + { x: 762, y: -1016 }, + { x: 762, y: 1016 }, + { x: -762, y: 1016 }, + { x: -762, y: -1016 }, + ], + }, + }, + defaultMount: 'floor', + color: '#8a7a68', + quantityOwned: 1, + }, + { + id: 'item-lamp', + name: 'Table lamp', + category: 'lighting', + widthMm: 220, + depthMm: 220, + heightMm: 420, + voidBelowMm: 0, + canHostSurface: false, + footprint: { + generator: { kind: 'circle', r: 110 }, + outline: { + pts: [ + { x: 110, y: 0 }, + { x: 0, y: 110 }, + { x: -110, y: 0 }, + { x: 0, y: -110 }, + ], + }, + }, + defaultMount: 'surface', + color: '#d8cfc0', + quantityOwned: 2, + }, + { + id: 'item-dresser', + name: 'Dresser', + category: 'storage', + widthMm: 1200, + depthMm: 450, + heightMm: 810, + voidBelowMm: 0, + canHostSurface: true, + footprint: { + generator: { kind: 'rect', w: 1200, d: 450 }, + outline: { + pts: [ + { x: 600, y: -225 }, + { x: 600, y: 225 }, + { x: -600, y: 225 }, + { x: -600, y: -225 }, + ], + }, + }, + defaultMount: 'floor', + clearances: [{ edge: 'front', depthMm: 600, reason: 'drawer pull' }], + color: '#6b5c4b', + quantityOwned: 1, + }, + ], + floors: [ + { + id: 'floor-ground', + name: 'Ground', + index: 0, + elevationMm: 0, + defaultCeilingHeightMm: 2438, + walls: [ + { id: 'w-n', a: { x: 0, y: 0 }, b: { x: 4200, y: 0 }, thicknessMm: 114, heightMm: 2438, baseElevationMm: 0 }, + { id: 'w-e', a: { x: 4200, y: 0 }, b: { x: 4200, y: 3600 }, thicknessMm: 114, heightMm: 2438, baseElevationMm: 0 }, + { id: 'w-s', a: { x: 4200, y: 3600 }, b: { x: 0, y: 3600 }, thicknessMm: 114, heightMm: 2438, baseElevationMm: 0 }, + { id: 'w-w', a: { x: 0, y: 3600 }, b: { x: 0, y: 0 }, thicknessMm: 114, heightMm: 2438, baseElevationMm: 0 }, + ], + openings: [ + { + id: 'o-door', + wallId: 'w-s', + offsetMm: 1600, + widthMm: 813, + heightMm: 2032, + sillMm: 0, + kind: 'door', + swing: { hinge: 'a', into: 'front', angleDeg: 90 }, + }, + { + id: 'o-window', + wallId: 'w-n', + offsetMm: 1800, + widthMm: 1200, + heightMm: 1200, + sillMm: 914, + kind: 'window', + }, + ], + rooms: [ + { + id: 'room-bedroom', + name: 'Bedroom', + boundary: { + pts: [ + { x: 0, y: 0 }, + { x: 4200, y: 0 }, + { x: 4200, y: 3600 }, + { x: 0, y: 3600 }, + ], + }, + ceilingHeightMm: 2600, + areaMm2: 15120000, + }, + ], + placements: [ + { + id: 'p-bed', + itemId: 'item-bed', + floorId: 'floor-ground', + position: { x: 2100, y: 1200 }, + rotation: 0, + mount: { kind: 'floor' }, + elevation: 0, + }, + { + id: 'p-dresser', + itemId: 'item-dresser', + floorId: 'floor-ground', + position: { x: 3500, y: 3200 }, + rotation: 180, + mount: { kind: 'floor' }, + elevation: 0, + }, + { + id: 'p-lamp', + itemId: 'item-lamp', + floorId: 'floor-ground', + position: { x: 3500, y: 3200 }, + rotation: 0, + mount: { kind: 'surface', hostId: 'p-dresser' }, + elevation: 810, + }, + ], + background: { + assetId: 'asset-plan', + pageIndex: 0, + pixelSize: { width: 1, height: 1 }, + calibration: { + refA: { x: 0, y: 0 }, + refB: { x: 1, y: 0 }, + realLengthMm: 4200, + mmPerPx: 4200, + }, + transform: { position: { x: 0, y: 0 }, rotationDeg: 0 }, + opacity: 0.45, + locked: true, + }, + }, + { + id: 'floor-upper', + name: 'Upstairs', + index: 1, + elevationMm: 2738, + defaultCeilingHeightMm: 2438, + walls: [ + { id: 'w-u1', a: { x: 0, y: 0 }, b: { x: 4200, y: 0 }, thicknessMm: 114, heightMm: 2438, baseElevationMm: 0 }, + ], + openings: [], + rooms: [], + placements: [], + }, + ], + activeFloorId: 'floor-ground', + savedViews: [ + { + id: 'view-corner', + name: 'From the door', + mode: 'space3d', + camera: { px: 2100, py: 1600, pz: 3400, tx: 2100, ty: 1650, tz: 1200 }, + }, + ], + assets: [ + { id: 'asset-plan', path: 'assets/asset-plan.png', mime: 'image/png', bytes: PNG.byteLength }, + ], +}; + +const manifest = { + app: 'floorplan', + appVersion: '0.1.0', + schemaVersion: 1, + title: document.title, + createdAt: document.createdAt, + modifiedAt: document.modifiedAt, +}; + +const bytes = zipSync( + { + 'manifest.json': strToU8(JSON.stringify(manifest, null, 2)), + 'document.json': strToU8(JSON.stringify(document, null, 2)), + 'assets/asset-plan.png': new Uint8Array(PNG), + }, + { level: 6 }, +); + +const out = join(here, 'schema-v1.space'); +writeFileSync(out, bytes); +console.log(`wrote ${out} (${bytes.byteLength} bytes)`); 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/fixtures/schema-v1.space b/src/core/fixtures/schema-v1.space new file mode 100644 index 0000000..ccf0120 Binary files /dev/null and b/src/core/fixtures/schema-v1.space differ diff --git a/src/core/floors.test.ts b/src/core/floors.test.ts new file mode 100644 index 0000000..db87581 --- /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.placements, '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.placements, '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..f5ff305 --- /dev/null +++ b/src/core/floors.ts @@ -0,0 +1,213 @@ +/** + * 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. + * + * Takes a placement list rather than a floor because the very thing it is guarding + * against is already representable: a cross-floor surface mount is in the model and can + * be in a file, and searching only the source floor would leave exactly the rider this + * function exists to carry. + */ +export function descendantsOf(placements: readonly Placement[], 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 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/geometry/collision.ts b/src/core/geometry/collision.ts index 61a0954..d66f301 100644 --- a/src/core/geometry/collision.ts +++ b/src/core/geometry/collision.ts @@ -12,7 +12,8 @@ */ import polygonClipping from 'polygon-clipping'; -import { bounds, boundsOverlap, polygon, signedArea, type Polygon } from './polygon'; +import { bounds, boundsOverlap, containsPoint, polygon, signedArea, type Polygon } from './polygon'; +import { distanceToSegment, type Vec2 } from './vec'; /** A closed vertical interval in millimetres above the floor datum. */ export type Span = { bottom: number; top: number }; @@ -97,6 +98,34 @@ export function polygonsIntersect(a: Polygon, b: Polygon): boolean { return intersectionArea(a, b) > OVERLAP_TOLERANCE_MM2; } +/** + * Whether a circle overlaps a polygon. + * + * Inside counts, which is what makes this usable for a walker: a body that has + * somehow ended up inside a wall must read as colliding, not as clear because no + * edge is within its radius. + */ +export function circleIntersects(poly: Polygon, centre: Vec2, radius: number): boolean { + const b = bounds(poly); + if ( + centre.x + radius < b.minX || + centre.x - radius > b.maxX || + centre.y + radius < b.minY || + centre.y - radius > b.maxY + ) { + return false; + } + if (containsPoint(poly, centre)) return true; + + const pts = poly.pts; + for (let i = 0; i < pts.length; i++) { + const a = pts[i]!; + const c = pts[(i + 1) % pts.length]!; + if (distanceToSegment(centre, a, c) <= radius) return true; + } + return false; +} + export type Volume = { outline: Polygon; span: Span; diff --git a/src/core/geometry/vec.ts b/src/core/geometry/vec.ts index 248b8a0..20ad5a3 100644 --- a/src/core/geometry/vec.ts +++ b/src/core/geometry/vec.ts @@ -61,6 +61,23 @@ export function equals(a: Vec2, b: Vec2, tolerance = 0): boolean { return Math.abs(a.x - b.x) <= tolerance && Math.abs(a.y - b.y) <= tolerance; } +/** + * Perpendicular distance from a point to a **segment**, not to the infinite line. + * + * Lives here rather than in `wall.ts` because walls are not the only thing measured + * against: the walker's capsule is tested against every polygon edge in the scene, + * and that must not have to import a wall to do it. + */ +export function distanceToSegment(p: Vec2, a: Vec2, b: Vec2): number { + const dx = b.x - a.x; + const dy = b.y - a.y; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) return distance(p, a); + + const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq)); + return distance(p, { x: a.x + t * dx, y: a.y + t * dy }); +} + export function toDegrees(radians: number): number { return (radians * 180) / Math.PI; } diff --git a/src/core/geometry/wall.ts b/src/core/geometry/wall.ts index 5849b64..3809c10 100644 --- a/src/core/geometry/wall.ts +++ b/src/core/geometry/wall.ts @@ -14,7 +14,11 @@ */ import { polygon, ensureCounterClockwise, type Polygon } from './polygon'; -import { distance, normalize, perp, sub, type Vec2 } from './vec'; +import { distance, distanceToSegment, normalize, perp, sub, type Vec2 } from './vec'; + +// Re-exported because it was part of this module's surface before the walker needed +// it too, and callers should not have to care that it moved down a layer. +export { distanceToSegment }; export type WallLine = { a: Vec2; @@ -69,17 +73,6 @@ export function wallOutline(wall: WallLine): Polygon { ); } -/** Perpendicular distance from a point to a segment (not the infinite line). */ -export function distanceToSegment(p: Vec2, a: Vec2, b: Vec2): number { - const dx = b.x - a.x; - const dy = b.y - a.y; - const lenSq = dx * dx + dy * dy; - if (lenSq === 0) return distance(p, a); - - const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq)); - return distance(p, { x: a.x + t * dx, y: a.y + t * dy }); -} - /** * How far along the centreline a point falls, in mm from `a`, clamped to the wall. * This is the coordinate `Opening.offsetMm` is expressed in. @@ -104,6 +97,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/media.test.ts b/src/core/media.test.ts new file mode 100644 index 0000000..562a749 --- /dev/null +++ b/src/core/media.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { assetPath, extensionFor, isRaster, sniffMime } from './media'; + +/** Bytes with the given prefix, padded so length is never the thing under test. */ +function withPrefix(prefix: number[], length = 64): Uint8Array { + const out = new Uint8Array(length); + out.set(prefix, 0); + return out; +} + +const PNG = withPrefix([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const JPEG = withPrefix([0xff, 0xd8, 0xff, 0xe0]); +const PDF = withPrefix([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37]); + +function webp(): Uint8Array { + const out = withPrefix([0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00]); + out.set([0x57, 0x45, 0x42, 0x50], 8); + return out; +} + +describe('sniffMime', () => { + it('identifies the formats the importer accepts', () => { + expect(sniffMime(PNG)).toBe('image/png'); + expect(sniffMime(JPEG)).toBe('image/jpeg'); + expect(sniffMime(PDF)).toBe('application/pdf'); + expect(sniffMime(webp())).toBe('image/webp'); + }); + + it('returns null for a format it cannot import', () => { + // GIF — a real image format, and deliberately not one of ours. + expect(sniffMime(withPrefix([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]))).toBeNull(); + expect(sniffMime(new Uint8Array([1, 2, 3]))).toBeNull(); + expect(sniffMime(new Uint8Array(0))).toBeNull(); + }); + + it('does not read past the end of a truncated file', () => { + // Four bytes of a PNG signature. Reading the full eight would run off the end; + // returning `null` beats throwing inside a decoder later. + expect(sniffMime(new Uint8Array([0x89, 0x50, 0x4e, 0x47]))).toBeNull(); + }); + + it('requires WEBP after RIFF, not RIFF alone', () => { + // A WAV file is also RIFF. Matching on the container alone would send audio to + // the image decoder. + const wav = withPrefix([0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00]); + wav.set([0x57, 0x41, 0x56, 0x45], 8); + expect(sniffMime(wav)).toBeNull(); + }); + + it('ignores what the file claims to be', () => { + // The whole reason this function exists: a JPEG named plan.png arrives from the + // browser as `image/png`, and the bytes are the only honest source. + expect(sniffMime(JPEG)).toBe('image/jpeg'); + }); +}); + +describe('isRaster', () => { + it('separates what can be drawn from what must be rendered first', () => { + expect(isRaster('image/png')).toBe(true); + expect(isRaster('image/webp')).toBe(true); + expect(isRaster('application/pdf')).toBe(false); + }); +}); + +describe('assetPath', () => { + it('lands inside the container prefix writeSpace enforces', () => { + expect(assetPath('abc', 'image/png')).toBe('assets/abc.png'); + expect(assetPath('abc', 'application/pdf')).toBe('assets/abc.pdf'); + }); + + it('uses jpg for jpeg', () => { + expect(extensionFor('image/jpeg')).toBe('jpg'); + }); +}); diff --git a/src/core/media.ts b/src/core/media.ts new file mode 100644 index 0000000..3bb6b06 --- /dev/null +++ b/src/core/media.ts @@ -0,0 +1,82 @@ +/** + * Media type detection for imported files. See PLAN.md §6.1. + * + * The browser gives a `File.type`, and it lies often enough to matter: a `.pdf` + * dragged from some archives arrives as `application/octet-stream`, and a file + * renamed `plan.png` that is really a JPEG arrives as `image/png`. Both would then + * be handed to the wrong decoder. Magic bytes are the only thing that actually + * knows, so nothing downstream reads `File.type`. + * + * Pure — no DOM. `src/ui/import/` does the decoding; this decides what to decode. + */ + +export const IMPORT_MIMES = ['image/png', 'image/jpeg', 'image/webp', 'application/pdf'] as const; +export type ImportMime = (typeof IMPORT_MIMES)[number]; + +/** What a background raster is stored as. PDFs are rendered to one of these. */ +export type RasterMime = Exclude; + +const EXTENSIONS: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'application/pdf': 'pdf', +}; + +function startsWith(bytes: Uint8Array, signature: readonly number[], offset = 0): boolean { + if (bytes.length < offset + signature.length) return false; + for (let i = 0; i < signature.length; i++) { + if (bytes[offset + i] !== signature[i]) return false; + } + return true; +} + +const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +const JPEG = [0xff, 0xd8, 0xff]; +const PDF = [0x25, 0x50, 0x44, 0x46]; // "%PDF" +const RIFF = [0x52, 0x49, 0x46, 0x46]; +const WEBP = [0x57, 0x45, 0x42, 0x50]; + +/** + * The real type of a file, from its leading bytes. `null` for anything this + * application cannot import — the caller turns that into a message naming the + * formats that do work, rather than failing later inside a decoder. + */ +export function sniffMime(bytes: Uint8Array): ImportMime | null { + if (startsWith(bytes, PNG)) return 'image/png'; + if (startsWith(bytes, JPEG)) return 'image/jpeg'; + // A PDF is allowed to carry junk before the header, but only a little — the spec + // says a reader may accept the marker within the first 1024 bytes. + if (startsWith(bytes, PDF)) return 'application/pdf'; + if (startsWith(bytes, RIFF) && startsWith(bytes, WEBP, 8)) return 'image/webp'; + return null; +} + +export function isRaster(mime: ImportMime): mime is RasterMime { + return mime !== 'application/pdf'; +} + +export function extensionFor(mime: ImportMime): string { + return EXTENSIONS[mime]; +} + +/** Where an asset lives inside the `.space` container. */ +export function assetPath(id: string, mime: ImportMime): string { + return `assets/${id}.${extensionFor(mime)}`; +} + +/** + * What a picked file turned out to be. + * + * The answer to "what is this", which is a media question, so it lives here rather + * than with the importer that produces it — the store holds a pending one while a + * multi-page PDF waits for its page to be chosen, and the state layer has no business + * importing a type out of the UI layer to do it. + */ +export type Inspection = + | { kind: 'image'; fileName: string; mime: RasterMime; bytes: Uint8Array } + | { kind: 'pdf'; fileName: string; bytes: Uint8Array; pageCount: number }; + +/** The list shown in an error message and in the file picker's `accept`. */ +export const IMPORT_ACCEPT = '.pdf,.png,.jpg,.jpeg,.webp'; +export const IMPORT_FORMATS_LABEL = 'PDF, PNG, JPEG or WebP'; 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/modes.test.ts b/src/core/modes.test.ts index f508286..b0ba201 100644 --- a/src/core/modes.test.ts +++ b/src/core/modes.test.ts @@ -3,10 +3,14 @@ import { EDIT_MODES, otherEditMode, placementsAreEditable, + refIsEditable, structureIsEditable, type EditMode, + type SelectableKind, } from './modes'; +const KINDS: SelectableKind[] = ['wall', 'room', 'opening', 'placement']; + describe('edit modes', () => { it('makes structure and placements mutually exclusive in every mode', () => { // This is the whole point of the layer toggle: exactly one of the two @@ -27,4 +31,13 @@ describe('edit modes', () => { expect(structureIsEditable(mode)).toBe(false); expect(placementsAreEditable(mode)).toBe(true); }); + + it('lets exactly one kind be selected in each mode', () => { + // Exhaustive over the kinds, which is the point: a door is structure and locks + // with the wall it is cut into, in both viewports. + for (const kind of KINDS) { + expect(refIsEditable('plan', kind)).toBe(kind !== 'placement'); + expect(refIsEditable('furnish', kind)).toBe(kind === 'placement'); + } + }); }); diff --git a/src/core/modes.ts b/src/core/modes.ts index 62a10f4..d0a0f09 100644 --- a/src/core/modes.ts +++ b/src/core/modes.ts @@ -39,3 +39,20 @@ export function placementsAreEditable(mode: EditMode): boolean { export function otherEditMode(mode: EditMode): EditMode { return mode === 'plan' ? 'furnish' : 'plan'; } + +/** Everything a click can land on, in either viewport. */ +export type SelectableKind = 'wall' | 'room' | 'opening' | 'placement'; + +/** + * Whether a click on a thing of this kind may select it, in this mode. + * + * Written as *placement or else structure* rather than as a check per kind, so a + * kind added later is locked with the structure it belongs to instead of falling + * through both branches and quietly becoming selectable everywhere. That is not + * hypothetical: `opening` was added to the 3D scene's refs in phase 6, and a + * two-branch guard would have let a door be selected — and deleted — in furnish + * mode, where the plan view refuses to let you touch it. + */ +export function refIsEditable(mode: EditMode, kind: SelectableKind): boolean { + return kind === 'placement' ? placementsAreEditable(mode) : structureIsEditable(mode); +} 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/placement-snap.test.ts b/src/core/placement-snap.test.ts new file mode 100644 index 0000000..17c9a36 --- /dev/null +++ b/src/core/placement-snap.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest'; +import type { Placement, Wall } from './document'; +import { rectFootprint, type Footprint } from './geometry/footprint'; +import { makeFootprint } from './geometry/footprint'; +import { worldOutline } from './placement'; +import { + ROTATION_STEP_DEG, + backOffset, + hostAt, + snapPlacement, + snapRotation, + snapToWall, + type PlacementSnapContext, + type SnapHost, +} from './placement-snap'; +import { polygon } from './geometry/polygon'; + +const SOFA = rectFootprint(2000, 900); // backOffset 450 + +function wall(a: { x: number; y: number }, b: { x: number; y: number }, thicknessMm = 200): Wall { + return { id: 'w1', a, b, thicknessMm, heightMm: 2438, baseElevationMm: 0 }; +} + +function placementAt(position: { x: number; y: number }, rotation: number): Placement { + return { + id: 'p1', + itemId: 'i1', + floorId: 'f1', + position, + rotation, + mount: { kind: 'floor' }, + elevation: 0, + }; +} + +/** Perpendicular distance from a point to the infinite line through a wall. */ +function distanceToLine(w: Wall, p: { x: number; y: number }): number { + const dx = w.b.x - w.a.x; + const dy = w.b.y - w.a.y; + const len = Math.hypot(dx, dy); + return Math.abs((p.x - w.a.x) * (dy / len) - (p.y - w.a.y) * (dx / len)); +} + +/** + * The nearest the item's outline comes to the wall's centreline after snapping. + * + * A flush item touches the face exactly, so this equals the wall's half-thickness — + * more means a gap, less means the item is inside the wall. + */ +function nearestApproach(w: Wall, footprint: Footprint, snap: { position: { x: number; y: number }; rotation: number }): number { + const outline = worldOutline(placementAt(snap.position, snap.rotation), { + footprint, + } as never); + return Math.min(...outline.pts.map((p) => distanceToLine(w, p))); +} + +describe('backOffset', () => { + it('is the half-depth of a centred footprint', () => { + expect(backOffset(SOFA)).toBe(450); + }); + + it('is the true offset for a footprint that does not straddle its origin', () => { + // A shape whose local origin sits at its front edge: the back is a full depth away. + const offCentre = makeFootprint({ + kind: 'poly', + pts: [ + { x: -500, y: -800 }, + { x: 500, y: -800 }, + { x: 500, y: 0 }, + { x: -500, y: 0 }, + ], + }); + expect(backOffset(offCentre)).toBe(800); + }); +}); + +describe('snapToWall', () => { + it('seats the back edge on the wall face, not the centre on the centreline', () => { + // The bug this guards: centring a 2000x900 sofa on a wall puts half of it inside. + const w = wall({ x: 0, y: 0 }, { x: 5000, y: 0 }); + const snap = snapToWall({ x: 2000, y: 700 }, w, SOFA, 1000); + expect(snap).not.toBeNull(); + + expect(snap!.rotation).toBe(0); + expect(snap!.position).toEqual({ x: 2000, y: 550 }); + expect(nearestApproach(w, SOFA, snap!)).toBeCloseTo(100, 6); // half of 200 + }); + + it('turns the item so its back faces the wall, whichever side it is on', () => { + const w = wall({ x: 0, y: 0 }, { x: 5000, y: 0 }); + + const below = snapToWall({ x: 2000, y: 700 }, w, SOFA, 1000)!; + const above = snapToWall({ x: 2000, y: -700 }, w, SOFA, 1000)!; + + expect(below.rotation).toBe(0); + expect(Math.abs(above.rotation)).toBe(180); + expect(below.position.y).toBeGreaterThan(0); + expect(above.position.y).toBeLessThan(0); + }); + + it('stays flush at angles that are not axis-aligned', () => { + // The case where a half-extent shortcut silently stops working. + for (const degrees of [0, 30, 45, 67.5, 90, 143, 200, 315]) { + const rad = (degrees * Math.PI) / 180; + const end = { x: Math.round(5000 * Math.cos(rad)), y: Math.round(5000 * Math.sin(rad)) }; + const w = wall({ x: 0, y: 0 }, end); + + // A point offset perpendicular from the wall's midpoint, on one side. + const n = { x: -Math.sin(rad), y: Math.cos(rad) }; + const raw = { x: end.x / 2 + n.x * 600, y: end.y / 2 + n.y * 600 }; + + const snap = snapToWall(raw, w, SOFA, 1000); + expect(snap, `no snap at ${degrees}deg`).not.toBeNull(); + // Within half a millimetre, which is the most that can be asserted: the + // canonical unit is the integer millimetre and `worldOutline` rounds its + // vertices onto it, so an off-axis edge lands within a rounding step of the + // face rather than exactly on it. + expect(nearestApproach(w, SOFA, snap!), `${degrees}deg`).toBeCloseTo(100, 0); + } + }); + + it('pulls an item back out when it is already inside the wall', () => { + // A negative gap still snaps — that is exactly the correction the user wants. + const w = wall({ x: 0, y: 0 }, { x: 5000, y: 0 }); + const snap = snapToWall({ x: 2000, y: 50 }, w, SOFA, 100); + expect(snap).not.toBeNull(); + expect(nearestApproach(w, SOFA, snap!)).toBeCloseTo(100, 6); + }); + + it('does not snap to a wall it is nowhere near', () => { + const w = wall({ x: 0, y: 0 }, { x: 5000, y: 0 }); + expect(snapToWall({ x: 2000, y: 4000 }, w, SOFA, 200)).toBeNull(); + }); + + it('does not snap to the wall extension past either end', () => { + // Standing off the end of a wall is not standing against it. + const w = wall({ x: 0, y: 0 }, { x: 5000, y: 0 }); + expect(snapToWall({ x: -3000, y: 600 }, w, SOFA, 200)).toBeNull(); + expect(snapToWall({ x: 9000, y: 600 }, w, SOFA, 200)).toBeNull(); + }); + + it('ignores a degenerate wall rather than dividing by zero', () => { + const w = wall({ x: 100, y: 100 }, { x: 100, y: 100 }); + expect(snapToWall({ x: 100, y: 200 }, w, SOFA, 500)).toBeNull(); + }); +}); + +describe('hostAt', () => { + const table: SnapHost = { + id: 'table', + canHostSurface: true, + outline: polygon([ + { x: 0, y: 0 }, + { x: 1000, y: 0 }, + { x: 1000, y: 1000 }, + { x: 0, y: 1000 }, + ]), + }; + const sofa: SnapHost = { ...table, id: 'sofa', canHostSurface: false }; + + it('finds a host under the point', () => { + expect(hostAt({ x: 500, y: 500 }, [table])?.id).toBe('table'); + }); + + it('ignores anything that cannot host', () => { + // Without the flag a lamp carried across the room would mount to every sofa it + // passed over. + expect(hostAt({ x: 500, y: 500 }, [sofa])).toBeUndefined(); + }); + + it('returns nothing when the point is outside', () => { + expect(hostAt({ x: 5000, y: 5000 }, [table])).toBeUndefined(); + }); +}); + +describe('snapPlacement', () => { + const base = (over: Partial = {}): PlacementSnapContext => ({ + walls: [], + hosts: [], + footprint: SOFA, + gridMm: 25, + gridEnabled: true, + toleranceMm: 200, + suppressed: false, + ...over, + }); + + it('prefers a host over a wall', () => { + // Something standing on a table is not also standing against a wall, and rotating + // it to a wall it happens to be near would spin it under the cursor. + const host: SnapHost = { + id: 'table', + canHostSurface: true, + outline: polygon([ + { x: 0, y: 0 }, + { x: 2000, y: 0 }, + { x: 2000, y: 2000 }, + { x: 0, y: 2000 }, + ]), + }; + const ctx = base({ hosts: [host], walls: [wall({ x: 0, y: 0 }, { x: 5000, y: 0 })] }); + const result = snapPlacement({ x: 1000, y: 600 }, 42, ctx); + + expect(result.mount).toEqual({ kind: 'surface', hostId: 'table' }); + expect(result.rotation).toBe(42); // untouched + }); + + it('falls through to the wall, then to the grid', () => { + const w = wall({ x: 0, y: 0 }, { x: 5000, y: 0 }); + expect(snapPlacement({ x: 2000, y: 620 }, 0, base({ walls: [w] })).hints).toEqual([ + { kind: 'wall', wallId: 'w1' }, + ]); + expect(snapPlacement({ x: 2000, y: 4013 }, 0, base({ walls: [w] })).hints).toEqual([ + { kind: 'grid' }, + ]); + }); + + it('picks the nearer of two walls', () => { + const near = { ...wall({ x: 0, y: 0 }, { x: 5000, y: 0 }), id: 'near' }; + const far = { ...wall({ x: 0, y: 1400 }, { x: 5000, y: 1400 }), id: 'far' }; + const result = snapPlacement({ x: 2000, y: 620 }, 0, base({ walls: [near, far] })); + expect(result.hints).toEqual([{ kind: 'wall', wallId: 'near' }]); + }); + + it('rounds to the grid when nothing else applies', () => { + const result = snapPlacement({ x: 1013, y: 2007 }, 0, base()); + expect(result.position).toEqual({ x: 1025, y: 2000 }); + }); + + it('leaves the raw point alone with the grid off', () => { + const result = snapPlacement({ x: 1013, y: 2007 }, 0, base({ gridEnabled: false })); + expect(result.position).toEqual({ x: 1013, y: 2007 }); + expect(result.hints).toEqual([]); + }); + + it('suppresses everything under Alt, including the wall', () => { + const ctx = base({ walls: [wall({ x: 0, y: 0 }, { x: 5000, y: 0 })], suppressed: true }); + const result = snapPlacement({ x: 2000, y: 620 }, 33, ctx); + + expect(result.position).toEqual({ x: 2000, y: 620 }); + expect(result.rotation).toBe(33); + expect(result.mount).toEqual({ kind: 'floor' }); + }); +}); + +describe('snapRotation', () => { + it('rounds onto the 15 degree ladder', () => { + expect(snapRotation(7, false)).toBe(0); + expect(snapRotation(8, false)).toBe(ROTATION_STEP_DEG); + expect(snapRotation(-8, false)).toBe(-ROTATION_STEP_DEG); + expect(snapRotation(91, false)).toBe(90); + }); + + it('leaves the angle alone when suppressed', () => { + expect(snapRotation(37.4, true)).toBe(37.4); + }); +}); diff --git a/src/core/placement-snap.ts b/src/core/placement-snap.ts new file mode 100644 index 0000000..1ca4d2a --- /dev/null +++ b/src/core/placement-snap.ts @@ -0,0 +1,190 @@ +/** + * Snapping for placements. See PLAN.md §9.1. + * + * Three snaps, in strict precedence: + * + * 1. **Surface** — dragged over something whose `canHostSurface` is true, the mount + * converts to `surface` and the item seats exactly on top. Dragging off returns + * it to the floor. The flag is what stops a lamp carried across the room from + * mounting itself to every sofa it passes over. + * 2. **Wall** — within tolerance of a wall, the item rotates to match the wall and + * translates so its *back edge* is flush against the wall's near face. Not its + * centre: a sofa centred on a wall is half inside it. + * 3. **Grid**. + * + * Alt suppresses all of it, the same escape hatch the drawing tools have. + * + * **The back of an item is its local −y edge** — at rotation 0 the back faces "north", + * which is how furniture is drawn and how the footprint generators are oriented. Every + * offset here is measured from the footprint's local origin to that edge, so a shape + * that is not symmetric about its origin still sits flush. + * + * Pure — no DOM, no store. + */ + +import type { Id, Mount, Wall } from './document'; +import type { Footprint } from './geometry/footprint'; +import { bounds, containsPoint, type Polygon } from './geometry/polygon'; +import { add, dot, normalize, perp, scale, sub, toDegrees, type Vec2 } from './geometry/vec'; +import { snapToGrid } from './snapping'; + +/** Rotation snaps to this increment unless suppressed. */ +export const ROTATION_STEP_DEG = 15; + +export type SnapHost = { + id: Id; + outline: Polygon; + canHostSurface: boolean; +}; + +export type PlacementSnapHint = + | { kind: 'wall'; wallId: Id } + | { kind: 'surface'; hostId: Id } + | { kind: 'grid' }; + +export type PlacementSnapContext = { + walls: readonly Wall[]; + /** Existing placements, nearest-first is not required; the last match wins. */ + hosts: readonly SnapHost[]; + footprint: Footprint; + gridMm: number; + gridEnabled: boolean; + /** Snap radius in document mm — normally `pxToMm(viewport, 10)`. */ + toleranceMm: number; + /** Alt held. */ + suppressed: boolean; +}; + +export type PlacementSnapResult = { + position: Vec2; + rotation: number; + mount: Mount; + hints: PlacementSnapHint[]; +}; + +/** + * Distance from the footprint's local origin to its back edge. + * + * `bounds().minY` is negative for a footprint straddling its origin, so this is the + * half-depth for a centred shape and the true offset for one that is not. + */ +export function backOffset(footprint: Footprint): number { + return -bounds(footprint.outline).minY; +} + +/** + * Where an item must sit, and how it must be turned, to stand flush against a wall. + * + * Returns `null` when the item is not beside this wall at all — past either end, or + * further than `toleranceMm` from the face. A negative gap (the item overlapping the + * wall) still snaps: pulling it out is exactly what the user wants. + */ +export function snapToWall( + raw: Vec2, + wall: Wall, + footprint: Footprint, + toleranceMm: number, +): { position: Vec2; rotation: number; gap: number } | null { + const along = sub(wall.b, wall.a); + const length = Math.hypot(along.x, along.y); + if (length === 0) return null; + + const d = normalize(along); + const n = perp(d); + const rel = sub(raw, wall.a); + + // Past either end is beside the wall's *extension*, not the wall. + const t = dot(rel, d); + if (t < -toleranceMm || t > length + toleranceMm) return null; + + const offset = dot(rel, n); + const side = offset >= 0 ? 1 : -1; + // Unit vector pointing from the item toward the wall. + const toWall = scale(n, -side); + + const half = wall.thicknessMm / 2; + const back = backOffset(footprint); + const gap = Math.abs(offset) - half - back; + if (gap > toleranceMm) return null; + + // The item's local −y must end up pointing at the wall. rotate((0,-1), θ) is + // (sin θ, −cos θ), so θ = atan2(toWall.x, −toWall.y). + const rotation = toDegrees(Math.atan2(toWall.x, -toWall.y)); + + const projection = add(wall.a, scale(d, Math.max(0, Math.min(length, t)))); + const position = sub(projection, scale(toWall, half + back)); + + return { position, rotation, gap }; +} + +/** The host a point lands on, or undefined. Later hosts win — they render on top. */ +export function hostAt(point: Vec2, hosts: readonly SnapHost[]): SnapHost | undefined { + let found: SnapHost | undefined; + for (const host of hosts) { + if (host.canHostSurface && containsPoint(host.outline, point)) found = host; + } + return found; +} + +/** + * Resolve a dragged position into where the placement actually goes. + * + * `rotation` is the item's current rotation and is returned unchanged unless a wall + * snap overrides it — turning an item because it drifted near a wall is expected; + * turning it for any other reason is not. + */ +export function snapPlacement( + raw: Vec2, + rotation: number, + ctx: PlacementSnapContext, +): PlacementSnapResult { + if (ctx.suppressed) { + return { position: raw, rotation, mount: { kind: 'floor' }, hints: [] }; + } + + const host = hostAt(raw, ctx.hosts); + if (host) { + // Deliberately no wall snap here: something already standing on a table is not + // also standing against a wall, and rotating it to a wall it happens to be near + // would spin it out from under the user's cursor. + return { + position: ctx.gridEnabled ? snapToGrid(raw, ctx.gridMm) : raw, + rotation, + mount: { kind: 'surface', hostId: host.id }, + hints: [{ kind: 'surface', hostId: host.id }], + }; + } + + let best: { wall: Wall; snap: NonNullable> } | null = null; + for (const wall of ctx.walls) { + const snap = snapToWall(raw, wall, ctx.footprint, ctx.toleranceMm); + if (!snap) continue; + if (!best || Math.abs(snap.gap) < Math.abs(best.snap.gap)) best = { wall, snap }; + } + + if (best) { + return { + position: best.snap.position, + rotation: best.snap.rotation, + mount: { kind: 'floor' }, + hints: [{ kind: 'wall', wallId: best.wall.id }], + }; + } + + if (ctx.gridEnabled) { + return { + position: snapToGrid(raw, ctx.gridMm), + rotation, + mount: { kind: 'floor' }, + hints: [{ kind: 'grid' }], + }; + } + + return { position: raw, rotation, mount: { kind: 'floor' }, hints: [] }; +} + +/** Round a rotation onto the 15° ladder, or leave it alone when suppressed. */ +export function snapRotation(degrees: number, suppressed: boolean): number { + if (suppressed) return degrees; + return Math.round(degrees / ROTATION_STEP_DEG) * ROTATION_STEP_DEG; +} 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 new file mode 100644 index 0000000..042e3d0 --- /dev/null +++ b/src/core/presets.ts @@ -0,0 +1,91 @@ +/** + * The preset library. See PLAN.md §7.1. + * + * Standard dimensions so common items are one click rather than three fields and a + * tape measure. These are real published sizes — a US queen mattress is 1524 × 2032mm + * because it is 60" × 80", a dishwasher bay is 610mm because it is 24" — rounded to + * the millimetre and no further. + * + * Every preset carries its `voidBelowMm` explicitly, including the zeroes. That field + * is what stops a rug under a coffee table reading as a collision, and a preset that + * inherited it from the category default would be one refactor away from silently + * losing it. + * + * A preset is an `ItemDraft`, not a `CatalogItem`: it has no id and no footprint until + * someone adds it, and it stays editable in the same form as manual entry. + */ + +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. */ + key: string; + group: string; +}; + +export const PRESETS: readonly Preset[] = [ + // -- Sleeping ------------------------------------------------------------ + { key: 'bed-twin', group: 'Beds', name: 'Twin bed', category: 'bed', shape: 'rect', widthMm: 991, depthMm: 1905, heightMm: 635, voidBelowMm: 250 }, + { key: 'bed-full', group: 'Beds', name: 'Full bed', category: 'bed', shape: 'rect', widthMm: 1372, depthMm: 1905, heightMm: 635, voidBelowMm: 250 }, + { key: 'bed-queen', group: 'Beds', name: 'Queen bed', category: 'bed', shape: 'rect', widthMm: 1524, depthMm: 2032, heightMm: 635, voidBelowMm: 250 }, + { key: 'bed-king', group: 'Beds', name: 'King bed', category: 'bed', shape: 'rect', widthMm: 1930, depthMm: 2032, heightMm: 635, voidBelowMm: 250 }, + { key: 'nightstand', group: 'Beds', name: 'Nightstand', category: 'storage', shape: 'rect', widthMm: 500, depthMm: 400, heightMm: 600, voidBelowMm: 0, canHostSurface: true }, + + // -- Seating ------------------------------------------------------------- + { 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, 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. + { key: 'dining-table-6', group: 'Tables', name: 'Dining table (6)', category: 'table', shape: 'rect', widthMm: 1830, depthMm: 910, heightMm: 760, voidBelowMm: 720, canHostSurface: true }, + { key: 'dining-table-round', group: 'Tables', name: 'Dining table (round)', category: 'table', shape: 'circle', widthMm: 1220, depthMm: 1220, heightMm: 760, voidBelowMm: 720, canHostSurface: true }, + { key: 'coffee-table', group: 'Tables', name: 'Coffee table', category: 'table', shape: 'rect', widthMm: 1220, depthMm: 610, heightMm: 450, voidBelowMm: 380, canHostSurface: true }, + { key: 'side-table', group: 'Tables', name: 'Side table', category: 'table', shape: 'circle', widthMm: 500, depthMm: 500, heightMm: 550, voidBelowMm: 450, canHostSurface: true }, + { 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, 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, 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, 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. + { key: 'tv-65', group: 'Other', name: 'TV (65 in)', category: 'decor', shape: 'rect', widthMm: 1450, depthMm: 80, heightMm: 830, voidBelowMm: 0, defaultMount: 'wall' }, + { key: 'floor-lamp', group: 'Other', name: 'Floor lamp', category: 'lighting', shape: 'circle', widthMm: 400, depthMm: 400, heightMm: 1600, voidBelowMm: 0 }, + { key: 'pendant', group: 'Other', name: 'Pendant light', category: 'lighting', shape: 'circle', widthMm: 350, depthMm: 350, heightMm: 300, voidBelowMm: 0, defaultMount: 'ceiling' }, + // A rug is 10mm of solid sitting on the floor. Everything with a void above 10mm — + // every table, every bed — passes over it without a warning, which is the point. + { key: 'rug-5x8', group: 'Other', name: 'Rug (5 x 8 ft)', category: 'rug', shape: 'rect', widthMm: 1520, depthMm: 2440, heightMm: 10, voidBelowMm: 0 }, + { key: 'rug-8x10', group: 'Other', name: 'Rug (8 x 10 ft)', category: 'rug', shape: 'rect', widthMm: 2440, depthMm: 3050, heightMm: 10, voidBelowMm: 0 }, +]; + +export const PRESET_GROUPS: readonly string[] = [...new Set(PRESETS.map((p) => p.group))]; + +export function findPreset(key: string): Preset | undefined { + return PRESETS.find((p) => p.key === key); +} 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/core/recovery.test.ts b/src/core/recovery.test.ts new file mode 100644 index 0000000..39367f6 --- /dev/null +++ b/src/core/recovery.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest'; +import { + chooseRecovery, + isUntouched, + recoveryMessage, + type AutosaveSummary, +} from './recovery'; +import { createDocument, type SpaceDocument } from './document'; +import { polygon } from './geometry/polygon'; + +function doc(over: Partial = {}): SpaceDocument { + return { + ...createDocument({ id: 'doc-a', floorId: 'ground', now: '2026-01-01T00:00:00.000Z' }), + ...over, + }; +} + +function record(over: Partial = {}): AutosaveSummary { + return { + documentId: 'doc-a', + title: 'Apartment', + savedAt: '2026-01-01T12:00:00.000Z', + modifiedAt: '2026-01-01T12:00:00.000Z', + ...over, + }; +} + +describe('is there anything here to lose', () => { + it('a fresh document holds nothing', () => { + expect(isUntouched(doc())).toBe(true); + }); + + it('a renamed empty document still holds nothing', () => { + // Otherwise someone who typed a name and then crashed is refused their own work + // back, because naming an empty space counted as content. + expect(isUntouched(doc({ title: 'The new flat' }))).toBe(true); + }); + + it('one wall is content', () => { + const d = doc(); + d.floors[0]!.walls.push({ + id: 'w', + a: { x: 0, y: 0 }, + b: { x: 1000, y: 0 }, + thicknessMm: 114, + heightMm: 2438, + baseElevationMm: 0, + }); + expect(isUntouched(d)).toBe(false); + }); + + it('a room drawn with the area tool is content, though it has no walls', () => { + const d = doc(); + d.floors[0]!.rooms.push({ + id: 'r', + name: 'Room', + boundary: polygon([ + { x: 0, y: 0 }, + { x: 1000, y: 0 }, + { x: 1000, y: 1000 }, + ]), + ceilingHeightMm: 2438, + areaMm2: 500_000, + }); + expect(isUntouched(d)).toBe(false); + }); + + it('a shopping list with no floor plan is content', () => { + // The inventory-first document. Nothing is drawn, and losing it is still a loss. + const d = doc(); + d.catalog.push({ + id: 'i', + name: 'Sofa', + category: 'seating', + widthMm: 2000, + depthMm: 900, + heightMm: 800, + voidBelowMm: 0, + canHostSurface: false, + footprint: { generator: { kind: 'rect', w: 2000, d: 900 }, outline: polygon([ + { x: -1000, y: -450 }, + { x: 1000, y: -450 }, + { x: 1000, y: 450 }, + ]) }, + defaultMount: 'floor', + color: '#888', + quantityOwned: 1, + }); + expect(isUntouched(d)).toBe(false); + }); + + it('an imported floor plan is content, even before a line is drawn', () => { + const d = doc(); + d.floors[0]!.background = { + assetId: 'a', + pixelSize: { width: 100, height: 100 }, + transform: { position: { x: 0, y: 0 }, rotationDeg: 0 }, + opacity: 0.45, + locked: true, + }; + expect(isUntouched(d)).toBe(false); + }); +}); + +describe('choosing a recovery', () => { + it('offers nothing when the database is empty', () => { + expect(chooseRecovery([], doc(), { dirty: false })).toBeNull(); + }); + + it('offers an autosave that is ahead of the file that was opened', () => { + const opened = doc({ modifiedAt: '2026-01-01T10:00:00.000Z' }); + const offer = chooseRecovery([record({ modifiedAt: '2026-01-01T11:00:00.000Z' })], opened, { + dirty: false, + }); + expect(offer?.reason).toBe('newer'); + }); + + it('does not offer an autosave the opened file has already caught up with', () => { + // Saved, then reloaded. The record and the file describe the same state, and an + // offer here is a prompt with nothing behind it. + const opened = doc({ modifiedAt: '2026-01-01T12:00:00.000Z' }); + expect(chooseRecovery([record()], opened, { dirty: false })).toBeNull(); + }); + + it('compares how far along the documents are, not which write happened last', () => { + // The autosave ran *after* the file was written but holds an *earlier* state — + // a background flush that lost the race with a manual save. Going by `savedAt` + // would hand the user back the older document. + const opened = doc({ modifiedAt: '2026-01-01T12:00:00.000Z' }); + const stale = record({ savedAt: '2026-01-01T13:00:00.000Z', modifiedAt: '2026-01-01T11:00:00.000Z' }); + expect(chooseRecovery([stale], opened, { dirty: false })).toBeNull(); + }); + + it('offers an orphan when this session has nothing to lose', () => { + // The case §5 does not describe and the one that matters after a crash: the tab + // came back on a fresh document and the work belongs to an id it has never seen. + const offer = chooseRecovery([record({ documentId: 'other', title: 'Flat' })], doc(), { + dirty: false, + }); + expect(offer?.reason).toBe('orphan'); + expect(offer?.record.documentId).toBe('other'); + }); + + it('will not offer another document over work in progress', () => { + const d = doc(); + d.floors[0]!.walls.push({ + id: 'w', + a: { x: 0, y: 0 }, + b: { x: 1000, y: 0 }, + thicknessMm: 114, + heightMm: 2438, + baseElevationMm: 0, + }); + expect(chooseRecovery([record({ documentId: 'other' })], d, { dirty: false })).toBeNull(); + }); + + it('offers nothing at all once the session is dirty', () => { + // Not a startup any more. Every accept path replaces the open document, so an + // offer here proposes destroying the work it claims to be protecting. + expect(chooseRecovery([record({ documentId: 'other' })], doc(), { dirty: true })).toBeNull(); + }); + + it('picks the most recent of several orphans', () => { + const offer = chooseRecovery( + [ + record({ documentId: 'old', savedAt: '2026-01-01T08:00:00.000Z' }), + record({ documentId: 'new', savedAt: '2026-01-01T09:00:00.000Z' }), + ], + doc(), + { dirty: false }, + ); + expect(offer?.record.documentId).toBe('new'); + }); + + it('prefers this document over a newer orphan', () => { + // An offer for the document you are looking at is the one you can act on without + // losing your place, even when another one was written more recently. + const opened = doc({ modifiedAt: '2026-01-01T10:00:00.000Z' }); + const offer = chooseRecovery( + [ + record({ modifiedAt: '2026-01-01T11:00:00.000Z', savedAt: '2026-01-01T11:00:00.000Z' }), + record({ documentId: 'other', savedAt: '2026-01-01T23:00:00.000Z' }), + ], + opened, + { dirty: false }, + ); + expect(offer?.reason).toBe('newer'); + expect(offer?.record.documentId).toBe('doc-a'); + }); + + it('treats an unreadable timestamp as old rather than throwing', () => { + const opened = doc({ modifiedAt: '2026-01-01T10:00:00.000Z' }); + expect(chooseRecovery([record({ modifiedAt: 'not a date' })], opened, { dirty: false })).toBeNull(); + }); +}); + +describe('what the prompt says', () => { + it('names the other document when it is not this one', () => { + const message = recoveryMessage({ + record: record({ documentId: 'other', title: 'Riverside flat' }), + reason: 'orphan', + }); + expect(message).toContain('Riverside flat'); + }); + + it('survives a corrupt timestamp', () => { + const message = recoveryMessage({ record: record({ savedAt: 'nonsense' }), reason: 'newer' }); + expect(message).toContain('earlier'); + expect(message).not.toContain('Invalid Date'); + }); +}); diff --git a/src/core/recovery.ts b/src/core/recovery.ts new file mode 100644 index 0000000..593de19 --- /dev/null +++ b/src/core/recovery.ts @@ -0,0 +1,119 @@ +/** + * What to do with an autosave found at startup. See PLAN.md §5. + * + * §5 says "if an autosave is newer than the opened file, offer recovery", which + * answers the easy half. The case that actually matters after a crash is the one it + * does not describe: **there is no opened file**. The tab died with an hour of drawing + * in it, the page comes back on a fresh empty document, and the autosave belongs to a + * document id the new session has never heard of. + * + * So there are two offers, and they are distinguished because they are not equally + * safe: + * + * - `newer` — an autosave for *this* document, holding a later edit than the file + * that was opened. Comparison is on the document's own `modifiedAt`, not on when + * the autosave ran: the question is which state is further along, not which write + * happened last. + * - `orphan` — the current document is untouched, so there is nothing to lose, and an + * autosave for some other document exists. The most recently written one is offered. + * + * ## Why this is not noisy + * + * An autosave record is **deleted the moment its document is saved to a file**. A + * record surviving therefore means one thing: that document had unsaved changes when + * the tab went away. Without that rule every clean reload would greet you with an + * offer to recover work you had already saved, and the prompt would be trained out of + * you long before the one time it mattered. + * + * Pure — no IndexedDB, no store. The database lives in `state/autosave-db.ts`. + */ + +import type { Id, SpaceDocument } from './document'; + +/** The metadata a recovery decision needs. The document bytes are not required. */ +export type AutosaveSummary = { + documentId: Id; + title: string; + /** When the autosave was written. */ + savedAt: string; + /** The document's own `modifiedAt` at that moment. */ + modifiedAt: string; +}; + +export type RecoveryReason = 'newer' | 'orphan'; + +export type RecoveryOffer = { + record: AutosaveSummary; + reason: RecoveryReason; +}; + +/** + * Whether a document holds nothing a person put there. + * + * The test for "safe to replace without asking twice". Deliberately structural rather + * than `dirty`: a fresh document is not dirty either, and a document can be + * un-dirtied by saving while plainly holding an apartment. Title and grid are not + * counted — renaming an empty space is not work worth protecting, and the alternative + * is refusing to offer recovery to someone who typed a name before the crash. + */ +export function isUntouched(doc: SpaceDocument): boolean { + if (doc.catalog.length > 0) return false; + if (doc.assets.length > 0) return false; + if (doc.savedViews.length > 0) return false; + + return doc.floors.every( + (f) => + f.walls.length === 0 && + f.rooms.length === 0 && + f.placements.length === 0 && + f.openings.length === 0 && + f.background === undefined, + ); +} + +function time(iso: string): number { + const t = Date.parse(iso); + // An unparseable timestamp sorts oldest rather than throwing. A corrupt record + // should cost you a recovery offer, not the ability to open the app. + return Number.isNaN(t) ? -Infinity : t; +} + +/** The most recently written record, or undefined. */ +function mostRecent(records: readonly AutosaveSummary[]): AutosaveSummary | undefined { + return [...records].sort((a, b) => time(b.savedAt) - time(a.savedAt))[0]; +} + +/** + * Decide whether to offer a recovery, and which one. + * + * `dirty` short-circuits everything: if the session already has unsaved changes, this + * is not a startup, and a prompt whose accept path replaces the open document would + * be offering to destroy the very work it claims to protect. + */ +export function chooseRecovery( + records: readonly AutosaveSummary[], + current: SpaceDocument, + opts: { dirty: boolean }, +): RecoveryOffer | null { + if (opts.dirty) return null; + if (records.length === 0) return null; + + const mine = records.filter((r) => r.documentId === current.id); + const ahead = mostRecent(mine.filter((r) => time(r.modifiedAt) > time(current.modifiedAt))); + if (ahead) return { record: ahead, reason: 'newer' }; + + // Someone else's document, and nothing here to lose by offering it. + if (!isUntouched(current)) return null; + const orphan = mostRecent(records.filter((r) => r.documentId !== current.id)); + return orphan ? { record: orphan, reason: 'orphan' } : null; +} + +/** The sentence the recovery prompt shows. */ +export function recoveryMessage(offer: RecoveryOffer): string { + const when = new Date(offer.record.savedAt); + const stamp = Number.isNaN(when.getTime()) ? 'earlier' : when.toLocaleString(); + + return offer.reason === 'newer' + ? `This space has unsaved changes from ${stamp} that are newer than the file you opened.` + : `“${offer.record.title}” has unsaved changes from ${stamp} that were never saved to a file.`; +} 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/core/scene.test.ts b/src/core/scene.test.ts new file mode 100644 index 0000000..f5833af --- /dev/null +++ b/src/core/scene.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from 'vitest'; +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, createFloor, type Placement, type SpaceDocument } from './document'; + +let seq = 0; +const id = () => `id-${seq++}`; + +const RUG: ItemDraft = { + name: 'Rug', + category: 'rug', + shape: 'rect', + widthMm: 2000, + depthMm: 1400, + heightMm: 10, + voidBelowMm: 0, +}; + +const LAMP: ItemDraft = { + name: 'Lamp', + category: 'lighting', + shape: 'circle', + widthMm: 300, + depthMm: 300, + heightMm: 500, + voidBelowMm: 0, +}; + +/** A 5m × 4m room with four walls, and nothing in it. */ +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 }, over: Partial = {}) { + 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, + ...over, + }; + doc.floors[0]!.placements.push(placement); + return placement; +} + +describe('buildScene', () => { + it('turns each wall into one solid when nothing is cut into it', () => { + const doc = room(); + const scene = buildScene(doc, doc.floors[0]!); + + expect(scene.solids).toHaveLength(4); + expect(scene.solids.every((s) => s.span.bottom === 0 && s.span.top === 2438)).toBe(true); + expect(new Set(scene.solids.map((s) => s.ref.id)).size).toBe(4); + }); + + it('splits a wall around a doorway, leaving only the lintel above it', () => { + const doc = room(); + const north = doc.floors[0]!.walls[0]!; + doc.floors[0]!.openings.push( + createOpening({ id: 'o', wall: north, kind: 'door', centreMm: 2500 }), + ); + + const scene = buildScene(doc, doc.floors[0]!); + const pieces = scene.solids.filter((s) => s.ref.id === north.id); + + expect(pieces).toHaveLength(3); + // The only solid over the doorway starts at the head height. That, and nothing + // else, is what makes the doorway walkable. + const lintel = pieces.find((p) => p.span.bottom === 2032)!; + expect(lintel.span.top).toBe(2438); + expect(pieces.filter((p) => p.span.bottom === 0)).toHaveLength(2); + }); + + it('gives every room a floor and a ceiling slab', () => { + const doc = room(); + const scene = buildScene(doc, doc.floors[0]!); + + expect(scene.slabs.map((s) => s.kind).sort()).toEqual(['ceiling', 'floor']); + expect(scene.slabs.find((s) => s.kind === 'ceiling')!.elevationMm).toBe(2438); + }); + + it('carries a placement’s solid span, not its full height', () => { + // The same `voidBelowMm` the plan view and the collision engine use — a rug is + // 10mm of solid, and the 3D view must not draw or block more than that. + const doc = room(); + place(doc, RUG, { x: 2500, y: 2000 }); + + const scene = buildScene(doc, doc.floors[0]!); + const rug = scene.solids.find((s) => s.ref.kind === 'placement')!; + expect(rug.span).toEqual({ bottom: 0, top: 10 }); + }); + + it('raises a surface-mounted item onto its host', () => { + const doc = room(); + const host = place(doc, { ...RUG, name: 'Table', heightMm: 760, voidBelowMm: 720 }, { x: 2500, y: 2000 }); + place(doc, LAMP, { x: 2500, y: 2000 }, { mount: { kind: 'surface', hostId: host.id } }); + + const scene = buildScene(doc, doc.floors[0]!); + const lamp = scene.solids.find((s) => s.id !== host.id && s.ref.kind === 'placement')!; + expect(lamp.span).toEqual({ bottom: 760, top: 1260 }); + }); + + it('skips a placement whose item is gone rather than failing the whole view', () => { + const doc = room(); + place(doc, RUG, { x: 2500, y: 2000 }); + doc.catalog = []; + + const scene = buildScene(doc, doc.floors[0]!); + expect(scene.solids.every((s) => s.ref.kind === 'wall')).toBe(true); + }); + + it('skips a mount cycle rather than recursing into the renderer', () => { + const doc = room(); + const a = place(doc, LAMP, { x: 1000, y: 1000 }); + const b = place(doc, LAMP, { x: 1000, y: 1000 }); + a.mount = { kind: 'surface', hostId: b.id }; + b.mount = { kind: 'surface', hostId: a.id }; + + const scene = buildScene(doc, doc.floors[0]!); + expect(scene.solids.filter((s) => s.ref.kind === 'placement')).toHaveLength(0); + }); + + it('stands an over-dropped ceiling item on the floor rather than sinking it', () => { + // 3000mm of pendant in a 2438mm room resolves to an elevation of −562. The part + // below the slab would be invisible and would still block the walker, so it is + // clamped — the item is really in the room, and validation is what says the drop + // is wrong. + const doc = room(); + place(doc, { ...LAMP, heightMm: 3000 }, { x: 2500, y: 2000 }, { + mount: { kind: 'ceiling', drop: 0 }, + }); + + const scene = buildScene(doc, doc.floors[0]!); + const pendant = scene.solids.find((s) => s.ref.kind === 'placement')!; + expect(pendant.span).toEqual({ bottom: 0, top: 2438 }); + }); + + it('reports an empty floor as having no extent, rather than a point at the origin', () => { + const doc = createDocument({ id: 'd', floorId: 'f', now: '2026-01-01T00:00:00.000Z' }); + expect(buildScene(doc, doc.floors[0]!).bounds).toBeNull(); + }); +}); + +describe('blockersOf', () => { + it('is exactly the solids, as collision volumes', () => { + const doc = room(); + place(doc, RUG, { x: 2500, y: 2000 }); + const scene = buildScene(doc, doc.floors[0]!); + + expect(blockersOf(scene)).toHaveLength(scene.solids.length); + }); +}); + +describe('defaultStandpoint', () => { + it('is the middle of the largest room, not the origin', () => { + // A plan traced from an imported raster can sit anywhere; dropping the walker at + // 0,0 would routinely start them outside the building. + const doc = room(); + expect(defaultStandpoint(doc.floors[0]!, buildScene(doc, doc.floors[0]!))).toEqual({ + x: 2500, + y: 2000, + }); + }); + + it('falls back to the middle of everything drawn when no room is traced', () => { + const doc = room(); + doc.floors[0]!.rooms = []; + const scene = buildScene(doc, doc.floors[0]!); + const at = defaultStandpoint(doc.floors[0]!, scene); + + expect(at.x).toBeCloseTo(2500, 6); + expect(at.y).toBeCloseTo(2000, 6); + }); + + it('is the origin only when there is genuinely nothing', () => { + const doc = createDocument({ id: 'd', floorId: 'f', now: '2026-01-01T00:00:00.000Z' }); + expect(defaultStandpoint(doc.floors[0]!, buildScene(doc, doc.floors[0]!))).toEqual({ + x: 0, + y: 0, + }); + }); +}); + +describe('leaves in the scene', () => { + function withOpening(kind: 'door' | 'window' | 'pocket' | 'cased' | 'sliding') { + const doc = room(); + const wall = doc.floors[0]!.walls[0]!; + const opening = createOpening({ id: 'o1', wall, kind, centreMm: 2500 }); + doc.floors[0]!.openings.push(opening); + const scene = buildScene(doc, doc.floors[0]!); + return { scene, leaf: scene.solids.find((s) => s.id === 'o1:leaf') }; + } + + it('stands a door leaf where the door comes to rest', () => { + const { leaf } = withOpening('door'); + expect(leaf).toBeDefined(); + expect(leaf!.ref).toEqual({ kind: 'opening', id: 'o1' }); + }); + + it('does not let an open door narrow its own doorway', () => { + // A leaf is drawn open, and you cannot push it. Treating it as solid would make + // a doorway passable or not depending on how far the door happens to be swung. + const { scene, leaf } = withOpening('door'); + expect(leaf!.blocking).toBe(false); + expect(blockersOf(scene).some((v) => v.outline === leaf!.outline)).toBe(false); + }); + + it('glazes a window, and glass stops you', () => { + const { leaf } = withOpening('window'); + expect(leaf!.blocking).toBe(true); + expect(leaf!.opacity).toBeLessThan(1); + }); + + it('draws nothing for a pocket door or a cased opening', () => { + expect(withOpening('pocket').leaf).toBeUndefined(); + expect(withOpening('cased').leaf).toBeUndefined(); + }); + + it('parks a sliding leaf over the wall beside the opening', () => { + const { leaf } = withOpening('sliding'); + expect(leaf).toBeDefined(); + 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 new file mode 100644 index 0000000..276dd8e --- /dev/null +++ b/src/core/scene.ts @@ -0,0 +1,325 @@ +/** + * The 3D scene, derived from the document. See PLAN.md §10.1. + * + * There is no separate scene graph to keep in sync — everything here is computed from + * the same entities the plan view draws and the collision engine tests. A placement's + * `outline` is the polygon `worldOutline` already produces; a wall's boxes are the + * ones `wallSegments` already produces for openings. One primitive, three consumers. + * + * **This module knows nothing about three.js.** It emits document millimetres and + * Z-up spans; the renderer converts at its boundary through `docToThree`, and the + * walker consumes the same output without any renderer being involved at all. That + * separation is what makes traversal testable in a plain unit test, and what keeps + * the walk loop alive when WebGL is not. + */ + +import { findItem, type Floor, type Id, type Room, type SpaceDocument } from './document'; +import type { Span, Volume } from './geometry/collision'; +import { bounds, type Bounds, type Polygon } from './geometry/polygon'; +import { wallOutline } from './geometry/wall'; +import { openingSpan, segmentOutline, wallSegments } from './openings'; +import { leafOf, leafPanel } from './swing'; +import { MountCycleError, placementSpan, worldOutline } from './placement'; + +/** + * What a click in the 3D view resolves to, mirroring the editor's selection shape. + * + * `opening` is here because a door leaf is a thing you can point at. It is + * **structure**, not furniture — whoever consumes this has to gate it the way it + * gates walls, or the layer toggle stops meaning anything in 3D. + */ +export type SceneRef = { kind: 'wall' | 'placement' | 'opening'; id: Id }; + +/** + * A solid box: a plan polygon extruded between two elevations. + * + * Walls arrive already split around their openings, so a doorway is simply an absence + * of solid rather than a hole anything has to subtract. + */ +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; + color: string; + /** True when the walker's body must not pass through it. */ + blocking: boolean; + /** 1 for everything solid; less for glazing. */ + opacity: number; +}; + +/** A horizontal slab — a room's floor or its ceiling. */ +export type SceneSlab = { + id: string; + floorId: Id; + kind: 'floor' | 'ceiling'; + roomId: Id; + boundary: Polygon; + elevationMm: number; + color: string; +}; + +export type SceneModel = { + solids: SceneSolid[]; + slabs: SceneSlab[]; + /** Extent of everything, for framing a camera. Null when the floor is empty. */ + bounds: Bounds | null; + /** Tallest ceiling on the floor — how high a fly camera needs to clear. */ + ceilingHeightMm: number; +}; + +export const WALL_COLOR = '#d9d4cc'; +export const LEAF_COLOR = '#c9b79c'; +export const GLAZING_COLOR = '#bcd8e6'; +export const GLAZING_OPACITY = 0.35; +export const FLOOR_COLOR = '#efe9df'; +export const CEILING_COLOR = '#f6f3ee'; +export const DEFAULT_PLACEMENT_COLOR = '#8ba7c4'; + +/** + * Everything a renderer or a walker needs, for one floor. + * + * A placement whose item is missing, or whose mount is a cycle, contributes nothing + * rather than throwing: the validation panel is where a broken document is reported, + * and a renderer that dies on one bad entity takes the whole view with it. + */ +export function buildScene(doc: SpaceDocument, floor: Floor): SceneModel { + const solids: SceneSolid[] = []; + const slabs: SceneSlab[] = []; + + for (const wall of floor.walls) { + const segments = wallSegments(wall, floor.openings); + segments.forEach((segment, i) => { + let outline: Polygon; + try { + outline = segmentOutline(wall, segment); + } catch { + return; // degenerate wall — the validation panel's problem, not the renderer's + } + solids.push({ + id: `${wall.id}:${i}`, + floorId: floor.id, + ref: { kind: 'wall', id: wall.id }, + outline, + span: { bottom: segment.bottom, top: segment.top }, + color: WALL_COLOR, + blocking: true, + opacity: 1, + }); + }); + } + + // Leaves. A door is drawn where it comes to rest when open, a window is glazed, + // and a pocket door contributes nothing because its leaf is inside the wall. + 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; + const panel = leafPanel(wall, opening); + if (!panel) continue; + + 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), + color: glazing ? GLAZING_COLOR : LEAF_COLOR, + // Glass stops you; a door standing open does not. At an ordinary window the + // sill wall below already blocks, so this only decides the case that should + // decide differently — full-height glazing, which you cannot walk through. + // A door leaf is drawn open, and treating it as solid would narrow a doorway + // by however far it happens to have been swung. + blocking: glazing, + opacity: glazing ? GLAZING_OPACITY : 1, + }); + } + + for (const placement of floor.placements) { + const item = findItem(doc, placement.itemId); + if (!item) continue; + + let span: Span; + try { + span = placementSpan(doc, placement, item); + } catch (err) { + if (err instanceof MountCycleError) continue; + throw err; + } + // A ceiling mount resolves to `ceiling − drop − height`, which goes negative for + // a tall enough item — the pendant reaches the floor and keeps going. Clamp to + // the floor datum rather than skipping it: the thing really is in the room and + // should be seen and collided with, and validation is what says it is wrong. + // Geometry below the slab would be invisible and still block the walker. + const clamped = { bottom: Math.max(0, span.bottom), top: span.top }; + if (clamped.top <= clamped.bottom) continue; + + solids.push({ + id: placement.id, + floorId: floor.id, + ref: { kind: 'placement', id: placement.id }, + outline: worldOutline(placement, item), + span: clamped, + color: placement.overrides?.color ?? item.color ?? DEFAULT_PLACEMENT_COLOR, + blocking: true, + opacity: 1, + }); + } + + for (const room of floor.rooms) { + slabs.push({ + id: `${room.id}:floor`, + floorId: floor.id, + kind: 'floor', + roomId: room.id, + boundary: room.boundary, + elevationMm: 0, + color: room.floorColor ?? FLOOR_COLOR, + }); + slabs.push({ + id: `${room.id}:ceiling`, + floorId: floor.id, + kind: 'ceiling', + roomId: room.id, + boundary: room.boundary, + elevationMm: room.ceilingHeightMm, + color: CEILING_COLOR, + }); + } + + return { + solids, + slabs, + bounds: sceneBounds(solids, floor.rooms), + ceilingHeightMm: floor.rooms.reduce( + (h, r) => Math.max(h, r.ceilingHeightMm), + floor.defaultCeilingHeightMm, + ), + }; +} + +/** + * 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)); + for (const room of rooms) boxes.push(bounds(room.boundary)); + if (boxes.length === 0) return null; + + return boxes.reduce((acc, b) => ({ + minX: Math.min(acc.minX, b.minX), + minY: Math.min(acc.minY, b.minY), + maxX: Math.max(acc.maxX, b.maxX), + maxY: Math.max(acc.maxY, b.maxY), + })); +} + +/** The scene's solids as collision volumes — exactly what the walker tests against. */ +export function blockersOf(scene: SceneModel): Volume[] { + return scene.solids + .filter((s) => s.blocking) + .map((s) => ({ outline: s.outline, span: s.span })); +} + +/** + * A sensible place to stand when walk mode is entered without a saved position. + * + * The centre of the largest room, or the centre of everything drawn when no room has + * been traced. Not the origin: a plan traced from an imported raster can sit anywhere, + * and dropping the walker at 0,0 would routinely start them outside the building. + */ +export function defaultStandpoint(floor: Floor, scene: SceneModel): { x: number; y: number } { + const largest = floor.rooms.reduce( + (best, room) => (!best || room.areaMm2 > best.areaMm2 ? room : best), + null, + ); + const box = largest ? bounds(largest.boundary) : scene.bounds; + if (!box) return { x: 0, y: 0 }; + return { x: (box.minX + box.maxX) / 2, y: (box.minY + box.maxY) / 2 }; +} + +/** + * A wall's full outline, ignoring its openings. + * + * Only used for framing and for the plan-side hit test; the 3D view always draws the + * split segments, or doorways would be walled up again. + */ +export function fullWallOutline(floor: Floor, wallId: Id): Polygon | null { + const wall = floor.walls.find((w) => w.id === wallId); + if (!wall) return null; + try { + return wallOutline(wall); + } catch { + return null; + } +} diff --git a/src/core/schema-v1.test.ts b/src/core/schema-v1.test.ts new file mode 100644 index 0000000..ab00905 --- /dev/null +++ b/src/core/schema-v1.test.ts @@ -0,0 +1,92 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { readSpace, writeSpace } from './space-file'; +import { findFloor, findItem, findPlacement } from './document'; +import { SCHEMA_VERSION } from './document'; + +/** + * The frozen fixture. See `fixtures/make-schema-v1.mjs`. + * + * `schema-v1.space` was written once and committed. Nothing in this file regenerates + * it, and nothing should: a fixture produced by the same build that reads it asserts + * only that today's writer agrees with today's reader. + * + * When a second schema version arrives, these tests do not change. They keep asserting + * that a schema-1 file opens — through whatever migration chain is by then required — + * and the correct response to a failure is a migration, not a new fixture. + */ + +const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'schema-v1.space'); +const bytes = () => new Uint8Array(readFileSync(FIXTURE)); + +describe('a schema-1 container written before this build', () => { + it('opens', () => { + const { document } = readSpace(bytes()); + expect(document.title).toBe('Schema 1 fixture'); + expect(document.schemaVersion).toBe(SCHEMA_VERSION); + expect(document.floors).toHaveLength(2); + expect(document.catalog).toHaveLength(3); + }); + + it('carries the asset bytes, not just the manifest entry', () => { + // The failure this guards is silent and remote: a container that opens fine here + // and shows a blank background on the recipient's machine. + const { document, assets } = readSpace(bytes()); + const ref = document.assets[0]; + expect(ref?.path).toBe('assets/asset-plan.png'); + expect(assets[ref!.path]).toBeInstanceOf(Uint8Array); + expect(assets[ref!.path]!.byteLength).toBe(ref!.bytes); + }); + + it('keeps integer millimetres exactly', () => { + const { document } = readSpace(bytes()); + const wall = findFloor(document, 'floor-ground')?.walls.find((w) => w.id === 'w-e'); + expect(wall?.b).toEqual({ x: 4200, y: 3600 }); + expect(wall?.thicknessMm).toBe(114); + expect(findItem(document, 'item-bed')?.widthMm).toBe(1524); + }); + + it('still resolves a surface mount to its host', () => { + const { document } = readSpace(bytes()); + const lamp = findPlacement(document, 'p-lamp'); + expect(lamp?.mount).toEqual({ kind: 'surface', hostId: 'p-dresser' }); + expect(findPlacement(document, 'p-dresser')).toBeDefined(); + }); + + it('keeps an opening attached to its wall, at its offset', () => { + const { document } = readSpace(bytes()); + const ground = findFloor(document, 'floor-ground'); + const door = ground?.openings.find((o) => o.id === 'o-door'); + expect(door?.wallId).toBe('w-s'); + expect(door?.offsetMm).toBe(1600); + expect(door?.swing).toEqual({ hinge: 'a', into: 'front', angleDeg: 90 }); + // A window's sill is measured from the floor datum, not the wall base — the kind + // of field that reads plausibly whichever way a future refactor moves it. + expect(ground?.openings.find((o) => o.id === 'o-window')?.sillMm).toBe(914); + }); + + it('keeps the upper floor off the ground', () => { + const { document } = readSpace(bytes()); + const upper = findFloor(document, 'floor-upper'); + expect(upper?.index).toBe(1); + expect(upper?.elevationMm).toBe(2738); + expect(upper?.defaultCeilingHeightMm).toBe(2438); + }); + + it('keeps a calibrated background calibrated', () => { + const { document } = readSpace(bytes()); + const bg = findFloor(document, 'floor-ground')?.background; + expect(bg?.calibration?.mmPerPx).toBe(4200); + expect(bg?.locked).toBe(true); + expect(bg?.pixelSize).toEqual({ width: 1, height: 1 }); + }); + + it('survives a trip through the writer this build ships, unchanged', () => { + const first = readSpace(bytes()); + const again = readSpace(writeSpace(first)); + expect(again.document).toEqual(first.document); + expect(again.assets).toEqual(first.assets); + }); +}); diff --git a/src/core/space-file.test.ts b/src/core/space-file.test.ts index 5c0ee56..9772cba 100644 --- a/src/core/space-file.test.ts +++ b/src/core/space-file.test.ts @@ -118,6 +118,7 @@ function fixture(): SpaceDocument { floor.background = { assetId: 'asset-bg', pageIndex: 0, + pixelSize: { width: 1600, height: 1200 }, calibration: { refA: { x: 100, y: 100 }, refB: { x: 500, y: 100 }, @@ -160,7 +161,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/core/swing.test.ts b/src/core/swing.test.ts new file mode 100644 index 0000000..cc9c066 --- /dev/null +++ b/src/core/swing.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, it } from 'vitest'; +import { + ARC_STEP_DEG, + DEFAULT_SWING, + LEAF_THICKNESS_MM, + clampSwingAngle, + clearanceVolume, + leafOf, + leafPanel, + movingLeafOf, + parkRun, + pocketFitReason, + swingSweep, +} from './swing'; +import { volumesCollide } from './geometry/collision'; +import { area, bounds, containsPoint, polygon } from './geometry/polygon'; +import type { Opening, Wall } from './document'; + +/** A 5m wall running east from the origin, 2438 high, 114 thick. Front is +y. */ +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, + }; +} + +/** + * Whether the sweep covers a point. + * + * Deliberately not "is vertex 0 the hinge": `ensureCounterClockwise` reverses the + * ring when the turn goes the other way, so vertex *order* is not a contract — and + * a mirrored door is exactly the case that reverses it. What the sector covers is + * the thing that matters to every consumer anyway. + */ +function covers(wall: Wall, o: Opening, p: { x: number; y: number }): boolean { + return containsPoint(swingSweep(wall, o)!, p); +} + +function hasVertex(poly: { pts: readonly { x: number; y: number }[] }, p: { x: number; y: number }) { + return poly.pts.some((v) => Math.abs(v.x - p.x) < 1e-6 && Math.abs(v.y - p.y) < 1e-6); +} + +describe('reading a leaf off an opening', () => { + it('gives a door a hinge, a side and an angle', () => { + expect(leafOf(opening())).toEqual({ + style: 'hinged', + pivot: 'a', + face: 'front', + angleDeg: 90, + }); + }); + + it('gives a sliding door no angle to read', () => { + // The point of the kind-aware shape: nothing downstream can take a swing angle + // off a door that does not swing. + expect(leafOf(opening({ kind: 'sliding' }))).toEqual({ + style: 'sliding', + pivot: 'a', + face: 'front', + }); + }); + + it('gives a pocket door no side, because it goes inside the wall', () => { + expect(leafOf(opening({ kind: 'pocket' }))).toEqual({ style: 'pocket', pivot: 'a' }); + }); + + it('gives a cased opening and a window no moving leaf', () => { + expect(leafOf(opening({ kind: 'cased' }))).toEqual({ style: 'none' }); + expect(leafOf(opening({ kind: 'window' }))).toEqual({ style: 'pane' }); + }); + + it('falls back to a standard swing when the document has none', () => { + // Every opening written before this phase has no `swing`, and none of them + // should read as a door hinged nowhere. + const legacy = opening(); + delete legacy.swing; + expect(leafOf(legacy)).toEqual({ + style: 'hinged', + pivot: DEFAULT_SWING.hinge, + face: DEFAULT_SWING.into, + angleDeg: DEFAULT_SWING.angleDeg, + }); + }); + + it('keeps a stored swing readable through a kind that ignores it', () => { + const stored = opening({ kind: 'cased', swing: { hinge: 'b', into: 'back', angleDeg: 45 } }); + expect(leafOf(stored)).toEqual({ style: 'none' }); + expect(leafOf({ ...stored, kind: 'door' })).toEqual({ + style: 'hinged', + pivot: 'b', + face: 'back', + angleDeg: 45, + }); + }); + + it('clamps a nonsense angle rather than drawing one', () => { + expect(clampSwingAngle(0)).toBe(15); + expect(clampSwingAngle(400)).toBe(180); + expect(clampSwingAngle(Number.NaN)).toBe(90); + }); + + it('offers a leaf to hang only where there is one', () => { + for (const kind of ['door', 'sliding', 'pocket'] as const) { + expect(movingLeafOf(opening({ kind }))?.pivot).toBe('a'); + } + expect(movingLeafOf(opening({ kind: 'cased' }))).toBeNull(); + expect(movingLeafOf(opening({ kind: 'window' }))).toBeNull(); + }); +}); + +describe('the swept sector', () => { + it('hinges on the face it opens onto, not on the centreline', () => { + // Half a wall thickness off the centre: 57mm on a 114 wall. Small enough to be + // invisible in a drawing and large enough to bias every clearance answer. + expect(hasVertex(swingSweep(WALL, opening())!, { x: 1000, y: 57 })).toBe(true); + }); + + it('opens a quarter turn onto the front of the wall', () => { + const sweep = swingSweep(WALL, opening())!; + expect(hasVertex(sweep, { x: 1000, y: 870 })).toBe(true); // 57 + 813 + expect(bounds(sweep)).toEqual({ minX: 1000, minY: 57, maxX: 1813, maxY: 870 }); + }); + + it('mirrors when the hinge moves to the other jamb, and stays on the same side', () => { + // The two sectors have the same bounding box — they are reflections of each + // other about the middle of the opening — so the assertion has to be about what + // each one covers. Near the far jamb is inside one and outside the other. + const byA = opening(); + const byB = opening({ swing: { hinge: 'b', into: 'front', angleDeg: 90 } }); + + expect(covers(WALL, byA, { x: 1050, y: 800 })).toBe(true); + expect(covers(WALL, byA, { x: 1760, y: 800 })).toBe(false); + expect(covers(WALL, byB, { x: 1760, y: 800 })).toBe(true); + expect(covers(WALL, byB, { x: 1050, y: 800 })).toBe(false); + }); + + it('turns the other way when it opens onto the back', () => { + const back = opening({ swing: { hinge: 'a', into: 'back', angleDeg: 90 } }); + expect(hasVertex(swingSweep(WALL, back)!, { x: 1000, y: -57 })).toBe(true); + expect(covers(WALL, back, { x: 1050, y: -800 })).toBe(true); + expect(covers(WALL, back, { x: 1050, y: 800 })).toBe(false); + }); + + it('covers the true sector area, not a chord-cut approximation of it', () => { + // The polygon is inscribed, so it always under-reports; what matters is by how + // little. A quarter of an 813 radius circle is 519,163mm², and 18 chords over + // 90° leave a sagitta of 0.8mm — under the width of anything worth reporting. + const sweep = swingSweep(WALL, opening())!; + const exact = (Math.PI * 813 * 813) / 4; + expect(area(sweep)).toBeGreaterThan(exact * 0.998); + expect(area(sweep)).toBeLessThanOrEqual(exact); + }); + + it('uses more vertices for a wider swing', () => { + // One hinge, then a vertex per step plus the closing one. + expect(swingSweep(WALL, opening())!.pts).toHaveLength(2 + 90 / ARC_STEP_DEG); + const half = swingSweep(WALL, opening({ swing: { hinge: 'a', into: 'front', angleDeg: 180 } }))!; + expect(half.pts).toHaveLength(2 + 180 / ARC_STEP_DEG); + }); + + it('is nothing at all for the styles that do not swing', () => { + for (const kind of ['sliding', 'pocket', 'cased', 'window'] as const) { + expect(swingSweep(WALL, opening({ kind }))).toBeNull(); + } + }); +}); + +describe('the leaf panel', () => { + it('stands where the door comes to rest', () => { + const panel = leafPanel(WALL, opening())!; + const box = bounds(panel); + expect(box.maxY).toBeCloseTo(870, 6); + expect(box.maxX - box.minX).toBeCloseTo(LEAF_THICKNESS_MM, 6); + }); + + it('parks a sliding leaf over the wall beside the opening, standing off its face', () => { + const panel = leafPanel(WALL, opening({ kind: 'sliding' }))!; + const box = bounds(panel); + expect(box.minX).toBeCloseTo(187, 6); // 1000 − 813 + expect(box.maxX).toBeCloseTo(1000, 6); + // 57 wall + 20 standoff, then the leaf's own 35. + expect(box.minY).toBeCloseTo(77, 6); + expect(box.maxY).toBeCloseTo(112, 6); + }); + + it('draws no pocket leaf, because it is inside the wall', () => { + expect(leafPanel(WALL, opening({ kind: 'pocket' }))).toBeNull(); + expect(leafPanel(WALL, opening({ kind: 'cased' }))).toBeNull(); + }); + + it('glazes a window in the plane of the wall', () => { + const pane = leafPanel(WALL, opening({ kind: 'window', sillMm: 914, heightMm: 1219 }))!; + const box = bounds(pane); + expect(box.minX).toBeCloseTo(1000, 6); + expect(box.maxX).toBeCloseTo(1813, 6); + expect(box.maxY - box.minY).toBeCloseTo(LEAF_THICKNESS_MM / 2, 6); + }); +}); + +describe('what has to stay clear', () => { + /** A 600 × 600 box, floor to 900, centred at `x, y`. */ + function box(x: number, y: number) { + return { + outline: polygon([ + { x: x - 300, y: y - 300 }, + { x: x + 300, y: y - 300 }, + { x: x + 300, y: y + 300 }, + { x: x - 300, y: y + 300 }, + ]), + span: { bottom: 0, top: 900 }, + }; + } + + it('reports a chest of drawers standing in the swing', () => { + const volume = clearanceVolume(WALL, opening())!; + expect(volumesCollide(volume, box(1400, 400))).toBe(true); + }); + + it('leaves the same chest alone once the door is hung the other way', () => { + const other = opening({ swing: { hinge: 'a', into: 'back', angleDeg: 90 } }); + expect(volumesCollide(clearanceVolume(WALL, other)!, box(1400, 400))).toBe(false); + }); + + it('catches a thin object at the outer edge of the arc', () => { + // The chord-versus-arc case the vertex count exists for. This box sits at 22.5° + // from the hinge, 750–803mm out — comfortably inside the true sector, and + // outside the polygon a 45°-per-step arc would produce, because the chord + // between two coarse vertices cuts across in front of it. + const sliver = { + outline: polygon([ + { x: 1700, y: 330 }, + { x: 1740, y: 330 }, + { x: 1740, y: 370 }, + { x: 1700, y: 370 }, + ]), + span: { bottom: 0, top: 900 }, + }; + expect(volumesCollide(clearanceVolume(WALL, opening())!, sliver)).toBe(true); + }); + + it('lets a rug lie under the door', () => { + // The sweep has the opening's own vertical extent, so anything below the sill + // is not in the way — the same span test every other collision here uses. + const rug = { outline: box(1400, 400).outline, span: { bottom: 0, top: 5 } }; + const door = opening({ sillMm: 20 }); + expect(volumesCollide(clearanceVolume(WALL, door)!, rug)).toBe(false); + }); + + it('reports a bookcase where a sliding door has to park', () => { + const slider = opening({ kind: 'sliding' }); + expect(volumesCollide(clearanceVolume(WALL, slider)!, box(600, 120))).toBe(true); + }); + + it('asks nothing of the room for a pocket door — that is the whole point of one', () => { + expect(clearanceVolume(WALL, opening({ kind: 'pocket' }))).toBeNull(); + expect(clearanceVolume(WALL, opening({ kind: 'cased' }))).toBeNull(); + expect(clearanceVolume(WALL, opening({ kind: 'window' }))).toBeNull(); + }); +}); + +describe('a pocket door needs a cavity', () => { + it('is happy with a leaf width of wall beside it', () => { + expect(parkRun(opening(), 'a')).toEqual({ from: 187, to: 1000 }); + expect(pocketFitReason(WALL, opening({ kind: 'pocket' }), [])).toBeNull(); + }); + + it('says how much wall it is short of when it runs off the end', () => { + const tooNearTheCorner = opening({ kind: 'pocket', offsetMm: 400 }); + expect(pocketFitReason(WALL, tooNearTheCorner, [])).toContain('413mm short'); + }); + + it('slides the other way when the hinge end is flipped', () => { + const flipped = opening({ + kind: 'pocket', + offsetMm: 400, + swing: { hinge: 'b', into: 'front', angleDeg: 90 }, + }); + expect(parkRun(flipped, 'b')).toEqual({ from: 1213, to: 2026 }); + expect(pocketFitReason(WALL, flipped, [])).toBeNull(); + }); + + it('refuses to share a cavity with another opening', () => { + const pocket = opening({ kind: 'pocket' }); + const window = opening({ id: 'o2', offsetMm: 300, widthMm: 914, kind: 'window' }); + expect(pocketFitReason(WALL, pocket, [pocket, window])).toContain('window'); + }); + + it('has nothing to say about a door that swings', () => { + expect(pocketFitReason(WALL, opening(), [])).toBeNull(); + }); +}); diff --git a/src/core/swing.ts b/src/core/swing.ts new file mode 100644 index 0000000..67e197f --- /dev/null +++ b/src/core/swing.ts @@ -0,0 +1,390 @@ +/** + * What an opening's leaf does. See PLAN.md §4.4, §9.2 and §10.1. + * + * Phase 5 cut the hole; this is the door that lives in it. Five kinds of opening + * behave in four different ways, and the difference is not decoration — it is the + * whole reason the kinds exist: + * + * | kind | leaf | what has to stay clear | + * |----------|------------------------------|-----------------------------------| + * | `door` | hinged, swings into a room | the swept sector | + * | `sliding`| slides across the wall face | the wall it parks over, room-side | + * | `pocket` | slides *into* the wall | nothing in the room | + * | `cased` | none | nothing | + * | `window` | fixed glazing | nothing | + * + * A pocket door needing no room-side clearance is the entire argument for fitting + * one, so a model that drew it the same as a sliding door would be answering the + * question wrong rather than approximately. + * + * ## One stored field, a kind-aware reading of it + * + * The document stores `Opening.swing` — `{ hinge, into, angleDeg }` — and nothing + * else. Everything here reads it through `leafOf`, which returns a shape named for + * the *leaf* rather than the swing, and which structurally omits what does not + * apply: a sliding leaf has no `angleDeg` to read, a pocket leaf has no `face`. That + * is what stops a sliding door from quietly acquiring a swing angle nobody set. + * + * The stored field is **kept across a kind change**. A door turned into a cased + * opening and back is the door you had, hinged on the same side, rather than one + * re-seeded from defaults. The cost is a `swing` sitting unread on a cased opening + * in the file; the alternative is losing a choice the user made, silently. + * + * ## The hinge is on the face, not the centreline + * + * A door hangs in the jamb on the side it opens onto, so the pivot sits half a wall + * thickness off the centreline. On a 114mm wall that is 57mm — invisible in a + * drawing, and a systematic bias in every clearance answer if it is skipped. + * + * ## Clearance is tested against placements only + * + * PLAN.md §9.2 says "object volumes", and it means furniture. A door swinging back + * to rest against the adjacent wall is how doors are hung, not a fault, and testing + * the sweep against walls would fire on every door in a corner. Walls are what the + * opening is *in*; they are not in its way. + */ + +import type { Opening, OpeningKind, Wall } from './document'; +import type { Span, Volume } from './geometry/collision'; +import { ensureCounterClockwise, polygon, type Polygon } from './geometry/polygon'; +import { add, normalize, perp, rotate, scale, sub, toRadians, type Vec2 } from './geometry/vec'; +import { isDegenerate, wallLength } from './geometry/wall'; +import { OPENING_KIND_LABELS, openingRange, openingSpan, rangesOverlap, type Range } from './openings'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** A door leaf is 35mm; a 1-3/8" interior slab is 35mm exactly. */ +export const LEAF_THICKNESS_MM = 35; + +/** How far a surface-mounted sliding leaf stands off the wall it runs across. */ +export const SLIDE_STANDOFF_MM = 20; + +/** + * A door that opens less than this is a door that does not open; one that opens + * more than 180° has gone through the wall. + */ +export const MIN_SWING_DEG = 15; +export const MAX_SWING_DEG = 180; + +/** + * How finely the swept sector is polygonised — roughly one vertex per 5°. + * + * Not a cosmetic setting. The sector is a *collision* polygon, and a chord cuts + * inside the true arc: too few vertices and a narrow object sitting at the outer + * edge of the sweep falls into the gap between chord and arc and is never reported. + * At 5° the sagitta on an 813mm leaf is under 1mm. + */ +export const ARC_STEP_DEG = 5; + +// --------------------------------------------------------------------------- +// The leaf +// --------------------------------------------------------------------------- + +export type LeafStyle = 'hinged' | 'sliding' | 'pocket' | 'pane' | 'none'; + +export const LEAF_STYLE: Record = { + door: 'hinged', + window: 'pane', + cased: 'none', + pocket: 'pocket', + sliding: 'sliding', +}; + +/** The stored field, non-optional. */ +export type Swing = NonNullable; + +export const DEFAULT_SWING: Swing = { hinge: 'a', into: 'front', angleDeg: 90 }; + +/** + * The leaf, read for the kind the opening actually is. + * + * `pivot` is the end of the opening the leaf is fixed at — hinged there for a door, + * parked there when open for a slider. `face` is the side of the wall it lies on: + * `front` is the `perp(direction)` side, which is the wall's left as you look from + * `a` toward `b`, and is what the 2D arc draws so nobody has to hold it in their head. + */ +export type Leaf = + | { style: 'hinged'; pivot: 'a' | 'b'; face: 'front' | 'back'; angleDeg: number } + | { style: 'sliding'; pivot: 'a' | 'b'; face: 'front' | 'back' } + | { style: 'pocket'; pivot: 'a' | 'b' } + | { style: 'pane' } + | { style: 'none' }; + +export function clampSwingAngle(deg: number): number { + if (!Number.isFinite(deg)) return DEFAULT_SWING.angleDeg; + return Math.round(Math.max(MIN_SWING_DEG, Math.min(MAX_SWING_DEG, deg))); +} + +export function leafOf(opening: Opening): Leaf { + const swing = opening.swing ?? DEFAULT_SWING; + switch (LEAF_STYLE[opening.kind]) { + case 'hinged': + return { + style: 'hinged', + pivot: swing.hinge, + face: swing.into, + angleDeg: clampSwingAngle(swing.angleDeg), + }; + case 'sliding': + return { style: 'sliding', pivot: swing.hinge, face: swing.into }; + case 'pocket': + return { style: 'pocket', pivot: swing.hinge }; + case 'pane': + return { style: 'pane' }; + case 'none': + return { style: 'none' }; + } +} + +export const LEAF_STYLE_LABELS: Record = { + hinged: 'Hinged', + sliding: 'Slides across the wall', + pocket: 'Slides into the wall', + pane: 'Fixed glazing', + none: 'No leaf', +}; + +/** A leaf that is fixed at one end of the opening and moves. */ +export type MovingLeaf = Extract; + +/** + * The leaf, when there is one to hang. Null for a cased opening or a window. + * + * The narrowing is the point: a caller that gets a `MovingLeaf` back can ask which + * end it is fixed at without a cast, and still cannot read an angle off a slider. + * This is what the properties panel offers its controls on. + */ +export function movingLeafOf(opening: Opening): MovingLeaf | null { + const leaf = leafOf(opening); + return leaf.style === 'pane' || leaf.style === 'none' ? null : leaf; +} + +// --------------------------------------------------------------------------- +// The wall frame +// --------------------------------------------------------------------------- + +type WallFrame = { + dir: Vec2; + /** The `front` side normal. */ + normal: Vec2; + halfThickness: number; + length: number; +}; + +function frameOf(wall: Wall): WallFrame | null { + if (isDegenerate(wall)) return null; + const dir = normalize(sub(wall.b, wall.a)); + return { + dir, + normal: perp(dir), + halfThickness: wall.thicknessMm / 2, + length: wallLength(wall), + }; +} + +/** A point on the wall: `t` mm along the centreline, `offset` mm to the front side. */ +function pointOn(wall: Wall, frame: WallFrame, t: number, offset: number): Vec2 { + return { + x: wall.a.x + frame.dir.x * t + frame.normal.x * offset, + y: wall.a.y + frame.dir.y * t + frame.normal.y * offset, + }; +} + +/** Along-wall coordinate of the leaf's fixed end. */ +function pivotAt(opening: Opening, pivot: 'a' | 'b'): number { + const range = openingRange(opening); + return pivot === 'a' ? range.from : range.to; +} + +function faceSign(face: 'front' | 'back'): number { + return face === 'front' ? 1 : -1; +} + +/** + * Which way the leaf turns, as a sign on the rotation. + * + * The closed leaf points from its pivot toward the far jamb: `+dir` from the `a` + * end, `−dir` from the `b` end. Opening it turns that vector onto the face normal, + * and `rotate` is counter-clockwise, so reaching `+perp(dir)` from `+dir` is a + * positive turn and every other combination flips one sign. + */ +function turnSign(pivot: 'a' | 'b', face: 'front' | 'back'): number { + return (pivot === 'a' ? 1 : -1) * faceSign(face); +} + +function closedDirection(frame: WallFrame, pivot: 'a' | 'b'): Vec2 { + return pivot === 'a' ? frame.dir : scale(frame.dir, -1); +} + +/** A rectangle `thickness` wide about the segment `from → to`. */ +function slab(from: Vec2, to: Vec2, thickness: number): Polygon | null { + const along = sub(to, from); + if (along.x === 0 && along.y === 0) return null; + const n = scale(perp(normalize(along)), thickness / 2); + return ensureCounterClockwise( + polygon([add(from, n), add(to, n), sub(to, n), sub(from, n)]), + ); +} + +// --------------------------------------------------------------------------- +// Swept area +// --------------------------------------------------------------------------- + +/** + * The sector a hinged leaf sweeps, from closed to fully open, as a plan polygon. + * + * Null for every other style — a slider sweeps nothing. Drawn as the door symbol in + * 2D (its boundary *is* the closed leaf, the arc, and the open leaf) and tested + * against furniture in 3D. One polygon, two consumers, no second formula. + */ +export function swingSweep(wall: Wall, opening: Opening): Polygon | null { + const leaf = leafOf(opening); + if (leaf.style !== 'hinged') return null; + const frame = frameOf(wall); + if (!frame || opening.widthMm <= 0) return null; + + const hinge = pointOn( + wall, + frame, + pivotAt(opening, leaf.pivot), + frame.halfThickness * faceSign(leaf.face), + ); + const base = closedDirection(frame, leaf.pivot); + const sign = turnSign(leaf.pivot, leaf.face); + const steps = Math.max(2, Math.ceil(leaf.angleDeg / ARC_STEP_DEG)); + + const pts: Vec2[] = [hinge]; + for (let i = 0; i <= steps; i++) { + const turned = rotate(base, sign * toRadians((leaf.angleDeg * i) / steps)); + pts.push(add(hinge, scale(turned, opening.widthMm))); + } + return ensureCounterClockwise(polygon(pts)); +} + +/** The leaf itself, where it comes to rest when open. Null when there is nothing to draw. */ +export function leafPanel(wall: Wall, opening: Opening): Polygon | null { + const leaf = leafOf(opening); + const frame = frameOf(wall); + if (!frame || opening.widthMm <= 0) return null; + + switch (leaf.style) { + case 'hinged': { + const hinge = pointOn( + wall, + frame, + pivotAt(opening, leaf.pivot), + frame.halfThickness * faceSign(leaf.face), + ); + const turned = rotate( + closedDirection(frame, leaf.pivot), + turnSign(leaf.pivot, leaf.face) * toRadians(leaf.angleDeg), + ); + return slab(hinge, add(hinge, scale(turned, opening.widthMm)), LEAF_THICKNESS_MM); + } + case 'sliding': { + // Standing off the wall face by the runner gap, so it does not z-fight with + // the wall it parks over. + const run = parkRun(opening, leaf.pivot); + const offset = + (frame.halfThickness + SLIDE_STANDOFF_MM + LEAF_THICKNESS_MM / 2) * faceSign(leaf.face); + return slab( + pointOn(wall, frame, run.from, offset), + pointOn(wall, frame, run.to, offset), + LEAF_THICKNESS_MM, + ); + } + case 'pocket': + // Inside the wall cavity. There is nothing to see, and drawing it would put a + // slab inside a solid — `pocketFitReason` is how a pocket door is checked. + return null; + case 'pane': + // Glazing, filling the opening in the plane of the wall. + return slab( + pointOn(wall, frame, openingRange(opening).from, 0), + pointOn(wall, frame, openingRange(opening).to, 0), + LEAF_THICKNESS_MM / 2, + ); + case 'none': + return null; + } +} + +/** + * The stretch of wall a sliding or pocket leaf occupies when open, in mm from `a`. + * + * One leaf width beyond the jamb it parks at — which is why a sliding door needs as + * much wall beside it as the door is wide, and why one placed too near a corner has + * nowhere to go. + */ +export function parkRun(opening: Opening, pivot: 'a' | 'b'): Range { + const range = openingRange(opening); + return pivot === 'a' + ? { from: range.from - opening.widthMm, to: range.from } + : { from: range.to, to: range.to + opening.widthMm }; +} + +// --------------------------------------------------------------------------- +// Clearance +// --------------------------------------------------------------------------- + +/** + * The volume this opening's leaf needs kept clear of furniture, or null when it + * needs none. + * + * The vertical extent is the opening's own — a door blocks from its sill to its + * head, so a rug under it is not in the way and a bookcase beside it is. That is the + * same span/footprint pair every other collision in the application uses, which is + * why this can be handed straight to `volumesCollide`. + */ +export function clearanceVolume(wall: Wall, opening: Opening): Volume | null { + const leaf = leafOf(opening); + const span: Span = openingSpan(opening); + + if (leaf.style === 'hinged') { + const sweep = swingSweep(wall, opening); + return sweep ? { outline: sweep, span } : null; + } + if (leaf.style === 'sliding') { + const panel = leafPanel(wall, opening); + return panel ? { outline: panel, span } : null; + } + // A pocket door parks inside the wall, a cased opening has no leaf, and a window + // pane does not move. None of them can be obstructed by anything in the room. + return null; +} + +/** + * Why a pocket door has nowhere to slide, or null when it has. + * + * The one check that makes a pocket door more than a rendering variant: the cavity + * has to exist. It needs a full leaf width of wall beyond the jamb, and that stretch + * has to be solid — another opening in it means the two share a cavity, which is a + * wall that cannot be built. + */ +export function pocketFitReason( + wall: Wall, + opening: Opening, + siblings: readonly Opening[], +): string | null { + const leaf = leafOf(opening); + if (leaf.style !== 'pocket') return null; + + const frame = frameOf(wall); + if (!frame) return null; + const run = parkRun(opening, leaf.pivot); + const label = OPENING_KIND_LABELS[opening.kind]; + + if (run.from < 0 || run.to > frame.length) { + const short = Math.round(run.from < 0 ? -run.from : run.to - frame.length); + return `${label} needs ${opening.widthMm}mm of wall to slide into and is ${short}mm short of it.`; + } + for (const other of siblings) { + if (other.id === opening.id || other.wallId !== opening.wallId) continue; + if (rangesOverlap(run, openingRange(other))) { + return `${label} would slide into ${OPENING_KIND_LABELS[other.kind].toLowerCase()}.`; + } + } + return null; +} diff --git a/src/core/thumbnail.test.ts b/src/core/thumbnail.test.ts new file mode 100644 index 0000000..2d2d1ad --- /dev/null +++ b/src/core/thumbnail.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest'; +import { + THUMBNAIL_PX, + thumbnailBounds, + thumbnailFit, + thumbnailShapes, +} from './thumbnail'; +import { createDocument, createFloor, type CatalogItem, type SpaceDocument } from './document'; +import { polygon } from './geometry/polygon'; +import { rectFootprint } from './geometry/footprint'; + +function doc(): SpaceDocument { + return createDocument({ id: 'd', floorId: 'ground', now: '2026-01-01T00:00:00.000Z' }); +} + +function item(over: Partial = {}): CatalogItem { + return { + id: 'i', + name: 'Dresser', + category: 'storage', + widthMm: 1200, + depthMm: 400, + heightMm: 800, + voidBelowMm: 0, + canHostSurface: true, + footprint: rectFootprint(1200, 400), + defaultMount: 'floor', + color: '#888', + quantityOwned: 1, + ...over, + }; +} + +function wall(id: string, ax: number, ay: number, bx: number, by: number) { + return { + id, + a: { x: ax, y: ay }, + b: { x: bx, y: by }, + thicknessMm: 114, + heightMm: 2438, + baseElevationMm: 0, + }; +} + +describe('framing the picture', () => { + it('fits a wide plan by its width and centres it vertically', () => { + const fit = thumbnailFit({ minX: 0, minY: 0, maxX: 8000, maxY: 2000 }, 512, 16); + + // 480 available pixels over 8000mm. + expect(fit.scale).toBeCloseTo(480 / 8000, 10); + expect(fit.x).toBeCloseTo(16, 6); + // 2000mm at that scale is 120px, so 196px of margin above and below. + expect(fit.y).toBeCloseTo((512 - 120) / 2, 6); + }); + + it('keeps proportions rather than filling the square', () => { + const fit = thumbnailFit({ minX: 0, minY: 0, maxX: 8000, maxY: 2000 }); + const w = 8000 * fit.scale; + const h = 2000 * fit.scale; + expect(w / h).toBeCloseTo(4, 6); + }); + + it('does not divide by zero on a single wall', () => { + // One horizontal wall has no vertical extent at all. Rendering it as a line is + // fine; throwing here would fail the save, and a thumbnail is never worth that. + const fit = thumbnailFit({ minX: 0, minY: 500, maxX: 4000, maxY: 500 }); + expect(Number.isFinite(fit.scale)).toBe(true); + expect(fit.scale).toBeGreaterThan(0); + }); + + it('places the extent inside the padding, both corners', () => { + const box = { minX: -3000, minY: -1000, maxX: 1000, maxY: 5000 }; + const fit = thumbnailFit(box, 512, 16); + + const topLeft = { x: box.minX * fit.scale + fit.x, y: box.minY * fit.scale + fit.y }; + const bottomRight = { x: box.maxX * fit.scale + fit.x, y: box.maxY * fit.scale + fit.y }; + + // Negative document coordinates are the case a naive `scale`-only transform gets + // wrong: the plan is drawn off the top-left of the canvas and the file ships a + // blank square that looks like a rendering bug rather than a maths one. + for (const p of [topLeft, bottomRight]) { + expect(p.x).toBeGreaterThanOrEqual(16 - 0.001); + expect(p.y).toBeGreaterThanOrEqual(16 - 0.001); + expect(p.x).toBeLessThanOrEqual(THUMBNAIL_PX - 16 + 0.001); + expect(p.y).toBeLessThanOrEqual(THUMBNAIL_PX - 16 + 0.001); + } + }); +}); + +describe('what goes in the picture', () => { + it('has nothing to draw on an empty floor', () => { + const d = doc(); + expect(thumbnailBounds(d, d.floors[0]!)).toBeNull(); + }); + + it('frames furniture that stands outside the walls', () => { + // An inventory-first document — items placed before any structure exists — has a + // perfectly good picture and no walls at all. Framing on `floorBounds`, which + // covers rooms and walls only, would produce nothing for it. + const d = doc(); + d.catalog.push(item()); + d.floors[0]!.placements.push({ + id: 'p', + itemId: 'i', + floorId: 'ground', + position: { x: 6000, y: 6000 }, + rotation: 0, + mount: { kind: 'floor' }, + elevation: 0, + }); + + const box = thumbnailBounds(d, d.floors[0]!); + expect(box).not.toBeNull(); + expect(box!.maxX).toBeGreaterThan(6000); + }); + + it('draws rooms, walls and placements, each as a closed ring', () => { + const d = doc(); + const floor = d.floors[0]!; + floor.walls.push(wall('w', 0, 0, 4000, 0)); + floor.rooms.push({ + id: 'r', + name: '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, + }); + d.catalog.push(item()); + floor.placements.push({ + id: 'p', + itemId: 'i', + floorId: 'ground', + position: { x: 2000, y: 1500 }, + rotation: 0, + mount: { kind: 'floor' }, + elevation: 0, + }); + + const fit = thumbnailFit(thumbnailBounds(d, floor)!); + const shapes = thumbnailShapes(d, floor, fit); + + expect(shapes.rooms).toHaveLength(1); + expect(shapes.walls).toHaveLength(1); + expect(shapes.placements).toHaveLength(1); + for (const ring of [...shapes.rooms, ...shapes.walls, ...shapes.placements]) { + expect(ring.length).toBeGreaterThanOrEqual(3); + for (const p of ring) { + expect(Number.isFinite(p.x)).toBe(true); + expect(Number.isFinite(p.y)).toBe(true); + } + } + }); + + it('skips a placement whose catalog item is gone', () => { + // Not reachable through the UI, but a hand-edited or partially-migrated file can + // hold one, and a save that throws on it is a document you cannot get out. + const d = doc(); + d.floors[0]!.placements.push({ + id: 'p', + itemId: 'missing', + floorId: 'ground', + position: { x: 0, y: 0 }, + rotation: 0, + mount: { kind: 'floor' }, + elevation: 0, + }); + + expect(thumbnailBounds(d, d.floors[0]!)).toBeNull(); + const fit = thumbnailFit({ minX: 0, minY: 0, maxX: 1000, maxY: 1000 }); + expect(thumbnailShapes(d, d.floors[0]!, fit).placements).toHaveLength(0); + }); + + it('draws one floor, not the storey below it', () => { + const d = doc(); + const ground = d.floors[0]!; + ground.walls.push(wall('w1', 0, 0, 4000, 0)); + + const upper = createFloor('upper', 'Upstairs', 1); + upper.walls.push(wall('w2', 0, 0, 1000, 0)); + d.floors.push(upper); + + // A small upstairs framed on the whole building would render at the scale of the + // floor beneath it, which is the ghost underlay leaking into the file. + const box = thumbnailBounds(d, upper)!; + expect(box.maxX).toBeLessThan(1200); + }); +}); diff --git a/src/core/thumbnail.ts b/src/core/thumbnail.ts new file mode 100644 index 0000000..8b099d4 --- /dev/null +++ b/src/core/thumbnail.ts @@ -0,0 +1,154 @@ +/** + * The 512px plan render that rides in the `.space` container. See PLAN.md §5. + * + * Split in two on purpose, and the seam is the point: everything that decides *what* + * the picture contains and *where* each shape lands is here, pure and tested; the + * `ctx.fill()` calls that turn those numbers into pixels live in `ui/thumbnail.ts`. + * + * ## Why not `stage.toDataURL()` + * + * Konva can hand back a raster of exactly what is on screen, and it is the wrong + * source. It couples saving to the plan view being mounted — there is no stage at all + * in 3D mode, and saving from there would silently produce a file with no thumbnail — + * and it captures the current pan and zoom, so the picture is whatever corner of the + * plan you happened to be looking at rather than the plan. + * + * ## The active floor, and nothing under it + * + * One floor, the one you are editing, framed on its own extent. The ghost underlay is + * excluded for the same reason it is excluded from `floorBounds`: it is not this + * floor's content, and letting it into the frame would make a small upstairs render + * at the scale of the storey below it. + * + * An empty floor gets **no thumbnail** rather than a blank square. A 512px field of + * background colour in every file is not information, and `writeSpace` already treats + * the entry as optional. + */ + +import type { Bounds, Polygon } from './geometry/polygon'; +import { bounds } from './geometry/polygon'; +import type { Vec2 } from './geometry/vec'; +import { wallOutline } from './geometry/wall'; +import { worldOutline } from './placement'; +import { findItem, type Floor, type SpaceDocument } from './document'; + +export const THUMBNAIL_PX = 512; +export const THUMBNAIL_PADDING_PX = 16; + +/** Document millimetres to thumbnail pixels: `p * scale + {x,y}`. */ +export type ThumbnailFit = { + scale: number; + x: number; + y: number; + size: number; +}; + +/** + * Frame a document extent in a square of `size` pixels. + * + * Uniform scale and centred, so a long thin apartment keeps its proportions instead + * of being stretched to fill the square. A degenerate extent — a single wall, which + * has zero height in one axis — would divide by zero, so each side is floored at 1mm; + * the result is a legible line rather than an exception at save time. + */ +export function thumbnailFit( + box: Bounds, + size = THUMBNAIL_PX, + paddingPx = THUMBNAIL_PADDING_PX, +): ThumbnailFit { + const w = Math.max(1, box.maxX - box.minX); + const h = Math.max(1, box.maxY - box.minY); + const avail = Math.max(1, size - paddingPx * 2); + const scale = Math.min(avail / w, avail / h); + + return { + scale, + x: (size - w * scale) / 2 - box.minX * scale, + y: (size - h * scale) / 2 - box.minY * scale, + size, + }; +} + +function project(fit: ThumbnailFit, poly: Polygon): Vec2[] { + return poly.pts.map((p) => ({ x: p.x * fit.scale + fit.x, y: p.y * fit.scale + fit.y })); +} + +/** Every ring the thumbnail draws, already in pixels, in painting order. */ +export type ThumbnailShapes = { + rooms: Vec2[][]; + walls: Vec2[][]; + placements: Vec2[][]; +}; + +/** + * The extent a thumbnail of this floor would be framed on. + * + * Deliberately not `store.floorBounds`: that is the zoom-to-fit extent and covers + * rooms and walls only. A plan whose furniture sticks past its walls — an island of + * placements with no structure yet, which is exactly what an inventory-first document + * looks like — would otherwise render with the furniture cropped off or, with no walls + * at all, produce no thumbnail while plainly having content. + */ +export function thumbnailBounds(doc: SpaceDocument, floor: Floor): Bounds | null { + const boxes: Bounds[] = []; + + for (const room of floor.rooms) boxes.push(bounds(room.boundary)); + for (const wall of floor.walls) { + try { + boxes.push(bounds(wallOutline(wall))); + } catch { + // A degenerate wall has no extent to contribute and must not stop a save. + } + } + for (const placement of floor.placements) { + const item = findItem(doc, placement.itemId); + if (!item) continue; + try { + boxes.push(bounds(worldOutline(placement, item))); + } catch { + // Same: a placement we cannot outline is skipped, not thrown over. + } + } + + const first = boxes[0]; + if (!first) return null; + + return boxes.reduce((acc, b) => ({ + minX: Math.min(acc.minX, b.minX), + minY: Math.min(acc.minY, b.minY), + maxX: Math.max(acc.maxX, b.maxX), + maxY: Math.max(acc.maxY, b.maxY), + })); +} + +export function thumbnailShapes( + doc: SpaceDocument, + floor: Floor, + fit: ThumbnailFit, +): ThumbnailShapes { + const rooms: Vec2[][] = []; + const walls: Vec2[][] = []; + const placements: Vec2[][] = []; + + for (const room of floor.rooms) rooms.push(project(fit, room.boundary)); + + for (const wall of floor.walls) { + try { + walls.push(project(fit, wallOutline(wall))); + } catch { + // Degenerate — nothing to draw. + } + } + + for (const placement of floor.placements) { + const item = findItem(doc, placement.itemId); + if (!item) continue; + try { + placements.push(project(fit, worldOutline(placement, item))); + } catch { + // Same. + } + } + + return { rooms, walls, placements }; +} diff --git a/src/core/tools.ts b/src/core/tools.ts index 3165009..7087fa9 100644 --- a/src/core/tools.ts +++ b/src/core/tools.ts @@ -23,15 +23,17 @@ 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', 'walkway'] 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', + walkway: 'Walkway', }; /** Single-key shortcuts, matching the first letter where it is free. */ @@ -39,8 +41,10 @@ export const PLAN_TOOL_KEYS: Record = { select: 'v', wall: 'w', room: 'r', + opening: 'o', shape: 's', dimension: 'd', + walkway: 'p', // path }; /** @@ -79,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 new file mode 100644 index 0000000..5fbc3a3 --- /dev/null +++ b/src/core/validation.test.ts @@ -0,0 +1,391 @@ +import { describe, expect, it } from 'vitest'; +import { createCatalogItem, type ItemDraft } from './catalog'; +import { createBackground } from './calibration'; +import { + createDocument, + type Opening, + type Placement, + type SpaceDocument, + type Wall, +} from './document'; +import { flaggedPlacements, validateFloor } from './validation'; + +function doc(): SpaceDocument { + return createDocument({ id: 'd', floorId: 'f', now: '2026-01-01T00:00:00.000Z' }); +} + +const DRAFTS: Record = { + // 720mm of open air beneath — the field the whole 3D collision model turns on. + table: { name: 'Table', category: 'table', shape: 'rect', widthMm: 1800, depthMm: 900, heightMm: 760, voidBelowMm: 720 }, + rug: { name: 'Rug', category: 'rug', shape: 'rect', widthMm: 2400, depthMm: 1600, heightMm: 10, voidBelowMm: 0 }, + dresser: { name: 'Dresser', category: 'storage', shape: 'rect', widthMm: 1500, depthMm: 500, heightMm: 810, voidBelowMm: 0 }, + bookcase: { name: 'Bookcase', category: 'storage', shape: 'rect', widthMm: 800, depthMm: 300, heightMm: 3000, voidBelowMm: 0 }, +}; + +function withItems(keys: (keyof typeof DRAFTS)[]): SpaceDocument { + const d = doc(); + for (const key of keys) d.catalog.push(createCatalogItem(DRAFTS[key]!, key)); + return d; +} + +function place(itemId: string, at: { x: number; y: number }, id = `p-${itemId}`): Placement { + return { + id, + itemId, + floorId: 'f', + position: at, + rotation: 0, + mount: { kind: 'floor' }, + elevation: 0, + }; +} + +describe('validateFloor', () => { + it('reports nothing for an empty floor', () => { + const d = doc(); + expect(validateFloor(d, d.floors[0]!)).toEqual([]); + }); + + it('does not call a rug under a table a collision', () => { + // The case that makes the vertical axis worth having: footprints overlap + // completely, solid spans do not — the rug is [0,10] and the table is [720,760]. + const d = withItems(['table', 'rug']); + d.floors[0]!.placements = [place('rug', { x: 0, y: 0 }), place('table', { x: 0, y: 0 })]; + + expect(validateFloor(d, d.floors[0]!)).toEqual([]); + }); + + it('reports two solid objects in the same place', () => { + const d = withItems(['dresser']); + d.floors[0]!.placements = [ + place('dresser', { x: 0, y: 0 }, 'a'), + place('dresser', { x: 200, y: 0 }, 'b'), + ]; + + const issues = validateFloor(d, d.floors[0]!); + expect(issues).toHaveLength(1); + expect(issues[0]!.kind).toBe('overlap'); + expect(issues[0]!.severity).toBe('warning'); + expect(issues[0]!.refs.map((r) => r.id).sort()).toEqual(['a', 'b']); + }); + + it('warns rather than blocks — nothing here refuses an edit', () => { + const d = withItems(['dresser']); + d.floors[0]!.placements = [ + place('dresser', { x: 0, y: 0 }, 'a'), + place('dresser', { x: 100, y: 0 }, 'b'), + ]; + expect(validateFloor(d, d.floors[0]!).every((i) => i.severity === 'warning')).toBe(true); + }); + + it('catches an item taller than the ceiling above it', () => { + const d = withItems(['bookcase']); + d.floors[0]!.placements = [place('bookcase', { x: 0, y: 0 })]; + + const issues = validateFloor(d, d.floors[0]!); + expect(issues.map((i) => i.kind)).toContain('headroom'); + // 3000mm bookcase under a 2438mm ceiling. + expect(issues.find((i) => i.kind === 'headroom')!.message).toContain('562mm'); + }); + + it('reports a placement whose item is gone', () => { + const d = doc(); + d.floors[0]!.placements = [place('vanished', { x: 0, y: 0 })]; + + const issues = validateFloor(d, d.floors[0]!); + expect(issues.map((i) => i.kind)).toEqual(['missing-item']); + }); + + it('reports something sitting on a host that no longer exists', () => { + const d = withItems(['dresser']); + d.floors[0]!.placements = [ + { ...place('dresser', { x: 0, y: 0 }), mount: { kind: 'surface', hostId: 'gone' } }, + ]; + + const issues = validateFloor(d, d.floors[0]!); + expect(issues.map((i) => i.kind)).toContain('broken-mount'); + }); + + it('reports a surface-mount cycle instead of blowing the stack', () => { + const d = withItems(['dresser']); + d.floors[0]!.placements = [ + { ...place('dresser', { x: 0, y: 0 }, 'a'), mount: { kind: 'surface', hostId: 'b' } }, + { ...place('dresser', { x: 3000, y: 0 }, 'b'), mount: { kind: 'surface', hostId: 'a' } }, + ]; + + const issues = validateFloor(d, d.floors[0]!); + expect(issues.filter((i) => i.kind === 'broken-mount').length).toBeGreaterThan(0); + }); + + it('leads with the calibration gate, which is the only blocking issue', () => { + const d = withItems(['dresser']); + d.floors[0]!.background = createBackground({ + assetId: 'a', + pixelSize: { width: 1000, height: 800 }, + }); + d.floors[0]!.placements = [ + place('dresser', { x: 0, y: 0 }, 'a'), + place('dresser', { x: 100, y: 0 }, 'b'), + ]; + + const issues = validateFloor(d, d.floors[0]!); + expect(issues[0]!.kind).toBe('uncalibrated'); + expect(issues[0]!.severity).toBe('blocking'); + expect(issues.filter((i) => i.severity === 'blocking')).toHaveLength(1); + }); +}); + +describe('flaggedPlacements', () => { + it('collects every placement any issue points at', () => { + const d = withItems(['dresser']); + d.floors[0]!.placements = [ + place('dresser', { x: 0, y: 0 }, 'a'), + place('dresser', { x: 100, y: 0 }, 'b'), + ]; + + expect([...flaggedPlacements(validateFloor(d, d.floors[0]!))].sort()).toEqual(['a', 'b']); + }); + + it('is empty when the only issue points at nothing', () => { + const d = doc(); + d.floors[0]!.background = createBackground({ + assetId: 'a', + pixelSize: { width: 100, height: 100 }, + }); + 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'); + }); +}); + +describe('what a leaf needs kept clear', () => { + /** A 5m wall running east from the origin, and one running north from it. */ + const SOUTH_WALL: Wall = { + id: 'w1', + a: { x: 0, y: 0 }, + b: { x: 5000, y: 0 }, + thicknessMm: 114, + heightMm: 2438, + baseElevationMm: 0, + }; + const CORNER_WALL: Wall = { ...SOUTH_WALL, id: 'w2', b: { x: 0, y: 4000 } }; + + function opening(over: Partial = {}): Opening { + return { + id: 'o1', + wallId: 'w1', + offsetMm: 1000, + widthMm: 813, + heightMm: 2032, + sillMm: 0, + kind: 'door', + ...over, + }; + } + + /** A floor with the two walls, one opening, and whatever placements are given. */ + function floorWith(o: Opening, placements: Placement[] = []) { + const d = withItems(['dresser', 'rug', 'bookcase']); + const floor = d.floors[0]!; + floor.walls = [SOUTH_WALL, CORNER_WALL]; + floor.openings = [o]; + floor.placements = placements; + return { d, floor }; + } + + function kinds(o: Opening, placements: Placement[] = []) { + const { d, floor } = floorWith(o, placements); + return validateFloor(d, floor).map((i) => i.kind); + } + + it('reports a dresser standing in a door swing', () => { + const { d, floor } = floorWith(opening(), [place('dresser', { x: 1400, y: 400 })]); + const issue = validateFloor(d, floor).find((i) => i.kind === 'swing-blocked')!; + + expect(issue.message).toContain('cannot open fully'); + expect(issue.message).toContain('Dresser'); + expect(issue.refs.map((r) => r.kind).sort()).toEqual(['opening', 'placement']); + }); + + it('says nothing once the door is hung to open the other way', () => { + const other = opening({ swing: { hinge: 'a', into: 'back', angleDeg: 90 } }); + expect(kinds(other, [place('dresser', { x: 1400, y: 400 })])).not.toContain('swing-blocked'); + }); + + it('lets a rug lie under the door', () => { + // A door blocks from its sill to its head, so a 10mm rug is not in its way — + // the same span test as every other collision here, not a special case. + expect(kinds(opening({ sillMm: 20 }), [place('rug', { x: 1400, y: 400 })])).not.toContain( + 'swing-blocked', + ); + }); + + it('does not flag a door for swinging against the wall next to it', () => { + // A door hung in a corner rests on the adjacent wall. That is how doors are + // hung; testing the sweep against walls would fire on nearly every one of them. + expect(kinds(opening({ offsetMm: 0 }))).toEqual([]); + }); + + it('reports a bookcase where a sliding door has to park', () => { + const { d, floor } = floorWith(opening({ kind: 'sliding' }), [ + place('bookcase', { x: 600, y: 120 }), + ]); + const issue = validateFloor(d, floor).find((i) => i.kind === 'swing-blocked')!; + + expect(issue.message).toContain('nowhere to slide'); + }); + + it('asks nothing of the room for a pocket door', () => { + // The whole argument for fitting one: the leaf goes inside the wall, so the + // bookcase beside it is not in its way. + expect(kinds(opening({ kind: 'pocket' }), [place('bookcase', { x: 600, y: 120 })])).not.toContain( + 'swing-blocked', + ); + }); + + it('reports a pocket door with no cavity to slide into', () => { + const { d, floor } = floorWith(opening({ kind: 'pocket', offsetMm: 400 })); + const issue = validateFloor(d, floor).find((i) => i.kind === 'pocket-blocked')!; + + expect(issue.message).toContain('413mm short'); + expect(issue.severity).toBe('warning'); + }); + + it('reports a pocket door that would slide into a window', () => { + const pocket = opening({ kind: 'pocket' }); + const { d, floor } = floorWith(pocket); + floor.openings.push({ ...opening(), id: 'o2', offsetMm: 300, widthMm: 914, kind: 'window' }); + + 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 new file mode 100644 index 0000000..7373ce8 --- /dev/null +++ b/src/core/validation.ts @@ -0,0 +1,308 @@ +/** + * The validation report. See PLAN.md §9.2. + * + * Everything here **warns; nothing blocks** — except the calibration gate, which is + * the one genuine refusal in the application because an unscaled plan makes every + * number downstream meaningless. Sometimes you really do mean to put the ottoman + * half under the coffee table, and an app that refuses to let you do something you + * understand is worse than one that tells you and gets out of the way. + * + * Collision is genuinely three-dimensional: footprints must overlap in plan *and* + * solid vertical spans must overlap. A rug under a table is not a collision, a bin + * under a desk is not a collision, and boxes under a bed frame are not a collision — + * each falls out of `voidBelowMm` rather than a special case. + * + * Pure — no DOM, no store. `IssueRef` mirrors the editor's selection shape so the UI + * can select what an issue points at, without core knowing the store exists. + */ + +import { placementBlockReason } from './calibration'; +import { findItem, type Floor, type Id, type SpaceDocument } from './document'; +import { + OPENING_KIND_LABELS, + openingFitReason, + openingRange, + 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, + ceilingHeightAt, + placementVolume, + placementSpan, +} from './placement'; + +export type IssueKind = + | 'uncalibrated' + | 'overlap' + | 'headroom' + | 'below-floor' + | 'missing-item' + | 'broken-mount' + | 'opening-fit' + | 'opening-overlap' + | 'swing-blocked' + | 'pocket-blocked' + | 'clearance'; + +export type IssueSeverity = 'blocking' | 'warning'; + +export type IssueRef = { kind: 'wall' | 'room' | 'opening' | 'placement'; id: Id }; + +export type Issue = { + kind: IssueKind; + severity: IssueSeverity; + message: string; + refs: IssueRef[]; +}; + +/** Placement ids involved in any issue — what the plan renders in the warning colour. */ +export function flaggedPlacements(issues: readonly Issue[]): Set { + const ids = new Set(); + for (const issue of issues) { + for (const ref of issue.refs) if (ref.kind === 'placement') ids.add(ref.id); + } + return ids; +} + +function label(doc: SpaceDocument, placementId: Id, floor: Floor): string { + const placement = floor.placements.find((p) => p.id === placementId); + const item = placement ? findItem(doc, placement.itemId) : undefined; + return item?.name ?? 'Unknown item'; +} + +/** + * Every issue on one floor. + * + * Ordered blocking-first, then by kind, so the panel's top line is always the thing + * most worth doing something about. + */ +export function validateFloor(doc: SpaceDocument, floor: Floor): Issue[] { + const issues: Issue[] = []; + + const blocked = placementBlockReason(floor); + if (blocked) { + 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 }, + ], + }); + } + + // A pocket door with nowhere to slide is a pocket door in name only, and it is + // the one thing about the kind that geometry cannot show you: the cavity is + // inside the wall, so an unbuildable one looks perfectly fine in both views. + const pocket = pocketFitReason(wall, opening, floor.openings); + if (pocket) { + issues.push({ + kind: 'pocket-blocked', + severity: 'warning', + message: pocket, + 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. + const volumes: Volume[] = []; + const owners: Id[] = []; + + for (const placement of floor.placements) { + const item = findItem(doc, placement.itemId); + if (!item) { + issues.push({ + kind: 'missing-item', + severity: 'warning', + message: 'This placement points at an item that is no longer in the inventory.', + refs: [{ kind: 'placement', id: placement.id }], + }); + continue; + } + + if (placement.mount.kind === 'surface') { + const hostId = placement.mount.hostId; + const host = floor.placements.find((p) => p.id === hostId); + if (!host) { + issues.push({ + kind: 'broken-mount', + severity: 'warning', + message: `${item.name} was sitting on something that no longer exists, so it is resting on the floor.`, + refs: [{ kind: 'placement', id: placement.id }], + }); + } + } + + // 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', + severity: 'warning', + message: `${item.name} is ${Math.round(span.top - ceiling)}mm taller than the ceiling above it.`, + refs: [{ kind: 'placement', id: placement.id }], + }); + } + } catch (err) { + if (err instanceof MountCycleError) { + issues.push({ + kind: 'broken-mount', + severity: 'warning', + message: `${item.name} is stacked on itself. Move it back to the floor.`, + refs: err.chain.map((id) => ({ kind: 'placement' as const, id })), + }); + continue; + } + throw err; + } + } + + // What each leaf needs kept clear, against the furniture. Walls are deliberately + // not tested: a door swinging back to rest against the adjacent wall is how doors + // are hung, and flagging it would fire on every door in a corner. + for (const opening of floor.openings) { + const wall = wallsById.get(opening.wallId); + if (!wall) continue; + const clearance = clearanceVolume(wall, opening); + if (!clearance) continue; + + const style = leafOf(opening).style; + for (const [i, volume] of volumes.entries()) { + if (!volumesCollide(clearance, volume)) continue; + const who = label(doc, owners[i]!, floor); + issues.push({ + kind: 'swing-blocked', + severity: 'warning', + message: + style === 'sliding' + ? `${OPENING_KIND_LABELS[opening.kind]} has nowhere to slide — ${who} is where it parks.` + : `${OPENING_KIND_LABELS[opening.kind]} cannot open fully — ${who} is in its way.`, + refs: [ + { kind: 'opening', id: opening.id }, + { kind: 'placement', id: owners[i]! }, + ], + }); + } + } + + // 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]!; + issues.push({ + kind: 'overlap', + severity: 'warning', + message: `${label(doc, a, floor)} overlaps ${label(doc, b, floor)}.`, + refs: [ + { kind: 'placement', id: a }, + { kind: 'placement', id: b }, + ], + }); + } + + const order: Record = { + uncalibrated: 0, + 'broken-mount': 1, + 'missing-item': 2, + 'below-floor': 3, + 'opening-fit': 4, + 'opening-overlap': 5, + 'pocket-blocked': 6, + 'swing-blocked': 7, + headroom: 8, + clearance: 9, + overlap: 10, + }; + return issues.sort((x, y) => order[x.kind] - order[y.kind]); +} diff --git a/src/core/views.test.ts b/src/core/views.test.ts new file mode 100644 index 0000000..6f1b298 --- /dev/null +++ b/src/core/views.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_CAMERA, + createSavedView, + decodeCamera, + encodeCamera, + spaceViews, + uniqueViewName, + type SpaceCamera, +} from './views'; +import type { SavedView } from './document'; + +const CAMERA: SpaceCamera = { + position: { x: 1200, y: 300, z: 1650 }, + target: { x: 2500, y: 2500, z: 1200 }, + mode: 'walk', +}; + +describe('camera encoding', () => { + it('round-trips through the record the file format stores', () => { + expect(decodeCamera(encodeCamera(CAMERA))).toEqual(CAMERA); + }); + + it('uses the key set the format fixture already contains', () => { + // `space-file.test.ts` has a checked-in view using x/y/z/tx/ty/tz. Changing the + // names here would silently stop reading files saved before the change. + expect(Object.keys(encodeCamera(CAMERA)).sort()).toEqual( + ['m', 'tx', 'ty', 'tz', 'x', 'y', 'z'].sort(), + ); + }); +}); + +describe('decoding a record this version did not write', () => { + it('fills in every missing field rather than producing NaN', () => { + // A NaN reaches a matrix and blanks the screen with no error anywhere. + const camera = decodeCamera({}); + expect(camera).toEqual(DEFAULT_CAMERA); + }); + + it('rejects a non-finite value instead of passing it through', () => { + const camera = decodeCamera({ x: NaN, y: Infinity, z: 900, tx: 0, ty: 0, tz: 0, m: 1 }); + expect(camera.position.x).toBe(DEFAULT_CAMERA.position.x); + expect(camera.position.y).toBe(DEFAULT_CAMERA.position.y); + expect(camera.position.z).toBe(900); + }); + + it('clamps a camera mode index it does not recognise', () => { + expect(decodeCamera({ m: 99 }).mode).toBe('orbit'); + expect(decodeCamera({ m: -1 }).mode).toBe('orbit'); + }); + + it('nudges a camera that is looking at itself', () => { + // Position equal to target is a zero-length view direction and a degenerate + // matrix — a black screen with nothing logged. + const camera = decodeCamera({ x: 100, y: 200, z: 300, tx: 100, ty: 200, tz: 300 }); + expect(camera.target).not.toEqual(camera.position); + }); +}); + +describe('the saved view list', () => { + const views: SavedView[] = [ + createSavedView('a', 'Doorway', CAMERA), + { id: 'b', name: 'Plan bookmark', mode: 'plan2d', camera: {} }, + ]; + + it('shows only space views', () => { + expect(spaceViews(views).map((v) => v.id)).toEqual(['a']); + }); + + it('suffixes a duplicate name rather than refusing to save', () => { + // The point of the button is that pressing it saves where you are standing. + expect(uniqueViewName('Doorway', views)).toBe('Doorway 2'); + expect(uniqueViewName('Kitchen', views)).toBe('Kitchen'); + }); + + it('keeps suffixing past the first collision', () => { + const many = [...views, createSavedView('c', 'Doorway 2', CAMERA)]; + expect(uniqueViewName('Doorway', many)).toBe('Doorway 3'); + }); +}); diff --git a/src/core/views.ts b/src/core/views.ts new file mode 100644 index 0000000..5d1a2eb --- /dev/null +++ b/src/core/views.ts @@ -0,0 +1,95 @@ +/** + * Saved views. See PLAN.md §10.3. + * + * `SavedView.camera` is typed `Record` in the document, which is + * flexible enough to hold any camera and loose enough that a future version could + * write a shape this one cannot read. So the key set is pinned here, in one place, + * with an explicit encode and a **total** decode: every field has a defined fallback, + * and an unrecognised or truncated record produces a usable camera rather than a + * `NaN` that propagates silently into a matrix and blanks the screen. + * + * Camera mode travels as an index into `CAMERA_MODES` because the field is numeric. + * That is the one place the encoding is not self-describing, which is why the decode + * clamps it rather than indexing blindly. + */ + +import type { Id, SavedView } from './document'; +import type { DocPoint3 } from './units'; +import { CAMERA_MODES, type CameraMode } from './walk'; + +/** A camera pose in document millimetres, Z-up — the same space as everything else. */ +export type SpaceCamera = { + position: DocPoint3; + target: DocPoint3; + mode: CameraMode; +}; + +export const DEFAULT_CAMERA: SpaceCamera = { + position: { x: 0, y: 0, z: 1650 }, + target: { x: 0, y: -1000, z: 1650 }, + mode: 'orbit', +}; + +export function encodeCamera(camera: SpaceCamera): Record { + return { + x: camera.position.x, + y: camera.position.y, + z: camera.position.z, + tx: camera.target.x, + ty: camera.target.y, + tz: camera.target.z, + m: CAMERA_MODES.indexOf(camera.mode), + }; +} + +/** A finite number from the record, or the fallback. Rejects `NaN` and `Infinity`. */ +function num(record: Record, key: string, fallback: number): number { + const value = record[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +export function decodeCamera(record: Record): SpaceCamera { + const position = { + x: num(record, 'x', DEFAULT_CAMERA.position.x), + y: num(record, 'y', DEFAULT_CAMERA.position.y), + z: num(record, 'z', DEFAULT_CAMERA.position.z), + }; + const target = { + x: num(record, 'tx', DEFAULT_CAMERA.target.x), + y: num(record, 'ty', DEFAULT_CAMERA.target.y), + z: num(record, 'tz', DEFAULT_CAMERA.target.z), + }; + const index = Math.round(num(record, 'm', 0)); + const mode = CAMERA_MODES[index] ?? DEFAULT_CAMERA.mode; + + // A camera whose target is its own position has no direction and produces a + // degenerate view matrix. Nudge it rather than handing three.js a zero vector. + if (position.x === target.x && position.y === target.y && position.z === target.z) { + target.y -= 1000; + } + return { position, target, mode }; +} + +export function createSavedView(id: Id, name: string, camera: SpaceCamera): SavedView { + return { id, name, mode: 'space3d', camera: encodeCamera(camera) }; +} + +/** Space views only — a plan bookmark is a different feature and does not exist yet. */ +export function spaceViews(views: readonly SavedView[]): SavedView[] { + return views.filter((v) => v.mode === 'space3d'); +} + +/** + * A name that is not already taken, so two bookmarks are never ambiguous in the list. + * + * Suffixes rather than refuses: the point of the button is that pressing it saves + * where you are standing, and stopping to argue about a name defeats it. + */ +export function uniqueViewName(base: string, existing: readonly SavedView[]): string { + const taken = new Set(existing.map((v) => v.name)); + if (!taken.has(base)) return base; + for (let i = 2; ; i++) { + const candidate = `${base} ${i}`; + if (!taken.has(candidate)) return candidate; + } +} diff --git a/src/core/walk.test.ts b/src/core/walk.test.ts new file mode 100644 index 0000000..0dc3116 --- /dev/null +++ b/src/core/walk.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, it } from 'vitest'; +import { + BODY_RADIUS_MM, + WALK_SPEED_MMS, + CROUCH_EYE_MM, + EYE_HEIGHT_MM, + NO_INPUT, + createWalker, + bodySpan, + eyePosition, + forwardVector, + groundHeight, + isClear, + look, + lookTarget, + normalizeHeading, + rightVector, + stepWalker, + type WalkInput, + type Walker, + type WalkWorld, +} from './walk'; +import { blockersOf, buildScene } from './scene'; +import { createOpening, type OpeningDefaults } from './openings'; +import { createCatalogItem, type ItemDraft } from './catalog'; +import { commitRoomRect } from './tools'; +import { createDocument, type OpeningKind, type Placement, type SpaceDocument } from './document'; + +let seq = 0; +const id = () => `id-${seq++}`; + +/** + * A 5m × 4m room. Its north wall runs along y = 0; the interior is y > 57. + * + * The walker fixtures below all stand in the middle facing north (heading 0) and + * walk at the north wall, so "did I get out?" is `position.y < 0`. + */ +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 cutNorthWall(doc: SpaceDocument, kind: OpeningKind, size?: Partial) { + const north = doc.floors[0]!.walls[0]!; + doc.floors[0]!.openings.push( + createOpening({ id: id(), wall: north, kind, centreMm: 2500, ...(size ? { size } : {}) }), + ); +} + +function put(doc: SpaceDocument, draft: ItemDraft, at: { x: number; y: number }, over: Partial = {}) { + const item = createCatalogItem(draft, id()); + doc.catalog.push(item); + doc.floors[0]!.placements.push({ + id: id(), + itemId: item.id, + floorId: 'f', + position: at, + rotation: 0, + mount: { kind: 'floor' }, + elevation: 0, + ...over, + }); +} + +function world(doc: SpaceDocument, mode: WalkWorld['mode'] = 'walk'): WalkWorld { + return { blockers: blockersOf(buildScene(doc, doc.floors[0]!)), mode }; +} + +/** Run the walk loop at 60fps for `seconds`, which is what the render loop does. */ +function walkFor( + walker: Walker, + input: Partial, + seconds: number, + w: WalkWorld, +): Walker { + const dt = 1 / 60; + let current = walker; + for (let t = 0; t < seconds; t += dt) { + current = stepWalker(current, { ...NO_INPUT, ...input }, dt, w); + } + return current; +} + +/** Standing in the middle of the room, facing the north wall. */ +function inside(): Walker { + return createWalker({ x: 2500, y: 2000 }, 0); +} + +describe('bearings', () => { + it('reads 0 as north and 90 as east, matching the plan on screen', () => { + // The document's y axis points south, so a compass bearing is the reading that + // matches what you see in plan view. + expect(forwardVector(0).x).toBeCloseTo(0, 9); + expect(forwardVector(0).y).toBeCloseTo(-1, 9); + expect(forwardVector(90).x).toBeCloseTo(1, 9); + expect(forwardVector(90).y).toBeCloseTo(0, 9); + }); + + it('puts the walker’s right hand to the east when they face north', () => { + expect(rightVector(0).x).toBeCloseTo(1, 9); + expect(rightVector(0).y).toBeCloseTo(0, 9); + expect(rightVector(90).y).toBeCloseTo(1, 9); // facing east, right is south + }); + + it('keeps a heading in [0, 360)', () => { + expect(normalizeHeading(-90)).toBe(270); + expect(normalizeHeading(450)).toBe(90); + }); +}); + +describe('the body interval', () => { + it('starts a stride above the floor, not at it', () => { + // The reason a 5mm rug does not stop a walker dead: [0,1800] genuinely overlaps + // [0,5], and [200,1800] does not. + expect(bodySpan(inside())).toEqual({ bottom: 200, top: 1800 }); + }); + + it('rises for a deliberate step up, and lowers its head for a crouch', () => { + expect(bodySpan(inside(), true).bottom).toBe(450); + expect(bodySpan({ ...inside(), crouching: true }).top).toBe(1250); + }); + + it('rides on whatever the walker is standing on', () => { + expect(bodySpan({ ...inside(), elevation: 400 })).toEqual({ bottom: 600, top: 2200 }); + }); +}); + +describe('walking through a doorway', () => { + it('gets out through an 813mm door', () => { + const doc = room(); + cutNorthWall(doc, 'door'); + + const after = walkFor(inside(), { forward: 1 }, 4, world(doc)); + expect(after.position.y).toBeLessThan(0); + // Straight through, not squeezed sideways. + expect(after.position.x).toBeCloseTo(2500, 0); + }); + + it('does not get out through a solid wall', () => { + const doc = room(); + const after = walkFor(inside(), { forward: 1 }, 4, world(doc)); + + // Stopped a body radius clear of the wall's inner face, which is at y = 57 — + // within one frame of it, since the loop moves in discrete 23mm steps and stops + // at the last position that was clear. + const contact = 57 + BODY_RADIUS_MM; + expect(after.position.y).toBeGreaterThanOrEqual(contact); + expect(after.position.y).toBeLessThan(contact + (WALK_SPEED_MMS / 60)); + }); + + it('does not fit through a 400mm gap', () => { + // The capsule radius has to be enforced, not merely declared. Every other + // traversal test uses an 813mm door, which would never exercise it. + const doc = room(); + cutNorthWall(doc, 'cased', { widthMm: 400 }); + + const after = walkFor(inside(), { forward: 1 }, 4, world(doc)); + expect(after.position.y).toBeGreaterThan(0); + }); + + it('walks under the lintel without ducking', () => { + const doc = room(); + cutNorthWall(doc, 'door'); + const after = walkFor(inside(), { forward: 1 }, 4, world(doc)); + + expect(after.crouching).toBe(false); + expect(after.position.y).toBeLessThan(0); + }); + + it('cannot climb through a window', () => { + // Sill 914 is inside the body interval, so the wall below a window is still wall. + const doc = room(); + cutNorthWall(doc, 'window'); + + const after = walkFor(inside(), { forward: 1 }, 4, world(doc)); + expect(after.position.y).toBeGreaterThan(0); + }); +}); + +describe('walking among furniture', () => { + const RUG: ItemDraft = { + name: 'Rug', + category: 'rug', + shape: 'rect', + widthMm: 3000, + depthMm: 3000, + heightMm: 10, + voidBelowMm: 0, + }; + + const DRESSER: ItemDraft = { + name: 'Dresser', + category: 'storage', + shape: 'rect', + widthMm: 1500, + depthMm: 500, + heightMm: 810, + voidBelowMm: 0, + }; + + const TABLE: ItemDraft = { + name: 'Table', + category: 'table', + shape: 'rect', + widthMm: 1800, + depthMm: 900, + heightMm: 760, + voidBelowMm: 720, + }; + + const SHELF: ItemDraft = { + name: 'Shelf', + category: 'storage', + shape: 'rect', + widthMm: 2000, + depthMm: 300, + heightMm: 300, + voidBelowMm: 0, + }; + + const PLATFORM: ItemDraft = { + name: 'Storage cube', + category: 'storage', + shape: 'rect', + widthMm: 1200, + depthMm: 1200, + heightMm: 400, + voidBelowMm: 0, + }; + + it('walks over a rug rather than being stopped by a carpet', () => { + const doc = room(); + cutNorthWall(doc, 'door'); + put(doc, RUG, { x: 2500, y: 1200 }); + + const after = walkFor(inside(), { forward: 1 }, 4, world(doc)); + expect(after.position.y).toBeLessThan(0); + }); + + it('stands on the rug while crossing it', () => { + const doc = room(); + put(doc, RUG, { x: 2500, y: 1500 }); + + const after = walkFor(inside(), { forward: 1 }, 0.4, world(doc)); + expect(after.elevation).toBe(10); + }); + + it('is stopped by a dresser', () => { + const doc = room(); + cutNorthWall(doc, 'door'); + put(doc, DRESSER, { x: 2500, y: 1200 }); + + const after = walkFor(inside(), { forward: 1 }, 4, world(doc)); + expect(after.position.y).toBeGreaterThan(1200); + }); + + it('is stopped by a dining table, because a table top is at chest height', () => { + // `voidBelowMm` is the open air *below* the top, so a table is [720, 760] of + // solid — squarely inside the body interval [200, 1800]. The void is what lets a + // rug lie under the table, not what lets a person walk through it. + const doc = room(); + cutNorthWall(doc, 'door'); + put(doc, TABLE, { x: 2500, y: 1200 }); + + const after = walkFor(inside(), { forward: 1 }, 4, world(doc)); + expect(after.position.y).toBeGreaterThan(1650); // the table's near edge + }); + + it('ducks under a wall shelf it cannot otherwise pass', () => { + const doc = room(); + put(doc, SHELF, { x: 2500, y: 1200 }, { mount: { kind: 'wall', wallId: 'w' }, elevation: 1400 }); + const w = world(doc); + + // Standing: the shelf at [1400, 1700] is inside the body interval. + expect(walkFor(inside(), { forward: 1 }, 3, w).position.y).toBeGreaterThan(1200); + // Crouching: the head drops to 1250 and the shelf passes overhead. + expect(walkFor(inside(), { forward: 1, crouch: true }, 3, w).position.y).toBeLessThan(1200); + }); + + it('steps up onto a low platform only when the step key is held', () => { + // The cube spans y 400..1600, so a walker starting at y = 2000 begins clear of it. + const doc = room(); + put(doc, PLATFORM, { x: 2500, y: 1000 }); + const w = world(doc); + + // 0.7s is ~980mm — far enough to be standing on it, not far enough to cross it. + const walked = walkFor(inside(), { forward: 1 }, 0.7, w); + expect(walked.elevation).toBe(0); + expect(walked.position.y).toBeGreaterThan(1600); // stopped at its near edge + + const stepped = walkFor(inside(), { forward: 1, stepUp: true }, 0.7, w); + expect(stepped.elevation).toBe(400); + expect(stepped.position.y).toBeLessThan(1600); + }); + + it('steps back down off the platform without needing the key', () => { + // Rising is limited to a stride; falling is not, which is what lets you walk off. + const doc = room(); + put(doc, PLATFORM, { x: 2500, y: 1000 }); + const w = world(doc); + + const up = walkFor(inside(), { forward: 1, stepUp: true }, 0.7, w); + expect(up.elevation).toBe(400); + + const down = walkFor(up, { forward: 1 }, 1.5, w); + expect(down.elevation).toBe(0); + }); + + it('lets a walker who has been built around walk back out', () => { + // Furniture placed on top of where someone is standing blocks every candidate + // move, and they would be frozen with no way out but switching to fly. + const doc = room(); + put(doc, DRESSER, { x: 2500, y: 2000 }); + + const after = walkFor(inside(), { forward: 1 }, 1, world(doc)); + expect(after.position.y).toBeLessThan(2000); + }); +}); + +describe('sliding', () => { + it('slides along a wall approached at an angle instead of stopping dead', () => { + const doc = room(); + // Facing north-east, into the north wall. The y component is blocked; the x + // component is not, so the walker should end up further east than they started. + const after = walkFor(createWalker({ x: 2000, y: 500 }, 45), { forward: 1 }, 2, world(doc)); + + expect(after.position.x).toBeGreaterThan(2500); + expect(after.position.y).toBeGreaterThan(0); + }); +}); + +describe('the camera', () => { + it('puts the eye at standing height above whatever the feet are on', () => { + expect(eyePosition(inside()).z).toBe(EYE_HEIGHT_MM); + expect(eyePosition({ ...inside(), elevation: 400 }).z).toBe(400 + EYE_HEIGHT_MM); + expect(eyePosition({ ...inside(), crouching: true }).z).toBe(CROUCH_EYE_MM); + }); + + it('looks where the walker is facing', () => { + const target = lookTarget(inside(), 1000); + expect(target.y).toBeCloseTo(2000 - 1000, 6); + expect(target.z).toBeCloseTo(EYE_HEIGHT_MM, 6); + }); + + it('clamps pitch so the horizon never ends up behind you', () => { + expect(look(inside(), 0, 200).pitch).toBe(85); + expect(look(inside(), 0, -200).pitch).toBe(-85); + }); + + it('wraps yaw rather than accumulating a five-figure angle', () => { + expect(look({ ...inside(), heading: 350 }, 20, 0).heading).toBe(10); + }); +}); + +describe('fly mode', () => { + it('goes through walls, because inspecting a ceiling means getting to it', () => { + const doc = room(); + const after = walkFor(inside(), { forward: 1 }, 4, world(doc, 'fly')); + expect(after.position.y).toBeLessThan(0); + }); + + it('rises and falls, but not below the floor', () => { + const doc = room(); + const w = world(doc, 'fly'); + expect(walkFor(inside(), { rise: 1 }, 1, w).elevation).toBeGreaterThan(1000); + expect(walkFor(inside(), { rise: -1 }, 1, w).elevation).toBe(0); + }); + + it('does not crouch — there is nothing to duck under when nothing blocks', () => { + const doc = room(); + expect(walkFor(inside(), { crouch: true }, 0.5, world(doc, 'fly')).crouching).toBe(false); + }); +}); + +describe('the timestep', () => { + it('clamps a huge delta, so a backgrounded tab does not teleport the walker', () => { + const doc = room(); + const after = stepWalker(inside(), { ...NO_INPUT, forward: 1 }, 30, world(doc)); + + // 30 seconds would be 42 metres. Clamped to 100ms, it is 140mm. + expect(after.position.y).toBeCloseTo(2000 - 140, 6); + }); + + it('does nothing at all for a zero-length frame', () => { + const doc = room(); + const before = inside(); + expect(stepWalker(before, { ...NO_INPUT, forward: 1 }, 0, world(doc))).toBe(before); + }); +}); + +describe('isClear and groundHeight', () => { + it('reports a body inside a solid as colliding, not as clear', () => { + // A walker that has somehow ended up inside a wall must read as blocked, or it + // would be free to walk out through the far side. + const doc = room(); + const blockers = blockersOf(buildScene(doc, doc.floors[0]!)); + expect(isClear({ x: 2500, y: 0 }, { bottom: 200, top: 1800 }, blockers)).toBe(false); + }); + + it('finds the floor under a point with nothing on it', () => { + const doc = room(); + const blockers = blockersOf(buildScene(doc, doc.floors[0]!)); + expect(groundHeight({ x: 2500, y: 2000 }, 0, blockers, 200)).toBe(0); + }); +}); diff --git a/src/core/walk.ts b/src/core/walk.ts new file mode 100644 index 0000000..db433b6 --- /dev/null +++ b/src/core/walk.ts @@ -0,0 +1,330 @@ +/** + * Traversal. See PLAN.md §10.2. + * + * Pure: a walker state in, a walker state out. No three.js, no DOM, no store. The + * render loop calls `stepWalker` once a frame and hands the result to a camera; a + * unit test calls it a hundred times with a fixed timestep and asserts where the + * walker ended up. Nothing about walking through a doorway needs a GPU to verify. + * + * ## The body interval is the whole design + * + * Collision is 2D circle-vs-polygon against every solid whose vertical span overlaps + * the walker's **body interval**, and that interval starts *above* the floor: + * + * body = [ feet + stepClearance , feet + standingHeight ] + * + * `stepClearance` is not a key you press — it is the bottom of the interval, and it + * is what a stride actually clears. With a body of `[0, 1800]` a 5mm rug reads as a + * collision, because `[0, 5]` and `[0, 1800]` genuinely overlap; the walker would be + * stopped dead by a carpet. With `[200, 1800]` the rug passes underneath, the dresser + * at `[0, 810]` still blocks, the bed frame at `[250, 600]` still blocks, and the + * lintel over a doorway at `[2032, 2438]` still lets you through. Same shape of fix as + * `voidBelowMm`: one number, and every case falls out of it. + * + * Holding the step key raises the clearance to `STEP_UP_MM`, which is how you get onto + * a low platform deliberately rather than by walking at it. Crouching lowers the top + * of the interval instead, so you can duck under a wall-mounted shelf you cannot + * otherwise pass. + * + * ## Sliding + * + * Blocked moves are retried on each axis separately: full, then x-only, then y-only. + * That gives a clean slide along axis-aligned walls, which is most walls, and a + * stickier one along diagonals — a proper resolver would push out along the contact + * normal, and is not worth its bug surface here. Documented rather than hidden. + */ + +import { circleIntersects, spansOverlap, type Span, type Volume } from './geometry/collision'; +import { containsPoint } from './geometry/polygon'; +import { toRadians, type Vec2 } from './geometry/vec'; +import type { DocPoint3 } from './units'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Eye height of a standing adult, and of a crouching one. */ +export const EYE_HEIGHT_MM = 1650; +export const CROUCH_EYE_MM = 1100; + +/** Top of the body interval. Crouching narrows it so you can duck under things. */ +export const STAND_HEIGHT_MM = 1800; +export const CROUCH_HEIGHT_MM = 1250; + +/** What an ordinary stride clears — a threshold, a rug, a cable. */ +export const STEP_OVER_MM = 200; +/** What a deliberate step up clears, with the step key held. A stair riser is ~190. */ +export const STEP_UP_MM = 450; + +/** Shoulder-width capsule. A 400mm gap is impassable; an 813mm door is not. */ +export const BODY_RADIUS_MM = 250; + +/** Comfortable indoor walking pace, and a run. */ +export const WALK_SPEED_MMS = 1400; +export const RUN_MULTIPLIER = 2; +/** Vertical rate in fly mode. */ +export const FLY_SPEED_MMS = 1400; + +export const TURN_SPEED_DEG = 140; +/** Looking further than this puts the horizon behind you and reads as broken. */ +export const MAX_PITCH_DEG = 85; + +/** Longest timestep honoured. A backgrounded tab returns with a huge delta. */ +export const MAX_STEP_SECONDS = 0.1; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +export const CAMERA_MODES = ['orbit', 'walk', 'fly'] as const; +export type CameraMode = (typeof CAMERA_MODES)[number]; + +export const CAMERA_MODE_LABELS: Record = { + orbit: 'Orbit', + walk: 'Walk', + fly: 'Fly', +}; + +export type Walker = { + /** Position in document mm. */ + position: Vec2; + /** + * Compass bearing in degrees: 0 faces document −y, 90 faces +x. + * + * A bearing rather than a maths angle because the document's y axis points south, + * so "0 is north, 90 is east" is the reading that matches the plan on screen. + */ + heading: number; + /** Degrees above the horizon, clamped to ±`MAX_PITCH_DEG`. */ + pitch: number; + /** Feet above the floor datum — derived each step from what is underfoot. */ + elevation: number; + crouching: boolean; +}; + +export type WalkInput = { + /** −1 back to +1 forward. */ + forward: number; + /** −1 left to +1 right. */ + strafe: number; + /** −1 turn left to +1 turn right. */ + turn: number; + run: boolean; + crouch: boolean; + /** The step key: raises the body interval's floor for a deliberate step up. */ + stepUp: boolean; + /** Fly mode only: −1 down to +1 up. */ + rise: number; +}; + +export const NO_INPUT: WalkInput = { + forward: 0, + strafe: 0, + turn: 0, + run: false, + crouch: false, + stepUp: false, + rise: 0, +}; + +export type WalkWorld = { + blockers: readonly Volume[]; + /** `fly` ignores collision and gravity entirely. */ + mode: CameraMode; +}; + +export function createWalker(position: Vec2, heading = 0): Walker { + return { position: { ...position }, heading, pitch: 0, elevation: 0, crouching: false }; +} + +// --------------------------------------------------------------------------- +// Geometry of a walker +// --------------------------------------------------------------------------- + +/** Unit vector the walker is facing, in document space. */ +export function forwardVector(headingDeg: number): Vec2 { + const t = toRadians(headingDeg); + return { x: Math.sin(t), y: -Math.cos(t) }; +} + +/** Unit vector to the walker's right. Facing north (0°), that is east. */ +export function rightVector(headingDeg: number): Vec2 { + const t = toRadians(headingDeg); + return { x: Math.cos(t), y: Math.sin(t) }; +} + +/** + * The vertical interval the walker's body occupies. + * + * Read the module comment before changing the bottom of this: it is the single number + * that decides whether a rug stops you. + */ +export function bodySpan(walker: Walker, stepUp = false): Span { + const clearance = stepUp ? STEP_UP_MM : STEP_OVER_MM; + const height = walker.crouching ? CROUCH_HEIGHT_MM : STAND_HEIGHT_MM; + return { bottom: walker.elevation + clearance, top: walker.elevation + height }; +} + +export function eyeHeight(walker: Walker): number { + return walker.elevation + (walker.crouching ? CROUCH_EYE_MM : EYE_HEIGHT_MM); +} + +/** Where the camera sits, in document space. */ +export function eyePosition(walker: Walker): DocPoint3 { + return { x: walker.position.x, y: walker.position.y, z: eyeHeight(walker) }; +} + +/** A point one metre ahead of the eye, along the heading and pitch — the look target. */ +export function lookTarget(walker: Walker, distanceMm = 1000): DocPoint3 { + const f = forwardVector(walker.heading); + const pitch = toRadians(walker.pitch); + const horizontal = Math.cos(pitch) * distanceMm; + return { + x: walker.position.x + f.x * horizontal, + y: walker.position.y + f.y * horizontal, + z: eyeHeight(walker) + Math.sin(pitch) * distanceMm, + }; +} + +// --------------------------------------------------------------------------- +// Collision +// --------------------------------------------------------------------------- + +/** Whether a body of `span` centred at `at` clears everything in the world. */ +export function isClear( + at: Vec2, + span: Span, + blockers: readonly Volume[], + radiusMm = BODY_RADIUS_MM, +): boolean { + for (const blocker of blockers) { + if (!spansOverlap(span, blocker.span)) continue; + if (circleIntersects(blocker.outline, at, radiusMm)) return false; + } + return true; +} + +/** + * The surface the walker is standing on at a point. + * + * The highest solid top that is underfoot and no more than `maxRiseMm` above the + * current feet — you step *up* only as far as a stride reaches, but you step *down* + * as far as there is to fall, which is what lets you walk off a platform. + * + * Tested on the centre point rather than the capsule, so standing half off the edge + * of a rug still counts as being on it. Anything finer would need a real support + * polygon and buys nothing here. + */ +export function groundHeight( + at: Vec2, + feetMm: number, + blockers: readonly Volume[], + maxRiseMm: number, +): number { + let ground = 0; + const ceiling = feetMm + maxRiseMm; + + for (const blocker of blockers) { + if (blocker.span.top > ceiling || blocker.span.top <= ground) continue; + if (containsPoint(blocker.outline, at)) ground = blocker.span.top; + } + return ground; +} + +/** + * Move as far as the world allows, sliding along whatever is in the way. + * + * Full move, then x-only, then y-only. Diagonal walls slide stickily; see the module + * comment. + */ +export function slide( + from: Vec2, + delta: Vec2, + span: Span, + blockers: readonly Volume[], + radiusMm = BODY_RADIUS_MM, +): Vec2 { + // Already inside something — a sofa placed on top of where you were standing, or a + // wall dragged across it. Every candidate is blocked, so the walker would be frozen + // in place with no way out but switching to fly. Let them move freely until they + // are clear again; being stuck is a worse answer than being briefly inside a wall. + if (!isClear(from, span, blockers, radiusMm)) { + return { x: from.x + delta.x, y: from.y + delta.y }; + } + + const candidates: Vec2[] = [ + { x: from.x + delta.x, y: from.y + delta.y }, + { x: from.x + delta.x, y: from.y }, + { x: from.x, y: from.y + delta.y }, + ]; + for (const candidate of candidates) { + if (isClear(candidate, span, blockers, radiusMm)) return candidate; + } + return from; +} + +// --------------------------------------------------------------------------- +// The step +// --------------------------------------------------------------------------- + +/** + * Advance the walker by one frame. + * + * Turning happens first, so strafing after a turn uses the heading you can already + * see. In `fly` mode collision and ground-following are both skipped — that is the + * mode's entire purpose, since inspecting a ceiling fan means being able to get to it. + */ +export function stepWalker( + walker: Walker, + input: WalkInput, + deltaSeconds: number, + world: WalkWorld, +): Walker { + const dt = Math.min(Math.max(deltaSeconds, 0), MAX_STEP_SECONDS); + if (dt === 0) return walker; + + const heading = normalizeHeading(walker.heading + input.turn * TURN_SPEED_DEG * dt); + const crouching = world.mode === 'walk' ? input.crouch : false; + const turned: Walker = { ...walker, heading, crouching }; + + const speed = WALK_SPEED_MMS * (input.run ? RUN_MULTIPLIER : 1) * dt; + const f = forwardVector(heading); + const r = rightVector(heading); + const delta = { + x: (f.x * input.forward + r.x * input.strafe) * speed, + y: (f.y * input.forward + r.y * input.strafe) * speed, + }; + + if (world.mode === 'fly') { + return { + ...turned, + position: { x: turned.position.x + delta.x, y: turned.position.y + delta.y }, + elevation: Math.max(0, turned.elevation + input.rise * FLY_SPEED_MMS * dt), + }; + } + + const span = bodySpan(turned, input.stepUp); + const position = slide(turned.position, delta, span, world.blockers); + const elevation = groundHeight( + position, + turned.elevation, + world.blockers, + input.stepUp ? STEP_UP_MM : STEP_OVER_MM, + ); + + return { ...turned, position, elevation }; +} + +/** Apply a mouse-look delta, in degrees. Pitch is clamped; heading wraps. */ +export function look(walker: Walker, deltaYawDeg: number, deltaPitchDeg: number): Walker { + return { + ...walker, + heading: normalizeHeading(walker.heading + deltaYawDeg), + pitch: Math.max(-MAX_PITCH_DEG, Math.min(MAX_PITCH_DEG, walker.pitch + deltaPitchDeg)), + }; +} + +export function normalizeHeading(degrees: number): number { + const d = degrees % 360; + return d < 0 ? d + 360 : d; +} 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..8149853 --- /dev/null +++ b/src/core/walkway.ts @@ -0,0 +1,257 @@ +/** + * 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. + * + * **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( + 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/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/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/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..b5888c4 --- /dev/null +++ b/src/server/lookup.test.ts @@ -0,0 +1,338 @@ +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); + } + }); + + 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', () => { + 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..8ac9f8f --- /dev/null +++ b/src/server/lookup.ts @@ -0,0 +1,342 @@ +/** + * 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; +} + +/** + * 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 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 { + 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/state/actions.ts b/src/state/actions.ts index 22cc9d0..90a5a96 100644 --- a/src/state/actions.ts +++ b/src/state/actions.ts @@ -6,10 +6,54 @@ * one committed from a test take exactly the same path. */ -import type { 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 { 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'; +import type { Mount } from '../core/document'; +import { createCatalogItem, type ItemDraft } from '../core/catalog'; +import { snapPlacement, snapRotation, type PlacementSnapContext } from '../core/placement-snap'; +import { worldOutline } from '../core/placement'; +import { findItem } from '../core/document'; +import { newId } from '../core/tools'; import { commitRoomRect, commitShapeRoom, commitWallChain, type ShapeKind } from '../core/tools'; +import { + PlacementBlockedError, + applyCalibration, + assertAcceptsPlacements, + clampOpacity, + docToImage, + rotateBackground, + backgroundCentre, +} from '../core/calibration'; import type { Vec2 } from '../core/geometry/vec'; -import { activeFloor, useStore, type SelectionRef, type WallTransform } from './store'; +import { + activeFloor, + useStore, + type MutateOptions, + type PlacementTransform, + type SelectionRef, + type WallTransform, +} from './store'; function withActiveFloor(label: string, fn: (floor: ReturnType) => void): void { const { mutate, doc } = useStore.getState(); @@ -69,14 +113,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: + * + * - **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`; @@ -84,13 +142,272 @@ 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) { + 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; + } + } + } + }); +} + + +// --------------------------------------------------------------------------- +// 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()); + + // Written inside the recipe rather than through `setActiveFloor`: the inverse + // patch then restores both together, so undoing an added floor puts you back on + // the floor you were on instead of leaving `activeFloorId` naming one that has + // just been removed — which resolves through a fallback, looks fine, and saves a + // document whose active floor is not in it. + useStore.getState().mutate(`Add floor ${where}`, (draft) => { + draft.floors.push(floor); + draft.activeFloorId = floor.id; + }); + useStore.getState().clearFloorScopedState(); + 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; + }); + + // Not `setActiveFloor`: the recipe above has already moved `activeFloorId`, so it + // would early-return and the route drawn on the floor that is now gone would + // survive, re-answering against the remaining floor's geometry. + useStore.getState().clearFloorScopedState(); + 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)); + const to = doc.floors.find((f) => f.id === floorId); + if (!from || !to || from.id === floorId) return null; + + // The same gate `addPlacement` applies. A floor whose imported plan has not been + // calibrated has no scale, and moving furniture onto it produces exactly the state + // the gate exists to refuse — arrived at by a different door. + try { + assertAcceptsPlacements(to); + } catch (err) { + return err instanceof PlacementBlockedError ? err.message : null; + } + + // Across every floor, not just the source: a cross-floor surface mount is already + // representable, and searching one floor would leave behind the very rider this is + // here to carry. + const moving = descendantsOf(doc.floors.flatMap((f) => f.placements), placementId); + let reseated = 0; + + useStore.getState().mutate('Move to floor', (draft) => { + const target = draft.floors.find((f) => f.id === floorId); + if (!target) return; + + const travelling: Placement[] = []; + for (const floor of draft.floors) { + if (floor.id === floorId) continue; + for (const p of floor.placements) if (moving.has(p.id)) travelling.push(p); + floor.placements = floor.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) +// --------------------------------------------------------------------------- + +/** + * 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. + * + * A `kind` change deliberately does **not** clear the stored `swing`. Turning a door + * into a cased opening and back gives you the door you had, hinged where you hung it, + * rather than one re-seeded from defaults. `leafOf` decides whether the field is read + * at all, so an unread swing on a cased opening costs nothing, and dropping one costs + * a choice the user made. + */ +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)); + if (patch.swing !== undefined) opening.swing = { ...patch.swing }; } }); } +/** + * Change one part of an opening's swing — which end it is hinged at, which side it + * opens onto, how far it opens. + * + * Merged onto the *effective* swing rather than the stored one, so the first edit to + * an opening that has never had a swing written writes a whole one instead of a + * fragment. `leafOf` supplies the standard until then, which is why nothing had to be + * seeded when the opening was created — and why every opening in a file saved before + * this phase still reads as a door hung the ordinary way. + */ +export function setOpeningSwing(openingId: Id, patch: Partial): void { + const opening = activeFloor(useStore.getState()).openings.find((o) => o.id === openingId); + if (!opening) return; + + const current: Swing = opening.swing ?? DEFAULT_SWING; + updateOpening(openingId, { + swing: { + hinge: patch.hinge ?? current.hinge, + into: patch.into ?? current.into, + angleDeg: clampSwingAngle(patch.angleDeg ?? current.angleDeg), + }, + }); +} + export function renameDocument(title: string): void { useStore.getState().mutate('Rename', (draft) => { draft.title = title; @@ -106,6 +423,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. * @@ -149,3 +508,528 @@ export function previewWallTransform(transform: WallTransform, at: Vec2): WallTr } return { ...transform, [transform.end]: at } as WallTransform; } + +// --------------------------------------------------------------------------- +// Background (PLAN.md §6.1) +// --------------------------------------------------------------------------- + +/** + * Attach an imported floor plan to the active floor. + * + * The manifest entries and the background land in one mutation, so an import is one + * undo step and there is never a document state that references an asset it has not + * declared. The bytes are already in the runtime asset store by this point; only the + * manifest is document state. + * + * Replacing an existing background drops the old manifest entries but deliberately + * leaves its bytes in the runtime store, because undo has to be able to bring them + * back and there is nowhere else they could come from. + */ +export function setBackground(background: Background, assets: readonly AssetRef[]): void { + const { doc } = useStore.getState(); + const floorId = doc.activeFloorId; + const previous = doc.floors.find((f) => f.id === floorId)?.background; + const retired = new Set( + previous ? [previous.assetId, previous.sourceAssetId].filter((x): x is string => !!x) : [], + ); + + useStore.getState().mutate('Import floor plan', (draft) => { + draft.assets = draft.assets.filter((a) => !retired.has(a.id)); + for (const ref of assets) { + if (!draft.assets.some((a) => a.id === ref.id)) draft.assets.push({ ...ref }); + } + const floor = draft.floors.find((f) => f.id === floorId); + if (floor) floor.background = background; + }); +} + +export function removeBackground(): void { + const { doc } = useStore.getState(); + const floorId = doc.activeFloorId; + const bg = doc.floors.find((f) => f.id === floorId)?.background; + if (!bg) return; + + const retired = new Set([bg.assetId, bg.sourceAssetId].filter((x): x is string => !!x)); + useStore.getState().mutate('Remove floor plan', (draft) => { + draft.assets = draft.assets.filter((a) => !retired.has(a.id)); + const floor = draft.floors.find((f) => f.id === floorId); + if (floor) delete floor.background; + }); +} + +/** + * Edit the active floor's background in place. No-op when there is none. + * + * The equality check is not redundant with `mutate`'s no-op guard. These recipes + * assign a whole new object, and immer compares by reference — so setting a property + * to the value it already holds produces a patch and an undo entry that does + * nothing. Comparing the result first is what keeps "click Locked twice" out of the + * history. + */ +function mutateBackground( + label: string, + fn: (bg: Background) => Background, + options?: MutateOptions, +): void { + const { doc } = useStore.getState(); + const floorId = doc.activeFloorId; + const current = doc.floors.find((f) => f.id === floorId)?.background; + if (!current) return; + + const next = fn(current); + if (JSON.stringify(next) === JSON.stringify(current)) return; + + useStore.getState().mutate( + label, + (draft) => { + const floor = draft.floors.find((f) => f.id === floorId); + if (floor) floor.background = next; + }, + options, + ); +} + +/** + * Close the calibration gate. + * + * The reference line arrives in document millimetres, because that is what the stage + * produces. It is converted to image pixels here, through the background's *current* + * transform — provisional on a first calibration, real on a recalibration — which is + * why `applyCalibration` then rescales about `refA` rather than about the origin. + * + * Throws `CalibrationError` with a message meant to be shown; the caller does not + * need to know why the numbers were unusable. + */ +export function commitCalibration(refDocA: Vec2, refDocB: Vec2, realLengthMm: number): void { + const floor = activeFloor(useStore.getState()); + const bg = floor.background; + if (!bg) return; + + // Validate before mutating: `applyCalibration` throws on a line too short to + // measure, and a failed gate must leave the document untouched. + const next = applyCalibration(bg, docToImage(bg, refDocA), docToImage(bg, refDocB), realLengthMm); + mutateBackground('Calibrate floor plan', () => next); +} + +/** + * Opacity, coalesced. + * + * A range input fires `change` continuously, so one drag across the slider is + * hundreds of calls. Without coalescing each is an undo entry, and moving the slider + * once would push every wall you drew off a 200-deep history. + */ +export function setBackgroundOpacity(opacity: number): void { + mutateBackground('Background opacity', (bg) => ({ ...bg, opacity: clampOpacity(opacity) }), { + coalesce: true, + }); +} + +export function setBackgroundLocked(locked: boolean): void { + mutateBackground(locked ? 'Lock floor plan' : 'Unlock floor plan', (bg) => ({ ...bg, locked })); +} + +export function moveBackground(position: Vec2): void { + mutateBackground('Move floor plan', (bg) => ({ + ...bg, + transform: { ...bg.transform, position }, + })); +} + +/** Rotate about the raster centre, so squaring a crooked scan does not fling it away. */ +export function nudgeBackgroundRotation(degrees: number): void { + mutateBackground('Rotate floor plan', (bg) => rotateBackground(bg, degrees, backgroundCentre(bg))); +} + +// --------------------------------------------------------------------------- +// Catalog (PLAN.md §4.3, §7) +// --------------------------------------------------------------------------- + +/** Add an item to the inventory. Throws `CatalogError` with a message to show. */ +export function addCatalogItem(draft: ItemDraft): CatalogItem { + const item = createCatalogItem(draft, newId()); + useStore.getState().mutate(`Add ${item.name}`, (doc) => { + doc.catalog.push(item); + }); + return item; +} + +/** + * Edit an item in place, keeping its id. + * + * The id is what placements point at, so a rebuilt item must reuse it — replacing it + * would orphan every placement of that thing, which is precisely the coupling the + * catalog/placement split exists to make safe. + */ +export function updateCatalogItem(id: Id, draft: ItemDraft): CatalogItem { + const item = createCatalogItem(draft, id); + useStore.getState().mutate(`Edit ${item.name}`, (doc) => { + const index = doc.catalog.findIndex((i) => i.id === id); + if (index >= 0) doc.catalog[index] = item; + }); + return item; +} + +/** Remove an item and every placement of it — a placement with no item has no shape. */ +export function removeCatalogItem(id: Id): void { + const { doc } = useStore.getState(); + const item = doc.catalog.find((i) => i.id === id); + if (!item) return; + + useStore.getState().mutate(`Remove ${item.name}`, (draft) => { + draft.catalog = draft.catalog.filter((i) => i.id !== id); + const removed = new Set(); + for (const floor of draft.floors) { + for (const p of floor.placements) if (p.itemId === id) removed.add(p.id); + floor.placements = floor.placements.filter((p) => p.itemId !== id); + for (const p of floor.placements) { + if (p.mount.kind === 'surface' && removed.has(p.mount.hostId)) { + p.mount = { kind: 'floor' }; + p.elevation = 0; + } + } + } + }); + + if (useStore.getState().placingItemId === id) useStore.getState().setPlacingItem(null); +} + +export function setQuantityOwned(id: Id, quantity: number): void { + const next = Math.max(0, Math.round(quantity)); + useStore.getState().mutate('Quantity owned', (draft) => { + const item = draft.catalog.find((i) => i.id === id); + if (item) item.quantityOwned = next; + }); +} + +// --------------------------------------------------------------------------- +// Placements (PLAN.md §4.2, §9.1) +// --------------------------------------------------------------------------- + +/** + * The snap context for the active floor, minus the placement being dragged. + * + * Excluding it matters: an item is always inside its own outline, so a placement left + * in the host list would surface-mount to itself the moment it moved. + */ +export function placementSnapContext( + itemId: Id, + options: { excludePlacementId?: Id; toleranceMm: number }, +): PlacementSnapContext | null { + const state = useStore.getState(); + const floor = activeFloor(state); + const item = findItem(state.doc, itemId); + if (!item) return null; + + const hosts = floor.placements + .filter((p) => p.id !== options.excludePlacementId) + .flatMap((p) => { + const hostItem = findItem(state.doc, p.itemId); + if (!hostItem) return []; + return [ + { id: p.id, outline: worldOutline(p, hostItem), canHostSurface: hostItem.canHostSurface }, + ]; + }); + + return { + walls: floor.walls, + hosts, + footprint: item.footprint, + gridMm: state.doc.gridMm, + gridEnabled: state.gridEnabled, + toleranceMm: options.toleranceMm, + suppressed: state.snapSuppressed, + }; +} + +/** + * How high a wall-mounted item hangs when nothing more specific is known. + * + * Roughly the centre of a TV or the middle shelf of a run — high enough to read as + * mounted rather than as sitting on the floor, and always editable in the panel. + */ +export const DEFAULT_WALL_MOUNT_MM = 1200; + +/** How near a wall an item has to land for "wall-mounted" to mean anything. */ +export const WALL_MOUNT_REACH_MM = 900; + +/** + * The mount an item lands on, honouring the default recorded on the catalog item. + * + * A wall mount with no wall within reach falls back to the floor and **says so** — + * returning a reason rather than quietly storing a `wallId` it guessed. An item + * attached to a wall the user did not choose is worse than one on the floor, because + * moving that wall would then move the item. + */ +export function resolveDropMount( + floor: ReturnType, + item: CatalogItem, + position: Vec2, +): { mount: Mount; elevation: number; notice: string | null } { + switch (item.defaultMount) { + case 'wall': { + const wall = nearestWall(floor.walls, position, WALL_MOUNT_REACH_MM); + if (!wall) { + return { + mount: { kind: 'floor' }, + elevation: 0, + notice: `${item.name} is wall-mounted, but there is no wall here. It is on the floor — drop it against a wall, or set the mount in the panel.`, + }; + } + return { + mount: { kind: 'wall', wallId: wall.id }, + elevation: DEFAULT_WALL_MOUNT_MM, + notice: null, + }; + } + case 'ceiling': + // Flush to the ceiling; the drop is the number the user then adjusts. + return { mount: { kind: 'ceiling', drop: 0 }, elevation: 0, notice: null }; + default: + return { mount: { kind: 'floor' }, elevation: 0, notice: null }; + } +} + +/** + * Change what a placement is attached to. + * + * Returns a reason when the mount could not be applied, for the panel to show. + * Surface mounts are not settable here: a surface mount needs a specific host, which + * is chosen by dragging the item onto it, not by picking a word from a list. + */ +export function setPlacementMount(placementId: Id, kind: Mount['kind']): string | null { + const state = useStore.getState(); + const floor = activeFloor(state); + const placement = floor.placements.find((p) => p.id === placementId); + if (!placement) return null; + + let mount: Mount; + let elevation = 0; + + if (kind === 'wall') { + const wall = nearestWall(floor.walls, placement.position, WALL_MOUNT_REACH_MM); + if (!wall) return 'There is no wall near enough to mount this on. Move it against one first.'; + mount = { kind: 'wall', wallId: wall.id }; + elevation = placement.elevation || DEFAULT_WALL_MOUNT_MM; + } else if (kind === 'ceiling') { + mount = { kind: 'ceiling', drop: 0 }; + } else if (kind === 'surface') { + return 'Drag this onto the thing you want it to sit on.'; + } else { + mount = { kind: 'floor' }; + } + + useStore.getState().mutate('Change mount', (draft) => { + for (const f of draft.floors) { + const target = f.placements.find((p) => p.id === placementId); + if (!target) continue; + target.mount = mount; + target.elevation = elevation; + } + }); + return null; +} + +/** How far a ceiling-mounted item hangs below the ceiling. */ +export function setCeilingDrop(placementId: Id, dropMm: number): void { + const next = Math.max(0, Math.round(dropMm)); + useStore.getState().mutate('Drop', (draft) => { + for (const floor of draft.floors) { + const placement = floor.placements.find((p) => p.id === placementId); + if (placement?.mount.kind === 'ceiling') placement.mount = { kind: 'ceiling', drop: next }; + } + }); +} + +// --------------------------------------------------------------------------- +// Saved views (PLAN.md 10.3) +// --------------------------------------------------------------------------- + +/** Bookmark a camera. Document state — a bookmark travels with the file. */ +export function addSavedView(name: string, camera: SpaceCamera): void { + const { doc } = useStore.getState(); + const view = createSavedView( + newId(), + uniqueViewName(name.trim() || 'View', doc.savedViews), + camera, + ); + useStore.getState().mutate(`Save view ${view.name}`, (draft) => { + draft.savedViews.push(view); + }); +} + +export function removeSavedView(id: Id): void { + useStore.getState().mutate('Remove view', (draft) => { + draft.savedViews = draft.savedViews.filter((v) => v.id !== id); + }); +} + +/** + * Drop an item onto the plan. + * + * **This is where the calibration gate stops being decorative.** A floor whose plan + * has no scale refuses, with the same sentence the validation panel shows, because + * anything placed on an unscaled raster is placed at a size that means nothing. + * + * An item whose catalog entry says it is wall- or ceiling-mounted lands mounted, not + * on the floor — `resolveDropMount` decides, and reports when it could not. + */ +export function addPlacement( + itemId: Id, + position: Vec2, + options: { rotation?: number; mount?: Placement['mount'] } = {}, +): Placement | null { + const state = useStore.getState(); + const floor = activeFloor(state); + assertAcceptsPlacements(floor); + + const item = findItem(state.doc, itemId); + if (!item) return null; + + // An explicit mount wins over the item's own default: it means the gesture found + // something specific — a surface to stand on — and that beats a preference recorded + // when the item was created. Callers must not pass a floor mount just because they + // have one to hand; absent means "use the item's default". + const resolved = options.mount + ? { mount: options.mount, elevation: 0, notice: null } + : resolveDropMount(floor, item, position); + useStore.getState().setNotice(resolved.notice); + + const placement: Placement = { + id: newId(), + itemId, + floorId: floor.id, + position: { x: Math.round(position.x), y: Math.round(position.y) }, + // Normalized on the way in: a wall snap solves an angle with atan2, which happily + // returns -90, and nobody wants to read that in the properties panel. + rotation: normalizeRotation(options.rotation ?? 0), + mount: resolved.mount, + elevation: resolved.elevation, + }; + + const floorId = floor.id; + useStore.getState().mutate(`Place ${item.name}`, (draft) => { + const target = draft.floors.find((f) => f.id === floorId); + if (target) target.placements.push(placement); + }); + return placement; +} + +/** The geometry a placement drag currently previews, given the raw pointer position. */ +export function previewPlacementTransform( + transform: PlacementTransform, + at: Vec2, + ctx: PlacementSnapContext | null, +): PlacementTransform { + if (transform.mode === 'rotate') { + const dx = at.x - transform.origin.position.x; + const dy = at.y - transform.origin.position.y; + // The handle sticks out of the item's back, so a pointer directly above the + // centre reads as rotation 0 — the same convention wall snap uses. + const degrees = (Math.atan2(dx, -dy) * 180) / Math.PI; + const rotation = snapRotation(degrees, ctx?.suppressed ?? false); + return { ...transform, rotation, hints: [] }; + } + + const raw = { + x: transform.origin.position.x + (at.x - transform.grab.x), + y: transform.origin.position.y + (at.y - transform.grab.y), + }; + if (!ctx) return { ...transform, position: raw, hints: [] }; + + const snapped = snapPlacement(raw, transform.rotation, ctx); + return { + ...transform, + position: snapped.position, + rotation: snapped.rotation, + mount: draggedMount(transform.origin.mount, snapped.mount), + hints: snapped.hints, + }; +} + +/** + * What a placement is attached to after being dragged. + * + * The snap only ever reports two things: a *surface* mount when the item landed on + * something that can host it, and a floor mount otherwise. A floor mount from the + * snap therefore means "no host here", **not** "put this on the floor" — a wall snap + * seats the footprint against the wall and still reports floor. Letting it through + * would drop a wall-mounted TV to the ground the moment it was nudged 5mm along its + * own wall, silently, which is the same shadowing bug the drop path had. + * + * So: a host wins; otherwise a *surface* mount that found no host has genuinely been + * dragged off its host and falls to the floor; and a wall or ceiling mount is left + * alone, because moving a thing is not the same as detaching it. + */ +function draggedMount(origin: Mount, snapped: Mount): Mount { + if (snapped.kind === 'surface') return snapped; + if (origin.kind === 'surface') return { kind: 'floor' }; + return origin; +} + +/** + * Commit a placement drag. Called once on release, never during it. + * + * A press that never moved records nothing: immer patches an assignment even when the + * value is deep-equal, so an unchanged placement is skipped here rather than filtered + * out of the history later. + */ +export function commitPlacementTransform(transform: PlacementTransform): void { + const position = { x: Math.round(transform.position.x), y: Math.round(transform.position.y) }; + const state = useStore.getState(); + const floor = activeFloor(state); + const existing = floor.placements.find((p) => p.id === transform.placementId); + if (!existing) return; + + const sameMount = + existing.mount.kind === transform.mount.kind && + (existing.mount.kind !== 'surface' || + (transform.mount.kind === 'surface' && existing.mount.hostId === transform.mount.hostId)); + + if ( + existing.position.x === position.x && + existing.position.y === position.y && + existing.rotation === normalizeRotation(transform.rotation) && + sameMount + ) { + return; + } + + const label = transform.mode === 'rotate' ? 'Rotate item' : 'Move item'; + useStore.getState().mutate(label, (draft) => { + for (const f of draft.floors) { + const placement = f.placements.find((p) => p.id === transform.placementId); + if (!placement) continue; + placement.position = position; + placement.rotation = normalizeRotation(transform.rotation); + placement.mount = transform.mount; + // Elevation is derived for surface mounts, so the stored value is only + // meaningful on the floor and on a wall — and on the floor it is zero. + if (transform.mount.kind !== 'wall') placement.elevation = 0; + } + }); +} + +/** Keep rotation in [0, 360) so the properties panel never shows −450°. */ +function normalizeRotation(degrees: number): number { + const d = degrees % 360; + return d < 0 ? d + 360 : d; +} + +export function rotatePlacementBy(placementId: Id, degrees: number): void { + useStore.getState().mutate('Rotate item', (draft) => { + for (const floor of draft.floors) { + const placement = floor.placements.find((p) => p.id === placementId); + if (placement) placement.rotation = normalizeRotation(placement.rotation + degrees); + } + }); +} + +export function setPlacementElevation(placementId: Id, elevationMm: number): void { + const next = Math.max(0, Math.round(elevationMm)); + useStore.getState().mutate('Elevation', (draft) => { + for (const floor of draft.floors) { + const placement = floor.placements.find((p) => p.id === placementId); + if (placement) placement.elevation = next; + } + }); +} diff --git a/src/state/assets.test.ts b/src/state/assets.test.ts new file mode 100644 index 0000000..45b8473 --- /dev/null +++ b/src/state/assets.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createDocument, type SpaceDocument } from '../core/document'; +import { + adoptAssets, + assetIds, + assetMapFor, + assetUrl, + clearAssets, + getAsset, + hasAsset, + missingAssets, + putAsset, +} from './assets'; + +function doc(): SpaceDocument { + return createDocument({ id: 'doc', floorId: 'floor', now: '2026-01-01T00:00:00.000Z' }); +} + +const BYTES = new Uint8Array([1, 2, 3, 4, 5]); + +beforeEach(() => { + clearAssets(); +}); + +describe('putAsset', () => { + it('returns the manifest entry the document stores', () => { + const ref = putAsset({ mime: 'image/png', bytes: BYTES, id: 'a1' }); + expect(ref).toEqual({ id: 'a1', path: 'assets/a1.png', mime: 'image/png', bytes: 5 }); + }); + + it('keeps the bytes out of the document and in the store', () => { + // The document goes through immer on every mutation; a megabyte of raster in + // there would be snapshotted onto the undo stack. + putAsset({ mime: 'image/png', bytes: BYTES, id: 'a1' }); + expect(getAsset('a1')?.bytes).toBe(BYTES); + expect(hasAsset('a1')).toBe(true); + }); + + it('mints an id when the caller has none', () => { + const ref = putAsset({ mime: 'application/pdf', bytes: BYTES }); + expect(ref.id).toMatch(/[0-9a-f-]{36}/); + expect(ref.path).toBe(`assets/${ref.id}.pdf`); + }); +}); + +describe('assetMapFor', () => { + it('produces the path-keyed map writeSpace wants', () => { + const d = doc(); + d.assets = [putAsset({ mime: 'image/png', bytes: BYTES, id: 'a1' })]; + expect(assetMapFor(d)).toEqual({ 'assets/a1.png': BYTES }); + }); + + it('throws rather than writing a container short of a referenced file', () => { + // Silently writing `{}` here is what produces a .space that opens with a blank + // background on somebody else's machine — the failure the format exists to stop. + const d = doc(); + d.assets = [{ id: 'gone', path: 'assets/gone.png', mime: 'image/png', bytes: 12 }]; + expect(() => assetMapFor(d)).toThrow(/no longer loaded/); + }); + + it('names every missing file, not just the first', () => { + const d = doc(); + d.assets = [ + { id: 'x', path: 'assets/x.png', mime: 'image/png', bytes: 1 }, + { id: 'y', path: 'assets/y.pdf', mime: 'application/pdf', bytes: 1 }, + ]; + expect(() => assetMapFor(d)).toThrow(/assets\/x\.png, assets\/y\.pdf/); + }); + + it('is empty and happy for a document with no assets', () => { + expect(assetMapFor(doc())).toEqual({}); + }); +}); + +describe('adoptAssets', () => { + it('joins container paths to document ids through the manifest', () => { + const d = doc(); + d.assets = [{ id: 'a1', path: 'assets/a1.png', mime: 'image/png', bytes: 5 }]; + adoptAssets(d, { 'assets/a1.png': BYTES }); + + expect(getAsset('a1')?.bytes).toBe(BYTES); + expect(missingAssets(d)).toEqual([]); + }); + + it('replaces rather than merges, so one document cannot leak into the next', () => { + // Opening a second space used to leave the first one's background in the store, + // and the next save wrote it into the wrong file. + putAsset({ mime: 'image/png', bytes: BYTES, id: 'old' }); + + const d = doc(); + d.assets = [{ id: 'new', path: 'assets/new.png', mime: 'image/png', bytes: 5 }]; + adoptAssets(d, { 'assets/new.png': BYTES }); + + expect(hasAsset('old')).toBe(false); + expect(assetIds()).toEqual(['new']); + }); + + it('reports a file the container did not carry instead of faking it', () => { + const d = doc(); + d.assets = [{ id: 'a1', path: 'assets/a1.png', mime: 'image/png', bytes: 5 }]; + adoptAssets(d, {}); + + expect(missingAssets(d).map((a) => a.id)).toEqual(['a1']); + expect(() => assetMapFor(d)).toThrow(); + }); +}); + +describe('assetUrl', () => { + it('mints a URL from the bytes the store holds now', () => { + putAsset({ mime: 'image/png', bytes: BYTES, id: 'a1' }); + expect(assetUrl('a1')).toMatch(/^blob:/); + }); + + it('is stable for the same asset, so the image element is not rebuilt per render', () => { + putAsset({ mime: 'image/png', bytes: BYTES, id: 'a1' }); + expect(assetUrl('a1')).toBe(assetUrl('a1')); + }); + + it('returns null for an id it does not hold', () => { + expect(assetUrl('nope')).toBeNull(); + }); + + it('issues a fresh URL after the bytes are replaced', () => { + // The old URL points at the old blob. Handing it back after a reimport renders + // the previous plan, which looks exactly like the import having failed. + putAsset({ mime: 'image/png', bytes: BYTES, id: 'a1' }); + const first = assetUrl('a1'); + putAsset({ mime: 'image/png', bytes: new Uint8Array([9, 9]), id: 'a1' }); + + expect(assetUrl('a1')).not.toBe(first); + }); +}); + +describe('clearAssets', () => { + it('empties the store', () => { + putAsset({ mime: 'image/png', bytes: BYTES, id: 'a1' }); + clearAssets(); + expect(assetIds()).toEqual([]); + }); +}); diff --git a/src/state/assets.ts b/src/state/assets.ts new file mode 100644 index 0000000..a06dcd3 --- /dev/null +++ b/src/state/assets.ts @@ -0,0 +1,155 @@ +/** + * The runtime asset store. + * + * `SpaceDocument.assets` is a *manifest* — ids, paths, sizes. The bytes themselves + * are not in the document, because putting megabytes of raster into the undo stack + * would be absurd: immer would snapshot them on every mutation and a background + * import would become the most expensive operation in the application. + * + * So the bytes live here, keyed by asset id, for exactly as long as the document is + * open. Saving pulls them back out; opening puts them in. The document and this + * store are two halves of one thing, which is why `loadDocument` and `newDocument` + * reset both — a background that survived into the next document would be silently + * written into that document's file. + * + * Object URLs are created lazily and revoked on reset. They are deliberately *not* + * carried across a load: a `blob:` URL minted at import time looks correct until you + * save, reload the page and reopen, at which point it points at nothing. Every URL + * here is derived from bytes this store currently holds. + */ + +import type { AssetRef, Id, SpaceDocument } from '../core/document'; +import { assetPath, type ImportMime } from '../core/media'; +import type { AssetMap } from '../core/space-file'; + +export type StoredAsset = { + id: Id; + path: string; + mime: string; + bytes: Uint8Array; +}; + +const store = new Map(); +const urls = new Map(); + +function canMintUrls(): boolean { + return typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function'; +} + +/** Store bytes and return the manifest entry to put in the document. */ +export function putAsset(params: { + mime: ImportMime; + bytes: Uint8Array; + id?: Id; +}): AssetRef { + const id = params.id ?? crypto.randomUUID(); + const path = assetPath(id, params.mime); + store.set(id, { id, path, mime: params.mime, bytes: params.bytes }); + releaseUrl(id); + return { id, path, mime: params.mime, bytes: params.bytes.byteLength }; +} + +export function getAsset(id: Id): StoredAsset | undefined { + return store.get(id); +} + +export function hasAsset(id: Id): boolean { + return store.has(id); +} + +/** + * A URL the DOM can render this asset from, or `null` when the bytes are missing or + * the environment has no `createObjectURL` (the node test environment). + */ +export function assetUrl(id: Id): string | null { + const existing = urls.get(id); + if (existing) return existing; + + const asset = store.get(id); + if (!asset || !canMintUrls()) return null; + + // `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 url = URL.createObjectURL(new Blob([asset.bytes.slice()], { type: asset.mime })); + urls.set(id, url); + return url; +} + +function releaseUrl(id: Id): void { + const url = urls.get(id); + if (!url) return; + if (canMintUrls()) URL.revokeObjectURL(url); + urls.delete(id); +} + +/** Drop everything. Called whenever the open document is replaced. */ +export function clearAssets(): void { + for (const id of [...urls.keys()]) releaseUrl(id); + store.clear(); +} + +/** + * Replace the store with the assets read out of a `.space` container. + * + * The container keys entries by path and the document keys them by id, so the + * manifest is what joins them. An entry the container does not carry is skipped + * rather than faked — `missingAssets` is how the UI finds out. + */ +export function adoptAssets(doc: SpaceDocument, assets: AssetMap): void { + clearAssets(); + for (const ref of doc.assets) { + const bytes = assets[ref.path]; + if (!bytes) continue; + store.set(ref.id, { id: ref.id, path: ref.path, mime: ref.mime, bytes }); + } +} + +/** Manifest entries whose bytes this store does not have. */ +export function missingAssets(doc: SpaceDocument): AssetRef[] { + return doc.assets.filter((ref) => !store.has(ref.id)); +} + +/** + * The path-keyed map `writeSpace` wants. + * + * Throws when an asset is missing rather than writing a container short of a file + * the document references: that produces a `.space` which opens with a blank + * background on somebody else's machine, and it is the exact silent loss the format + * exists to prevent. + */ +export function assetMapFor(doc: SpaceDocument): AssetMap { + const map: AssetMap = {}; + const missing: string[] = []; + + for (const ref of doc.assets) { + const asset = store.get(ref.id); + if (!asset) { + missing.push(ref.path); + continue; + } + map[ref.path] = asset.bytes; + } + + if (missing.length > 0) { + throw new Error( + `Cannot save: ${missing.length} file(s) this space references are no longer loaded ` + + `(${missing.join(', ')}).`, + ); + } + return map; +} + +/** + * Every asset currently held. + * + * The autosave database wants the bytes by id, not by path; the `.space` writer wants + * the reverse. Both views are cheap, and neither is the "real" one. + */ +export function allAssets(): StoredAsset[] { + return [...store.values()]; +} + +/** Every id currently held. Test and diagnostic use. */ +export function assetIds(): Id[] { + return [...store.keys()]; +} diff --git a/src/state/autosave-db.ts b/src/state/autosave-db.ts new file mode 100644 index 0000000..f273586 --- /dev/null +++ b/src/state/autosave-db.ts @@ -0,0 +1,262 @@ +/** + * The autosave database. See PLAN.md §5. + * + * IndexedDB, two object stores: + * + * documents keyed by document id { documentId, title, savedAt, modifiedAt, document, assetIds } + * assets keyed by asset id { id, path, mime, bytes } + * + * ## Why assets get their own store + * + * An autosave that keeps only `document.json` recovers a space whose background is + * gone — which is precisely the failure `assetMapFor` throws to prevent, reached + * through a different door. But a floor plan raster is megabytes, and rewriting it + * every twenty seconds to protect a document that is measured in kilobytes is absurd. + * + * The way out is that **an asset is immutable once stored**: `putAsset` mints a new id + * for new bytes and never rewrites an existing one. So the assets an autosave needs + * are written *by id, once*, and every tick after that writes the document record + * alone. The tick cost does not depend on how big the background is. + * + * ## Failure is not an error here + * + * Every call resolves rather than rejecting when IndexedDB is unavailable, blocked by + * a privacy setting, or over quota. Autosave is a safety net; a safety net that can + * take down the application it is protecting is a worse bargain than no net. The + * caller finds out through a `false` or a `null`, and the file-save path — the one the + * user actually relies on — is untouched by any of it. + */ + +import type { Id, SpaceDocument } from '../core/document'; +import type { AutosaveSummary } from '../core/recovery'; +import type { AssetMap } from '../core/space-file'; +import type { StoredAsset } from './assets'; + +const DB_NAME = 'floorplan'; +const DB_VERSION = 1; +const DOC_STORE = 'documents'; +const ASSET_STORE = 'assets'; + +export type AutosaveRecord = AutosaveSummary & { + document: SpaceDocument; + assetIds: Id[]; +}; + +type AssetRow = { id: Id; path: string; mime: string; bytes: Uint8Array }; + +/** + * Read at call time, never captured at module load. + * + * The same rule as the save picker: a module-level snapshot cannot be replaced by a + * test, and it decides for the life of the page based on whatever was true during the + * first import. + */ +export function autosaveAvailable(): boolean { + return typeof indexedDB !== 'undefined' && indexedDB !== null; +} + +function open(): Promise { + if (!autosaveAvailable()) return Promise.resolve(null); + + return new Promise((resolve) => { + let request: IDBOpenDBRequest; + try { + request = indexedDB.open(DB_NAME, DB_VERSION); + } catch { + resolve(null); + return; + } + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(DOC_STORE)) { + db.createObjectStore(DOC_STORE, { keyPath: 'documentId' }); + } + if (!db.objectStoreNames.contains(ASSET_STORE)) { + db.createObjectStore(ASSET_STORE, { keyPath: 'id' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => resolve(null); + // A blocked upgrade means another tab holds the old version. Give up rather than + // hang: the other tab is autosaving perfectly well. + request.onblocked = () => resolve(null); + }); +} + +function wrap(request: IDBRequest): Promise { + return new Promise((resolve) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => resolve(null); + }); +} + +function finish(tx: IDBTransaction): Promise { + return new Promise((resolve) => { + tx.oncomplete = () => resolve(true); + tx.onerror = () => resolve(false); + tx.onabort = () => resolve(false); + }); +} + +/** Every autosave in the database, newest first. Metadata only. */ +export async function listAutosaves(): Promise { + const db = await open(); + if (!db) return []; + + try { + const tx = db.transaction(DOC_STORE, 'readonly'); + const rows = await wrap(tx.objectStore(DOC_STORE).getAll()); + return (rows ?? []) + .map((r) => ({ + documentId: r.documentId, + title: r.title, + savedAt: r.savedAt, + modifiedAt: r.modifiedAt, + })) + .sort((a, b) => (a.savedAt < b.savedAt ? 1 : -1)); + } catch { + return []; + } finally { + db.close(); + } +} + +/** + * Write the document, and any of its assets not already stored. + * + * Returns false when nothing was written. The caller uses that to stop claiming a + * document is protected when it is not. + */ +export async function writeAutosave( + doc: SpaceDocument, + assets: readonly StoredAsset[], + now = new Date().toISOString(), +): Promise { + const db = await open(); + if (!db) return false; + + try { + const tx = db.transaction([DOC_STORE, ASSET_STORE], 'readwrite'); + const assetStore = tx.objectStore(ASSET_STORE); + + // Only the ids that are not there yet. This is what keeps a tick cheap: the + // background is written once, on the tick after it was imported, and never again. + const known = new Set((await wrap(assetStore.getAllKeys())) ?? []); + for (const asset of assets) { + if (known.has(asset.id)) continue; + const row: AssetRow = { + id: asset.id, + path: asset.path, + mime: asset.mime, + // `bytes.buffer` may be a pooled ArrayBuffer larger than the data; slice so + // the structured clone stores this asset and not whatever followed it. + bytes: asset.bytes.slice(), + }; + assetStore.put(row); + } + + const record: AutosaveRecord = { + documentId: doc.id, + title: doc.title, + savedAt: now, + modifiedAt: doc.modifiedAt, + document: doc, + assetIds: doc.assets.map((a) => a.id), + }; + tx.objectStore(DOC_STORE).put(record); + + return await finish(tx); + } catch { + return false; + } finally { + db.close(); + } +} + +/** A stored autosave, with its assets in the shape `loadDocument` wants. */ +export async function readAutosave( + documentId: Id, +): Promise<{ document: SpaceDocument; assets: AssetMap } | null> { + const db = await open(); + if (!db) return null; + + try { + const tx = db.transaction([DOC_STORE, ASSET_STORE], 'readonly'); + const record = await wrap( + tx.objectStore(DOC_STORE).get(documentId), + ); + if (!record) return null; + + // Every request issued before the first await. A transaction stays alive across a + // microtask but not across a macrotask, and awaiting each `get` in turn walks that + // line for no benefit — these are independent lookups. + const store = tx.objectStore(ASSET_STORE); + const pending = record.assetIds.map((id) => wrap(store.get(id))); + + const assets: AssetMap = {}; + for (const row of await Promise.all(pending)) { + // A missing row is left out rather than faked. `missingAssets` is how the UI + // finds out, and it says so — the same contract as opening a short container. + if (row) assets[row.path] = row.bytes; + } + + return { document: record.document, assets }; + } catch { + return null; + } finally { + db.close(); + } +} + +/** + * Forget a document's autosave, and any asset no remaining record references. + * + * Called when a document is saved to a file. That rule is what keeps the recovery + * prompt meaningful: a surviving record means unsaved work, so the prompt appears + * when something is genuinely at risk rather than on every clean reload. A prompt you + * see every morning is one you dismiss without reading. + */ +export async function dropAutosave(documentId: Id): Promise { + const db = await open(); + if (!db) return false; + + try { + const tx = db.transaction([DOC_STORE, ASSET_STORE], 'readwrite'); + const docs = tx.objectStore(DOC_STORE); + docs.delete(documentId); + + // Sweep in the same transaction, so the read of what is still referenced cannot + // race a concurrent write from another tab. + const remaining = (await wrap(docs.getAll())) ?? []; + const referenced = new Set(remaining.flatMap((r) => r.assetIds)); + + const assets = tx.objectStore(ASSET_STORE); + const keys = (await wrap(assets.getAllKeys())) ?? []; + for (const key of keys) { + if (typeof key === 'string' && !referenced.has(key)) assets.delete(key); + } + + return await finish(tx); + } catch { + return false; + } finally { + db.close(); + } +} + +/** Test and diagnostic use — drops the whole database. */ +export async function clearAutosaves(): Promise { + const db = await open(); + if (!db) return; + try { + const tx = db.transaction([DOC_STORE, ASSET_STORE], 'readwrite'); + tx.objectStore(DOC_STORE).clear(); + tx.objectStore(ASSET_STORE).clear(); + await finish(tx); + } catch { + // Nothing to do — the caller is a test or a reset button. + } finally { + db.close(); + } +} diff --git a/src/state/autosave.test.ts b/src/state/autosave.test.ts new file mode 100644 index 0000000..d0650d3 --- /dev/null +++ b/src/state/autosave.test.ts @@ -0,0 +1,138 @@ +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 + * are two schedules, one of which writes on every frame of a wall drag. `autosaveDelay` + * is the reading that satisfies both: a quiet debounce with a hard deadline. + */ +describe('when the next autosave runs', () => { + it('waits for the user to stop, not for the interval', () => { + // One edit, then nothing. Twenty seconds of exposure for a two-second wait is a + // bad trade, and the deadline is not the point of the debounce. + const now = 100_000; + expect(autosaveDelay(now, now)).toBe(AUTOSAVE_QUIET_MS); + }); + + it('does not let continuous activity postpone it forever', () => { + // A drag reschedules on every mousemove. A pure debounce would then never fire + // while the user keeps working — which is precisely when there is most to lose. + const lastWrite = 0; + const now = AUTOSAVE_MAX_INTERVAL_MS - 500; + expect(autosaveDelay(now, lastWrite)).toBe(500); + }); + + it('fires immediately once the deadline has passed', () => { + expect(autosaveDelay(AUTOSAVE_MAX_INTERVAL_MS + 5_000, 0)).toBe(0); + }); + + it('never returns a negative delay', () => { + // `setTimeout` would treat one as zero anyway; stated rather than relied upon. + expect(autosaveDelay(1_000_000, 0)).toBeGreaterThanOrEqual(0); + }); + + it('is bounded by the quiet period and the deadline, whatever the inputs', () => { + for (let elapsed = 0; elapsed <= AUTOSAVE_MAX_INTERVAL_MS + 5_000; elapsed += 250) { + const delay = autosaveDelay(elapsed, 0); + expect(delay).toBeGreaterThanOrEqual(0); + expect(delay).toBeLessThanOrEqual(AUTOSAVE_QUIET_MS); + // The invariant that matters: a write lands by the deadline, or at once if the + // deadline has already gone by. + if (elapsed >= AUTOSAVE_MAX_INTERVAL_MS) expect(delay).toBe(0); + else expect(elapsed + delay).toBeLessThanOrEqual(AUTOSAVE_MAX_INTERVAL_MS); + } + }); +}); + +/** + * 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 new file mode 100644 index 0000000..3278c62 --- /dev/null +++ b/src/state/autosave.ts @@ -0,0 +1,122 @@ +/** + * When the autosave runs. See PLAN.md §5. + * + * §5 asks for "every 20s **and** on every meaningful mutation", which as written are + * two different schedules — one of them writes a megabyte-class record on every + * mousemove of a wall drag. The reading that satisfies both without that is a debounce + * with a deadline: + * + * - **quiet** — two seconds after the last change, so a single edit is protected + * almost immediately rather than up to twenty seconds later. + * - **deadline** — never longer than twenty seconds since the last write, so a + * continuous drag cannot postpone the autosave indefinitely by resetting the + * debounce on every frame. + * + * `autosaveDelay` is that rule, pure and tested. Everything below it is a timer and a + * store subscription. + */ + +import { useStore } from './store'; +import { allAssets } from './assets'; +import { dropAutosave, writeAutosave } from './autosave-db'; + +/** Longest a change may go unwritten. */ +export const AUTOSAVE_MAX_INTERVAL_MS = 20_000; +/** How long after the last change a quiet document is written. */ +export const AUTOSAVE_QUIET_MS = 2_000; + +/** + * How long to wait before writing, given when the last write happened. + * + * The `min` is the deadline: as `now` approaches `lastWriteAt + MAX` the second term + * shrinks past the quiet period and finally to zero, so continuous activity gets a + * write every `MAX` rather than none at all. The `max` keeps an overdue write at zero + * instead of a negative delay, which `setTimeout` would silently treat as zero anyway + * — stated rather than relied on. + */ +export function autosaveDelay( + now: number, + lastWriteAt: number, + quietMs = AUTOSAVE_QUIET_MS, + maxMs = AUTOSAVE_MAX_INTERVAL_MS, +): number { + return Math.max(0, Math.min(quietMs, lastWriteAt + maxMs - now)); +} + +let timer: ReturnType | null = null; +let lastWriteAt = 0; +let running = false; + +function cancel(): void { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } +} + +async function write(): Promise { + timer = null; + const { doc, dirty } = useStore.getState(); + // Re-checked at fire time, not only at schedule time: a save landing during the + // debounce window would otherwise write a record for a document that is now on + // disk, and the recovery prompt exists to mean "there is unsaved work". + if (!dirty) return; + + lastWriteAt = Date.now(); + await writeAutosave(doc, allAssets()); +} + +function schedule(): void { + // 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)); +} + +/** + * Begin autosaving the open document. Returns a stop function. + * + * Idempotent, because React 19's StrictMode mounts effects twice in development and a + * second subscription would double every write. + */ +export function startAutosave(): () => void { + if (running) return () => undefined; + running = true; + lastWriteAt = Date.now(); + + const unsubscribe = useStore.subscribe((state, previous) => { + if (state.doc === previous.doc && state.dirty === previous.dirty) return; + if (state.dirty) schedule(); + else cancel(); + }); + + return () => { + running = false; + cancel(); + unsubscribe(); + }; +} + +/** + * Forget the autosave for a document that has just been written to a file. + * + * This rule is what keeps the recovery prompt worth reading: a surviving record means + * that document had unsaved changes when the tab went away. Without it every clean + * reload would offer to recover work already saved, and the prompt would be trained + * out of the user long before the one time it mattered. + */ +export function forgetAutosave(documentId: string): void { + cancel(); + lastWriteAt = Date.now(); + void dropAutosave(documentId); +} + +/** Test seam — resets the module's timing state between cases. */ +export function resetAutosaveTiming(): void { + cancel(); + running = false; + lastWriteAt = 0; +} diff --git a/src/state/background.test.ts b/src/state/background.test.ts new file mode 100644 index 0000000..91d9bd4 --- /dev/null +++ b/src/state/background.test.ts @@ -0,0 +1,257 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { activeFloor, useStore } from './store'; +import { + commitCalibration, + nudgeBackgroundRotation, + removeBackground, + setBackground, + setBackgroundLocked, + setBackgroundOpacity, +} from './actions'; +import { assetIds, clearAssets, putAsset } from './assets'; +import { + CalibrationError, + createBackground, + effectiveMmPerPx, + imageToDoc, + isCalibrated, +} from '../core/calibration'; +import type { AssetRef } from '../core/document'; + +function floor() { + return activeFloor(useStore.getState()); +} + +const RASTER = new Uint8Array([1, 2, 3]); + +/** Import a plan the way `attachPlan` does, minus the DOM decoding. */ +function importPlan(id = 'raster', withSource = false): AssetRef { + const assets: AssetRef[] = []; + let sourceId: string | undefined; + if (withSource) { + const src = putAsset({ mime: 'application/pdf', bytes: RASTER, id: `${id}-src` }); + assets.push(src); + sourceId = src.id; + } + const ref = putAsset({ mime: 'image/png', bytes: RASTER, id }); + assets.push(ref); + + setBackground( + createBackground({ + assetId: ref.id, + pixelSize: { width: 1200, height: 900 }, + ...(sourceId ? { sourceAssetId: sourceId } : {}), + }), + assets, + ); + return ref; +} + +beforeEach(() => { + useStore.getState().newDocument(); + clearAssets(); +}); + +describe('setBackground', () => { + it('lands the manifest and the background in one undo step', () => { + importPlan(); + const state = useStore.getState(); + + expect(state.past).toHaveLength(1); + expect(state.doc.assets.map((a) => a.id)).toEqual(['raster']); + expect(floor().background?.assetId).toBe('raster'); + }); + + it('never leaves a document referencing an asset it has not declared', () => { + // One mutation, so there is no intermediate state where the background points + // at a manifest entry that does not exist yet. + importPlan(); + const bg = floor().background!; + expect(useStore.getState().doc.assets.some((a) => a.id === bg.assetId)).toBe(true); + }); + + it('carries the original PDF alongside the render', () => { + importPlan('r2', true); + expect(useStore.getState().doc.assets.map((a) => a.id).sort()).toEqual(['r2', 'r2-src']); + expect(floor().background?.sourceAssetId).toBe('r2-src'); + }); + + it('retires the previous plans manifest entries when replaced', () => { + importPlan('first'); + importPlan('second'); + + expect(useStore.getState().doc.assets.map((a) => a.id)).toEqual(['second']); + // The bytes stay: undo has to be able to bring the first plan back, and there + // is nowhere else they could come from. + expect(assetIds().sort()).toEqual(['first', 'second']); + }); + + it('is undoable back to no plan at all', () => { + importPlan(); + useStore.getState().undo(); + + expect(floor().background).toBeUndefined(); + expect(useStore.getState().doc.assets).toEqual([]); + }); +}); + +describe('removeBackground', () => { + it('drops the background and its manifest entries together', () => { + importPlan('r3', true); + removeBackground(); + + expect(floor().background).toBeUndefined(); + expect(useStore.getState().doc.assets).toEqual([]); + }); + + it('does nothing, and records nothing, when there is no plan', () => { + removeBackground(); + expect(useStore.getState().past).toHaveLength(0); + }); + + it('restores on undo, bytes included', () => { + importPlan(); + removeBackground(); + useStore.getState().undo(); + + expect(floor().background?.assetId).toBe('raster'); + expect(assetIds()).toContain('raster'); + }); +}); + +describe('commitCalibration', () => { + it('takes the reference in document mm and makes it measure what was typed', () => { + importPlan(); + const before = floor().background!; + + // Two points 2000mm apart under the provisional scale, declared to be 4000mm. + const a = imageToDoc(before, { x: 200, y: 400 }); + const b = imageToDoc(before, { x: 600, y: 400 }); + commitCalibration(a, b, 4000); + + const after = floor().background!; + expect(isCalibrated(after)).toBe(true); + const measured = Math.hypot( + imageToDoc(after, { x: 600, y: 400 }).x - imageToDoc(after, { x: 200, y: 400 }).x, + imageToDoc(after, { x: 600, y: 400 }).y - imageToDoc(after, { x: 200, y: 400 }).y, + ); + expect(measured).toBeCloseTo(4000, 6); + expect(effectiveMmPerPx(after)).toBeCloseTo(10, 9); + }); + + it('is one undo step, and undoes back to uncalibrated', () => { + importPlan(); + const bg = floor().background!; + commitCalibration(imageToDoc(bg, { x: 0, y: 0 }), imageToDoc(bg, { x: 400, y: 0 }), 3000); + + expect(useStore.getState().past).toHaveLength(2); + useStore.getState().undo(); + expect(isCalibrated(floor().background)).toBe(false); + }); + + it('leaves the document untouched when the line is too short to measure', () => { + importPlan(); + const bg = floor().background!; + const a = imageToDoc(bg, { x: 100, y: 100 }); + const b = imageToDoc(bg, { x: 102, y: 100 }); + + expect(() => commitCalibration(a, b, 3000)).toThrow(CalibrationError); + expect(useStore.getState().past).toHaveLength(1); + expect(isCalibrated(floor().background)).toBe(false); + }); + + it('does nothing when there is no plan to calibrate', () => { + commitCalibration({ x: 0, y: 0 }, { x: 1000, y: 0 }, 3000); + expect(useStore.getState().past).toHaveLength(0); + }); +}); + +describe('background properties', () => { + it('clamps opacity into range', () => { + importPlan(); + setBackgroundOpacity(2); + expect(floor().background?.opacity).toBe(1); + setBackgroundOpacity(-1); + expect(floor().background?.opacity).toBe(0); + }); + + it('records nothing when a property is set to what it already is', () => { + // immer patches an assignment even when the value is deep-equal, so the no-op + // guard in `mutate` is what keeps this off the undo stack. + importPlan(); + const before = useStore.getState().past.length; + setBackgroundLocked(true); + expect(useStore.getState().past).toHaveLength(before); + }); + + it('folds a slider drag into one entry but does not swallow the edit next to it', () => { + // Coalescing keys on the label alone, so this is the case that would break it: + // a different edit landing between two slider frames must not be absorbed by + // the second one and lost. + importPlan(); + const base = useStore.getState().past.length; + + setBackgroundOpacity(0.8); + setBackgroundOpacity(0.7); // same label — folds into the entry above + nudgeBackgroundRotation(0.5); + setBackgroundOpacity(0.6); // different label above it, so a new entry + + expect(useStore.getState().past).toHaveLength(base + 3); + + useStore.getState().undo(); + expect(floor().background?.opacity).toBeCloseTo(0.7, 9); + expect(floor().background?.transform.rotationDeg).toBeCloseTo(0.5, 9); + + useStore.getState().undo(); + expect(floor().background?.transform.rotationDeg).toBe(0); + expect(floor().background?.opacity).toBeCloseTo(0.7, 9); + + // And the folded pair undoes as one, back to where the drag started. + useStore.getState().undo(); + expect(floor().background?.opacity).toBeCloseTo(0.45, 9); + }); + + it('accumulates rotation nudges', () => { + importPlan(); + nudgeBackgroundRotation(0.5); + nudgeBackgroundRotation(0.5); + expect(floor().background?.transform.rotationDeg).toBeCloseTo(1, 9); + }); +}); + +describe('the calibration gate in editor state', () => { + it('records nothing — an abandoned calibration leaves no history', () => { + importPlan(); + const before = useStore.getState().past.length; + + useStore.getState().beginCalibration(); + for (let i = 0; i < 50; i++) { + useStore.getState().setCalibrationRef({ a: { x: 0, y: 0 }, b: { x: i, y: i } }); + } + useStore.getState().endCalibration(); + + expect(useStore.getState().past).toHaveLength(before); + expect(useStore.getState().calibrationRef).toBeNull(); + }); + + it('cancels whatever gesture was in flight when it opens', () => { + importPlan(); + const store = useStore.getState(); + store.setDraft({ tool: 'wall', points: [{ x: 0, y: 0 }], cursor: { x: 0, y: 0 } }); + store.beginCalibration(); + + expect(useStore.getState().draft).toBeNull(); + expect(useStore.getState().calibrating).toBe(true); + }); + + it('closes when the document is replaced', () => { + importPlan(); + useStore.getState().beginCalibration(); + useStore.getState().newDocument(); + + expect(useStore.getState().calibrating).toBe(false); + // The asset store is the other half of the document; a leftover plan here would + // be written into the next document's file. + expect(assetIds()).toEqual([]); + }); +}); diff --git a/src/state/floors.test.ts b/src/state/floors.test.ts new file mode 100644 index 0000000..fb9a23b --- /dev/null +++ b/src/state/floors.test.ts @@ -0,0 +1,329 @@ +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'); + }); +}); + +describe('the defects a review pass found', () => { + it('clears a walkway route drawn on a floor that is then deleted', () => { + // `deleteFloor` moves `activeFloorId` inside its own recipe, which makes + // `setActiveFloor` a no-op afterwards — so folding the reset into the switch left + // the route alive, re-answering against the remaining floor's geometry. + const upstairs = addFloor('above'); + useStore.getState().setWalkway([ + { x: 0, y: 2000 }, + { x: 5000, y: 2000 }, + ]); + + deleteFloor(upstairs); + expect(useStore.getState().walkway).toBeNull(); + }); + + it('leaves the active floor resolvable after undoing the floor that was added', () => { + // `addFloor` used to switch *after* its own mutation, so the inverse patch removed + // the floor while `activeFloorId` still named it. `activeFloor` falls back, which + // hides it — but the picker matches no option and a save writes an id that is not + // in the document. + const ground = groundId(); + addFloor('above'); + useStore.getState().undo(); + + const { doc } = useStore.getState(); + expect(doc.floors.some((f) => f.id === doc.activeFloorId)).toBe(true); + expect(doc.activeFloorId).toBe(ground); + }); + + it('refuses to move furniture onto a floor whose plan has no scale', () => { + // The same gate `addPlacement` applies, arrived at by a different door: a floor + // with an uncalibrated background has no trustworthy scale, and anything on it is + // placed at a size that means nothing. + const dresser = addCatalogItem(DRESSER); + const placed = addPlacement(dresser.id, { x: 2000, y: 2000 })!; + const ground = groundId(); + + const upstairs = addFloor('above'); + useStore.getState().mutate('uncalibrated plan', (draft) => { + draft.floors.find((f) => f.id === upstairs)!.background = { + assetId: 'a', + pixelSize: { width: 1000, height: 800 }, + transform: { position: { x: 0, y: 0 }, rotationDeg: 0 }, + opacity: 1, + locked: false, + }; + }); + + expect(movePlacementToFloor(placed.id, upstairs)).toContain('not been calibrated'); + expect( + useStore.getState().doc.floors.find((f) => f.id === ground)!.placements, + ).toHaveLength(1); + }); + + it('carries a rider that was already sitting on another floor', () => { + // A cross-floor surface mount is representable and can be in a file. Walking only + // the source floor leaves behind the very rider the walk exists to carry. + 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 }; + }); + + const attic = addFloor('above'); + movePlacementToFloor(host.id, attic); + + const top = useStore.getState().doc.floors.find((f) => f.id === attic)!; + expect(top.placements.map((p) => p.id).sort()).toEqual([host.id, rider.id].sort()); + // And it is still on the dresser, because the dresser came with it. + expect(top.placements.find((p) => p.id === rider.id)!.mount).toEqual({ + kind: 'surface', + hostId: host.id, + }); + }); +}); diff --git a/src/state/inventory.test.ts b/src/state/inventory.test.ts new file mode 100644 index 0000000..de2f374 --- /dev/null +++ b/src/state/inventory.test.ts @@ -0,0 +1,292 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { activeFloor, useStore } from './store'; +import { + addCatalogItem, + addPlacement, + commitPlacementTransform, + deleteSelection, + previewPlacementTransform, + placementSnapContext, + removeCatalogItem, + rotatePlacementBy, + setBackground, + setQuantityOwned, + updateCatalogItem, +} from './actions'; +import { clearAssets, putAsset } from './assets'; +import { CatalogError, type ItemDraft } from '../core/catalog'; +import { PlacementBlockedError, createBackground } from '../core/calibration'; +import { placedCount, unplacedCount, type CatalogItem } from '../core/document'; +import type { PlacementTransform } from './store'; + +const TABLE: ItemDraft = { + name: 'Dining table', + category: 'table', + shape: 'rect', + widthMm: 1800, + depthMm: 900, + heightMm: 760, + voidBelowMm: 720, + quantityOwned: 1, +}; + +const CHAIR: ItemDraft = { + name: 'Dining chair', + category: 'seating', + shape: 'rect', + widthMm: 460, + depthMm: 510, + heightMm: 900, + voidBelowMm: 0, + quantityOwned: 6, +}; + +const LAMP: ItemDraft = { + name: 'Lamp', + category: 'lighting', + shape: 'circle', + widthMm: 300, + depthMm: 300, + heightMm: 500, + voidBelowMm: 0, +}; + +function floor() { + return activeFloor(useStore.getState()); +} + +function transformFor(placementId: string, mode: 'move' | 'rotate' = 'move'): PlacementTransform { + const placement = floor().placements.find((p) => p.id === placementId)!; + return { + placementId, + mode, + grab: placement.position, + origin: { + position: placement.position, + rotation: placement.rotation, + mount: placement.mount, + }, + position: placement.position, + rotation: placement.rotation, + mount: placement.mount, + hints: [], + }; +} + +beforeEach(() => { + useStore.getState().newDocument(); + clearAssets(); +}); + +describe('the catalog', () => { + it('adds an item as one undo step', () => { + const item = addCatalogItem(TABLE); + expect(useStore.getState().doc.catalog).toHaveLength(1); + expect(useStore.getState().past).toHaveLength(1); + expect(item.voidBelowMm).toBe(720); + }); + + it('refuses an item that would have no solid part', () => { + expect(() => addCatalogItem({ ...TABLE, voidBelowMm: 800 })).toThrow(CatalogError); + expect(useStore.getState().doc.catalog).toHaveLength(0); + expect(useStore.getState().past).toHaveLength(0); + }); + + it('keeps the id when an item is edited, so placements survive', () => { + // The whole point of the catalog/placement split: editing the thing you own must + // not orphan the six of them you already put down. + const item = addCatalogItem(CHAIR); + addPlacement(item.id, { x: 0, y: 0 }); + updateCatalogItem(item.id, { ...CHAIR, name: 'Side chair', widthMm: 480 }); + + const updated = useStore.getState().doc.catalog[0] as CatalogItem; + expect(updated.id).toBe(item.id); + expect(updated.name).toBe('Side chair'); + expect(floor().placements[0]!.itemId).toBe(item.id); + }); + + it('counts owned and placed separately', () => { + // "I own 6, 4 are placed, 2 unplaced" has to stay expressible. + const item = addCatalogItem(CHAIR); + for (let i = 0; i < 4; i++) addPlacement(item.id, { x: i * 1000, y: 0 }); + + const doc = useStore.getState().doc; + expect(placedCount(doc, item.id)).toBe(4); + expect(unplacedCount(doc, item.id)).toBe(2); + }); + + it('reports a negative unplaced count rather than clamping it', () => { + // Owning fewer than you have placed is a real state, and hiding it would hide the + // fact that the plan needs two more chairs bought. + const item = addCatalogItem(CHAIR); + for (let i = 0; i < 6; i++) addPlacement(item.id, { x: i * 1000, y: 0 }); + setQuantityOwned(item.id, 4); + + expect(unplacedCount(useStore.getState().doc, item.id)).toBe(-2); + }); + + it('removes an item together with every placement of it', () => { + const item = addCatalogItem(CHAIR); + addPlacement(item.id, { x: 0, y: 0 }); + addPlacement(item.id, { x: 2000, y: 0 }); + removeCatalogItem(item.id); + + expect(useStore.getState().doc.catalog).toHaveLength(0); + expect(floor().placements).toHaveLength(0); + }); + + it('re-seats anything standing on a placement it removes', () => { + const table = addCatalogItem(TABLE); + const lamp = addCatalogItem(LAMP); + const host = addPlacement(table.id, { x: 0, y: 0 })!; + const child = addPlacement(lamp.id, { x: 0, y: 0 }, { mount: { kind: 'surface', hostId: host.id } })!; + + removeCatalogItem(table.id); + + const remaining = floor().placements.find((p) => p.id === child.id)!; + expect(remaining.mount).toEqual({ kind: 'floor' }); + }); +}); + +describe('placing', () => { + it('refuses on an uncalibrated plan, with the reason the panel shows', () => { + // The gate shipped in phase 3 as a predicate; this is what makes it real. + const item = addCatalogItem(TABLE); + const ref = putAsset({ mime: 'image/png', bytes: new Uint8Array([1]), id: 'bg' }); + setBackground(createBackground({ assetId: ref.id, pixelSize: { width: 800, height: 600 } }), [ref]); + + expect(() => addPlacement(item.id, { x: 0, y: 0 })).toThrow(PlacementBlockedError); + expect(floor().placements).toHaveLength(0); + }); + + it('allows placing once the plan has a scale', () => { + const item = addCatalogItem(TABLE); + const ref = putAsset({ mime: 'image/png', bytes: new Uint8Array([1]), id: 'bg' }); + const bg = createBackground({ assetId: ref.id, pixelSize: { width: 800, height: 600 } }); + setBackground({ ...bg, calibration: { refA: { x: 0, y: 0 }, refB: { x: 400, y: 0 }, realLengthMm: 3000, mmPerPx: 7.5 } }, [ref]); + + expect(() => addPlacement(item.id, { x: 0, y: 0 })).not.toThrow(); + expect(floor().placements).toHaveLength(1); + }); + + it('rounds the position onto the integer-millimetre grid', () => { + const item = addCatalogItem(TABLE); + const placement = addPlacement(item.id, { x: 1000.4, y: -2000.6 })!; + expect(placement.position).toEqual({ x: 1000, y: -2001 }); + }); +}); + +describe('dragging a placement', () => { + it('records nothing until the pointer is released', () => { + const item = addCatalogItem(TABLE); + const placement = addPlacement(item.id, { x: 0, y: 0 })!; + const before = useStore.getState().past.length; + + let transform = transformFor(placement.id); + const ctx = placementSnapContext(item.id, { toleranceMm: 100, excludePlacementId: placement.id }); + for (let i = 1; i <= 200; i++) { + transform = previewPlacementTransform(transform, { x: i * 10, y: 0 }, ctx); + useStore.getState().setPlacementTransform(transform); + } + + expect(useStore.getState().past).toHaveLength(before); + + commitPlacementTransform(transform); + expect(useStore.getState().past).toHaveLength(before + 1); + }); + + it('is absolute, not accumulated frame by frame', () => { + const item = addCatalogItem(TABLE); + const placement = addPlacement(item.id, { x: 0, y: 0 })!; + let transform = transformFor(placement.id); + + // Wander, then come back to exactly where the drag began. + for (const at of [{ x: 900, y: 900 }, { x: -400, y: 250 }, { x: 0, y: 0 }]) { + transform = previewPlacementTransform(transform, at, null); + } + expect(transform.position).toEqual({ x: 0, y: 0 }); + }); + + it('records nothing for a press that never moved', () => { + const item = addCatalogItem(TABLE); + const placement = addPlacement(item.id, { x: 0, y: 0 })!; + const before = useStore.getState().past.length; + + commitPlacementTransform(transformFor(placement.id)); + expect(useStore.getState().past).toHaveLength(before); + }); + + it('mounts onto a host it is dragged over, and back off again', () => { + const table = addCatalogItem(TABLE); + const lamp = addCatalogItem(LAMP); + addPlacement(table.id, { x: 0, y: 0 }); + const child = addPlacement(lamp.id, { x: 5000, y: 5000 })!; + + const ctx = placementSnapContext(lamp.id, { toleranceMm: 50, excludePlacementId: child.id }); + let transform = transformFor(child.id); + + transform = previewPlacementTransform(transform, { x: 5000, y: 5000 }, ctx); + expect(transform.mount.kind).toBe('floor'); + + // Drag it onto the table at the origin. + transform = previewPlacementTransform(transform, { x: 0, y: 0 }, ctx); + expect(transform.mount.kind).toBe('surface'); + + // And off again. + transform = previewPlacementTransform(transform, { x: 5000, y: 5000 }, ctx); + expect(transform.mount.kind).toBe('floor'); + }); + + it('never lets a placement mount onto itself', () => { + // An item is always inside its own outline, so leaving it in the host list would + // surface-mount it to itself the moment it moved. + const table = addCatalogItem(TABLE); + const placement = addPlacement(table.id, { x: 0, y: 0 })!; + + const ctx = placementSnapContext(table.id, { toleranceMm: 50, excludePlacementId: placement.id }); + const transform = previewPlacementTransform(transformFor(placement.id), { x: 0, y: 0 }, ctx); + + expect(transform.mount).toEqual({ kind: 'floor' }); + }); +}); + +describe('rotating', () => { + it('keeps the angle in [0, 360)', () => { + const item = addCatalogItem(TABLE); + const placement = addPlacement(item.id, { x: 0, y: 0 })!; + + rotatePlacementBy(placement.id, -15); + expect(floor().placements[0]!.rotation).toBe(345); + + rotatePlacementBy(placement.id, 30); + expect(floor().placements[0]!.rotation).toBe(15); + }); +}); + +describe('deleting a placement', () => { + it('re-seats its children on the floor rather than leaving a dangling host', () => { + const table = addCatalogItem(TABLE); + const lamp = addCatalogItem(LAMP); + const host = addPlacement(table.id, { x: 0, y: 0 })!; + const child = addPlacement(lamp.id, { x: 0, y: 0 }, { mount: { kind: 'surface', hostId: host.id } })!; + + deleteSelection([{ kind: 'placement', id: host.id }]); + + const remaining = floor().placements.find((p) => p.id === child.id)!; + expect(remaining.mount).toEqual({ kind: 'floor' }); + expect(remaining.elevation).toBe(0); + }); + + it('undoes back to the child still standing on its host', () => { + const table = addCatalogItem(TABLE); + const lamp = addCatalogItem(LAMP); + const host = addPlacement(table.id, { x: 0, y: 0 })!; + const child = addPlacement(lamp.id, { x: 0, y: 0 }, { mount: { kind: 'surface', hostId: host.id } })!; + + deleteSelection([{ kind: 'placement', id: host.id }]); + useStore.getState().undo(); + + const restored = floor().placements.find((p) => p.id === child.id)!; + expect(restored.mount).toEqual({ kind: 'surface', hostId: host.id }); + }); +}); diff --git a/src/state/openings.test.ts b/src/state/openings.test.ts new file mode 100644 index 0000000..d84b9d4 --- /dev/null +++ b/src/state/openings.test.ts @@ -0,0 +1,271 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { activeFloor, useStore } from './store'; +import { + addCatalogItem, + addOpening, + addWallChain, + deleteSelection, + setOpeningSwing, + updateOpening, +} from './actions'; +import { OpeningError } from '../core/openings'; +import { DEFAULT_SWING, leafOf } from '../core/swing'; +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([]); + }); +}); + +describe('hanging a door', () => { + function door() { + wall(); + return addOpening({ x: 2500, y: 20 }, 'door', 100)!; + } + + it('reads as a standard swing before anyone has touched it', () => { + // Nothing is seeded on creation, so a file written before this existed opens + // with its doors hung the ordinary way rather than hinged nowhere. + const opening = door(); + expect(opening.swing).toBeUndefined(); + expect(leafOf(opening)).toEqual({ + style: 'hinged', + pivot: DEFAULT_SWING.hinge, + face: DEFAULT_SWING.into, + angleDeg: DEFAULT_SWING.angleDeg, + }); + }); + + it('writes a whole swing on the first edit, not a fragment', () => { + const opening = door(); + setOpeningSwing(opening.id, { into: 'back' }); + + expect(floor().openings[0]!.swing).toEqual({ hinge: 'a', into: 'back', angleDeg: 90 }); + }); + + it('clamps an angle nobody could hang a door at', () => { + const opening = door(); + setOpeningSwing(opening.id, { angleDeg: 500 }); + expect(floor().openings[0]!.swing!.angleDeg).toBe(180); + }); + + it('costs one undo entry per committed angle, not one per digit', () => { + // The clamp is why the panel holds the text locally: writing per keystroke would + // store `1` as `15` and then type the rest against that, as well as filling the + // undo stack. This is the store half of that contract. + const opening = door(); + const before = useStore.getState().past.length; + + setOpeningSwing(opening.id, { angleDeg: 135 }); + expect(useStore.getState().past).toHaveLength(before + 1); + expect(floor().openings[0]!.swing!.angleDeg).toBe(135); + }); + + it('is one undo step per change', () => { + const opening = door(); + const before = useStore.getState().past.length; + + setOpeningSwing(opening.id, { hinge: 'b' }); + expect(useStore.getState().past).toHaveLength(before + 1); + useStore.getState().undo(); + expect(floor().openings[0]!.swing).toBeUndefined(); + }); + + it('keeps the hinge you chose through a kind change and back', () => { + // A door turned into a cased opening and back is the door you had. `leafOf` + // decides whether the field is read; the document just keeps it. + const opening = door(); + setOpeningSwing(opening.id, { hinge: 'b', into: 'back' }); + + updateOpening(opening.id, { kind: 'cased' }); + expect(leafOf(floor().openings[0]!)).toEqual({ style: 'none' }); + + updateOpening(opening.id, { kind: 'door' }); + expect(leafOf(floor().openings[0]!)).toEqual({ + style: 'hinged', + pivot: 'b', + face: 'back', + angleDeg: 90, + }); + }); + + it('does nothing to an opening that is gone', () => { + const opening = door(); + const before = useStore.getState().past.length; + setOpeningSwing(`${opening.id}-nope`, { hinge: 'b' }); + expect(useStore.getState().past).toHaveLength(before); + }); +}); diff --git a/src/state/save-target.test.ts b/src/state/save-target.test.ts new file mode 100644 index 0000000..b176065 --- /dev/null +++ b/src/state/save-target.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + SaveCancelled, + clearSaveTarget, + currentTarget, + ensureWritable, + pickSaveTarget, + setSaveTarget, + supportsSaveInPlace, + writeToTarget, + type SaveHandle, +} from './save-target'; + +/** + * `window` does not exist in the node test environment, so these cases install one. + * That is only possible because `supportsSaveInPlace` reads the global at call time — + * the property under test as much as anything below it. + */ +function withWindow(props: Record): void { + (globalThis as { window?: unknown }).window = props; +} + +function noWindow(): void { + delete (globalThis as { window?: unknown }).window; +} + +type FakeHandle = SaveHandle & { written: Blob[]; closed: number }; + +function handle(over: Partial = {}): FakeHandle { + const written: Blob[] = []; + const fake = { + name: 'plan.space', + written, + closed: 0, + createWritable: () => + Promise.resolve({ + write: (data: Blob) => { + written.push(data); + return Promise.resolve(); + }, + close: () => { + fake.closed++; + return Promise.resolve(); + }, + }), + ...over, + } as FakeHandle; + return fake; +} + +afterEach(() => { + noWindow(); + clearSaveTarget(); +}); + +describe('whether this browser can save in place', () => { + it('says no when there is no picker', () => { + withWindow({}); + expect(supportsSaveInPlace()).toBe(false); + }); + + it('says yes once one appears', () => { + // Read at call time, not captured at import. A module-level snapshot would decide + // this for the life of the page from whatever was true during the first import, + // and no test — or `addInitScript` stub — could ever reach the other branch. + withWindow({}); + expect(supportsSaveInPlace()).toBe(false); + withWindow({ showSaveFilePicker: () => Promise.resolve(handle()) }); + expect(supportsSaveInPlace()).toBe(true); + }); + + it('says no when there is no window at all', () => { + noWindow(); + expect(supportsSaveInPlace()).toBe(false); + }); +}); + +describe('picking a file', () => { + it('passes the suggested name through', async () => { + let seen: unknown; + withWindow({ + showSaveFilePicker: (options: unknown) => { + seen = options; + return Promise.resolve(handle()); + }, + }); + + await pickSaveTarget('apartment.space'); + expect(seen).toMatchObject({ suggestedName: 'apartment.space' }); + }); + + it('turns a dismissed dialog into SaveCancelled, not a failure', async () => { + // Chromium throws `AbortError` when the picker is dismissed. Reported as an error + // it becomes an alert saying the save failed, on a save the user chose not to + // make — and, worse, it is indistinguishable from a real write failure. + const abort = Object.assign(new Error('The user aborted a request.'), { name: 'AbortError' }); + withWindow({ showSaveFilePicker: () => Promise.reject(abort) }); + + await expect(pickSaveTarget('a.space')).rejects.toBeInstanceOf(SaveCancelled); + }); + + it('lets a real failure through as itself', async () => { + const boom = new Error('disk on fire'); + withWindow({ showSaveFilePicker: () => Promise.reject(boom) }); + + await expect(pickSaveTarget('a.space')).rejects.toBe(boom); + }); +}); + +describe('permission on a retained handle', () => { + it('writes without asking when permission is already granted', async () => { + let asked = 0; + const h = handle({ + queryPermission: () => Promise.resolve('granted'), + requestPermission: () => { + asked++; + return Promise.resolve('granted'); + }, + }); + + expect(await ensureWritable(h)).toBe(true); + expect(asked).toBe(0); + }); + + it('asks when permission has lapsed', async () => { + // A handle carried across a reload, or revoked from the omnibox mid-session. + // Without this the failure surfaces from inside `createWritable`, as a stack + // trace rather than as the prompt the user actually recognises. + const h = handle({ + queryPermission: () => Promise.resolve('prompt'), + requestPermission: () => Promise.resolve('granted'), + }); + expect(await ensureWritable(h)).toBe(true); + }); + + it('reports a refusal rather than trying anyway', async () => { + const h = handle({ + queryPermission: () => Promise.resolve('prompt'), + requestPermission: () => Promise.resolve('denied'), + }); + expect(await ensureWritable(h)).toBe(false); + }); + + it('takes a handle with no permission methods at its word', async () => { + expect(await ensureWritable(handle())).toBe(true); + }); +}); + +describe('writing', () => { + it('writes the bytes and closes the stream', async () => { + const h = handle(); + await writeToTarget(h, new Uint8Array([1, 2, 3])); + + expect(h.written).toHaveLength(1); + expect(h.written[0]?.size).toBe(3); + expect(h.closed).toBe(1); + }); + + it('writes only its own bytes out of a pooled buffer', async () => { + // `Uint8Array#subarray` shares the backing store. Handing the view straight to a + // Blob writes the whole buffer, and a padded zip is a corrupt `.space`. + const pool = new Uint8Array([9, 9, 1, 2, 3, 9, 9]); + const h = handle(); + await writeToTarget(h, pool.subarray(2, 5)); + + expect(h.written[0]?.size).toBe(3); + }); + + it('closes the stream even when the write fails', async () => { + // An unclosed writable leaves a `.crswap` temp file sitting next to the real one. + const h = handle(); + h.createWritable = () => + Promise.resolve({ + write: () => Promise.reject(new Error('quota')), + close: () => { + h.closed++; + return Promise.resolve(); + }, + }); + + await expect(writeToTarget(h, new Uint8Array([1]))).rejects.toThrow('quota'); + expect(h.closed).toBe(1); + }); +}); + +describe('the handle belongs to one document', () => { + it('is held between saves', () => { + const h = handle(); + setSaveTarget(h); + expect(currentTarget()).toBe(h); + }); + + it('is dropped when the document is replaced', () => { + // A handle that outlived its document sends the next Ctrl+S into the previous + // space's file. There is no warning for that and no undo. + setSaveTarget(handle()); + clearSaveTarget(); + expect(currentTarget()).toBeNull(); + }); +}); diff --git a/src/state/save-target.ts b/src/state/save-target.ts new file mode 100644 index 0000000..df2f5ab --- /dev/null +++ b/src/state/save-target.ts @@ -0,0 +1,140 @@ +/** + * Where Ctrl+S writes. See PLAN.md §5. + * + * The File System Access API hands back a *handle* the first time you pick a file, and + * keeping it is the whole feature: the second save writes the same file with no dialog + * and no download shelf. Everything else here exists because that handle is less + * durable than it looks. + * + * ## Read the API at call time + * + * `supportsSaveInPlace()` looks at `window` on every call rather than snapshotting it + * at module load. A snapshot decides for the life of the page based on whatever was + * true during the first import, which makes the branch impossible to exercise from a + * test — and this is a branch where the two sides behave visibly differently. + * + * ## A handle is session state, not document state + * + * It does not serialize, it does not survive a reload, and it belongs to one document. + * So it lives here beside the asset store — the other half of the open document that + * is deliberately not in the document — and `loadDocument`/`newDocument` clear it. A + * handle that outlived its document would send the next Ctrl+S into the previous + * space's file, which is a data loss with no warning and no undo. + * + * After a reload or a crash recovery there is no handle, so the next save asks where + * to put it. That is correct, and it is the reason recovery does not pretend to + * restore "the file you were working on". + */ + +/** + * Structural, rather than the DOM lib's `FileSystemFileHandle`. + * + * `queryPermission`/`requestPermission` are a Chromium extension to the spec and are + * not in every `lib.dom`, so a nominal type would either fail to compile or need a + * cast at each use. Structural also means a test fake is just an object literal. + */ +export type SaveHandle = { + readonly name: string; + createWritable: () => Promise; + queryPermission?: (descriptor: { mode: 'readwrite' }) => Promise; + requestPermission?: (descriptor: { mode: 'readwrite' }) => Promise; +}; + +export type WritableTarget = { + write: (data: Blob) => Promise; + close: () => Promise; +}; + +type PickerOptions = { + suggestedName?: string; + types?: { description: string; accept: Record }[]; +}; + +type Picker = (options: PickerOptions) => Promise; + +/** Raised when the user dismisses the file picker. Not an error to report. */ +export class SaveCancelled extends Error { + constructor() { + super('Save cancelled.'); + this.name = 'SaveCancelled'; + } +} + +function picker(): Picker | null { + if (typeof window === 'undefined') return null; + const fn = (window as unknown as { showSaveFilePicker?: Picker }).showSaveFilePicker; + return typeof fn === 'function' ? (fn.bind(window) as Picker) : null; +} + +export function supportsSaveInPlace(): boolean { + return picker() !== null; +} + +let target: SaveHandle | null = null; + +export function currentTarget(): SaveHandle | null { + return target; +} + +export function setSaveTarget(handle: SaveHandle | null): void { + target = handle; +} + +/** Called whenever the open document is replaced. See the note above. */ +export function clearSaveTarget(): void { + target = null; +} + +/** + * Ask where to save. + * + * A dismissed picker throws `AbortError`, which is a decision, not a failure — it is + * translated to `SaveCancelled` so the caller can tell "the user changed their mind" + * apart from "the write failed", and show a dialog for exactly one of them. + */ +export async function pickSaveTarget(suggestedName: string): Promise { + const show = picker(); + if (!show) throw new Error('This browser cannot save in place.'); + + try { + return await show({ + suggestedName, + types: [{ description: 'floorplan space', accept: { 'application/zip': ['.space'] } }], + }); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') throw new SaveCancelled(); + throw err; + } +} + +/** + * Whether this handle may still be written to. + * + * A permission granted in one session is not carried into the next, and can be revoked + * mid-session from the omnibox. Asking first turns a silent failure deep inside + * `createWritable` into a prompt the user recognises. A handle that predates the + * Chromium permission extension has neither method, and is taken at its word. + */ +export async function ensureWritable(handle: SaveHandle): Promise { + if (!handle.queryPermission || !handle.requestPermission) return true; + + const descriptor = { mode: 'readwrite' } as const; + if ((await handle.queryPermission(descriptor)) === 'granted') return true; + return (await handle.requestPermission(descriptor)) === 'granted'; +} + +/** + * Write bytes through a handle. + * + * `bytes.slice()` because `bytes.buffer` may be a pooled ArrayBuffer larger than the + * data — the same reason the download path slices. A padded `.space` is a corrupt zip. + */ +export async function writeToTarget(handle: SaveHandle, bytes: Uint8Array): Promise { + const writable = await handle.createWritable(); + try { + await writable.write(new Blob([bytes.slice()], { type: 'application/zip' })); + } finally { + // Always close: an open writable holds a `.crswap` temp file next to the real one. + await writable.close(); + } +} diff --git a/src/state/space.test.ts b/src/state/space.test.ts new file mode 100644 index 0000000..384456d --- /dev/null +++ b/src/state/space.test.ts @@ -0,0 +1,328 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { activeFloor, useStore } from './store'; +import { + DEFAULT_WALL_MOUNT_MM, + addCatalogItem, + addPlacement, + addRoomRect, + addSavedView, + commitPlacementTransform, + placementSnapContext, + previewPlacementTransform, + removeSavedView, + setCeilingDrop, + setPlacementMount, +} from './actions'; +import type { ItemDraft } from '../core/catalog'; +import { resolveElevation } from '../core/placement'; +import { spaceViews, type SpaceCamera } from '../core/views'; +import type { PlacementTransform } from './store'; +import type { Placement } from '../core/document'; +import type { Vec2 } from '../core/geometry/vec'; + +const SHELF: ItemDraft = { + name: 'Wall shelf', + category: 'storage', + shape: 'rect', + widthMm: 900, + depthMm: 250, + heightMm: 300, + voidBelowMm: 0, + defaultMount: 'wall', +}; + +const PENDANT: ItemDraft = { + name: 'Pendant', + category: 'lighting', + shape: 'circle', + widthMm: 400, + depthMm: 400, + heightMm: 500, + voidBelowMm: 0, + defaultMount: 'ceiling', +}; + +const CHAIR: ItemDraft = { + name: 'Chair', + category: 'seating', + shape: 'rect', + widthMm: 460, + depthMm: 510, + heightMm: 900, + voidBelowMm: 0, +}; + +const CAMERA: SpaceCamera = { + position: { x: 1200, y: 300, z: 1650 }, + target: { x: 2500, y: 2500, z: 1200 }, + mode: 'walk', +}; + +function floor() { + return activeFloor(useStore.getState()); +} + +/** A 5m × 4m room, whose north wall runs along y = 0. */ +function room() { + addRoomRect({ x: 0, y: 0 }, { x: 5000, y: 4000 }); +} + +beforeEach(() => { + useStore.getState().newDocument(); +}); + +describe('dropping a wall-mounted item', () => { + it('hangs it on the wall it was dropped against', () => { + room(); + const item = addCatalogItem(SHELF); + const placement = addPlacement(item.id, { x: 2500, y: 100 })!; + + expect(placement.mount).toEqual({ kind: 'wall', wallId: floor().walls[0]!.id }); + expect(placement.elevation).toBe(DEFAULT_WALL_MOUNT_MM); + expect(useStore.getState().notice).toBeNull(); + }); + + it('lands it on the floor and says why when there is no wall in reach', () => { + // Never a wallId it guessed: an item attached to a wall the user did not choose + // moves when that wall does, which is worse than one sitting on the floor. + room(); + const item = addCatalogItem(SHELF); + const placement = addPlacement(item.id, { x: 2500, y: 2000 })!; + + expect(placement.mount).toEqual({ kind: 'floor' }); + expect(useStore.getState().notice).toContain('no wall here'); + }); + + it('clears the notice on the next placement that goes to plan', () => { + room(); + const shelf = addCatalogItem(SHELF); + addPlacement(shelf.id, { x: 2500, y: 2000 }); + expect(useStore.getState().notice).not.toBeNull(); + + const chair = addCatalogItem(CHAIR); + addPlacement(chair.id, { x: 2500, y: 2000 }); + expect(useStore.getState().notice).toBeNull(); + }); + + it('hangs a ceiling item from the ceiling wherever it is dropped', () => { + room(); + const item = addCatalogItem(PENDANT); + const placement = addPlacement(item.id, { x: 2500, y: 2000 })!; + + expect(placement.mount).toEqual({ kind: 'ceiling', drop: 0 }); + // Flush to a 2438 ceiling, so a 500 tall pendant hangs with its base at 1938. + expect(resolveElevation(useStore.getState().doc, placement)).toBe(1938); + }); +}); + +describe('changing a mount', () => { + it('moves an item onto the nearest wall', () => { + room(); + const item = addCatalogItem(CHAIR); + const placement = addPlacement(item.id, { x: 2500, y: 200 })!; + + expect(setPlacementMount(placement.id, 'wall')).toBeNull(); + expect(floor().placements[0]!.mount.kind).toBe('wall'); + }); + + it('refuses, with a reason, when nothing is near enough', () => { + room(); + const item = addCatalogItem(CHAIR); + const placement = addPlacement(item.id, { x: 2500, y: 2000 })!; + + expect(setPlacementMount(placement.id, 'wall')).toContain('no wall near enough'); + expect(floor().placements[0]!.mount).toEqual({ kind: 'floor' }); + }); + + it('sends you to the drag gesture for a surface mount', () => { + // A surface mount names a specific host, and there is nothing sensible to pick + // from a list of words. + room(); + const item = addCatalogItem(CHAIR); + const placement = addPlacement(item.id, { x: 2500, y: 2000 })!; + + expect(setPlacementMount(placement.id, 'surface')).toContain('Drag this onto'); + }); + + it('is one undo step', () => { + room(); + const item = addCatalogItem(CHAIR); + const placement = addPlacement(item.id, { x: 2500, y: 200 })!; + const before = useStore.getState().past.length; + + setPlacementMount(placement.id, 'wall'); + expect(useStore.getState().past).toHaveLength(before + 1); + useStore.getState().undo(); + expect(floor().placements[0]!.mount).toEqual({ kind: 'floor' }); + }); +}); + +describe('the ceiling drop', () => { + it('lowers the item by the amount it is dropped', () => { + room(); + const item = addCatalogItem(PENDANT); + const placement = addPlacement(item.id, { x: 2500, y: 2000 })!; + + setCeilingDrop(placement.id, 600); + const updated = floor().placements[0]!; + expect(updated.mount).toEqual({ kind: 'ceiling', drop: 600 }); + expect(resolveElevation(useStore.getState().doc, updated)).toBe(1338); + }); + + it('is ignored on something that is not hanging', () => { + room(); + const item = addCatalogItem(CHAIR); + const placement = addPlacement(item.id, { x: 2500, y: 2000 })!; + + setCeilingDrop(placement.id, 600); + expect(floor().placements[0]!.mount).toEqual({ kind: 'floor' }); + }); +}); + +describe('saved views', () => { + it('records a bookmark in the document, so it travels with the file', () => { + addSavedView('Doorway', CAMERA); + const views = spaceViews(useStore.getState().doc.savedViews); + + expect(views).toHaveLength(1); + expect(views[0]!.name).toBe('Doorway'); + expect(views[0]!.camera.x).toBe(1200); + }); + + it('suffixes a duplicate name rather than refusing', () => { + addSavedView('Doorway', CAMERA); + addSavedView('Doorway', CAMERA); + + expect(useStore.getState().doc.savedViews.map((v) => v.name)).toEqual([ + 'Doorway', + 'Doorway 2', + ]); + }); + + it('names an unnamed bookmark rather than saving an empty label', () => { + addSavedView(' ', CAMERA); + expect(useStore.getState().doc.savedViews[0]!.name).toBe('View'); + }); + + it('removes one', () => { + addSavedView('Doorway', CAMERA); + const id = useStore.getState().doc.savedViews[0]!.id; + removeSavedView(id); + + expect(useStore.getState().doc.savedViews).toHaveLength(0); + }); + + it('using a view moves the walker but records no history', () => { + // A bookmark records where you looked from; using one is not an edit to the space. + addSavedView('Doorway', CAMERA); + const view = useStore.getState().doc.savedViews[0]!; + const before = useStore.getState().past.length; + + useStore.getState().applySavedView(view); + + expect(useStore.getState().past).toHaveLength(before); + expect(useStore.getState().cameraMode).toBe('walk'); + expect(useStore.getState().walker?.position).toEqual({ x: 1200, y: 300 }); + }); + + it('faces the walker back at what the view was looking at', () => { + // The camera looked from (1200,300) toward (2500,2500) — south-east, so a + // bearing between 90 and 180. + addSavedView('Doorway', CAMERA); + useStore.getState().applySavedView(useStore.getState().doc.savedViews[0]!); + + const heading = useStore.getState().walker!.heading; + expect(heading).toBeGreaterThan(90); + expect(heading).toBeLessThan(180); + }); + + it('leaves the walker alone for an orbit bookmark', () => { + addSavedView('Above', { ...CAMERA, mode: 'orbit' }); + useStore.getState().applySavedView(useStore.getState().doc.savedViews[0]!); + + expect(useStore.getState().walker).toBeNull(); + expect(useStore.getState().pendingCamera?.mode).toBe('orbit'); + }); +}); + +describe('dragging something that is mounted', () => { + /** Drag a placement to a new point, the way the stage does: preview, then commit. */ + function dragTo(placement: Placement, to: Vec2) { + const ctx = placementSnapContext(placement.itemId, { + toleranceMm: 100, + excludePlacementId: placement.id, + }); + const start: PlacementTransform = { + placementId: placement.id, + mode: 'move', + grab: placement.position, + origin: { + position: placement.position, + rotation: placement.rotation, + mount: placement.mount, + }, + position: placement.position, + rotation: placement.rotation, + mount: placement.mount, + hints: [], + }; + commitPlacementTransform(previewPlacementTransform(start, to, ctx)); + } + + it('keeps a wall-mounted item on its wall, and at its height', () => { + // The snap reports a *floor* mount for anything that is not a surface-host match + // — a wall snap seats the footprint against the wall but never claims a wall + // mount. Letting that through drops the TV to the ground on a 5mm nudge. + room(); + const item = addCatalogItem(SHELF); + const placement = addPlacement(item.id, { x: 2500, y: 100 })!; + expect(placement.mount.kind).toBe('wall'); + + dragTo(placement, { x: 2700, y: 100 }); + + const moved = floor().placements[0]!; + expect(moved.mount).toEqual(placement.mount); + expect(moved.elevation).toBe(DEFAULT_WALL_MOUNT_MM); + expect(moved.position.x).not.toBe(placement.position.x); + }); + + it('keeps a hanging item hanging', () => { + room(); + const item = addCatalogItem(PENDANT); + const placement = addPlacement(item.id, { x: 2500, y: 2000 })!; + + dragTo(placement, { x: 1500, y: 2000 }); + expect(floor().placements[0]!.mount).toEqual({ kind: 'ceiling', drop: 0 }); + }); + + it('still drops a surface-mounted item to the floor when dragged off its host', () => { + // The other half of the rule: a surface mount that finds no host really has been + // taken off the thing it was standing on. + room(); + const table = addCatalogItem({ + name: 'Table', + category: 'table', + shape: 'rect', + widthMm: 1800, + depthMm: 900, + heightMm: 760, + voidBelowMm: 720, + }); + const lamp = addCatalogItem({ + name: 'Lamp', + category: 'lighting', + shape: 'circle', + widthMm: 300, + depthMm: 300, + heightMm: 500, + voidBelowMm: 0, + }); + const host = addPlacement(table.id, { x: 2500, y: 2000 })!; + const child = addPlacement(lamp.id, { x: 2500, y: 2000 }, { + mount: { kind: 'surface', hostId: host.id }, + })!; + + dragTo(child, { x: 800, y: 3500 }); + expect(floor().placements.find((p) => p.id === child.id)!.mount).toEqual({ kind: 'floor' }); + }); +}); diff --git a/src/state/store.test.ts b/src/state/store.test.ts index 8cd29a4..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, @@ -352,3 +353,45 @@ 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(); + }); + + 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(); + }); +}); diff --git a/src/state/store.ts b/src/state/store.ts index 5af0571..99c3929 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -37,16 +37,58 @@ 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'; +import type { Mount, OpeningKind, SavedView } from '../core/document'; +import type { CameraMode, Walker } from '../core/walk'; +import { decodeCamera, type SpaceCamera } from '../core/views'; +import { toDegrees } from '../core/geometry/vec'; +import type { PlacementSnapHint } from '../core/placement-snap'; +import type { AssetMap } from '../core/space-file'; +import type { Inspection } from '../core/media'; +import { adoptAssets, clearAssets } from './assets'; +import { clearSaveTarget } from './save-target'; // immer 10 gates patch recording behind this plugin. Without it `produceWithPatches` // 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 = { + /** + * Fold this change into the previous entry when that entry has the same label. + * + * For continuous controls — an opacity slider fires `change` on every pixel of the + * drag — where the alternative is a hundred history entries for one edit, and a + * 200-deep stack erased by moving a slider once. + * + * Only safe for recipes that write an **absolute** value at a fixed path, which is + * what makes replaying the newest patches over the oldest inverse correct. Do not + * 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 = { label: string; patches: Patch[]; @@ -58,6 +100,45 @@ export const HISTORY_LIMIT = 200; export type Measurement = { from: { x: number; y: number }; to: { x: number; y: number } }; +/** + * The reference line drawn during the calibration gate, in **document mm**. + * + * Document rather than image pixels because that is what the stage produces and what + * the draft layer draws; it is converted to image pixels once, at commit, through + * the background's current (provisional) transform. Keeping it here rather than in + * the document means an abandoned calibration leaves no undo entry behind. + */ +export type CalibrationRef = { a: Vec2; b: Vec2 }; + +/** + * A placement being dragged or turned, held as preview state in the editor slice. + * + * Exactly the same shape of solution as `WallTransform`, and for the same reason: the + * document keeps the original until the pointer is released, so a drag across the + * whole plan is one undo step rather than four hundred. `origin` makes a move + * absolute — deriving each frame from the last accumulates the error the snap keeps + * correcting. + */ +export type PlacementTransform = { + placementId: Id; + mode: 'move' | 'rotate'; + /** Where the drag started, in document mm. */ + grab: Vec2; + /** + * The placement as it was when the drag began. + * + * `mount` is part of it because the snap reports a *floor* mount for anything that + * is not a surface-host match — a wall snap seats the footprint against the wall + * but never claims a wall mount. Without the original to fall back on, nudging a + * wall-mounted TV along its own wall would drop it to the floor. + */ + origin: { position: Vec2; rotation: number; mount: Mount }; + position: Vec2; + rotation: number; + mount: Mount; + hints: PlacementSnapHint[]; +}; + /** * A wall being dragged, held as preview geometry in the editor slice. * @@ -76,6 +157,19 @@ export type WallTransform = { b: { x: number; y: number }; }; +/** Extra instruction for `loadDocument`. */ +export type LoadOptions = { + /** + * Whether the loaded document counts as having unsaved changes. + * + * False for a file that was just opened — it is on disk exactly as it is in memory. + * True for a recovered autosave, which by definition was never written anywhere the + * user can find it; marking it clean would let them close the tab a second time on + * the same unsaved work. + */ + dirty?: boolean; +}; + export type StoreState = { // -- document slice ------------------------------------------------------ doc: SpaceDocument; @@ -84,10 +178,10 @@ export type StoreState = { /** True once the document has changed since it was created, loaded or saved. */ dirty: boolean; - mutate: (label: string, recipe: (draft: SpaceDocument) => void) => void; + mutate: (label: string, recipe: (draft: SpaceDocument) => void, options?: MutateOptions) => void; undo: () => void; redo: () => void; - loadDocument: (doc: SpaceDocument) => void; + loadDocument: (doc: SpaceDocument, assets?: AssetMap, options?: LoadOptions) => void; newDocument: () => void; markSaved: () => void; @@ -96,10 +190,19 @@ 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[]; draft: Draft | null; + /** + * A multi-page PDF waiting for its page to be chosen. + * + * Editor state: it is a half-finished gesture, like a draft wall chain, and it + * carries the file's bytes — which have no business on the undo stack. + */ + pendingImport: Inspection | null; /** Snapped cursor position in document mm, or null when the pointer is outside. */ cursor: { x: number; y: number } | null; snapHints: SnapHint[]; @@ -110,13 +213,71 @@ 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; + /** + * True while the calibration gate is open (PLAN.md §6.1). + * + * Blocking: the tools and the mode switches are unavailable until the background + * has a scale, because tracing an uncalibrated plan produces walls whose lengths + * mean nothing and which nothing later can correct. + */ + calibrating: boolean; + /** The reference line being drawn, or the finished one awaiting its real length. */ + calibrationRef: CalibrationRef | null; + /** The placement drag in flight, if any. */ + placementTransform: PlacementTransform | null; + + // -- space view (PLAN.md §10) -------------------------------------------- + cameraMode: CameraMode; + /** + * The walker, or null before they have been put anywhere. + * + * Null rather than an origin default: a plan traced from an imported raster can sit + * anywhere, so a walker at 0,0 would routinely start outside the building. The view + * seeds this from `defaultStandpoint` the first time it is needed. + * + * Editor state, not document state. Where you are standing is not something undo + * should take away, and a walk across a room would otherwise be several hundred + * history entries. + */ + 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. + * + * Orbit controls own their own camera state internally, so "go here" cannot be + * expressed by setting a value and leaving it — the next drag would fight it. A + * one-shot instruction the renderer picks up and clears says exactly what is meant. + */ + pendingCamera: SpaceCamera | null; + /** A transient one-line message, shown until the next action replaces it. */ + notice: string | null; + /** + * The catalog item armed for placing — the next click on the plan drops one. + * + * Held rather than entered as a tool because the gesture starts in the inventory + * list: you pick the thing you want, then you point at where it goes. + */ + placingItemId: Id | null; setEditMode: (mode: EditMode) => void; 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; @@ -127,7 +288,23 @@ 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; + setCameraMode: (mode: CameraMode) => void; + setWalker: (walker: Walker | null) => void; + setShowCeilings: (on: boolean) => void; + setFloorVisibility: (visibility: FloorVisibility) => void; + setActiveFloor: (floorId: Id) => void; + clearFloorScopedState: () => void; + setPendingCamera: (camera: SpaceCamera | null) => void; + setNotice: (notice: string | null) => void; + setPendingImport: (inspection: Inspection | null) => void; + applySavedView: (view: SavedView) => void; + beginCalibration: () => void; + setCalibrationRef: (ref: CalibrationRef | null) => void; + endCalibration: () => void; zoomToFit: () => void; }; @@ -149,6 +326,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}`)); @@ -183,7 +361,7 @@ export const useStore = create((set, get) => ({ future: [], dirty: false, - mutate: (label, recipe) => { + mutate: (label, recipe, options) => { const state = get(); const [next, patches, inverse] = produceWithPatches(state.doc, (draft) => { recipe(draft); @@ -194,7 +372,21 @@ 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; - const past = [...state.past, { label, patches: [...patches], inverse: [...inverse] }]; + 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. + const past = + options?.coalesce && previous?.label === label + ? [ + ...state.past.slice(0, -1), + { label, patches: [...patches], inverse: previous.inverse }, + ] + : [...state.past, { label, patches: [...patches], inverse: [...inverse] }]; set({ doc: next, past: past.length > HISTORY_LIMIT ? past.slice(past.length - HISTORY_LIMIT) : past, @@ -218,6 +410,11 @@ export const useStore = create((set, get) => ({ selection: pruneSelection(next, selection), draft: null, transform: null, + placementTransform: null, + // The notice describes what the last action did; undoing it leaves a sentence + // about something that no longer happened. + notice: null, + pendingImport: null, }); }, @@ -235,24 +432,48 @@ export const useStore = create((set, get) => ({ selection: pruneSelection(next, selection), draft: null, transform: null, + notice: null, + pendingImport: null, }); }, - loadDocument: (doc) => + // The asset store and the save target are the other halves of the open document + // (see `state/assets.ts`, `state/save-target.ts`), so replacing one replaces all + // three. Stale bytes would mean the next save wrote a file carrying the previous + // document's background; a stale handle would write it into the previous + // document's *file*, which has no warning and no undo. + loadDocument: (doc, assets, options) => { + if (assets) adoptAssets(doc, assets); + else clearAssets(); + clearSaveTarget(); set({ doc, past: [], future: [], - dirty: false, + // A recovered autosave has never been written to a file, so it arrives dirty. + // Loading a `.space` does not: that file is on disk and matches what is open. + dirty: options?.dirty ?? false, selection: [], draft: null, transform: null, measurement: null, + walkway: null, cursor: null, snapHints: [], - }), + calibrating: false, + calibrationRef: null, + placementTransform: null, + placingItemId: null, + walker: null, + pendingCamera: null, + notice: null, + pendingImport: null, + }); + }, - newDocument: () => + newDocument: () => { + clearAssets(); + clearSaveTarget(); set({ doc: freshDocument(), past: [], @@ -262,10 +483,20 @@ export const useStore = create((set, get) => ({ draft: null, transform: null, measurement: null, + walkway: null, cursor: null, snapHints: [], + calibrating: false, + calibrationRef: null, + placementTransform: null, + placingItemId: null, viewport: DEFAULT_VIEWPORT, - }), + walker: null, + pendingCamera: null, + notice: null, + pendingImport: null, + }); + }, markSaved: () => set({ dirty: false }), @@ -274,6 +505,7 @@ export const useStore = create((set, get) => ({ viewMode: 'plan2d', tool: 'select', shapeKind: 'rect', + openingKind: 'door', viewport: DEFAULT_VIEWPORT, stageSize: { width: 800, height: 600 }, selection: [], @@ -285,7 +517,19 @@ export const useStore = create((set, get) => ({ snapSuppressed: false, wallDefaults: DEFAULT_WALL_DEFAULTS, measurement: null, + walkway: null, transform: null, + calibrating: false, + calibrationRef: null, + placementTransform: null, + placingItemId: null, + cameraMode: 'orbit', + walker: null, + showCeilings: false, + floorVisibility: 'active', + pendingCamera: null, + notice: null, + pendingImport: null, setEditMode: (editMode) => // Structure tools have no meaning in furnish mode, and a half-drawn wall would @@ -294,13 +538,22 @@ export const useStore = create((set, get) => ({ editMode, draft: null, transform: null, + placementTransform: null, + placingItemId: null, selection: [], + notice: null, + pendingImport: null, tool: editMode === 'plan' ? get().tool : 'select', }), - setViewMode: (viewMode) => set({ viewMode }), - setTool: (tool) => set({ tool, draft: null, transform: null, measurement: null }), + 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 }), setViewport: (viewport) => set({ viewport }), setStageSize: (stageSize) => set({ stageSize }), setSelection: (selection) => set({ selection }), @@ -321,7 +574,121 @@ 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 + // both drop a new item and grab an existing one. + setPlacingItem: (placingItemId) => set({ placingItemId, placementTransform: null }), + + 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. A *bare* switch is not an + * edit; a switch that rides along with one — adding a floor, deleting the one you + * are standing on — is written inside that edit's own recipe instead, so undoing it + * puts you back where you were rather than leaving `activeFloorId` naming a floor + * the inverse patch has just removed. + */ + 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 }, + ); + get().clearFloorScopedState(); + }, + + /** + * Drop everything in the editor that names something on a particular floor. + * + * 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; nothing prunes the route at all. + * + * Separate from `setActiveFloor` because the actions that change floors as part of a + * document edit write `activeFloorId` inside their own recipe, which makes + * `setActiveFloor` a no-op by the time they could call it. Deleting a floor is every + * one of those, so folding this into the switch left the route from the deleted + * floor alive and re-answering against geometry that had nothing to do with it. + */ + clearFloorScopedState: () => + 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 }), + setPendingImport: (pendingImport) => set({ pendingImport }), + + /** + * Jump to a bookmarked view. + * + * Editor state only — a bookmark records where you looked from, and using one is + * not an edit to the space. Adding and removing bookmarks *is* a document change, + * and lives in `actions.ts` with everything else that touches the document. + * + * In walk and fly the walker is moved directly, so the pose survives the next frame + * of input; in orbit the pose goes to `pendingCamera` for the controls to adopt. + */ + applySavedView: (view) => { + const camera = decodeCamera(view.camera); + const dx = camera.target.x - camera.position.x; + const dy = camera.target.y - camera.position.y; + const dz = camera.target.z - camera.position.z; + const horizontal = Math.hypot(dx, dy); + + set({ + cameraMode: camera.mode, + pendingCamera: camera, + ...(camera.mode === 'orbit' + ? {} + : { + walker: { + position: { x: camera.position.x, y: camera.position.y }, + // The inverse of `forwardVector`: a bearing, not a maths angle. + heading: horizontal === 0 ? 0 : toDegrees(Math.atan2(dx, -dy)), + pitch: horizontal === 0 ? 0 : toDegrees(Math.atan2(dz, horizontal)), + elevation: 0, + crouching: false, + }, + }), + }); + }, + + // Opening the gate cancels whatever was in flight: a half-drawn wall committed + // against an uncalibrated plan is exactly the geometry the gate exists to stop. + beginCalibration: () => + set({ + calibrating: true, + calibrationRef: null, + draft: null, + transform: null, + placementTransform: null, + placingItemId: null, + selection: [], + }), + setCalibrationRef: (calibrationRef) => set({ calibrationRef }), + endCalibration: () => set({ calibrating: false, calibrationRef: null }), zoomToFit: () => { const state = get(); diff --git a/src/styles/global.css b/src/styles/global.css index 5569ecd..beeff7c 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -193,7 +193,6 @@ body { .viewport[data-view='space3d'] { grid-template-rows: 1fr; - place-items: center; } .viewport__placeholder { @@ -360,3 +359,492 @@ body { white-space: nowrap; border: 0; } + +/* ---------- calibration gate (PLAN.md 6.1) ---------- */ + +/* + * A band across the top of the viewport rather than a modal over the canvas: the + * gesture the gate asks for happens *on* the canvas, so covering it would be + * self-defeating. Blocking is enforced by disabling the tools, not by a scrim. + */ +.gate { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 16px; + padding: 10px 14px; + background: var(--surface); + border-bottom: 2px solid var(--accent); +} + +.gate__head { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 240px; + flex: 1 1 320px; +} + +.gate__title { + margin: 0; + font-size: 13px; + font-weight: 650; + letter-spacing: 0.01em; +} + +.gate__sub { + margin: 0; + font-size: 12px; + color: var(--text-dim); +} + +.gate__form { + display: flex; + align-items: center; + gap: 8px; +} + +.gate__field { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-dim); +} + +.gate__field input { + font: inherit; + width: 96px; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--surface-2); + color: var(--text); +} + +.gate__ref { + font-size: 12px; + color: var(--accent); +} + +.gate__prompt { + margin: 0; + font-size: 12px; + color: var(--text-dim); +} + +/* + * Always in the layout, empty or not. The gate sits directly above the canvas the + * user is dragging on, so anything that changes its height mid-gesture moves the + * plan under their pointer. + */ +.gate__error { + margin: 0; + flex-basis: 100%; + min-height: 16px; + font-size: 12px; + color: #c0392b; +} + +.gate__error[data-empty='true'] { + visibility: hidden; +} + +.gate__ref { + min-width: 132px; + white-space: nowrap; +} + +.gate__field input:disabled, +.gate .btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.gate__foot { + display: flex; + align-items: center; + gap: 12px; + margin-left: auto; +} + +.gate__scale { + font-size: 11px; + color: var(--text-dim); +} + +.gate .btn { + margin-top: 0; +} + +.btn--primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-text); +} + +/* ---------- PDF page picker ---------- */ + +.pagepick { + position: absolute; + z-index: 20; + top: calc(var(--topbar-h) + 8px); + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 28px rgb(0 0 0 / 22%); +} + +.pagepick__label { + font-size: 12px; +} + +.pagepick input { + font: inherit; + width: 68px; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--surface-2); + color: var(--text); +} + +.pagepick .btn { + margin-top: 0; +} + +/* ---------- panel additions ---------- */ + +.panel__warn { + margin: 8px 0 0; + padding: 8px 10px; + font-size: 12px; + line-height: 1.45; + color: var(--text); + background: color-mix(in srgb, #c0392b 12%, transparent); + border-left: 2px solid #c0392b; + border-radius: 0 4px 4px 0; +} + +.panel__row { + display: flex; + gap: 8px; +} + +.panel__row .btn { + flex: 1 1 0; + margin-top: 10px; + padding: 6px 8px; + font-size: 12px; +} + +/* ---------- inventory (PLAN.md 7) ---------- */ + +.items { + list-style: none; + margin: 8px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.item { + padding: 7px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-2); +} + +/* The armed item is the answer to "what am I about to place?", so it has to be + visible from the canvas, where the user is actually looking. */ +.item[data-armed='true'] { + border-color: var(--accent); + box-shadow: inset 2px 0 0 var(--accent); +} + +.item__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.item__name { + font-weight: 600; +} + +.item__count { + font-variant-numeric: tabular-nums; + font-size: 12px; + color: var(--text-dim); +} + +.item__meta { + margin-top: 2px; + font-size: 11px; + color: var(--text-dim); +} + +.item__actions { + display: flex; + align-items: center; + gap: 4px; + margin-top: 6px; +} + +.item__qty { + font: inherit; + width: 52px; + margin-left: auto; + padding: 3px 5px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + color: var(--text); +} + +.itemform { + margin-top: 10px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-2); +} + +.field__hint { + font-size: 11px; + color: var(--text-dim); + min-width: 56px; + text-align: right; +} + +.panel__note { + margin: 8px 0 0; + padding: 7px 9px; + font-size: 12px; + line-height: 1.45; + border-left: 2px solid var(--accent); + border-radius: 0 4px 4px 0; + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +.panel__row--rotate { + align-items: center; +} + +.panel__row--rotate .field__value { + flex: 1 1 0; + text-align: center; + font-variant-numeric: tabular-nums; +} + +/* ---------- 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; +} + +.seg--select { + min-width: 0; + max-width: 140px; +} + +.panel__count { + font-size: 12px; + color: var(--text-dim); + margin: 0; +} + +.issues { + list-style: none; + margin: 8px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.issue__button { + appearance: none; + display: block; + width: 100%; + text-align: left; + font: inherit; + font-size: 12px; + line-height: 1.45; + padding: 7px 9px; + border: 0; + border-left: 2px solid var(--text-dim); + border-radius: 0 4px 4px 0; + background: var(--surface-2); + color: var(--text); + cursor: pointer; +} + +.issue__button:disabled { + cursor: default; +} + +.issue[data-severity='warning'] .issue__button { + border-left-color: #d08a2c; +} + +.issue[data-severity='blocking'] .issue__button { + border-left-color: #c0392b; +} + +/* ---------- space view ---------- */ + +.space { + display: grid; + grid-template-rows: 1fr auto; + min-height: 0; + min-width: 0; +} + +/* The canvas fills its cell and is allowed to shrink; without `min-height: 0` a + grid item refuses to go below its content size and the canvas grows forever + on every resize. */ +.space__canvas { + position: relative; + min-height: 0; + min-width: 0; + overflow: hidden; + background: var(--surface-2); + touch-action: none; +} + +.space__canvas canvas { + display: block; + outline: none; +} + +.space[data-mode='walk'] .space__canvas, +.space[data-mode='fly'] .space__canvas { + cursor: grab; +} + +.hud { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 12px; + border-top: 1px solid var(--border); + background: var(--surface); + font-size: 12px; +} + +.hud__row { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.hud__row--readout { + color: var(--text-dim); + gap: 14px; + font-variant-numeric: tabular-nums; +} + +.hud__row--views input { + font: inherit; + padding: 3px 6px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface-2); + color: var(--text); + min-width: 120px; +} + +.hud__view { + display: inline-flex; + gap: 2px; +} + +.hud__help { + margin: 0; + color: var(--text-dim); +} + +/* ---------- recovery banner ---------- */ + +.recovery { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + background: var(--surface-2); + border-bottom: 1px solid var(--border); + flex: 0 0 auto; +} + +.recovery__text { + flex: 1; + color: var(--text); +} + +/* ---------- drag-and-drop overlay ---------- */ + +.dropzone { + position: fixed; + inset: 0; + z-index: 40; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--bg) 70%, transparent); + /* Feedback, never a hit area — a drop landing on the overlay instead of the + window is the one way this feature breaks itself. */ + pointer-events: none; +} + +.dropzone__label { + padding: 14px 22px; + border: 2px dashed var(--accent); + border-radius: 8px; + 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/CalibrationGate.tsx b/src/ui/CalibrationGate.tsx new file mode 100644 index 0000000..79aadd1 --- /dev/null +++ b/src/ui/CalibrationGate.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { CalibrationError, effectiveMmPerPx, isCalibrated } from '../core/calibration'; +import { distance } from '../core/geometry/vec'; +import { formatLength, parseLength } from '../core/units'; +import { commitCalibration, removeBackground } from '../state/actions'; +import { activeFloor, useStore } from '../state/store'; + +/** + * The calibration gate. PLAN.md calls this "the single most important step in the + * application", and it is the reason this is a gate rather than a settings field. + * + * A floor plan raster carries no scale. Trace one without calibrating and every wall + * comes out a plausible-looking wrong length — the failure mode that survives all the + * way to somebody ordering a sofa that does not fit. So until a scale exists the + * tools are unavailable, and there are exactly two ways out: give it one, or discard + * the plan. + * + * It is *not* a modal over the canvas, because the gesture it asks for happens on the + * canvas. The stage stays live and takes only the reference drag; everything else is + * disabled. Re-openable later from the properties panel, because people do get the + * first attempt wrong. + */ +export function CalibrationGate() { + const { calibrating, calibrationRef, doc } = useStore( + useShallow((s) => ({ + calibrating: s.calibrating, + calibrationRef: s.calibrationRef, + doc: s.doc, + })), + ); + + const [text, setText] = useState(''); + const [error, setError] = useState(null); + + const floor = activeFloor({ doc }); + const background = floor.background; + const first = !isCalibrated(background); + + // A fresh line is a fresh attempt: clearing the entry stops a rejected length from + // sitting in the box looking like it was accepted. + useEffect(() => { + setError(null); + }, [calibrationRef]); + + if (!calibrating || !background) return null; + + const lengthMm = calibrationRef ? distance(calibrationRef.a, calibrationRef.b) : 0; + + const onSubmit = () => { + if (!calibrationRef) return; + const realLengthMm = parseLength(text, doc.displayUnit); + if (realLengthMm === null) { + setError('Enter a length — 3m, 10ft, 3000, 9′ 10″ all work.'); + return; + } + try { + commitCalibration(calibrationRef.a, calibrationRef.b, realLengthMm); + useStore.getState().endCalibration(); + setText(''); + } catch (err) { + setError( + err instanceof CalibrationError ? err.message : 'That scale could not be applied.', + ); + } + }; + + const onCancel = () => { + // Backing out of a *first* calibration discards the import: keeping an + // uncalibrated plan around would leave the document permanently unable to accept + // placements, with no obvious way to see why. Backing out of a recalibration + // just leaves the existing scale alone. + if (first) removeBackground(); + useStore.getState().endCalibration(); + setText(''); + }; + + return ( +
+
+

{first ? 'Set the scale' : 'Recalibrate'}

+

+ Drag a line across something you know the real size of — a stated room width, + a doorway, a printed scale bar — then type that length. +

+
+ + {/* + Always rendered, disabled until a line exists, because the gate's height must + not change while the user is drawing on the canvas below it. An earlier + version swapped a one-line prompt for this row on mousedown; the band grew, + the stage shifted down under the pointer, and the line committed was not the + line drawn — a 3000mm reference came out 3048mm and every dimension traced + afterwards inherited the error. + */} +
+ + {calibrationRef ? formatLength(lengthMm, doc.displayUnit) : 'Drag a line…'} + + + + +
+ + {/* Reserved whether or not there is an error, for the same reason. */} +

+ {error ?? ''} +

+ +
+ {/* Static text. The measured length belongs in the fixed-width slot above, + not here: a string that grows once a line is drawn can rewrap this row + and change the gate's height while the pointer is still down. */} + + {isCalibrated(background) + ? `Scale ${effectiveMmPerPx(background).toFixed(2)} mm per pixel` + : 'Not calibrated'} + + +
+
+ ); +} diff --git a/src/ui/DropZone.tsx b/src/ui/DropZone.tsx new file mode 100644 index 0000000..e0bd415 --- /dev/null +++ b/src/ui/DropZone.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react'; +import { acceptFile } from './file-actions'; +import { useStore } from '../state/store'; + +/** + * Drag a file anywhere onto the window. See PLAN.md §5. + * + * Listeners go on `window`, not on a div: the point is that there is no target to + * aim at. The overlay is feedback, not a hit area, and is `pointer-events: none` so + * it cannot become one — an overlay that swallowed the drop would make the feature + * work only where it was not covering anything. + * + * ## Why `dragover` must call `preventDefault` + * + * Without it the browser treats the page as a non-drop-target and navigates to the + * file instead, discarding the open document with no prompt. The default behaviour of + * a drop is the destructive one, which is worth stating because the two lines that + * prevent it look like ceremony. + * + * Drops are ignored while the calibration gate is open. That gate is modal for a + * reason — a background with no scale accepts nothing — and letting a drop replace + * the very file being calibrated would leave the modal describing something else. + */ +export function DropZone() { + const [over, setOver] = useState(false); + const calibrating = useStore((s) => s.calibrating); + + useEffect(() => { + if (calibrating) { + setOver(false); + return; + } + + // `dragenter`/`dragleave` fire per element crossed, so a naive pair flickers the + // overlay on every child boundary. Counting entries against leaves is the usual + // fix; `relatedTarget === null` is the cheaper one — it is the only leave that + // means the pointer actually left the window. + const onDragOver = (e: DragEvent) => { + if (!e.dataTransfer) return; + if (!Array.from(e.dataTransfer.types).includes('Files')) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + setOver(true); + }; + const onDragLeave = (e: DragEvent) => { + if (e.relatedTarget === null) setOver(false); + }; + const onDrop = (e: DragEvent) => { + if (!e.dataTransfer) return; + e.preventDefault(); + setOver(false); + const file = e.dataTransfer.files.item(0); + // One file. A multi-file drop of two floor plans has no meaning this + // application can act on, and picking the first silently is at least legible. + if (file) void acceptFile(file); + }; + + window.addEventListener('dragover', onDragOver); + window.addEventListener('dragleave', onDragLeave); + window.addEventListener('drop', onDrop); + return () => { + window.removeEventListener('dragover', onDragOver); + window.removeEventListener('dragleave', onDragLeave); + window.removeEventListener('drop', onDrop); + }; + }, [calibrating]); + + if (!over) return null; + + return ( + + ); +} diff --git a/src/ui/ImportButton.tsx b/src/ui/ImportButton.tsx new file mode 100644 index 0000000..3ce3150 --- /dev/null +++ b/src/ui/ImportButton.tsx @@ -0,0 +1,111 @@ +import { useRef, useState } from 'react'; +import { IMPORT_ACCEPT } from '../core/media'; +import { attachPlan, beginImport } from './import/plan-import'; +import { useStore } from '../state/store'; + +/** + * "Import plan" — the entry point to PLAN.md §6.1. + * + * The button owns the file input and the busy flag; the *pending* multi-page PDF + * lives in the store instead, because a file dropped on the window has to raise the + * same page picker this button does. Keeping it local was what would have let the two + * entry points drift apart — the dropped one silently taking page 1. + */ +export function ImportButton({ disabled }: { disabled: boolean }) { + const input = useRef(null); + const pending = useStore((s) => s.pendingImport); + const [page, setPage] = useState(1); + const [busy, setBusy] = useState(false); + + const fail = (err: unknown) => { + window.alert(err instanceof Error ? err.message : 'That file could not be imported.'); + }; + + const onPick = async (file: File) => { + setBusy(true); + try { + setPage(1); + await beginImport(file); + } catch (err) { + fail(err); + } finally { + setBusy(false); + } + }; + + const onConfirmPage = async () => { + if (!pending) return; + setBusy(true); + try { + await attachPlan(pending, page - 1); + useStore.getState().setPendingImport(null); + } catch (err) { + fail(err); + } finally { + setBusy(false); + } + }; + + return ( + <> + + { + const file = e.target.files?.[0]; + // Clear the input so picking the same file again fires change. + e.target.value = ''; + if (file) void onPick(file); + }} + /> + + {pending?.kind === 'pdf' ? ( +
+ + {pending.fileName} has {pending.pageCount} pages. Trace which one? + + { + const n = Number(e.target.value); + if (Number.isFinite(n)) setPage(Math.min(pending.pageCount, Math.max(1, Math.round(n)))); + }} + /> + + +
+ ) : null} + + ); +} diff --git a/src/ui/InventoryPanel.tsx b/src/ui/InventoryPanel.tsx index 919e9c2..55548a6 100644 --- a/src/ui/InventoryPanel.tsx +++ b/src/ui/InventoryPanel.tsx @@ -1,17 +1,361 @@ +import { useState } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { CATEGORY_LABELS, CatalogError, draftFromItem, type ItemDraft } from '../core/catalog'; +import { placedCount, type CatalogItem, type Id } from '../core/document'; +import { PRESETS, findPreset } from '../core/presets'; +import { placementBlockReason } from '../core/calibration'; +import { formatLength } from '../core/units'; +import { activeFloor, useStore } from '../state/store'; +import { + addCatalogItem, + removeCatalogItem, + setQuantityOwned, + 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'; + /** - * Placeholder for the catalog list (phase 4). + * The inventory (PLAN.md §7). * - * Lists CatalogItems with owned/placed counts — deliberately independent of - * placement, so "I own 6, 4 are placed" stays expressible (PLAN.md §4.3). + * Items exist independently of placement: you can build a shopping list before you + * have a floor plan, and the unplaced count is the whole point of the exercise. That + * is why the list shows owned and placed separately rather than one number — "I own + * 6, 4 are placed, 2 to go" has to stay expressible. + * + * Placing starts here rather than from a tool: you pick the thing you want, then you + * point at where it goes. */ export function InventoryPanel() { + const { doc, editMode, placingItemId, notice } = useStore( + useShallow((s) => ({ + doc: s.doc, + editMode: s.editMode, + placingItemId: s.placingItemId, + notice: s.notice, + })), + ); + + const [adding, setAdding] = useState(false); + 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); + const editing = editingId ? doc.catalog.find((i) => i.id === editingId) : undefined; + + const guarded = (fn: () => void) => { + try { + fn(); + setError(null); + } catch (err) { + // `CatalogError` messages are written to be read by whoever typed the number. + setError(err instanceof CatalogError ? err.message : 'That item could not be saved.'); + } + }; + + const onAdd = (draft: ItemDraft) => + guarded(() => { + addCatalogItem(draft); + 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); + setEditingId(null); + }); + + const onPreset = (key: string) => { + const preset = findPreset(key); + if (!preset) return; + // Spread away `key` and `group`: they are library metadata, not item fields. + const { key: _key, group: _group, ...draft } = preset; + guarded(() => addCatalogItem(draft)); + }; + + const arm = (item: CatalogItem) => { + const store = useStore.getState(); + if (store.editMode !== 'furnish') store.setEditMode('furnish'); + store.setPlacingItem(store.placingItemId === item.id ? null : item.id); + }; + return ( ); } diff --git a/src/ui/ItemForm.tsx b/src/ui/ItemForm.tsx new file mode 100644 index 0000000..1c9e9ff --- /dev/null +++ b/src/ui/ItemForm.tsx @@ -0,0 +1,214 @@ +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 { formatLength, parseLength } from '../core/units'; +import { LengthField } from './LengthField'; +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', +}; + +/** + * 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); + // 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 ? formatLength(base.voidBelowMm, unit) : '', + ); + 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); + // 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); + }; + + 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/LengthField.tsx b/src/ui/LengthField.tsx new file mode 100644 index 0000000..c50306d --- /dev/null +++ b/src/ui/LengthField.tsx @@ -0,0 +1,172 @@ +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 ( + + ); +} + +/** + * A whole number bound to the document, committed on blur or Enter. + * + * The same contract as `LengthInput`, and needed for the same two reasons plus a + * third that only bites when the stored value is clamped. A swing angle is held + * between 15° and 180°, so writing per keystroke means typing `135` sends `1`, which + * is stored as `15`, which re-renders the controlled input as "15" — and the next + * keystroke lands against that. No angle whose first digit is below the floor can be + * typed at all. Holding the text locally is what lets a number be half-typed. + * + * Anything unreadable reverts rather than becoming zero, and Escape abandons. + */ +export function NumberInput({ + label, + value, + onCommit, + min, + max, + suffix, + testId, +}: { + label: string; + value: number; + onCommit: (next: number) => void; + min?: number; + max?: number; + suffix?: string; + testId?: string; +}) { + const stored = String(value); + const [text, setText] = useState(stored); + + useEffect(() => setText(stored), [stored]); + + const commit = () => { + const parsed = Number(text.trim()); + if (text.trim() === '' || !Number.isFinite(parsed)) { + setText(stored); + return; + } + if (parsed !== value) onCommit(parsed); + else setText(stored); // a clamped commit that changed nothing still redraws + }; + + return ( + + ); +} diff --git a/src/ui/PropertiesPanel.tsx b/src/ui/PropertiesPanel.tsx index 640e036..b632a3f 100644 --- a/src/ui/PropertiesPanel.tsx +++ b/src/ui/PropertiesPanel.tsx @@ -1,12 +1,378 @@ 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 { deleteSelection, setRoomName } from '../state/actions'; +import { + deleteSelection, + nudgeBackgroundRotation, + setCeilingDrop, + setOpeningSwing, + setPlacementMount, + updateOpening, + removeBackground, + rotatePlacementBy, + setBackgroundLocked, + setBackgroundOpacity, + setPlacementElevation, + 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'; +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, + OPENING_KIND_LABELS, + OPENING_DEFAULTS, + openingRange, + openingSpan, +} from '../core/openings'; +import { + LEAF_STYLE_LABELS, + MAX_SWING_DEG, + MIN_SWING_DEG, + leafOf, + movingLeafOf, +} from '../core/swing'; +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', + wall: 'Wall-mounted', + ceiling: 'Hanging', +}; + +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. */ +/** + * Hanging the leaf: which end it is fixed at, which side it opens onto, how far. + * + * Flip buttons rather than selects, because there is no honest label for the two + * sides of a wall — "front" and "back" mean nothing to anyone looking at a plan. + * The swing arc in the drawing is what makes the choice legible, so the control's + * job is to change it and let you look, which is what a CAD tool gives you too. + * + * Nothing here renders for a cased opening or a window: `movingLeafOf` returns null + * for them, and the narrowing is what lets the angle field exist only for a leaf + * that actually has an angle. + */ +function OpeningSwing({ opening }: { opening: Opening }) { + const leaf = movingLeafOf(opening); + if (!leaf) return null; + + return ( +
+
+ + {leaf.style === 'pocket' ? null : ( + + )} +
+ + {leaf.style === 'hinged' ? ( + setOpeningSwing(opening.id, { angleDeg: deg })} + testId="swing-angle" + /> + ) : null} +
+ ); +} + +/** + * 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. + * + * 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. + * + * 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 (
@@ -29,11 +395,21 @@ export function PropertiesPanel() { const unit = doc.displayUnit; const only = selection.length === 1 ? selection[0] : null; + const background = floor.background; + 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; + 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 ?? ''); + const [mountError, setMountError] = useState(null); useEffect(() => setDraftName(room?.name ?? ''), [room?.id, room?.name]); const commitName = () => { @@ -77,11 +453,232 @@ 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} + {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 ? ( +
+ + + + + {/* 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. */} + + + {mountError ? ( +

+ {mountError} +

+ ) : null} + {placement.mount.kind === 'surface' ? ( + + ) : null} + {placement.mount.kind === 'wall' ? ( + placement.mount.kind === 'wall' && w.id === placement.mount.wallId) + ? 'yes' + : 'a wall that is gone' + } + /> + ) : 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} + +
+ + + {Math.round(placement.rotation)}° + + +
+ + {placement.mount.kind === 'wall' ? ( + setPlacementElevation(placement.id, mm)} + testId="placement-elevation" + /> + ) : null} + + {/* How far a pendant hangs below the ceiling. The base is derived from it + rather than stored, which is why this edits the drop and the Base field + above reads back through `resolveElevation`. */} + {placement.mount.kind === 'ceiling' ? ( + setCeilingDrop(placement.id, mm)} + testId="placement-drop" + /> + ) : null} + + {placementItem.canHostSurface ? ( +

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

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

{selection.length} items selected. @@ -98,11 +695,138 @@ export function PropertiesPanel() { ) : null} + {background ? ( +

+

Floor plan

+ + {!hasAsset(background.assetId) ? ( +

+ The image for this plan is not loaded. It was probably opened from a + file saved without it. +

+ ) : null} + + + + + + + + +
+ + +
+ +
+ + +
+
+ ) : null} + +

Floor

+ + +

Rooms

+ + +

Walkway

+ +

Validation

-

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

+ {issues.length === 0 ? ( +

+ No issues{doc.floors.length > 1 ? ` on ${floor.name}` : ''}. +

+ ) : ( + <> +

+ {issues.length === 1 ? '1 issue' : `${issues.length} issues`} + {/* Named rather than implied. Every check here — overlaps, headroom, + clearance, swing — runs against the active floor, which was a + distinction without a difference until there was more than one floor + and is now the difference between "no issues" and "none that I + looked for". */} + {doc.floors.length > 1 ? ` on ${floor.name}` : ''} +

+ {/* 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/RecoveryBanner.tsx b/src/ui/RecoveryBanner.tsx new file mode 100644 index 0000000..f7eefc1 --- /dev/null +++ b/src/ui/RecoveryBanner.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from 'react'; +import { chooseRecovery, recoveryMessage, type RecoveryOffer } from '../core/recovery'; +import { dropAutosave, listAutosaves, readAutosave } from '../state/autosave-db'; +import { useStore } from '../state/store'; + +/** + * "There is unsaved work in the database." See PLAN.md §5. + * + * Asked exactly once, at startup, and never again for the life of the page — an offer + * that reappeared after you dismissed it would be a nag, and one that appeared + * mid-session would be describing a database this session is itself writing to. + * + * Both buttons are terminal and both are safe. **Restore** replaces the open document, + * which is why `chooseRecovery` refuses to offer when there is anything here to lose. + * **Discard** deletes the record, because a recovery offer that can be dismissed + * without resolving is one you dismiss forever. + */ +export function RecoveryBanner() { + const [offer, setOffer] = useState(null); + const [busy, setBusy] = useState(false); + const asked = useRef(false); + + useEffect(() => { + // StrictMode mounts effects twice in development; asking twice would race two + // reads of the same database and could show the banner after it was dismissed. + if (asked.current) return; + asked.current = true; + + void (async () => { + const records = await listAutosaves(); + const { doc, dirty } = useStore.getState(); + setOffer(chooseRecovery(records, doc, { dirty })); + })(); + }, []); + + if (!offer) return null; + + const restore = async () => { + setBusy(true); + try { + const found = await readAutosave(offer.record.documentId); + if (!found) { + // The record went away underneath us — another tab recovered or saved it. + // Nothing to say: the work is not lost, it is just not ours to restore. + setOffer(null); + return; + } + // `dirty: true` — a recovered document has never been written to a file, and + // letting it open clean would let the user close the tab a second time on the + // same unsaved work. + useStore.getState().loadDocument(found.document, found.assets, { dirty: true }); + useStore.getState().zoomToFit(); + setOffer(null); + } finally { + setBusy(false); + } + }; + + const discard = async () => { + setBusy(true); + try { + await dropAutosave(offer.record.documentId); + setOffer(null); + } finally { + setBusy(false); + } + }; + + return ( +
    + {recoveryMessage(offer)} + + +
    + ); +} diff --git a/src/ui/StatusBar.tsx b/src/ui/StatusBar.tsx index 773c40e..3d6dd30 100644 --- a/src/ui/StatusBar.tsx +++ b/src/ui/StatusBar.tsx @@ -1,4 +1,5 @@ import { useShallow } from 'zustand/react/shallow'; +import { backgroundExtentMm, isCalibrated } from '../core/calibration'; import { formatLength } from '../core/units'; import { DEFAULT_SCALE, gridStepMm } from '../core/viewport'; import { activeFloor, documentGridMm, useStore } from '../state/store'; @@ -36,10 +37,24 @@ export function StatusBar() { Rooms {floor.rooms.length} + + Openings {floor.openings.length} + Items {floor.placements.length} + {/* 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..b808042 100644 --- a/src/ui/ToolPalette.tsx +++ b/src/ui/ToolPalette.tsx @@ -1,11 +1,13 @@ 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, PLAN_TOOL_LABELS, SHAPE_KINDS, SHAPE_KIND_LABELS, + type PlanTool, } from '../core/tools'; import { useStore } from '../state/store'; @@ -17,16 +19,31 @@ import { useStore } from '../state/store'; * mode is why. */ export function ToolPalette() { - const { tool, shapeKind, editMode, gridEnabled } = 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, })), ); - 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; + + /** + * 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 (
    @@ -38,7 +55,7 @@ export function ToolPalette() { className="seg" data-active={t === tool} aria-pressed={t === tool} - disabled={!enabled && t !== 'select'} + disabled={!enabled && (calibrating || !alwaysAvailable(t))} title={`${PLAN_TOOL_LABELS[t]} (${PLAN_TOOL_KEYS[t].toUpperCase()})`} onClick={() => useStore.getState().setTool(t)} > @@ -47,6 +64,23 @@ export function ToolPalette() { ))}
    + {tool === 'opening' && enabled ? ( +
    + {OPENING_KINDS.map((k) => ( + + ))} +
    + ) : null} + {tool === 'shape' && enabled ? (
    {SHAPE_KINDS.map((k) => ( diff --git a/src/ui/TopBar.tsx b/src/ui/TopBar.tsx index 6a4b1be..63a464a 100644 --- a/src/ui/TopBar.tsx +++ b/src/ui/TopBar.tsx @@ -6,14 +6,18 @@ import { VIEW_MODES, VIEW_MODE_LABELS, } from '../core/modes'; +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() { 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 +25,7 @@ export function TopBar() { dirty: s.dirty, canUndo: s.past.length > 0, canRedo: s.future.length > 0, + calibrating: s.calibrating, })), ); @@ -35,31 +40,59 @@ 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 { - useStore.getState().loadDocument(await openDocumentFile(file)); - 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 (
    @@ -110,7 +161,7 @@ export function TopBar() {
    + {/* 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) => (
    + } + > + + )} ); 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 195645d..9ec2551 100644 --- a/src/ui/file-io.ts +++ b/src/ui/file-io.ts @@ -1,48 +1,85 @@ /** - * Saving and opening `.space` files. + * Saving and opening `.space` files. See PLAN.md §5. * - * Phase 2 uses a download and a file input — universally supported, and enough to - * prove the portability requirement end to end: draw, save, reload the page, open, - * get the same space back. 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 } from '../core/space-file'; +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 { - // Nothing creates assets yet, so there is nowhere to read their bytes from. The - // moment PDF import lands in phase 3 this becomes reachable, and writing `{}` would - // silently produce a file that reopens with a background pointing at an image that - // is not in it — the same hole `writeSpaceJson` already refuses. Fail loudly here - // instead, and wire the real asset store when phase 3 creates one. - if (doc.assets.length > 0) { - throw new SpaceFileError( - `Saving assets is not implemented yet: this space references ${doc.assets.length} ` + - `file(s) that would be lost. (Phase 3 wires the asset store.)`, - ); +/** + * 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); + } catch (err) { + 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(); @@ -50,10 +87,60 @@ export function saveDocument(doc: SpaceDocument, appVersion?: string): void { setTimeout(() => URL.revokeObjectURL(url), 10_000); } -export async function openDocumentFile(file: File): Promise { +/** + * 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. + * + * Returns the whole bundle, not just the document: the caller has to hand the asset + * bytes to the runtime store. An earlier version of this returned `.document` and + * dropped `.assets` on the floor, which looked correct right up until a space with a + * background was opened, saved and reopened with nothing behind the walls. + */ +export async function openDocumentFile(file: File): Promise { const buffer = await file.arrayBuffer(); try { - return readSpace(new Uint8Array(buffer)).document; + return readSpace(new Uint8Array(buffer)); } catch (err) { if (err instanceof SpaceFileError) throw err; throw new SpaceFileError(`Could not open ${file.name}.`); diff --git a/src/ui/import/pdf.ts b/src/ui/import/pdf.ts new file mode 100644 index 0000000..1d41305 --- /dev/null +++ b/src/ui/import/pdf.ts @@ -0,0 +1,111 @@ +/** + * PDF → raster, via pdfjs. See PLAN.md §6.1. + * + * **PDF import is a background-image feature, not a geometry-extraction feature.** + * Detecting walls in a raster is a computer-vision research problem, and pretending + * otherwise sinks the schedule. What happens here is narrow and boring on purpose: + * render a page to pixels at a resolution worth tracing over, and hand it on. Vector + * path extraction through `getOperatorList` is v2 — the same foundation, an extra + * input to the same tracing layer. + * + * DOM-dependent (canvas, worker). Never import this from `src/core/`. + */ + +import * as pdfjs from 'pdfjs-dist'; +import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; +import { canvasToPng, type PixelSize } from './raster'; + +// Bundled and hashed by Vite, so this resolves in the production build too — which +// matters because the e2e suite runs against `vite build && vite preview`, not dev. +pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; + +/** PDF user space is 1/72 inch. 150dpi is legible line work without being enormous. */ +export const DEFAULT_RENDER_DPI = 150; + +/** Ceilings, so a poster-sized E1 sheet cannot allocate a gigabyte of canvas. */ +const MAX_EDGE_PX = 6000; +const MAX_TOTAL_PX = 24_000_000; + +export type PdfPageRender = { + bytes: Uint8Array; + mime: 'image/png'; + pixelSize: PixelSize; +}; + +/** + * pdfjs takes ownership of the buffer it is handed and detaches it, which would + * leave the caller holding an empty `Uint8Array` — and the caller needs those exact + * bytes to store the original PDF as an asset. Always pass a copy. + */ +function loadingTask(bytes: Uint8Array) { + return pdfjs.getDocument({ data: bytes.slice() }); +} + +export async function pdfPageCount(bytes: Uint8Array): Promise { + // `destroy` lives on the loading task, not the document proxy — tearing down the + // task is what terminates the worker, and skipping it leaks one per import. + const task = loadingTask(bytes); + try { + const doc = await task.promise; + return doc.numPages; + } finally { + await task.destroy(); + } +} + +/** + * Render one page to PNG bytes. + * + * `pageIndex` is zero-based here and one-based in pdfjs; the conversion happens once, + * in this function, and `Background.pageIndex` stores the zero-based value. + */ +export async function renderPdfPage( + bytes: Uint8Array, + pageIndex: number, + dpi = DEFAULT_RENDER_DPI, +): Promise { + const task = loadingTask(bytes); + try { + const doc = await task.promise; + if (pageIndex < 0 || pageIndex >= doc.numPages) { + throw new Error(`That PDF has ${doc.numPages} page(s); page ${pageIndex + 1} is not one.`); + } + const page = await doc.getPage(pageIndex + 1); + + const base = page.getViewport({ scale: 1 }); + const wanted = dpi / 72; + const scale = Math.min( + wanted, + MAX_EDGE_PX / Math.max(base.width, base.height), + Math.sqrt(MAX_TOTAL_PX / (base.width * base.height)), + ); + const viewport = page.getViewport({ scale }); + + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.floor(viewport.width)); + canvas.height = Math.max(1, Math.floor(viewport.height)); + + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('This browser did not provide a 2D canvas to render into.'); + + // Paint white first. A PDF page is transparent where nothing is drawn, and a + // transparent background over the dark theme renders black lines on black. + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + await page.render({ canvas, viewport }).promise; + + const pixelSize = { width: canvas.width, height: canvas.height }; + const png = await canvasToPng(canvas); + + // Release the backing store immediately; a 24MP canvas held by a closure is + // 96MB the collector is in no hurry about. Read the size first — zeroing the + // canvas is what frees it, and it also erases the dimensions. + canvas.width = 0; + canvas.height = 0; + + return { bytes: png, mime: 'image/png', pixelSize }; + } finally { + await task.destroy(); + } +} diff --git a/src/ui/import/plan-import.ts b/src/ui/import/plan-import.ts new file mode 100644 index 0000000..6e7137f --- /dev/null +++ b/src/ui/import/plan-import.ts @@ -0,0 +1,145 @@ +/** + * The import flow, end to end. See PLAN.md §6.1. + * + * pick a file + * → sniff its real type from the magic bytes + * → [PDF] page picker, then render the chosen page to a raster + * → store the raster as an asset, and the original file alongside it + * → attach it to the active floor as an uncalibrated background + * → ▶ open the calibration gate ◀ (blocking) + * + * Split in two on purpose. `inspectFile` is cheap and answers the one question the + * UI needs before it can ask anything ("how many pages?"); `attachPlan` does the + * expensive render once a page is chosen. A single call would have to either render + * every page up front or guess. + * + * The viewport is deliberately **not** re-fitted on import. An uncalibrated raster is + * shown at a nominal 6m wide (`NOMINAL_PLAN_WIDTH_MM`), which is already a drawable + * size at the default zoom, and leaving the transform alone keeps every screen-to- + * document coordinate the user — and the e2e suite — has already learned. + */ + +import { createBackground } from '../../core/calibration'; +import type { AssetRef } from '../../core/document'; +import { + IMPORT_FORMATS_LABEL, + isRaster, + sniffMime, + type ImportMime, + type Inspection, + type RasterMime, +} from '../../core/media'; +import { putAsset } from '../../state/assets'; +import { setBackground } from '../../state/actions'; +import { useStore } from '../../state/store'; +import { decodeImageSize } from './raster'; + +/** + * pdfjs and its worker are ~450kB of the bundle, and most people draw their plan by + * hand or import a photo. Loading it on demand keeps that off the first paint for + * everyone who never opens a PDF; the chunk arrives while the file picker is still + * closing, so nothing waits on it in practice. + */ +const pdfModule = () => import('./pdf'); + +export type { Inspection }; + +export class ImportError extends Error { + constructor(message: string) { + super(message); + this.name = 'ImportError'; + } +} + +/** + * Identify a picked file. + * + * The type comes from the leading bytes, never from `File.type` — browsers hand out + * `application/octet-stream` for perfectly good PDFs from some archives, and a JPEG + * renamed `.png` arrives claiming to be a PNG. Either would reach the wrong decoder. + */ +export async function inspectFile(file: File): Promise { + const bytes = new Uint8Array(await file.arrayBuffer()); + const mime: ImportMime | null = sniffMime(bytes); + + if (!mime) { + throw new ImportError( + `${file.name} is not a format this can read. Import a ${IMPORT_FORMATS_LABEL} file.`, + ); + } + + if (isRaster(mime)) return { kind: 'image', fileName: file.name, mime, bytes }; + + const pageCount = await (await pdfModule()).pdfPageCount(bytes); + if (pageCount < 1) throw new ImportError(`${file.name} has no pages to import.`); + return { kind: 'pdf', fileName: file.name, bytes, pageCount }; +} + +/** + * Attach an inspected file to the active floor and open the calibration gate. + * + * A PDF stores two assets: the render that gets traced, and the original document. + * Retaining the source is what makes re-rendering at a different resolution — or + * extracting vector paths in v2 — possible on someone else's machine, and it costs + * only the bytes they already gave us. + */ +export async function attachPlan(inspection: Inspection, pageIndex = 0): Promise { + const assets: AssetRef[] = []; + let rasterBytes: Uint8Array; + let rasterMime: RasterMime; + let pixelSize: { width: number; height: number }; + let sourceRef: AssetRef | undefined; + let page: number | undefined; + + if (inspection.kind === 'image') { + rasterBytes = inspection.bytes; + rasterMime = inspection.mime; + pixelSize = await decodeImageSize(rasterBytes, rasterMime); + } else { + const render = await (await pdfModule()).renderPdfPage(inspection.bytes, pageIndex); + rasterBytes = render.bytes; + rasterMime = render.mime; + pixelSize = render.pixelSize; + page = pageIndex; + sourceRef = putAsset({ mime: 'application/pdf', bytes: inspection.bytes }); + assets.push(sourceRef); + } + + if (!(pixelSize.width > 0) || !(pixelSize.height > 0)) { + throw new ImportError(`${inspection.fileName} decoded to an empty image.`); + } + + const rasterRef = putAsset({ mime: rasterMime, bytes: rasterBytes }); + assets.push(rasterRef); + + const background = createBackground({ + assetId: rasterRef.id, + pixelSize, + ...(sourceRef ? { sourceAssetId: sourceRef.id } : {}), + ...(page !== undefined ? { pageIndex: page } : {}), + }); + + 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/import/raster.ts b/src/ui/import/raster.ts new file mode 100644 index 0000000..1e7f4e5 --- /dev/null +++ b/src/ui/import/raster.ts @@ -0,0 +1,47 @@ +/** + * Raster decoding helpers. + * + * DOM-dependent by nature — `createImageBitmap`, ``, `toBlob`. Nothing in + * `src/core/` may import this file: the unit suite runs in a node environment, and + * one core module reaching for a canvas takes the whole suite down with it. + */ + +export type PixelSize = { width: number; height: number }; + +/** + * The intrinsic pixel size of an encoded image. + * + * `createImageBitmap` is the direct route and is what Chrome and Firefox use; the + * `` fallback covers the rest. Both go through a blob URL that is revoked + * either way, including on the error path — a leaked object URL pins the whole + * decoded image in memory for the life of the tab. + */ +export async function decodeImageSize(bytes: Uint8Array, mime: string): Promise { + const blob = new Blob([bytes.slice()], { type: mime }); + + if (typeof createImageBitmap === 'function') { + const bitmap = await createImageBitmap(blob); + const size = { width: bitmap.width, height: bitmap.height }; + bitmap.close(); + return size; + } + + const url = URL.createObjectURL(blob); + try { + return await new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); + img.onerror = () => reject(new Error('That image could not be decoded.')); + img.src = url; + }); + } finally { + URL.revokeObjectURL(url); + } +} + +/** Encode a canvas as PNG bytes. PNG because a traced plan must stay crisp. */ +export async function canvasToPng(canvas: HTMLCanvasElement): Promise { + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + if (!blob) throw new Error('The page could not be encoded as an image.'); + return new Uint8Array(await blob.arrayBuffer()); +} diff --git a/src/ui/plan/BackgroundLayer.tsx b/src/ui/plan/BackgroundLayer.tsx new file mode 100644 index 0000000..e8f8bc6 --- /dev/null +++ b/src/ui/plan/BackgroundLayer.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +import { Image as KonvaImage, Layer } from 'react-konva'; +import type Konva from 'konva'; +import type { Background } from '../../core/document'; +import { effectiveMmPerPx } from '../../core/calibration'; +import { docToScreen, screenToDoc, type Viewport } from '../../core/viewport'; +import { assetUrl } from '../../state/assets'; +import { moveBackground } from '../../state/actions'; + +type Props = { + background: Background | undefined; + viewport: Viewport; + /** True only when the background is unlocked and the select tool is active. */ + interactive: boolean; +}; + +/** + * Decode an asset into an image element the canvas can paint. + * + * The URL is minted from bytes the runtime store holds *now*, so it survives a + * save-reload-open cycle: the alternative — keeping the `blob:` URL made at import + * time — looks correct until the page is reloaded and then silently renders nothing. + */ +function useAssetImage(assetId: string | undefined): HTMLImageElement | null { + const [image, setImage] = useState(null); + + useEffect(() => { + if (!assetId) { + setImage(null); + return; + } + const url = assetUrl(assetId); + if (!url) { + setImage(null); + return; + } + + let live = true; + const img = new window.Image(); + img.onload = () => { + if (live) setImage(img); + }; + // A background that fails to decode is not worth a dialog — the properties panel + // already reports a missing plan, and the walls drawn over it still work. + img.onerror = () => { + if (live) setImage(null); + }; + img.src = url; + + return () => { + live = false; + }; + }, [assetId]); + + return image; +} + +/** + * The imported floor plan, underneath everything else. + * + * Below the grid rather than above it: the grid is what you snap to, and a plan at + * 45% opacity sitting on top of it makes the lines you are actually aiming at + * harder to see. + * + * The Konva node carries the whole image-pixel to screen map — position, rotation + * and `mmPerPx * scale` — so the raster is never resampled into document space. That + * is also what makes recalibration cheap: one number changes and the node redraws. + */ +export function BackgroundLayer({ background, viewport, interactive }: Props) { + const image = useAssetImage(background?.assetId); + if (!background || !image) return null; + + const origin = docToScreen(viewport, background.transform.position); + const pxPerImagePx = effectiveMmPerPx(background) * viewport.scale; + + const onDragEnd = (e: Konva.KonvaEventObject) => { + const node = e.target; + moveBackground(screenToDoc(viewport, { x: node.x(), y: node.y() })); + }; + + return ( + + + + ); +} diff --git a/src/ui/plan/DraftLayer.tsx b/src/ui/plan/DraftLayer.tsx index b1e5c76..bd0e7d5 100644 --- a/src/ui/plan/DraftLayer.tsx +++ b/src/ui/plan/DraftLayer.tsx @@ -4,12 +4,18 @@ import { shapeBoundary, type Draft } from '../../core/tools'; import { formatLength, type DisplayUnit } from '../../core/units'; import { docToScreen, flattenToScreen, type Viewport } from '../../core/viewport'; import type { SnapHint } from '../../core/snapping'; -import type { Measurement } from '../../state/store'; +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[]; cursor: Vec2 | null; viewport: Viewport; @@ -64,6 +70,9 @@ function SegmentLabel({ export function DraftLayer({ draft, measurement, + walkway, + probe, + calibrationRef, snapHints, cursor, viewport, @@ -159,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 ? ( <> ) : null} + {/* The calibration reference. Labelled with what it measures *today*, under the + provisional scale — which is exactly the number the gate is about to + replace, and seeing it change is how the correction reads as having + worked. End caps because the endpoints are what get anchored. */} + {calibrationRef ? ( + <> + + {[calibrationRef.a, calibrationRef.b].map((p, i) => { + const s = docToScreen(viewport, p); + return ; + })} + + + ) : null} + {snapHints.map((hint, i) => { if (hint.kind === 'point') { const s = docToScreen(viewport, hint.at); diff --git a/src/ui/plan/GhostLayer.tsx b/src/ui/plan/GhostLayer.tsx new file mode 100644 index 0000000..67eb830 --- /dev/null +++ b/src/ui/plan/GhostLayer.tsx @@ -0,0 +1,59 @@ +import { Layer, Line } from 'react-konva'; +import type { Floor } from '../../core/document'; +import { wallOutline } from '../../core/geometry/wall'; +import { flattenToScreen, type Viewport } from '../../core/viewport'; +import type { PlanTheme } from './theme'; + +/** + * The floor below, drawn faintly under the one being edited. See PLAN.md §11. + * + * This is how a staircase lands in the right place, and how an upstairs wall gets put + * over the one holding it up. It is a tracing-paper underlay and nothing more. + * + * **It participates in nothing.** `listening={false}` keeps it out of the hit graph, + * so a click always addresses the active floor even where a ghost wall sits directly + * under the cursor. It is equally absent from the wall and room counts, from + * `floorBounds` and therefore from zoom-to-fit — which is what stops the viewport + * jumping when you change floors, and stops the status bar reporting walls you cannot + * select. All of that follows from it reading `floorBelow` here rather than being + * merged into `floor` anywhere upstream. + * + * Rooms are drawn as outlines rather than fills: two translucent room fills stacked + * read as a third colour, and the underlay would start looking like part of the plan. + */ +export function GhostLayer({ + floor, + viewport, + theme, +}: { + floor: Floor | undefined; + viewport: Viewport; + theme: PlanTheme; +}) { + if (!floor) return null; + + return ( + + {floor.rooms.map((room) => ( + + ))} + + {floor.walls.map((wall) => { + let points: number[]; + try { + points = flattenToScreen(viewport, wallOutline(wall).pts); + } catch { + return null; // degenerate — the validation panel's problem, not the underlay's + } + return ; + })} + + ); +} diff --git a/src/ui/plan/PlacementLayer.tsx b/src/ui/plan/PlacementLayer.tsx index 3e47942..ab5e493 100644 --- a/src/ui/plan/PlacementLayer.tsx +++ b/src/ui/plan/PlacementLayer.tsx @@ -1,11 +1,19 @@ -import { Layer, Line } from 'react-konva'; -import type { Floor, SpaceDocument } from '../../core/document'; +import { useLayoutEffect, useRef } from 'react'; +import { Circle, Layer, Line, Text } from 'react-konva'; +import type Konva from 'konva'; +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 { zoneOutline } from '../../core/clearance'; +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; @@ -14,16 +22,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, @@ -32,31 +47,159 @@ 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 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, 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'); }} /> ); })} + + {/* Clearance zones, on the selected item only. + + Drawn rather than merely reported, for the same reason the swing arc is: a + warning that says "the bookcase blocks the drawer pull" is an argument, and + the dashed rectangle it is about is the evidence. On the selection only, + because six dining chairs with pull-out zones would otherwise cover the + floor in hatching and tell you nothing. */} + {only + ? (() => { + 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. */} + {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 7326c2a..834487e 100644 --- a/src/ui/plan/PlanStage.tsx +++ b/src/ui/plan/PlanStage.tsx @@ -15,23 +15,43 @@ 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, addWallChain, + commitPlacementTransform, commitWallTransform, deleteSelection, + placementSnapContext, + previewPlacementTransform, previewWallTransform, + 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'; import { DraftLayer } from './DraftLayer'; +import { floorBelow } from '../../core/floors'; +import { GhostLayer } from './GhostLayer'; 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 @@ -63,7 +83,10 @@ export function PlanStage() { const containerRef = useRef(null); const pan = useRef<{ active: boolean; x: number; y: number }>({ active: false, x: 0, y: 0 }); 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, @@ -77,6 +100,10 @@ export function PlanStage() { selection, measurement, transform, + calibrating, + calibrationRef, + placementTransform, + placingItemId, } = useStore( useShallow((s) => ({ doc: s.doc, @@ -90,6 +117,10 @@ export function PlanStage() { selection: s.selection, measurement: s.measurement, transform: s.transform, + calibrating: s.calibrating, + calibrationRef: s.calibrationRef, + placementTransform: s.placementTransform, + placingItemId: s.placingItemId, })), ); @@ -115,6 +146,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); @@ -149,7 +184,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), @@ -183,6 +220,116 @@ 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; + // 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, + mode, + grab, + origin: { + position: placement.position, + rotation: placement.rotation, + mount: placement.mount, + }, + 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, + // Only a mount the *gesture* actually determined — the snap finding a host to + // stand on. A wall snap seats the item against the wall but leaves it on the + // floor, and passing that `{ kind: 'floor' }` through would override the + // item's own default and quietly ground every wall-mounted shelf. + ...(snapped && snapped.mount.kind !== 'floor' ? { 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; + } + }; + + /** + * 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(); @@ -193,6 +340,34 @@ export function PlanStage() { const middle = e.evt.button === 1; const onEmptyCanvas = e.target === stage; + // The gate takes the whole stage. Snapping is deliberately off for this drag: + // the reference has to land on the feature in the raster the user is pointing + // at, and quantising it to the grid quantises the scale that comes out of it. + if (useStore.getState().calibrating && !middle) { + if (e.evt.button !== 0) return; + const pos = stage.getPointerPosition(); + if (!pos) return; + const at = screenToDoc(useStore.getState().viewport, pos); + calDrag.current = true; + useStore.getState().setCalibrationRef({ a: at, b: at }); + 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; + } + + // 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')) { @@ -253,11 +428,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; @@ -271,6 +485,26 @@ export function PlanStage() { } const store = useStore.getState(); + + if (calDrag.current) { + const pos = stage.getPointerPosition(); + const ref = store.calibrationRef; + if (pos && ref) store.setCalibrationRef({ a: ref.a, b: screenToDoc(store.viewport, pos) }); + return; + } + 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; @@ -298,6 +532,23 @@ export function PlanStage() { const store = useStore.getState(); + if (calDrag.current) { + calDrag.current = false; + // A click without a drag is not a reference line; drop it so the gate keeps + // asking rather than accepting a zero-length one it would only reject later. + const ref = store.calibrationRef; + if (ref && ref.a.x === ref.b.x && ref.a.y === ref.b.y) store.setCalibrationRef(null); + return; + } + if (store.calibrating) return; + + const moving = store.placementTransform; + if (moving) { + commitPlacementTransform(moving); + store.setPlacementTransform(null); + return; + } + const dragging = store.transform; if (dragging) { commitWallTransform(dragging); @@ -354,6 +605,14 @@ export function PlanStage() { if (isTypingTarget(e.target)) return; const store = useStore.getState(); + // The gate is blocking, so the shortcuts are too. Undoing past an import while + // the gate is open would leave it prompting for a background that is gone, and + // a tool key would arm a tool the palette is showing as unavailable. + if (store.calibrating) { + if (e.key === 'Escape') store.setCalibrationRef(null); + return; + } + if (e.key === 'Alt') { store.setSnapSuppressed(true); return; @@ -376,16 +635,30 @@ 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') { addWallChain(current.points); store.setDraft(null); } + if (current?.tool === 'walkway') finishWalkway(current.points); lastClickPx.current = null; return; } @@ -420,8 +693,18 @@ export function PlanStage() { }, []); // ---- render ------------------------------------------------------------ - const structureInteractive = structureIsEditable(editMode) && tool === 'select'; - const placementsInteractive = placementsAreEditable(editMode); + // Mode drives `listening`, which is the layer toggle from the brief. The tool + // drives `selectable`, which the shape handlers read — because flipping + // `listening` only takes effect on Konva's next draw, and a click that arrives in + // the same frame lands on a layer that is still deaf. + const structureInteractive = structureIsEditable(editMode) && !calibrating; + const structureSelectable = structureInteractive && tool === 'select'; + const placementsInteractive = placementsAreEditable(editMode) && !calibrating; + // The background only accepts a drag when it has been deliberately unlocked, and + // never while the gate is open — dragging the plan out from under the reference + // line you are drawing on it is not a gesture anyone means. + const backgroundInteractive = + structureSelectable && floor.background?.locked === false; const onSelect = (ref: SelectionRef, additive: boolean) => { const store = useStore.getState(); @@ -433,7 +716,7 @@ export function PlanStage() {
    e.evt.preventDefault()} > + + + {/* Under the active floor, and out of the hit graph entirely. */} + + beginTransform(wallId, 'both')} @@ -491,12 +788,22 @@ export function PlanStage() { theme={theme} selection={selection} interactive={placementsInteractive} + // 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} + onGrab={beginPlacementTransform} /> void; @@ -24,6 +28,33 @@ type Props = { onGrabEndpoint: (wallId: string, end: 'a' | 'b') => void; }; +/** + * Rebuild the hit graph as soon as its inputs change, rather than on the next draw. + * + * Konva writes hit-test geometry into a separate canvas that is only refreshed when + * the layer is drawn, so anything that changes what is hittable is invisible to a + * click arriving in the same frame. Two triggers matter: + * + * **`listening` flipped** — switch back to plan mode and immediately click a wall + * and nothing is selected, because the layer is still deaf. + * + * **A shape appeared or vanished** — draw a wall, click it straight away, and the + * click is tested against a hit canvas that does not contain it yet. + * + * `useLayoutEffect` runs after React has committed and before the browser paints, + * which is exactly the window this needs to close. `count` keeps the work + * proportional to structural change rather than to every render. + */ +function useSyncHitGraph( + ref: RefObject, + listening: boolean, + count: number, +): void { + useLayoutEffect(() => { + ref.current?.drawHit(); + }, [ref, listening, count]); +} + function isSelected(selection: SelectionRef[], kind: SelectionRef['kind'], id: string): boolean { return selection.some((s) => s.kind === kind && s.id === id); } @@ -46,12 +77,56 @@ function openingQuad(wall: Wall, opening: Opening): number[] | null { return [at(start, 1), at(end, 1), at(end, -1), at(start, -1)].flatMap((p) => [p.x, p.y]); } +/** + * The door symbol, in document space. + * + * A hinged door draws as the sector `swingSweep` already computes: its boundary *is* + * the closed leaf, the arc, and the open leaf, which is exactly the symbol drawn on + * a plan. Nothing traces the arc a second time, so the drawing and the clearance + * check can never disagree about where the door goes. + * + * A slider draws the leaf where it parks, dashed, because that is the wall it needs + * kept clear. A pocket door draws the same run on the wall centreline — the cavity + * is inside the wall, and showing it is the only way the drawing says why the door + * cannot go 200mm from the corner. + */ +function swingSymbol( + wall: Wall, + opening: Opening, +): { points: Vec2[]; closed: boolean; dashed: boolean } | null { + const leaf = leafOf(opening); + + if (leaf.style === 'hinged') { + const sweep = swingSweep(wall, opening); + return sweep ? { points: sweep.pts, closed: true, dashed: false } : null; + } + if (leaf.style === 'sliding') { + const panel = leafPanel(wall, opening); + return panel ? { points: panel.pts, closed: true, dashed: true } : null; + } + if (leaf.style === 'pocket') { + const dir = normalize(sub(wall.b, wall.a)); + if (dir.x === 0 && dir.y === 0) return null; + const run = parkRun(opening, leaf.pivot); + const at = (t: number) => ({ x: wall.a.x + dir.x * t, y: wall.a.y + dir.y * t }); + return { points: [at(run.from), at(run.to)], closed: false, dashed: true }; + } + return null; +} + /** * Rooms, walls and openings. * * `listening` is driven by the edit mode, which is the layer toggle from the brief: * in furnish mode the structure is still drawn but is not hit-testable, so a click * that lands on a wall passes through to whatever is beneath it. + * + * **`listening` deliberately does not track the active tool.** Konva rebuilds a + * layer's hit graph on the next draw, not on the assignment, so a layer switched on + * and clicked within the same frame is still deaf — pick Select and click a wall + * fast enough and nothing happens. Tool state is therefore checked inside the + * handlers, where it takes effect immediately, and a non-selectable click returns + * *without* cancelling the bubble so the stage still receives it and can draw. */ export function StructureLayer({ floor, @@ -60,11 +135,19 @@ export function StructureLayer({ displayUnit, selection, interactive, + selectable, transform, onSelect, onGrabWall, onGrabEndpoint, }: Props) { + const layerRef = useRef(null); + 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. const walls = transform @@ -75,7 +158,7 @@ export function StructureLayer({ const wallsById = new Map(walls.map((w) => [w.id, w])); return ( - + {floor.rooms.map((room) => { const c = docToScreen(viewport, centroid(room.boundary)); const selected = isSelected(selection, 'room', room.id); @@ -88,6 +171,7 @@ export function StructureLayer({ stroke={selected ? theme.selection : theme.roomStroke} strokeWidth={selected ? 2 : 1} onMouseDown={(e) => { + if (!selectable) return; e.cancelBubble = true; onSelect({ kind: 'room', id: room.id }, e.evt.shiftKey); }} @@ -126,6 +210,7 @@ export function StructureLayer({ stroke={selected ? theme.selection : theme.wallStroke} strokeWidth={selected ? 2 : 0.75} onMouseDown={(e) => { + if (!selectable) return; e.cancelBubble = true; onSelect({ kind: 'wall', id: wall.id }, e.evt.shiftKey); // Selecting and grabbing are the same gesture; a press that never moves @@ -147,15 +232,49 @@ 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); + }} + /> + ); + })} + + {/* Door symbols, over the openings they belong to and under the handles. Not + hit-testable: clicking a swing arc should select the door, and the doorway + itself is the target for that — an arc that swallowed clicks would cover a + square metre of floor nobody could then select furniture through. */} + {floor.openings.map((opening) => { + const wall = wallsById.get(opening.wallId); + if (!wall) return null; + const symbol = swingSymbol(wall, opening); + if (!symbol) return null; + + return ( + ); })} @@ -179,6 +298,7 @@ export function StructureLayer({ strokeWidth={1} hitStrokeWidth={12} onMouseDown={(e) => { + if (!selectable) return; e.cancelBubble = true; onGrabEndpoint(wall.id, end); }} diff --git a/src/ui/plan/theme.ts b/src/ui/plan/theme.ts index 9cfe2ac..c9f0d2a 100644 --- a/src/ui/plan/theme.ts +++ b/src/ui/plan/theme.ts @@ -19,6 +19,12 @@ export type PlanTheme = { roomStroke: string; roomLabel: string; openingFill: string; + /** The door-swing symbol: the arc and leaf line, and the sector behind them. */ + swingStroke: string; + swingFill: string; + /** Clearance zones on the selected item. */ + zoneStroke: string; + zoneFill: string; placementFill: string; placementStroke: string; draft: string; @@ -26,6 +32,10 @@ export type PlanTheme = { selection: string; dimension: string; dimensionText: string; + /** Validation warnings — overlaps, headroom. Distinct from selection blue. */ + warning: string; + /** The floor below, showing through as an alignment underlay. */ + ghost: string; }; const LIGHT: PlanTheme = { @@ -38,6 +48,10 @@ const LIGHT: PlanTheme = { roomStroke: 'rgba(47, 111, 94, 0.5)', roomLabel: '#4a4a45', openingFill: '#f4f4f2', + swingStroke: '#8c8c86', + swingFill: 'rgba(140, 140, 134, 0.10)', + zoneStroke: 'rgba(200, 84, 31, 0.55)', + zoneFill: 'rgba(200, 84, 31, 0.10)', placementFill: 'rgba(120, 120, 130, 0.35)', placementStroke: '#6b6b66', draft: '#2f6f5e', @@ -45,6 +59,8 @@ const LIGHT: PlanTheme = { selection: '#1f6fd0', dimension: '#c8541f', dimensionText: '#8a3a14', + warning: '#c0392b', + ghost: 'rgba(80, 80, 96, 0.45)', }; const DARK: PlanTheme = { @@ -57,6 +73,10 @@ const DARK: PlanTheme = { roomStroke: 'rgba(77, 157, 134, 0.55)', roomLabel: '#c2c2c8', openingFill: '#17171a', + swingStroke: '#7e7e88', + swingFill: 'rgba(150, 150, 165, 0.12)', + zoneStroke: 'rgba(232, 131, 74, 0.6)', + zoneFill: 'rgba(232, 131, 74, 0.12)', placementFill: 'rgba(150, 150, 165, 0.32)', placementStroke: '#91919a', draft: '#4d9d86', @@ -64,6 +84,8 @@ const DARK: PlanTheme = { selection: '#5aa2f0', dimension: '#e8834a', dimensionText: '#f0a878', + warning: '#e5645a', + ghost: 'rgba(190, 190, 210, 0.40)', }; const QUERY = '(prefers-color-scheme: dark)'; 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/src/ui/space/CameraRig.tsx b/src/ui/space/CameraRig.tsx new file mode 100644 index 0000000..95e8dc3 --- /dev/null +++ b/src/ui/space/CameraRig.tsx @@ -0,0 +1,124 @@ +import { useEffect, useRef, type ComponentRef, type RefObject } from 'react'; +import { useFrame, useThree } from '@react-three/fiber'; +import { OrbitControls } from '@react-three/drei'; +import type { Bounds } from '../../core/geometry/polygon'; +import { docToThree } from '../../core/units'; +import { eyePosition, lookTarget, type CameraMode } from '../../core/walk'; +import type { SpaceCamera } from '../../core/views'; +import { useStore } from '../../state/store'; + +type Props = { + mode: CameraMode; + bounds: Bounds | null; + ceilingHeightMm: number; + /** Written every frame so the HUD can bookmark wherever the camera actually is. */ + poseRef: RefObject; +}; + +/** + * The camera, in all three modes. + * + * In walk and fly it is a **consumer** of the walker: `useFrame` reads the store + * imperatively and writes the camera, with no React subscription and therefore no + * re-render per frame. The walk simulation itself is elsewhere (`useWalkLoop`), which + * is what lets traversal keep working when this component never mounts because there + * is no WebGL context. + * + * In orbit, drei's controls own the camera and this only frames the scene on entry. + */ +export function CameraRig({ mode, bounds, ceilingHeightMm, poseRef }: Props) { + const camera = useThree((s) => s.camera); + const controls = useRef>(null); + const orbiting = mode === 'orbit'; + + // Frame the whole floor when orbit is entered. Only on entry: doing it per render + // would snap the camera back every time anything in the document changed. + useEffect(() => { + if (!orbiting) return; + const framing = frameBounds(bounds, ceilingHeightMm); + camera.position.set(framing.position.x, framing.position.y, framing.position.z); + controls.current?.target.set(framing.target.x, framing.target.y, framing.target.z); + controls.current?.update(); + }, [orbiting, bounds, ceilingHeightMm, camera]); + + useFrame(() => { + const store = useStore.getState(); + + // A bookmark being applied. One-shot: adopting it and clearing it is what keeps + // the next orbit drag from fighting a value that would otherwise be reasserted. + const pending = store.pendingCamera; + if (pending) { + const p = docToThree(pending.position); + const t = docToThree(pending.target); + camera.position.set(p.x, p.y, p.z); + camera.lookAt(t.x, t.y, t.z); + controls.current?.target.set(t.x, t.y, t.z); + controls.current?.update(); + store.setPendingCamera(null); + } + + if (store.cameraMode !== 'orbit' && store.walker) { + const eye = docToThree(eyePosition(store.walker)); + const at = docToThree(lookTarget(store.walker)); + camera.position.set(eye.x, eye.y, eye.z); + camera.lookAt(at.x, at.y, at.z); + } + + // Reported in document millimetres, so a saved view is expressed in the same + // units as everything else in the file and does not depend on the renderer. + poseRef.current = { + position: threeToDocPoint(camera.position), + target: + store.cameraMode === 'orbit' && controls.current + ? threeToDocPoint(controls.current.target) + : store.walker + ? lookTarget(store.walker) + : threeToDocPoint(camera.position), + mode: store.cameraMode, + }; + }); + + return ( + + ); +} + +function threeToDocPoint(v: { x: number; y: number; z: number }) { + // The inverse of `docToThree`, without the millimetre rounding `threeToDoc` applies + // — a camera is not document geometry and does not want to be snapped to a grid. + return { x: v.x * 1000, y: v.z * 1000, z: v.y * 1000 }; +} + +/** + * A three-quarter view that contains the whole floor. + * + * Sized from the diagonal rather than from either side, so a long thin corridor is + * framed by its length and not cropped by its width. + */ +function frameBounds(bounds: Bounds | null, ceilingHeightMm: number) { + if (!bounds) { + return { + position: { x: 6, y: 6, z: 6 }, + target: { x: 0, y: ceilingHeightMm / 2000, z: 0 }, + }; + } + const cx = (bounds.minX + bounds.maxX) / 2; + const cy = (bounds.minY + bounds.maxY) / 2; + const diagonal = Math.hypot(bounds.maxX - bounds.minX, bounds.maxY - bounds.minY); + const distance = Math.max(diagonal, 3000) * 0.9; + + const target = docToThree({ x: cx, y: cy, z: ceilingHeightMm / 2 }); + const eye = docToThree({ + x: cx - distance * 0.4, + y: cy + distance, + z: ceilingHeightMm / 2 + distance * 0.8, + }); + return { position: eye, target }; +} diff --git a/src/ui/space/SpaceHud.tsx b/src/ui/space/SpaceHud.tsx new file mode 100644 index 0000000..d994f65 --- /dev/null +++ b/src/ui/space/SpaceHud.tsx @@ -0,0 +1,180 @@ +import { useState, type RefObject } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { CAMERA_MODES, CAMERA_MODE_LABELS } from '../../core/walk'; +import { spaceViews, type SpaceCamera } from '../../core/views'; +import { roomAt } from '../../core/placement'; +import { formatLength } from '../../core/units'; +import { FLOOR_VISIBILITY, FLOOR_VISIBILITY_LABELS } from '../../core/floors'; +import { activeFloor, useStore } from '../../state/store'; +import { addSavedView, removeSavedView } from '../../state/actions'; + +/** Per mode, the keys worth knowing. Shown rather than hidden behind a help icon. */ +const HELP: Record = { + orbit: 'Drag to orbit · scroll to zoom · Tab for walk mode', + walk: '↑↓ walk · ←→ turn · A/D strafe · Shift run · C crouch · Space step up · drag to look', + fly: '↑↓ fly · ←→ turn · R/F up and down · no collision · Tab to return to orbit', +}; + +/** + * The heads-up display over the 3D view. + * + * Plain DOM, and outside the WebGL error boundary on purpose. It reads the walker + * from the store rather than from the camera, so where you are standing is legible — + * and assertable in a test — whether or not a canvas ever came up. That is the same + * reasoning as the status bar under the plan: a renderer that draws to a bitmap needs + * a surface where its state becomes readable. + */ +export function SpaceHud({ poseRef }: { poseRef: RefObject }) { + const { doc, cameraMode, walker, showCeilings, floorVisibility } = useStore( + useShallow((s) => ({ + doc: s.doc, + cameraMode: s.cameraMode, + walker: s.walker, + showCeilings: s.showCeilings, + floorVisibility: s.floorVisibility, + })), + ); + const [naming, setNaming] = useState(false); + const [name, setName] = useState(''); + + const floor = activeFloor({ doc }); + const unit = doc.displayUnit; + const views = spaceViews(doc.savedViews); + const room = walker ? roomAt(floor, walker.position) : undefined; + + const save = () => { + // The camera pose if there is a renderer to ask, and the walker's own if there is + // not — a bookmark should not be a thing you can only make when WebGL works. + const pose: SpaceCamera | null = + poseRef.current ?? + (walker + ? { + position: { x: walker.position.x, y: walker.position.y, z: 1650 }, + target: { x: walker.position.x, y: walker.position.y - 1000, z: 1650 }, + mode: cameraMode, + } + : null); + if (!pose) return; + + addSavedView(name || (room ? room.name : 'View'), pose); + setName(''); + setNaming(false); + }; + + return ( +
    +
    + {CAMERA_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) => ( + + ))} + +
    + +
    + + {walker + ? `${formatLength(Math.round(walker.position.x), unit)}, ${formatLength( + Math.round(walker.position.y), + unit, + )}` + : 'Not placed'} + + {room ? room.name : 'Unbounded'} + {walker && walker.elevation > 0 ? ( + + standing on {formatLength(walker.elevation, unit)} + + ) : null} + {walker?.crouching ? crouching : null} +
    + +
    + {views.map((view) => ( + + + + + ))} + + {naming ? ( + <> + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') save(); + if (e.key === 'Escape') setNaming(false); + }} + /> + + + ) : ( + + )} +
    + +

    + {HELP[cameraMode]} +

    +
    + ); +} diff --git a/src/ui/space/SpaceScene.tsx b/src/ui/space/SpaceScene.tsx new file mode 100644 index 0000000..5ac823e --- /dev/null +++ b/src/ui/space/SpaceScene.tsx @@ -0,0 +1,105 @@ +import { useEffect, useMemo } from 'react'; +import * as THREE from 'three'; +import type { SceneModel, SceneSolid } from '../../core/scene'; +import type { SelectionRef } from '../../state/store'; +import { UPRIGHT, extrudePolygon, extrudeSlab } from './geometry'; + +type Props = { + scene: SceneModel; + /** Only this floor's solids take clicks — see the note in `onClick` below. */ + activeFloorId: string; + selection: SelectionRef[]; + showCeilings: boolean; + onSelect: (ref: SelectionRef, additive: boolean) => void; +}; + +const SELECTED_COLOR = '#2f6fed'; + +/** + * The meshes. + * + * Geometry is rebuilt only when the scene model changes — which, because the scene is + * derived from a document zustand replaces wholesale on every edit, means once per + * edit rather than once per frame. Old geometries are disposed on the way out; three + * allocates GPU buffers that React knows nothing about and will not collect. + * + * **Instancing is deliberately not done here.** PLAN.md §10.4 targets 500 placements + * at 60fps with repeated catalog items merged into single draw calls; this draws one + * mesh per solid. That is honest about what has been built rather than claiming a + * number nothing has measured, and the seam for instancing — a scene model that + * already groups by catalog item — is unchanged by it. + */ +export function SpaceScene({ scene, activeFloorId, selection, showCeilings, onSelect }: Props) { + const solids = useMemo( + () => scene.solids.map((solid) => ({ solid, geometry: extrudePolygon(solid.outline, solid.span) })), + [scene], + ); + + const slabs = useMemo( + () => + scene.slabs.map((slab) => ({ + slab, + geometry: extrudeSlab(slab.boundary, slab.elevationMm), + })), + [scene], + ); + + useEffect(() => { + return () => { + for (const { geometry } of solids) geometry.dispose(); + for (const { geometry } of slabs) geometry.dispose(); + }; + }, [solids, slabs]); + + const isSelected = (solid: SceneSolid) => + selection.some((s) => s.kind === solid.ref.kind && s.id === solid.ref.id); + + return ( + <> + {/* Flat, soft light. A single directional source leaves whole walls black at + the angles a room is actually viewed from. */} + + + + + {slabs.map(({ slab, geometry }) => + slab.kind === 'ceiling' && !showCeilings ? null : ( + + + + ), + )} + + {solids.map(({ solid, geometry }) => ( + { + // A solid on another floor is scenery. Selecting it would put something + // in the panel that the plan view — which edits one floor — cannot show, + // and that the delete key would then remove from a storey you are not on. + // + // Checked **before** stopping propagation, and the order is the whole + // point: R3F calls every intersected mesh in distance order until one + // stops it, so a scenery solid that stopped first and declined second + // would eat the click on the wall behind it. Looking down at a building + // with every floor shown, the top storey would swallow everything. + if (solid.floorId !== activeFloorId) return; + // Only the nearest hit: without this a click passes through a wall and + // selects everything behind it as well. + e.stopPropagation(); + onSelect({ kind: solid.ref.kind, id: solid.ref.id }, e.nativeEvent.shiftKey); + }} + > + + + ))} + + ); +} diff --git a/src/ui/space/SpaceView.tsx b/src/ui/space/SpaceView.tsx new file mode 100644 index 0000000..735cd6f --- /dev/null +++ b/src/ui/space/SpaceView.tsx @@ -0,0 +1,164 @@ +import { Component, useRef, type ErrorInfo, type ReactNode } from 'react'; +import { Canvas } from '@react-three/fiber'; +import { useShallow } from 'zustand/react/shallow'; +import { look } from '../../core/walk'; +import { refIsEditable } from '../../core/modes'; +import type { SpaceCamera } from '../../core/views'; +import { activeFloor, useStore, type SelectionRef } from '../../state/store'; +import { stackFor } from './scene-cache'; +import { CameraRig } from './CameraRig'; +import { SpaceHud } from './SpaceHud'; +import { SpaceScene } from './SpaceScene'; +import { useWalkLoop } from './useWalkLoop'; + +/** Degrees of rotation per pixel dragged. Slow enough to aim, fast enough to turn. */ +const LOOK_SENSITIVITY = 0.22; + +/** Movement under this is a click, not a drag — so looking around does not select. */ +const DRAG_THRESHOLD_PX = 4; + +/** + * A WebGL context can fail to come up — a blocklisted driver, a headless browser, a + * tab that has run out of contexts — and React Three Fiber signals that by throwing + * during render. Without a boundary that takes down the whole application, including + * the plan view, which is still perfectly usable. + */ +class CanvasBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { + override state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + override componentDidCatch(error: Error, info: ErrorInfo) { + console.error('3D view failed to start', error, info); + } + + override render() { + if (this.state.failed) { + return ( +
    +

    3D is unavailable here

    +

    + This browser could not start WebGL. The plan view and everything in it still + works, and the readout below still tracks where you are walking. +

    +
    + ); + } + return this.props.children; + } +} + +/** + * The 3D space view (PLAN.md §10). + * + * The layering is the point: the walk loop and the HUD sit *outside* the canvas + * boundary, so traversal, the position readout and the saved-view list all work when + * the renderer does not. The camera consumes the walker; it never owns it. + */ +export function SpaceView() { + const { doc, editMode, cameraMode, selection, showCeilings, floorVisibility } = useStore( + useShallow((s) => ({ + doc: s.doc, + editMode: s.editMode, + cameraMode: s.cameraMode, + selection: s.selection, + showCeilings: s.showCeilings, + floorVisibility: s.floorVisibility, + })), + ); + + const poseRef = useRef(null); + const drag = useRef<{ active: boolean; x: number; y: number; moved: number }>({ + active: false, + x: 0, + y: 0, + moved: 0, + }); + + useWalkLoop(true); + + const floor = activeFloor({ doc }); + const scene = stackFor(doc, floorVisibility); + + const onPointerDown = (e: React.PointerEvent) => { + if (cameraMode === 'orbit' || e.button !== 0) return; + drag.current = { active: true, x: e.clientX, y: e.clientY, moved: 0 }; + e.currentTarget.setPointerCapture(e.pointerId); + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (!drag.current.active) return; + const dx = e.clientX - drag.current.x; + const dy = e.clientY - drag.current.y; + drag.current = { + active: true, + x: e.clientX, + y: e.clientY, + moved: drag.current.moved + Math.abs(dx) + Math.abs(dy), + }; + + const store = useStore.getState(); + if (!store.walker) return; + // Dragging right turns right, dragging up looks up — the direction the scene + // moves under the pointer, which is what a first-person view has always done. + store.setWalker(look(store.walker, dx * LOOK_SENSITIVITY, -dy * LOOK_SENSITIVITY)); + }; + + const endDrag = (e: React.PointerEvent) => { + if (!drag.current.active) return; + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + drag.current = { ...drag.current, active: false }; + }; + + const select = (ref: SelectionRef, additive: boolean) => { + // A drag that happened to end on a mesh was a look, not a click. Without this, + // turning around selects whatever you happened to finish facing. + if (drag.current.moved > DRAG_THRESHOLD_PX) return; + + // The layer toggle is a property of the document, not of the renderer: structure + // locked in the plan view is locked here too. Without this, clicking a wall — or + // a door leaf — in furnish mode selects it and the panel offers to delete it. + if (!refIsEditable(editMode, ref.kind)) return; + + const store = useStore.getState(); + if (additive) store.toggleSelection(ref); + else store.setSelection([ref]); + }; + + return ( +
    +
    + + + + + + + +
    + + +
    + ); +} diff --git a/src/ui/space/geometry.ts b/src/ui/space/geometry.ts new file mode 100644 index 0000000..3580e01 --- /dev/null +++ b/src/ui/space/geometry.ts @@ -0,0 +1,64 @@ +import * as THREE from 'three'; +import type { Polygon } from '../../core/geometry/polygon'; +import type { Span } from '../../core/geometry/collision'; + +/** + * Document polygons to three.js geometry — the one place the axis swap happens. + * + * Document space is `x` east, `y` south, `z` up, in millimetres. three.js is Y-up in + * metres. `docToThree` states the mapping; this builds geometry that obeys it. + * + * `THREE.Shape` is always in the XY plane and `ExtrudeGeometry` always extrudes along + * local +z, so the mesh has to be turned to stand up. Rotating −90° about X sends + * local `(u, v, w)` to world `(u, w, −v)`: the extrusion becomes height, and the + * shape's `v` becomes world `−z`. Feeding the shape `−y` therefore lands document `y` + * on world `z` the right way round — **not** mirrored, which is the failure mode this + * is easy to ship with, because a mirrored room looks perfectly plausible until you + * notice the door is on the wrong side. + * + * The negated `v` also reverses the ring's winding, which earcut handles either way; + * `ExtrudeGeometry` computes its own normals, so nothing downstream depends on it. + */ + +const MM = 0.001; + +export function shapeFromPolygon(poly: Polygon): THREE.Shape { + const shape = new THREE.Shape(); + const pts = poly.pts; + const first = pts[0]!; + shape.moveTo(first.x * MM, -first.y * MM); + for (let i = 1; i < pts.length; i++) { + const p = pts[i]!; + shape.lineTo(p.x * MM, -p.y * MM); + } + shape.closePath(); + return shape; +} + +/** + * A polygon extruded between two elevations, positioned in world space. + * + * The geometry is translated so the mesh can sit at the origin with a fixed rotation + * — one `rotation` value shared by every solid, rather than a transform per mesh that + * has to be got right in three places. + */ +export function extrudePolygon(poly: Polygon, span: Span): THREE.ExtrudeGeometry { + const height = Math.max(span.top - span.bottom, 1) * MM; + const geometry = new THREE.ExtrudeGeometry(shapeFromPolygon(poly), { + depth: height, + bevelEnabled: false, + curveSegments: 1, + }); + // Local +z becomes world +y after the shared rotation, so shifting along local z + // is what lifts the box to its elevation. + geometry.translate(0, 0, span.bottom * MM); + return geometry; +} + +/** The rotation every extruded solid shares. See the module comment. */ +export const UPRIGHT: [number, number, number] = [-Math.PI / 2, 0, 0]; + +/** A flat slab — a room floor or ceiling — as a thin extrusion. */ +export function extrudeSlab(poly: Polygon, elevationMm: number, thicknessMm = 20): THREE.ExtrudeGeometry { + return extrudePolygon(poly, { bottom: elevationMm - thicknessMm, top: elevationMm }); +} diff --git a/src/ui/space/scene-cache.ts b/src/ui/space/scene-cache.ts new file mode 100644 index 0000000..19f2b41 --- /dev/null +++ b/src/ui/space/scene-cache.ts @@ -0,0 +1,61 @@ +import type { Floor, SpaceDocument } from '../../core/document'; +import { blockersOf, buildScene, buildStack, type SceneModel } from '../../core/scene'; +import { visibleFloors, type FloorVisibility } from '../../core/floors'; +import type { Volume } from '../../core/geometry/collision'; + +/** + * One built scene per document version. + * + * The walk loop needs collision geometry sixty times a second, and rebuilding every + * wall segment and placement outline each frame is the difference between a target of + * 500 placements and a slideshow. The cache lives here rather than in `core/scene.ts` + * because a module-level mutable cache is not something a pure geometry module should + * own — and because the correctness argument depends on the *store*: zustand replaces + * `doc` with a new object on every mutation, so reference equality is an exact test + * for "has anything changed". + * + * Deliberately one entry deep. Two documents are never live at once, and a growing + * cache would pin every intermediate document version an undo stack has produced. + */ +let cachedDoc: SpaceDocument | null = null; +let cachedFloorId: string | null = null; +let cachedScene: SceneModel | null = null; +let cachedBlockers: Volume[] | null = null; + +export function sceneFor(doc: SpaceDocument, floor: Floor): SceneModel { + if (cachedDoc === doc && cachedFloorId === floor.id && cachedScene) return cachedScene; + + cachedScene = buildScene(doc, floor); + cachedBlockers = null; + cachedDoc = doc; + cachedFloorId = floor.id; + return cachedScene; +} + +let cachedStackDoc: SpaceDocument | null = null; +let cachedStackKey: string | null = null; +let cachedStack: SceneModel | null = null; + +/** + * The stacked scene the space view draws. + * + * Kept apart from `sceneFor` rather than replacing it, because the walker must keep + * being fed the **active floor alone**: collision that followed a display setting + * would have you walking into walls you had only chosen to look at. Two consumers, + * two scenes, and `blockersFor` is untouched. + */ +export function stackFor(doc: SpaceDocument, visibility: FloorVisibility): SceneModel { + const key = visibility + ':' + doc.activeFloorId; + if (cachedStackDoc === doc && cachedStackKey === key && cachedStack) return cachedStack; + + cachedStack = buildStack(doc, visibleFloors(doc, visibility), doc.activeFloorId); + cachedStackDoc = doc; + cachedStackKey = key; + return cachedStack; +} + +export function blockersFor(doc: SpaceDocument, floor: Floor): Volume[] { + const scene = sceneFor(doc, floor); + cachedBlockers ??= blockersOf(scene); + return cachedBlockers; +} diff --git a/src/ui/space/useWalkLoop.ts b/src/ui/space/useWalkLoop.ts new file mode 100644 index 0000000..eebdd2d --- /dev/null +++ b/src/ui/space/useWalkLoop.ts @@ -0,0 +1,151 @@ +import { useEffect, useRef } from 'react'; +import { useStore } from '../../state/store'; +import { activeFloor } from '../../state/store'; +import { defaultStandpoint } from '../../core/scene'; +import { createWalker, stepWalker, type CameraMode, type WalkInput } from '../../core/walk'; +import { blockersFor, sceneFor } from './scene-cache'; + +/** + * The traversal loop — and it deliberately does **not** live inside three.js. + * + * `useFrame` would be the obvious home for this, and it is the wrong one. Putting the + * walk simulation inside the render loop makes walking a thing that only happens when + * a WebGL context exists: no context, no movement, and no way to test traversal + * without a GPU. Here it is a plain `requestAnimationFrame` over pure functions, so + * the camera is a *consumer* of the walker rather than its owner, the position + * readout keeps working when the canvas does not, and an end-to-end test can press + * ArrowUp and assert the walker moved. + * + * The store is written to only while a key is held. An idle walker produces no + * updates at all, so nothing subscribed to the walker re-renders while you are + * standing still. + */ + +function readInput(held: ReadonlySet, mode: CameraMode): WalkInput { + const on = (...keys: string[]) => keys.some((k) => held.has(k)); + const axis = (positive: string[], negative: string[]) => + (on(...positive) ? 1 : 0) - (on(...negative) ? 1 : 0); + + return { + forward: axis(['arrowup', 'w'], ['arrowdown', 's']), + strafe: axis(['d'], ['a']), + // Arrow left/right **turn**, which PLAN.md §10.2 assigned to strafing. Someone + // using only the arrow keys — which is the stated requirement — could otherwise + // never change direction, and would be stuck sliding along one axis forever. + // A and D strafe instead; Q and E also turn, for anyone who learned it that way. + turn: axis(['arrowright', 'e'], ['arrowleft', 'q']), + run: on('shift'), + crouch: on('c'), + stepUp: on(' '), + rise: mode === 'fly' ? axis(['r'], ['f']) : 0, + }; +} + +function isTypingTarget(target: EventTarget | null): boolean { + const el = target as HTMLElement | null; + if (!el) return false; + return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable === true; +} + +const MOVEMENT_KEYS = new Set([ + 'arrowup', + 'arrowdown', + 'arrowleft', + 'arrowright', + 'w', + 'a', + 's', + 'd', + 'q', + 'e', + 'r', + 'f', + 'c', + ' ', + 'shift', +]); + +export function useWalkLoop(active: boolean): void { + const held = useRef>(new Set()); + const frame = useRef(0); + const last = useRef(0); + + useEffect(() => { + if (!active) { + held.current.clear(); + return; + } + + const onKeyDown = (e: KeyboardEvent) => { + if (isTypingTarget(e.target)) return; + const key = e.key.toLowerCase(); + + if (key === 'tab') { + e.preventDefault(); + const store = useStore.getState(); + const order: CameraMode[] = ['orbit', 'walk', 'fly']; + const next = order[(order.indexOf(store.cameraMode) + 1) % order.length]!; + store.setCameraMode(next); + return; + } + if (!MOVEMENT_KEYS.has(key)) return; + // Arrows and space scroll the page; a walker who scrolls the app away while + // walking forward is not walking anywhere. + e.preventDefault(); + held.current.add(key); + }; + + const onKeyUp = (e: KeyboardEvent) => held.current.delete(e.key.toLowerCase()); + // A window that loses focus never delivers the keyup, leaving the walker + // sprinting into a wall until something else is pressed. + const onBlur = () => held.current.clear(); + + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + window.addEventListener('blur', onBlur); + return () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + window.removeEventListener('blur', onBlur); + }; + }, [active]); + + useEffect(() => { + if (!active) return; + last.current = performance.now(); + + const tick = (now: number) => { + frame.current = requestAnimationFrame(tick); + const dt = (now - last.current) / 1000; + last.current = now; + + const store = useStore.getState(); + if (store.cameraMode === 'orbit') return; + + const input = readInput(held.current, store.cameraMode); + const idle = + input.forward === 0 && + input.strafe === 0 && + input.turn === 0 && + input.rise === 0 && + input.crouch === (store.walker?.crouching ?? false); + if (idle && store.walker) return; + + const floor = activeFloor(store); + // Cached on the document's identity, so a frame of walking costs a lookup + // rather than a rebuild of every wall segment in the room. + const walker = + store.walker ?? createWalker(defaultStandpoint(floor, sceneFor(store.doc, floor))); + + store.setWalker( + stepWalker(walker, input, dt, { + blockers: blockersFor(store.doc, floor), + mode: store.cameraMode, + }), + ); + }; + + frame.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame.current); + }, [active]); +} 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; + } +} diff --git a/src/ui/useWalkwayProbe.ts b/src/ui/useWalkwayProbe.ts new file mode 100644 index 0000000..4b6eeae --- /dev/null +++ b/src/ui/useWalkwayProbe.ts @@ -0,0 +1,30 @@ +import { useMemo } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { activeFloor, useStore } from '../state/store'; +import { narrowestGap, walkwayObstructions, type WalkwayProbe } from '../core/walkway'; + +/** + * The narrowest gap along the walkway path, recomputed whenever the plan changes. + * + * The store holds the *path*, not the answer, so this is derived rather than cached: + * drag the sofa 100mm and the number moves with it. That is the whole reason a probe + * is worth having over a measurement — it stays true while you rearrange, instead of + * describing a room you have since changed. + * + * Cheap enough to derive in two places. Two rays per sample over the walls and the + * furniture solid at body height is a few thousand line intersections at worst, and + * `useMemo` keeps it off every render. + */ +export function useWalkwayProbe(): { path: readonly { x: number; y: number }[] | null; probe: WalkwayProbe | null } { + const { doc, walkway } = useStore( + useShallow((s) => ({ 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 }; +} 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: {