Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7d90b5f
Rename roomplan to floorplan
chntnm Sep 2, 2026
70124b8
Phase 3: import and the calibration gate
chntnm Sep 2, 2026
71be408
Share the e2e coordinate contract, pin the coalescing boundary
chntnm Sep 2, 2026
00840fe
Phase 4: inventory, placement, and 3D overlap warnings
chntnm Sep 2, 2026
9c27d85
Do not offer mounts that cannot be reached, and cover the drag-to-wal…
chntnm Sep 2, 2026
9f91aeb
Openings: cut real doorways, and the wall geometry they leave behind
chntnm Sep 2, 2026
5a00740
Phase 5: the space view, and walking through it
chntnm Sep 2, 2026
837fd4a
Do not unmount a dragged item, and honour the layer toggle in 3D
chntnm Sep 2, 2026
76891cc
Phase 6: hang the leaf, and keep what it needs clear
chntnm Sep 2, 2026
8cab3eb
A clamped field cannot be typed into one keystroke at a time
chntnm Sep 2, 2026
69a9915
Phase 7: what has to stay clear, and whether a person fits
chntnm Sep 3, 2026
d7aee04
A lamp on the dresser is not blocking the dresser
chntnm Sep 3, 2026
d313376
Phase 8a: which of these walls enclose a room
chntnm Sep 3, 2026
95cb86b
Phase 8b: floors that stack, and a floor below showing through
chntnm Sep 3, 2026
353ede8
Five defects a review pass found in the floor stack
chntnm Sep 3, 2026
513a0df
Phase 9a: saving in place, and getting work back after a crash
chntnm Sep 3, 2026
d44f86e
The autosave deadline a review pass found never firing
chntnm Sep 3, 2026
c3d3677
Phase 9b: reading a product page, and not trusting what it says
chntnm Sep 3, 2026
ab3b8ee
Two gaps a review pass found around the lookup endpoint
chntnm Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
442 changes: 400 additions & 42 deletions PLAN.md

Large diffs are not rendered by default.

29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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

Expand Down
42 changes: 42 additions & 0 deletions api/product-lookup.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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,
});
}
190 changes: 190 additions & 0 deletions e2e/clearance.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
68 changes: 68 additions & 0 deletions e2e/coords.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Loading
Loading