From 9689bfcc18f90feab2abb36f60f8ffbe2e2ea1cc Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Fri, 11 Sep 2026 16:58:56 +0100 Subject: [PATCH] . --- README.md | 38 +- e2e/planner.spec.ts | 334 +++++++++++++--- src/App.tsx | 7 + src/components/CutDialog.tsx | 81 +--- src/components/CutList.tsx | 291 +++++--------- src/components/CutPreview.tsx | 148 +++++-- src/components/DoorPosition.tsx | 64 +++ src/components/FloorPlan.tsx | 50 ++- src/components/MaterialsEditor.tsx | 2 +- src/components/OutlineEditor.tsx | 15 +- src/components/PrintPlan.tsx | 114 ++++++ src/lib/cut-list.ts | 31 +- src/lib/cut-preview.ts | 136 +++++++ src/lib/export.ts | 44 +- src/lib/flooring.ts | 59 ++- src/lib/url-state.ts | 215 ++++++++-- src/lib/use-project.ts | 33 +- src/styles.css | 621 ++++++++++++----------------- tests/cut-list.test.ts | 126 +++--- tests/cut-preview.test.ts | 139 ++++++- tests/export.test.ts | 12 +- tests/flooring.test.ts | 72 ++++ tests/url-state.test.ts | 140 ++++++- 23 files changed, 1816 insertions(+), 956 deletions(-) create mode 100644 src/components/DoorPosition.tsx create mode 100644 src/components/PrintPlan.tsx diff --git a/README.md b/README.md index baa006a..61f06ac 100644 --- a/README.md +++ b/README.md @@ -20,40 +20,40 @@ npm run check # Build, unit tests and browser tests ## Planning workflow 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. -2. Add door openings with a width and an offset from the wall's start. 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. +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. Skirting stock lengths are purchase options, not a fixed inventory. Materials are shared across rooms; direction is per room. -5. The drawing and cut plan update on the same page. Flooring and skirting are visible together, with the whole drawing fitting the page without an internal scrollbar. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Identical flooring cuts share a batch row; each piece has a small shape preview that opens exact dimensions in a popup. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Source allocations identify the stock used by each piece. Export CSV for both materials, or print the plan. -6. Copy the link to save or share the complete project. Measurements, materials, selected room and selected piece live in a compressed, versioned URL fragment. Old links containing steps, layers and zoom still load; those obsolete view choices are ignored. Reloading or opening that link reproduces the plan. Browser back/forward restores room changes. There is no local storage, account, API, database or analytics. Fonts are system fonts; the app makes no external font requests. +5. The drawing and cut plan update on the same page. Flooring and skirting are visible together, with the whole drawing fitting the page without an internal scrollbar. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. 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. 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. Export CSV in the same board order, or 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. Copy the link to save or share the complete project. Measurements, materials, selected room and selected piece live in readable query parameters such as `flooring.length=1200`, `room.1.wall.1.length=4800` and `selectedCut=F1`. 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. There is no local storage, account, API, database or analytics. Fonts are system fonts; the app makes no external font requests. ## Calculation rules and practical limits - Geometry follows measured vectors, checks final-wall length/direction, detects crossings and validates opening bounds and overlaps. A small accepted closure residual snaps only the final wall to the origin, with a visible warning. A mismatch can indicate measurement error or an irregular wall; lengths and corner angles alone cannot identify or reconstruct a bow. Remeasure and represent known changes of direction as additional segments. -- Polygon offsets and clipping use `clipper-lib` at 0.001 mm internal precision. Displayed dimensions are rounded to 0.1 mm. The supplied cut templates contain board-relative polygon vertices and edge lengths for angled and notched pieces. +- Polygon offsets and clipping use `clipper-lib` at 0.001 mm internal precision. List dimensions are rounded to 0.1 mm; cutting diagrams retain up to 0.001 mm. The supplied cut templates contain board-relative polygon vertices and edge lengths for angled and notched pieces. - Laminate is inset from every wall, including fireplace recesses. Interior rows retain full factory width. When the final row would be narrower than the minimum, the first and last rows share the width equally. A bounded search also compares other feasible edge-row arrangements. -- Brick uses a half-board stagger. Random is deterministic, prefers usable offcuts and avoids a repeating two-row sequence when feasible. The offset is the nearest end-joint distance on the adjacent row's grid, not a distance from a wall. Half a board is the largest possible nearest-joint offset. Settings that cannot be met produce an error instead of a partial layout. +- Brick uses a half-board stagger. Random is deterministic and discourages staircase runs, near-equal successive steps and repeating joint alignments, including steps that wrap across a board length. Pattern preferences never invalidate a physically valid plan when tight offset limits force repetition. The offset is the nearest end-joint distance on the adjacent row's grid, not a distance from a wall. Half a board is the largest possible nearest-joint offset. Settings that cannot be met produce an error instead of a partial layout. - Minimum laminate sizes apply to each clipped piece's bounding length/width. Irregular shapes can still have narrow tips or necks; their exact templates are available in the cut popups. Product-specific click systems and assembly sequences are not simulated. - 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 butt joints: the previous wall runs through and the next is shortened by thickness, adjusted for non-square corners. Coping is excluded. External corners use equal mitres. Minimum sizes apply after corner allowances and door subtraction. An unavoidable undersized run is an error. - Skirting cut lengths are the longest-point envelope. Cut square blanks first, then bevel their ends as specified; this keeps reusable stock remainders square. Back lengths and diagram positions locate the wall-facing edge. The stock allocation includes crosscut kerf and counts the additional bevel passes. -- Flooring uses a bounded beam search, minimizing purchased boards, then saw passes. Skirting compares full-stock, balanced and offcut-aware splits and packs them across offered lengths, preferring less waste, then fewer cuts. Available offcuts can move the next wall’s joints. Minimum lengths are reserved on both front and back edges of angled pieces. These are deterministic, auditable heuristics rather than guarantees of a global minimum. Room order is an input and can affect shared-offcut allocation. +- 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 across offered lengths, preferring less waste, then fewer cuts. Available offcuts can move the next wall’s joints. Minimum lengths are reserved on both front and back edges of angled pieces. These are deterministic, auditable heuristics rather than guarantees of a global minimum. Room order is an input and can affect shared-offcut allocation. - 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. ## Code map -| File | Responsibility | -| ------------------------ | ----------------------------------------------------------- | -| `src/lib/types.ts` | Inputs and derived result contracts | -| `src/lib/defaults.ts` | Starter measurements and deterministic IDs | -| `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` | Canonical schema, compression and untrusted-link validation | -| `src/lib/use-project.ts` | URL and browser-history synchronization | -| `src/lib/export.ts` | CSV generation, including formula-injection protection | -| `src/components/` | Editors, SVG drawing and linked cut tables | -| `tests/` | Pure calculation and state tests | -| `e2e/` | Actual browser workflows at desktop and mobile sizes | +| File | Responsibility | +| ------------------------ | ---------------------------------------------------------------- | +| `src/lib/types.ts` | Inputs and derived result contracts | +| `src/lib/defaults.ts` | Starter measurements and deterministic IDs | +| `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/use-project.ts` | URL and browser-history synchronization | +| `src/lib/export.ts` | CSV generation, including formula-injection protection | +| `src/components/` | Editors, dimensioned SVGs, board groups and printable cut sheets | +| `tests/` | Pure calculation and state tests | +| `e2e/` | Actual browser workflows at desktop and mobile sizes | Only the inputs and view selections are persisted. Geometry, plans, quantities, warning messages and drawings are derived. There are no timestamps or random-number calls in the planners. `npm run build` also checks unused variables and strict TypeScript types. diff --git a/e2e/planner.spec.ts b/e2e/planner.spec.ts index 22af99e..e220881 100644 --- a/e2e/planner.spec.ts +++ b/e2e/planner.spec.ts @@ -1,7 +1,9 @@ import { expect, test } from '@playwright/test'; -import type { Page } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import LZString from 'lz-string'; import { readFile } from 'node:fs/promises'; -import { decodeState } from '../src/lib/url-state'; +import { decodeState, encodeState } from '../src/lib/url-state'; +import { defaultState, rectangle } from '../src/lib/defaults'; import { planFlooring } from '../src/lib/flooring'; import { planSkirting } from '../src/lib/skirting'; import { flooringCuts, skirtingCuts, uncutFlooring } from '../src/lib/cut-list'; @@ -9,15 +11,16 @@ import { mm } from '../src/lib/format'; const errors = new WeakMap(); const preview = (page: Page) => page.getByRole('region', { name: 'Floor plan preview' }); -const floorRows = (page: Page) => - page.getByRole('table', { name: 'Flooring cuts and offcut placements' }).locator('tbody tr'); -const skirtRows = (page: Page) => - page.getByRole('table', { name: 'Skirting pieces requiring cuts' }).locator('tbody tr'); +const floorEntries = (page: Page) => page.locator('.flooring-cuts .board-cut'); +const skirtEntries = (page: Page) => page.locator('.skirting-cuts .board-cut'); const floorCodes = (page: Page) => - floorRows(page).getByRole('button', { name: /^Preview flooring cut / }); + floorEntries(page).getByRole('button', { name: /^Preview flooring cut / }); const skirtCodes = (page: Page) => - skirtRows(page).getByRole('button', { name: /^Preview skirting cut / }); -const state = (page: Page) => decodeState(new URL(page.url()).hash).state; + 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; +}; const plans = (page: Page) => { const { project } = state(page); return { @@ -27,6 +30,49 @@ const plans = (page: Page) => { }; }; +async function stockGroups(groups: Locator) { + return groups.evaluateAll((elements) => + elements.map((element) => ({ + stockId: element.getAttribute('data-stock-id'), + codes: [...element.querySelectorAll('[data-cut-id]')] + .map((cut) => cut.getAttribute('data-cut-id')) + .sort(), + })), + ); +} + +function expectedGroups(stocks: { id: string; pieceIds: string[] }[], cuts: { id: string }[]) { + const visible = new Set(cuts.map((piece) => piece.id)); + return stocks + .map((stock) => ({ + stockId: stock.id, + codes: stock.pieceIds.filter((id) => visible.has(id)).sort(), + })) + .filter((group) => group.codes.length); +} + +async function measuredEdges(preview: Locator) { + return preview.evaluate((element) => { + const polygon = element.querySelector('polygon')!; + const points = [...(polygon as SVGPolygonElement).points].map((point) => ({ + x: point.x, + y: point.y, + })); + return [...element.querySelectorAll('.dimension-caption')].map((caption) => { + const edge = Number(caption.getAttribute('data-edge')); + const from = points[edge], + to = points[(edge + 1) % points.length]; + return { + shown: Number( + (caption.querySelector('.dimension-label tspan') ?? + caption.querySelector('.dimension-label'))!.textContent!.replaceAll(',', ''), + ), + actual: Math.hypot(to.x - from.x, to.y - from.y), + }; + }); + }); +} + async function openWalls(page: Page) { if ( !(await page @@ -97,6 +143,7 @@ test.beforeEach(async ({ page }) => { page.on('pageerror', (error) => errors.get(page)!.push(error.message)); await page.goto('/'); await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible(); + await expect(page).toHaveURL(/\?version=1&/); }); test.afterEach(async ({ page }) => { @@ -132,43 +179,37 @@ test('measurements, materials, drawing and both cut lists share one page', async ); }); -test('drawing includes full boards, while numbered cut thumbnails stay complete and sequential', async ({ +test('source-board groups contain every numbered cut once and leave full boards unlabelled', async ({ page, }) => { const { project, floor, skirt } = plans(page); - const cutFloor = flooringCuts(floor); - const cutSkirt = skirtingCuts(skirt); + const cutFloor = flooringCuts(floor), + cutSkirt = skirtingCuts(skirt); const wholeBoards = uncutFlooring(floor, project.flooring).wholeBoards; expect(wholeBoards).toBeGreaterThan(0); await expect(floorCodes(page)).toHaveCount(cutFloor.length); await expect(skirtCodes(page)).toHaveCount(cutSkirt.length); - const shownCodes = await floorCodes(page).evaluateAll((buttons) => - buttons.map((button) => - button.getAttribute('aria-label')!.replace('Preview flooring cut ', ''), - ), - ); + const floorGroups = await stockGroups(page.locator('.flooring-cuts .stock-cut-group')); + const skirtGroups = await stockGroups(page.locator('.skirting-cuts .stock-cut-group')); + expect(floorGroups).toEqual(expectedGroups(floor.stocks, cutFloor)); + expect(skirtGroups).toEqual(expectedGroups(skirt.stocks, cutSkirt)); + const shownCodes = floorGroups.flatMap((group) => group.codes); expect(shownCodes.sort()).toEqual(cutFloor.map((piece) => piece.id).sort()); expect(cutFloor.map((piece) => piece.id)).toEqual(cutFloor.map((_, i) => `F${i + 1}`)); expect(cutSkirt.map((piece) => piece.id)).toEqual(cutSkirt.map((_, i) => `A${i + 1}`)); - expect(await floorRows(page).count(), 'Repeated dimensions share one cutting batch').toBeLessThan( - cutFloor.length, - ); - const batches = await floorRows(page).evaluateAll((rows) => - rows.map((row) => ({ - codes: [...row.querySelectorAll('button[aria-label^="Preview flooring cut "]')].map( - (button) => button.getAttribute('aria-label')!.replace('Preview flooring cut ', ''), - ), - size: row.querySelector('.batch-size')?.textContent?.trim(), - quantity: row.querySelector('.batch-qty')?.textContent?.trim(), - })), - ); - for (const batch of batches) { - expect(batch.quantity).toBe(String(batch.codes.length)); - for (const code of batch.codes) { - const piece = cutFloor.find((piece) => piece.id === code)!; - expect(batch.size).toBe(`${mm(piece.length)} × ${mm(piece.width)}`); - } + for (const piece of cutFloor) { + await expect( + floorEntries(page) + .filter({ + has: page.getByRole('button', { + name: `Preview flooring cut ${piece.id}`, + exact: true, + }), + }) + .locator('.cut-size'), + ).toHaveText(`${mm(piece.length)} × ${mm(piece.width)} mm`); } + 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( @@ -178,7 +219,7 @@ test('drawing includes full boards, while numbered cut thumbnails stay complete cutSkirt.length, ); await expect(page.locator('.flooring-cuts .cut-summary')).toContainText( - `${wholeBoards} full boards laid uncut`, + `${wholeBoards} laid uncut`, ); const drawnCodes = await preview(page).locator('svg text').allTextContents(); for (const piece of floor.pieces.filter((piece) => !piece.cut && !piece.reused)) { @@ -192,19 +233,29 @@ test('drawing includes full boards, while numbered cut thumbnails stay complete preview(page).locator(`.skirt-piece[data-piece-id="${piece.id}"]`), ).toHaveAttribute('aria-hidden', 'true'); } - const repeatedCode = batches.find((batch) => batch.codes.length > 1)!.codes.at(-1)!; - const repeatedPiece = cutFloor.find((piece) => piece.id === repeatedCode)!; + const uncutColor = await preview(page) + .locator('.skirt-piece.uncut > polygon') + .first() + .getAttribute('fill'); + const cutColor = await preview(page).locator('.skirt-cut > polygon').first().getAttribute('fill'); + expect(uncutColor).not.toBe(cutColor); + const piece = cutFloor[0]; const thumbnail = page.getByRole('button', { - name: `Preview flooring cut ${repeatedCode}`, + name: `Preview flooring cut ${piece.id}`, exact: true, }); await expect(thumbnail.locator('svg')).toBeVisible(); await thumbnail.click(); - const dialog = page.getByRole('dialog', { name: `Flooring cut ${repeatedCode}`, exact: true }); + const dialog = page.getByRole('dialog', { name: `Flooring cut ${piece.id}`, exact: true }); await expect(dialog).toBeVisible(); - await expect(dialog.locator('.cut-origin')).toContainText(`Row ${repeatedPiece.row}`); - await expect(dialog.locator('.cut-origin')).toContainText(repeatedPiece.stockId); - await expect(dialog.getByRole('img', { name: `Flooring shape ${repeatedCode}` })).toBeVisible(); + await expect(dialog.locator('.cut-origin')).toHaveText( + `${project.rooms[0].name} · Row ${piece.row}`, + ); + const shape = dialog.getByRole('img', { name: `Flooring shape ${piece.id}` }); + await expect(shape).toBeVisible(); + const dimensions = await measuredEdges(shape); + expect(dimensions).toHaveLength(2); + for (const dimension of dimensions) expect(dimension.shown).toBeCloseTo(dimension.actual, 3); await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0); await expect(thumbnail).toBeFocused(); @@ -217,14 +268,14 @@ test('inconsistent final measurements block cuts and small residuals show the ex await openWalls(page); await page.getByLabel('Wall 4 length', { exact: true }).fill('3700'); await expect(page.getByText(/The outline is open by 100\.0 mm/).first()).toBeVisible(); - await expect(floorRows(page)).toHaveCount(0); - await expect(skirtRows(page)).toHaveCount(0); + await expect(floorEntries(page)).toHaveCount(0); + await expect(skirtEntries(page)).toHaveCount(0); await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled(); await page.getByLabel('Wall 4 length', { exact: true }).fill('3605'); await expect(page.getByText(/non-straight wall/)).toBeVisible(); await expect(page.getByText('Final wall: 3,600 mm expected · 5 mm difference')).toBeVisible(); await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled(); - expect(await floorRows(page).count()).toBeGreaterThan(0); + expect(await floorEntries(page).count()).toBeGreaterThan(0); }); test('room warnings stay with their room even when two room names match', async ({ page }) => { @@ -288,7 +339,14 @@ test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialo await thumbnail.click(); const dialog = page.getByRole('dialog', { name: `Skirting cut ${mitred.id}`, exact: true }); await expect(dialog).toBeVisible(); - await expect(dialog).toContainText(/long point/i); + await expect(dialog).toContainText(/longest points/i); + const lengths = await dialog.locator('.skirting-full-preview .dimension-label').allTextContents(); + expect(lengths.map((text) => Number(text.replaceAll(',', '')))).toEqual( + expect.arrayContaining([ + Number(mitred.length.toFixed(3)), + Number(mitred.backLength.toFixed(3)), + ]), + ); const end = Math.abs(mitred.leftMitre) === 45 ? 'left' : 'right'; const projected = await dialog .locator(`.skirting-cut-edge[data-end="${end}"]`) @@ -319,7 +377,16 @@ test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialo await expect( page.getByRole('dialog', { name: `Flooring cut ${shaped.id}`, exact: true }), ).toBeVisible(); - await expect(page.getByRole('img', { name: `Flooring shape ${shaped.id}` })).toBeVisible(); + const shapedPreview = page + .getByRole('dialog') + .getByRole('img', { name: `Flooring shape ${shaped.id}` }); + await expect(shapedPreview).toBeVisible(); + const dimensions = await measuredEdges(shapedPreview); + expect(dimensions).toHaveLength(shaped.template.length); + for (const dimension of dimensions) expect(dimension.shown).toBeCloseTo(dimension.actual, 3); + await expect( + page.getByRole('dialog').locator('.template-coordinates, .vertex-label, .cut-measures'), + ).toHaveCount(0); await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0); await expect(page.locator('.selected-cut')).toHaveCount(0); @@ -331,12 +398,19 @@ test('door positions and material lengths update skirting immediately', async ({ await expect(page.locator('.door-card')).toHaveCount(2); await page.locator('.door-card').last().getByRole('combobox').selectOption({ label: 'W1' }); const movedDoor = page.locator('.door-card').first(); - await movedDoor.getByLabel('Door offset', { exact: true }).fill('1200'); + await expect(movedDoor.locator('.door-position')).toContainText( + 'W1: measure from W4 corner toward W2 corner.', + ); + await movedDoor.getByLabel(/^From W\d+ (corner|start)$/).fill('1200'); await movedDoor.getByLabel('Door width', { exact: true }).fill('900'); expect(state(page).project.rooms[0].walls[0].doors[0]).toMatchObject({ offset: 1200, width: 900, }); + await expect(movedDoor.getByRole('img', { name: /^Door on W1:/ })).toHaveAttribute( + 'aria-label', + 'Door on W1: 1,200 mm from W4 corner, then 900 mm opening toward W2 corner', + ); await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled(); expect(await page.locator('.skirting-cuts').innerText()).not.toBe(before); await page.getByLabel('Stock length 1', { exact: true }).fill('3600'); @@ -349,19 +423,19 @@ test('door positions and material lengths update skirting immediately', async ({ await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled(); await movedDoor.getByLabel('Door width', { exact: true }).fill('6000'); await expect(page.getByText(/must fit within the wall/).first()).toBeVisible(); - await expect(skirtRows(page)).toHaveCount(0); + await expect(skirtEntries(page)).toHaveCount(0); await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled(); }); test('invalid skirting settings keep flooring results usable', async ({ page }) => { const floorCount = await floorCodes(page).count(); await page.getByLabel('Stock length 1', { exact: true }).fill('100'); - await expect(skirtRows(page)).toHaveCount(0); + await expect(skirtEntries(page)).toHaveCount(0); await expect(floorCodes(page)).toHaveCount(floorCount); await expect(page.locator('.issue.error').first()).toBeVisible(); await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled(); await page.getByLabel('Stock length 1', { exact: true }).fill('2400'); - expect(await skirtRows(page).count()).toBeGreaterThan(0); + expect(await skirtEntries(page).count()).toBeGreaterThan(0); await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled(); }); @@ -376,7 +450,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(['roomId', 'selectedPiece']); expect(Object.keys(state(page)).sort()).toEqual(['project', 'view']); - const table = await page.locator('.flooring-cuts tbody').innerText(); + const table = await page.locator('.flooring-cuts').innerText(); await page.reload(); await expect(page.getByLabel('Wall spacer', { exact: true })).toHaveValue('12'); await expect(page.getByRole('button', { name: 'Random', exact: true })).toHaveAttribute( @@ -386,7 +460,7 @@ test('pattern, direction and spacers persist without steps, layers or stored res await expect( page.getByRole('button', { name: 'Across first wall', exact: true }), ).toHaveAttribute('aria-pressed', 'true'); - await expect(page.locator('.flooring-cuts tbody')).toHaveText(table, { useInnerText: true }); + await expect(page.locator('.flooring-cuts')).toHaveText(table, { useInnerText: true }); await noHorizontalOverflow(page); expect(await page.evaluate(() => Object.keys(localStorage))).toEqual([]); }); @@ -398,10 +472,10 @@ test('a narrow remainder is shared between the first and last flooring rows', as for (const row of [1, 20]) { const piece = flooringCuts(floor).find((piece) => piece.row === row); expect(piece).toBeDefined(); - const group = floorRows(page).filter({ + const group = floorEntries(page).filter({ has: page.getByRole('button', { name: `Preview flooring cut ${piece!.id}`, exact: true }), }); - await expect(group.locator('[data-label="Cut size · mm"]')).toContainText('× 105'); + await expect(group.locator('.cut-size')).toContainText('× 105'); } }); @@ -413,8 +487,8 @@ test('multiple rooms retain independent measurements with combined cut lists', a expect(state(page).project.rooms).toHaveLength(2); expect(state(page).project.rooms[0].walls[0].length).toBe(4800); expect(state(page).project.rooms[1].walls[0].length).toBe(3200); - expect(await floorRows(page).filter({ hasText: 'Living room' }).count()).toBeGreaterThan(0); - expect(await skirtRows(page).filter({ hasText: 'Study' }).count()).toBeGreaterThan(0); + expect(await floorEntries(page).filter({ hasText: 'Living room' }).count()).toBeGreaterThan(0); + expect(await skirtEntries(page).filter({ hasText: 'Study' }).count()).toBeGreaterThan(0); await page.getByRole('button', { name: /^Living room.*m²/ }).click(); await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800'); await page.getByRole('button', { name: /^Study.*m²/ }).click(); @@ -440,13 +514,13 @@ test('shared URLs reopen the selected preview and CSV contains every cut once', ).toBeVisible(); expect(state(page).view.selectedPiece).toBe(pieceCode); const shareUrl = page.url(); - const originalTable = await page.locator('.flooring-cuts tbody').innerText(); + const originalTable = await page.locator('.flooring-cuts').innerText(); const shared = await context.newPage(); await shared.goto(shareUrl); await expect( shared.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }), ).toBeVisible(); - await expect(shared.locator('.flooring-cuts tbody')).toHaveText(originalTable, { + await expect(shared.locator('.flooring-cuts')).toHaveText(originalTable, { useInnerText: true, }); await shared.reload(); @@ -478,7 +552,7 @@ test('shared URLs reopen the selected preview and CSV contains every cut once', await expect(page.getByText('Link copied', { exact: true })).toBeVisible(); expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(closedUrl); await page.reload(); - await expect(page.locator('.flooring-cuts tbody')).toHaveText(originalTable, { + await expect(page.locator('.flooring-cuts')).toHaveText(originalTable, { useInnerText: true, }); }); @@ -505,15 +579,141 @@ test('saw kerf help works with keyboard and touch or mouse', async ({ page, isMo }); test('invalid URLs recover when measurements are edited', async ({ page }) => { - await page.goto('/#plan=broken'); + await page.goto('/?flooring.length=broken'); await expect(page.getByText(/Could not load this plan/)).toBeVisible(); await page.getByLabel('Room name', { exact: true }).fill('Recovered plan'); await expect(page.getByText(/Could not load this plan/)).toHaveCount(0); - expect(decodeState(new URL(page.url()).hash).error).toBeNull(); + expect(decodeState(new URL(page.url()).search).error).toBeNull(); + expect(new URL(page.url()).hash).toBe(''); await page.reload(); await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Recovered plan'); }); +test('clicking the skirting code itself opens its cutting dialog', async ({ page }) => { + for (const target of ['rect', 'text']) { + const code = preview(page).locator('.skirting-code').first(); + const id = await code.evaluate((element) => + element.closest('[data-piece-id]')!.getAttribute('data-piece-id'), + ); + await code.locator(target).click(); + await expect( + page.getByRole('dialog', { name: `Skirting cut ${id}`, exact: true }), + ).toBeVisible(); + expect(state(page).view.selectedPiece).toBe(id); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toHaveCount(0); + } +}); + +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); + const original = page.url(); + await page.getByRole('button', { name: 'Add room', exact: true }).click(); + await page.getByLabel('Room name', { exact: true }).fill('Study'); + await page.getByLabel('Room width', { exact: true }).fill('3333'); + const twoRooms = page.url(); + const params = new URL(twoRooms).searchParams; + expect(params.get('room.2.name')).toBe('Study'); + expect(params.get('room.2.wall.1.length')).toBe('3333'); + expect(params.get('flooring.pattern')).toBe('random'); + expect(params.has('plan')).toBe(false); + 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'); + expect(state(page).project.rooms).toHaveLength(1); + await page.goForward(); + await expect(page).toHaveURL(twoRooms); + await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study'); + await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('3333'); + params.set('flooring.width', '205'); + await page.goto(`/?${params}`); + await expect(page.getByLabel('Board width', { exact: true })).toHaveValue('205'); + await page.reload(); + await expect(page.getByLabel('Board width', { exact: true })).toHaveValue('205'); + expect(state(page).project.rooms).toHaveLength(2); +}); + +test('printing includes every room and every cut once, grouped by its source board', async ({ + page, +}) => { + const fixture = defaultState(); + fixture.project.flooring.width = 200; + fixture.project.rooms = [ + rectangle('room-1', 'Living room', 1820, 1220), + rectangle('room-2', 'Study', 1220, 1820), + ]; + fixture.project.rooms.forEach((room) => + room.walls.forEach((wall) => { + wall.doors = []; + }), + ); + fixture.project.rooms[1].direction = 90; + fixture.view.roomId = 'room-2'; + await page.goto(`/${encodeState(fixture)}`); + await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study'); + const { floor, skirt, project } = plans(page); + const cuts = flooringCuts(floor), + skirting = skirtingCuts(skirt); + const screenFloor = await stockGroups(page.locator('.flooring-cuts .stock-cut-group')); + const screenSkirting = await stockGroups(page.locator('.skirting-cuts .stock-cut-group')); + expect(screenFloor).toEqual(expectedGroups(floor.stocks, cuts)); + expect(screenSkirting).toEqual(expectedGroups(skirt.stocks, skirting)); + expect( + screenFloor.some( + (group) => + new Set(group.codes.map((id) => cuts.find((piece) => piece.id === id)!.roomId)).size > 1, + ), + ).toBe(true); + await expect(page.locator('.print-plan')).toBeHidden(); + await page.emulateMedia({ media: 'print' }); + const printed = page.locator('.print-plan'); + await expect(printed).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeHidden(); + await expect(printed.locator('.print-room')).toHaveCount(project.rooms.length); + for (const room of project.rooms) { + const drawing = printed.locator(`.print-room[data-room-id="${room.id}"]`); + await expect(drawing.locator('h2').first()).toContainText(room.name); + await expect(drawing.getByRole('img', { name: /floor plan with/ })).toBeVisible(); + await expect(drawing.locator('.floor-piece')).toHaveCount( + floor.pieces.filter((piece) => piece.roomId === room.id).length, + ); + await expect(drawing.locator('.skirt-piece')).toHaveCount( + skirt.pieces.filter((piece) => piece.roomId === room.id).length, + ); + await expect(drawing.getByRole('button')).toHaveCount(0); + } + const printedFloor = await stockGroups(printed.locator('.print-flooring .print-stock')); + const printedSkirting = await stockGroups(printed.locator('.print-skirting .print-stock')); + expect(printedFloor).toEqual(screenFloor); + expect(printedSkirting).toEqual(screenSkirting); + expect(printedFloor.flatMap((group) => group.codes).sort()).toEqual( + cuts.map((piece) => piece.id).sort(), + ); + expect(printedSkirting.flatMap((group) => group.codes).sort()).toEqual( + skirting.map((piece) => piece.id).sort(), + ); + for (const piece of [...cuts, ...skirting]) { + const cut = printed.locator(`.print-cut[data-cut-id="${piece.id}"]`); + await expect(cut).toBeVisible(); + expect(await cut.locator('.dimension-label').count()).toBeGreaterThanOrEqual(2); + await expect(cut.locator('svg').first()).toBeVisible(); + } + expect(await printed.innerText()).not.toMatch(/\b(?:FU|SU)\d+\b/); + await page.emulateMedia({ media: 'screen' }); + await expect(printed).toBeHidden(); + await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study'); +}); + for (const width of [320, 390]) { test(`phone layout works at ${width} px with readable inputs and touch targets`, async ({ page, @@ -555,10 +755,10 @@ for (const width of [320, 390]) { ); expect(smallInputs, '16 px input text avoids phone browser zoom').toEqual([]); expect( - await floorRows(page) + await floorEntries(page) .first() .evaluate((element) => getComputedStyle(element).display), - ).toMatch(/block|grid/); + ).toMatch(/block|grid|flex/); await page.getByRole('button', { name: 'With fireplace' }).click(); await noWallLabelOverlap(page); await noDrawingScroll(page); @@ -574,7 +774,9 @@ for (const width of [320, 390]) { await page.mouse.click(1, 1); await expect(page.getByRole('dialog')).toHaveCount(0); expect(state(page).view.selectedPiece).toBeNull(); - await page.getByText('Source lengths for skirting cuts', { exact: true }).click(); + await expect(page.getByText('Source lengths for skirting cuts', { exact: true })).toHaveCount( + 0, + ); await noHorizontalOverflow(page); }); } diff --git a/src/App.tsx b/src/App.tsx index 5f983f9..8fca6dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import { OutlineEditor } from './components/OutlineEditor'; import { MaterialsEditor } from './components/MaterialsEditor'; import { FloorPlan } from './components/FloorPlan'; import { CutList } from './components/CutList'; +import { PrintPlan } from './components/PrintPlan'; import { Issues } from './components/Fields'; const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ block: 'start' }); @@ -254,6 +255,12 @@ export default function App() { /> + ); } diff --git a/src/components/CutDialog.tsx b/src/components/CutDialog.tsx index 3cfcd97..b6ed0be 100644 --- a/src/components/CutDialog.tsx +++ b/src/components/CutDialog.tsx @@ -3,7 +3,6 @@ import { X } from 'lucide-react'; import type { FlooringPiece, Project, SkirtingPiece } from '../lib/types'; import { flooringInstructions } from '../lib/cut-instructions'; import { readyOffcutInstructions } from '../lib/cut-list'; -import { exactMm, flooringTemplate, skirtingTemplate } from '../lib/cut-preview'; import { FlooringPreview, SkirtingPreview } from './CutPreview'; interface Props { @@ -33,11 +32,6 @@ export function CutDialog({ floor, skirt, project, close }: Props) { const room = selected && (project.rooms.find((candidate) => candidate.id === selected.roomId)?.name ?? selected.roomId); - const template = floor - ? flooringTemplate(floor) - : skirt - ? skirtingTemplate(skirt, project.skirting.thickness) - : []; return (

