This commit is contained in:
Andras Schmelczer 2026-09-11 16:58:56 +01:00
commit 9689bfcc18
23 changed files with 1815 additions and 955 deletions

View file

@ -20,39 +20,39 @@ npm run check # Build, unit tests and browser tests
## Planning workflow ## 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. 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. 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. 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. 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 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. 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 ## 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. - 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. - 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. - 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. - 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 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. - 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. - 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. - 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 ## Code map
| File | Responsibility | | File | Responsibility |
| ------------------------ | ----------------------------------------------------------- | | ------------------------ | ---------------------------------------------------------------- |
| `src/lib/types.ts` | Inputs and derived result contracts | | `src/lib/types.ts` | Inputs and derived result contracts |
| `src/lib/defaults.ts` | Starter measurements and deterministic IDs | | `src/lib/defaults.ts` | Starter measurements and deterministic IDs |
| `src/lib/geometry.ts` | Vector tracing, closure, validation and polygon offsets | | `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/flooring.ts` | Row search, clipping, factory-end-aware stock allocation |
| `src/lib/skirting.ts` | Corner allowances, run splitting and 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/url-state.ts` | Readable query schema, legacy links and input validation |
| `src/lib/use-project.ts` | URL and browser-history synchronization | | `src/lib/use-project.ts` | URL and browser-history synchronization |
| `src/lib/export.ts` | CSV generation, including formula-injection protection | | `src/lib/export.ts` | CSV generation, including formula-injection protection |
| `src/components/` | Editors, SVG drawing and linked cut tables | | `src/components/` | Editors, dimensioned SVGs, board groups and printable cut sheets |
| `tests/` | Pure calculation and state tests | | `tests/` | Pure calculation and state tests |
| `e2e/` | Actual browser workflows at desktop and mobile sizes | | `e2e/` | Actual browser workflows at desktop and mobile sizes |

View file

@ -1,7 +1,9 @@
import { expect, test } from '@playwright/test'; 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 { 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 { planFlooring } from '../src/lib/flooring';
import { planSkirting } from '../src/lib/skirting'; import { planSkirting } from '../src/lib/skirting';
import { flooringCuts, skirtingCuts, uncutFlooring } from '../src/lib/cut-list'; import { flooringCuts, skirtingCuts, uncutFlooring } from '../src/lib/cut-list';
@ -9,15 +11,16 @@ import { mm } from '../src/lib/format';
const errors = new WeakMap<Page, string[]>(); const errors = new WeakMap<Page, string[]>();
const preview = (page: Page) => page.getByRole('region', { name: 'Floor plan preview' }); const preview = (page: Page) => page.getByRole('region', { name: 'Floor plan preview' });
const floorRows = (page: Page) => const floorEntries = (page: Page) => page.locator('.flooring-cuts .board-cut');
page.getByRole('table', { name: 'Flooring cuts and offcut placements' }).locator('tbody tr'); const skirtEntries = (page: Page) => page.locator('.skirting-cuts .board-cut');
const skirtRows = (page: Page) =>
page.getByRole('table', { name: 'Skirting pieces requiring cuts' }).locator('tbody tr');
const floorCodes = (page: Page) => const floorCodes = (page: Page) =>
floorRows(page).getByRole('button', { name: /^Preview flooring cut / }); floorEntries(page).getByRole('button', { name: /^Preview flooring cut / });
const skirtCodes = (page: Page) => const skirtCodes = (page: Page) =>
skirtRows(page).getByRole('button', { name: /^Preview skirting cut / }); skirtEntries(page).getByRole('button', { name: /^Preview skirting cut / });
const state = (page: Page) => decodeState(new URL(page.url()).hash).state; const state = (page: Page) => {
const url = new URL(page.url());
return decodeState(url.search || url.hash).state;
};
const plans = (page: Page) => { const plans = (page: Page) => {
const { project } = state(page); const { project } = state(page);
return { 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) { async function openWalls(page: Page) {
if ( if (
!(await page !(await page
@ -97,6 +143,7 @@ test.beforeEach(async ({ page }) => {
page.on('pageerror', (error) => errors.get(page)!.push(error.message)); page.on('pageerror', (error) => errors.get(page)!.push(error.message));
await page.goto('/'); await page.goto('/');
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
await expect(page).toHaveURL(/\?version=1&/);
}); });
test.afterEach(async ({ page }) => { 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, page,
}) => { }) => {
const { project, floor, skirt } = plans(page); const { project, floor, skirt } = plans(page);
const cutFloor = flooringCuts(floor); const cutFloor = flooringCuts(floor),
const cutSkirt = skirtingCuts(skirt); cutSkirt = skirtingCuts(skirt);
const wholeBoards = uncutFlooring(floor, project.flooring).wholeBoards; const wholeBoards = uncutFlooring(floor, project.flooring).wholeBoards;
expect(wholeBoards).toBeGreaterThan(0); expect(wholeBoards).toBeGreaterThan(0);
await expect(floorCodes(page)).toHaveCount(cutFloor.length); await expect(floorCodes(page)).toHaveCount(cutFloor.length);
await expect(skirtCodes(page)).toHaveCount(cutSkirt.length); await expect(skirtCodes(page)).toHaveCount(cutSkirt.length);
const shownCodes = await floorCodes(page).evaluateAll((buttons) => const floorGroups = await stockGroups(page.locator('.flooring-cuts .stock-cut-group'));
buttons.map((button) => const skirtGroups = await stockGroups(page.locator('.skirting-cuts .stock-cut-group'));
button.getAttribute('aria-label')!.replace('Preview flooring cut ', ''), 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(shownCodes.sort()).toEqual(cutFloor.map((piece) => piece.id).sort());
expect(cutFloor.map((piece) => piece.id)).toEqual(cutFloor.map((_, i) => `F${i + 1}`)); 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(cutSkirt.map((piece) => piece.id)).toEqual(cutSkirt.map((_, i) => `A${i + 1}`));
expect(await floorRows(page).count(), 'Repeated dimensions share one cutting batch').toBeLessThan( for (const piece of cutFloor) {
cutFloor.length, await expect(
); floorEntries(page)
const batches = await floorRows(page).evaluateAll((rows) => .filter({
rows.map((row) => ({ has: page.getByRole('button', {
codes: [...row.querySelectorAll('button[aria-label^="Preview flooring cut "]')].map( name: `Preview flooring cut ${piece.id}`,
(button) => button.getAttribute('aria-label')!.replace('Preview flooring cut ', ''), exact: true,
), }),
size: row.querySelector('.batch-size')?.textContent?.trim(), })
quantity: row.querySelector('.batch-qty')?.textContent?.trim(), .locator('.cut-size'),
})), ).toHaveText(`${mm(piece.length)} × ${mm(piece.width)} mm`);
);
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)}`);
}
} }
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('.floor-piece')).toHaveCount(floor.pieces.length);
await expect(preview(page).locator('.skirt-piece')).toHaveCount(skirt.pieces.length); await expect(preview(page).locator('.skirt-piece')).toHaveCount(skirt.pieces.length);
await expect(preview(page).getByRole('button', { name: /^Select flooring cut / })).toHaveCount( 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, cutSkirt.length,
); );
await expect(page.locator('.flooring-cuts .cut-summary')).toContainText( 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(); const drawnCodes = await preview(page).locator('svg text').allTextContents();
for (const piece of floor.pieces.filter((piece) => !piece.cut && !piece.reused)) { 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}"]`), preview(page).locator(`.skirt-piece[data-piece-id="${piece.id}"]`),
).toHaveAttribute('aria-hidden', 'true'); ).toHaveAttribute('aria-hidden', 'true');
} }
const repeatedCode = batches.find((batch) => batch.codes.length > 1)!.codes.at(-1)!; const uncutColor = await preview(page)
const repeatedPiece = cutFloor.find((piece) => piece.id === repeatedCode)!; .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', { const thumbnail = page.getByRole('button', {
name: `Preview flooring cut ${repeatedCode}`, name: `Preview flooring cut ${piece.id}`,
exact: true, exact: true,
}); });
await expect(thumbnail.locator('svg')).toBeVisible(); await expect(thumbnail.locator('svg')).toBeVisible();
await thumbnail.click(); 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).toBeVisible();
await expect(dialog.locator('.cut-origin')).toContainText(`Row ${repeatedPiece.row}`); await expect(dialog.locator('.cut-origin')).toHaveText(
await expect(dialog.locator('.cut-origin')).toContainText(repeatedPiece.stockId); `${project.rooms[0].name} · Row ${piece.row}`,
await expect(dialog.getByRole('img', { name: `Flooring shape ${repeatedCode}` })).toBeVisible(); );
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 page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(thumbnail).toBeFocused(); await expect(thumbnail).toBeFocused();
@ -217,14 +268,14 @@ test('inconsistent final measurements block cuts and small residuals show the ex
await openWalls(page); await openWalls(page);
await page.getByLabel('Wall 4 length', { exact: true }).fill('3700'); 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(page.getByText(/The outline is open by 100\.0 mm/).first()).toBeVisible();
await expect(floorRows(page)).toHaveCount(0); await expect(floorEntries(page)).toHaveCount(0);
await expect(skirtRows(page)).toHaveCount(0); await expect(skirtEntries(page)).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled(); await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
await page.getByLabel('Wall 4 length', { exact: true }).fill('3605'); await page.getByLabel('Wall 4 length', { exact: true }).fill('3605');
await expect(page.getByText(/non-straight wall/)).toBeVisible(); 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.getByText('Final wall: 3,600 mm expected · 5 mm difference')).toBeVisible();
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled(); 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 }) => { 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(); await thumbnail.click();
const dialog = page.getByRole('dialog', { name: `Skirting cut ${mitred.id}`, exact: true }); const dialog = page.getByRole('dialog', { name: `Skirting cut ${mitred.id}`, exact: true });
await expect(dialog).toBeVisible(); 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 end = Math.abs(mitred.leftMitre) === 45 ? 'left' : 'right';
const projected = await dialog const projected = await dialog
.locator(`.skirting-cut-edge[data-end="${end}"]`) .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( await expect(
page.getByRole('dialog', { name: `Flooring cut ${shaped.id}`, exact: true }), page.getByRole('dialog', { name: `Flooring cut ${shaped.id}`, exact: true }),
).toBeVisible(); ).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 page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(page.locator('.selected-cut')).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 expect(page.locator('.door-card')).toHaveCount(2);
await page.locator('.door-card').last().getByRole('combobox').selectOption({ label: 'W1' }); await page.locator('.door-card').last().getByRole('combobox').selectOption({ label: 'W1' });
const movedDoor = page.locator('.door-card').first(); 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'); await movedDoor.getByLabel('Door width', { exact: true }).fill('900');
expect(state(page).project.rooms[0].walls[0].doors[0]).toMatchObject({ expect(state(page).project.rooms[0].walls[0].doors[0]).toMatchObject({
offset: 1200, offset: 1200,
width: 900, 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(); await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
expect(await page.locator('.skirting-cuts').innerText()).not.toBe(before); expect(await page.locator('.skirting-cuts').innerText()).not.toBe(before);
await page.getByLabel('Stock length 1', { exact: true }).fill('3600'); 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 expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
await movedDoor.getByLabel('Door width', { exact: true }).fill('6000'); await movedDoor.getByLabel('Door width', { exact: true }).fill('6000');
await expect(page.getByText(/must fit within the wall/).first()).toBeVisible(); 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(); await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
}); });
test('invalid skirting settings keep flooring results usable', async ({ page }) => { test('invalid skirting settings keep flooring results usable', async ({ page }) => {
const floorCount = await floorCodes(page).count(); const floorCount = await floorCodes(page).count();
await page.getByLabel('Stock length 1', { exact: true }).fill('100'); 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(floorCodes(page)).toHaveCount(floorCount);
await expect(page.locator('.issue.error').first()).toBeVisible(); await expect(page.locator('.issue.error').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled(); await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
await page.getByLabel('Stock length 1', { exact: true }).fill('2400'); 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(); 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(state(page).project.rooms[0].direction).toBe(90);
expect(Object.keys(state(page).view).sort()).toEqual(['roomId', 'selectedPiece']); expect(Object.keys(state(page).view).sort()).toEqual(['roomId', 'selectedPiece']);
expect(Object.keys(state(page)).sort()).toEqual(['project', 'view']); 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 page.reload();
await expect(page.getByLabel('Wall spacer', { exact: true })).toHaveValue('12'); await expect(page.getByLabel('Wall spacer', { exact: true })).toHaveValue('12');
await expect(page.getByRole('button', { name: 'Random', exact: true })).toHaveAttribute( 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( await expect(
page.getByRole('button', { name: 'Across first wall', exact: true }), page.getByRole('button', { name: 'Across first wall', exact: true }),
).toHaveAttribute('aria-pressed', '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); await noHorizontalOverflow(page);
expect(await page.evaluate(() => Object.keys(localStorage))).toEqual([]); 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]) { for (const row of [1, 20]) {
const piece = flooringCuts(floor).find((piece) => piece.row === row); const piece = flooringCuts(floor).find((piece) => piece.row === row);
expect(piece).toBeDefined(); 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 }), 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).toHaveLength(2);
expect(state(page).project.rooms[0].walls[0].length).toBe(4800); expect(state(page).project.rooms[0].walls[0].length).toBe(4800);
expect(state(page).project.rooms[1].walls[0].length).toBe(3200); expect(state(page).project.rooms[1].walls[0].length).toBe(3200);
expect(await floorRows(page).filter({ hasText: 'Living room' }).count()).toBeGreaterThan(0); expect(await floorEntries(page).filter({ hasText: 'Living room' }).count()).toBeGreaterThan(0);
expect(await skirtRows(page).filter({ hasText: 'Study' }).count()).toBeGreaterThan(0); expect(await skirtEntries(page).filter({ hasText: 'Study' }).count()).toBeGreaterThan(0);
await page.getByRole('button', { name: /^Living room.*m²/ }).click(); await page.getByRole('button', { name: /^Living room.*m²/ }).click();
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800'); await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800');
await page.getByRole('button', { name: /^Study.*m²/ }).click(); 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(); ).toBeVisible();
expect(state(page).view.selectedPiece).toBe(pieceCode); expect(state(page).view.selectedPiece).toBe(pieceCode);
const shareUrl = page.url(); 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(); const shared = await context.newPage();
await shared.goto(shareUrl); await shared.goto(shareUrl);
await expect( await expect(
shared.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }), shared.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }),
).toBeVisible(); ).toBeVisible();
await expect(shared.locator('.flooring-cuts tbody')).toHaveText(originalTable, { await expect(shared.locator('.flooring-cuts')).toHaveText(originalTable, {
useInnerText: true, useInnerText: true,
}); });
await shared.reload(); 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(); await expect(page.getByText('Link copied', { exact: true })).toBeVisible();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(closedUrl); expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(closedUrl);
await page.reload(); await page.reload();
await expect(page.locator('.flooring-cuts tbody')).toHaveText(originalTable, { await expect(page.locator('.flooring-cuts')).toHaveText(originalTable, {
useInnerText: true, 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 }) => { 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 expect(page.getByText(/Could not load this plan/)).toBeVisible();
await page.getByLabel('Room name', { exact: true }).fill('Recovered plan'); await page.getByLabel('Room name', { exact: true }).fill('Recovered plan');
await expect(page.getByText(/Could not load this plan/)).toHaveCount(0); 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 page.reload();
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Recovered plan'); 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]) { for (const width of [320, 390]) {
test(`phone layout works at ${width} px with readable inputs and touch targets`, async ({ test(`phone layout works at ${width} px with readable inputs and touch targets`, async ({
page, page,
@ -555,10 +755,10 @@ for (const width of [320, 390]) {
); );
expect(smallInputs, '16 px input text avoids phone browser zoom').toEqual([]); expect(smallInputs, '16 px input text avoids phone browser zoom').toEqual([]);
expect( expect(
await floorRows(page) await floorEntries(page)
.first() .first()
.evaluate((element) => getComputedStyle(element).display), .evaluate((element) => getComputedStyle(element).display),
).toMatch(/block|grid/); ).toMatch(/block|grid|flex/);
await page.getByRole('button', { name: 'With fireplace' }).click(); await page.getByRole('button', { name: 'With fireplace' }).click();
await noWallLabelOverlap(page); await noWallLabelOverlap(page);
await noDrawingScroll(page); await noDrawingScroll(page);
@ -574,7 +774,9 @@ for (const width of [320, 390]) {
await page.mouse.click(1, 1); await page.mouse.click(1, 1);
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(page.getByRole('dialog')).toHaveCount(0);
expect(state(page).view.selectedPiece).toBeNull(); 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); await noHorizontalOverflow(page);
}); });
} }

View file

@ -12,6 +12,7 @@ import { OutlineEditor } from './components/OutlineEditor';
import { MaterialsEditor } from './components/MaterialsEditor'; import { MaterialsEditor } from './components/MaterialsEditor';
import { FloorPlan } from './components/FloorPlan'; import { FloorPlan } from './components/FloorPlan';
import { CutList } from './components/CutList'; import { CutList } from './components/CutList';
import { PrintPlan } from './components/PrintPlan';
import { Issues } from './components/Fields'; import { Issues } from './components/Fields';
const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ block: 'start' }); const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ block: 'start' });
@ -254,6 +255,12 @@ export default function App() {
/> />
</div> </div>
</main> </main>
<PrintPlan
project={project}
geometries={geometries}
flooring={flooring}
skirting={skirting}
/>
</> </>
); );
} }

