Haven: room scan to fit-checked furnishing plan
An app that turns an iPhone room scan into a millimetre-accurate floor plan and a furnishing plan, checking every piece against the measured room. A prototype, tested on a sample scan.
- Context
- Independent project
- Year
- 2026
- Stack
- TypeScript, Cloudflare Workers + D1, Apple RoomPlan, Swift / Capacitor, OpenAI gpt-image-2
110
sofa positions checked for fit in one sweep
99 accepted, 11 refused for collisions or leaving the room
1,329
automated unit tests, none failing
1,265 pass, 64 skipped
94/94
end-to-end checks of the full user journey passing
run on a local server
A furnished living room: the kind of result Haven designs toward.
Overview
Problem
Furnishing a room online means guessing whether a sofa will fit. Haven starts from a measured scan of the room and only suggests pieces it can check against it, drawn by design from UAE retailer catalogues.
Approach
- An iPhone app scans the room with Apple RoomPlan, and the backend turns the scan into a millimetre-accurate floor plan, with each object marked fixed, movable or uncertain.
- Before ranking furniture, a filter can reject a product for 23 named reasons, such as missing dimensions or no delivery.
- A fit checker tests each placement for collisions and for running outside the room.
- The backend is written in TypeScript on Cloudflare Workers, and designs are drawn by a built-in renderer.
Results
On a hand-made sample scan, one automatic run placed 8 products and kept 3 existing objects. The fit checker found all 8 likely to fit, but 400 mm of one wall had never been scanned, so Haven tells the user "fit unknown" rather than overclaim. Sliding one sofa through 110 positions, it accepted 99 and refused 11 for hitting another object or leaving the room.
All 94 end-to-end checks pass, and 1,265 of 1,329 unit tests pass, with 64 skipped and none failing. Still missing is real-world input: no scan from a real device, no licensed catalogue, no AI-generated room image, and no measure of match quality.
What I built
- Built the step that turns a room scan into millimetre geometry, marks unscanned stretches of wall as unknown, and labels objects as fixed, movable or uncertain.
- Wrote a fit checker that tests every placement for collisions and room boundaries, and will not confirm a fit against walls the scan never saw.
- Built furniture filtering with 23 named rejection reasons covering dimensions, delivery and listing rights; every rejected product comes back with its reason.
Results in detail
Scan to shoppable result
Real module and service names under every stage. Rust marks what is not finished.
1Scan
Apple RoomPlan (iOS 16+, LiDAR)
CapturedRoomProjection.swift
schema haven-captured-room-subset@1
Metres, column-major transforms, ARKit +Y up. Per surface: dimensions, confidence, completedEdges.
built, never run on a LiDAR device
2Room model and dimensions
server/capture-normalize.ts
method haven-capture-envelope@1
Metres to integer millimetres; floor polygon from observed wall endpoints only. A gap is a labelled hypothesis, never a wall. A curved surface is refused, not flattened.
working
3Theme and brief
packages/themes (8 themes)
server/brief-contract.ts
A theme is data: palette, material and colour tokens, shapes, and a categoryPlan saying what to buy. Answers are stored as USER_INTENT evidence.
working
4Product selection
packages/catalog
server/catalog-provider.ts
Cloudflare D1
23 hard filter codes run first; a score can never compensate. Survivors ranked over 8 weighted dimensions with a per-dimension explanation and a rejection trace.
working; visual + shape dimensions report MISSING
5Placement and fit
packages/geometry evaluatePlacementFit
packages/capture clampFitStatusForRoomGeometry
Containment, oriented overlap, clearance, ceiling. Tolerances propagate into combinedUncertaintyMm. A partial envelope collapses any positive fit to UNKNOWN.
working
6Scene revision
packages/scene
packages/budget
packages/procurement
Immutable snapshot plus append-only events. Locks are hard. Budget totals stay split by evidence class. Export is bound to one exact revision.
working
7Visualisation
packages/render → haven-svg-composite@1
OpenAI gpt-image-2 · POST /v1/images/edits
The deterministic SVG composite always renders. The model receives a plan guide bitmap, up to 12 product photos and the scene as JSON — no depth or edge map.
SVG working; model unconfigured (no API key)
8Shoppable result
/designs/:id/items · /alternatives · /export
Every row carries retailer, dimensions, fit status and a rights label. Swapping one piece writes a new revision.
working; every row currently DEMO_FIXTURE
The dashed path does not exist.
Nothing in this system looks at the generated image. Products are chosen at stage 4 and become the model's reference inputs at stage 7 — the reverse of detect-then-retrieve.
packages/ports declares an ObjectDetector with no implementation; signals.visualSimilarity has no producer.
One model is called anywhere in the repository: gpt-image-2, at server/gpt-image-provider.ts.
Technical details
The full list of measured results behind this project.
Show all 13 measurements
| Measurement | Value |
|---|---|
| End-to-end journey smoke, local server | 94 assertions passed, 0 failed |
| Deterministic unit suite | 1329 tests, 1265 pass, 0 fail, 64 skipped (Miniflare-gated) |
| Room reconstructed from the RoomPlan capture | 5800 × 4200 mm; 6 wall segments (5 observed, 1 hypothesis); 3 openings; 1 fixed obstacle; 7 detected objects; 11 issues raised |
| Objects classified by the capture normalizer | 1 FIXED, 2 MOVABLE, 4 UNCERTAIN; 1 RoomPlan category outside Haven's mapping, preserved verbatim |
| One automatic design run | 11 scene nodes — 8 products sourced, 3 objects kept and locked; AED 13,940.00 fixture-reported; delivery AED 605.00 known on 8 lines, assembly AED 750.00 known on 5 lines, 3 unknown |
| Catalogue retrieval for one node (sofa) | 14 rows considered, 8 eligible after dimension and rights filters, 6 ranked, 10 refused; pool read in 3 ms |
| Refusal reasons actually returned | DIMENSIONS_UNKNOWN (depth unresolved), OBJECT_OVERLAP, SHIPPING_UNAVAILABLE, CATEGORY_MISMATCH |
| Fit engine on the sourced pieces | 8 of 8 return FIT_PROBABLE from evaluatePlacementFit; 8 of 8 are reported to the user as FIT_UNKNOWN after clampFitStatusForRoomGeometry |
| Fit engine refuses when geometry is impossible | sofa widened 2360 → 6400 mm: OUTSIDE_ROOM_BOUNDARY + OBJECT_OVERLAP; sofa turned across the fixed bookcase: OBJECT_OVERLAP. Refusal survives the clamp. |
| Fit engine across a 4.87 m sweep (the clip) | 110 positions evaluated: 99 FIT_PROBABLE, 11 refused (OBJECT_OVERLAP, then OUTSIDE_ROOM_BOUNDARY) |
| Ranking dimensions with no producer | visual (weight 24) and shape (weight 14) both report state MISSING with contribution 0; score is carried by availability and delivery at weight 9 each |
| Catalogue corpus on disk | 1,871 scraped rows across ikea-ae (799), danubehome-ae (650), 2xl-ae (220), homesrus-ae (200), local-demo (2), plus a 59-row synthetic feed. 67.8% of scraped rows carry all three dimensions. |
| Retailer terms position | UNREVIEWED for all five scraped sources; 0 licensed rows exist, so the default posture shows only DEMO_FIXTURE data |
Limitations (9)
- No real LiDAR scan exists. Every room in these assets comes from fixtures/captures/sample-roomplan-furnished.json, a hand-authored RoomPlan-shaped fixture labelled DEMO_FIXTURE in the repository itself. The iOS RoomScan plugin, the CapturedRoomProjection encoder, the HTTP route, the decoder and the normalizer are all implemented, but no physical LiDAR device has ever produced a capture for this project.
- No AI-generated interior image could be produced. OPENAI_API_KEY is not configured on this machine, so GET /designs/:id/presentation returns configured:false, status:ABSENT, and the UI says "AI presentations are not connected for this demo." The 'after' images here are the deterministic SVG renderer (haven-svg-composite@1), which is what the product actually shows without a key.
- Furniture matching is not visual. There is no object detector, no embedding index and no image analysis anywhere in the repository: packages/ports declares an ObjectDetector interface with zero implementations, and CatalogCandidate.signals.visualSimilarity has no producer. Products are selected by hard filters plus token-overlap ranking against theme vocabulary, and the image is generated from them afterwards.
- The fit check is implemented — and in these assets it never returns a positive answer. The geometry engine returns FIT_PROBABLE for all eight pieces, but the sample scan leaves 400 mm of one wall unobserved, so clampFitStatusForRoomGeometry collapses every positive claim to FIT_UNKNOWN. This is the system behaving correctly, and it is also the honest state of the demo: no shipped configuration can currently show a confirmed fit.
- No licensed product data. 1,871 rows were scraped from four UAE retailers for an internal proof of concept; every source's terms position is UNREVIEWED, and the default rights posture excludes them entirely. Every product in these assets is a synthetic DEMO_FIXTURE row authored in this repository.
- No retailer product photograph appears in any asset, by rule; the fixture feed's placeholder images also fail the pre-render availability probe, so all eight sourced pieces render as proxy geometry rather than packshots.
- The clip is a diagram animation, not a screen recording. Each frame's verdict, clearance and violation codes are genuine outputs of evaluatePlacementFit, but the visual is drawn by portfolio/make-clip.ts, not captured from the running app.
- Nothing is deployed. The project has never been pushed or released; every number here comes from a local dev server and a local D1.
- Match quality is unmeasured. There is no labelled set, no retrieval benchmark and no hand-rated sample in the repository, so no accuracy figure is reported.