- {room} · Row {floor.row} · Source {floor.stockId} - {floor.reused ? ' · Reused offcut' : ''} + {room} · Row {floor.row}

{floor.cut ? flooringInstructions(floor) : readyOffcutInstructions(floor)}

-

- Left and right run along the board; top and bottom are its long edges. -

- {floor.shaped && ( - <> -

- Mark these coordinates from the blank’s top-left corner: x along the board, y - across it. -

-
- {template.map((point, index) => ( - - {index + 1}: ({exactMm(point.x)}, {exactMm(point.y)}) mm - - ))} -
-

- {template - .map((point, index) => { - const next = template[(index + 1) % template.length]; - return `${index + 1}–${((index + 1) % template.length) + 1}: ${exactMm(Math.hypot(next.x - point.x, next.y - point.y))} mm`; - }) - .join(' · ')} -

- - )} )} {skirt && ( <>

- {room} · Wall {skirt.wallIndex + 1} · Source {skirt.stockId} + {room} · Wall {skirt.wallIndex + 1}

-
-
-
Long point
-
{exactMm(skirt.length)} mm
-
-
-
Wall-facing length
-
{exactMm(skirt.backLength)} mm
-
-
-
Left mitre
-
- {exactMm(Math.abs(skirt.leftMitre))}° · {skirt.leftType} -
-
-
-
Right mitre
-
- {exactMm(Math.abs(skirt.rightMitre))}° · {skirt.rightType} -
-
-
- {template.length === 4 && ( - <> -

- Cut the square blank first. Mark these distances from its left end, then join the - front and back marks at each end: -

-
- Back left: {exactMm(template[0].x)} mm - Back right: {exactMm(template[1].x)} mm - Front left: {exactMm(template[3].x)} mm - Front right: {exactMm(template[2].x)} mm -
-

- Left and right follow the wall direction. Mitre angles are measured from a square - cut; the marked points show which way to lean each cut. -

- - )} +

+ Measure the longest points. Mitre angles are measured from square; the end views show + the cut direction. +

)} diff --git a/src/components/CutList.tsx b/src/components/CutList.tsx index 1c2f522..06494cd 100644 --- a/src/components/CutList.tsx +++ b/src/components/CutList.tsx @@ -5,6 +5,7 @@ import { downloadCsv } from '../lib/export'; import { flooringCuts, groupFlooringCuts, + groupSkirtingCuts, skirtingCuts, uncutFlooring, uncutSkirting, @@ -12,32 +13,6 @@ import { import { FlooringPreview, SkirtingPreview } from './CutPreview'; import { CutDialog } from './CutDialog'; -function StockAllocations({ - title, - items, -}: { - title: string; - items: { id: string; pieces: string[]; size: string; remainder: string }[]; -}) { - if (!items.length) return null; - return ( -
- {title} -
    - {items.map((item) => ( -
  • - - {item.id} · {item.size} - - {item.pieces.join(' + ')} - {item.remainder} -
  • - ))} -
-
- ); -} - interface Props { project: Project; flooring: FlooringPlan; @@ -49,29 +24,23 @@ interface Props { export function CutList({ project, flooring, skirting, selectedPiece, select }: Props) { const floorCuts = flooringCuts(flooring), skirtCuts = skirtingCuts(skirting); - const floorGroups = groupFlooringCuts(flooring); - const floorIds = new Set(floorCuts.map((piece) => piece.id)); - const skirtIds = new Set(skirtCuts.map((piece) => piece.id)); const wholeFloor = uncutFlooring(flooring, project.flooring); const wholeSkirt = uncutSkirting(skirting); - const selectedFloor = floorCuts.find((piece) => piece.id === selectedPiece); - const selectedSkirt = skirtCuts.find((piece) => piece.id === selectedPiece); const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id; const errors = [...flooring.issues, ...skirting.issues].some( (issue) => issue.severity === 'error', ); - return (

Cut plan

- Cut pieces and offcut placements. All measurements are in millimetres. + Each group comes from one board. Select a piece for its cutting diagram.

-

Flooring cuts {floorCuts.length}

- Buy {flooring.boardCount} boards · {flooring.pieces.length} installed pieces ·{' '} - {wholeFloor.wholeBoards} full boards laid uncut + Buy {flooring.boardCount} boards · {wholeFloor.wholeBoards} laid uncut {wholeFloor.readyOffcuts > 0 && ` · ${wholeFloor.readyOffcuts} offcuts need no further cuts`} - .

- {floorCuts.length ? ( -
- - - - - - - - - - - - - {floorGroups.map((group) => { - const first = group[0]; - return ( - piece.id === selectedPiece) ? 'selected' : ''}`} - > - - - - - - - ); - })} - -
Flooring cuts and offcut placements
PiecesQtyCut size · mmRoomAction
-
- {group.map((piece) => ( - - ))} -
-
- {group.length} - - {mm(first.length)} × {mm(first.width)} - - {roomName(first.roomId)} - - {!first.cut - ? 'Use offcut · no new cut' - : first.shaped - ? 'Shaped cut' - : 'Rectangular cut'} -
-
- ) : ( + {groupFlooringCuts(flooring).map((group) => ( +
+

+ Floorboard {group[0].stockId} + + {mm(project.flooring.length)} × {mm(project.flooring.width)} mm + +

+
    + {group.map((piece) => ( +
  • + +
    +

    + {mm(piece.length)} × {mm(piece.width)} mm +

    +

    + {roomName(piece.roomId)} · Row {piece.row} +

    + {piece.reused && ( +

    + {piece.cut ? 'Cut from the remaining offcut' : 'Use the offcut as it is'} +

    + )} +
    +
  • + ))} +
+
+ ))} + {!floorCuts.length && (

{flooring.pieces.length ? 'No flooring cuts needed.' : 'Complete the outline and flooring measurements to calculate cuts.'}

)} - stock.pieceIds.some((id) => floorIds.has(id))) - .map((stock) => ({ - id: stock.id, - pieces: stock.pieceIds.filter((id) => floorIds.has(id)), - size: `${mm(project.flooring.length)} × ${mm(project.flooring.width)} mm`, - remainder: `${mm(stock.remaining)} mm remaining`, - }))} - />
-

Skirting cuts {skirtCuts.length}

- Buy {skirting.stockCount} lengths · {skirting.pieces.length} installed pieces ·{' '} - {wholeSkirt.reduce((sum, group) => sum + group.count, 0)} full lengths installed uncut. + Buy {skirting.stockCount} lengths ·{' '} + {wholeSkirt.reduce((sum, group) => sum + group.count, 0)} installed uncut

-

- Lengths use the longest point. Mitres are saw settings from square; left and right follow - the wall direction. -

- {skirtCuts.length ? ( -
- - - - - - - - - - - - - {skirtCuts.map((piece) => ( - - - - - - - - ))} - -
Skirting pieces requiring cuts
PieceRoom / wallLength · mmLeft / rightSource
- - - {roomName(piece.roomId)} - Wall {piece.wallIndex + 1} - - {mm(piece.length)} - {mm(piece.backLength)} at wall - - {degree(piece.leftMitre)} / {degree(piece.rightMitre)} - - {piece.leftType} / {piece.rightType} - - {piece.stockId}
-
- ) : ( + {groupSkirtingCuts(skirting).map((group) => ( +
+

+ Skirting board {group[0].stockId} + + {mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm + +

+
    + {group.map((piece) => ( +
  • + +
    +

    + {mm(piece.length)} mm long point +

    +

    + {roomName(piece.roomId)} · Wall {piece.wallIndex + 1} +

    +

    + Left {degree(piece.leftMitre)} · Right {degree(piece.rightMitre)} +

    +
    +
  • + ))} +
+
+ ))} + {!skirtCuts.length && (

{skirting.pieces.length ? 'No skirting cuts needed.' : 'Complete the outline and skirting measurements to calculate cuts.'}

)} - stock.pieceIds.some((id) => skirtIds.has(id))) - .map((stock) => ({ - id: stock.id, - pieces: stock.pieceIds.filter((id) => skirtIds.has(id)), - size: `${mm(stock.length)} mm`, - remainder: `${mm(stock.waste)} mm waste including kerf`, - }))} - />
piece.id === selectedPiece)} + skirt={skirtCuts.find((piece) => piece.id === selectedPiece)} project={project} close={() => select(null)} /> diff --git a/src/components/CutPreview.tsx b/src/components/CutPreview.tsx index f6fb11d..d61c7e1 100644 --- a/src/components/CutPreview.tsx +++ b/src/components/CutPreview.tsx @@ -2,12 +2,107 @@ import { Fragment } from 'react'; import type { FlooringPiece, SkirtingPiece } from '../lib/types'; import { exactMm, + dimensionedOutline, flooringTemplate, fullPreviewBox, skirtingEndBoxes, skirtingTemplate, viewBox, } from '../lib/cut-preview'; +import type { EdgeDimension } from '../lib/cut-preview'; + +function Dimensions({ dimensions, font }: { dimensions: EdgeDimension[]; font: number }) { + return ( + + {dimensions.map((dimension) => { + const { start, end, lineStart, lineEnd, normal, label } = dimension; + const center = { x: (lineStart.x + lineEnd.x) / 2, y: (lineStart.y + lineEnd.y) / 2 }; + const staggered = + Math.abs((label.x - center.x) * -normal.y + (label.y - center.y) * normal.x) > font * 0.1; + return ( + + {[ + [start, lineStart], + [end, lineEnd], + ].map(([point, projected], i) => ( + + ))} + + {[lineStart, lineEnd].map((point, i) => ( + + ))} + {staggered && ( + + )} + + ); + })} + {dimensions.map((dimension) => ( + + + + {dimension.angle === undefined ? ( + dimension.text + ) : ( + <> + + {dimension.text} + {' '} + + {exactMm(dimension.angle)}° + + + )} + + + ))} + + ); +} export function FlooringPreview({ piece, @@ -17,12 +112,15 @@ export function FlooringPreview({ compact?: boolean; }) { const template = flooringTemplate(piece); + const dimensions = compact + ? undefined + : dimensionedOutline(template, piece.shaped ? undefined : [0, 1]); const Wrapper = compact ? 'span' : 'figure'; return ( - {!compact && - piece.shaped && - template.map((point, index) => ( - - {index + 1} - - ))} + {dimensions && } {!compact && (
- Length {exactMm(piece.length)} mm × width {exactMm(piece.width)} mm · top view, to scale + {exactMm(piece.length)} × {exactMm(piece.width)} mm + {dimensions?.dimensions.some((dimension) => dimension.angle !== undefined) && + ' · angles from horizontal'}
)}
@@ -74,6 +161,18 @@ export function SkirtingPreview({ const Grid = compact ? 'span' : 'div'; if (template.length !== 4) return Shape template unavailable; const boxes = skirtingEndBoxes(template, thickness); + const dimensions = compact + ? undefined + : dimensionedOutline( + [template[0], template[1], { x: piece.length, y: thickness }, { x: 0, y: thickness }], + [0, 2], + ); + // The lower dimension measures the overall blank, between its actual longest points. + const overall = dimensions?.dimensions.find((dimension) => dimension.edge === 2); + if (overall) { + overall.start = template.reduce((a, b) => (a.x > b.x ? a : b)); + overall.end = template.reduce((a, b) => (a.x < b.x ? a : b)); + } const points = template.map((point) => `${point.x},${point.y}`).join(' '); const ends = [ { side: 'left', back: template[0], front: template[3], box: boxes.left }, @@ -84,7 +183,7 @@ export function SkirtingPreview({ {!compact && (
+
- Full length {exactMm(piece.length)} mm × thickness {exactMm(thickness)} mm · top view, - to scale + {exactMm(piece.length)} × {exactMm(thickness)} mm · wall side above
)} @@ -142,8 +241,9 @@ export function SkirtingPreview({ {!compact && (
- {index === 0 ? 'Left' : 'Right'} end ·{' '} - {exactMm(Math.abs(index === 0 ? piece.leftMitre : piece.rightMitre))}° + {index === 0 ? 'Left' : 'Right'} ·{' '} + {exactMm(Math.abs(index === 0 ? piece.leftMitre : piece.rightMitre))}°{' '} + {index === 0 ? piece.leftType : piece.rightType}
)} @@ -155,12 +255,6 @@ export function SkirtingPreview({ ))} - {!compact && ( -

- End close-ups; the middle is omitted. The back edge faces the wall, the front edge faces - the room. Both axes use the same scale. -

- )} ); } diff --git a/src/components/DoorPosition.tsx b/src/components/DoorPosition.tsx new file mode 100644 index 0000000..addb2b5 --- /dev/null +++ b/src/components/DoorPosition.tsx @@ -0,0 +1,64 @@ +import type { Door, Wall } from '../lib/types'; +import { mm } from '../lib/format'; + +/** A wall viewed from its start toward its end; only the user's offset and width are inputs. */ +export function DoorPosition({ + wall, + door, + wallIndex, + wallCount, + closed, +}: { + wall: Wall; + door: Door; + wallIndex: number; + wallCount: number; + closed: boolean; +}) { + const start = + wallIndex === 0 && !closed ? 'W1 start' : `W${wallIndex === 0 ? wallCount : wallIndex} corner`; + const end = + wallIndex === wallCount - 1 && !closed + ? `W${wallCount} end` + : `W${((wallIndex + 1) % wallCount) + 1} corner`; + const ratio = (n: number) => 12 + Math.max(0, Math.min(1, n / Math.max(wall.length, 1))) * 216; + const a = ratio(door.offset), + b = ratio(door.offset + door.width); + return ( +
+

+ W{wallIndex + 1}: measure from {start} toward {end}. +

+ + + + + + + + {start} + + + {end} + + +
+ ); +} diff --git a/src/components/FloorPlan.tsx b/src/components/FloorPlan.tsx index 212d252..fa9dbf0 100644 --- a/src/components/FloorPlan.tsx +++ b/src/components/FloorPlan.tsx @@ -111,10 +111,19 @@ interface Props { flooring: FlooringPlan; skirting: SkirtingPlan; selectedPiece: string | null; - select: (id: string) => void; + select?: (id: string) => void; + interactive?: boolean; } -export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, select }: Props) { +export function FloorPlan({ + room, + geometry, + flooring, + skirting, + selectedPiece, + select, + interactive = true, +}: Props) { const hatch = useId().replaceAll(':', ''); const allPoints = geometry.edges.flatMap((edge) => [edge.start, edge.end]); const minX = Math.min(0, ...allPoints.map((p) => p.x)), @@ -159,13 +168,18 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s
- Flooring + Flooring cuts - Skirting + Skirting cuts + + + + Uncut
+ {interactive &&

Select a numbered piece for cut details.

}
select(piece.id) : undefined} + onClick={cut && interactive ? () => select?.(piece.id) : undefined} onKeyDown={ cut ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - select(piece.id); + select?.(piece.id); } } : undefined @@ -283,18 +297,18 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s className={`skirt-piece ${cut ? 'svg-cut skirt-cut' : 'uncut'}`} data-material="skirting" data-piece-id={piece.id} - role={cut ? 'button' : undefined} - tabIndex={cut ? 0 : undefined} + role={cut && interactive ? 'button' : undefined} + tabIndex={cut && interactive ? 0 : undefined} aria-hidden={cut ? undefined : true} aria-label={cut ? `Select skirting cut ${piece.id}` : undefined} aria-pressed={cut ? selectedPiece === piece.id : undefined} - onClick={cut ? () => select(piece.id) : undefined} + onClick={cut && interactive ? () => select?.(piece.id) : undefined} onKeyDown={ cut ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - select(piece.id); + select?.(piece.id); } } : undefined @@ -302,8 +316,8 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s > {cut && ( @@ -317,20 +331,20 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s /> )} {label && ( - + diff --git a/src/components/MaterialsEditor.tsx b/src/components/MaterialsEditor.tsx index 2581d7a..92a906d 100644 --- a/src/components/MaterialsEditor.tsx +++ b/src/components/MaterialsEditor.tsx @@ -24,7 +24,7 @@ export function MaterialsEditor({ project, room, changeProject, changeRoom }: Pr <>

Materials

- Shared across rooms · mm + Shared across rooms

diff --git a/src/components/OutlineEditor.tsx b/src/components/OutlineEditor.tsx index 8d4a19f..9c09ca0 100644 --- a/src/components/OutlineEditor.tsx +++ b/src/components/OutlineEditor.tsx @@ -4,6 +4,7 @@ import { fireplace, rectangle, nextId } from '../lib/defaults'; import { normalizeAngle } from '../lib/geometry'; import { mm } from '../lib/format'; import { NumberField, Issues } from './Fields'; +import { DoorPosition } from './DoorPosition'; interface Props { room: Room; geometry: Geometry; @@ -87,7 +88,6 @@ export function OutlineEditor({ <>

Room measurements

- Millimetres

{room.walls.reduce((n, w) => n + w.doors.length, 0)} openings
-

- Width includes trim where skirting stops. Offset is measured from the wall’s start. -

+

Door width includes trim where skirting stops.

{room.walls.flatMap((wall, wallIndex) => wall.doors.map((door) => (
@@ -312,9 +310,16 @@ export function OutlineEditor({
+
changeWall(wall.id, (w) => ({ diff --git a/src/components/PrintPlan.tsx b/src/components/PrintPlan.tsx new file mode 100644 index 0000000..003b9cb --- /dev/null +++ b/src/components/PrintPlan.tsx @@ -0,0 +1,114 @@ +import type { FlooringPlan, Geometry, Project, SkirtingPlan } from '../lib/types'; +import { area, mm } from '../lib/format'; +import { + groupFlooringCuts, + groupSkirtingCuts, + readyOffcutInstructions, + uncutFlooring, + uncutSkirting, +} from '../lib/cut-list'; +import { flooringInstructions } from '../lib/cut-instructions'; +import { FloorPlan } from './FloorPlan'; +import { FlooringPreview, SkirtingPreview } from './CutPreview'; + +/** All rooms first, then cutting sheets in purchased-board order, including shared offcuts. */ +export function PrintPlan({ + project, + geometries, + flooring, + skirting, +}: { + project: Project; + geometries: Geometry[]; + flooring: FlooringPlan; + skirting: SkirtingPlan; +}) { + const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id; + const wholeFloor = uncutFlooring(flooring, project.flooring).wholeBoards; + const wholeSkirt = uncutSkirting(skirting).reduce((sum, group) => sum + group.count, 0); + return ( +
+
+

Flooring installation plan

+

+ {project.rooms.length} rooms · {mm(project.flooring.length)} ×{' '} + {mm(project.flooring.width)} mm floorboards · {mm(project.flooring.gap)} mm wall spacers +

+

+ Buy {flooring.boardCount} floorboards and {skirting.stockCount} skirting lengths. Install{' '} + {wholeFloor} floorboards and {wholeSkirt} skirting lengths uncut. +

+
+ {project.rooms.map((room, index) => ( +
+

+ {index + 1}. {room.name} {area(geometries[index].area)} m² +

+ +
+ ))} +
+

Flooring cuts

+ {groupFlooringCuts(flooring).map((group) => ( +
+

+ Floorboard {group[0].stockId}{' '} + + {mm(project.flooring.length)} × {mm(project.flooring.width)} mm + +

+
+ {group.map((piece) => ( +
+

+ {piece.id}{' '} + + {roomName(piece.roomId)} · Row {piece.row} + +

+ +

{piece.cut ? flooringInstructions(piece) : readyOffcutInstructions(piece)}

+
+ ))} +
+
+ ))} + {!groupFlooringCuts(flooring).length &&

No flooring cuts needed.

} +
+
+

Skirting cuts

+ {groupSkirtingCuts(skirting).map((group) => ( +
+

+ Skirting board {group[0].stockId}{' '} + + {mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm + +

+
+ {group.map((piece) => ( +
+

+ {piece.id}{' '} + + {roomName(piece.roomId)} · Wall {piece.wallIndex + 1} + +

+ +
+ ))} +
+
+ ))} + {!groupSkirtingCuts(skirting).length &&

No skirting cuts needed.

} +
+
+ ); +} diff --git a/src/lib/cut-list.ts b/src/lib/cut-list.ts index 691b88c..1e0a1c7 100644 --- a/src/lib/cut-list.ts +++ b/src/lib/cut-list.ts @@ -54,25 +54,18 @@ export function readyOffcutInstructions(piece: FlooringPiece): string { return `Use offcut; no new cut. Required factory short ends: ${ends}.`; } -/** Batch identical cuts without losing any placement codes or changing factory-edge orientation. */ -export function groupFlooringCuts(plan: FlooringPlan): FlooringPiece[][] { - const groups = new Map(); - const precise = (value: number) => Math.round(value * 1000); - for (const piece of flooringCuts(plan)) { - const key = JSON.stringify([ - piece.roomId, - precise(piece.length), - precise(piece.width), - piece.shaped, - piece.cut, - piece.factoryLeft, - piece.factoryRight, - piece.ripEdge, - piece.shaped ? piece.template.map((point) => [precise(point.x), precise(point.y)]) : null, - ]); - const group = groups.get(key); +/** Keep every cut from a purchased board together, in allocation order across rooms. */ +function groupByStock(pieces: T[], stocks: { id: string }[]): T[][] { + const groups = new Map(stocks.map((stock) => [stock.id, []])); + for (const piece of pieces) { + const group = groups.get(piece.stockId); if (group) group.push(piece); - else groups.set(key, [piece]); + else groups.set(piece.stockId, [piece]); } - return [...groups.values()]; + return [...groups.values()].filter((group) => group.length > 0); } + +export const groupFlooringCuts = (plan: FlooringPlan) => + groupByStock(flooringCuts(plan), plan.stocks); +export const groupSkirtingCuts = (plan: SkirtingPlan) => + groupByStock(skirtingCuts(plan), plan.stocks); diff --git a/src/lib/cut-preview.ts b/src/lib/cut-preview.ts index eac0619..4c21731 100644 --- a/src/lib/cut-preview.ts +++ b/src/lib/cut-preview.ts @@ -10,6 +10,142 @@ export const exactMm = (value: number) => new Intl.NumberFormat('en-GB', { maximumFractionDigits: 3 }).format(value); export const viewBox = (box: PreviewBox) => `${box.x} ${box.y} ${box.width} ${box.height}`; +export interface EdgeDimension { + edge: number; + length: number; + text: string; + /** Acute angle to the horizontal board edge; the diagram supplies its slope direction. */ + angle?: number; + start: Point; + end: Point; + lineStart: Point; + lineEnd: Point; + normal: Point; + label: Point; + labelBox: PreviewBox; +} + +const moved = (point: Point, direction: Point, distance: number): Point => ({ + x: point.x + direction.x * distance, + y: point.y + direction.y * distance, +}); +const overlaps = (a: PreviewBox, b: PreviewBox, gap: number) => + a.x < b.x + b.width + gap && + a.x + a.width + gap > b.x && + a.y < b.y + b.height + gap && + a.y + a.height + gap > b.y; + +/** Dimensions stay outside the outline. Short adjacent edges share a lane with staggered text. */ +export function dimensionedOutline(points: Point[], edges?: number[]) { + if (points.length < 3) + return { dimensions: [] as EdgeDimension[], font: 1, box: fullPreviewBox(1, 1) }; + const span = Math.max( + 1, + Math.max(...points.map((point) => point.x)) - Math.min(...points.map((point) => point.x)), + Math.max(...points.map((point) => point.y)) - Math.min(...points.map((point) => point.y)), + ); + const font = span * 0.065; + const orientation = + Math.sign( + points.reduce((sum, point, i) => { + const next = points[(i + 1) % points.length]; + return sum + point.x * next.y - next.x * point.y; + }, 0), + ) || 1; + const dimensions: EdgeDimension[] = []; + const selected = (edges ?? points.map((_, i) => i)) + .map((edge) => ({ + edge, + start: points[edge], + end: points[(edge + 1) % points.length], + })) + .map((edge) => ({ + ...edge, + length: Math.hypot(edge.end.x - edge.start.x, edge.end.y - edge.start.y), + })) + .filter((edge) => edge.length > 0.000001) + .sort((a, b) => b.length - a.length || a.edge - b.edge); + + for (const edge of selected) { + const direction = { + x: (edge.end.x - edge.start.x) / edge.length, + y: (edge.end.y - edge.start.y) / edge.length, + }; + const normal = { x: orientation * direction.y, y: -orientation * direction.x }; + const center = { x: (edge.start.x + edge.end.x) / 2, y: (edge.start.y + edge.end.y) / 2 }; + const text = exactMm(edge.length); + const angle = + Math.abs(direction.x) > 0.000001 && Math.abs(direction.y) > 0.000001 + ? (Math.atan2(Math.abs(direction.y), Math.abs(direction.x)) * 180) / Math.PI + : undefined; + const angleText = angle === undefined ? '' : `${exactMm(angle)}°`; + const width = Math.max(font * 1.2, Math.max(text.length, angleText.length) * font * 0.64), + height = font * (angle === undefined ? 1.3 : 2.5); + const outside = Math.max( + 0, + ...points.map((point) => (point.x - center.x) * normal.x + (point.y - center.y) * normal.y), + ); + let offset = outside + font * 0.65; + let label = center, + labelBox: PreviewBox = { x: 0, y: 0, width, height }; + let placed = false; + for (let lane = 0; lane < 30 && !placed; lane++) { + const base = moved( + center, + normal, + offset + (Math.abs(normal.x) * width + Math.abs(normal.y) * height) / 2 + font * 0.18, + ); + for (let step = 0; step < 25; step++) { + const shift = step === 0 ? 0 : Math.ceil(step / 2) * (step % 2 ? -1 : 1) * font * 1.55; + label = moved(base, direction, shift); + labelBox = { x: label.x - width / 2, y: label.y - height / 2, width, height }; + if (!dimensions.some((dimension) => overlaps(labelBox, dimension.labelBox, font * 0.25))) { + placed = true; + break; + } + } + if (!placed) offset += font * 1.8; + } + dimensions.push({ + ...edge, + text, + angle, + normal, + lineStart: moved(edge.start, normal, offset), + lineEnd: moved(edge.end, normal, offset), + label, + labelBox, + }); + } + const xs = points.map((point) => point.x), + ys = points.map((point) => point.y); + for (const dimension of dimensions) { + xs.push( + dimension.lineStart.x, + dimension.lineEnd.x, + dimension.labelBox.x, + dimension.labelBox.x + dimension.labelBox.width, + ); + ys.push( + dimension.lineStart.y, + dimension.lineEnd.y, + dimension.labelBox.y, + dimension.labelBox.y + dimension.labelBox.height, + ); + } + const padding = font * 0.4; + return { + dimensions: dimensions.sort((a, b) => a.edge - b.edge), + font, + box: { + x: Math.min(...xs) - padding, + y: Math.min(...ys) - padding, + width: Math.max(...xs) - Math.min(...xs) + padding * 2, + height: Math.max(...ys) - Math.min(...ys) + padding * 2, + }, + }; +} + export function flooringTemplate(piece: FlooringPiece): Point[] { if (piece.template.length) return piece.template; return piece.shaped diff --git a/src/lib/export.ts b/src/lib/export.ts index 05008fe..eb2c1e0 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -1,8 +1,8 @@ import type { FlooringPlan, Project, SkirtingPlan } from './types'; import { flooringInstructions } from './cut-instructions'; import { - flooringCuts, - skirtingCuts, + groupFlooringCuts, + groupSkirtingCuts, uncutFlooring, uncutSkirting, readyOffcutInstructions, @@ -19,10 +19,8 @@ export function cutListCsv( skirting: SkirtingPlan, ): string { const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id; - const floorCuts = flooringCuts(flooring), - skirtCuts = skirtingCuts(skirting); - const floorIds = new Set(floorCuts.map((piece) => piece.id)); - const skirtIds = new Set(skirtCuts.map((piece) => piece.id)); + const floorCuts = groupFlooringCuts(flooring).flat(), + skirtCuts = groupSkirtingCuts(skirting).flat(); const wholeFloor = uncutFlooring(flooring, project.flooring); const rows: (string | number)[][] = [ [ @@ -138,40 +136,6 @@ export function cutListCsv( '', ]); } - for (const stock of flooring.stocks) { - const pieces = stock.pieceIds.filter((id) => floorIds.has(id)); - if (pieces.length) - rows.push([ - 'Flooring stock', - '', - pieces.join(' / '), - stock.id, - project.flooring.length, - project.flooring.width, - '', - '', - 1, - `Remaining length ${stock.remaining.toFixed(1)} mm`, - '', - ]); - } - for (const stock of skirting.stocks) { - const pieces = stock.pieceIds.filter((id) => skirtIds.has(id)); - if (pieces.length) - rows.push([ - 'Skirting stock', - '', - pieces.join(' / '), - stock.id, - stock.length, - '', - '', - '', - 1, - `Waste including kerf ${stock.waste.toFixed(1)} mm`, - '', - ]); - } return '\uFEFF' + rows.map((row) => row.map(cell).join(',')).join('\r\n'); } diff --git a/src/lib/flooring.ts b/src/lib/flooring.ts index 0164207..95ba1d9 100644 --- a/src/lib/flooring.ts +++ b/src/lib/flooring.ts @@ -31,6 +31,7 @@ interface Layout { stocks: AvailableStock[]; cuts: number; reusedCount: number; + patternPenalty: number; } interface CutPiece { polygon: Point[]; @@ -250,6 +251,7 @@ function appendRow( rows: [...base.rows], cuts: base.cuts, reusedCount: base.reusedCount, + patternPenalty: base.patternPenalty + randomPatternPenalty(base.rows, room.id, phase, s), stocks: base.stocks.map((stock) => ({ ...stock, pieceIds: [...stock.pieceIds] })), }; const pieceIds: string[] = []; @@ -315,6 +317,45 @@ function staggerAllowed(a: number, b: number, s: FlooringSettings): boolean { return nearest >= s.minOffset - EPSILON && nearest <= s.maxOffset + EPSILON; } +/** Signed nearest-joint movement handles a step crossing the board-length boundary. */ +function jointStep(from: number, to: number, length: number): number { + return phaseOf(to - from + length / 2, length) - length / 2; +} + +/** Aesthetic preferences are penalties, never reasons to reject a physically valid row. */ +function randomPatternPenalty( + rows: FlooringRow[], + roomId: string, + phase: number, + s: FlooringSettings, +): number { + if (s.pattern !== 'random') return 0; + const recent = rows.filter((row) => row.roomId === roomId).slice(-4); + if (recent.length < 2) return 0; + const previous = recent.at(-1)!.offset; + const twoBack = recent.at(-2)!.offset; + const step = jointStep(previous, phase, s.length); + const previousStep = jointStep(twoBack, previous, s.length); + // A few millimetres of kerf drift still looks like the same joint line or step. + const similar = Math.max(s.kerf * 2, s.length * 0.03); + // Half-board steps have equally near joints in both directions and tend to look brick-like. + let penalty = close(Math.abs(step), s.length / 2) ? 1 : 0; + for (let distance = 2; distance <= recent.length; distance++) { + if (Math.abs(jointStep(recent.at(-distance)!.offset, phase, s.length)) <= similar) + penalty += 5 - distance; + } + if (step * previousStep > 0) { + penalty += 1; + if (Math.abs(step - previousStep) <= similar) penalty += 3; + if ( + recent.length >= 3 && + previousStep * jointStep(recent.at(-3)!.offset, twoBack, s.length) > 0 + ) + penalty += 2; + } + return penalty; +} + /** Small fixed hash, never time or Math.random: edits and shared URLs reproduce the same result. */ function fraction(seed: string): number { let hash = 2166136261; @@ -338,13 +379,9 @@ function candidatePhases( if (s.pattern === 'brick' && firstPhase !== undefined) return [phaseOf(firstPhase + ((row % 2) * s.length) / 2, s.length)]; const candidates: number[] = []; - const twoRowsBack = layout.rows.filter((r) => r.roomId === room.id).at(-2)?.offset; - const avoidRepeatedPair = - s.pattern === 'random' && s.minOffset < s.length / 2 - EPSILON && s.maxOffset > EPSILON; const add = (value: number) => { const phase = phaseOf(value, s.length); if (previousPhase !== undefined && !staggerAllowed(phase, previousPhase, s)) return; - if (avoidRepeatedPair && twoRowsBack !== undefined && close(phase, twoRowsBack)) return; if (!candidates.some((p) => close(p, phase))) candidates.push(phase); }; // Reusing a suitable end is usually the most effective way to save a board and a crosscut. @@ -386,6 +423,7 @@ function candidatePhases( function compareLayouts(a: Layout, b: Layout): number { return ( a.stocks.length - b.stocks.length || + a.patternPenalty - b.patternPenalty || a.cuts - b.cuts || b.reusedCount - a.reusedCount || compareText( @@ -418,13 +456,22 @@ function validateSettings(s: FlooringSettings): string | null { } /** - * Deterministic bounded beam search, ordered by boards consumed, then saw passes. + * Deterministic bounded beam search: boards consumed first; random layouts then + * prefer less visual repetition before fewer saw passes. This can spend an extra + * crosscut to break a staircase without buying an extra board. * Stock is shared between rooms, but layouts and joint phases are independent. * Only full-width end remnants are reused; ripped strips are conservatively waste. * This is a practical cutting plan, not a claim of a global packing optimum. */ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: number): FlooringPlan { - let layout: Layout = { pieces: [], rows: [], stocks: [], cuts: 0, reusedCount: 0 }; + let layout: Layout = { + pieces: [], + rows: [], + stocks: [], + cuts: 0, + reusedCount: 0, + patternPenalty: 0, + }; const issues: Issue[] = []; const invalid = validateSettings(s); if (invalid) issues.push({ severity: 'error', message: invalid }); diff --git a/src/lib/url-state.ts b/src/lib/url-state.ts index f397a7a..6b9105c 100644 --- a/src/lib/url-state.ts +++ b/src/lib/url-state.ts @@ -4,6 +4,7 @@ import type { Door, FlooringSettings, Room, SkirtingSettings, UrlState, Wall } f /** Resource limits apply to links as well as edits made inside the app. */ export const URL_LIMITS = { + queryLength: 100_000, hashLength: 100_000, jsonLength: 1_000_000, rooms: 20, @@ -159,46 +160,190 @@ function parseState(value: unknown): UrlState { }; } -/** Stable URLs: canonical property order, no timestamps, random seeds or results. */ +/** Explicit keys in a fixed order: no compressed payload or derived results. */ export function encodeState(state: UrlState): string { - const json = JSON.stringify(parseState(state)); - if (json.length > URL_LIMITS.jsonLength) - throw new Error('This plan is too large for a link. Split it into smaller projects.'); - const hash = `#plan=${LZString.compressToEncodedURIComponent(json)}`; - if (hash.length > URL_LIMITS.hashLength) + const { project, view } = parseState(state); + const params = new URLSearchParams(); + const put = (key: string, value: string | number) => params.append(key, String(value)); + put('version', project.version); + put('name', project.name); + put('tolerance', project.tolerance); + for (const [key, value] of Object.entries(project.flooring)) put(`flooring.${key}`, value); + // An empty value explicitly represents an empty stock list during editing. + if (!project.skirting.stockLengths.length) put('skirting.stock', ''); + for (const length of project.skirting.stockLengths) put('skirting.stock', length); + put('skirting.thickness', project.skirting.thickness); + put('skirting.minLength', project.skirting.minLength); + put('skirting.kerf', project.skirting.kerf); + project.rooms.forEach((room, roomIndex) => { + const prefix = `room.${roomIndex + 1}`; + put(`${prefix}.id`, room.id); + put(`${prefix}.name`, room.name); + put(`${prefix}.direction`, room.direction); + room.walls.forEach((wall, wallIndex) => { + const wallPrefix = `${prefix}.wall.${wallIndex + 1}`; + put(`${wallPrefix}.id`, wall.id); + put(`${wallPrefix}.length`, wall.length); + put(`${wallPrefix}.turn`, wall.turn); + wall.doors.forEach((door, doorIndex) => { + const doorPrefix = `${wallPrefix}.door.${doorIndex + 1}`; + put(`${doorPrefix}.id`, door.id); + put(`${doorPrefix}.offset`, door.offset); + put(`${doorPrefix}.width`, door.width); + }); + }); + }); + put('selectedRoom', view.roomId); + if (view.selectedPiece !== null) put('selectedCut', view.selectedPiece); + const query = `?${params}`; + if (query.length > URL_LIMITS.queryLength) throw new Error('This plan link is too large. Split it into smaller projects.'); - return hash; + return query; } -/** Never throw for an untrusted link. Return a usable starter plan and a visible error. */ -export function decodeState(hash: string): { state: UrlState; error: string | null } { - if (hash === '' || hash === '#') return { state: defaultState(), error: null }; +function parseQuery(query: string): UrlState { + if (query.length > URL_LIMITS.queryLength) + throw new Error('The link is too large. Use a smaller project link.'); + // URLSearchParams otherwise replaces damaged percent escapes silently. + decodeURIComponent(query); + const params = new URLSearchParams(query); + const defaults = defaultState(); + const roomFields = [...params.keys()].filter((key) => + /^room\.[^.]+\.(?:id|name|direction|wall\.[^.]+\.(?:id|length|turn|door\.[^.]+\.(?:id|offset|width)))$/.test( + key, + ), + ); + const text = (key: string, fallback?: string): string => { + const values = params.getAll(key); + if (values.length > 1) throw new Error(`${key} must appear only once.`); + if (!values.length) { + if (fallback !== undefined) return fallback; + throw new Error(`The link is missing ${key}.`); + } + return values[0]; + }; + const measurement = (value: string, key: string) => { + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value.trim())) + throw new Error(`${key} must be a finite number.`); + return number(Number(value), key); + }; + const numeric = (key: string, fallback?: number) => + measurement(text(key, fallback === undefined ? undefined : String(fallback)), key); + const indices = (prefix: string, limit: number) => { + const found = new Set(); + for (const key of roomFields) { + if (!key.startsWith(`${prefix}.`)) continue; + const index = key.slice(prefix.length + 1).split('.')[0]; + if (!/^[1-9]\d*$/.test(index)) + throw new Error(`${prefix} indices must be positive whole numbers, starting at 1.`); + const value = Number(index); + if (value > limit) throw new Error(`${prefix} supports at most ${limit} entries.`); + found.add(value); + } + const ordered = [...found].sort((a, b) => a - b); + if (ordered.some((value, index) => value !== index + 1)) + throw new Error(`${prefix} indices must be consecutive, starting at 1.`); + return ordered; + }; + const roomIndices = indices('room', URL_LIMITS.rooms); + const rooms = roomIndices.length + ? roomIndices.map((roomIndex) => { + const prefix = `room.${roomIndex}`; + const id = text(`${prefix}.id`, `room-${roomIndex}`); + return { + id, + name: text(`${prefix}.name`, `Room ${roomIndex}`), + direction: numeric(`${prefix}.direction`, 0), + walls: indices(`${prefix}.wall`, URL_LIMITS.walls).map((wallIndex) => { + const wallPrefix = `${prefix}.wall.${wallIndex}`; + const wallId = text(`${wallPrefix}.id`, `${id}-w${wallIndex}`); + return { + id: wallId, + length: numeric(`${wallPrefix}.length`), + turn: numeric(`${wallPrefix}.turn`, 90), + doors: indices(`${wallPrefix}.door`, URL_LIMITS.doors).map((doorIndex) => { + const doorPrefix = `${wallPrefix}.door.${doorIndex}`; + return { + id: text(`${doorPrefix}.id`, `${wallId}-door-${doorIndex}`), + offset: numeric(`${doorPrefix}.offset`), + width: numeric(`${doorPrefix}.width`), + }; + }), + }; + }), + }; + }) + : defaults.project.rooms; + const stockValues = params.getAll('skirting.stock'); + if (stockValues.length > URL_LIMITS.stockLengths) + throw new Error(`Skirting stock lengths supports at most ${URL_LIMITS.stockLengths} entries.`); + const stockLengths = + stockValues.length === 1 && stockValues[0] === '' + ? [] + : stockValues.length + ? stockValues.map((value) => measurement(value, 'skirting.stock')) + : defaults.project.skirting.stockLengths; + const floor = defaults.project.flooring; + return parseState({ + project: { + version: numeric('version', 1), + name: text('name', defaults.project.name), + tolerance: numeric('tolerance', defaults.project.tolerance), + rooms, + flooring: Object.fromEntries( + Object.entries(floor).map(([key, value]) => [ + key, + typeof value === 'number' + ? numeric(`flooring.${key}`, value) + : text(`flooring.${key}`, value), + ]), + ), + skirting: { + stockLengths, + thickness: numeric('skirting.thickness', defaults.project.skirting.thickness), + minLength: numeric('skirting.minLength', defaults.project.skirting.minLength), + kerf: numeric('skirting.kerf', defaults.project.skirting.kerf), + }, + }, + view: { + roomId: text('selectedRoom', rooms[0].id), + selectedPiece: params.has('selectedCut') ? text('selectedCut') : null, + }, + }); +} + +/** Read existing shared links so the hook can replace them with the readable query format. */ +function parseLegacy(hash: string): UrlState { + if (hash.length > URL_LIMITS.hashLength) + throw new Error('The link is too large. Use a smaller project link.'); + if (!hash.startsWith('#plan=')) + throw new Error('Use a complete plan query or an existing #plan= link.'); + const compressed = decodeURIComponent(hash.slice(6)); + if (!compressed || !/^[A-Za-z0-9+$-]+$/.test(compressed)) + throw new Error('The link is damaged or incomplete. Copy the complete plan link again.'); + const json = LZString.decompressFromEncodedURIComponent(compressed); + if (!json) + throw new Error('The link is damaged or incomplete. Copy the complete plan link again.'); + if (json.length > URL_LIMITS.jsonLength) + throw new Error('The project in this link is too large. Use a smaller project link.'); + if (LZString.compressToEncodedURIComponent(json) !== compressed) + throw new Error('The link is damaged or incomplete. Copy the complete plan link again.'); + let value: unknown; try { - if (hash.length > URL_LIMITS.hashLength) - throw new Error('The link is too large. Use a smaller project link.'); - if (!hash.startsWith('#plan=')) - throw new Error('The link must start with #plan=. Copy the complete plan link.'); - const compressed = decodeURIComponent(hash.slice(6)); - if (!compressed || !/^[A-Za-z0-9+$-]+$/.test(compressed)) { - throw new Error('The link is damaged or incomplete. Copy the complete plan link again.'); - } - const json = LZString.decompressFromEncodedURIComponent(compressed); - if (!json) - throw new Error('The link is damaged or incomplete. Copy the complete plan link again.'); - if (json.length > URL_LIMITS.jsonLength) - throw new Error('The project in this link is too large. Use a smaller project link.'); - // LZ decoding alone accepts ignored trailing characters. Require the exact - // encoding of the decoded text so a truncated or appended link is rejected. - if (LZString.compressToEncodedURIComponent(json) !== compressed) { - throw new Error('The link is damaged or incomplete. Copy the complete plan link again.'); - } - let value: unknown; - try { - value = JSON.parse(json); - } catch { - throw new Error('The project data is damaged. Copy the complete plan link again.'); - } - return { state: parseState(value), error: null }; + value = JSON.parse(json); + } catch { + throw new Error('The project data is damaged. Copy the complete plan link again.'); + } + return parseState(value); +} + +/** Accept a readable query or a legacy fragment; never throw for an untrusted link. */ +export function decodeState(link: string): { state: UrlState; error: string | null } { + if (link === '' || link === '#' || link === '?') return { state: defaultState(), error: null }; + try { + if (!link.startsWith('?') && !link.startsWith('#')) + throw new Error('Use the complete plan query, starting with ?.'); + return { state: link.startsWith('?') ? parseQuery(link) : parseLegacy(link), error: null }; } catch (error) { const detail = error instanceof URIError diff --git a/src/lib/use-project.ts b/src/lib/use-project.ts index 653ed29..01322ba 100644 --- a/src/lib/use-project.ts +++ b/src/lib/use-project.ts @@ -2,14 +2,31 @@ import { useCallback, useEffect, useState } from 'react'; import type { UrlState } from './types'; import { decodeState, encodeState } from './url-state'; -/** The fragment is the durable source of truth. No local storage or server state. */ +const readLocation = () => decodeState(window.location.search || window.location.hash); +const queryUrl = (query: string) => `${window.location.pathname}${query}`; + +/** Readable query parameters are the durable source of truth. */ export function useProject() { - const [loaded, setLoaded] = useState(() => decodeState(window.location.hash)); + const [loaded, setLoaded] = useState(readLocation); useEffect(() => { - const restore = () => setLoaded(decodeState(window.location.hash)); + const restore = () => { + const next = readLocation(); + if (!next.error) { + try { + // Canonicalize editable queries and migrate existing fragment links + // without creating an extra entry in browser history. + const query = encodeState(next.state); + if (query !== window.location.search || window.location.hash) + window.history.replaceState(null, '', queryUrl(query)); + } catch (error) { + next.error = error instanceof Error ? error.message : 'This plan could not be saved.'; + } + } + setLoaded(next); + }; window.addEventListener('hashchange', restore); window.addEventListener('popstate', restore); - if (!window.location.hash) window.history.replaceState(null, '', encodeState(loaded.state)); + restore(); return () => { window.removeEventListener('hashchange', restore); window.removeEventListener('popstate', restore); @@ -19,10 +36,10 @@ export function useProject() { setLoaded((current) => { const next = transform(current.state); try { - const hash = encodeState(next); - if (hash !== window.location.hash) - window.history[milestone ? 'pushState' : 'replaceState'](null, '', hash); - const canonical = decodeState(hash).state; + const query = encodeState(next); + if (query !== window.location.search || window.location.hash) + window.history[milestone ? 'pushState' : 'replaceState'](null, '', queryUrl(query)); + const canonical = decodeState(query).state; if (next.project === current.state.project) canonical.project = current.state.project; return { state: canonical, error: null }; } catch (error) { diff --git a/src/styles.css b/src/styles.css index d840b80..c6f7e5d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -826,99 +826,6 @@ summary span { .material-cuts > .helper { padding: 0 17px 15px; } -.table-scroll { - width: 100%; - overflow-x: auto; -} -.cut-table { - width: 100%; - border-collapse: collapse; - text-align: left; - font-size: 12px; -} -.cut-table th { - font-weight: 600; - font-size: 10px; - background: #eff2f7; - border-top: 1px solid var(--line); - border-bottom: 1px solid var(--line); - padding: 11px 12px; - white-space: nowrap; -} -.cut-table td { - padding: 10px 12px; - border-bottom: 1px solid #dce1e8; -} -.cut-table td small { - display: block; - font-size: 10px; - color: #53647c; - margin-top: 2px; -} -.cut-table .numeric { - font-family: var(--mono); - white-space: nowrap; - font-size: 12px; -} -.piece-link { - min-width: 46px; - padding: 7px; - font: 700 12px var(--mono); - color: var(--blue); - border-color: #b3c9ed; - background: #f1f5fc; -} -.skirting-cuts .piece-link { - color: #983e03; - border-color: #e2b594; - background: #fff5eb; -} -.cut-table tr.selected td { - background: #f3f5fa; -} -.cut-table tr.selected .piece-link { - color: white; - background: var(--ink); -} -.coordinates { - display: flex; - flex-wrap: wrap; - gap: 6px; -} -.coordinates span { - font: 11px var(--mono); - padding: 5px 6px; - border: 1px solid var(--line); - background: white; -} -.stock-allocations { - margin: 12px 16px; -} -.stock-allocations summary { - font-size: 12px; -} -.allocation-list { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - padding: 0; - list-style: none; -} -.stock-card { - display: flex; - flex-direction: column; - gap: 5px; - padding: 10px; - background: #f6f7f9; - border: 1px solid #dce1e8; - overflow-wrap: anywhere; - font: 11px var(--mono); -} -.stock-card strong span, -.stock-card small { - font-size: 10px; - font-weight: 400; -} .empty-state { padding: 18px; font-size: 13px; @@ -1193,167 +1100,6 @@ summary span { .material-cuts > .helper { padding: 0 14px 15px; } - .table-scroll { - overflow: visible; - } - .cut-table, - .cut-table tbody { - display: block; - } - .cut-table thead { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - } - .cut-table tr { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 8px 12px; - padding: 14px; - border-top: 1px solid var(--line); - } - .cut-table td { - display: flex; - flex-direction: column; - justify-content: flex-start; - padding: 0; - border: 0; - font-size: 13px; - overflow-wrap: anywhere; - } - .cut-table td::before { - content: attr(data-label); - display: block; - font: - 10px Arial, - sans-serif; - color: #53647c; - margin-bottom: 3px; - } - .cut-table td:first-child { - grid-row: span 2; - align-items: flex-start; - } - .cut-table td:first-child::before { - display: none; - } - .piece-link { - min-height: 44px; - min-width: 58px; - font-size: 14px; - } - .cut-table .numeric { - font-size: 14px; - } - .cut-table td small { - font-size: 11px; - } - .cut-table tr.selected { - background: #eef2f9; - } - .cut-table tr.selected td { - background: none; - } - .stock-allocations { - margin: 10px 14px; - } - .stock-allocations summary { - font-size: 12px; - } - .coordinates span { - font-size: 11px; - } -} -@media print { - :root { - background: white; - font-size: 11px; - } - .app-header, - .project-bar, - .header-actions, - .input-columns, - .page-jumps, - .copy-status, - .button, - .cut-dialog { - display: none !important; - } - .app-main { - padding: 0; - } - .workspace, - .drawing-column { - display: block; - position: static; - } - .drawing-card { - break-inside: avoid; - max-width: 600px; - margin: 0 auto; - } - .drawing-viewport { - max-height: none; - overflow: visible; - background: white; - } - .room-svg { - width: 100% !important; - min-height: 0; - max-height: 380px; - } - .purchase-summary { - margin: 15px 0; - } - .purchase-item { - padding: 8px; - } - .cut-list-card { - display: block; - } - .material-cuts { - margin-bottom: 20px; - } - .table-scroll { - overflow: visible; - } - .cut-table, - .cut-table tbody { - display: table; - width: 100%; - } - .cut-table tbody { - display: table-row-group; - } - .cut-table thead { - position: static; - display: table-header-group; - width: auto; - height: auto; - clip: auto; - } - .cut-table tr { - display: table-row; - break-inside: avoid; - } - .cut-table td { - display: table-cell; - padding: 6px 9px; - } - .cut-table td::before { - display: none; - } - .stock-allocations { - display: none; - } - .piece-link { - min-height: 0; - border: 0; - background: none; - padding: 0; - } } @media (max-width: 699px) { .tooltip-content { @@ -1366,92 +1112,6 @@ summary span { max-width: none; } } -.piece-chips { - display: flex; - flex-wrap: wrap; - gap: 5px; - min-width: 92px; -} -.batch-codes { - width: 32%; -} -.batch-qty { - text-align: center; -} -.flooring-batch.selected .piece-chip:not(.selected) { - color: var(--blue); - background: #f1f5fc; -} -.cut-origin { - font: 12px var(--mono); - color: #344963; - border-bottom: 1px solid var(--line); - padding-bottom: 9px; -} -@media (max-width: 699px) { - .cut-table tr.flooring-batch { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 12px; - } - .cut-table .flooring-batch .batch-size { - grid-column: 1; - grid-row: 1; - font-size: 16px; - } - .cut-table .flooring-batch .batch-qty { - grid-column: 2; - grid-row: 1; - text-align: left; - font-size: 16px; - } - .cut-table .flooring-batch .batch-room { - grid-column: 1; - grid-row: 2; - } - .cut-table .flooring-batch .batch-action { - grid-column: 2; - grid-row: 2; - } - .cut-table .flooring-batch td.batch-codes { - grid-column: 1 / -1; - grid-row: 3; - width: auto; - } - .piece-chips { - width: 100%; - gap: 6px; - } - .piece-chip { - min-width: 44px; - min-height: 44px; - padding: 7px; - font-size: 13px; - } - .skirting-cuts .cut-table tr { - grid-template-columns: 76px minmax(0, 1fr) minmax(0, 1fr); - gap: 10px; - } -} -@media print { - .stock-allocations { - display: block; - } - .stock-allocations::details-content { - content-visibility: visible !important; - display: block !important; - } - .stock-allocations > summary { - display: none; - } - .piece-chips { - gap: 6px; - } - .piece-chip { - min-height: 0; - min-width: 0; - padding: 2px; - } -} /* Each cut has a small shape preview; exact measurements open in a native dialog. */ .cut-preview-button { @@ -1561,7 +1221,7 @@ summary span { display: block; width: 100%; height: 200px; - background: #f5f8fd; + background: white; } .skirting-full-preview svg { display: block; @@ -1572,22 +1232,6 @@ summary span { max-width: 400px; margin: 16px auto; } -.cut-measurements { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; - margin: 0; - padding: 14px 0; - border-block: 1px solid var(--line); -} -.cut-measurements dt { - font-size: 12px; - color: #53647c; -} -.cut-measurements dd { - margin: 4px 0 0; - font: 600 13px var(--mono); -} @media (max-width: 699px) { .cut-dialog-header { padding-inline: 12px; @@ -1599,13 +1243,264 @@ summary span { height: 150px; } } +.stock-cut-group { + border-top: 1px solid var(--line); +} +.stock-cut-group h4 { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin: 0; + padding: 12px 16px; + font-size: 14px; + background: #f1f3f6; +} +.stock-cut-group h4 span { + font-size: 12px; + font-weight: 400; + text-align: right; +} +.board-cuts { + padding: 0 16px; + margin: 0; + list-style: none; +} +.board-cut { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 0; +} +.board-cut + .board-cut { + border-top: 1px solid #e0e4e9; +} +.cut-size { + font-size: 14px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.cut-location, +.cut-note { + font-size: 12px; + color: #4b5c73; + font-weight: 400; +} +.cut-location { + margin-top: 3px; +} +.cut-preview-button { + flex-shrink: 0; + background: #f0f5ff; + border-color: #1647ab; + color: #1647ab; +} +.skirting-preview-button { + background: #fff5eb; + border-color: #b74900; + color: #983e03; +} +.cut-preview-code { + font-family: inherit; +} +.cut-preview-code > span { + margin-left: 4px; +} +.drawing-legend { + flex-wrap: wrap; + gap: 8px 16px; + padding-bottom: 8px; +} +.uncut-key { + width: 17px; + height: 10px; + display: inline-block; + background: #deddd8; + border: 1px solid #79756b; +} +.drawing-help { + font-size: 11px; + padding: 0 20px 12px; + color: #4b5c73; +} +.svg-cut:hover > polygon, +.svg-cut:focus-visible > polygon { + stroke: #17263f; + stroke-width: 3px; + vector-effect: non-scaling-stroke; +} +.skirting-code { + cursor: pointer; +} +.skirt-cut:hover .skirting-code rect, +.skirt-cut:focus-visible .skirting-code rect { + fill: #17263f; + stroke: #17263f; +} +.door-position { + margin-bottom: 12px; + font-size: 12px; +} +.door-position svg { + display: block; + width: 100%; + max-width: 300px; + margin: 6px auto 0; +} +.door-position text { + font: + 11px Arial, + sans-serif; + fill: #17263f; +} +.cut-dialog-body { + font-size: 14px; + gap: 14px; +} +.cut-origin { + font-size: 14px; + color: #4b5c73; +} +.exact-preview figcaption { + font: + 13px Arial, + sans-serif; + color: #4b5c73; +} +.cut-dialog-body .helper { + font-size: 13px; +} +.print-plan { + display: none; +} +@page { + size: A4; + margin: 12mm; +} @media print { - .cut-thumbnail { - width: 48px; - height: 28px; + :root { + background: white; + font-size: 11px; } - .cut-preview-button { - width: 50px; - min-width: 50px; + body { + color: #17263f; + } + .app-header, + .app-main { + display: none !important; + } + .print-plan { + display: block; + } + .print-title { + margin-bottom: 8mm; + } + .print-title h1 { + font-size: 22px; + margin-bottom: 3mm; + } + .print-title p { + margin-top: 2mm; + } + .print-room { + break-before: page; + break-inside: avoid; + } + .print-title + .print-room { + break-before: auto; + } + .print-room > h2 { + margin: 5mm 0; + font-size: 19px; + } + .print-room > h2 span { + font-size: 13px; + font-weight: 400; + } + .print-room .drawing-card { + border: 0; + } + .print-room .drawing-header, + .print-room .drawing-legend { + display: none; + } + .print-room .drawing-viewport { + border: 0; + background: white; + } + .print-room .room-svg { + width: 100%; + max-height: 190mm; + } + .print-material { + break-before: page; + columns: 2; + column-gap: 5mm; + column-fill: auto; + } + .print-material > h2 { + column-span: all; + font-size: 22px; + margin-bottom: 4mm; + } + .print-stock { + break-inside: avoid; + margin-bottom: 4mm; + } + .print-stock > h3 { + display: flex; + justify-content: space-between; + gap: 5mm; + border-block: 1px solid #9baabd; + padding: 2mm 0; + margin-bottom: 2mm; + font-size: 12px; + break-after: avoid; + } + .print-stock h3 span { + font-size: 10px; + font-weight: 400; + } + .print-cut { + min-width: 0; + break-inside: avoid; + margin-bottom: 3mm; + } + .print-cut h4 { + display: flex; + flex-wrap: wrap; + gap: 2mm; + margin: 0 0 2mm; + font-size: 13px; + } + .print-cut h4 span { + font-size: 11px; + font-weight: 400; + } + .print-cut p { + font-size: 9px; + line-height: 1.4; + } + .print-cut .exact-preview > .flooring-preview { + height: 28mm; + background: white; + } + .print-cut .exact-preview figcaption { + font-size: 10px; + margin: 1mm 0; + } + .print-cut .skirting-full-preview svg { + height: 18mm; + } + .print-cut .exact-preview .skirting-end-grid { + max-width: 38mm; + margin: 2mm auto; + } + .print-cut .helper { + font-size: 10px; + } + .print-plan * { + print-color-adjust: exact; + -webkit-print-color-adjust: exact; } } diff --git a/tests/cut-list.test.ts b/tests/cut-list.test.ts index ee3eb99..10831fe 100644 --- a/tests/cut-list.test.ts +++ b/tests/cut-list.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { defaultProject } from '../src/lib/defaults'; -import { flooringCuts, groupFlooringCuts } from '../src/lib/cut-list'; +import { defaultProject, rectangle } from '../src/lib/defaults'; +import { + flooringCuts, + groupFlooringCuts, + groupSkirtingCuts, + skirtingCuts, +} from '../src/lib/cut-list'; import { planFlooring } from '../src/lib/flooring'; +import { planSkirting } from '../src/lib/skirting'; import type { FlooringPiece, FlooringPlan } from '../src/lib/types'; -function piece(id: string, changes: Partial = {}): FlooringPiece { +function piece(id: string, stockId: string, changes: Partial = {}): FlooringPiece { return { id, roomId: 'room-1', @@ -13,14 +19,11 @@ function piece(id: string, changes: Partial = {}): FlooringPiece polygon: [], length: 600, width: 190, - stockId: 'B1', + stockId, reused: false, cut: true, shaped: false, template: [], - factoryLeft: true, - factoryRight: false, - ripEdge: null, ...changes, }; } @@ -28,8 +31,12 @@ function plan(pieces: FlooringPiece[]): FlooringPlan { return { pieces, rows: [], - stocks: [], - boardCount: 0, + stocks: ['B1', 'B2', 'B3'].map((id) => ({ + id, + pieceIds: pieces.filter((p) => p.stockId === id).map((p) => p.id), + remaining: 0, + })), + boardCount: 3, cuts: 0, wasteArea: 0, wastePercent: 0, @@ -39,81 +46,54 @@ function plan(pieces: FlooringPiece[]): FlooringPlan { }; } -describe('flooring cut batches', () => { - it('combines matching cuts across rows and stocks, preserving every placement exactly once', () => { - const pieces = [ - piece('F1'), - piece('F2', { row: 4, stockId: 'B4', reused: true }), - piece('F3', { cut: false, length: 1200 }), - piece('F4', { cut: false, reused: true }), - piece('F5', { roomId: 'room-2' }), - ]; - const source = plan(pieces), - before = structuredClone(source); +describe('cuts grouped by purchased board', () => { + it('keeps different cuts and cross-room offcuts together, separating identical cuts from different boards', () => { + const source = plan([ + piece('F1', 'B2'), + piece('F2', 'B1'), + piece('F3', 'B2', { roomId: 'room-2', row: 4, length: 397, reused: true }), + piece('FU1', 'B3', { length: 1200, cut: false }), + ]); + const before = structuredClone(source); const groups = groupFlooringCuts(source); - expect(groups.map((group) => group.map((p) => p.id))).toEqual([['F1', 'F2'], ['F4'], ['F5']]); - expect( - groups - .flat() - .map((p) => p.id) - .sort(), - ).toEqual( - flooringCuts(source) - .map((p) => p.id) - .sort(), - ); + expect(groups.map((group) => group.map((p) => p.id))).toEqual([['F2'], ['F1', 'F3']]); expect(source).toEqual(before); expect(groupFlooringCuts(source)).toEqual(groups); }); - it('keeps different factory ends, rip edges and thousandth-millimetre dimensions separate', () => { + it('includes ready-to-use offcuts in their original board group', () => { const groups = groupFlooringCuts( - plan([ - piece('F1'), - piece('F2', { factoryLeft: false, factoryRight: true }), - piece('F3', { ripEdge: 'top' }), - piece('F4', { ripEdge: 'bottom' }), - piece('F5', { length: 600.001 }), - piece('F6', { width: 190.001 }), - ]), + plan([piece('F1', 'B1'), piece('F2', 'B1', { cut: false, reused: true })]), ); - expect(groups).toHaveLength(6); + expect(groups.map((group) => group.map((p) => p.id))).toEqual([['F1', 'F2']]); }); - it('groups shapes only when their complete measured templates match', () => { - const template = [ - { x: 0, y: 0 }, - { x: 600, y: 0 }, - { x: 600, y: 190 }, - { x: 0, y: 100 }, - ]; - const groups = groupFlooringCuts( - plan([ - piece('F1', { shaped: true, template }), - piece('F2', { shaped: true, template: structuredClone(template) }), - piece('F3', { shaped: true, template: [...template.slice(0, 3), { x: 0, y: 100.001 }] }), - piece('F4'), - ]), - ); - expect(groups.map((group) => group.map((p) => p.id))).toEqual([['F1', 'F2'], ['F3'], ['F4']]); - }); - - it('reduces the default room to a few batches while keeping all cut and offcut codes', () => { + it('includes every flooring and skirting cut once in stock order across multiple rooms', () => { const project = defaultProject(); + project.rooms.push(rectangle('room-2', 'Study', 3200, 3100)); const floor = planFlooring(project.rooms, project.flooring, project.tolerance); - const groups = groupFlooringCuts(floor); - expect(groups.length).toBeLessThanOrEqual(7); - expect(groups.length).toBeLessThan(flooringCuts(floor).length / 2); + const skirt = planSkirting(project.rooms, project.skirting, project.tolerance); + for (const [groups, pieces, stocks] of [ + [groupFlooringCuts(floor), flooringCuts(floor), floor.stocks], + [groupSkirtingCuts(skirt), skirtingCuts(skirt), skirt.stocks], + ] as const) { + expect( + groups + .flat() + .map((p) => p.id) + .sort(), + ).toEqual(pieces.map((p) => p.id).sort()); + expect(new Set(groups.flat().map((p) => p.id)).size).toBe(pieces.length); + const used = new Set(pieces.map((p) => p.stockId)); + expect(groups.map((group) => group[0].stockId)).toEqual( + stocks.filter((s) => used.has(s.id)).map((s) => s.id), + ); + for (const group of groups) expect(new Set(group.map((p) => p.stockId)).size).toBe(1); + } expect( - groups - .flat() - .map((p) => p.id) - .sort(), - ).toEqual( - flooringCuts(floor) - .map((p) => p.id) - .sort(), - ); - expect(new Set(groups.flat().map((p) => p.id)).size).toBe(flooringCuts(floor).length); + [...groupFlooringCuts(floor), ...groupSkirtingCuts(skirt)].some( + (group) => new Set(group.map((p) => p.roomId)).size > 1, + ), + ).toBe(true); }); }); diff --git a/tests/cut-preview.test.ts b/tests/cut-preview.test.ts index 730930d..4561f00 100644 --- a/tests/cut-preview.test.ts +++ b/tests/cut-preview.test.ts @@ -2,7 +2,14 @@ import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; import { FlooringPreview, SkirtingPreview } from '../src/components/CutPreview'; -import { exactMm, flooringTemplate, skirtingEndBoxes } from '../src/lib/cut-preview'; +import { CutDialog } from '../src/components/CutDialog'; +import { defaultProject } from '../src/lib/defaults'; +import { + dimensionedOutline, + exactMm, + flooringTemplate, + skirtingEndBoxes, +} from '../src/lib/cut-preview'; import type { FlooringPiece, Point, SkirtingPiece } from '../src/lib/types'; const floor: FlooringPiece = { @@ -54,7 +61,10 @@ describe('cut shape previews', () => { const markup = renderToStaticMarkup(createElement(FlooringPreview, { piece: floor })); expect(markup).toContain('aria-label="Flooring shape F1"'); expect(markup).toContain('preserveAspectRatio="xMidYMid meet"'); - expect(markup).toContain('Length 600.001 mm × width 190 mm'); + expect(markup).toContain('600.001 × 190 mm'); + expect(markup).toContain('>600.001'); + expect(markup).toContain('>190'); + expect(markup.match(/class="dimension-label"/g)).toHaveLength(2); expect(exactMm(600.001)).toBe('600.001'); }); @@ -81,10 +91,133 @@ describe('cut shape previews', () => { ); expect(markup).toContain('points="18,0 1182,0 1200,18 0,18"'); expect(markup.match(/preserveAspectRatio="xMidYMid meet"/g)).toHaveLength(3); - expect(markup).toContain('Full length 1,200 mm × thickness 18 mm'); + expect(markup).toContain('1,200 × 18 mm'); + expect(markup).toContain('>1,164'); + expect(markup).toContain('>1,200'); expect(markup).not.toContain('exaggerat'); }); + it('labels every edge of a 320 by 100 mm notch without collisions or tiny mobile text', () => { + const notch = [ + { x: 0, y: 0 }, + { x: 880, y: 0 }, + { x: 880, y: 100 }, + { x: 1200, y: 100 }, + { x: 1200, y: 190 }, + { x: 0, y: 190 }, + ]; + const original = structuredClone(notch); + const preview = dimensionedOutline(notch); + expect(preview.dimensions.map((dimension) => dimension.length)).toEqual([ + 880, 100, 320, 90, 1200, 190, + ]); + expect((preview.font * 280) / preview.box.width).toBeGreaterThanOrEqual(12); + preview.dimensions.forEach((dimension, i) => { + const box = dimension.labelBox; + expect(box.x).toBeGreaterThan(preview.box.x); + expect(box.y).toBeGreaterThan(preview.box.y); + expect(box.x + box.width).toBeLessThan(preview.box.x + preview.box.width); + expect(box.y + box.height).toBeLessThan(preview.box.y + preview.box.height); + const projection = (point: Point) => + point.x * dimension.normal.x + point.y * dimension.normal.y; + const nearestLabel = Math.min( + ...[ + { x: box.x, y: box.y }, + { x: box.x + box.width, y: box.y }, + { x: box.x, y: box.y + box.height }, + { x: box.x + box.width, y: box.y + box.height }, + ].map(projection), + ); + expect(nearestLabel, 'Dimension text stays outside the shape').toBeGreaterThan( + Math.max(...notch.map(projection)), + ); + for (const other of preview.dimensions.slice(i + 1)) { + const b = other.labelBox; + expect( + box.x + box.width <= b.x || + b.x + b.width <= box.x || + box.y + box.height <= b.y || + b.y + b.height <= box.y, + ).toBe(true); + } + }); + expect(notch).toEqual(original); + expect(dimensionedOutline(notch)).toEqual(preview); + const markup = renderToStaticMarkup( + createElement(FlooringPreview, { + piece: { ...floor, length: 1200, shaped: true, template: notch }, + }), + ); + expect(markup).toContain('>320'); + expect(markup).toContain('>100'); + expect(markup).not.toMatch(/>[1-6]<\/text>/); + }); + + it('measures angled edges along the cut and retains the original outline', () => { + const points = [ + { x: 0, y: 0 }, + { x: 800, y: 0 }, + { x: 920, y: 160 }, + { x: 0, y: 160 }, + ]; + const preview = dimensionedOutline(points); + expect((preview.font * 280) / preview.box.width).toBeGreaterThanOrEqual(12); + expect(preview.dimensions[1].length).toBe(200); + expect(preview.dimensions[1].text).toBe('200'); + const angle = preview.dimensions[1].angle!; + expect(angle).toBeCloseTo(53.130102, 6); + const markedAngle = (Number(exactMm(angle)) * Math.PI) / 180; + expect(200 * Math.cos(markedAngle)).toBeCloseTo(120, 2); + expect(200 * Math.sin(markedAngle)).toBeCloseTo(160, 2); + expect(preview.dimensions[1].start).toEqual({ x: 800, y: 0 }); + expect(preview.dimensions[1].end).toEqual({ x: 920, y: 160 }); + const reversed = dimensionedOutline([...points].reverse()); + expect(reversed.dimensions.map((dimension) => dimension.length).sort()).toEqual( + preview.dimensions.map((dimension) => dimension.length).sort(), + ); + expect(preview.dimensions.filter((dimension) => dimension.angle !== undefined)).toHaveLength(1); + const markup = renderToStaticMarkup( + createElement(FlooringPreview, { + piece: { ...floor, length: 920, width: 160, shaped: true, template: points }, + }), + ); + expect(markup).toContain('>53.13°'); + expect(markup).toContain('angles from horizontal'); + }); + + it('keeps dialog instructions useful without source codes or numbered coordinate lists', () => { + const markup = renderToStaticMarkup( + createElement(CutDialog, { + floor: { ...floor, factoryLeft: true, ripEdge: 'top' }, + project: defaultProject(), + close: () => {}, + }), + ); + expect(markup).toContain('Living room · Row 1'); + expect(markup).toContain('Keep the left factory end; trim from the right.'); + expect(markup).toContain('Rip the top long edge; keep the bottom factory edge.'); + expect(markup).not.toContain('Source'); + expect(markup).not.toContain('coordinates'); + expect(markup).not.toContain('cut-edge-lengths'); + }); + + it('shows skirting dimensions and mitre directions without a second measurement list', () => { + const markup = renderToStaticMarkup( + createElement(CutDialog, { + skirt, + project: defaultProject(), + close: () => {}, + }), + ); + expect(markup).toContain('Living room · Wall 1'); + expect(markup).toContain('>1,164'); + expect(markup).toContain('>1,200'); + expect(markup).toContain('45° external'); + expect(markup).not.toContain('Source'); + expect(markup).not.toContain('coordinates'); + expect(markup).not.toContain(' { const shallow = [ { x: 0, y: 0 }, diff --git a/tests/export.test.ts b/tests/export.test.ts index 6a9e460..8430850 100644 --- a/tests/export.test.ts +++ b/tests/export.test.ts @@ -62,7 +62,7 @@ const skirt: SkirtingPlan = { }; describe('cut list CSV', () => { - it('exports measured cuts, shape coordinates, mitres, allocations and purchase counts', () => { + it('exports measured cuts, shape coordinates, mitres, board references and purchase counts', () => { const csv = cutListCsv(defaultProject(), floor, skirt); expect(csv.startsWith('\uFEFF"Material"')).toBe(true); expect(csv).toContain('"Flooring","Living room","F1","B1","450","190"'); @@ -70,11 +70,9 @@ describe('cut list CSV', () => { expect(csv).toContain('0.0:0.0 | 450.0:0.0 | 450.0:190.0 | 0.0:100.0'); expect(csv).toContain('"Skirting","Living room","A1","S1","1518","18","0","45"'); expect(csv).toContain('Back length 1500.0 mm; left square; right external'); - expect(csv).toContain('Remaining length 747.0 mm'); - expect(csv).toContain('Waste including kerf 879.0 mm'); expect(csv).toContain('Boards to buy; 1 installed pieces; 2 saw cuts'); expect(csv).not.toContain('Check the shaped edge.'); - expect(csv.split('\r\n')).toHaveLength(8); + expect(csv.split('\r\n')).toHaveLength(6); }); it('counts uncut full boards and lengths without exporting their codes or source allocations', () => { @@ -134,12 +132,12 @@ describe('cut list CSV', () => { expect(csv).toContain('"Skirting total","","","","","18","","","2"'); expect(csv.split('\r\n').filter((row) => row.startsWith('"Flooring",'))).toHaveLength(2); expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting",'))).toHaveLength(1); - expect(csv.split('\r\n').filter((row) => row.startsWith('"Flooring stock",'))).toHaveLength(1); - expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting stock",'))).toHaveLength(1); + expect(csv.split('\r\n').filter((row) => row.startsWith('"Flooring stock",'))).toHaveLength(0); + expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting stock",'))).toHaveLength(0); expect(flooringCuts(floorPlan)).toEqual([...floor.pieces, readyOffcut]); expect(csv).toContain('"Flooring","Living room","READY-F","B1","747","190"'); expect(csv).toContain('Reused offcut; Use offcut; no new cut'); - expect(csv).toContain('"Flooring stock","","F1 / READY-F","B1"'); + expect(csv).not.toContain('"Flooring stock"'); expect(uncutFlooring(floorPlan, project.flooring)).toEqual({ wholeBoards: 1, readyOffcuts: 1 }); expect(uncutSkirting(skirtPlan)).toEqual([{ length: 2400, count: 1 }]); }); diff --git a/tests/flooring.test.ts b/tests/flooring.test.ts index 8ed517b..1728215 100644 --- a/tests/flooring.test.ts +++ b/tests/flooring.test.ts @@ -215,6 +215,78 @@ describe('laminate planning', () => { expectCoverage(a, room(), s.gap); }); + it.each([ + ['rectangle', room(4800, 3600, 'room-1'), 76, 42], + ['fireplace', fireplace('room-2', 'Fireplace room'), 74, 48], + ] as const)( + 'breaks random staircases and near-brick repeats in a common %s', + (_, r, boardBudget, cutBudget) => { + const s = { ...settings, width: 190, pattern: 'random' as const }; + const plan = planFlooring([r], s, 1); + expect(errors(plan)).toEqual([]); + expect(plan).toEqual(planFlooring([r], s, 1)); + const phases = plan.rows.map((row) => row.offset); + const signedDistance = (a: number, b: number) => { + let difference = b - a; + if (difference >= s.length / 2) difference -= s.length; + if (difference < -s.length / 2) difference += s.length; + return difference; + }; + const steps = phases.slice(1).map((phase, i) => signedDistance(phases[i], phase)); + // This case actually crosses the board boundary, so checking raw differences would miss steps. + expect( + phases.some((phase, i) => i > 0 && Math.abs(phase - phases[i - 1]) > s.length / 2), + ).toBe(true); + for (let i = 1; i < steps.length; i++) { + // Similar signed steps across three rows produce a visible staircase, even with small kerf drift. + expect(Math.abs(steps[i] - steps[i - 1])).toBeGreaterThan(36); + // Returning to almost the same joint line two rows later produces an H/brick repeat. + expect(Math.abs(signedDistance(phases[i - 1], phases[i + 1]))).toBeGreaterThan(36); + } + for (let i = 2; i < steps.length; i++) { + expect( + steps.slice(i - 2, i + 1).every((step) => step > 0) || + steps.slice(i - 2, i + 1).every((step) => step < 0), + ).toBe(false); + } + // Breaking the old regular pattern must not increase the stock requirement for these rooms. + expect(plan.boardCount).toBeLessThanOrEqual(boardBudget); + expect(plan.cuts).toBeLessThanOrEqual(cutBudget); + expect(plan.reusedCount).toBeGreaterThan(0); + expect( + plan.pieces.every((piece) => piece.length >= s.minLength && piece.width >= s.minWidth), + ).toBe(true); + expectOffsets(plan, s); + expectStockFits(plan, s); + expectCoverage(plan, r, s.gap); + }, + ); + + it('keeps a constrained random layout valid when only alternating starts can fit', () => { + const r = room(2420, 1620); + const s = { ...settings, pattern: 'random' as const, minLength: 600 }; + const plan = planFlooring([r], s, 1); + expect(errors(plan)).toEqual([]); + expect(plan.rows).toHaveLength(8); + expect(new Set(plan.rows.map((row) => row.offset))).toEqual(new Set([0, 600])); + expect(plan.rows[0].offset).toBe(plan.rows[2].offset); + expectOffsets(plan, s); + expectStockFits(plan, s); + expectCoverage(plan, r, s.gap); + }); + + it.each([400, 600])( + 'respects an exact %i mm random offset even when repetition is unavoidable', + (offset) => { + const s = { ...settings, pattern: 'random' as const, minOffset: offset, maxOffset: offset }; + const plan = planFlooring([room()], s, 1); + expect(errors(plan)).toEqual([]); + expect(plan).toEqual(planFlooring([room()], s, 1)); + expectOffsets(plan, s); + expectStockFits(plan, s); + }, + ); + it('rejects impossible brick stagger constraints', () => { const plan = planFlooring([room()], { ...settings, maxOffset: 400 }, 1); expect(plan.boardCount).toBe(0); diff --git a/tests/url-state.test.ts b/tests/url-state.test.ts index 62362a8..0360810 100644 --- a/tests/url-state.test.ts +++ b/tests/url-state.test.ts @@ -15,7 +15,7 @@ function expectInvalid(hash: string, message?: RegExp) { describe('URL state', () => { it('starts with independent defaults when no link is provided', () => { - for (const hash of ['', '#']) { + for (const hash of ['', '#', '?']) { const first = decodeState(hash); expect(first).toEqual({ state: defaultState(), error: null }); first.state.project.rooms[0].name = 'Edited'; @@ -26,7 +26,12 @@ describe('URL state', () => { it('round-trips all default inputs exactly and produces the same link every time', () => { const state = defaultState(); const hash = encodeState(state); - expect(hash).toMatch(/^#plan=[A-Za-z0-9+$-]+$/); + expect(hash).toMatch(/^\?version=1&name=/); + const params = new URLSearchParams(hash); + expect(params.get('flooring.length')).toBe('1200'); + expect(params.get('room.1.wall.1.length')).toBe('4800'); + expect(params.get('room.1.wall.4.door.1.width')).toBe('826'); + expect(hash).not.toContain('#plan='); expect(decodeState(hash)).toEqual({ state, error: null }); expect(encodeState(decodeState(hash).state)).toBe(hash); expect(encodeState(structuredClone(state))).toBe(hash); @@ -101,8 +106,8 @@ describe('URL state', () => { expect(result.state.view.roomId).toBe(state.project.rooms[0].id); }); - it('accepts URL-escaped compression characters', () => { - const hash = encodeState(defaultState()); + it('accepts URL-escaped legacy compression characters', () => { + const hash = rawLink(defaultState()); expect(decodeState(`#plan=${encodeURIComponent(hash.slice(6))}`).state).toEqual(defaultState()); }); @@ -111,8 +116,8 @@ describe('URL state', () => { (hash) => expectInvalid(hash), ); - it('rejects truncated and appended payloads even when the decoder ignores trailing data', () => { - const hash = encodeState(defaultState()); + it('rejects truncated and appended legacy payloads even when the decoder ignores trailing data', () => { + const hash = rawLink(defaultState()); expectInvalid(hash.slice(0, -4)); expectInvalid(`${hash}AAAA`); }); @@ -226,6 +231,129 @@ describe('URL state', () => { expectInvalid(rawLink(largeName), /at most 200 characters/); }); + it('uses individually editable fields and canonicalizes query parameter order', () => { + const state = defaultState(); + const params = new URLSearchParams(encodeState(state)); + params.set('flooring.length', '1400'); + params.set('flooring.maxOffset', '700'); + params.set('room.1.wall.1.length', '5123.5'); + params.set('room.1.wall.4.door.1.width', '900'); + params.append('skirting.stock', '3600'); + params.set('selectedCut', 'F12'); + const result = decodeState(`?${params}`); + expect(result.error).toBeNull(); + expect(result.state.project.flooring.length).toBe(1400); + expect(result.state.project.rooms[0].walls[0].length).toBe(5123.5); + expect(result.state.project.rooms[0].walls[3].doors[0].width).toBe(900); + expect(result.state.project.skirting.stockLengths).toEqual([2400, 3600]); + expect(result.state.view.selectedPiece).toBe('F12'); + const reversed = new URLSearchParams([...params].reverse()); + // Repeated stock values retain their order; every other key can move freely. + reversed.delete('skirting.stock'); + params.getAll('skirting.stock').forEach((length) => reversed.append('skirting.stock', length)); + expect(encodeState(decodeState(`?${reversed}`).state)).toBe(encodeState(result.state)); + }); + + it('accepts a small hand-written query and uses defaults for omitted material fields', () => { + const result = decodeState('?flooring.length=1400&flooring.pattern=random&selectedCut=F2'); + const state = defaultState(); + state.project.flooring.length = 1400; + state.project.flooring.pattern = 'random'; + state.view.selectedPiece = 'F2'; + expect(result).toEqual({ state, error: null }); + }); + + it('can describe an unfinished room without storing array counts or generated plans', () => { + const result = decodeState( + '?room.1.name=Office&room.1.wall.1.length=4000&room.1.wall.1.turn=-90', + ); + expect(result.error).toBeNull(); + expect(result.state.project.rooms).toEqual([ + { + id: 'room-1', + name: 'Office', + direction: 0, + walls: [{ id: 'room-1-w1', length: 4000, turn: -90, doors: [] }], + }, + ]); + const encoded = encodeState(result.state); + expect(encoded).not.toMatch(/count|polygon|stocks|pieces|JSON|plan=/); + expect(new URLSearchParams(encoded).has('room.1.wall.2.length')).toBe(false); + }); + + it('preserves explicitly empty stocks and rooms while omitting an unselected cut', () => { + const state = defaultState(); + state.project.skirting.stockLengths = []; + state.project.rooms[0].walls = []; + const query = encodeState(state); + expect(query).toContain('skirting.stock='); + expect(query).not.toContain('selectedCut='); + expect(decodeState(query)).toEqual({ state, error: null }); + }); + + it.each([ + '?flooring.length=NaN', + '?flooring.length=Infinity', + '?flooring.length=1e999', + '?flooring.length=', + '?flooring.length=true', + '?flooring.length=0x10', + '?flooring.pattern=diagonal', + '?room.1.direction=45', + '?version=2', + '?room.1.wall.1.turn=90', + '?room.1.wall.1.length=4000&room.1.wall.1.door.1.offset=300', + '?skirting.stock=&skirting.stock=2400', + '?name=%', + '?name=%C0%AF', + ])('rejects an invalid editable query %s', (query) => expectInvalid(query)); + + it('rejects ambiguous duplicate single-value fields but permits multiple stock lengths', () => { + expectInvalid('?flooring.length=1200&flooring.length=1400', /only once/); + expectInvalid('?selectedRoom=one&selectedRoom=two', /only once/); + expectInvalid('?room.1.name=Office&room.1.name=Kitchen', /only once/); + const result = decodeState('?skirting.stock=2400&skirting.stock=3600'); + expect(result.error).toBeNull(); + expect(result.state.project.skirting.stockLengths).toEqual([2400, 3600]); + }); + + it('rejects sparse, zero, malformed and excessive room, wall and door indices', () => { + expectInvalid('?room.2.id=second', /consecutive/); + expectInvalid('?room.0.id=zero', /positive whole numbers/); + expectInvalid('?room.one.id=one', /positive whole numbers/); + expectInvalid('?room.21.id=excess', /at most 20/); + expectInvalid('?room.1.wall.101.length=4000', /at most 100/); + expectInvalid('?room.1.wall.1.length=4000&room.1.wall.1.door.31.width=500', /at most 30/); + expectInvalid(`?${Array(31).fill('skirting.stock=2400').join('&')}`, /at most 30/); + expectInvalid('?room.1.id=same&room.2.id=same', /duplicate identifiers/); + }); + + it('discards unrecognized query fields instead of storing derived state', () => { + const decoded = decodeState('?flooring.length=1400&boardCount=999&cache=old&room.8.cache=old'); + expect(decoded.error).toBeNull(); + const canonical = encodeState(decoded.state); + expect(canonical).toContain('flooring.length=1400'); + expect(canonical).not.toMatch(/boardCount|cache/); + expect(decoded.state.project.rooms).toEqual(defaultState().project.rooms); + }); + + it('converts a legacy shared plan to readable fields without losing inputs or selections', () => { + const state = defaultState(); + state.project.rooms.push(fireplace('second', 'Fireplace room')); + state.project.skirting.stockLengths = [2400, 3600]; + state.view = { roomId: 'second', selectedPiece: 'B2' }; + const legacy = decodeState(rawLink(state)); + expect(legacy.error).toBeNull(); + const query = encodeState(legacy.state); + expect(query).toContain('room.2.wall.4.turn=-90'); + expect(query).toContain('selectedRoom=second&selectedCut=B2'); + expect(decodeState(query)).toEqual({ state, error: null }); + }); + + it('rejects oversized query strings before parsing fields', () => { + expectInvalid(`?name=${'x'.repeat(URL_LIMITS.queryLength)}`, /link is too large/); + }); + it('rejects oversized compressed and expanded payloads', () => { expectInvalid(`#plan=${'a'.repeat(URL_LIMITS.hashLength)}`, /link is too large/); expectInvalid(