diff --git a/README.md b/README.md index 4d34f27..ee5df15 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,7 @@ Use a supported Node.js release (22.12+ or 24+). Open the local URL printed by V The home page introduces the planner, a short DIY laminate fitting guide, and the difference between scotia and skirting boards. **Plan my floor** opens the app at `?planner`, resuming the -last locally saved project when available. Existing project queries and legacy `#plan=` links -still open the planner directly. Both pages work on +last locally saved project when available. Current project queries open the planner directly. Both pages work on static hosting without server-side routing. The planner loads only when opened. ```sh @@ -21,17 +20,21 @@ npx playwright install chromium npm run test:e2e # Desktop Chromium and mobile-sized Chromium npm run build # Strict TypeScript check and production build npm run check # Build, unit tests and browser tests +npm run benchmark # Compare cut-first optimisation with the fixed 84-case baseline +npm run benchmark -- --random # Compare appearance-first optimisation with the same baseline ``` ## Planning workflow +New plans start with a random laying pattern. Click the numbered room, materials and cut-plan headings to expand or collapse each panel. Click **Custom** to build an outline wall by wall; guidance below the shape choices explains how to begin. Advanced flooring and skirting settings expand separately when needed. Saved plans keep their chosen laying pattern. + 1. Start with a rectangle, fireplace recess, or custom outline. Enter wall lengths in order. The first wall runs east; the turn **after** each wall is positive for right and negative for left. For example, a clockwise rectangle has 90° turns. The final corner is derived from the closed outline. Insert a wall after any existing wall, drag its handle to reorder it, or use the up/down buttons on a phone or keyboard. Doors stay attached to their wall. Numeric fields allow empty values and a minus sign while typing; unfinished entries revert to the last valid value when you leave the field. 2. Add door openings with a width and a distance from the named starting corner. Each doorway shows the neighbouring wall names and an arrow in the measurement direction. Include architrave/trim in the width where skirting must be omitted. Doors terminate skirting; laminate ends at the room outline with a transition and expansion gap. Rooms are planned independently. 3. For a custom outline, the closing-wall suggestion gives the required length and turn. Add it, then replace the suggested length with the actual measured length. An outline must close within the chosen tolerance and have no intersections or invalid doors before a cut plan can be produced. 4. Set material dimensions, spacer, kerf and minimum piece sizes alongside the measurements. Kerf means material removed by the blade: a 3 mm cut leaves 600 mm + 397 mm from a 1,000 mm board. Both kerf fields have keyboard and touch tooltips. Set one purchased skirting board length for the whole project. Materials are shared across rooms; direction is per room. -5. Room measurements, materials and cut plans open and close independently on the same page. The drawing stays pinned on the right while you scroll the panels; on phones a compact drawing stays above them. Flooring and skirting are visible together, with the whole drawing fitting without an internal scrollbar. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Cut codes sit above the wall and skirting drawing; short pieces use nearby labels with pointers so their names remain visible. The compact drawing header shows only the room, area and material legend. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Flooring and skirting cuts are grouped by the purchased board they come from, including offcuts reused across rooms. Group headings use the cut codes; internal stock IDs stay out of the interface. Pieces wrap side by side when space allows. Each piece has a small preview that opens dimensions directly on its shape. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Numbered skirting pieces and their labels open cut details; uncut lengths use a neutral fill. Print all room drawings followed by flooring and skirting cut sheets grouped by purchased board. Every cut is included once, with its room and row or wall. +5. Room measurements, materials and cut plans open and close independently on the same page. The drawing stays pinned on the right while you scroll the panels; on phones a compact drawing stays above them. The **Floorboards / Skirting** switch above the drawing shows one material at a time, with the whole drawing fitting without an internal scrollbar. The choice is saved as `view=flooring` or `view=skirting`. Locating or searching for a cut switches to its material automatically; printing includes both materials. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Cut codes sit above the wall and skirting drawing; short pieces use nearby labels with pointers so their names remain visible. The compact drawing header shows only the room, area and material legend. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Flooring and skirting cuts are grouped by the purchased board they come from, including offcuts reused across rooms. Group headings use the cut codes; internal stock IDs stay out of the interface. Pieces wrap side by side when space allows. Each piece has a small preview that opens dimensions directly on its shape. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Numbered skirting pieces and their labels open cut details; uncut lengths use a neutral fill. Print all room drawings followed by flooring and skirting cut sheets grouped by purchased board. Every cut is included once, with its room and row or wall. 6. Find a cut by its code, or search by room name. Searching several cut codes together, such as `F27 + F28`, finds their pieces together. **Show on plan** highlights the source board’s pieces or an individual cut, with room buttons when a source is reused across rooms. A visible **Search active** banner stays above the pinned drawing; **Show all pieces** clears the search there. Source boards have **Mark done** checkboxes; completion marks appear on their list entries, installed pieces and printed sheets. Room search results also let you locate and mark uncut boards without adding cut labels to them. Measurements, wall order, flooring direction or material changes clear completion because the allocation changes; renaming rooms keeps it. -7. Copy the link to save or share the complete project. Measurements, materials and view choices live in readable query parameters such as `flooring.length=1200`, `skirting.length=2400`, `room.1.wall.1.length=4800` and `selectedCut=F1`. Older stock-list links retain their first length; their completion marks are cleared because coping blanks and rounded stock allocation have changed, including for single-size links. Progress uses repeated `done=floor:B4` or `done=skirting:S6` parameters; `search=F1` and repeated `collapsed=materials` parameters preserve the search and closed panels. You can edit values directly in the URL. Older compressed fragment links still load and migrate automatically; obsolete step, layer and zoom choices are ignored. Reloading or opening that link reproduces the plan. Browser back/forward restores room changes. The same parameters are saved automatically in localStorage under `floor-planner.project`. Opening the app without URL parameters restores the last saved plan; explicit query or legacy fragment links take precedence and become the saved plan. If localStorage is unavailable or full, URL saving still works. There is no account, API, database or analytics. Fonts are system fonts; the app makes no external font requests. +7. Copy the link to save or share the complete project. Measurements, materials and view choices live in readable version 2 query parameters such as `flooring.length=1200`, `skirting.length=2400`, `room.1.wall.1.length=4800` and `selectedCut=F1`. Progress uses repeated `done=floor:B4` or `done=skirting:S6` parameters; `search=F1` and repeated `collapsed=materials` parameters preserve the search and closed panels. You can edit values directly in the URL. Reloading or opening that link reproduces the plan. Browser back/forward restores room changes. The same parameters are saved automatically in localStorage under `floor-planner.project`. Opening the app without URL parameters restores the last saved plan; an explicit project query takes precedence and becomes the saved plan. If localStorage is unavailable or full, URL saving still works. There is no account, API or database. Fonts are system fonts; the app makes no external font requests. ## Calculation rules and practical limits @@ -43,7 +46,8 @@ npm run check # Build, unit tests and browser tests - Offcut reuse preserves the needed left/right factory ends. Only full-width end remnants are available for reuse; ripped side strips and shaped scraps are conservatively waste. Crosscuts include kerf. The stock count is actual allocated boards, not an area-only estimate. Waste includes kerf and unused material, including still-usable stock remainders. - Skirting internal corners use coped joints. The blank keeps the wall-corner long point instead of subtracting the receiving board’s thickness; the cope preparation is half the measured turn. Earlier walls receive adjoining later walls, so installation order has no cycle. At square corners, a 4,000 mm wall keeps a 4,000 mm blank; with 18 mm skirting at both corners its exposed front length is 3,964 mm. Non-square receiving ends have a wall-fitting bevel. External corners use equal mitres. Minimum sizes check the exposed face and installed body after corner and door allowances. Preparation previews and dashed body edges guide marking; the actual moulding profile must be scribed and back-cut, since thickness alone does not define it. This follows [Gary Katz’s coping and long-point measurement guidance](https://www.finehomebuilding.com/pdf/021174063.pdf); the general-angle allowances are derived from intersecting wall faces. - Skirting cut lengths are the longest-point envelope. Cut square blanks first, then prepare the mitres and cope the indicated internal ends; this keeps reusable stock remainders square. Back lengths and diagram positions locate the wall-facing edge. The stock allocation includes crosscut kerf and counts additional bevel and coping operations. -- Flooring uses a bounded beam search, minimizing purchased boards first. Random layouts then prefer less visual repetition before fewer saw passes; brick layouts prioritize fewer saw passes. This can add cuts to break a staircase without increasing the selected board count. Skirting compares full-stock, balanced and offcut-aware splits and packs them into the single purchased length, preferring less waste, then fewer cuts. Available offcuts can move the next wall’s joints. Minimum lengths are reserved on the exposed face and full-thickness body of angled and coped pieces. These are deterministic, auditable heuristics rather than guarantees of a global minimum. Room order is an input and can affect shared-offcut allocation. +- Random flooring offers **Minimise cuts** and **Maximise randomness**, with the actual cut count and waste percentage beside each choice. Minimise cuts ranks saw passes first, then simpler cuts (fewer shaping passes, then fewer rips), visual repetition and finally purchased boards. Maximise randomness ranks visual repetition first, then the same cutting and waste priorities. The choice is saved as `flooring.optimisation=cuts` or `flooring.optimisation=random`; changing it clears completion marks because stock allocation can change. Brick prioritises cutting effort and then waste. +- Flooring uses a bounded beam search that preserves factory-ended offcuts for later exact fits. Completed candidates are repacked, and interchangeable full-width rectangular rows can be reordered without adding cuts. Both options share clipping results, and the best completed candidates are compared before displaying totals. Skirting compares full-stock, balanced and offcut-aware splits, including exact three-piece stock combinations, preferring fewer cuts and then less waste. Available offcuts can move the next wall’s joints. Minimum lengths are reserved on the exposed face and full-thickness body of angled and coped pieces. These are deterministic, auditable heuristics rather than guarantees of a global minimum. Room order remains an input. See [the benchmark definitions and iteration results](benchmarks/README.md). - Board counts exclude an extra allowance for damage or mistakes. Spacer, minimum size and staggering defaults are editable starting values; follow the exact product's installation instructions. For example, [Quick-Step's installation guidance](https://www.quick-step.co.uk/en-gb/laminate/installation) provides product-specific manuals, and its [room-transition guidance](https://www.quick-step.co.uk/en-gb/frequently-asked-questions/laminate/preparation-and-installation/do-i-really-need-expansion-gaps-between-every-room) explains separate expansion joints. - Links are bounded to 20 rooms, 100 walls per room and 30 doors per wall. Flooring calculations are bounded to 2,000 purchased boards and 500 rows per room. Large plans may take longer; very large projects should be split into separate links. Long links may exceed the limits of a messaging service. @@ -56,7 +60,8 @@ npm run check # Build, unit tests and browser tests | `src/lib/geometry.ts` | Vector tracing, closure, validation and polygon offsets | | `src/lib/flooring.ts` | Row search, clipping, factory-end-aware stock allocation | | `src/lib/skirting.ts` | Corner allowances, run splitting and stock allocation | -| `src/lib/url-state.ts` | Readable query schema, legacy links and input validation | +| `src/lib/url-state.ts` | Readable query schema and input validation | +| `src/lib/limits.ts` | Shared project, persistence and calculation bounds | | `src/lib/piece-labels.ts` | Collision-free cut-code placement and pointers for small pieces | | `src/lib/board-tracking.ts` | Exact board/cut matching and room-name search | | `src/lib/wall-editing.ts` | Wall insertion and reordering with stable IDs | diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..f4e9c03 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,94 @@ +# Optimiser measurements + +Run `npm run benchmark` for **Minimise cuts**, or `npm run benchmark -- --random` for +**Maximise randomness**. Both are compared with the original stock-first optimiser's +frozen results in `baseline.json`. The 84 inputs in `cases.ts` cover rectangular +remainders, fireplaces in both directions, room-order permutations, tight and exact +stagger limits, fractional stock, large floors, and both supplied angled rooms. +`--output=/tmp/results.json` saves a run; `--compare=/tmp/results.json` compares +against a previous iteration. Do not overwrite the baseline to hide regressions. + +## Fixed metrics + +- **Saw passes:** crosscuts actually required by the allocation, long-edge rips, + and additional shaping edges. Skirting includes bevel and coping operations. +- **Cut difficulty:** shaping passes, then rip passes, used to break equal saw-count + ties. This is an explicit proxy, not an estimate of minutes or tool-specific effort. +- **Random appearance:** the existing repetition penalty, evaluated independently + from the output rows. It penalises nearby repeating joints, similar consecutive + steps and staircases, including steps that wrap across a board length. Raw repeat, + similar-step and staircase counts are also reported. A lower score is better. +- **Waste:** purchased stock minus installed material, including kerf. The report + gives board counts and waste percentages weighted by purchased stock. +- **Validity and runtime:** errors, elapsed time, p95 and maximum case time. Tests + separately verify coverage, non-overlapping row bands, minimum sizes, stagger, + factory ends, rounded stock consumption, cut accounting, references and input purity. + +Minimise cuts orders `(passes, shaping, rips, pattern penalty, boards)`. +Maximise randomness orders `(pattern penalty, passes, shaping, rips, boards)`. +Validity precedes either objective. Skirting orders `(passes, waste)`. + +## Measured results + +Totals across the same 84 cases, September 2026: + +| Metric | Original | Minimise cuts | Maximise randomness | +| ------------------- | -------: | ------------: | ------------------: | +| Flooring saw passes | 2,177 | **1,741** | **1,994** | +| Shaping passes | 192 | 184 | 185 | +| Rip passes | 414 | 402 | 405 | +| Pattern penalty | 265 | 1,349 | **113** | +| Flooring boards | 3,561 | 3,537 | 3,579 | +| Flooring waste | 8.20% | 7.57% | 8.66% | +| Invalid cases | 0 | 0 | 0 | + +Minimise cuts saves **20.0%** of saw passes. Maximise randomness reduces the pattern +penalty **57.4%**, while also saving **8.4%** of saw passes. For random flooring in the +default 4,800 × 3,600 mm room, 38 passes become **24** or **30**, respectively; both options use 76 +boards and have approximately 1.2% waste. + +Skirting drops from **1,447 to 1,436 passes**. It uses 521 boards instead of 513: +cutting effort now takes precedence over waste, which rises from 9.28% to 10.68%. +Eight cases improve and 76 tie. +For flooring, 78 cut-priority cases improve and six tie. Appearance priority improves +72, ties 11, and uses one extra cut in `rectangle-38` despite the same zero pattern +penalty. The bounded search does not establish global optimality. + +## Iterations retained + +1. Correct the objective order; measuring stock first contradicted the requested priorities. +2. Deduplicate the complete relevant state, including recent joint history. Stop + computing keys once the beam is full; share immutable exhausted stocks and avoid + searching offcuts for pieces requiring both factory ends. +3. Repack completed boundary blanks, reserving exact complementary factory ends + first. Preserve the original placement and cutting order within each source board. +4. Try bounded swaps of interchangeable rectangular rows. Accept only a lower + pattern penalty with unchanged cuts, stock and geometry coverage. +5. Polish all surviving completed candidates, retaining the best result. +6. Preserve end remnants during search instead of trimming them immediately. + This alone improves 56 cut-priority cases and saves another 99 passes after the + earlier improvements. Final packing can still combine non-exact blanks. +7. When repetition remains, compare an immediate-reuse search as well. This improves + five appearance-priority cases. All searches share their clipping cache, and the + two UI choices select from the completed results. Selection itself does not rerun + the optimiser. +8. Compare exact three-piece skirting fills with the original packing. This improves + two cases for a small additional calculation cost. + +## Experiments rejected + +Increasing beam widths from 5/2 to 12/4 roughly tripled search time, with regressions +in both modes. Carrying two room results into the next room worsened two appearance +cases for a single waste improvement. Expanding the offcut candidate pool with +distinct remnants had no cut-priority benefit and regressed a large-room result, +including after the end-preservation change. These variants were removed. + +The original single-result benchmark took about 1.5 seconds for the corpus. Producing +both choices, including the conditional extra appearance search, takes about 3 seconds +on the same machine; p95 is around 110 ms and the most complex case around 0.7 seconds. +These are indicative timings, not CI thresholds. Ordinary option changes reuse the +already calculated plans. A separate 500-row, 2,000-board boundary check completes +without an error; temporary stock is repacked before enforcing the board limit. + +Tests enforce the retained aggregate improvements as well as physical invariants. +Metric definitions and input cases stay fixed while search strategies change. diff --git a/benchmarks/cases.ts b/benchmarks/cases.ts index 593d19e..0ad1aac 100644 --- a/benchmarks/cases.ts +++ b/benchmarks/cases.ts @@ -54,8 +54,22 @@ export function plannerCases(): PlannerCase[] { add('large-rectangle', [room(14020, 8020)]); add('long-room', [room(30020, 1820)]); cases.push( - { name: 'angled', project: angled.project as Project }, - { name: 'angled-f8', project: angledF8.project as Project }, + { + name: 'angled', + project: { + ...angled.project, + version: defaultProject().version, + flooring: { ...defaultProject().flooring, ...angled.project.flooring }, + } as Project, + }, + { + name: 'angled-f8', + project: { + ...angledF8.project, + version: defaultProject().version, + flooring: { ...defaultProject().flooring, ...angledF8.project.flooring }, + } as Project, + }, ); return cases; } diff --git a/e2e/landing.spec.ts b/e2e/landing.spec.ts index 61ddf24..6c5f8a4 100644 --- a/e2e/landing.spec.ts +++ b/e2e/landing.spec.ts @@ -5,14 +5,19 @@ test('the home page explains the app and its guides fit the screen', async ({ pa page.on('pageerror', (error) => errors.push(error.message)); await page.goto('/'); await expect(page.getByRole('heading', { level: 1 })).toHaveText( - 'A great floorstarts witha little planning.', + 'A great floor starts with a little planning.', ); await expect(page.getByRole('heading', { name: 'Scotia or skirting board?' })).toBeVisible(); - await expect(page.getByRole('link', { name: 'Plan my floor' })).toHaveCount(2); + await expect(page.getByRole('link', { name: 'Plan my floor' })).toHaveCount(1); await expect(page.getByRole('link', { name: 'Plan my floor' }).first()).toBeInViewport(); await page.getByRole('link', { name: 'First time fitting a floor?' }).click(); await expect(page).toHaveURL(/#guide$/); await expect(page.getByRole('heading', { name: /Your first floor/ })).toBeInViewport(); + await expect(page.getByRole('link', { name: 'Open planner' })).toBeInViewport(); + const header = await page.getByRole('banner').boundingBox(); + const guide = await page.getByRole('heading', { name: /Your first floor/ }).boundingBox(); + expect(header!.y).toBe(0); + expect(guide!.y).toBeGreaterThanOrEqual(header!.height); await page.reload(); await expect(page.getByRole('heading', { name: /Your first floor/ })).toBeInViewport(); await page.getByRole('link', { name: 'Back to top' }).click(); @@ -26,16 +31,19 @@ test('the home page explains the app and its guides fit the screen', async ({ pa for (const [name, selector] of [ ['header', '.landing-header'], ['hero', '.landing-hero'], - ['closing', '.landing-final-cta'], ]) { test(`the ${name} CTA opens a working planner and browser history returns home`, async ({ page, }) => { await page.goto('/'); + if (name === 'header') { + await page.getByRole('contentinfo').scrollIntoViewIfNeeded(); + await expect(page.getByRole('link', { name: 'Open planner' })).toBeInViewport(); + } await page.locator(selector).locator('a[href="?planner"]').click(); await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible(); await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800'); - await expect(page).toHaveURL(/\?version=1&/); + await expect(page).toHaveURL(/\?version=\d+&/); await page.getByLabel('Room name', { exact: true }).fill('My first floor'); const projectUrl = page.url(); await page.reload(); diff --git a/e2e/planner.spec.ts b/e2e/planner.spec.ts index b956aed..f590e38 100644 --- a/e2e/planner.spec.ts +++ b/e2e/planner.spec.ts @@ -1,6 +1,5 @@ import { expect, test } from '@playwright/test'; import type { Locator, Page } from '@playwright/test'; -import LZString from 'lz-string'; import { readFile } from 'node:fs/promises'; import { decodeState, encodeState } from '../src/lib/url-state'; import { PROJECT_STORAGE_KEY } from '../src/lib/use-project'; @@ -9,6 +8,7 @@ import { planFlooring } from '../src/lib/flooring'; import { planSkirting } from '../src/lib/skirting'; import { flooringCuts, skirtingCuts, uncutFlooring } from '../src/lib/cut-list'; import { cutMm } from '../src/lib/format'; +import type { UrlState } from '../src/lib/types'; const errors = new WeakMap(); const panel = (page: Page, section: string) => @@ -22,7 +22,7 @@ const skirtCodes = (page: Page) => skirtEntries(page).getByRole('button', { name: /^Preview skirting cut / }); const state = (page: Page) => { const url = new URL(page.url()); - return decodeState(url.search || url.hash).state; + return decodeState(url.search).state; }; const savedQuery = (page: Page) => page.evaluate((key) => localStorage.getItem(key), PROJECT_STORAGE_KEY); @@ -60,6 +60,7 @@ function shortFireplaceFixture() { length: 1200, width: 190, pattern: 'random', + optimisation: 'cuts', minOffset: 100, maxOffset: 1000, minLength: 10, @@ -265,7 +266,7 @@ test.beforeEach(async ({ page }) => { page.on('pageerror', (error) => errors.get(page)!.push(error.message)); await page.goto('/?planner'); await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible(); - await expect(page).toHaveURL(/\?version=1&/); + await expect(page).toHaveURL(/\?version=2&/); }); test.afterEach(async ({ page }) => { @@ -345,10 +346,14 @@ test('source-board groups contain every numbered cut once and leave full boards } await expect(page.locator('.material-cuts table')).toHaveCount(0); await expect(preview(page).locator('.floor-piece')).toHaveCount(floor.pieces.length); - await expect(preview(page).locator('.skirt-piece')).toHaveCount(skirt.pieces.length); await expect(preview(page).getByRole('button', { name: /^Select flooring cut / })).toHaveCount( cutFloor.length, ); + await page + .getByRole('group', { name: 'Plan view' }) + .getByRole('button', { name: 'Skirting', exact: true }) + .click(); + await expect(preview(page).locator('.skirt-piece')).toHaveCount(skirt.pieces.length); await expect(preview(page).getByRole('button', { name: /^Select skirting cut / })).toHaveCount( cutSkirt.length, ); @@ -539,7 +544,7 @@ test('internal skirting corners retain coping blanks and expose the installed mi expect( skirt.pieces.every( (piece) => - Math.abs(piece.length - 2400) < 0.001 && Math.abs(piece.visibleLength! - 2364) < 0.001, + Math.abs(piece.length - 2400) < 0.001 && Math.abs(piece.visibleLength - 2364) < 0.001, ), ).toBe(true); const receiver = skirt.pieces.find((piece) => piece.wallIndex === 0)!; @@ -580,6 +585,7 @@ test('internal skirting corners retain coping blanks and expose the installed mi expect(piece.leftOverlap).toBeCloseTo(18, 3); await page.keyboard.press('Escape'); const floorCount = await floorEntries(page).count(); + await page.getByText('Advanced skirting settings', { exact: true }).click(); await page.getByLabel('Minimum piece', { exact: true }).fill('2365'); await expect(skirtEntries(page)).toHaveCount(0); await expect(floorEntries(page)).toHaveCount(floorCount); @@ -666,6 +672,7 @@ test('pattern, direction and spacers persist without steps, layers or stored res expect(state(page).project.rooms[0].direction).toBe(90); expect(Object.keys(state(page).view).sort()).toEqual([ 'collapsedSections', + 'material', 'roomId', 'search', 'selectedPiece', @@ -713,6 +720,7 @@ test('localStorage restores all project and view parameters when reopening witho selectedPiece: 'F1', search: 'Study', collapsedSections: ['cuts', 'materials'], + material: 'flooring', }; const query = encodeState(fixture); await page.goto(`/${query}`); @@ -760,7 +768,7 @@ for (const saved of ['damaged data', '?flooring.length=broken']) { [PROJECT_STORAGE_KEY, saved], ); await page.goto('/?planner'); - await expect(page).toHaveURL(/\?version=1&/); + await expect(page).toHaveURL(/\?version=2&/); await expect(page.locator('.url-error')).toHaveCount(0); expect(state(page)).toEqual(defaultState()); expect(await savedQuery(page)).toBe(encodeState(defaultState())); @@ -869,6 +877,8 @@ test('shared URLs reopen the selected preview and copying the link preserves the }); test('saw kerf help works with keyboard and touch or mouse', async ({ page, isMobile }) => { + await page.getByText('Advanced flooring settings', { exact: true }).click(); + await page.getByText('Advanced skirting settings', { exact: true }).click(); for (const name of ['What is saw kerf?', 'What is skirting saw kerf?']) { const button = page.getByRole('button', { name, exact: true }); await button.focus(); @@ -904,6 +914,10 @@ test('invalid URLs recover when measurements are edited', async ({ page }) => { }); test('clicking the skirting code itself opens its cutting dialog', async ({ page }) => { + await page + .getByRole('group', { name: 'Plan view' }) + .getByRole('button', { name: 'Skirting', exact: true }) + .click(); for (const target of ['rect', 'text']) { const code = preview(page).locator('.skirting-code').first(); const id = await code.evaluate((element) => @@ -920,18 +934,14 @@ test('clicking the skirting code itself opens its cutting dialog', async ({ page } }); -test('readable query fields, legacy links and browser history preserve the plan', async ({ - page, -}) => { - const legacy = defaultState(); - legacy.project.rooms[0].name = 'Legacy lounge'; - legacy.project.flooring.pattern = 'random'; - const hash = `#plan=${LZString.compressToEncodedURIComponent(JSON.stringify(legacy))}`; - await page.goto(`/${hash}`); - await expect(page).toHaveURL(/\?version=1&/); - expect(new URL(page.url()).hash).toBe(''); - expect(state(page)).toEqual(legacy); - expect(await savedQuery(page)).toBe(encodeState(legacy)); +test('readable query fields and browser history preserve the plan', async ({ page }) => { + const initial = defaultState(); + initial.project.rooms[0].name = 'Current lounge'; + initial.project.flooring.pattern = 'random'; + await page.goto(`/${encodeState(initial)}`); + await expect(page).toHaveURL(/\?version=2&/); + expect(state(page)).toEqual(initial); + await expect.poll(() => savedQuery(page)).toBe(encodeState(initial)); const original = page.url(); await page.getByRole('button', { name: 'Add room', exact: true }).click(); await page.getByLabel('Room name', { exact: true }).fill('Study'); @@ -945,7 +955,7 @@ test('readable query fields, legacy links and browser history preserve the plan' expect([...params.keys()].some((key) => /boardCount|polygon|pieceIds/.test(key))).toBe(false); await page.goBack(); await expect(page).toHaveURL(original); - await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Legacy lounge'); + await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Current lounge'); expect(state(page).project.rooms).toHaveLength(1); expect(await savedQuery(page)).toBe(new URL(original).search); await page.goForward(); @@ -961,35 +971,6 @@ test('readable query fields, legacy links and browser history preserve the plan' expect(state(page).project.rooms).toHaveLength(2); }); -test('legacy skirting sizes migrate to one editable length and reset changed allocations', async ({ - page, -}) => { - const initial = defaultState(); - const legacy = { - ...initial, - project: { - ...initial.project, - completedBoards: ['skirting:S1'], - skirting: { ...initial.project.skirting, stockLength: undefined, stockLengths: [3600, 2400] }, - }, - }; - await page.goto(`/#plan=${LZString.compressToEncodedURIComponent(JSON.stringify(legacy))}`); - await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('3600'); - expect(state(page).project.skirting.stockLength).toBe(3600); - expect(state(page).project.completedBoards).toEqual([]); - const params = new URL(page.url()).searchParams; - expect(params.get('skirting.length')).toBe('3600'); - expect(params.has('skirting.stock')).toBe(false); - await page.getByLabel('Skirting board length', { exact: true }).fill('3000'); - await page.reload(); - await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('3000'); - await page.goto('/?skirting.stock=2400&skirting.stock=2400&done=skirting:S1'); - await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('2400'); - expect(state(page).project.completedBoards).toEqual([]); - expect(new URL(page.url()).searchParams.get('skirting.length')).toBe('2400'); - expect(new URL(page.url()).searchParams.has('skirting.stock')).toBe(false); -}); - test('printing includes every room and every cut once, grouped by its source board', async ({ page, }) => { @@ -1173,6 +1154,7 @@ test('inserting and reordering walls keeps stable wall IDs and attached doors', if (!isMobile) { const source = page.locator(`.wall-row[data-wall-id="${doorWall.id}"] .wall-drag-handle`); const target = page.locator(`.wall-row[data-wall-id="${originalIds[0]}"]`); + await page.locator('.wall-table').evaluate((table) => table.scrollIntoView({ block: 'start' })); await source.dragTo(target); expect(state(page).project.rooms[0].walls[0]).toEqual(doorWall); } @@ -1201,9 +1183,8 @@ test('the supplied angled room keeps W12 lines and labels inside the drawing', a new URL('../tests/fixtures/angled-room.json', import.meta.url), 'utf8', ); - const fixture = decodeState(`#plan=${LZString.compressToEncodedURIComponent(angledRoom)}`); - expect(fixture.error).toBeNull(); - await page.goto(`/${encodeState(fixture.state)}`); + const fixture = JSON.parse(angledRoom) as UrlState; + await page.goto(`/${encodeState(fixture)}`); await expect(preview(page).locator('.wall-drawing')).toHaveCount(12); for (const width of isMobile ? [320, 390] : [1280]) { await page.setViewportSize({ width, height: 844 }); @@ -1317,11 +1298,10 @@ test('the supplied F8 room aligns a row seam with its fireplace corner and keeps new URL('../tests/fixtures/angled-room-f8.json', import.meta.url), 'utf8', ); - const fixture = decodeState(`#plan=${LZString.compressToEncodedURIComponent(json)}`); - expect(fixture.error).toBeNull(); - fixture.state.view.selectedPiece = null; - fixture.state.view.search = ''; - await page.goto(`/${encodeState(fixture.state)}`); + const fixture = JSON.parse(json) as UrlState; + fixture.view.selectedPiece = null; + fixture.view.search = ''; + await page.goto(`/${encodeState(fixture)}`); const { floor, skirt } = plans(page); const corner = { x: 7607.513, y: 2160.34 }; const atCorner = floor.pieces.filter((piece) => @@ -1355,7 +1335,7 @@ test('the supplied F8 room aligns a row seam with its fireplace corner and keeps dimensions.some(({ actual }) => Math.abs(actual * 2 - Math.round(actual * 2)) > 0.001), ).toBe(true); expectHalfMillimetreCuts(dimensions); - expect(state(page).project.rooms[0].walls).toEqual(fixture.state.project.rooms[0].walls); + expect(state(page).project.rooms[0].walls).toEqual(fixture.project.rooms[0].walls); await page.keyboard.press('Escape'); const skirtCut = skirtingCuts(skirt).find( (piece) => @@ -1399,6 +1379,7 @@ test('completed source boards and locate actions follow shared offcuts across ro const sourcePieces = floor.pieces.filter((piece) => piece.stockId === shared.id); const key = `floor:${shared.id}`; const group = page.locator(`.stock-cut-group[data-board-key="${key}"]`); + await expect(group).toBeVisible(); const groupCodes = await group .locator('.board-cut') .evaluateAll((cuts) => cuts.map((cut) => cut.getAttribute('data-cut-id')).join(' + ')); @@ -1464,6 +1445,10 @@ test('completed source boards and locate actions follow shared offcuts across ro .getByRole('checkbox') .check(); expect(state(page).project.completedBoards).toEqual([key, skirtKey]); + await page + .getByRole('group', { name: 'Plan view' }) + .getByRole('button', { name: 'Skirting', exact: true }) + .click(); expect(await preview(page).locator('.skirt-piece[data-done="true"]').count()).toBeGreaterThan(0); await page.getByLabel('Board width', { exact: true }).fill('201'); expect(state(page).project.completedBoards).toEqual([]); @@ -1508,6 +1493,7 @@ test('cuts from one board share the available width and wrap on a narrow phone', .filter({ has: page.locator('.board-cut:nth-child(2)') }) .first(); const pieces = group.locator('.board-cut'); + await expect(group).toBeVisible(); expect(await pieces.count()).toBeGreaterThan(1); const first = (await pieces.nth(0).boundingBox())!; const second = (await pieces.nth(1).boundingBox())!; @@ -1558,6 +1544,7 @@ for (const width of [320, 390]) { }) => { await page.setViewportSize({ width, height: 844 }); await openWalls(page); + await page.getByText('Advanced flooring settings', { exact: true }).click(); await page.getByRole('button', { name: 'What is saw kerf?', exact: true }).click(); await noHorizontalOverflow(page); await expect(page.getByRole('tooltip')).toBeVisible(); diff --git a/e2e/usability.spec.ts b/e2e/usability.spec.ts new file mode 100644 index 0000000..880a4e5 --- /dev/null +++ b/e2e/usability.spec.ts @@ -0,0 +1,160 @@ +import { expect, test } from '@playwright/test'; +import { defaultState } from '../src/lib/defaults'; +import { decodeState, encodeState } from '../src/lib/url-state'; + +test.beforeEach(async ({ page }) => { + await page.goto('/?planner'); + await expect(page.getByRole('heading', { name: 'Room measurements', exact: true })).toBeVisible(); +}); + +test('new plans start random and guide custom measurements through to the cut plan', async ({ + page, +}) => { + await expect(page.getByRole('button', { name: 'Random', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + await expect(page.locator('#custom-shape-help')).toContainText('Click Custom'); + await page.getByRole('button', { name: 'Custom', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Custom', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + await expect(page.getByLabel('Wall 1 length', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Add a wall', exact: true }).click(); + await page.getByLabel('Wall 2 length', { exact: true }).fill('3000'); + await page.getByRole('button', { name: 'Add a wall', exact: true }).click(); + await page.getByLabel('Wall 3 length', { exact: true }).fill('4000'); + await page.getByRole('button', { name: 'Add closing wall', exact: true }).click(); + await expect(page.getByText('Outline closed', { exact: true })).toBeVisible(); + await expect(page.getByLabel('Wall 4 length', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Custom', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + + await page.getByRole('button', { name: 'Materials', exact: true }).click(); + await expect(page.getByLabel('Board length', { exact: true })).toBeHidden(); + await page.getByRole('button', { name: 'Materials', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Materials', exact: true })).toBeFocused(); + await expect(page.getByLabel('Board length', { exact: true })).toBeVisible(); + await expect(page.getByLabel('Saw kerf', { exact: true })).toBeHidden(); + await page.getByText('Advanced flooring settings', { exact: true }).click(); + await page.getByLabel('Saw kerf', { exact: true }).fill('4'); + expect(decodeState(new URL(page.url()).search).state.project.flooring.kerf).toBe(4); + + await page.getByRole('button', { name: 'Cut plan', exact: true }).click(); + await page.getByRole('button', { name: 'Cut plan', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Cut plan', exact: true })).toBeFocused(); + await expect(page.getByLabel('Find a cut or room', { exact: true })).toBeVisible(); +}); + +test('the top switch shows one material and preserves inputs and progress across reloads', async ({ + page, +}) => { + const preview = page.getByRole('region', { name: 'Floor plan preview' }); + const toggle = page.getByRole('group', { name: 'Plan view', exact: true }); + await expect(toggle.getByRole('button', { name: 'Floorboards', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + expect(await preview.locator('.floor-piece').count()).toBeGreaterThan(0); + await expect(preview.locator('.skirt-piece')).toHaveCount(0); + await page.locator('.stock-cut-group input[type="checkbox"]').first().check(); + const project = decodeState(new URL(page.url()).search).state.project; + await toggle.getByRole('button', { name: 'Skirting', exact: true }).click(); + await expect(preview.locator('.floor-piece')).toHaveCount(0); + expect(await preview.locator('.skirt-piece').count()).toBeGreaterThan(0); + await expect(preview.getByText('Flooring cuts', { exact: true })).toHaveCount(0); + expect(decodeState(new URL(page.url()).search).state.project).toEqual(project); + await page.reload(); + await expect(toggle.getByRole('button', { name: 'Skirting', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + await expect(preview.locator('.floor-piece')).toHaveCount(0); + await page.goto('/?planner'); + await expect(toggle.getByRole('button', { name: 'Skirting', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + expect(decodeState(new URL(page.url()).search).state.project).toEqual(project); + await toggle.getByRole('button', { name: 'Floorboards', exact: true }).click(); + expect(await preview.locator('.floor-piece').count()).toBeGreaterThan(0); + await expect(preview.locator('.skirt-piece')).toHaveCount(0); +}); + +test('saved brick plans keep their pattern and explicit links restore the selected material', async ({ + page, +}) => { + const state = defaultState(); + state.project.flooring.pattern = 'brick'; + state.view.material = 'skirting'; + await page.goto(`/${encodeState(state)}`); + await expect(page.getByRole('button', { name: 'Brick', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + await expect( + page + .getByRole('group', { name: 'Plan view' }) + .getByRole('button', { name: 'Skirting', exact: true }), + ).toHaveAttribute('aria-pressed', 'true'); + await page.goto('/?planner'); + await expect(page.getByRole('button', { name: 'Brick', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true', + ); +}); + +test('locating cuts changes material automatically and printing includes both materials', async ({ + page, +}) => { + const preview = page.getByRole('region', { name: 'Floor plan preview' }); + const toggle = page.getByRole('group', { name: 'Plan view', exact: true }); + await page.locator('.skirting-cuts .board-group-actions .board-locate').first().click(); + await expect(toggle.getByRole('button', { name: 'Skirting', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + expect(await preview.locator('.skirt-piece[data-highlighted="true"]').count()).toBeGreaterThan(0); + await page.getByRole('button', { name: 'Show all pieces', exact: true }).click(); + await page.getByLabel('Find a cut or room', { exact: true }).fill('F1'); + await expect(toggle.getByRole('button', { name: 'Floorboards', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + await expect(preview.locator('.floor-piece[data-piece-id="F1"]')).toHaveAttribute( + 'data-highlighted', + 'true', + ); + await toggle.getByRole('button', { name: 'Skirting', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Show all pieces', exact: true })).toHaveCount(0); + await preview + .getByRole('button', { name: /^Select skirting cut / }) + .first() + .click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.keyboard.press('Escape'); + await page.emulateMedia({ media: 'print' }); + expect(await page.locator('.print-plan .floor-piece').count()).toBeGreaterThan(0); + expect(await page.locator('.print-plan .skirt-piece').count()).toBeGreaterThan(0); +}); + +test('setup and the pinned view switch fit a narrow phone', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 844 }); + await page.getByRole('button', { name: 'Materials', exact: true }).scrollIntoViewIfNeeded(); + const heading = await page.getByRole('button', { name: 'Materials', exact: true }).boundingBox(); + const drawing = await page.getByRole('complementary', { name: 'Room drawing' }).boundingBox(); + expect(heading!.y).toBeGreaterThanOrEqual(drawing!.y + drawing!.height); + await page + .getByRole('group', { name: 'Plan view' }) + .getByRole('button', { name: 'Skirting', exact: true }) + .click(); + await page.getByText('Advanced flooring settings', { exact: true }).click(); + await page.getByText('Advanced skirting settings', { exact: true }).click(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320); + const toggle = await page.getByRole('group', { name: 'Plan view' }).boundingBox(); + expect(toggle!.y).toBeGreaterThanOrEqual(0); + expect(toggle!.y + toggle!.height).toBeLessThan(844); +}); diff --git a/package-lock.json b/package-lock.json index 2805792..a314499 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "@plausible-analytics/tracker": "^0.4.6", "clipper-lib": "^6.4.2", "lucide-react": "^0.468.0", - "lz-string": "^1.5.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, @@ -1757,15 +1756,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/package.json b/package.json index 966c69e..9169465 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "@plausible-analytics/tracker": "^0.4.6", "clipper-lib": "^6.4.2", "lucide-react": "^0.468.0", - "lz-string": "^1.5.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/scripts/benchmark-planner.mjs b/scripts/benchmark-planner.mjs index 320577d..dded4e0 100644 --- a/scripts/benchmark-planner.mjs +++ b/scripts/benchmark-planner.mjs @@ -1,7 +1,11 @@ import { readFile, writeFile } from 'node:fs/promises'; import { createServer } from 'vite'; -const server = await createServer({ server: { middlewareMode: true }, appType: 'custom' }); +const server = await createServer({ + server: { middlewareMode: true }, + appType: 'custom', + optimizeDeps: { noDiscovery: true, include: [] }, +}); try { const [{ planFlooring }, { planSkirting }, { plannerCases }, metrics] = await Promise.all([ server.ssrLoadModule('/src/lib/flooring.ts'), @@ -65,6 +69,22 @@ try { if (regressions.length) console.log(`${kind} regressions: ${regressions.join(', ')}`); } console.table(summary); + for (const kind of ['flooring', 'skirting']) { + const wastePercent = (rows) => { + const purchased = rows.reduce((total, row) => { + const { project } = cases.find((c) => c.name === row.name); + const size = + kind === 'flooring' + ? project.flooring.length * project.flooring.width + : project.skirting.stockLength; + return total + row[kind].boards * size; + }, 0); + return ((sum(rows, kind, 'waste') / purchased) * 100).toFixed(2); + }; + console.log( + `${kind} waste: ${wastePercent(baseline)}% → ${wastePercent(results)}% (weighted by purchased stock)`, + ); + } console.table( ['shapingCuts', 'ripCuts', 'patternPenalty', 'repeats', 'similarSteps', 'staircases'].map( (metric) => ({ diff --git a/src/App.tsx b/src/App.tsx index 8ee4119..8aa2cde 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,8 +9,9 @@ import { planFlooringOptions } from './lib/flooring'; import { planSkirting } from './lib/skirting'; import { area, mm } from './lib/format'; import { flooringCuts, isSkirtingCut, skirtingCuts } from './lib/cut-list'; -import type { PlannerSection, Project, Room } from './lib/types'; +import type { PlanMaterial, PlannerSection, Project, Room } from './lib/types'; import { boardSearchLabel, findBoardMatches } from './lib/board-tracking'; +import { PRECISION, PROJECT_LIMITS } from './lib/limits'; import { OutlineEditor } from './components/OutlineEditor'; import { MaterialsEditor } from './components/MaterialsEditor'; import { FloorPlan } from './components/FloorPlan'; @@ -22,12 +23,16 @@ import { Issues } from './components/Fields'; function PlannerPanel({ section, title, + step, + description, open, toggle, children, }: { section: PlannerSection; title: string; + step: number; + description: string; open: boolean; toggle: () => void; children: ReactNode; @@ -37,15 +42,22 @@ function PlannerPanel({

