Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
81 changes: 62 additions & 19 deletions PLAN.md

Large diffs are not rendered by default.

43 changes: 7 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

Spatial planning for real rooms. Bring a floor plan — a PDF, an image, or nothing at
all — trace it, build an inventory of the things you own, place them, and walk through
the result in 3D. Phases 0–9 of [PLAN.md](./PLAN.md) are built: import and calibration,
the plan editor, inventory and placement, the space view, door swing, clearance and
circulation, room detection and floor stacking, save-in-place and crash recovery.
the result in 3D. Import and calibration, the plan editor, inventory and placement, the
space view, door swing, clearance and circulation, room detection and floor stacking,
save-in-place and crash recovery.

**One decision explains most of the rest of it.** Every object carries a real height and
a real base elevation, and the geometry is three-dimensional everywhere rather than a
Expand Down Expand Up @@ -136,8 +136,9 @@ pnpm dev # http://localhost:5190
| `pnpm preview` | Serve the production build — no lookup endpoint, like a static deploy |
| `pnpm typecheck` | `tsc --noEmit` |
| `pnpm lint` | ESLint |
| `pnpm test` | Vitest — 765 unit tests |
| `pnpm test` | Vitest — 768 unit tests |
| `pnpm test:watch` | Vitest in watch mode |
| `pnpm bench` | Time the geometry passes at 500 placements — see PLAN.md §10.4 |
| `pnpm e2e` | Playwright — 124 end-to-end tests, against a production build |
| `pnpm e2e:install` | One-time Playwright browser install |
| `pnpm media` | Redraw every picture in this README (needs `ffmpeg`) |
Expand All @@ -155,7 +156,8 @@ src/
└── styles/ global CSS
e2e/ Playwright specs
media/ the capture script behind docs/media
PLAN.md architecture, decisions, and the phasing table
PLAN.md architecture, decisions, the phasing table, and what is
deliberately out of scope for v1
```

`src/core/` is free of React and of any renderer. The geometry engine is pure functions
Expand All @@ -178,37 +180,6 @@ three.

---

## What is not built, and what is not measured

Read this before believing anything above is finished.

**Not built, and not planned for v1.** Dragging a room boundary directly: rooms and their
walls are separate entities and moving one without the other desynchronises them, so the
gesture that does not exist is the one that would break. Move the walls and press Detect
rooms instead. Windows do not open — a casement sash would swing like a door and is not
implemented. Floors can only be added at the ends of the stack. A detected room is a
simple ring, so an island of walls inside one does not punch a hole in it. PDF vector path
extraction is v2: a PDF is rasterised and traced by hand.

**Built, and known to be approximate.** The walkway probe reports the narrowest gap *at a
sample*, not the true infimum — the medial-axis navmesh that would give the real answer is
explicitly out of scope. Collision is resolved by retrying a move per axis rather than
against a contact normal, so a walker slides stickily along a diagonal wall. The product
lookup's confidence flag records whether any dimension was accepted exactly as scraped; it
records nothing about whether the page was right. DNS rebinding between the endpoint's
address check and its connect is open, because `fetch` will not pin a socket.

**Not measured.** There are no performance numbers here, because none have been taken.
PLAN.md §10.4 sets a target of 500 objects at 60fps; the space view currently builds one
mesh per solid with no instancing, and nobody has run that test. The pictures on this page
are captured in headless Chromium on a software rasteriser, so they demonstrate what the
app draws and say nothing at all about how fast it draws it.

**Synthetic.** The product-page fixtures the lookup parser is tested against are written
by hand, not captured from real retailers, and the sample plan in the calibration
screenshot is drawn by the capture script. Neither has been run against a real shop or a
real estate agent's PDF.

## The pictures

Every image above is generated by driving the real application — `media/capture.spec.ts`,
Expand Down
5 changes: 2 additions & 3 deletions e2e/floors.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test, type Page } from '@playwright/test';
import { clickAt, dragBetween, selectTool } from './coords';
import { disableSaveInPlace } from './save';
import { clickSpaceCentre } from './space';

/**
* Multiple floors — PLAN.md §11.
Expand Down Expand Up @@ -233,9 +234,7 @@ test.describe('clicking through the stack in 3D', () => {
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 clickSpaceCentre(page);

await expect(page.getByTestId('wall-properties')).toBeVisible();
});
Expand Down
9 changes: 3 additions & 6 deletions e2e/space.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test, type Page } from '@playwright/test';
import { clickAt, dragBetween, selectTool } from './coords';
import { disableSaveInPlace } from './save';
import { clickSpaceCentre } from './space';

test.beforeEach(async ({ page }) => {
await page.goto('/');
Expand Down Expand Up @@ -207,11 +208,9 @@ test.describe('the layer toggle', () => {
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 clickSpaceCentre(page);

await expect(page.getByTestId('wall-properties')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Delete', exact: true })).toHaveCount(0);
Expand All @@ -220,9 +219,7 @@ test.describe('the layer toggle', () => {
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);
await clickSpaceCentre(page);

// 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.
Expand Down
52 changes: 52 additions & 0 deletions e2e/space.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { expect, type Locator, type Page } from '@playwright/test';

/**
* The 3D canvas, once the renderer has taken its size.
*
* Clicking "the middle of the canvas" is two measurements, and both of them start out
* wrong. A `<canvas>` with no width or height attributes lays out at 300 x 150 until
* something sizes it, so a bounding box taken on sight describes a rectangle in the
* corner of the cell rather than the canvas the user sees. And React Three Fiber keeps
* its *own* record of that size, taken from a ResizeObserver a frame or two later,
* which is what it divides a pointer offset by to get normalised device coordinates —
* so a click that is dead centre of the element is off the edge of the frustum until
* the renderer has caught up, and hits nothing at all.
*
* Both were live in `floors.spec.ts` and `space.spec.ts`, and between them they made a
* raycast test fail about half the time on this machine. Neither is a product bug: the
* scene, the camera and the click handling are deterministic — eight runs of the
* failing test produced eight byte-identical canvas screenshots. Only the arithmetic
* that turned "the middle" into a screen pixel was done against the wrong numbers.
*
* The gate is the drawing buffer. `gl.setSize` writes `canvas.width` from the size
* React Three Fiber has measured, so a buffer at least as wide as the element is proof
* that the observer has fired and the raycaster is dividing by the right number. It is
* `>=` rather than `===` because the buffer is multiplied by the device pixel ratio.
*/
export async function spaceCanvas(page: Page): Promise<Locator> {
const canvas = page.locator('.space__canvas canvas');
await expect(canvas).toBeVisible();
await expect
.poll(
async () =>
canvas.evaluate((el) => {
const c = el as HTMLCanvasElement;
return c.clientWidth > 0 && c.width >= c.clientWidth;
}),
{ message: 'the 3D renderer never took the size of its canvas' },
)
.toBe(true);
return canvas;
}

/**
* Click the middle of the 3D view.
*
* Every 3D selection test wants this and none of them wants to own the measurement,
* which is how the same mistake ended up in three places.
*/
export async function clickSpaceCentre(page: Page): Promise<void> {
const canvas = await spaceCanvas(page);
const box = (await canvas.boundingBox())!;
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"bench": "vitest bench --run",
"test:watch": "vitest",
"e2e": "playwright test",
"e2e:install": "playwright install --with-deps chromium",
Expand Down
26 changes: 19 additions & 7 deletions src/core/geometry/vec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,32 @@ export function equals(a: Vec2, b: Vec2, tolerance = 0): boolean {
}

/**
* Perpendicular distance from a point to a **segment**, not to the infinite line.
* The point on a **segment** nearest to `p` — not on the infinite line, so the ends
* are the answer when the foot of the perpendicular falls beyond them.
*
* 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.
* Split out from `distanceToSegment` because the walker needs the point and not only
* the distance: sliding along a surface means knowing which way that surface faces,
* and the direction from the nearest point to the body is exactly that.
*/
export function distanceToSegment(p: Vec2, a: Vec2, b: Vec2): number {
export function closestPointOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 {
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);
if (lenSq === 0) return 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 });
return { x: a.x + t * dx, y: a.y + t * dy };
}

/**
* 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 {
return distance(p, closestPointOnSegment(p, a, b));
}

export function toDegrees(radians: number): number {
Expand Down
113 changes: 113 additions & 0 deletions src/core/scene.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { bench, describe } from 'vitest';
import { createCatalogItem } from './catalog';
import { findCollisions } from './geometry/collision';
import { createDocument, type Floor, type SpaceDocument } from './document';
import { blockersOf, buildScene, buildStack } from './scene';
import { commitRoomRect } from './tools';
import { validateFloor } from './validation';
import { NO_INPUT, createWalker, stepWalker } from './walk';

/**
* What PLAN.md §10.4's target can honestly be checked against without a GPU.
*
* The target is 500 placements at 60fps, and most of what decides that is the
* renderer — draw calls, culling, whether repeated catalog items are instanced. None
* of it can be measured here, and a number taken from a software rasteriser in a
* headless browser would read as a frame rate while measuring nothing of the sort.
*
* What *is* measurable is everything the renderer is handed, and one thing that runs
* inside the frame. `buildScene` and `validateFloor` run once per edit;
* `stepWalker` runs sixty times a second against the cached blocker list, so it is
* the only figure here that comes out of the 16.7ms budget.
*
* Three densities, because placement count turns out not to be the variable that
* matters — the number of *overlapping pairs* is, and those are two very different
* numbers. Five hundred items on a 1.1m pitch is a furnished floor; on a 520mm pitch
* every item touches its neighbours, which is a floor with a problem on it; on a
* 120mm pitch they are piled on each other, which is not a plan anyone drew.
*
* Run with `pnpm bench`. Not part of `pnpm test` and not in CI: a timing that gates a
* merge fails on whatever else the machine was doing.
*/

const PITCHES = {
/** Furnished: a metre of clear floor around everything. */
sparse: 1100,
/** Every item against its neighbours. A floor the validation panel has notes on. */
touching: 520,
/** Piled. Not a plan, and here to show where the cost actually comes from. */
piled: 120,
} as const;

const COUNT = 500;

let seq = 0;
const id = () => `id-${seq++}`;

function floorOf(pitchMm: number): { doc: SpaceDocument; floor: Floor } {
seq = 0;
const doc = createDocument({ id: 'd', floorId: 'f', now: '2026-01-01T00:00:00.000Z' });
const built = commitRoomRect({ x: 0, y: 0 }, { x: 40000, y: 32000 }, { name: 'Hall', makeId: id })!;
const floor = doc.floors[0]!;
floor.rooms.push(built.room);
floor.walls.push(...built.walls);

// Six catalog items over five hundred placements — the repetition §10.4's
// instancing note is about, and the reason the scene model groups by item.
const items = ['Chair', 'Table', 'Shelf', 'Lamp', 'Rug', 'Box'].map((name, i) =>
createCatalogItem(
{ name, category: 'other', widthMm: 500 + i * 40, depthMm: 500, heightMm: 700, shape: 'rect' },
id(),
),
);
doc.catalog.push(...items);

const cols = Math.ceil(Math.sqrt(COUNT));
for (let k = 0; k < COUNT; k++) {
floor.placements.push({
id: id(),
itemId: items[k % items.length]!.id,
floorId: 'f',
position: { x: 900 + (k % cols) * pitchMm, y: 900 + Math.floor(k / cols) * pitchMm },
rotation: 0,
mount: { kind: 'floor' },
elevation: 0,
});
}
return { doc, floor };
}

for (const [label, pitchMm] of Object.entries(PITCHES)) {
describe(`${COUNT} placements, ${label}`, () => {
const { doc, floor } = floorOf(pitchMm);
const scene = buildScene(doc, floor);
const blockers = blockersOf(scene);
const walker = createWalker({ x: 20000, y: 16000 }, 45);
const world = { blockers, mode: 'walk' as const };

bench('buildScene', () => {
buildScene(doc, floor);
});

bench('buildStack', () => {
buildStack(doc, [floor], floor.id);
});

bench('blockersOf', () => {
blockersOf(scene);
});

bench('findCollisions', () => {
findCollisions(blockers);
});

bench('validateFloor', () => {
validateFloor(doc, floor);
});

// The frame. Everything above is per edit.
bench('stepWalker', () => {
stepWalker(walker, { ...NO_INPUT, forward: 1 }, 1 / 60, world);
});
});
}
Loading
Loading