View file

@ -3,7 +3,6 @@ import { X } from 'lucide-react';
import type { FlooringPiece, Project, SkirtingPiece } from '../lib/types'; import type { FlooringPiece, Project, SkirtingPiece } from '../lib/types';
import { flooringInstructions } from '../lib/cut-instructions'; import { flooringInstructions } from '../lib/cut-instructions';
import { readyOffcutInstructions } from '../lib/cut-list'; import { readyOffcutInstructions } from '../lib/cut-list';
import { exactMm, flooringTemplate, skirtingTemplate } from '../lib/cut-preview';
import { FlooringPreview, SkirtingPreview } from './CutPreview'; import { FlooringPreview, SkirtingPreview } from './CutPreview';
interface Props { interface Props {
@ -33,11 +32,6 @@ export function CutDialog({ floor, skirt, project, close }: Props) {
const room = const room =
selected && selected &&
(project.rooms.find((candidate) => candidate.id === selected.roomId)?.name ?? selected.roomId); (project.rooms.find((candidate) => candidate.id === selected.roomId)?.name ?? selected.roomId);
const template = floor
? flooringTemplate(floor)
: skirt
? skirtingTemplate(skirt, project.skirting.thickness)
: [];
return ( return (
<dialog <dialog
ref={dialog} ref={dialog}
@ -71,87 +65,24 @@ export function CutDialog({ floor, skirt, project, close }: Props) {
{floor && ( {floor && (
<> <>
<p className="cut-origin"> <p className="cut-origin">
{room} · Row {floor.row} · Source {floor.stockId} {room} · Row {floor.row}
{floor.reused ? ' · Reused offcut' : ''}
</p> </p>
<FlooringPreview piece={floor} /> <FlooringPreview piece={floor} />
<p className="cut-instructions"> <p className="cut-instructions">
{floor.cut ? flooringInstructions(floor) : readyOffcutInstructions(floor)} {floor.cut ? flooringInstructions(floor) : readyOffcutInstructions(floor)}
</p> </p>
<p className="helper">
Left and right run along the board; top and bottom are its long edges.
</p>
{floor.shaped && (
<>
<p>
Mark these coordinates from the blank’s top-left corner: x along the board, y
across it.
</p>
<div className="coordinates">
{template.map((point, index) => (
<span key={index}>
{index + 1}: ({exactMm(point.x)}, {exactMm(point.y)}) mm
</span>
))}
</div>
<p className="cut-edge-lengths">
{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(' · ')}
</p>
</>
)}
</> </>
)} )}
{skirt && ( {skirt && (
<> <>
<p className="cut-origin"> <p className="cut-origin">
{room} · Wall {skirt.wallIndex + 1} · Source {skirt.stockId} {room} · Wall {skirt.wallIndex + 1}
</p> </p>
<SkirtingPreview piece={skirt} thickness={project.skirting.thickness} /> <SkirtingPreview piece={skirt} thickness={project.skirting.thickness} />
<dl className="cut-measurements"> <p className="cut-instructions">
<div> Measure the longest points. Mitre angles are measured from square; the end views show
<dt>Long point</dt> the cut direction.
<dd>{exactMm(skirt.length)} mm</dd>
</div>
<div>
<dt>Wall-facing length</dt>
<dd>{exactMm(skirt.backLength)} mm</dd>
</div>
<div>
<dt>Left mitre</dt>
<dd>
{exactMm(Math.abs(skirt.leftMitre))}° · {skirt.leftType}
</dd>
</div>
<div>
<dt>Right mitre</dt>
<dd>
{exactMm(Math.abs(skirt.rightMitre))}° · {skirt.rightType}
</dd>
</div>
</dl>
{template.length === 4 && (
<>
<p>
Cut the square blank first. Mark these distances from its left end, then join the
front and back marks at each end:
</p> </p>
<div className="coordinates">
<span>Back left: {exactMm(template[0].x)} mm</span>
<span>Back right: {exactMm(template[1].x)} mm</span>
<span>Front left: {exactMm(template[3].x)} mm</span>
<span>Front right: {exactMm(template[2].x)} mm</span>
</div>
<p className="helper">
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.
</p>
</>
)}
</> </>
)} )}
</div> </div>

View file

@ -5,6 +5,7 @@ import { downloadCsv } from '../lib/export';
import { import {
flooringCuts, flooringCuts,
groupFlooringCuts, groupFlooringCuts,
groupSkirtingCuts,
skirtingCuts, skirtingCuts,
uncutFlooring, uncutFlooring,
uncutSkirting, uncutSkirting,
@ -12,32 +13,6 @@ import {
import { FlooringPreview, SkirtingPreview } from './CutPreview'; import { FlooringPreview, SkirtingPreview } from './CutPreview';
import { CutDialog } from './CutDialog'; 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 (
<details className="stock-allocations">
<summary>{title}</summary>
<ul className="allocation-list">
{items.map((item) => (
<li key={item.id} className="stock-card">
<strong>
{item.id} <span>· {item.size}</span>
</strong>
<span>{item.pieces.join(' + ')}</span>
<small>{item.remainder}</small>
</li>
))}
</ul>
</details>
);
}
interface Props { interface Props {
project: Project; project: Project;
flooring: FlooringPlan; flooring: FlooringPlan;
@ -49,29 +24,23 @@ interface Props {
export function CutList({ project, flooring, skirting, selectedPiece, select }: Props) { export function CutList({ project, flooring, skirting, selectedPiece, select }: Props) {
const floorCuts = flooringCuts(flooring), const floorCuts = flooringCuts(flooring),
skirtCuts = skirtingCuts(skirting); 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 wholeFloor = uncutFlooring(flooring, project.flooring);
const wholeSkirt = uncutSkirting(skirting); 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 roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
const errors = [...flooring.issues, ...skirting.issues].some( const errors = [...flooring.issues, ...skirting.issues].some(
(issue) => issue.severity === 'error', (issue) => issue.severity === 'error',
); );
return ( return (
<section className="cut-list-card" aria-labelledby="cut-plan-title"> <section className="cut-list-card" aria-labelledby="cut-plan-title">
<div className="cut-list-header"> <div className="cut-list-header">
<div> <div>
<h2 id="cut-plan-title">Cut plan</h2> <h2 id="cut-plan-title">Cut plan</h2>
<p className="helper"> <p className="helper">
Cut pieces and offcut placements. All measurements are in millimetres. Each group comes from one board. Select a piece for its cutting diagram.
</p> </p>
</div> </div>
<button <button
className="button secondary small" className="button"
onClick={() => downloadCsv(project, flooring, skirting)} onClick={() => downloadCsv(project, flooring, skirting)}
disabled={errors || (!flooring.pieces.length && !skirting.pieces.length)} disabled={errors || (!flooring.pieces.length && !skirting.pieces.length)}
> >
@ -79,45 +48,33 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
Export CSV Export CSV
</button> </button>
</div> </div>
<section className="material-cuts flooring-cuts" aria-labelledby="flooring-cuts-title"> <section className="material-cuts flooring-cuts" aria-labelledby="flooring-cuts-title">
<h3 id="flooring-cuts-title"> <h3 id="flooring-cuts-title">
Flooring cuts <span className="count-badge">{floorCuts.length}</span> Flooring cuts <span className="count-badge">{floorCuts.length}</span>
</h3> </h3>
<p className="cut-summary"> <p className="cut-summary">
Buy {flooring.boardCount} boards · {flooring.pieces.length} installed pieces ·{' '} Buy {flooring.boardCount} boards · {wholeFloor.wholeBoards} laid uncut
{wholeFloor.wholeBoards} full boards laid uncut
{wholeFloor.readyOffcuts > 0 && {wholeFloor.readyOffcuts > 0 &&
` · ${wholeFloor.readyOffcuts} offcuts need no further cuts`} ` · ${wholeFloor.readyOffcuts} offcuts need no further cuts`}
.
</p> </p>
{floorCuts.length ? ( {groupFlooringCuts(flooring).map((group) => (
<div className="table-scroll"> <section
<table className="cut-table"> className="stock-cut-group"
<caption className="sr-only">Flooring cuts and offcut placements</caption> data-stock-id={group[0].stockId}
<thead> key={group[0].stockId}
<tr> aria-label={`Floorboard ${group[0].stockId}`}
<th scope="col">Pieces</th>
<th scope="col">Qty</th>
<th scope="col">Cut size · mm</th>
<th scope="col">Room</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
{floorGroups.map((group) => {
const first = group[0];
return (
<tr
key={first.id}
className={`flooring-batch ${group.some((piece) => piece.id === selectedPiece) ? 'selected' : ''}`}
> >
<td data-label="Pieces" className="batch-codes"> <h4>
<div className="piece-chips"> Floorboard {group[0].stockId}
<span>
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm
</span>
</h4>
<ul className="board-cuts">
{group.map((piece) => ( {group.map((piece) => (
<li className="board-cut" key={piece.id} data-cut-id={piece.id}>
<button <button
key={piece.id} className="cut-preview-button flooring-preview-button"
className={`piece-link piece-chip cut-preview-button flooring-preview-button ${piece.id === selectedPiece ? 'selected' : ''}`}
aria-label={`Preview flooring cut ${piece.id}`} aria-label={`Preview flooring cut ${piece.id}`}
aria-haspopup="dialog" aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)} onClick={() => select(piece.id, piece.roomId)}
@ -125,84 +82,62 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
<span className="cut-thumbnail" aria-hidden="true"> <span className="cut-thumbnail" aria-hidden="true">
<FlooringPreview piece={piece} compact /> <FlooringPreview piece={piece} compact />
</span> </span>
<span className="cut-preview-code">{piece.id}</span> <span className="cut-preview-code">
{piece.id} <span aria-hidden="true">↗</span>
</span>
</button> </button>
<div>
<p className="cut-size">
{mm(piece.length)} × {mm(piece.width)} mm
</p>
<p className="cut-location">
{roomName(piece.roomId)} · Row {piece.row}
</p>
{piece.reused && (
<p className="cut-note">
{piece.cut ? 'Cut from the remaining offcut' : 'Use the offcut as it is'}
</p>
)}
</div>
</li>
))} ))}
</div> </ul>
</td> </section>
<td data-label="Qty" className="batch-qty numeric"> ))}
{group.length} {!floorCuts.length && (
</td>
<td data-label="Cut size · mm" className="batch-size numeric">
{mm(first.length)} × {mm(first.width)}
</td>
<td data-label="Room" className="batch-room">
{roomName(first.roomId)}
</td>
<td data-label="Action" className="batch-action">
{!first.cut
? 'Use offcut · no new cut'
: first.shaped
? 'Shaped cut'
: 'Rectangular cut'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<p className="empty-state"> <p className="empty-state">
{flooring.pieces.length {flooring.pieces.length
? 'No flooring cuts needed.' ? 'No flooring cuts needed.'
: 'Complete the outline and flooring measurements to calculate cuts.'} : 'Complete the outline and flooring measurements to calculate cuts.'}
</p> </p>
)} )}
<StockAllocations
title="Source boards for flooring cuts"
items={flooring.stocks
.filter((stock) => 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`,
}))}
/>
</section> </section>
<section className="material-cuts skirting-cuts" aria-labelledby="skirting-cuts-title"> <section className="material-cuts skirting-cuts" aria-labelledby="skirting-cuts-title">
<h3 id="skirting-cuts-title"> <h3 id="skirting-cuts-title">
Skirting cuts <span className="count-badge">{skirtCuts.length}</span> Skirting cuts <span className="count-badge">{skirtCuts.length}</span>
</h3> </h3>
<p className="cut-summary"> <p className="cut-summary">
Buy {skirting.stockCount} lengths · {skirting.pieces.length} installed pieces ·{' '} Buy {skirting.stockCount} lengths ·{' '}
{wholeSkirt.reduce((sum, group) => sum + group.count, 0)} full lengths installed uncut. {wholeSkirt.reduce((sum, group) => sum + group.count, 0)} installed uncut
</p> </p>
<p className="helper"> {groupSkirtingCuts(skirting).map((group) => (
Lengths use the longest point. Mitres are saw settings from square; left and right follow <section
the wall direction. className="stock-cut-group"
</p> data-stock-id={group[0].stockId}
{skirtCuts.length ? ( key={group[0].stockId}
<div className="table-scroll"> aria-label={`Skirting board ${group[0].stockId}`}
<table className="cut-table"> >
<caption className="sr-only">Skirting pieces requiring cuts</caption> <h4>
<thead> Skirting board {group[0].stockId}
<tr> <span>
<th scope="col">Piece</th> {mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
<th scope="col">Room / wall</th> </span>
<th scope="col">Length · mm</th> </h4>
<th scope="col">Left / right</th> <ul className="board-cuts">
<th scope="col">Source</th> {group.map((piece) => (
</tr> <li className="board-cut" key={piece.id} data-cut-id={piece.id}>
</thead>
<tbody>
{skirtCuts.map((piece) => (
<tr key={piece.id} className={selectedPiece === piece.id ? 'selected' : ''}>
<td data-label="Piece">
<button <button
className="piece-link cut-preview-button skirting-preview-button" className="cut-preview-button skirting-preview-button"
aria-label={`Preview skirting cut ${piece.id}`} aria-label={`Preview skirting cut ${piece.id}`}
aria-haspopup="dialog" aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)} onClick={() => select(piece.id, piece.roomId)}
@ -214,51 +149,37 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
compact compact
/> />
</span> </span>
<span className="cut-preview-code">{piece.id}</span> <span className="cut-preview-code">
{piece.id} <span aria-hidden="true">↗</span>
</span>
</button> </button>
</td> <div>
<td data-label="Room / wall"> <p className="cut-size">
{roomName(piece.roomId)} {mm(piece.length)} mm <span className="cut-note">long point</span>
<small>Wall {piece.wallIndex + 1}</small> </p>
</td> <p className="cut-location">
<td data-label="Length · mm" className="numeric"> {roomName(piece.roomId)} · Wall {piece.wallIndex + 1}
{mm(piece.length)} </p>
<small>{mm(piece.backLength)} at wall</small> <p className="cut-note">
</td> Left {degree(piece.leftMitre)} · Right {degree(piece.rightMitre)}
<td data-label="Left / right"> </p>
{degree(piece.leftMitre)} / {degree(piece.rightMitre)}
<small>
{piece.leftType} / {piece.rightType}
</small>
</td>
<td data-label="Source">{piece.stockId}</td>
</tr>
))}
</tbody>
</table>
</div> </div>
) : ( </li>
))}
</ul>
</section>
))}
{!skirtCuts.length && (
<p className="empty-state"> <p className="empty-state">
{skirting.pieces.length {skirting.pieces.length
? 'No skirting cuts needed.' ? 'No skirting cuts needed.'
: 'Complete the outline and skirting measurements to calculate cuts.'} : 'Complete the outline and skirting measurements to calculate cuts.'}
</p> </p>
)} )}
<StockAllocations
title="Source lengths for skirting cuts"
items={skirting.stocks
.filter((stock) => 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`,
}))}
/>
</section> </section>
<CutDialog <CutDialog
floor={selectedFloor} floor={floorCuts.find((piece) => piece.id === selectedPiece)}
skirt={selectedSkirt} skirt={skirtCuts.find((piece) => piece.id === selectedPiece)}
project={project} project={project}
close={() => select(null)} close={() => select(null)}
/> />

View file

@ -2,12 +2,107 @@ import { Fragment } from 'react';
import type { FlooringPiece, SkirtingPiece } from '../lib/types'; import type { FlooringPiece, SkirtingPiece } from '../lib/types';
import { import {
exactMm, exactMm,
dimensionedOutline,
flooringTemplate, flooringTemplate,
fullPreviewBox, fullPreviewBox,
skirtingEndBoxes, skirtingEndBoxes,
skirtingTemplate, skirtingTemplate,
viewBox, viewBox,
} from '../lib/cut-preview'; } from '../lib/cut-preview';
import type { EdgeDimension } from '../lib/cut-preview';
function Dimensions({ dimensions, font }: { dimensions: EdgeDimension[]; font: number }) {
return (
<g className="cut-dimensions">
{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 (
<g
key={dimension.edge}
className="dimension-lines"
stroke="#637185"
strokeWidth="0.8"
fill="none"
>
{[
[start, lineStart],
[end, lineEnd],
].map(([point, projected], i) => (
<line
key={i}
className="dimension-extension"
x1={point.x + normal.x * font * 0.12}
y1={point.y + normal.y * font * 0.12}
x2={projected.x + normal.x * font * 0.12}
y2={projected.y + normal.y * font * 0.12}
vectorEffect="non-scaling-stroke"
/>
))}
<line
className="dimension-line"
x1={lineStart.x}
y1={lineStart.y}
x2={lineEnd.x}
y2={lineEnd.y}
vectorEffect="non-scaling-stroke"
/>
{[lineStart, lineEnd].map((point, i) => (
<line
key={i}
className="dimension-tick"
x1={point.x - (normal.x - normal.y) * font * 0.075}
y1={point.y - (normal.y + normal.x) * font * 0.075}
x2={point.x + (normal.x - normal.y) * font * 0.075}
y2={point.y + (normal.y + normal.x) * font * 0.075}
vectorEffect="non-scaling-stroke"
/>
))}
{staggered && (
<line
className="dimension-leader"
x1={center.x}
y1={center.y}
x2={label.x}
y2={label.y}
vectorEffect="non-scaling-stroke"
/>
)}
</g>
);
})}
{dimensions.map((dimension) => (
<g key={dimension.edge} className="dimension-caption" data-edge={dimension.edge}>
<rect {...dimension.labelBox} fill="white" />
<text
className="dimension-label"
x={dimension.label.x}
y={dimension.label.y}
textAnchor="middle"
dominantBaseline="central"
fontSize={font}
fill="#17263f"
>
{dimension.angle === undefined ? (
dimension.text
) : (
<>
<tspan x={dimension.label.x} dy={-font * 0.6}>
{dimension.text}
</tspan>{' '}
<tspan x={dimension.label.x} dy={font * 1.2}>
{exactMm(dimension.angle)}°
</tspan>
</>
)}
</text>
</g>
))}
</g>
);
}
export function FlooringPreview({ export function FlooringPreview({
piece, piece,
@ -17,12 +112,15 @@ export function FlooringPreview({
compact?: boolean; compact?: boolean;
}) { }) {
const template = flooringTemplate(piece); const template = flooringTemplate(piece);
const dimensions = compact
? undefined
: dimensionedOutline(template, piece.shaped ? undefined : [0, 1]);
const Wrapper = compact ? 'span' : 'figure'; const Wrapper = compact ? 'span' : 'figure';
return ( return (
<Wrapper className={`flooring-preview-figure ${compact ? 'compact-preview' : 'exact-preview'}`}> <Wrapper className={`flooring-preview-figure ${compact ? 'compact-preview' : 'exact-preview'}`}>
<svg <svg
className="flooring-preview" className="flooring-preview"
viewBox={viewBox(fullPreviewBox(piece.length, piece.width))} viewBox={viewBox(dimensions?.box ?? fullPreviewBox(piece.length, piece.width))}
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
aria-hidden={compact || undefined} aria-hidden={compact || undefined}
role={compact ? undefined : 'img'} role={compact ? undefined : 'img'}
@ -35,24 +133,13 @@ export function FlooringPreview({
strokeWidth={compact ? 1 : 1.5} strokeWidth={compact ? 1 : 1.5}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
{!compact && {dimensions && <Dimensions dimensions={dimensions.dimensions} font={dimensions.font} />}
piece.shaped &&
template.map((point, index) => (
<text
key={index}
x={point.x}
y={point.y - Math.max(piece.length, piece.width) * 0.015}
textAnchor="middle"
fontSize={Math.max(piece.length, piece.width) * 0.025}
fill="#1e40af"
>
{index + 1}
</text>
))}
</svg> </svg>
{!compact && ( {!compact && (
<figcaption> <figcaption>
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'}
</figcaption> </figcaption>
)} )}
</Wrapper> </Wrapper>
@ -74,6 +161,18 @@ export function SkirtingPreview({
const Grid = compact ? 'span' : 'div'; const Grid = compact ? 'span' : 'div';
if (template.length !== 4) return <span className="helper">Shape template unavailable</span>; if (template.length !== 4) return <span className="helper">Shape template unavailable</span>;
const boxes = skirtingEndBoxes(template, thickness); 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 points = template.map((point) => `${point.x},${point.y}`).join(' ');
const ends = [ const ends = [
{ side: 'left', back: template[0], front: template[3], box: boxes.left }, { side: 'left', back: template[0], front: template[3], box: boxes.left },
@ -84,7 +183,7 @@ export function SkirtingPreview({
{!compact && ( {!compact && (
<figure className="skirting-full-preview"> <figure className="skirting-full-preview">
<svg <svg
viewBox={viewBox(fullPreviewBox(piece.length, thickness))} viewBox={viewBox(dimensions!.box)}
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
role="img" role="img"
aria-label={`Full skirting shape ${piece.id}`} aria-label={`Full skirting shape ${piece.id}`}
@ -96,10 +195,10 @@ export function SkirtingPreview({
strokeWidth={1} strokeWidth={1}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
<Dimensions dimensions={dimensions!.dimensions} font={dimensions!.font} />
</svg> </svg>
<figcaption> <figcaption>
Full length {exactMm(piece.length)} mm × thickness {exactMm(thickness)} mm · top view, {exactMm(piece.length)} × {exactMm(thickness)} mm · wall side above
to scale
</figcaption> </figcaption>
</figure> </figure>
)} )}
@ -142,8 +241,9 @@ export function SkirtingPreview({
</svg> </svg>
{!compact && ( {!compact && (
<figcaption> <figcaption>
{index === 0 ? 'Left' : 'Right'} end ·{' '} {index === 0 ? 'Left' : 'Right'} ·{' '}
{exactMm(Math.abs(index === 0 ? piece.leftMitre : piece.rightMitre))}° {exactMm(Math.abs(index === 0 ? piece.leftMitre : piece.rightMitre))}°{' '}
{index === 0 ? piece.leftType : piece.rightType}
</figcaption> </figcaption>
)} )}
</EndWrapper> </EndWrapper>
@ -155,12 +255,6 @@ export function SkirtingPreview({
</Fragment> </Fragment>
))} ))}
</Grid> </Grid>
{!compact && (
<p className="helper">
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.
</p>
)}
</Wrapper> </Wrapper>
); );
} }

View file

@ -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 (
<div className="door-position">
<p>
W{wallIndex + 1}: measure from {start} toward {end}.
</p>
<svg
viewBox="0 0 240 65"
role="img"
aria-label={`Door on W${wallIndex + 1}: ${mm(door.offset)} mm from ${start}, then ${mm(door.width)} mm opening toward ${end}`}
>
<line x1="12" y1="22" x2="228" y2="22" stroke="#17263f" strokeWidth="3" />
<line x1={a} y1="22" x2={b} y2="22" stroke="#fffdf7" strokeWidth="5" />
<line
x1={a}
y1="22"
x2={b}
y2="22"
stroke="#1647ab"
strokeWidth="2"
strokeDasharray="3 3"
/>
<circle cx="12" cy="22" r="4" fill="#17263f" />
<path
d="M 12 10 L 46 10 M 40 6 L 46 10 L 40 14"
stroke="#1647ab"
fill="none"
strokeWidth="1.5"
/>
<text x="12" y="45" textAnchor="start">
{start}
</text>
<text x="228" y="59" textAnchor="end">
{end}
</text>
</svg>
</div>
);
}

View file

@ -111,10 +111,19 @@ interface Props {
flooring: FlooringPlan; flooring: FlooringPlan;
skirting: SkirtingPlan; skirting: SkirtingPlan;
selectedPiece: string | null; 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 hatch = useId().replaceAll(':', '');
const allPoints = geometry.edges.flatMap((edge) => [edge.start, edge.end]); const allPoints = geometry.edges.flatMap((edge) => [edge.start, edge.end]);
const minX = Math.min(0, ...allPoints.map((p) => p.x)), const minX = Math.min(0, ...allPoints.map((p) => p.x)),
@ -159,13 +168,18 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s
<div className="drawing-legend"> <div className="drawing-legend">
<span> <span>
<i className="floor-key" /> <i className="floor-key" />
Flooring Flooring cuts
</span> </span>
<span> <span>
<i className="skirt-key" /> <i className="skirt-key" />
Skirting Skirting cuts
</span>
<span>
<i className="uncut-key" />
Uncut
</span> </span>
</div> </div>
{interactive && <p className="drawing-help">Select a numbered piece for cut details.</p>}
<div className="drawing-viewport"> <div className="drawing-viewport">
<svg <svg
className="room-svg" className="room-svg"
@ -212,18 +226,18 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s
className={`floor-piece ${cut ? 'svg-cut floor-cut' : 'uncut'}`} className={`floor-piece ${cut ? 'svg-cut floor-cut' : 'uncut'}`}
data-material="flooring" data-material="flooring"
data-piece-id={piece.id} data-piece-id={piece.id}
role={cut ? 'button' : undefined} role={cut && interactive ? 'button' : undefined}
tabIndex={cut ? 0 : undefined} tabIndex={cut && interactive ? 0 : undefined}
aria-hidden={cut ? undefined : true} aria-hidden={cut ? undefined : true}
aria-label={cut ? `Select flooring cut ${piece.id}` : undefined} aria-label={cut ? `Select flooring cut ${piece.id}` : undefined}
aria-pressed={cut ? selectedPiece === piece.id : undefined} aria-pressed={cut ? selectedPiece === piece.id : undefined}
onClick={cut ? () => select(piece.id) : undefined} onClick={cut && interactive ? () => select?.(piece.id) : undefined}
onKeyDown={ onKeyDown={
cut cut
? (e) => { ? (e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
select(piece.id); select?.(piece.id);
} }
} }
: undefined : undefined
@ -283,18 +297,18 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s
className={`skirt-piece ${cut ? 'svg-cut skirt-cut' : 'uncut'}`} className={`skirt-piece ${cut ? 'svg-cut skirt-cut' : 'uncut'}`}
data-material="skirting" data-material="skirting"
data-piece-id={piece.id} data-piece-id={piece.id}
role={cut ? 'button' : undefined} role={cut && interactive ? 'button' : undefined}
tabIndex={cut ? 0 : undefined} tabIndex={cut && interactive ? 0 : undefined}
aria-hidden={cut ? undefined : true} aria-hidden={cut ? undefined : true}
aria-label={cut ? `Select skirting cut ${piece.id}` : undefined} aria-label={cut ? `Select skirting cut ${piece.id}` : undefined}
aria-pressed={cut ? selectedPiece === piece.id : undefined} aria-pressed={cut ? selectedPiece === piece.id : undefined}
onClick={cut ? () => select(piece.id) : undefined} onClick={cut && interactive ? () => select?.(piece.id) : undefined}
onKeyDown={ onKeyDown={
cut cut
? (e) => { ? (e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
select(piece.id); select?.(piece.id);
} }
} }
: undefined : undefined
@ -302,8 +316,8 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s
> >
<polygon <polygon
points={points(polygon)} points={points(polygon)}
fill={selectedPiece === piece.id ? '#17263f' : '#b74900'} fill={cut ? (selectedPiece === piece.id ? '#17263f' : '#b74900') : '#d8d5cd'}
stroke="#8d3800" stroke={cut ? '#8d3800' : '#79756b'}
strokeWidth={span * 0.001} strokeWidth={span * 0.001}
/> />
{cut && ( {cut && (
@ -317,20 +331,20 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s
/> />
)} )}
{label && ( {label && (
<g transform={`translate(${label.x} ${label.y})`} pointerEvents="none"> <g className="skirting-code" transform={`translate(${label.x} ${label.y})`}>
<rect <rect
x={-font * 1.1} x={-font * 1.1}
y={-font * 0.58} y={-font * 0.58}
width={font * 2.2} width={font * 2.2}
height={font * 1.16} height={font * 1.16}
fill="#fffdf7" fill="#b74900"
stroke="#b74900" stroke="#8d3800"
strokeWidth={span * 0.0012} strokeWidth={span * 0.0012}
/> />
<text <text
textAnchor="middle" textAnchor="middle"
dominantBaseline="middle" dominantBaseline="middle"
fill="#8d3800" fill="#fff"
fontSize={font * 0.8} fontSize={font * 0.8}
fontWeight="700" fontWeight="700"
> >

View file

@ -24,7 +24,7 @@ export function MaterialsEditor({ project, room, changeProject, changeRoom }: Pr
<> <>
<div className="editor-heading"> <div className="editor-heading">
<h2>Materials</h2> <h2>Materials</h2>
<span>Shared across rooms · mm</span> <span>Shared across rooms</span>
</div> </div>
<section className="material-section flooring" aria-labelledby="flooring-materials"> <section className="material-section flooring" aria-labelledby="flooring-materials">
<h3 id="flooring-materials" className="material-heading"> <h3 id="flooring-materials" className="material-heading">

View file

@ -4,6 +4,7 @@ import { fireplace, rectangle, nextId } from '../lib/defaults';
import { normalizeAngle } from '../lib/geometry'; import { normalizeAngle } from '../lib/geometry';
import { mm } from '../lib/format'; import { mm } from '../lib/format';
import { NumberField, Issues } from './Fields'; import { NumberField, Issues } from './Fields';
import { DoorPosition } from './DoorPosition';
interface Props { interface Props {
room: Room; room: Room;
geometry: Geometry; geometry: Geometry;
@ -87,7 +88,6 @@ export function OutlineEditor({
<> <>
<div className="editor-heading"> <div className="editor-heading">
<h2>Room measurements</h2> <h2>Room measurements</h2>
<span>Millimetres</span>
</div> </div>
<label className="field"> <label className="field">
<span>Room name</span> <span>Room name</span>
@ -267,9 +267,7 @@ export function OutlineEditor({
</h3> </h3>
<span>{room.walls.reduce((n, w) => n + w.doors.length, 0)} openings</span> <span>{room.walls.reduce((n, w) => n + w.doors.length, 0)} openings</span>
</div> </div>
<p className="helper"> <p className="helper">Door width includes trim where skirting stops.</p>
Width includes trim where skirting stops. Offset is measured from the wall’s start.
</p>
{room.walls.flatMap((wall, wallIndex) => {room.walls.flatMap((wall, wallIndex) =>
wall.doors.map((door) => ( wall.doors.map((door) => (
<div className="door-card" key={door.id}> <div className="door-card" key={door.id}>
@ -312,9 +310,16 @@ export function OutlineEditor({
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
</div> </div>
<DoorPosition
wall={wall}
door={door}
wallIndex={wallIndex}
wallCount={room.walls.length}
closed={geometry.closed}
/>
<div className="field-grid"> <div className="field-grid">
<NumberField <NumberField
label="Door offset" label={`From ${wallIndex === 0 && !geometry.closed ? 'W1 start' : `W${wallIndex === 0 ? room.walls.length : wallIndex} corner`}`}
value={door.offset} value={door.offset}
onChange={(n) => onChange={(n) =>
changeWall(wall.id, (w) => ({ changeWall(wall.id, (w) => ({

View file

@ -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 (
<section className="print-plan" aria-label="Printable plan">
<header className="print-title">
<h1>Flooring installation plan</h1>
<p>
{project.rooms.length} rooms · {mm(project.flooring.length)} ×{' '}
{mm(project.flooring.width)} mm floorboards · {mm(project.flooring.gap)} mm wall spacers
</p>
<p>
Buy {flooring.boardCount} floorboards and {skirting.stockCount} skirting lengths. Install{' '}
{wholeFloor} floorboards and {wholeSkirt} skirting lengths uncut.
</p>
</header>
{project.rooms.map((room, index) => (
<section className="print-room" key={room.id} data-room-id={room.id}>
<h2>
{index + 1}. {room.name} <span>{area(geometries[index].area)} m²</span>
</h2>
<FloorPlan
room={room}
geometry={geometries[index]}
flooring={flooring}
skirting={skirting}
selectedPiece={null}
interactive={false}
/>
</section>
))}
<section className="print-material print-flooring" aria-label="Printed flooring cuts">
<h2>Flooring cuts</h2>
{groupFlooringCuts(flooring).map((group) => (
<section className="print-stock" data-stock-id={group[0].stockId} key={group[0].stockId}>
<h3>
Floorboard {group[0].stockId}{' '}
<span>
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm
</span>
</h3>
<div className="print-cut-grid">
{group.map((piece) => (
<article className="print-cut" data-cut-id={piece.id} key={piece.id}>
<h4>
{piece.id}{' '}
<span>
{roomName(piece.roomId)} · Row {piece.row}
</span>
</h4>
<FlooringPreview piece={piece} />
<p>{piece.cut ? flooringInstructions(piece) : readyOffcutInstructions(piece)}</p>
</article>
))}
</div>
</section>
))}
{!groupFlooringCuts(flooring).length && <p>No flooring cuts needed.</p>}
</section>
<section className="print-material print-skirting" aria-label="Printed skirting cuts">
<h2>Skirting cuts</h2>
{groupSkirtingCuts(skirting).map((group) => (
<section className="print-stock" data-stock-id={group[0].stockId} key={group[0].stockId}>
<h3>
Skirting board {group[0].stockId}{' '}
<span>
{mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
</span>
</h3>
<div className="print-cut-grid">
{group.map((piece) => (
<article className="print-cut" data-cut-id={piece.id} key={piece.id}>
<h4>
{piece.id}{' '}
<span>
{roomName(piece.roomId)} · Wall {piece.wallIndex + 1}
</span>
</h4>
<SkirtingPreview piece={piece} thickness={project.skirting.thickness} />
</article>
))}
</div>
</section>
))}
{!groupSkirtingCuts(skirting).length && <p>No skirting cuts needed.</p>}
</section>
</section>
);
}

View file

@ -54,25 +54,18 @@ export function readyOffcutInstructions(piece: FlooringPiece): string {
return `Use offcut; no new cut. Required factory short ends: ${ends}.`; return `Use offcut; no new cut. Required factory short ends: ${ends}.`;
} }
/** Batch identical cuts without losing any placement codes or changing factory-edge orientation. */ /** Keep every cut from a purchased board together, in allocation order across rooms. */
export function groupFlooringCuts(plan: FlooringPlan): FlooringPiece[][] { function groupByStock<T extends { stockId: string }>(pieces: T[], stocks: { id: string }[]): T[][] {
const groups = new Map<string, FlooringPiece[]>(); const groups = new Map<string, T[]>(stocks.map((stock) => [stock.id, []]));
const precise = (value: number) => Math.round(value * 1000); for (const piece of pieces) {
for (const piece of flooringCuts(plan)) { const group = groups.get(piece.stockId);
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);
if (group) group.push(piece); 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);

View file

@ -10,6 +10,142 @@ export const exactMm = (value: number) =>
new Intl.NumberFormat('en-GB', { maximumFractionDigits: 3 }).format(value); new Intl.NumberFormat('en-GB', { maximumFractionDigits: 3 }).format(value);
export const viewBox = (box: PreviewBox) => `${box.x} ${box.y} ${box.width} ${box.height}`; 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[] { export function flooringTemplate(piece: FlooringPiece): Point[] {
if (piece.template.length) return piece.template; if (piece.template.length) return piece.template;
return piece.shaped return piece.shaped

View file

@ -1,8 +1,8 @@
import type { FlooringPlan, Project, SkirtingPlan } from './types'; import type { FlooringPlan, Project, SkirtingPlan } from './types';
import { flooringInstructions } from './cut-instructions'; import { flooringInstructions } from './cut-instructions';
import { import {
flooringCuts, groupFlooringCuts,
skirtingCuts, groupSkirtingCuts,
uncutFlooring, uncutFlooring,
uncutSkirting, uncutSkirting,
readyOffcutInstructions, readyOffcutInstructions,
@ -19,10 +19,8 @@ export function cutListCsv(
skirting: SkirtingPlan, skirting: SkirtingPlan,
): string { ): string {
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id; const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
const floorCuts = flooringCuts(flooring), const floorCuts = groupFlooringCuts(flooring).flat(),
skirtCuts = skirtingCuts(skirting); skirtCuts = groupSkirtingCuts(skirting).flat();
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 wholeFloor = uncutFlooring(flooring, project.flooring);
const rows: (string | number)[][] = [ 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'); return '\uFEFF' + rows.map((row) => row.map(cell).join(',')).join('\r\n');
} }

View file

@ -31,6 +31,7 @@ interface Layout {
stocks: AvailableStock[]; stocks: AvailableStock[];
cuts: number; cuts: number;
reusedCount: number; reusedCount: number;
patternPenalty: number;
} }
interface CutPiece { interface CutPiece {
polygon: Point[]; polygon: Point[];
@ -250,6 +251,7 @@ function appendRow(
rows: [...base.rows], rows: [...base.rows],
cuts: base.cuts, cuts: base.cuts,
reusedCount: base.reusedCount, reusedCount: base.reusedCount,
patternPenalty: base.patternPenalty + randomPatternPenalty(base.rows, room.id, phase, s),
stocks: base.stocks.map((stock) => ({ ...stock, pieceIds: [...stock.pieceIds] })), stocks: base.stocks.map((stock) => ({ ...stock, pieceIds: [...stock.pieceIds] })),
}; };
const pieceIds: string[] = []; 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; 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. */ /** Small fixed hash, never time or Math.random: edits and shared URLs reproduce the same result. */
function fraction(seed: string): number { function fraction(seed: string): number {
let hash = 2166136261; let hash = 2166136261;
@ -338,13 +379,9 @@ function candidatePhases(
if (s.pattern === 'brick' && firstPhase !== undefined) if (s.pattern === 'brick' && firstPhase !== undefined)
return [phaseOf(firstPhase + ((row % 2) * s.length) / 2, s.length)]; return [phaseOf(firstPhase + ((row % 2) * s.length) / 2, s.length)];
const candidates: number[] = []; 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 add = (value: number) => {
const phase = phaseOf(value, s.length); const phase = phaseOf(value, s.length);
if (previousPhase !== undefined && !staggerAllowed(phase, previousPhase, s)) return; 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); 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. // 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 { function compareLayouts(a: Layout, b: Layout): number {
return ( return (
a.stocks.length - b.stocks.length || a.stocks.length - b.stocks.length ||
a.patternPenalty - b.patternPenalty ||
a.cuts - b.cuts || a.cuts - b.cuts ||
b.reusedCount - a.reusedCount || b.reusedCount - a.reusedCount ||
compareText( 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. * Stock is shared between rooms, but layouts and joint phases are independent.
* Only full-width end remnants are reused; ripped strips are conservatively waste. * 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. * This is a practical cutting plan, not a claim of a global packing optimum.
*/ */
export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: number): FlooringPlan { 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 issues: Issue[] = [];
const invalid = validateSettings(s); const invalid = validateSettings(s);
if (invalid) issues.push({ severity: 'error', message: invalid }); if (invalid) issues.push({ severity: 'error', message: invalid });

View file

@ -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. */ /** Resource limits apply to links as well as edits made inside the app. */
export const URL_LIMITS = { export const URL_LIMITS = {
queryLength: 100_000,
hashLength: 100_000, hashLength: 100_000,
jsonLength: 1_000_000, jsonLength: 1_000_000,
rooms: 20, 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 { export function encodeState(state: UrlState): string {
const json = JSON.stringify(parseState(state)); const { project, view } = parseState(state);
if (json.length > URL_LIMITS.jsonLength) const params = new URLSearchParams();
throw new Error('This plan is too large for a link. Split it into smaller projects.'); const put = (key: string, value: string | number) => params.append(key, String(value));
const hash = `#plan=${LZString.compressToEncodedURIComponent(json)}`; put('version', project.version);
if (hash.length > URL_LIMITS.hashLength) 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.'); 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. */ function parseQuery(query: string): UrlState {
export function decodeState(hash: string): { state: UrlState; error: string | null } { if (query.length > URL_LIMITS.queryLength)
if (hash === '' || hash === '#') return { state: defaultState(), error: null }; throw new Error('The link is too large. Use a smaller project link.');
try { // 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<number>();
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) if (hash.length > URL_LIMITS.hashLength)
throw new Error('The link is too large. Use a smaller project link.'); throw new Error('The link is too large. Use a smaller project link.');
if (!hash.startsWith('#plan=')) if (!hash.startsWith('#plan='))
throw new Error('The link must start with #plan=. Copy the complete plan link.'); throw new Error('Use a complete plan query or an existing #plan= link.');
const compressed = decodeURIComponent(hash.slice(6)); const compressed = decodeURIComponent(hash.slice(6));
if (!compressed || !/^[A-Za-z0-9+$-]+$/.test(compressed)) { if (!compressed || !/^[A-Za-z0-9+$-]+$/.test(compressed))
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.'); throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
}
const json = LZString.decompressFromEncodedURIComponent(compressed); const json = LZString.decompressFromEncodedURIComponent(compressed);
if (!json) if (!json)
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.'); throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
if (json.length > URL_LIMITS.jsonLength) if (json.length > URL_LIMITS.jsonLength)
throw new Error('The project in this link is too large. Use a smaller project link.'); 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 if (LZString.compressToEncodedURIComponent(json) !== compressed)
// 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.'); throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
}
let value: unknown; let value: unknown;
try { try {
value = JSON.parse(json); value = JSON.parse(json);
} catch { } catch {
throw new Error('The project data is damaged. Copy the complete plan link again.'); throw new Error('The project data is damaged. Copy the complete plan link again.');
} }
return { state: parseState(value), error: null }; 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) { } catch (error) {
const detail = const detail =
error instanceof URIError error instanceof URIError

View file

@ -2,14 +2,31 @@ import { useCallback, useEffect, useState } from 'react';
import type { UrlState } from './types'; import type { UrlState } from './types';
import { decodeState, encodeState } from './url-state'; 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() { export function useProject() {
const [loaded, setLoaded] = useState(() => decodeState(window.location.hash)); const [loaded, setLoaded] = useState(readLocation);
useEffect(() => { 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('hashchange', restore);
window.addEventListener('popstate', restore); window.addEventListener('popstate', restore);
if (!window.location.hash) window.history.replaceState(null, '', encodeState(loaded.state)); restore();
return () => { return () => {
window.removeEventListener('hashchange', restore); window.removeEventListener('hashchange', restore);
window.removeEventListener('popstate', restore); window.removeEventListener('popstate', restore);
@ -19,10 +36,10 @@ export function useProject() {
setLoaded((current) => { setLoaded((current) => {
const next = transform(current.state); const next = transform(current.state);
try { try {
const hash = encodeState(next); const query = encodeState(next);
if (hash !== window.location.hash) if (query !== window.location.search || window.location.hash)
window.history[milestone ? 'pushState' : 'replaceState'](null, '', hash); window.history[milestone ? 'pushState' : 'replaceState'](null, '', queryUrl(query));
const canonical = decodeState(hash).state; const canonical = decodeState(query).state;
if (next.project === current.state.project) canonical.project = current.state.project; if (next.project === current.state.project) canonical.project = current.state.project;
return { state: canonical, error: null }; return { state: canonical, error: null };
} catch (error) { } catch (error) {

View file

@ -826,99 +826,6 @@ summary span {
.material-cuts > .helper { .material-cuts > .helper {
padding: 0 17px 15px; 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 { .empty-state {
padding: 18px; padding: 18px;
font-size: 13px; font-size: 13px;
@ -1193,167 +1100,6 @@ summary span {
.material-cuts > .helper { .material-cuts > .helper {
padding: 0 14px 15px; 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) { @media (max-width: 699px) {
.tooltip-content { .tooltip-content {
@ -1366,92 +1112,6 @@ summary span {
max-width: none; 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. */ /* Each cut has a small shape preview; exact measurements open in a native dialog. */
.cut-preview-button { .cut-preview-button {
@ -1561,7 +1221,7 @@ summary span {
display: block; display: block;
width: 100%; width: 100%;
height: 200px; height: 200px;
background: #f5f8fd; background: white;
} }
.skirting-full-preview svg { .skirting-full-preview svg {
display: block; display: block;
@ -1572,22 +1232,6 @@ summary span {
max-width: 400px; max-width: 400px;
margin: 16px auto; 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) { @media (max-width: 699px) {
.cut-dialog-header { .cut-dialog-header {
padding-inline: 12px; padding-inline: 12px;
@ -1599,13 +1243,264 @@ summary span {
height: 150px; 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 { @media print {
.cut-thumbnail { :root {
width: 48px; background: white;
height: 28px; font-size: 11px;
} }
.cut-preview-button { body {
width: 50px; color: #17263f;
min-width: 50px; }
.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;
} }
} }

View file

@ -1,10 +1,16 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { defaultProject } from '../src/lib/defaults'; import { defaultProject, rectangle } from '../src/lib/defaults';
import { flooringCuts, groupFlooringCuts } from '../src/lib/cut-list'; import {
flooringCuts,
groupFlooringCuts,
groupSkirtingCuts,
skirtingCuts,
} from '../src/lib/cut-list';
import { planFlooring } from '../src/lib/flooring'; import { planFlooring } from '../src/lib/flooring';
import { planSkirting } from '../src/lib/skirting';
import type { FlooringPiece, FlooringPlan } from '../src/lib/types'; import type { FlooringPiece, FlooringPlan } from '../src/lib/types';
function piece(id: string, changes: Partial<FlooringPiece> = {}): FlooringPiece { function piece(id: string, stockId: string, changes: Partial<FlooringPiece> = {}): FlooringPiece {
return { return {
id, id,
roomId: 'room-1', roomId: 'room-1',
@ -13,14 +19,11 @@ function piece(id: string, changes: Partial<FlooringPiece> = {}): FlooringPiece
polygon: [], polygon: [],
length: 600, length: 600,
width: 190, width: 190,
stockId: 'B1', stockId,
reused: false, reused: false,
cut: true, cut: true,
shaped: false, shaped: false,
template: [], template: [],
factoryLeft: true,
factoryRight: false,
ripEdge: null,
...changes, ...changes,
}; };
} }
@ -28,8 +31,12 @@ function plan(pieces: FlooringPiece[]): FlooringPlan {
return { return {
pieces, pieces,
rows: [], rows: [],
stocks: [], stocks: ['B1', 'B2', 'B3'].map((id) => ({
boardCount: 0, id,
pieceIds: pieces.filter((p) => p.stockId === id).map((p) => p.id),
remaining: 0,
})),
boardCount: 3,
cuts: 0, cuts: 0,
wasteArea: 0, wasteArea: 0,
wastePercent: 0, wastePercent: 0,
@ -39,81 +46,54 @@ function plan(pieces: FlooringPiece[]): FlooringPlan {
}; };
} }
describe('flooring cut batches', () => { describe('cuts grouped by purchased board', () => {
it('combines matching cuts across rows and stocks, preserving every placement exactly once', () => { it('keeps different cuts and cross-room offcuts together, separating identical cuts from different boards', () => {
const pieces = [ const source = plan([
piece('F1'), piece('F1', 'B2'),
piece('F2', { row: 4, stockId: 'B4', reused: true }), piece('F2', 'B1'),
piece('F3', { cut: false, length: 1200 }), piece('F3', 'B2', { roomId: 'room-2', row: 4, length: 397, reused: true }),
piece('F4', { cut: false, reused: true }), piece('FU1', 'B3', { length: 1200, cut: false }),
piece('F5', { roomId: 'room-2' }), ]);
]; const before = structuredClone(source);
const source = plan(pieces),
before = structuredClone(source);
const groups = groupFlooringCuts(source); const groups = groupFlooringCuts(source);
expect(groups.map((group) => group.map((p) => p.id))).toEqual([['F1', 'F2'], ['F4'], ['F5']]); expect(groups.map((group) => group.map((p) => p.id))).toEqual([['F2'], ['F1', 'F3']]);
expect(
groups
.flat()
.map((p) => p.id)
.sort(),
).toEqual(
flooringCuts(source)
.map((p) => p.id)
.sort(),
);
expect(source).toEqual(before); expect(source).toEqual(before);
expect(groupFlooringCuts(source)).toEqual(groups); 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( const groups = groupFlooringCuts(
plan([ plan([piece('F1', 'B1'), piece('F2', 'B1', { cut: false, reused: true })]),
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 }),
]),
); );
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', () => { it('includes every flooring and skirting cut once in stock order across multiple rooms', () => {
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', () => {
const project = defaultProject(); const project = defaultProject();
project.rooms.push(rectangle('room-2', 'Study', 3200, 3100));
const floor = planFlooring(project.rooms, project.flooring, project.tolerance); const floor = planFlooring(project.rooms, project.flooring, project.tolerance);
const groups = groupFlooringCuts(floor); const skirt = planSkirting(project.rooms, project.skirting, project.tolerance);
expect(groups.length).toBeLessThanOrEqual(7); for (const [groups, pieces, stocks] of [
expect(groups.length).toBeLessThan(flooringCuts(floor).length / 2); [groupFlooringCuts(floor), flooringCuts(floor), floor.stocks],
[groupSkirtingCuts(skirt), skirtingCuts(skirt), skirt.stocks],
] as const) {
expect( expect(
groups groups
.flat() .flat()
.map((p) => p.id) .map((p) => p.id)
.sort(), .sort(),
).toEqual( ).toEqual(pieces.map((p) => p.id).sort());
flooringCuts(floor) expect(new Set(groups.flat().map((p) => p.id)).size).toBe(pieces.length);
.map((p) => p.id) const used = new Set(pieces.map((p) => p.stockId));
.sort(), expect(groups.map((group) => group[0].stockId)).toEqual(
stocks.filter((s) => used.has(s.id)).map((s) => s.id),
); );
expect(new Set(groups.flat().map((p) => p.id)).size).toBe(flooringCuts(floor).length); for (const group of groups) expect(new Set(group.map((p) => p.stockId)).size).toBe(1);
}
expect(
[...groupFlooringCuts(floor), ...groupSkirtingCuts(skirt)].some(
(group) => new Set(group.map((p) => p.roomId)).size > 1,
),
).toBe(true);
}); });
}); });

View file

@ -2,7 +2,14 @@ import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server'; import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { FlooringPreview, SkirtingPreview } from '../src/components/CutPreview'; 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'; import type { FlooringPiece, Point, SkirtingPiece } from '../src/lib/types';
const floor: FlooringPiece = { const floor: FlooringPiece = {
@ -54,7 +61,10 @@ describe('cut shape previews', () => {
const markup = renderToStaticMarkup(createElement(FlooringPreview, { piece: floor })); const markup = renderToStaticMarkup(createElement(FlooringPreview, { piece: floor }));
expect(markup).toContain('aria-label="Flooring shape F1"'); expect(markup).toContain('aria-label="Flooring shape F1"');
expect(markup).toContain('preserveAspectRatio="xMidYMid meet"'); 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</text>');
expect(markup).toContain('>190</text>');
expect(markup.match(/class="dimension-label"/g)).toHaveLength(2);
expect(exactMm(600.001)).toBe('600.001'); 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).toContain('points="18,0 1182,0 1200,18 0,18"');
expect(markup.match(/preserveAspectRatio="xMidYMid meet"/g)).toHaveLength(3); 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</text>');
expect(markup).toContain('>1,200</text>');
expect(markup).not.toContain('exaggerat'); 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</text>');
expect(markup).toContain('>100</text>');
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°</tspan>');
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</text>');
expect(markup).toContain('>1,200</text>');
expect(markup).toContain('45° external');
expect(markup).not.toContain('Source');
expect(markup).not.toContain('coordinates');
expect(markup).not.toContain('<dl');
});
it('keeps the complete end cut visible even at shallow mitre angles', () => { it('keeps the complete end cut visible even at shallow mitre angles', () => {
const shallow = [ const shallow = [
{ x: 0, y: 0 }, { x: 0, y: 0 },

View file

@ -62,7 +62,7 @@ const skirt: SkirtingPlan = {
}; };
describe('cut list CSV', () => { 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); const csv = cutListCsv(defaultProject(), floor, skirt);
expect(csv.startsWith('\uFEFF"Material"')).toBe(true); expect(csv.startsWith('\uFEFF"Material"')).toBe(true);
expect(csv).toContain('"Flooring","Living room","F1","B1","450","190"'); 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('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('"Skirting","Living room","A1","S1","1518","18","0","45"');
expect(csv).toContain('Back length 1500.0 mm; left square; right external'); 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).toContain('Boards to buy; 1 installed pieces; 2 saw cuts');
expect(csv).not.toContain('Check the shaped edge.'); 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', () => { 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).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('"Flooring",'))).toHaveLength(2);
expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting",'))).toHaveLength(1); 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('"Flooring stock",'))).toHaveLength(0);
expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting stock",'))).toHaveLength(1); expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting stock",'))).toHaveLength(0);
expect(flooringCuts(floorPlan)).toEqual([...floor.pieces, readyOffcut]); expect(flooringCuts(floorPlan)).toEqual([...floor.pieces, readyOffcut]);
expect(csv).toContain('"Flooring","Living room","READY-F","B1","747","190"'); expect(csv).toContain('"Flooring","Living room","READY-F","B1","747","190"');
expect(csv).toContain('Reused offcut; Use offcut; no new cut'); 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(uncutFlooring(floorPlan, project.flooring)).toEqual({ wholeBoards: 1, readyOffcuts: 1 });
expect(uncutSkirting(skirtPlan)).toEqual([{ length: 2400, count: 1 }]); expect(uncutSkirting(skirtPlan)).toEqual([{ length: 2400, count: 1 }]);
}); });

View file

@ -215,6 +215,78 @@ describe('laminate planning', () => {
expectCoverage(a, room(), s.gap); 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', () => { it('rejects impossible brick stagger constraints', () => {
const plan = planFlooring([room()], { ...settings, maxOffset: 400 }, 1); const plan = planFlooring([room()], { ...settings, maxOffset: 400 }, 1);
expect(plan.boardCount).toBe(0); expect(plan.boardCount).toBe(0);

View file

@ -15,7 +15,7 @@ function expectInvalid(hash: string, message?: RegExp) {
describe('URL state', () => { describe('URL state', () => {
it('starts with independent defaults when no link is provided', () => { it('starts with independent defaults when no link is provided', () => {
for (const hash of ['', '#']) { for (const hash of ['', '#', '?']) {
const first = decodeState(hash); const first = decodeState(hash);
expect(first).toEqual({ state: defaultState(), error: null }); expect(first).toEqual({ state: defaultState(), error: null });
first.state.project.rooms[0].name = 'Edited'; 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', () => { it('round-trips all default inputs exactly and produces the same link every time', () => {
const state = defaultState(); const state = defaultState();
const hash = encodeState(state); 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(decodeState(hash)).toEqual({ state, error: null });
expect(encodeState(decodeState(hash).state)).toBe(hash); expect(encodeState(decodeState(hash).state)).toBe(hash);
expect(encodeState(structuredClone(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); expect(result.state.view.roomId).toBe(state.project.rooms[0].id);
}); });
it('accepts URL-escaped compression characters', () => { it('accepts URL-escaped legacy compression characters', () => {
const hash = encodeState(defaultState()); const hash = rawLink(defaultState());
expect(decodeState(`#plan=${encodeURIComponent(hash.slice(6))}`).state).toEqual(defaultState()); expect(decodeState(`#plan=${encodeURIComponent(hash.slice(6))}`).state).toEqual(defaultState());
}); });
@ -111,8 +116,8 @@ describe('URL state', () => {
(hash) => expectInvalid(hash), (hash) => expectInvalid(hash),
); );
it('rejects truncated and appended payloads even when the decoder ignores trailing data', () => { it('rejects truncated and appended legacy payloads even when the decoder ignores trailing data', () => {
const hash = encodeState(defaultState()); const hash = rawLink(defaultState());
expectInvalid(hash.slice(0, -4)); expectInvalid(hash.slice(0, -4));
expectInvalid(`${hash}AAAA`); expectInvalid(`${hash}AAAA`);
}); });
@ -226,6 +231,129 @@ describe('URL state', () => {
expectInvalid(rawLink(largeName), /at most 200 characters/); 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', () => { it('rejects oversized compressed and expanded payloads', () => {
expectInvalid(`#plan=${'a'.repeat(URL_LIMITS.hashLength)}`, /link is too large/); expectInvalid(`#plan=${'a'.repeat(URL_LIMITS.hashLength)}`, /link is too large/);
expectInvalid( expectInvalid(