@@ -77,7 +89,7 @@ export default function App() { () => planFlooringOptions(project.rooms, project.flooring, project.tolerance), [project.rooms, flooringInputs, project.tolerance], ); - const flooring = flooringOptions[project.flooring.optimisation ?? 'cuts']; + const flooring = flooringOptions[project.flooring.optimisation]; const skirting = useMemo( () => planSkirting(project.rooms, project.skirting, project.tolerance), [project.rooms, project.skirting, project.tolerance], @@ -91,8 +103,8 @@ export default function App() { const wholeBoards = flooring.pieces.filter( (p) => !p.cut && - Math.abs(p.length - project.flooring.length) < 0.01 && - Math.abs(p.width - project.flooring.width) < 0.01, + Math.abs(p.length - project.flooring.length) < PRECISION.millimetres && + Math.abs(p.width - project.flooring.width) < PRECISION.millimetres, ).length; const wholeSkirting = skirting.pieces.filter((p) => !isSkirtingCut(p, skirting)).length; @@ -111,13 +123,30 @@ export default function App() { [project, flooring, skirting, view.search], ); const searchLabel = boardSearchLabel(project, flooring, skirting, view.search); + const visibleMatchCount = (view.material === 'flooring' ? flooring : skirting).pieces.filter( + (piece) => matches.pieceIds.includes(piece.id), + ).length; + const materialForPieces = (ids: string[], current: PlanMaterial): PlanMaterial => { + const hasFloor = flooring.pieces.some((piece) => ids.includes(piece.id)); + const hasSkirting = skirting.pieces.some((piece) => ids.includes(piece.id)); + return hasFloor && !hasSkirting ? 'flooring' : hasSkirting && !hasFloor ? 'skirting' : current; + }; const setSearch = (search: string) => update((s) => { const found = findBoardMatches(s.project, flooring, skirting, search); const roomId = found.roomIds.includes(s.view.roomId) ? s.view.roomId : (found.roomIds[0] ?? s.view.roomId); - return { ...s, view: { ...s.view, search, roomId, selectedPiece: null } }; + return { + ...s, + view: { + ...s.view, + search, + roomId, + selectedPiece: null, + material: materialForPieces(found.pieceIds, s.view.material), + }, + }; }); const togglePanel = (section: PlannerSection) => update((s) => ({ @@ -140,13 +169,27 @@ export default function App() { }, })); const locatePiece = (id: string, roomId: string) => - update((s) => ({ ...s, view: { ...s.view, search: id, roomId, selectedPiece: null } })); + update((s) => ({ + ...s, + view: { + ...s.view, + search: id, + roomId, + selectedPiece: null, + material: materialForPieces([id], s.view.material), + }, + })); const changeRoom = (fn: (r: Room) => Room) => changeProject((p) => ({ ...p, rooms: p.rooms.map((r) => (r.id === room.id ? fn(r) : r)) })); const select = (id: string | null, roomId?: string) => update((s) => ({ ...s, - view: { ...s.view, roomId: roomId ?? s.view.roomId, selectedPiece: id }, + view: { + ...s.view, + roomId: roomId ?? s.view.roomId, + selectedPiece: id, + material: id ? materialForPieces([id], s.view.material) : s.view.material, + }, })); const addRoom = () => { update((s) => { @@ -252,7 +295,11 @@ export default function App() { ))} - @@ -261,9 +308,18 @@ export default function App() {
+
+

Make this plan yours

+

+ Measure your room, choose your boards, then review the cuts. Your plan updates as + you go. +

+
togglePanel('measurements')} > @@ -288,6 +344,8 @@ export default function App() { togglePanel('materials')} > @@ -344,6 +402,8 @@ export default function App() { togglePanel('cuts')} > @@ -364,6 +424,34 @@ export default function App() {