diff --git a/.gitignore b/.gitignore index 5071c3f..d6fdcfd 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ Thumbs.db # The frozen schema-1 container. Checked in on purpose: a migration fixture that # is regenerated from current code proves nothing. !src/core/fixtures/**/*.space + +# Capture intermediates — full-size stills and GIF frames. +media/.work/ diff --git a/README.md b/README.md index 60e448a..39fae66 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,65 @@ # 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. +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 document is fully three-dimensional. Every object carries a real height and a base -elevation, so a rug under a table is not a collision, a wall shelf at 1400mm does not -block a desk at 750mm, and a 2100mm bookcase under a 2050mm soffit is a violation the -app catches. +**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 +plan with a height column bolted on. A rug under a coffee table is not a collision. A +wall shelf at 1400mm does not block a desk at 750mm. A 2100mm bookcase under a 2050mm +soffit is a violation the app catches. You walk *over* the rug, *under* the doorway's +lintel and *into* the dresser, with no special case for any of them — the walker is a +vertical interval tested against other vertical intervals, and a doorway is passable +because the only solid above it starts at 2032mm. -> **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). +Everything below follows from that, and from one more thing: the plan view is where you +build and the space view is where you find out. Neither is the real one. Both read and +write the same document. -## Walking around +| View | For | Renderer | +|------|-----|----------| +| **Plan**, 2D top-down | authoring — tracing, dimensioning, placing, measuring | Konva | +| **Space**, 3D | verification — sightlines, headroom, how it actually feels | three.js | -Press **Space** in the view switcher, then **Tab** to cycle orbit → walk → fly. +--- + +## The plan view + +![The plan editor: a two-room flat traced over, furnished, with the validation panel on the right](docs/media/plan.png) + +Draw walls and let detection find the rooms, or trace rooms directly. Cut doors and +windows into walls by clicking the wall. Everything is integer millimetres; the display +unit is a view preference and never touches a calculation. + +Tools are `V` select, `W` wall, `R` room, `O` opening, `S` shape, `D` dimension, +`P` walkway probe. `G` toggles the grid, `Alt` suppresses snapping while held, `[` and +`]` rotate the selection in 15° steps, `Enter` finishes a wall chain, `Ctrl+Z` undoes. +One drawn wall is one undo, not the several hundred mouse-move events that drew it. + +## The same document in 3D + +![The space view: the flat extruded, seen in orbit mode](docs/media/space.png) + +The 3D view is derived, never authored. Openings are cut in *elevation*, not in plan, so +a wall with a door in it is not a wall with a hole — it is the four solid boxes that +remain around the opening: flank, sill wall, lintel, flank. The same list of boxes is +handed to the renderer, to the walker and to the validation panel, which is what stops +the drawing and the collision test disagreeing about where the doorway is. + +## Walking through it + +![Walking from the living room through the interior doorway into the bedroom](docs/media/walk.gif) + +Click **Space** in the top bar, then **Walk** in the view HUD, or press `Tab` to cycle +orbit → walk → fly. The readout names the room you are standing in and what you are +standing on — in that clip, a 10mm rug. | Key | Does | |-----|------| +| `Tab` | cycle orbit → walk → fly | | `↑` `↓` / `W` `S` | walk forward and back | | `←` `→` / `Q` `E` | turn | | `A` `D` | strafe | @@ -29,8 +69,58 @@ Press **Space** in the view switcher, then **Tab** to cycle orbit → walk → f | `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. +The walk simulation deliberately lives *outside* three.js: a plain `requestAnimationFrame` +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. + +## Getting a plan in + +![The calibration gate over a freshly imported plan, with everything else disabled](docs/media/calibrate.png) + +Import a PDF or an image and the app blocks until you tell it the scale: drag a line +across something whose real length you know, then type that length. Nothing downstream +can correct a plan with no scale, so nothing downstream is offered — the tools grey out, +and `addPlacement` throws rather than accepting an item into a document that cannot say +how big it is. + +An uncalibrated background is shown at a nominal 6m width, so the reference line is +drawable before a real scale exists. Calibrating rescales about the point you anchored +on, so that point does not move. Import does not re-fit the viewport, on purpose. + +## Inventory + +Enter items by hand, or start from the preset library — real published sizes, a US queen +mattress at 1524 × 2032mm because it is 60" × 80". Every field parses any unit: `1.8m`, +`30"`, `2' 6"`, or a bare number in the document's display unit, echoed back so you can +see what it understood. + +![The confirmation dialog for a product URL, showing the text the dimensions were read from](docs/media/lookup.png) + +A product URL can be looked up instead, and the answer is never taken on trust. The +parser refuses a unitless number. What was scraped is shown next to the fields it filled, +along with the text it came from, and nothing enters the inventory until you press Add. +The lookup endpoint runs server-side only; with it absent the app is fully functional and +says so, which is the behaviour a static deployment gets. + +## What has to stay clear + +![A dresser selected, its drawer-pull clearance hatched, and the bookcase standing in it](docs/media/clearance.png) + +Two checks that sound alike and are not. A **clearance zone** asks whether a drawer opens +— 900mm in front of a dresser, 1067mm behind a dining chair, 1200mm at an appliance door. +A **walkway probe** asks whether a person fits. They differ on whether walls count: they +do not for a zone, they do for the probe. + +Zones are drawn on the selected item only. 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 validation panel groups by what you would do about a problem rather than by which +enum the issue came from. + +--- ## Quick start @@ -39,18 +129,18 @@ pnpm install pnpm dev # http://localhost:5190 ``` -## Scripts - | Script | Does | |--------|------| -| `pnpm dev` | Vite dev server | +| `pnpm dev` | Vite dev server, with the product-lookup endpoint | | `pnpm build` | Typecheck, then production build | +| `pnpm preview` | Serve the production build — no lookup endpoint, like a static deploy | | `pnpm typecheck` | `tsc --noEmit` | | `pnpm lint` | ESLint | -| `pnpm test` | Vitest unit tests | +| `pnpm test` | Vitest — 765 unit tests | | `pnpm test:watch` | Vitest in watch mode | -| `pnpm e2e` | Playwright against a production build | +| `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`) | ## Layout @@ -58,28 +148,74 @@ pnpm dev # http://localhost:5190 src/ ├── core/ pure logic — units, geometry, document model, tools, validation ├── state/ zustand store: document slice (undoable) + editor slice (not) +├── server/ the product-lookup endpoint ├── ui/ React shell, panels, dialogs -│ └── plan/ Konva stage and its layers +│ ├── plan/ Konva stage and its layers +│ └── space/ three.js scene, camera rig, walk loop └── styles/ global CSS e2e/ Playwright specs -PLAN.md architecture + phasing +media/ the capture script behind docs/media +PLAN.md architecture, decisions, and the phasing table ``` -`src/core/` is deliberately free of React and of any renderer. The geometry engine is -pure functions over integer millimeters, consumed identically by the Konva plan view, -the three.js space view, and the validation passes — which is what keeps one geometry -primitive serving all three. +`src/core/` is free of React and of any renderer. The geometry engine is pure functions +over integer millimetres, consumed identically by the Konva plan view, the three.js space +view and the validation passes — which is what keeps one geometry primitive serving all +three. ## Conventions -- **Integer millimeters** are the canonical unit everywhere in `src/core/`. Display - units are a view preference and never used for computation. +- **Integer millimetres** everywhere in `src/core/`. Display units are a view preference + and never used for computation. - The document is Z-up (`x` east, `y` south, `z` up). three.js is Y-up. The conversion lives in exactly one place and is tested both directions. - Rotation is never baked into stored vertices. Footprints stay in local coordinates; world geometry is derived per query. -- **Document state is undoable; editor state is not.** A drag lives entirely in the - editor slice and writes to the document once, on release, so one drawn wall is one - press of Ctrl+Z rather than several hundred. -- Coordinates round to integer millimetres at the commit boundary, never during a - drag — rounding mid-gesture makes geometry jitter against the cursor. +- **Document state is undoable; editor state is not.** A drag lives entirely in the editor + slice and writes to the document once, on release. +- Coordinates round to integer millimetres at the commit boundary, never during a drag — + rounding mid-gesture makes geometry jitter against the cursor. + +--- + +## 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`, +run with `pnpm media`, which builds the scene through the same screen-to-document mapping +the end-to-end suite uses, photographs it, and encodes the clip with ffmpeg. Each capture +asserts the state it is photographing: the walk clip fails if the walker does not reach +the bedroom, the clearance shot fails if the issue is not listed. + +That is the whole reason it exists. A hand-taken screenshot of a feature that has since +changed is a lie the repository tells silently, and it tells it for years. diff --git a/docs/media/calibrate.png b/docs/media/calibrate.png new file mode 100644 index 0000000..ee140a1 Binary files /dev/null and b/docs/media/calibrate.png differ diff --git a/docs/media/clearance.png b/docs/media/clearance.png new file mode 100644 index 0000000..5180743 Binary files /dev/null and b/docs/media/clearance.png differ diff --git a/docs/media/lookup.png b/docs/media/lookup.png new file mode 100644 index 0000000..b82b834 Binary files /dev/null and b/docs/media/lookup.png differ diff --git a/docs/media/plan.png b/docs/media/plan.png new file mode 100644 index 0000000..3facf6a Binary files /dev/null and b/docs/media/plan.png differ diff --git a/docs/media/space.png b/docs/media/space.png new file mode 100644 index 0000000..061abd6 Binary files /dev/null and b/docs/media/space.png differ diff --git a/docs/media/walk.gif b/docs/media/walk.gif new file mode 100644 index 0000000..2e75d19 Binary files /dev/null and b/docs/media/walk.gif differ diff --git a/media/capture.spec.ts b/media/capture.spec.ts new file mode 100644 index 0000000..329491f --- /dev/null +++ b/media/capture.spec.ts @@ -0,0 +1,298 @@ +import { expect, test, type Page } from '@playwright/test'; +import { clickAt, dragBetween, selectTool } from '../e2e/coords'; +import { clip, resetWork, still } from './shoot'; +import { samplePlanPng } from './sample-plan'; + +/** + * Every image in the README, drawn by driving the real application. + * + * Run with `pnpm media`. Generated rather than hand-taken because a screenshot of + * a feature that has since changed is a lie the repository tells silently — this + * way the pictures are re-derivable, and a capture that can no longer be driven + * fails instead of quietly going stale. + * + * Gestures come from `e2e/coords.ts`, so the screen-to-document mapping is the one + * the test suite depends on: **place all geometry before anything zooms, pans or + * fits**, because Fit invalidates the mapping. Framing is always the last thing + * that happens before a shutter. + * + * Each capture asserts the state it is photographing — the walker ends in the + * bedroom, the clearance issue is listed — so a shot of the wrong thing fails + * rather than being committed. + */ + +test.beforeAll(() => resetWork()); + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('plan-stage')).toBeVisible(); +}); + +/** Both side panels scroll. A shot of one mid-scroll looks like a rendering bug. */ +async function scrollPanels(page: Page) { + await page.locator('.panel--left').evaluate((el) => el.scrollTo(0, 0)); + await page.locator('.panel--right').evaluate((el) => el.scrollTo(0, 0)); +} + +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`, in document millimetres. */ +async function place(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); + await page.keyboard.press('Escape'); +} + +async function nameRoom(page: Page, at: { x: number; y: number }, name: string) { + const stage = page.getByTestId('plan-stage'); + await selectTool(page, 'Select'); + await clickAt(page, stage, at); + const field = page.getByTestId('room-properties').getByRole('textbox').first(); + await field.fill(name); + await field.press('Enter'); +} + +/** + * A 9m × 6m flat: a wall shell, a partition, four openings, and enough furniture + * for the space view to be worth walking. + * + * Drawn as walls and then *detected* into rooms rather than traced with the Room + * tool, because that is the path phase 8 exists for and it exercises the two + * T-junctions where the partition meets the shell. + * + * The interior door sits on y = 3000 on purpose. The walker seeds at the centre of + * the largest room facing east, so that line is the one along which a scripted + * clip can hold one key and end up in the next room — and the layout keeps it + * clear of everything but the rug, which is 10mm tall and meant to be walked over. + */ +async function apartment(page: Page) { + const stage = page.getByTestId('plan-stage'); + + await selectTool(page, 'Wall'); + await clickAt(page, stage, { x: 0, y: 0 }); + await clickAt(page, stage, { x: 9000, y: 0 }); + await clickAt(page, stage, { x: 9000, y: 6000 }); + await clickAt(page, stage, { x: 0, y: 6000 }); + await clickAt(page, stage, { x: 0, y: 0 }); + await expect(page.getByTestId('count-walls')).toContainText('4'); + + await selectTool(page, 'Wall'); + await clickAt(page, stage, { x: 5400, y: 0 }); + await clickAt(page, stage, { x: 5400, y: 6000 }); + await page.keyboard.press('Enter'); + + await page.getByTestId('detect-rooms').click(); + await expect(page.getByTestId('count-rooms')).toContainText('2'); + + await nameRoom(page, { x: 2700, y: 3000 }, 'Living room'); + await nameRoom(page, { x: 7200, y: 3000 }, 'Bedroom'); + + await selectTool(page, 'Opening'); + await clickAt(page, stage, { x: 2000, y: 0 }); // front door + await clickAt(page, stage, { x: 5400, y: 3000 }); // through the partition + await selectTool(page, 'Window'); + await clickAt(page, stage, { x: 3600, y: 6000 }); + await clickAt(page, stage, { x: 9000, y: 2200 }); + await expect(page.getByTestId('count-openings')).toContainText('4'); + + // The rug goes down before the coffee table. A rug is 10mm and the table stands + // 380mm clear underneath, so this is the case the vertical axis exists for — and + // it only reads as that case if the rug is under something. + await place(page, 'Other — Rug (5 x 8 ft)', { x: 2200, y: 3700 }); + await place(page, 'Tables — Coffee table', { x: 2200, y: 3700 }); + await place(page, 'Seating — Sofa (3-seat)', { x: 2200, y: 5600 }); + await place(page, 'Storage — TV stand', { x: 4400, y: 300 }); + await place(page, 'Storage — Bookcase', { x: 400, y: 1400 }); + await place(page, 'Tables — Dining table (6)', { x: 1800, y: 1400 }); + + await place(page, 'Beds — Queen bed', { x: 7400, y: 1200 }); + await place(page, 'Beds — Nightstand', { x: 6200, y: 400 }); + await place(page, 'Storage — Wardrobe', { x: 8600, y: 4400 }); + await place(page, 'Other — Floor lamp', { x: 6000, y: 5200 }); + + await expect(page.getByTestId('count-placements')).toContainText('10'); +} + +test('the plan editor, and the same document in 3D', async ({ page }) => { + await apartment(page); + + await page.getByRole('button', { name: 'Edit floor plan', exact: true }).click(); + await selectTool(page, 'Select'); + await page.keyboard.press('Escape'); + // Nothing below here may use the fixed mapping again. + await page.getByRole('button', { name: 'Fit', exact: true }).click(); + await page.waitForTimeout(400); + await scrollPanels(page); + await still(page, 'plan'); + + await page.getByRole('button', { name: 'Space', exact: true }).click(); + await expect(page.getByTestId('space-view')).toBeVisible(); + await page.waitForTimeout(2500); + + // Orbit frames the whole document, which puts a 9m flat in the middle third of + // the canvas. Zoom in on it. + const canvas = page.getByTestId('space-view'); + const box = (await canvas.boundingBox())!; + const centre = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + await page.mouse.move(centre.x, centre.y); + for (let i = 0; i < 6; i++) { + await page.mouse.wheel(0, -240); + await page.waitForTimeout(120); + } + // Orbit up a little: from the default elevation the near wall hides most of + // what is behind it. + await page.mouse.down(); + await page.mouse.move(centre.x, centre.y - 55, { steps: 8 }); + await page.mouse.up(); + await page.waitForTimeout(800); + await scrollPanels(page); + await still(page, 'space'); + +}); + +/** + * The walkthrough clip, captured at deviceScaleFactor 1. + * + * The stills are shot at 2 and downscaled, which is what keeps their text crisp. + * A clip cannot afford it: the GIF is 900px wide either way, so the second pixel + * is thrown away, and encoding it costs about 190ms a frame — over the walk + * loop's own MAX_STEP_SECONDS, which makes the recording jerky. At 1 the frames + * are cheap enough to be a walk. + * + * The apartment is therefore drawn twice, in two contexts. That is the price of + * the density difference and it is only paid by this script. + */ +test.describe('walking through it', () => { + test.use({ deviceScaleFactor: 1 }); + + test('out of the living room and into the bedroom', async ({ page }) => { + await apartment(page); + + await page.getByRole('button', { name: 'Space', exact: true }).click(); + await expect(page.getByTestId('space-view')).toBeVisible(); + await page.waitForTimeout(2500); + + const canvas = page.getByTestId('space-view'); + await page.getByTestId('camera-walk').click(); + await page.waitForTimeout(600); + await expect(page.getByTestId('walker-room')).toHaveText('Living room'); + + // The walker is seeded at the centre of the living room facing north. Look 90° + // right — 409px at LOOK_SENSITIVITY — and the interior doorway is straight + // ahead down the y = 3000 line. + await clip(page, 'walk', canvas, [ + { hold: [], ms: 300 }, + { look: { dx: 409 }, steps: 12 }, + { + hold: ['ArrowUp'], + ms: 3000, + // The point of the clip, asserted where it is true rather than at the end, + // because the look-around that follows turns the walker back around. + after: () => expect(page.getByTestId('walker-room')).toHaveText('Bedroom'), + }, + { look: { dx: -240 }, steps: 8 }, + ]); + }); +}); + +test('a clearance zone the furniture is standing in', async ({ page }) => { + const stage = page.getByTestId('plan-stage'); + await selectTool(page, 'Room'); + await dragBetween(page, stage, { x: 0, y: 0 }, { x: 3200, y: 2400 }); + + // A dresser against the north wall needs 900mm in front of it for the drawers. + // The bookcase is standing in it. + await place(page, 'Storage — Dresser', { x: 1600, y: 300 }); + await place(page, 'Storage — Bookcase', { x: 1600, y: 1000 }); + await expect(page.getByTestId('issue-list')).toContainText('drawer pull'); + + // Select the *dresser*, not the bookcase in its way: zones are drawn on the + // selection only, and the dresser is the one that owns this zone. The hatched + // rectangle is the evidence for the sentence in the panel. + await clickAt(page, stage, { x: 1600, y: 300 }); + await expect(page.getByTestId('placement-properties')).toBeVisible(); + + await page.getByRole('button', { name: 'Fit', exact: true }).click(); + await page.waitForTimeout(400); + await page.locator('.panel--left').evaluate((el) => el.scrollTo(0, 0)); + await page.getByTestId('issue-list').scrollIntoViewIfNeeded(); + await still(page, 'clearance'); +}); + +test('the calibration gate, before a scale exists', async ({ page }) => { + await page.getByTestId('import-input').setInputFiles({ + name: 'ground-floor.png', + mimeType: 'image/png', + buffer: await samplePlanPng(page), + }); + await expect(page.getByTestId('calibration-gate')).toBeVisible(); + + // An uncalibrated background is shown at a nominal 6m width, which at the + // default zoom is a postage stamp. Import deliberately does not re-fit the + // viewport, and Fit would not help anyway — the background is excluded from + // `floorBounds` — so zoom on it by hand, the way you would. + const stage = page.getByTestId('plan-stage'); + const box = (await stage.boundingBox())!; + const onPlan = { x: box.x + 270, y: box.y + 340 }; + await page.mouse.move(onPlan.x, onPlan.y); + for (let i = 0; i < 3; i++) { + await page.mouse.wheel(0, -240); + await page.waitForTimeout(100); + } + await page.waitForTimeout(400); + await scrollPanels(page); + await still(page, 'calibrate'); +}); + +test('what a product page claimed, before it is believed', async ({ page }) => { + // Tall enough that the confirmation form fits without scrolling — the whole + // point of the shot is the evidence line above the fields and the fields below + // it in one frame. + await page.setViewportSize({ width: 1440, height: 1280 }); + + // The endpoint is a serverless function and is not in the static build, so the + // response is stubbed here the same way `e2e/product-url.spec.ts` stubs it, and + // for the same reason. The dialog, the parse and the confidence flag are real. + await page.evaluate(() => { + window.fetch = () => + Promise.resolve( + new Response( + JSON.stringify({ + 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', + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + }); + + await page.getByTestId('add-from-url').click(); + await page.getByTestId('lookup-url-input').fill('https://shop.example.com/p/harlow-sofa'); + await page.getByTestId('lookup-go').click(); + await expect(page.getByTestId('lookup-confirm')).toBeVisible(); + + // From the evidence line down to the last scraped dimension. The rest of the + // form is the ordinary manual-entry form and says nothing about the lookup. + const panel = (await page.locator('.panel--left').boundingBox())!; + const top = (await page.getByTestId('lookup-confirm').boundingBox())!; + const last = (await page.getByLabel('Height').boundingBox())!; + await still(page, 'lookup', { + x: panel.x, + y: top.y - 2, + width: panel.width, + height: last.y + last.height + 34 - (top.y - 2), + }); +}); diff --git a/media/sample-plan.ts b/media/sample-plan.ts new file mode 100644 index 0000000..d71640c --- /dev/null +++ b/media/sample-plan.ts @@ -0,0 +1,117 @@ +import type { Page } from '@playwright/test'; + +/** + * The floor plan the import capture imports. + * + * `e2e/fixtures.ts` builds a white PNG with a diagonal, which is the right fixture + * for a test asserting pixel dimensions and the wrong one to photograph: the + * calibration gate's whole subject is a drawing with a stated dimension on it, and + * a blank rectangle shows none of that. + * + * Drawn with the browser's own 2D canvas rather than assembled byte by byte, + * because this one needs text and arcs. Nothing about the app is being tested + * here — it is a stand-in for the estate agent's PDF you would actually drop in. + */ +export async function samplePlanPng(page: Page): Promise { + const dataUrl = await page.evaluate(() => { + const W = 1400; + const H = 980; + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const c = canvas.getContext('2d')!; + + c.fillStyle = '#fdfcfa'; + c.fillRect(0, 0, W, H); + c.strokeStyle = '#1b1b1b'; + c.fillStyle = '#1b1b1b'; + c.lineCap = 'butt'; + + const wall = (x1: number, y1: number, x2: number, y2: number, w = 16) => { + c.lineWidth = w; + c.beginPath(); + c.moveTo(x1, y1); + c.lineTo(x2, y2); + c.stroke(); + }; + + // Outer shell, broken where the front door and the windows go. + const L = 160; + const R = 1240; + const T = 130; + const B = 800; + + wall(L, T, 470, T); + wall(560, T, R, T); // front door gap + wall(R, T, R, 330); + wall(R, 430, R, B); // window gap, east + wall(R, B, 830, B); + wall(720, B, L, B); // window gap, south + wall(L, B, L, T); + + // Partition, with a doorway through it. + wall(700, T, 700, 420); + wall(700, 520, 700, B); + + // Door leaves and their swings. + const swing = (hx: number, hy: number, r: number, from: number, to: number) => { + c.lineWidth = 3; + c.beginPath(); + c.arc(hx, hy, r, from, to); + c.stroke(); + }; + c.lineWidth = 6; + c.beginPath(); + c.moveTo(470, T); + c.lineTo(470, T + 90); + c.stroke(); + swing(470, T, 90, 0, Math.PI / 2); + + c.beginPath(); + c.moveTo(700, 420); + c.lineTo(700 - 100, 420); + c.stroke(); + swing(700, 420, 100, Math.PI, Math.PI * 1.5); + + // Windows: a thin pane inside the gap in the wall. + c.lineWidth = 3; + const pane = (x1: number, y1: number, x2: number, y2: number) => { + c.beginPath(); + c.moveTo(x1, y1); + c.lineTo(x2, y2); + c.stroke(); + }; + pane(R - 5, 330, R - 5, 430); + pane(R + 5, 330, R + 5, 430); + pane(720, B - 5, 830, B - 5); + pane(720, B + 5, 830, B + 5); + + // The stated dimension the calibration line is drawn across. + c.lineWidth = 1.5; + const dimY = B + 90; + c.beginPath(); + c.moveTo(L, dimY); + c.lineTo(R, dimY); + c.moveTo(L, dimY - 12); + c.lineTo(L, dimY + 12); + c.moveTo(R, dimY - 12); + c.lineTo(R, dimY + 12); + c.stroke(); + c.font = '30px Georgia, serif'; + c.textAlign = 'center'; + c.fillText('9.00 m', (L + R) / 2, dimY - 20); + + c.font = '26px Georgia, serif'; + c.fillText('LIVING / DINING', 430, 470); + c.fillText('BEDROOM', 970, 470); + + c.textAlign = 'left'; + c.font = '22px Georgia, serif'; + c.fillStyle = '#555'; + c.fillText('GROUND FLOOR — SCALE 1:50', L, 70); + + return canvas.toDataURL('image/png'); + }); + + return Buffer.from(dataUrl.split(',')[1]!, 'base64'); +} diff --git a/media/shoot.ts b/media/shoot.ts new file mode 100644 index 0000000..1618fab --- /dev/null +++ b/media/shoot.ts @@ -0,0 +1,212 @@ +/** + * Capture plumbing: screenshots and clips, scaled down on the way out. + * + * Captures are taken at deviceScaleFactor 2 and downscaled to a fixed width + * rather than shot at 1x, because text rendered at 1x and enlarged by the reader + * is the thing that makes a screenshot look like a photograph of a screen. ffmpeg + * does the scaling — it is already a hard dependency of the GIF path, so the + * still path may as well use the same tool and produce files that match. + */ + +import { execFileSync } from 'node:child_process'; +import { mkdirSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import type { Locator, Page } from '@playwright/test'; + +/** Committed output. Referenced from README.md, so the path is part of the API. */ +export const MEDIA_DIR = join(process.cwd(), 'docs', 'media'); +/** Frames and full-size stills on the way to MEDIA_DIR. Gitignored. */ +export const WORK_DIR = join(process.cwd(), 'media', '.work'); + +/** Wide enough to read a properties panel in, narrow enough to commit. */ +const STILL_WIDTH = 1600; +/** + * The clip is committed to the repository and re-committed every time it is + * regenerated, so its size is a real cost. A shaded 3D render is close to the + * worst case for GIF: 900px at 128 colours came to 2.5MB. 760 at 64, with a + * coarse ordered dither that compresses instead of fighting the LZW run + * lengths, is under a megabyte and looks the same at README width. + */ +const CLIP_WIDTH = 760; +const CLIP_COLORS = 64; +/** Above this the file grows faster than the motion improves. */ +const CLIP_MAX_FPS = 10; + +function ffmpeg(args: string[]): void { + execFileSync('ffmpeg', ['-y', '-loglevel', 'error', ...args], { stdio: 'inherit' }); +} + +export function resetWork(): void { + rmSync(WORK_DIR, { recursive: true, force: true }); + mkdirSync(WORK_DIR, { recursive: true }); + mkdirSync(MEDIA_DIR, { recursive: true }); +} + +export type Region = { x: number; y: number; width: number; height: number }; + +/** + * Photograph the whole window, one element, or a rectangle, into + * `docs/media/.png`. + * + * Cropping matters more here than it looks: a panel that scrolls is taller than + * the window, so photographing the element gives a 500 × 2400 ribbon nobody can + * read in a README. Most captures want the window; a few want a rectangle + * measured off two elements. + */ +export async function still( + page: Page, + name: string, + where?: Locator | Region, +): Promise { + const raw = join(WORK_DIR, `${name}.raw.png`); + mkdirSync(dirname(raw), { recursive: true }); + if (!where) await page.screenshot({ path: raw }); + else if ('width' in where) await page.screenshot({ path: raw, clip: where }); + else await where.screenshot({ path: raw }); + + ffmpeg([ + '-i', + raw, + '-vf', + `scale='min(${STILL_WIDTH},iw)':-2:flags=lanczos`, + join(MEDIA_DIR, `${name}.png`), + ]); +} + +/** Hold keys for a stretch of wall clock. */ +export type HoldBeat = { + hold: string[]; + /** + * How long to hold, in milliseconds of wall clock. + * + * Deliberately not a frame count. The walk simulation integrates real elapsed + * time, and a screenshot takes as long as it takes, so a beat counted in frames + * turns a given number of degrees on one machine and a different number on the + * next. Counted in milliseconds it is `TURN_SPEED_DEG`/`WALK_SPEED_MMS` times + * the duration, everywhere — which is what lets a clip be aimed at a doorway. + */ + ms: number; + /** + * Run once the beat's keys are released. This is where a clip asserts what it + * just recorded — "by here the walker is in the bedroom" — which has to happen + * mid-sequence, because a later beat can undo the thing being checked. + */ + after?: () => Promise; +}; + +/** + * Drag to look, by an exact number of pixels. + * + * The way to aim a walker at a doorway. A turn key integrates elapsed time and so + * lands wherever the frame rate leaves it, but mouse-look is applied per pointer + * event: the yaw is `dx * LOOK_SENSITIVITY` degrees whatever the machine is doing, + * which is the difference between walking through a door and into a wall. + */ +export type LookBeat = { + look: { dx: number; dy?: number }; + /** Pointer moves to split the drag into. One frame is shot per move. */ + steps: number; + after?: () => Promise; +}; + +export type Beat = HoldBeat | LookBeat; + +/** + * Record a GIF of `target` while a scripted sequence of keys is held. + * + * Frames are screenshots rather than a recorded video: a video is the whole + * lifetime of a browser context and would have to be trimmed by wall-clock + * guesswork, where a frame loop starts and stops exactly where the script says. + * The page keeps animating between screenshots, so the motion is real — it is + * sampled irregularly, not stepped. Playback is a fixed frame rate over an + * irregular sample, so the clip is roughly, not exactly, real time. + */ +export async function clip( + page: Page, + name: string, + target: Locator, + beats: Beat[], +): Promise { + const frames = join(WORK_DIR, name); + rmSync(frames, { recursive: true, force: true }); + mkdirSync(frames, { recursive: true }); + + let n = 0; + /** Milliseconds of real time the frames span, excluding the `after` hooks. */ + let recorded = 0; + const shoot = async () => { + await target.screenshot({ + path: join(frames, `f${String(n++).padStart(4, '0')}.jpg`), + type: 'jpeg', + quality: 92, + }); + }; + + for (const beat of beats) { + const beatStart = Date.now(); + if ('look' in beat) { + const box = (await target.boundingBox())!; + const from = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + for (let i = 1; i <= beat.steps; i++) { + await page.mouse.move( + from.x + (beat.look.dx * i) / beat.steps, + from.y + ((beat.look.dy ?? 0) * i) / beat.steps, + ); + await shoot(); + } + await page.mouse.up(); + recorded += Date.now() - beatStart; + if (beat.after) await beat.after(); + continue; + } + + for (const key of beat.hold) await page.keyboard.down(key); + + // Two timing hazards, and both of them aim the walker at the wrong wall. + // + // The key is released on the deadline rather than when the frame loop + // finishes, because "shoot until the deadline passes, then release" holds for + // up to one extra frame — at 140°/s that is ten degrees. + // + // And the frames are JPEG. `stepWalker` caps a step at MAX_STEP_SECONDS + // (100ms), so any frame that costs longer than that quietly throws away the + // difference: hold a key for 640ms of wall clock through 150ms PNG encodes + // and the walker turns about 60°, not 90°. A JPEG of the canvas encodes in + // well under the cap, which makes the clock and the simulation agree again. + const until = Date.now() + beat.ms; + let frameCost = 0; + do { + const started = Date.now(); + await shoot(); + frameCost = Date.now() - started; + } while (Date.now() + frameCost <= until); + + const left = until - Date.now(); + if (left > 0) await page.waitForTimeout(left); + for (const key of beat.hold) await page.keyboard.up(key); + recorded += Date.now() - beatStart; + if (beat.after) await beat.after(); + } + + // Play back at the rate the frames were actually taken, so the clip runs at the + // speed the app runs at. A fixed frame rate over an irregular sample makes a + // walk look like a run or a crawl depending on what the machine was doing. + const fps = Math.max(4, Math.min(CLIP_MAX_FPS, Math.round((n / recorded) * 1000))); + + // One palette for the whole clip. A per-frame palette shimmers on the flat + // wall fills, which is most of what is on screen here. + ffmpeg([ + '-framerate', + String(fps), + '-i', + join(frames, 'f%04d.jpg'), + '-vf', + `scale=${CLIP_WIDTH}:-2:flags=lanczos,split[a][b];` + + `[a]palettegen=max_colors=${CLIP_COLORS}[p];[b][p]paletteuse=dither=bayer:bayer_scale=5`, + '-loop', + '0', + join(MEDIA_DIR, `${name}.gif`), + ]); +} diff --git a/package.json b/package.json index eacb058..9d4b354 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "test": "vitest run", "test:watch": "vitest", "e2e": "playwright test", - "e2e:install": "playwright install --with-deps chromium" + "e2e:install": "playwright install --with-deps chromium", + "media": "playwright test --config playwright.media.config.ts" }, "dependencies": { "@react-three/drei": "^10.0.0", diff --git a/playwright.media.config.ts b/playwright.media.config.ts new file mode 100644 index 0000000..be304e9 --- /dev/null +++ b/playwright.media.config.ts @@ -0,0 +1,55 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = 5192; + +/** + * The README's screenshots and clips, captured from the running application. + * + * Deliberately separate from `playwright.config.ts`, because this is not a test + * run. It asserts only enough to know the app got into the state being + * photographed — a capture that silently shot an empty canvas is worse than no + * capture, since it looks like a product bug. It must never gate CI on whether a + * GIF re-encoded byte-identically. + * + * Same webServer contract as the e2e config: a production build, on its own port. + */ +export default defineConfig({ + testDir: './media', + fullyParallel: false, + workers: 1, + reporter: 'list', + timeout: 180_000, + + use: { + baseURL: `http://localhost:${PORT}`, + }, + + projects: [ + { + name: 'capture', + use: { + ...devices['Desktop Chrome'], + // After the device spread, or Desktop Chrome's own 1280x720 at 1x wins. + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 2, + launchOptions: { + // Headless Chromium has no GPU. Without a software rasteriser the + // three.js canvas photographs as an empty rectangle. + args: [ + '--use-gl=angle', + '--use-angle=swiftshader', + '--enable-unsafe-swiftshader', + '--ignore-gpu-blocklist', + ], + }, + }, + }, + ], + + webServer: { + command: `pnpm run build && pnpm run preview --port ${PORT} --strictPort`, + url: `http://localhost:${PORT}`, + reuseExistingServer: true, + timeout: 180_000, + }, +}); diff --git a/tsconfig.json b/tsconfig.json index e22c523..c95d094 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,5 +26,13 @@ "@/*": ["src/*"] } }, - "include": ["src", "e2e", "vite.config.ts", "playwright.config.ts", "eslint.config.js"] + "include": [ + "src", + "e2e", + "media", + "vite.config.ts", + "playwright.config.ts", + "playwright.media.config.ts", + "eslint.config.js" + ] }