All projects

Spatial computing / applied ML systems · 2026

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 living room with a grey sofa, glass coffee table, green striped rug, walnut sideboard and shelving, and a white armchair.

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

  1. 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.
  2. 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.
  3. 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

Architectural floor plan. Solid green walls enclose a room labelled 5800 millimetres by 4200 millimetres. Two windows are drawn as triple parallel lines and one door as a leaf with a dashed swing arc. A grey filled rectangle marks a fixed bookcase. Detected furniture is outlined in grey, dashed where the scan's classification was uncertain, and labelled sofa, table, chair, storage, television and unknown. On the left wall a short rust dashed segment is annotated 400 millimetre gap, never observed.
Figure 1. The sample room as Haven rebuilds it from a scan: measured walls and openings, and a 400 mm stretch of wall it marks as unknown rather than guess.

Scan to shoppable result

Real module and service names under every stage. Rust marks what is not finished.

  1. 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

  2. 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

  3. 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

  4. 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

  5. 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

  6. 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

  7. 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)

  8. 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.

Figure 2. The eight stages from room scan to furnishing plan. The dashed loop marks a feedback step that has not been built.
Left, the room plan with eight numbered green circles on sourced furniture footprints and three grey K circles on kept objects. Right, a list of the eight matched products giving name, listed width by depth by height in millimetres, retailer labelled DEMO_FIXTURE, a rust word unconfirmed, and the geometry engine's own result with a clearance figure. Below, a rust bordered box explaining that the engine returns fit probable but a partly unobserved boundary forces unconfirmed, then three catalogue rows refused before ranking for unknown dimensions, collision, and no stated UAE delivery.
Figure 3. Each chosen piece numbered on the plan, with its retailer, listed size and fit result, and why every fit currently reads unconfirmed.
Floor plan of the scanned room with walls, windows, a door, a fixed bookcase and seven grey furniture outlines, captioned BEFORE the capture as normalized.
Figure 4. Before: the scanned room as captured, with seven objects detected and nothing chosen or moved.
The same floor plan at the same scale and viewpoint, now with eight green furniture outlines labelled sofa, rug, coffee table, armchair, console, mirror, side table and floor lamp, plus three dashed grey outlines for kept objects.
Figure 5. After: the same room from the same viewpoint, with eight products placed and three existing pieces kept.

Technical details

The full list of measured results behind this project.

Show all 13 measurements
MeasurementValue
End-to-end journey smoke, local server94 assertions passed, 0 failed
Deterministic unit suite1329 tests, 1265 pass, 0 fail, 64 skipped (Miniflare-gated)
Room reconstructed from the RoomPlan capture5800 × 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 normalizer1 FIXED, 2 MOVABLE, 4 UNCERTAIN; 1 RoomPlan category outside Haven's mapping, preserved verbatim
One automatic design run11 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 returnedDIMENSIONS_UNKNOWN (depth unresolved), OBJECT_OVERLAP, SHIPPING_UNAVAILABLE, CATEGORY_MISMATCH
Fit engine on the sourced pieces8 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 impossiblesofa 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 producervisual (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 disk1,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 positionUNREVIEWED 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.