From 62a385e4c995ee97311915d196dc9cb5b63a364e Mon Sep 17 00:00:00 2001
From: Andras Schmelczer
Date: Fri, 11 Sep 2026 16:58:58 +0100
Subject: [PATCH] ..
---
README.md | 42 +--
e2e/planner.spec.ts | 553 ++++++++++++++++++++++++++---
src/App.tsx | 347 ++++++++++++------
src/components/CutList.tsx | 419 +++++++++++++++-------
src/components/CutPreview.tsx | 10 +-
src/components/Fields.tsx | 63 +++-
src/components/FloorPlan.tsx | 100 +++++-
src/components/OutlineEditor.tsx | 127 +++++--
src/components/PrintPlan.tsx | 24 +-
src/lib/board-tracking.ts | 73 ++++
src/lib/cut-preview.ts | 7 +-
src/lib/defaults.ts | 3 +-
src/lib/dimension-labels.ts | 16 +
src/lib/export.ts | 151 --------
src/lib/flooring.ts | 19 +-
src/lib/format.ts | 3 +
src/lib/skirting.ts | 12 +-
src/lib/types.ts | 5 +
src/lib/url-state.ts | 57 ++-
src/lib/use-project.ts | 9 +
src/lib/wall-editing.ts | 33 ++
src/styles.css | 451 ++++++++++++++++++-----
tests/board-tracking.test.ts | 200 +++++++++++
tests/cut-preview.test.ts | 24 +-
tests/dimension-labels.test.ts | 33 +-
tests/export.test.ts | 179 ----------
tests/fixtures/angled-room-f8.json | 132 +++++++
tests/fixtures/angled-room.json | 115 ++++++
tests/flooring.test.ts | 5 +-
tests/format.test.ts | 18 +
tests/skirting.test.ts | 17 +
tests/url-state.test.ts | 131 ++++++-
tests/wall-editing.test.ts | 80 +++++
33 files changed, 2649 insertions(+), 809 deletions(-)
create mode 100644 src/lib/board-tracking.ts
delete mode 100644 src/lib/export.ts
create mode 100644 src/lib/wall-editing.ts
create mode 100644 tests/board-tracking.test.ts
delete mode 100644 tests/export.test.ts
create mode 100644 tests/fixtures/angled-room-f8.json
create mode 100644 tests/fixtures/angled-room.json
create mode 100644 tests/format.test.ts
create mode 100644 tests/wall-editing.test.ts
diff --git a/README.md b/README.md
index 61f06ac..8c39634 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ npm run dev
Use a supported Node.js release (22.12+ or 24+). Open the local URL printed by Vite.
```sh
-npm test # Pure geometry, planning, URL and CSV tests
+npm test # Pure geometry, planning, URL and rendering tests
npx playwright install chromium
npm run test:e2e # Desktop Chromium and mobile-sized Chromium
npm run build # Strict TypeScript check and production build
@@ -19,18 +19,19 @@ npm run check # Build, unit tests and browser tests
## Planning workflow
-1. Start with a rectangle, fireplace recess, or custom outline. Enter wall lengths in order. The first wall runs east; the turn **after** each wall is positive for right and negative for left. For example, a clockwise rectangle has 90° turns. The final corner is derived from the closed outline.
+1. Start with a rectangle, fireplace recess, or custom outline. Enter wall lengths in order. The first wall runs east; the turn **after** each wall is positive for right and negative for left. For example, a clockwise rectangle has 90° turns. The final corner is derived from the closed outline. Insert a wall after any existing wall, drag its handle to reorder it, or use the up/down buttons on a phone or keyboard. Doors stay attached to their wall. Numeric fields allow empty values and a minus sign while typing; unfinished entries revert to the last valid value when you leave the field.
2. Add door openings with a width and a distance from the named starting corner. Each doorway shows the neighbouring wall names and an arrow in the measurement direction. Include architrave/trim in the width where skirting must be omitted. Doors terminate skirting; laminate ends at the room outline with a transition and expansion gap. Rooms are planned independently.
3. For a custom outline, the closing-wall suggestion gives the required length and turn. Add it, then replace the suggested length with the actual measured length. An outline must close within the chosen tolerance and have no intersections or invalid doors before a cut plan can be produced.
4. Set material dimensions, spacer, kerf and minimum piece sizes alongside the measurements. Kerf means material removed by the blade: a 3 mm cut leaves 600 mm + 397 mm from a 1,000 mm board. Both kerf fields have keyboard and touch tooltips. 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. Flooring and skirting cuts are grouped by the purchased board they come from, including offcuts reused across rooms. Each piece has a small preview that opens dimensions directly on its shape. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Numbered skirting pieces and their labels open cut details; uncut lengths use a neutral fill. Export CSV in the same board order, or print all room drawings followed by flooring and skirting cut sheets grouped by purchased board. Every cut is included once, with its room and row or wall.
-6. Copy the link to save or share the complete project. Measurements, materials, selected room and selected piece live in readable query parameters such as `flooring.length=1200`, `room.1.wall.1.length=4800` and `selectedCut=F1`. You can edit values directly in the URL. Older compressed fragment links still load and migrate automatically; obsolete step, layer and zoom choices are ignored. Reloading or opening that link reproduces the plan. Browser back/forward restores room changes. There is no local storage, account, API, database or analytics. Fonts are system fonts; the app makes no external font requests.
+5. Room measurements, materials and cut plans open and close independently on the same page. The drawing stays pinned on the right while you scroll the panels; on phones a compact drawing stays above them. Flooring and skirting are visible together, with the whole drawing fitting without an internal scrollbar. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Flooring and skirting cuts are grouped by the purchased board they come from, including offcuts reused across rooms. Group headings use the cut codes; internal stock IDs stay out of the interface. Pieces wrap side by side when space allows. Each piece has a small preview that opens dimensions directly on its shape. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Numbered skirting pieces and their labels open cut details; uncut lengths use a neutral fill. Print all room drawings followed by flooring and skirting cut sheets grouped by purchased board. Every cut is included once, with its room and row or wall.
+6. Find a board or cut by its exact code, or search by room name. **Show on plan** highlights the source board’s pieces or an individual cut, with room buttons when a source is reused across rooms. Source boards have **Mark done** checkboxes; completion marks appear on their list entries, installed pieces and printed sheets. A full uncut board can also be found and marked by its source code. Measurements, wall order, flooring direction or material changes clear completion because the allocation changes; renaming rooms keeps it.
+7. Copy the link to save or share the complete project. Measurements, materials and view choices live in readable query parameters such as `flooring.length=1200`, `room.1.wall.1.length=4800` and `selectedCut=F1`. Progress uses repeated `done=floor:B4` or `done=skirting:S6` parameters; `search=F1` and repeated `collapsed=materials` parameters preserve the search and closed panels. You can edit values directly in the URL. Older compressed fragment links still load and migrate automatically; obsolete step, layer and zoom choices are ignored. Reloading or opening that link reproduces the plan. Browser back/forward restores room changes. There is no local storage, account, API, database or analytics. Fonts are system fonts; the app makes no external font requests.
## Calculation rules and practical limits
- Geometry follows measured vectors, checks final-wall length/direction, detects crossings and validates opening bounds and overlaps. A small accepted closure residual snaps only the final wall to the origin, with a visible warning. A mismatch can indicate measurement error or an irregular wall; lengths and corner angles alone cannot identify or reconstruct a bow. Remeasure and represent known changes of direction as additional segments.
-- Polygon offsets and clipping use `clipper-lib` at 0.001 mm internal precision. 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.
+- Polygon offsets and clipping use `clipper-lib` at 0.001 mm internal precision. Displayed cut dimensions round up to the next 0.5 mm; angles display to 0.1°. New cutting blanks reserve that rounded length plus kerf so the displayed cuts fit their source board. Finished polygon coordinates retain their original precision. 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, including row seams aligned with inward corners to avoid unnecessary notches and tapered tips.
- Brick uses a half-board stagger. Random is deterministic and discourages staircase runs, near-equal successive steps and repeating joint alignments, including steps that wrap across a board length. Pattern preferences never invalidate a physically valid plan when tight offset limits force repetition. The offset is the nearest end-joint distance on the adjacent row's grid, not a distance from a wall. Half a board is the largest possible nearest-joint offset. Settings that cannot be met produce an error instead of a partial layout.
- Minimum laminate sizes apply to each clipped piece's bounding length/width. Irregular shapes can still have narrow tips or necks; their exact templates are available in the cut popups. Product-specific click systems and assembly sequences are not simulated.
- Offcut reuse preserves the needed left/right factory ends. Only full-width end remnants are available for reuse; ripped side strips and shaped scraps are conservatively waste. Crosscuts include kerf. The stock count is actual allocated boards, not an area-only estimate. Waste includes kerf and unused material, including still-usable stock remainders.
@@ -42,18 +43,19 @@ npm run check # Build, unit tests and browser tests
## Code map
-| File | Responsibility |
-| ------------------------ | ---------------------------------------------------------------- |
-| `src/lib/types.ts` | Inputs and derived result contracts |
-| `src/lib/defaults.ts` | Starter measurements and deterministic IDs |
-| `src/lib/geometry.ts` | Vector tracing, closure, validation and polygon offsets |
-| `src/lib/flooring.ts` | Row search, clipping, factory-end-aware stock allocation |
-| `src/lib/skirting.ts` | Corner allowances, run splitting and stock allocation |
-| `src/lib/url-state.ts` | Readable query schema, legacy links and input validation |
-| `src/lib/use-project.ts` | URL and browser-history synchronization |
-| `src/lib/export.ts` | CSV generation, including formula-injection protection |
-| `src/components/` | Editors, dimensioned SVGs, board groups and printable cut sheets |
-| `tests/` | Pure calculation and state tests |
-| `e2e/` | Actual browser workflows at desktop and mobile sizes |
+| File | Responsibility |
+| --------------------------- | ---------------------------------------------------------------- |
+| `src/lib/types.ts` | Inputs and derived result contracts |
+| `src/lib/defaults.ts` | Starter measurements and deterministic IDs |
+| `src/lib/geometry.ts` | Vector tracing, closure, validation and polygon offsets |
+| `src/lib/flooring.ts` | Row search, clipping, factory-end-aware stock allocation |
+| `src/lib/skirting.ts` | Corner allowances, run splitting and stock allocation |
+| `src/lib/url-state.ts` | Readable query schema, legacy links and input validation |
+| `src/lib/board-tracking.ts` | Exact board/cut matching and room-name search |
+| `src/lib/wall-editing.ts` | Wall insertion and reordering with stable IDs |
+| `src/lib/use-project.ts` | URL and browser-history synchronization |
+| `src/components/` | Editors, dimensioned SVGs, board groups and printable cut sheets |
+| `tests/` | Pure calculation and state tests |
+| `e2e/` | Actual browser workflows at desktop and mobile sizes |
-Only the inputs and view selections are persisted. Geometry, plans, quantities, warning messages and drawings are derived. There are no timestamps or random-number calls in the planners. `npm run build` also checks unused variables and strict TypeScript types.
+Only the inputs, completion marks and view selections are persisted. Incomplete numeric drafts exist only while editing a field. Geometry, plans, quantities, warning messages and drawings are derived. There are no timestamps or random-number calls in the planners. `npm run build` also checks unused variables and strict TypeScript types.
diff --git a/e2e/planner.spec.ts b/e2e/planner.spec.ts
index e220881..62872c9 100644
--- a/e2e/planner.spec.ts
+++ b/e2e/planner.spec.ts
@@ -7,9 +7,11 @@ import { defaultState, rectangle } from '../src/lib/defaults';
import { planFlooring } from '../src/lib/flooring';
import { planSkirting } from '../src/lib/skirting';
import { flooringCuts, skirtingCuts, uncutFlooring } from '../src/lib/cut-list';
-import { mm } from '../src/lib/format';
+import { cutMm } from '../src/lib/format';
const errors = new WeakMap();
+const panel = (page: Page, section: string) =>
+ page.locator(`.planner-panel[data-section="${section}"]`);
const preview = (page: Page) => page.getByRole('region', { name: 'Floor plan preview' });
const floorEntries = (page: Page) => page.locator('.flooring-cuts .board-cut');
const skirtEntries = (page: Page) => page.locator('.skirting-cuts .board-cut');
@@ -30,6 +32,78 @@ const plans = (page: Page) => {
};
};
+function sharedRoomFixture() {
+ 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';
+ return fixture;
+}
+
+async function highlightedIds(page: Page, material?: 'flooring' | 'skirting') {
+ const selector = `${material ? `[data-material="${material}"]` : '[data-piece-id]'}[data-highlighted="true"]`;
+ return (
+ await preview(page)
+ .locator(selector)
+ .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-piece-id')))
+ ).sort();
+}
+
+async function wallDimensionsInsideSvg(page: Page, wallNumber: number) {
+ const wall = preview(page)
+ .locator('.wall-drawing')
+ .filter({ hasText: new RegExp(`W${wallNumber} ·`) });
+ await expect(wall.locator('.wall-label text')).toBeVisible();
+ const result = await wall.evaluate((element) => {
+ const svg = element.ownerSVGElement!;
+ const box = svg.viewBox.baseVal;
+ const inverse = svg.getScreenCTM()!.inverse();
+ const rootPoint = (element: SVGGraphicsElement, x: number, y: number) =>
+ new DOMPoint(x, y).matrixTransform(inverse.multiply(element.getScreenCTM()!));
+ const lines = [...element.querySelectorAll(':scope > line')].map(
+ (line) => line as SVGLineElement,
+ );
+ const endpoints = lines.flatMap((line) => [
+ rootPoint(line, line.x1.baseVal.value, line.y1.baseVal.value),
+ rootPoint(line, line.x2.baseVal.value, line.y2.baseVal.value),
+ ]);
+ const label = element.querySelector('.wall-label') as SVGGElement;
+ const bounds = label.getBBox();
+ const corners = [
+ rootPoint(label, bounds.x, bounds.y),
+ rootPoint(label, bounds.x + bounds.width, bounds.y),
+ rootPoint(label, bounds.x + bounds.width, bounds.y + bounds.height),
+ rootPoint(label, bounds.x, bounds.y + bounds.height),
+ ];
+ return {
+ lineCount: lines.length,
+ outside: [...endpoints, ...corners]
+ .filter(
+ (point) =>
+ point.x < box.x - 0.01 ||
+ point.x > box.x + box.width + 0.01 ||
+ point.y < box.y - 0.01 ||
+ point.y > box.y + box.height + 0.01,
+ )
+ .map((point) => ({ x: point.x, y: point.y })),
+ };
+ });
+ expect(result.lineCount).toBe(4);
+ expect(
+ result.outside,
+ `W${wallNumber} lines, ticks and label must fit inside the SVG viewBox`,
+ ).toEqual([]);
+}
+
async function stockGroups(groups: Locator) {
return groups.evaluateAll((elements) =>
elements.map((element) => ({
@@ -73,6 +147,31 @@ async function measuredEdges(preview: Locator) {
});
}
+function expectHalfMillimetreCuts(dimensions: { shown: number; actual: number }[]) {
+ for (const { shown, actual } of dimensions) {
+ expect(shown, 'A displayed cut must never round down').toBeGreaterThanOrEqual(actual - 1e-7);
+ expect(shown, 'Round up by less than half a millimetre').toBeLessThan(actual + 0.5 + 1e-7);
+ expect(shown * 2).toBeCloseTo(Math.round(shown * 2), 7);
+ }
+}
+
+async function noVisibleStockIds(page: Page) {
+ expect(await page.locator('body').innerText()).not.toMatch(
+ /(?:Floorboard B|Skirting(?: board)? S)\d+|(?:floor:B|skirting:S)\d+/i,
+ );
+ const labels = await page
+ .locator('[aria-label]')
+ .evaluateAll((elements) =>
+ elements.map((element) => element.getAttribute('aria-label')).join(' '),
+ );
+ expect(labels).not.toMatch(
+ /(?:Floorboard B|Skirting(?: board)? S)\d+|(?:floor:B|skirting:S)\d+/i,
+ );
+ expect(await page.getByLabel('Find a cut or room', { exact: true }).inputValue()).not.toMatch(
+ /(?:floor:B|skirting:S)\d+/,
+ );
+}
+
async function openWalls(page: Page) {
if (
!(await page
@@ -161,6 +260,8 @@ test('measurements, materials, drawing and both cut lists share one page', async
await expect(page.getByRole('heading', { name: /^Skirting cuts/ })).toBeVisible();
await expect(preview(page).getByText('Outline closed', { exact: true })).toBeVisible();
await expect(page.locator('.layer-switch, .step-nav')).toHaveCount(0);
+ await expect(page.getByRole('button', { name: 'Export CSV' })).toHaveCount(0);
+ await noVisibleStockIds(page);
await expect(page.locator('.app-header').getByText('mm', { exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Zoom (in|out)$/ })).toHaveCount(0);
await noDrawingScroll(page);
@@ -207,7 +308,7 @@ test('source-board groups contain every numbered cut once and leave full boards
}),
})
.locator('.cut-size'),
- ).toHaveText(`${mm(piece.length)} × ${mm(piece.width)} mm`);
+ ).toHaveText(`${cutMm(piece.length)} × ${cutMm(piece.width)} mm`);
}
await expect(page.locator('.material-cuts table')).toHaveCount(0);
await expect(preview(page).locator('.floor-piece')).toHaveCount(floor.pieces.length);
@@ -255,11 +356,10 @@ test('source-board groups contain every numbered cut once and leave full boards
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);
+ expectHalfMillimetreCuts(dimensions);
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(thumbnail).toBeFocused();
- await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
});
test('inconsistent final measurements block cuts and small residuals show the expected length', async ({
@@ -270,11 +370,9 @@ test('inconsistent final measurements block cuts and small residuals show the ex
await expect(page.getByText(/The outline is open by 100\.0 mm/).first()).toBeVisible();
await expect(floorEntries(page)).toHaveCount(0);
await expect(skirtEntries(page)).toHaveCount(0);
- await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
await page.getByLabel('Wall 4 length', { exact: true }).fill('3605');
await expect(page.getByText(/non-straight wall/)).toBeVisible();
await expect(page.getByText('Final wall: 3,600 mm expected · 5 mm difference')).toBeVisible();
- await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
expect(await floorEntries(page).count()).toBeGreaterThan(0);
});
@@ -313,7 +411,6 @@ test('custom walls close using a calculated wall that can be measured again', as
await expect(page.getByLabel('Wall 4 length', { exact: true })).toHaveValue('3000');
await page.getByLabel('Wall 4 length', { exact: true }).fill('3050');
await expect(page.getByText(/3,000 mm expected; length differs by 50 mm/)).toBeVisible();
- await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
});
test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialogs', async ({
@@ -343,8 +440,8 @@ test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialo
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)),
+ Number(cutMm(mitred.length).replaceAll(',', '')),
+ Number(cutMm(mitred.backLength).replaceAll(',', '')),
]),
);
const end = Math.abs(mitred.leftMitre) === 45 ? 'left' : 'right';
@@ -383,7 +480,7 @@ test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialo
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);
+ expectHalfMillimetreCuts(dimensions);
await expect(
page.getByRole('dialog').locator('.template-coordinates, .vertex-label, .cut-measures'),
).toHaveCount(0);
@@ -411,7 +508,6 @@ test('door positions and material lengths update skirting immediately', async ({
'aria-label',
'Door on W1: 1,200 mm from W4 corner, then 900 mm opening toward W2 corner',
);
- await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
expect(await page.locator('.skirting-cuts').innerText()).not.toBe(before);
await page.getByLabel('Stock length 1', { exact: true }).fill('3600');
await page.getByLabel('Skirting thickness', { exact: true }).fill('22');
@@ -420,11 +516,9 @@ test('door positions and material lengths update skirting immediately', async ({
await expect(page.locator('.skirting-cuts .cut-summary')).toContainText(
`Buy ${skirt.stockCount} lengths`,
);
- await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
await movedDoor.getByLabel('Door width', { exact: true }).fill('6000');
await expect(page.getByText(/must fit within the wall/).first()).toBeVisible();
await expect(skirtEntries(page)).toHaveCount(0);
- await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
});
test('invalid skirting settings keep flooring results usable', async ({ page }) => {
@@ -433,10 +527,8 @@ test('invalid skirting settings keep flooring results usable', async ({ page })
await expect(skirtEntries(page)).toHaveCount(0);
await expect(floorCodes(page)).toHaveCount(floorCount);
await expect(page.locator('.issue.error').first()).toBeVisible();
- await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
await page.getByLabel('Stock length 1', { exact: true }).fill('2400');
expect(await skirtEntries(page).count()).toBeGreaterThan(0);
- await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
});
test('pattern, direction and spacers persist without steps, layers or stored results', async ({
@@ -448,7 +540,12 @@ test('pattern, direction and spacers persist without steps, layers or stored res
await page.getByLabel('Wall spacer', { exact: true }).fill('12');
expect(state(page).project.flooring).toMatchObject({ pattern: 'random', gap: 12 });
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([
+ 'collapsedSections',
+ 'roomId',
+ 'search',
+ 'selectedPiece',
+ ]);
expect(Object.keys(state(page)).sort()).toEqual(['project', 'view']);
const table = await page.locator('.flooring-cuts').innerText();
await page.reload();
@@ -499,12 +596,12 @@ test('multiple rooms retain independent measurements with combined cut lists', a
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Living room');
});
-test('shared URLs reopen the selected preview and CSV contains every cut once', async ({
+test('shared URLs reopen the selected preview and copying the link preserves the plan', async ({
page,
context,
}) => {
await page.getByLabel('Room name', { exact: true }).fill('Café 北');
- const { floor, skirt } = plans(page);
+ const { floor } = plans(page);
const pieceCode = flooringCuts(floor)[0].id;
await page
.getByRole('button', { name: `Preview flooring cut ${pieceCode}`, exact: true })
@@ -530,22 +627,6 @@ test('shared URLs reopen the selected preview and CSV contains every cut once',
await shared.close();
await page.keyboard.press('Escape');
expect(state(page).view.selectedPiece).toBeNull();
- const downloadPromise = page.waitForEvent('download');
- await page.getByRole('button', { name: 'Export CSV' }).click();
- const download = await downloadPromise;
- expect(download.suggestedFilename()).toBe('flooring-cut-plan.csv');
- const csv = await readFile((await download.path())!, 'utf8');
- expect(csv).toContain('"Material","Room","Piece","Stock"');
- expect(csv).toContain(`"Flooring","Café 北","${pieceCode}"`);
- expect(csv.split('\r\n').filter((line) => line.startsWith('"Flooring",'))).toHaveLength(
- flooringCuts(floor).length,
- );
- expect(csv.split('\r\n').filter((line) => line.startsWith('"Skirting",'))).toHaveLength(
- skirtingCuts(skirt).length,
- );
- for (const piece of floor.pieces.filter((piece) => !piece.cut && !piece.reused))
- expect(csv).not.toContain(`"${piece.id}"`);
- expect(csv).not.toContain('bounded heuristic');
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
const closedUrl = page.url();
await page.getByRole('button', { name: 'Copy link', exact: true }).click();
@@ -646,19 +727,7 @@ test('readable query fields, legacy links and browser history preserve the plan'
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';
+ const fixture = sharedRoomFixture();
await page.goto(`/${encodeState(fixture)}`);
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study');
const { floor, skirt, project } = plans(page);
@@ -708,12 +777,392 @@ test('printing includes every room and every cut once, grouped by its source boa
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/);
+ expect(await printed.innerText()).not.toMatch(
+ /\b(?:FU|SU)\d+\b|(?:Floorboard B|Skirting(?: board)? S)\d+/i,
+ );
await page.emulateMedia({ media: 'screen' });
await expect(printed).toBeHidden();
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study');
});
+test('panels collapse independently and the room stays pinned while reading cuts', async ({
+ page,
+}) => {
+ for (const section of ['measurements', 'materials', 'cuts']) {
+ await expect(panel(page, section).locator('.panel-toggle')).toHaveAttribute(
+ 'aria-expanded',
+ 'true',
+ );
+ await panel(page, section).locator('.panel-toggle').click();
+ await expect(page.locator(`#${section}-content`)).toBeHidden();
+ }
+ expect(state(page).view.collapsedSections).toEqual(['cuts', 'materials', 'measurements']);
+ expect(new URL(page.url()).searchParams.getAll('collapsed')).toEqual([
+ 'cuts',
+ 'materials',
+ 'measurements',
+ ]);
+ await page.reload();
+ for (const section of ['measurements', 'materials', 'cuts']) {
+ await expect(panel(page, section).locator('.panel-toggle')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
+ }
+ await panel(page, 'cuts').locator('.panel-toggle').click();
+ await expect(page.getByLabel('Find a cut or room', { exact: true })).toBeVisible();
+ await skirtEntries(page).last().scrollIntoViewIfNeeded();
+ const scroll = await page.evaluate(() => window.scrollY);
+ expect(scroll).toBeGreaterThan(500);
+ const drawing = await page.locator('.drawing-column').boundingBox();
+ const viewport = page.viewportSize()!;
+ expect(drawing!.y).toBeGreaterThanOrEqual(0);
+ expect(drawing!.y).toBeLessThanOrEqual(20);
+ expect(drawing!.y + drawing!.height).toBeLessThanOrEqual(viewport.height + 1);
+ if (viewport.width >= 900) {
+ const panels = await page.locator('.planning-panels').boundingBox();
+ expect(drawing!.x).toBeGreaterThan(panels!.x + panels!.width);
+ }
+ await noDrawingScroll(page);
+ await noHorizontalOverflow(page);
+});
+
+test('drawing cuts still open a usable preview when the cut plan is collapsed', async ({
+ page,
+}) => {
+ await panel(page, 'cuts').locator('.panel-toggle').click();
+ await expect(page.locator('#cuts-content')).toBeHidden();
+ await preview(page).getByRole('button', { name: 'Select flooring cut F1', exact: true }).click();
+ const dialog = page.getByRole('dialog', { name: 'Flooring cut F1', exact: true });
+ await expect(dialog).toBeVisible();
+ await expect(dialog.locator('.cut-dimensions')).toBeVisible();
+ expect(state(page).view.selectedPiece).toBe('F1');
+ await page.keyboard.press('Escape');
+ await expect(dialog).toHaveCount(0);
+ await expect(page.locator('#cuts-content')).toBeHidden();
+});
+
+test('negative angles can be backspaced and retyped without saving unfinished drafts', async ({
+ page,
+}) => {
+ await page.getByRole('button', { name: 'With fireplace' }).click();
+ const turn = page.getByLabel('Wall 4 turn', { exact: true });
+ await expect(turn).toHaveValue('-90');
+ await expect(turn).toHaveAttribute('type', 'text');
+ await turn.focus();
+ await turn.press('End');
+ await turn.press('Backspace');
+ await expect(turn).toHaveValue('-9');
+ expect(state(page).project.rooms[0].walls[3].turn).toBe(-9);
+ const committed = page.url();
+ await turn.press('Backspace');
+ await expect(turn).toHaveValue('-');
+ expect(page.url()).toBe(committed);
+ await turn.press('Backspace');
+ await expect(turn).toHaveValue('');
+ expect(page.url()).toBe(committed);
+ await turn.pressSequentially('-90');
+ await expect(turn).toHaveValue('-90');
+ expect(state(page).project.rooms[0].walls[3].turn).toBe(-90);
+ await expect(preview(page).getByText('Outline closed', { exact: true })).toBeVisible();
+ const restored = page.url();
+ await turn.fill('-');
+ expect(page.url()).toBe(restored);
+ await turn.press('Enter');
+ await expect(turn).toHaveValue('-90');
+ expect(page.url()).toBe(restored);
+ const length = page.getByLabel('Wall 4 length', { exact: true });
+ await length.fill('');
+ expect(page.url()).toBe(restored);
+ await length.press('Tab');
+ await expect(length).toHaveValue('450');
+ expect(decodeState(new URL(page.url()).search).error).toBeNull();
+});
+
+test('inserting and reordering walls keeps stable wall IDs and attached doors', async ({
+ page,
+ isMobile,
+}) => {
+ await openWalls(page);
+ const originals = state(page).project.rooms[0].walls;
+ const originalIds = originals.map((wall) => wall.id);
+ const doorWall = originals.find((wall) => wall.doors.length)!;
+ await page.getByRole('button', { name: 'Insert wall after W2', exact: true }).click();
+ let walls = state(page).project.rooms[0].walls;
+ expect(walls).toHaveLength(5);
+ const inserted = walls[2];
+ expect(originalIds).not.toContain(inserted.id);
+ expect(inserted).toMatchObject({ length: 1000, turn: 90, doors: [] });
+ expect(walls.map((wall) => wall.id)).toEqual([
+ originalIds[0],
+ originalIds[1],
+ inserted.id,
+ originalIds[2],
+ originalIds[3],
+ ]);
+ await page.getByRole('button', { name: 'Move wall 5 up', exact: true }).click();
+ expect(state(page).project.rooms[0].walls[3]).toEqual(doorWall);
+ await page.getByRole('button', { name: 'Move wall 4 down', exact: true }).click();
+ expect(state(page).project.rooms[0].walls[4]).toEqual(doorWall);
+ if (!isMobile) {
+ const source = page.locator(`.wall-row[data-wall-id="${doorWall.id}"] .wall-drag-handle`);
+ const target = page.locator(`.wall-row[data-wall-id="${originalIds[0]}"]`);
+ await source.dragTo(target);
+ expect(state(page).project.rooms[0].walls[0]).toEqual(doorWall);
+ }
+ walls = state(page).project.rooms[0].walls;
+ for (const original of originals)
+ expect(walls.find((wall) => wall.id === original.id)).toEqual(original);
+ expect(new Set(walls.map((wall) => wall.id)).size).toBe(5);
+ const order = walls.map((wall) => wall.id);
+ await page.reload();
+ await expect(page.locator('.wall-row')).toHaveCount(5);
+ expect(
+ await page
+ .locator('.wall-row')
+ .evaluateAll((rows) => rows.map((row) => row.getAttribute('data-wall-id'))),
+ ).toEqual(order);
+ expect(state(page).project.rooms[0].walls.find((wall) => wall.id === doorWall.id)!.doors).toEqual(
+ doorWall.doors,
+ );
+});
+
+test('the supplied angled room keeps W12 lines and labels inside the drawing', async ({
+ page,
+ isMobile,
+}) => {
+ const angledRoom = await readFile(
+ new URL('../tests/fixtures/angled-room.json', import.meta.url),
+ 'utf8',
+ );
+ const fixture = decodeState(`#plan=${LZString.compressToEncodedURIComponent(angledRoom)}`);
+ expect(fixture.error).toBeNull();
+ await page.goto(`/${encodeState(fixture.state)}`);
+ await expect(preview(page).locator('.wall-drawing')).toHaveCount(12);
+ for (const width of isMobile ? [320, 390] : [1280]) {
+ await page.setViewportSize({ width, height: 844 });
+ await wallDimensionsInsideSvg(page, 12);
+ await noDrawingScroll(page);
+ await noHorizontalOverflow(page);
+ }
+});
+
+test('the supplied F8 room aligns a row seam with its fireplace corner and keeps exact templates', async ({
+ page,
+}) => {
+ const json = await readFile(
+ new URL('../tests/fixtures/angled-room-f8.json', import.meta.url),
+ 'utf8',
+ );
+ const fixture = decodeState(`#plan=${LZString.compressToEncodedURIComponent(json)}`);
+ expect(fixture.error).toBeNull();
+ fixture.state.view.selectedPiece = null;
+ fixture.state.view.search = '';
+ await page.goto(`/${encodeState(fixture.state)}`);
+ const { floor, skirt } = plans(page);
+ const corner = { x: 7607.513, y: 2160.34 };
+ const atCorner = floor.pieces.filter((piece) =>
+ piece.polygon.some((point) => Math.hypot(point.x - corner.x, point.y - corner.y) < 0.01),
+ );
+ expect(atCorner.length).toBeGreaterThanOrEqual(2);
+ expect(new Set(atCorner.map((piece) => piece.row)).size).toBe(2);
+ for (const piece of atCorner) {
+ // Flooring runs vertically here, so a row seam has a constant world x coordinate.
+ const xs = piece.polygon.map((point) => point.x);
+ expect(
+ Math.min(Math.abs(corner.x - Math.min(...xs)), Math.abs(corner.x - Math.max(...xs))),
+ ).toBeLessThan(0.01);
+ const polygon = preview(page).locator(`.floor-piece[data-piece-id="${piece.id}"] > polygon`);
+ const drawn = await polygon.evaluate((element) =>
+ [...(element as SVGPolygonElement).points].map((point) => ({ x: point.x, y: point.y })),
+ );
+ expect(drawn).toHaveLength(piece.polygon.length);
+ drawn.forEach((point, i) => {
+ expect(point.x).toBeCloseTo(piece.polygon[i].x, 2);
+ expect(point.y).toBeCloseTo(piece.polygon[i].y, 2);
+ });
+ }
+ const cut = atCorner.find((piece) => piece.cut)!;
+ await page.getByRole('button', { name: `Preview flooring cut ${cut.id}`, exact: true }).click();
+ const dialog = page.getByRole('dialog', { name: `Flooring cut ${cut.id}`, exact: true });
+ const shape = dialog.getByRole('img', { name: `Flooring shape ${cut.id}`, exact: true });
+ await expect(shape).toBeVisible();
+ const dimensions = await measuredEdges(shape);
+ expect(
+ dimensions.some(({ actual }) => Math.abs(actual * 2 - Math.round(actual * 2)) > 0.001),
+ ).toBe(true);
+ expectHalfMillimetreCuts(dimensions);
+ expect(state(page).project.rooms[0].walls).toEqual(fixture.state.project.rooms[0].walls);
+ await page.keyboard.press('Escape');
+ const skirtCut = skirtingCuts(skirt).find(
+ (piece) =>
+ Math.abs(piece.length * 2 - Math.round(piece.length * 2)) > 0.001 ||
+ Math.abs(piece.backLength * 2 - Math.round(piece.backLength * 2)) > 0.001,
+ )!;
+ expect(skirtCut).toBeDefined();
+ await page
+ .getByRole('button', { name: `Preview skirting cut ${skirtCut.id}`, exact: true })
+ .click();
+ const skirtDialog = page.getByRole('dialog', {
+ name: `Skirting cut ${skirtCut.id}`,
+ exact: true,
+ });
+ const lengths = await skirtDialog
+ .locator('.skirting-full-preview .dimension-label')
+ .allTextContents();
+ expect(lengths.map((text) => Number(text.replaceAll(',', '')))).toEqual(
+ expect.arrayContaining([
+ Number(cutMm(skirtCut.length).replaceAll(',', '')),
+ Number(cutMm(skirtCut.backLength).replaceAll(',', '')),
+ ]),
+ );
+ await page.keyboard.press('Escape');
+ await noVisibleStockIds(page);
+ await noHorizontalOverflow(page);
+});
+
+test('completed source boards and locate actions follow shared offcuts across rooms', async ({
+ page,
+}) => {
+ const fixture = sharedRoomFixture();
+ await page.goto(`/${encodeState(fixture)}`);
+ const { floor, skirt } = plans(page);
+ const shared = floor.stocks.find(
+ (stock) =>
+ new Set(stock.pieceIds.map((id) => floor.pieces.find((piece) => piece.id === id)!.roomId))
+ .size > 1,
+ )!;
+ expect(shared).toBeDefined();
+ const sourcePieces = floor.pieces.filter((piece) => piece.stockId === shared.id);
+ const key = `floor:${shared.id}`;
+ const group = page.locator(`.stock-cut-group[data-board-key="${key}"]`);
+ const groupCodes = await group
+ .locator('.board-cut')
+ .evaluateAll((cuts) => cuts.map((cut) => cut.getAttribute('data-cut-id')).join(' + '));
+ const done = group.getByRole('checkbox', {
+ name: `Mark cuts ${groupCodes} done`,
+ exact: true,
+ });
+ await done.check();
+ await expect(group).toHaveClass(/is-done/);
+ expect(state(page).project.completedBoards).toEqual([key]);
+ expect(new URL(page.url()).searchParams.getAll('done')).toEqual([key]);
+ await group.getByRole('button', { name: `Show cuts ${groupCodes} on plan`, exact: true }).click();
+ expect(state(page).view.search).toBe(key);
+ const search = page.getByLabel('Find a cut or room', { exact: true });
+ await expect(search).toHaveValue(groupCodes);
+ await noVisibleStockIds(page);
+ await search.fill('');
+ await search.fill(groupCodes);
+ expect(state(page).view.search).toBe(groupCodes);
+ await noVisibleStockIds(page);
+ expect(await highlightedIds(page, 'flooring')).toEqual(
+ sourcePieces
+ .filter((piece) => piece.roomId === 'room-2')
+ .map((piece) => piece.id)
+ .sort(),
+ );
+ await page
+ .locator('.matching-rooms')
+ .getByRole('button', { name: 'Living room', exact: true })
+ .click();
+ expect(state(page).view.roomId).toBe('room-1');
+ expect(await highlightedIds(page, 'flooring')).toEqual(
+ sourcePieces
+ .filter((piece) => piece.roomId === 'room-1')
+ .map((piece) => piece.id)
+ .sort(),
+ );
+ expect(
+ await preview(page)
+ .locator('.floor-piece[data-highlighted="true"]')
+ .evaluateAll((pieces) => pieces.every((piece) => piece.getAttribute('data-done') === 'true')),
+ ).toBe(true);
+ const piece = sourcePieces.find((piece) => piece.roomId === 'room-2')!;
+ await page.getByRole('button', { name: `Show cut ${piece.id} on plan`, exact: true }).click();
+ expect(state(page).view.roomId).toBe('room-2');
+ expect(state(page).view.search).toBe(piece.id);
+ expect(await highlightedIds(page)).toEqual([piece.id]);
+ await expect(page.getByRole('dialog')).toHaveCount(0);
+ await expect(group.locator('.board-cut')).toHaveCount(sourcePieces.length);
+ await page.reload();
+ await expect(page.getByLabel('Find a cut or room', { exact: true })).toHaveValue(piece.id);
+ await expect(done).toBeChecked();
+ expect(await highlightedIds(page)).toEqual([piece.id]);
+ await page.getByLabel('Room name', { exact: true }).fill('Office');
+ expect(state(page).project.completedBoards).toEqual([key]);
+ await page.locator('.room-tab').first().click();
+ expect(state(page).project.completedBoards).toEqual([key]);
+ await page.getByLabel('Find a cut or room', { exact: true }).fill('');
+ const skirtPiece = skirtingCuts(skirt).find((piece) => piece.roomId === 'room-1')!;
+ const skirtKey = `skirting:${skirtPiece.stockId}`;
+ await page
+ .locator(`.stock-cut-group[data-board-key="${skirtKey}"]`)
+ .getByRole('checkbox')
+ .check();
+ expect(state(page).project.completedBoards).toEqual([key, skirtKey]);
+ expect(await preview(page).locator('.skirt-piece[data-done="true"]').count()).toBeGreaterThan(0);
+ await page.getByLabel('Board width', { exact: true }).fill('201');
+ expect(state(page).project.completedBoards).toEqual([]);
+ await expect(preview(page).locator('[data-done="true"]')).toHaveCount(0);
+ expect(new URL(page.url()).searchParams.getAll('done')).toEqual([]);
+});
+
+test('cuts from one board share the available width and wrap on a narrow phone', async ({
+ page,
+ isMobile,
+}) => {
+ await page.setViewportSize({ width: isMobile ? 320 : 1600, height: 900 });
+ await page.goto(`/${encodeState(sharedRoomFixture())}`);
+ const group = page
+ .locator('.flooring-cuts .stock-cut-group')
+ .filter({ has: page.locator('.board-cut:nth-child(2)') })
+ .first();
+ const pieces = group.locator('.board-cut');
+ expect(await pieces.count()).toBeGreaterThan(1);
+ const first = (await pieces.nth(0).boundingBox())!;
+ const second = (await pieces.nth(1).boundingBox())!;
+ if (isMobile) {
+ expect(second.y).toBeGreaterThanOrEqual(first.y + first.height);
+ expect(second.x).toBeCloseTo(first.x, 1);
+ } else {
+ expect(second.y).toBeCloseTo(first.y, 1);
+ expect(second.x).toBeGreaterThanOrEqual(first.x + first.width);
+ }
+ await noHorizontalOverflow(page);
+ await noVisibleStockIds(page);
+});
+
+test('search can locate and complete a whole board without inventing a cut code', async ({
+ page,
+}) => {
+ const { floor } = plans(page);
+ const whole = floor.pieces.find((piece) => !piece.cut && !piece.reused)!;
+ expect(whole).toBeDefined();
+ const search = page.getByLabel('Find a cut or room', { exact: true });
+ await search.fill(`floor:${whole.stockId}`);
+ const group = page.locator(`.stock-cut-group[data-board-key="floor:${whole.stockId}"]`);
+ await expect(group).toContainText('Installed uncut');
+ await group.getByRole('button', { name: 'Show uncut floorboard on plan', exact: true }).click();
+ await expect(search).toHaveValue('Uncut floorboard');
+ await noVisibleStockIds(page);
+ await expect(group.locator('.board-cut')).toHaveCount(0);
+ await expect(group.getByRole('button', { name: /^Preview/ })).toHaveCount(0);
+ expect(await highlightedIds(page)).toEqual([whole.id]);
+ await expect(preview(page).locator(`.floor-piece[data-piece-id="${whole.id}"] text`)).toHaveCount(
+ 0,
+ );
+ await group.getByRole('checkbox', { name: 'Mark uncut floorboard done', exact: true }).check();
+ await expect(preview(page).locator(`.floor-piece[data-piece-id="${whole.id}"]`)).toHaveAttribute(
+ 'data-done',
+ 'true',
+ );
+ await search.fill('nothing matches this room');
+ await expect(page.locator('.search-results')).toHaveText('No matching boards or cuts.');
+ expect(await highlightedIds(page)).toEqual([]);
+ expect(state(page).project.completedBoards).toEqual([`floor:${whole.stockId}`]);
+});
+
for (const width of [320, 390]) {
test(`phone layout works at ${width} px with readable inputs and touch targets`, async ({
page,
@@ -727,7 +1176,11 @@ for (const width of [320, 390]) {
.locator('button, input, select, summary')
.evaluateAll((elements) =>
elements.flatMap((element) => {
- const rect = element.getBoundingClientRect();
+ const target =
+ element instanceof HTMLInputElement && element.type === 'checkbox'
+ ? (element.closest('label') ?? element)
+ : element;
+ const rect = target.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && (rect.width < 39.5 || rect.height < 39.5)
? [
{
@@ -743,7 +1196,7 @@ for (const width of [320, 390]) {
[],
);
const smallInputs = await page
- .locator('input, select')
+ .locator('input:not([type="checkbox"]), select')
.evaluateAll((elements) =>
elements
.filter(
diff --git a/src/App.tsx b/src/App.tsx
index 8fca6dc..004eb8f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,21 +1,64 @@
import { useMemo, useRef } from 'react';
-import { Copy, Plus, Printer, ArrowDown } from 'lucide-react';
+import type { ReactNode } from 'react';
+import { Copy, Plus, Printer } from 'lucide-react';
import { useProject } from './lib/use-project';
import { rectangle, nextId } from './lib/defaults';
import { buildGeometry } from './lib/geometry';
import { planFlooring } from './lib/flooring';
import { planSkirting } from './lib/skirting';
import { area, mm } from './lib/format';
-import { isSkirtingCut } from './lib/cut-list';
-import type { Project, Room } from './lib/types';
+import { flooringCuts, isSkirtingCut, skirtingCuts } from './lib/cut-list';
+import type { PlannerSection, Project, Room } from './lib/types';
+import { boardSearchLabel, findBoardMatches } from './lib/board-tracking';
import { OutlineEditor } from './components/OutlineEditor';
import { MaterialsEditor } from './components/MaterialsEditor';
import { FloorPlan } from './components/FloorPlan';
import { CutList } from './components/CutList';
+import { CutDialog } from './components/CutDialog';
import { PrintPlan } from './components/PrintPlan';
import { Issues } from './components/Fields';
-const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ block: 'start' });
+function PlannerPanel({
+ section,
+ title,
+ open,
+ toggle,
+ children,
+}: {
+ section: PlannerSection;
+ title: string;
+ open: boolean;
+ toggle: () => void;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+ {title}
+ {open ? '−' : '+'}
+
+
+
+ {children}
+
+
+ );
+}
+
+// Completion refers to this exact allocation; names and view preferences do not change it.
+const allocationInputs = (project: Project) =>
+ JSON.stringify({
+ rooms: project.rooms.map(({ id, walls, direction }) => ({ id, walls, direction })),
+ flooring: project.flooring,
+ skirting: project.skirting,
+ tolerance: project.tolerance,
+ });
export default function App() {
const { state, error, update } = useProject();
@@ -50,7 +93,50 @@ export default function App() {
const wholeSkirting = skirting.pieces.filter((p) => !isSkirtingCut(p, skirting)).length;
const changeProject = (fn: (p: Project) => Project) =>
- update((s) => ({ ...s, project: fn(s.project), view: { ...s.view, selectedPiece: null } }));
+ update((s) => {
+ const project = fn(s.project);
+ const changed = allocationInputs(project) !== allocationInputs(s.project);
+ return {
+ ...s,
+ project: changed ? { ...project, completedBoards: [] } : project,
+ view: { ...s.view, selectedPiece: null, search: changed ? '' : s.view.search },
+ };
+ });
+ const matches = useMemo(
+ () => findBoardMatches(project, flooring, skirting, view.search),
+ [project, flooring, skirting, view.search],
+ );
+ const searchLabel = boardSearchLabel(project, flooring, skirting, view.search);
+ const setSearch = (search: string) =>
+ update((s) => {
+ const found = findBoardMatches(s.project, flooring, skirting, search);
+ const roomId = found.roomIds.includes(s.view.roomId)
+ ? s.view.roomId
+ : (found.roomIds[0] ?? s.view.roomId);
+ return { ...s, view: { ...s.view, search, roomId, selectedPiece: null } };
+ });
+ const togglePanel = (section: PlannerSection) =>
+ update((s) => ({
+ ...s,
+ view: {
+ ...s.view,
+ collapsedSections: s.view.collapsedSections.includes(section)
+ ? s.view.collapsedSections.filter((item) => item !== section)
+ : [...s.view.collapsedSections, section],
+ },
+ }));
+ const toggleBoard = (key: string) =>
+ update((s) => ({
+ ...s,
+ project: {
+ ...s.project,
+ completedBoards: s.project.completedBoards.includes(key)
+ ? s.project.completedBoards.filter((item) => item !== key)
+ : [...s.project.completedBoards, key],
+ },
+ }));
+ const locatePiece = (id: string, roomId: string) =>
+ update((s) => ({ ...s, view: { ...s.view, search: id, roomId, selectedPiece: null } }));
const changeRoom = (fn: (r: Room) => Room) =>
changeProject((p) => ({ ...p, rooms: p.rooms.map((r) => (r.id === room.id ? fn(r) : r)) }));
const select = (id: string | null, roomId?: string) =>
@@ -68,9 +154,18 @@ export default function App() {
...s,
project: {
...s.project,
+ completedBoards: [],
rooms: [...s.project.rooms, rectangle(id, `Room ${s.project.rooms.length + 1}`)],
},
- view: { ...s.view, roomId: id, selectedPiece: null },
+ view: {
+ ...s.view,
+ roomId: id,
+ selectedPiece: null,
+ search: '',
+ collapsedSections: s.view.collapsedSections.filter(
+ (section) => section !== 'measurements',
+ ),
+ },
};
}, true);
const removeRoom = () =>
@@ -78,8 +173,8 @@ export default function App() {
const rooms = s.project.rooms.filter((r) => r.id !== room.id);
return {
...s,
- project: { ...s.project, rooms },
- view: { ...s.view, roomId: rooms[0].id, selectedPiece: null },
+ project: { ...s.project, rooms, completedBoards: [] },
+ view: { ...s.view, roomId: rooms[0].id, selectedPiece: null, search: '' },
};
}, true);
const copyLink = async () => {
@@ -148,51 +243,116 @@ export default function App() {
{area(totalArea)} m² total
-
- jump('measurements')}>
- Room
-
- jump('materials')}>
- Materials
-
- jump('drawing')}>
- Drawing
-
- jump('cut-plan')}>
- Cuts
-
-
-
-
+ togglePanel('measurements')}
>
- 1}
- />
-
-
+
+
+
togglePanel('materials')}
>
-
-
+
+
+
+ Materials to buy{' '}
+ {project.rooms.length > 1 ? 'All rooms' : room.name}
+
+
+
+
+
+ {floorErrors.length ? '—' : flooring.boardCount} floorboards
+
+
+ {mm(project.flooring.length)} × {mm(project.flooring.width)} mm ·{' '}
+ {wholeBoards} used whole
+
+
+
+ {floorErrors.length ? '—' : mm(flooring.wastePercent)}%waste
+
+
+
+
+
+
+ {skirtErrors.length ? '—' : skirting.stockCount} skirting lengths
+
+
+ {[...new Set(skirting.stocks.map((s) => s.length))]
+ .sort((a, b) => a - b)
+ .map(
+ (l) =>
+ `${skirting.stocks.filter((s) => s.length === l).length} × ${mm(l)} mm`,
+ )
+ .join(' + ') || 'Check settings'}{' '}
+ · {wholeSkirting} used whole
+
+
+
+ {skirtErrors.length ? '—' : mm(skirting.waste / 1000)} mwaste
+
+
+
+
+
togglePanel('cuts')}
+ >
+
+
+
+
-
+
select(id)}
+ completedBoards={project.completedBoards}
+ highlightedPieces={matches.pieceIds}
+ searchActive={!!view.search.trim()}
/>
-
-
- Materials to buy {project.rooms.length > 1 ? 'All rooms' : room.name}
-
-
-
-
-
- {floorErrors.length ? '—' : flooring.boardCount} floorboards
-
-
- {mm(project.flooring.length)} × {mm(project.flooring.width)} mm · {wholeBoards}{' '}
- used whole
-
-
-
- {floorErrors.length ? '—' : mm(flooring.wastePercent)}%waste
-
+ {view.search.trim() && (
+
+
+ {matches.pieceIds.length
+ ? `${matches.pieceIds.length} piece${matches.pieceIds.length === 1 ? '' : 's'} found for “${searchLabel}”`
+ : `No boards or cuts match “${searchLabel}”`}
+
+ {matches.roomIds.length > 1 && (
+
+ {project.rooms
+ .filter((r) => matches.roomIds.includes(r.id))
+ .map((r) => (
+
+ update((s) => ({
+ ...s,
+ view: { ...s.view, roomId: r.id, selectedPiece: null },
+ }))
+ }
+ >
+ {r.name}
+
+ ))}
+
+ )}
-
-
-
-
- {skirtErrors.length ? '—' : skirting.stockCount} skirting lengths
-
-
- {[...new Set(skirting.stocks.map((s) => s.length))]
- .sort((a, b) => a - b)
- .map(
- (l) =>
- `${skirting.stocks.filter((s) => s.length === l).length} × ${mm(l)} mm`,
- )
- .join(' + ') || 'Check settings'}{' '}
- · {wholeSkirting} used whole
-
-
-
- {skirtErrors.length ? '—' : mm(skirting.waste / 1000)} mwaste
-
-
-
-
-
-
-
-
+ )}
+
+
piece.id === view.selectedPiece)}
+ skirt={skirtingCuts(skirting).find((piece) => piece.id === view.selectedPiece)}
+ project={project}
+ close={() => select(null)}
+ />
void;
+ locate: () => void;
+}) {
+ return (
+
+ );
+}
interface Props {
project: Project;
flooring: FlooringPlan;
skirting: SkirtingPlan;
- selectedPiece: string | null;
select: (id: string | null, roomId?: string) => void;
+ completedBoards: string[];
+ toggleBoard: (key: string) => void;
+ search: string;
+ setSearch: (query: string) => void;
+ locateBoard: (key: string) => void;
+ locatePiece: (id: string, roomId: string) => void;
}
-export function CutList({ project, flooring, skirting, selectedPiece, select }: Props) {
+export function CutList({
+ project,
+ flooring,
+ skirting,
+ select,
+ completedBoards,
+ toggleBoard,
+ search,
+ setSearch,
+ locateBoard,
+ locatePiece,
+}: Props) {
const floorCuts = flooringCuts(flooring),
skirtCuts = skirtingCuts(skirting);
- const wholeFloor = uncutFlooring(flooring, project.flooring);
- const wholeSkirt = uncutSkirting(skirting);
- const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
- const errors = [...flooring.issues, ...skirting.issues].some(
- (issue) => issue.severity === 'error',
+ const wholeFloor = uncutFlooring(flooring, project.flooring),
+ wholeSkirt = uncutSkirting(skirting);
+ const floorGroups = new Map(
+ groupFlooringCuts(flooring).map((group) => [group[0].stockId, group]),
);
+ const skirtGroups = new Map(
+ groupSkirtingCuts(skirting).map((group) => [group[0].stockId, group]),
+ );
+ const searching = search.trim().length > 0;
+ const matches = findBoardMatches(project, flooring, skirting, search);
+ const matchedBoards = new Set(matches.boardKeys),
+ matchedPieces = new Set(matches.pieceIds);
+ const completed = new Set(completedBoards);
+ // Keep every cut from a matching source together, even when only one piece or room matched.
+ const floorStocks = flooring.stocks.filter((stock) =>
+ searching ? matchedBoards.has(`floor:${stock.id}`) : floorGroups.has(stock.id),
+ );
+ const skirtStocks = skirting.stocks.filter((stock) =>
+ searching ? matchedBoards.has(`skirting:${stock.id}`) : skirtGroups.has(stock.id),
+ );
+ const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
return (
@@ -39,15 +111,39 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
Each group comes from one board. Select a piece for its cutting diagram.
- downloadCsv(project, flooring, skirting)}
- disabled={errors || (!flooring.pieces.length && !skirting.pieces.length)}
- >
-
- Export CSV
-
+
+
Find a cut or room
+
+ setSearch(event.target.value)}
+ placeholder="F12, A2, or a room name"
+ autoComplete="off"
+ />
+ {search.length > 0 && (
+ setSearch('')}
+ >
+
+ Clear
+
+ )}
+
+ {searching && (
+
+ {matches.boardKeys.length
+ ? `${matches.boardKeys.length} matching board${matches.boardKeys.length === 1 ? '' : 's'}`
+ : 'No matching boards or cuts.'}
+
+ )}
+
+
Flooring cuts {floorCuts.length}
@@ -57,61 +153,98 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
{wholeFloor.readyOffcuts > 0 &&
` · ${wholeFloor.readyOffcuts} offcuts need no further cuts`}
- {groupFlooringCuts(flooring).map((group) => (
-
-
- Floorboard {group[0].stockId}
-
- {mm(project.flooring.length)} × {mm(project.flooring.width)} mm
-
-
-
- {group.map((piece) => (
-
- select(piece.id, piece.roomId)}
- >
-
-
-
-
- {piece.id} ↗
-
-
-
-
- {mm(piece.length)} × {mm(piece.width)} mm
-
-
- {roomName(piece.roomId)} · Row {piece.row}
-
- {piece.reused && (
-
- {piece.cut ? 'Cut from the remaining offcut' : 'Use the offcut as it is'}
-
- )}
-
-
- ))}
-
-
- ))}
- {!floorCuts.length && (
+ {floorStocks.map((stock) => {
+ const key = `floor:${stock.id}`,
+ done = completed.has(key),
+ group = floorGroups.get(stock.id) ?? [],
+ label = group.length
+ ? `cuts ${group.map((piece) => piece.id).join(' + ')}`
+ : 'uncut floorboard';
+ return (
+
+ toggleBoard(key)}
+ locate={() => locateBoard(key)}
+ />
+ {group.length ? (
+
+ {group.map((piece) => (
+
+ select(piece.id, piece.roomId)}
+ >
+
+
+
+
+ {piece.id} ↗
+
+
+
+
+ {cutMm(piece.length)} × {cutMm(piece.width)} mm
+
+
+ {roomName(piece.roomId)} · Row {piece.row}
+
+ {piece.reused && (
+
+ {piece.cut
+ ? 'Cut from the remaining offcut'
+ : 'Use the offcut as it is'}
+
+ )}
+
locatePiece(piece.id, piece.roomId)}
+ >
+
+ Show on plan
+
+
+
+ ))}
+
+ ) : (
+
+ Installed uncut ·{' '}
+ {flooring.pieces
+ .filter((piece) => piece.stockId === stock.id)
+ .map((piece) => roomName(piece.roomId))
+ .join(', ')}
+
+ )}
+
+ );
+ })}
+ {!floorStocks.length && (
- {flooring.pieces.length
- ? 'No flooring cuts needed.'
- : 'Complete the outline and flooring measurements to calculate cuts.'}
+ {searching
+ ? 'No matching floorboards.'
+ : flooring.pieces.length
+ ? 'No flooring cuts needed.'
+ : 'Complete the outline and flooring measurements to calculate cuts.'}
)}
+
Skirting cuts {skirtCuts.length}
@@ -120,69 +253,97 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
Buy {skirting.stockCount} lengths ·{' '}
{wholeSkirt.reduce((sum, group) => sum + group.count, 0)} installed uncut
- {groupSkirtingCuts(skirting).map((group) => (
-
-
- Skirting board {group[0].stockId}
-
- {mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
-
-
-
- {group.map((piece) => (
-
- select(piece.id, piece.roomId)}
- >
-
-
-
-
- {piece.id} ↗
-
-
-
-
- {mm(piece.length)} mm long point
-
-
- {roomName(piece.roomId)} · Wall {piece.wallIndex + 1}
-
-
- Left {degree(piece.leftMitre)} · Right {degree(piece.rightMitre)}
-
-
-
- ))}
-
-
- ))}
- {!skirtCuts.length && (
+ {skirtStocks.map((stock) => {
+ const key = `skirting:${stock.id}`,
+ done = completed.has(key),
+ group = skirtGroups.get(stock.id) ?? [],
+ label = group.length
+ ? `cuts ${group.map((piece) => piece.id).join(' + ')}`
+ : 'uncut skirting board';
+ return (
+
+ toggleBoard(key)}
+ locate={() => locateBoard(key)}
+ />
+ {group.length ? (
+
+ {group.map((piece) => (
+
+ select(piece.id, piece.roomId)}
+ >
+
+
+
+
+ {piece.id} ↗
+
+
+
+
+ {cutMm(piece.length)} mm long point
+
+
+ {roomName(piece.roomId)} · Wall {piece.wallIndex + 1}
+
+
+ Left {degree(piece.leftMitre)} · Right {degree(piece.rightMitre)}
+
+
locatePiece(piece.id, piece.roomId)}
+ >
+
+ Show on plan
+
+
+
+ ))}
+
+ ) : (
+
+ Installed uncut ·{' '}
+ {skirting.pieces
+ .filter((piece) => piece.stockId === stock.id)
+ .map((piece) => roomName(piece.roomId))
+ .join(', ')}
+
+ )}
+
+ );
+ })}
+ {!skirtStocks.length && (
- {skirting.pieces.length
- ? 'No skirting cuts needed.'
- : 'Complete the outline and skirting measurements to calculate cuts.'}
+ {searching
+ ? 'No matching skirting boards.'
+ : skirting.pieces.length
+ ? 'No skirting cuts needed.'
+ : 'Complete the outline and skirting measurements to calculate cuts.'}
)}
- piece.id === selectedPiece)}
- skirt={skirtCuts.find((piece) => piece.id === selectedPiece)}
- project={project}
- close={() => select(null)}
- />
);
}
diff --git a/src/components/CutPreview.tsx b/src/components/CutPreview.tsx
index d61c7e1..6a5f8e5 100644
--- a/src/components/CutPreview.tsx
+++ b/src/components/CutPreview.tsx
@@ -1,7 +1,7 @@
import { Fragment } from 'react';
import type { FlooringPiece, SkirtingPiece } from '../lib/types';
+import { cutMm, degree } from '../lib/format';
import {
- exactMm,
dimensionedOutline,
flooringTemplate,
fullPreviewBox,
@@ -93,7 +93,7 @@ function Dimensions({ dimensions, font }: { dimensions: EdgeDimension[]; font: n
{dimension.text}
{' '}
- {exactMm(dimension.angle)}°
+ {degree(dimension.angle)}
>
)}
@@ -137,7 +137,7 @@ export function FlooringPreview({
{!compact && (
- {exactMm(piece.length)} × {exactMm(piece.width)} mm
+ {cutMm(piece.length)} × {cutMm(piece.width)} mm
{dimensions?.dimensions.some((dimension) => dimension.angle !== undefined) &&
' · angles from horizontal'}
@@ -198,7 +198,7 @@ export function SkirtingPreview({
- {exactMm(piece.length)} × {exactMm(thickness)} mm · wall side above
+ {cutMm(piece.length)} × {cutMm(thickness)} mm · wall side above
)}
@@ -242,7 +242,7 @@ export function SkirtingPreview({
{!compact && (
{index === 0 ? 'Left' : 'Right'} ·{' '}
- {exactMm(Math.abs(index === 0 ? piece.leftMitre : piece.rightMitre))}°{' '}
+ {degree(index === 0 ? piece.leftMitre : piece.rightMitre)}{' '}
{index === 0 ? piece.leftType : piece.rightType}
)}
diff --git a/src/components/Fields.tsx b/src/components/Fields.tsx
index 61a40b6..03a6794 100644
--- a/src/components/Fields.tsx
+++ b/src/components/Fields.tsx
@@ -1,6 +1,61 @@
-import { useId, useState } from 'react';
+import { useEffect, useId, useRef, useState } from 'react';
+import type { InputHTMLAttributes } from 'react';
import type { Issue } from '../lib/types';
import { AlertCircle, Info, Check } from 'lucide-react';
+
+const numberFromDraft = (text: string): number | null => {
+ if (!/^-?(?:\d+(?:\.\d*)?|\.\d+)$/.test(text)) return null;
+ const number = Number(text);
+ return Number.isFinite(number) ? number : null;
+};
+
+/** Only unfinished typing is local; every complete numeric value is committed immediately. */
+export function NumericInput({
+ value,
+ onChange,
+ onBlur,
+ onKeyDown,
+ ...props
+}: Omit, 'value' | 'onChange' | 'type'> & {
+ value: number;
+ onChange: (value: number) => void;
+}) {
+ const [draft, setDraft] = useState(String(value));
+ const emitted = useRef(value);
+ useEffect(() => {
+ if (value !== emitted.current) setDraft(String(value));
+ emitted.current = value;
+ }, [value]);
+ return (
+ {
+ const text = event.target.value;
+ setDraft(text);
+ const number = numberFromDraft(text);
+ if (number !== null && number !== value) {
+ emitted.current = number;
+ onChange(number);
+ }
+ }}
+ onBlur={(event) => {
+ setDraft(String(value));
+ onBlur?.(event);
+ }}
+ onKeyDown={(event) => {
+ onKeyDown?.(event);
+ if (!event.defaultPrevented && (event.key === 'Enter' || event.key === 'Escape')) {
+ event.preventDefault();
+ event.currentTarget.blur();
+ }
+ }}
+ />
+ );
+}
+
export function NumberField({
label,
value,
@@ -54,16 +109,14 @@ export function NumberField({
)}
- onChange(Number(e.target.value))}
+ onChange={onChange}
/>
{unit}
diff --git a/src/components/FloorPlan.tsx b/src/components/FloorPlan.tsx
index fa9dbf0..e691e87 100644
--- a/src/components/FloorPlan.tsx
+++ b/src/components/FloorPlan.tsx
@@ -1,10 +1,10 @@
import { useId } from 'react';
import type { FlooringPlan, Geometry, Point, Room, SkirtingPlan, WallEdge } from '../lib/types';
-import { area, mm } from '../lib/format';
+import { area, cutMm, mm } from '../lib/format';
import { signedArea } from '../lib/geometry';
import { isSkirtingCut } from '../lib/cut-list';
import { skirtingPolygon } from '../lib/skirting-polygon';
-import { dimensionLabels } from '../lib/dimension-labels';
+import { dimensionLabels, wallDimensionBox } from '../lib/dimension-labels';
import type { DimensionLabel, LabelBox } from '../lib/dimension-labels';
import { ClosedBadge } from './Fields';
@@ -113,6 +113,9 @@ interface Props {
selectedPiece: string | null;
select?: (id: string) => void;
interactive?: boolean;
+ completedBoards?: string[];
+ highlightedPieces?: string[];
+ searchActive?: boolean;
}
export function FloorPlan({
@@ -123,6 +126,9 @@ export function FloorPlan({
selectedPiece,
select,
interactive = true,
+ completedBoards = [],
+ highlightedPieces = [],
+ searchActive = false,
}: Props) {
const hatch = useId().replaceAll(':', '');
const allPoints = geometry.edges.flatMap((edge) => [edge.start, edge.end]);
@@ -145,14 +151,17 @@ export function FloorPlan({
const y = center.y - ((orientation * (piece.end.x - piece.start.x)) / length) * span * 0.032;
skirtLabels.set(piece.id, { x, y });
reserved.push({
- minX: x - font * 1.2,
- maxX: x + font * 1.2,
+ minX: x - font * 1.4,
+ maxX: x + font * 1.4,
minY: y - font * 0.7,
maxY: y + font * 0.7,
});
}
const labels = dimensionLabels(geometry.edges, span, orientation, reserved);
- const boxes = [...reserved, ...labels.map((label) => label.box)];
+ const boxes = [
+ ...reserved,
+ ...labels.map((label, i) => wallDimensionBox(geometry.edges[i], label, span)),
+ ];
const left = Math.min(minX, ...boxes.map((b) => b.minX)) - span * 0.035;
const top = Math.min(minY, ...boxes.map((b) => b.minY)) - span * 0.035;
const width = Math.max(maxX, ...boxes.map((b) => b.maxX)) + span * 0.035 - left;
@@ -161,7 +170,10 @@ export function FloorPlan({
- Floor plan {area(geometry.area)} m²
+ Floor plan{' '}
+
+ {room.name} · {area(geometry.area)} m²
+
@@ -212,6 +224,8 @@ export function FloorPlan({
{geometry.closed && }
{floorPieces.map((piece) => {
const cut = piece.cut || piece.reused;
+ const done = completedBoards.includes(`floor:${piece.stockId}`);
+ const highlighted = highlightedPieces.includes(piece.id);
const x =
(Math.min(...piece.polygon.map((p) => p.x)) +
Math.max(...piece.polygon.map((p) => p.x))) /
@@ -223,8 +237,11 @@ export function FloorPlan({
return (
+ {done && !cut && (
+
+ ✓
+
+ )}
{cut && (
<>
{piece.id}
+ {done ? ' ✓' : ''}
- {piece.id}: {mm(piece.length)} × {mm(piece.width)} mm
+ {piece.id}: {cutMm(piece.length)} × {cutMm(piece.width)} mm
>
)}
@@ -287,6 +324,8 @@ export function FloorPlan({
))}
{skirtPieces.map((piece) => {
const cut = isSkirtingCut(piece, skirting);
+ const done = completedBoards.includes(`skirting:${piece.stockId}`);
+ const highlighted = highlightedPieces.includes(piece.id);
const label = skirtLabels.get(piece.id);
// Each template uses its true wall-facing and room-facing edges, including mitres.
const thickness = piece.template ? Math.max(...piece.template.map((p) => p.y)) : 0;
@@ -294,8 +333,11 @@ export function FloorPlan({
return (
@@ -330,14 +380,27 @@ export function FloorPlan({
strokeWidth={span * 0.025}
/>
)}
+ {done && !cut && (
+
+ ✓
+
+ )}
{label && (
@@ -349,12 +412,13 @@ export function FloorPlan({
fontWeight="700"
>
{piece.id}
+ {done ? ' ✓' : ''}
)}
{cut && (
- {piece.id}: {mm(piece.length)} mm · {mm(piece.leftMitre)}° /{' '}
+ {piece.id}: {cutMm(piece.length)} mm · {mm(piece.leftMitre)}° /{' '}
{mm(piece.rightMitre)}°
)}
diff --git a/src/components/OutlineEditor.tsx b/src/components/OutlineEditor.tsx
index 9c09ca0..591a5dd 100644
--- a/src/components/OutlineEditor.tsx
+++ b/src/components/OutlineEditor.tsx
@@ -1,10 +1,21 @@
-import { Plus, Trash2, DoorOpen, Check, MoveUpRight } from 'lucide-react';
+import { Fragment, useRef, useState } from 'react';
+import {
+ Plus,
+ Trash2,
+ DoorOpen,
+ Check,
+ MoveUpRight,
+ GripVertical,
+ ArrowUp,
+ ArrowDown,
+} from 'lucide-react';
import type { Geometry, Project, Room, Wall } from '../lib/types';
import { fireplace, rectangle, nextId } from '../lib/defaults';
import { normalizeAngle } from '../lib/geometry';
import { mm } from '../lib/format';
-import { NumberField, Issues } from './Fields';
+import { NumberField, NumericInput, Issues } from './Fields';
import { DoorPosition } from './DoorPosition';
+import { insertWallAfter, moveWall } from '../lib/wall-editing';
interface Props {
room: Room;
geometry: Geometry;
@@ -23,6 +34,8 @@ export function OutlineEditor({
remove,
canRemove,
}: Props) {
+ const draggedWall = useRef(null);
+ const [dropTarget, setDropTarget] = useState(null);
const changeWall = (id: string, fn: (wall: Wall) => Wall) =>
changeRoom((r) => ({ ...r, walls: r.walls.map((w) => (w.id === id ? fn(w) : w)) }));
const isRectangle =
@@ -85,7 +98,7 @@ export function OutlineEditor({
}));
};
return (
- <>
+
Room measurements
@@ -168,23 +181,39 @@ export function OutlineEditor({
{room.walls.map((wall, i) => (
-
+
{
+ if (!draggedWall.current || draggedWall.current === wall.id) return;
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+ setDropTarget(wall.id);
+ }}
+ onDragLeave={(event) => {
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null))
+ setDropTarget(null);
+ }}
+ onDrop={(event) => {
+ const id = draggedWall.current;
+ if (!id) return;
+ event.preventDefault();
+ changeRoom((r) => moveWall(r, id, i));
+ draggedWall.current = null;
+ setDropTarget(null);
+ }}
+ >
W{i + 1}
-
- changeWall(wall.id, (w) => ({ ...w, length: Number(e.target.value) }))
- }
+ onChange={(length) => changeWall(wall.id, (w) => ({ ...w, length }))}
/>
-
- changeWall(wall.id, (w) => ({ ...w, turn: Number(e.target.value) }))
- }
+ onChange={(turn) => changeWall(wall.id, (w) => ({ ...w, turn }))}
/>
-
- changeRoom((r) => ({ ...r, walls: r.walls.filter((w) => w.id !== wall.id) }))
- }
- >
-
-
+
+
{
+ draggedWall.current = wall.id;
+ event.dataTransfer.effectAllowed = 'move';
+ event.dataTransfer.setData('text/plain', wall.id);
+ }}
+ onDragEnd={() => {
+ draggedWall.current = null;
+ setDropTarget(null);
+ }}
+ >
+
+
+
changeRoom((r) => moveWall(r, wall.id, i - 1))}
+ >
+
+
+
changeRoom((r) => moveWall(r, wall.id, i + 1))}
+ >
+
+
+
= 100}
+ onClick={() => changeRoom((r) => insertWallAfter(r, wall.id))}
+ >
+
+
+
+ changeRoom((r) => ({ ...r, walls: r.walls.filter((w) => w.id !== wall.id) }))
+ }
+ >
+
+
+
))}
@@ -364,6 +437,6 @@ export function OutlineEditor({
Remove this room
)}
- >
+
);
}
diff --git a/src/components/PrintPlan.tsx b/src/components/PrintPlan.tsx
index 003b9cb..d4f5edf 100644
--- a/src/components/PrintPlan.tsx
+++ b/src/components/PrintPlan.tsx
@@ -38,6 +38,7 @@ export function PrintPlan({
Buy {flooring.boardCount} floorboards and {skirting.stockCount} skirting lengths. Install{' '}
{wholeFloor} floorboards and {wholeSkirt} skirting lengths uncut.
+ Cut each group from one board.
{project.rooms.map((room, index) => (
@@ -51,15 +52,23 @@ export function PrintPlan({
skirting={skirting}
selectedPiece={null}
interactive={false}
+ completedBoards={project.completedBoards}
/>
))}
Flooring cuts
{groupFlooringCuts(flooring).map((group) => (
-
+
- Floorboard {group[0].stockId}{' '}
+ Cuts {group.map((piece) => piece.id).join(' + ')}
+ {project.completedBoards.includes(`floor:${group[0].stockId}`)
+ ? ' · Done ✓'
+ : ''}{' '}
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm
@@ -85,9 +94,16 @@ export function PrintPlan({
Skirting cuts
{groupSkirtingCuts(skirting).map((group) => (
-
+
- Skirting board {group[0].stockId}{' '}
+ Cuts {group.map((piece) => piece.id).join(' + ')}
+ {project.completedBoards.includes(`skirting:${group[0].stockId}`)
+ ? ' · Done ✓'
+ : ''}{' '}
{mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
diff --git a/src/lib/board-tracking.ts b/src/lib/board-tracking.ts
new file mode 100644
index 0000000..148b97c
--- /dev/null
+++ b/src/lib/board-tracking.ts
@@ -0,0 +1,73 @@
+import type { FlooringPlan, Project, SkirtingPlan } from './types';
+import { flooringCuts, skirtingCuts } from './cut-list';
+
+export interface BoardMatches {
+ pieceIds: string[];
+ roomIds: string[];
+ boardKeys: string[];
+}
+
+/** Codes match exactly; ordinary text searches room names. Qualified keys identify source boards. */
+export function findBoardMatches(
+ project: Project,
+ flooring: FlooringPlan,
+ skirting: SkirtingPlan,
+ query: string,
+): BoardMatches {
+ const text = query.trim().toLowerCase().replace(/\s+/g, ' ');
+ if (!text) return { pieceIds: [], roomIds: [], boardKeys: [] };
+ const qualified = /^(floor|skirting)\s*:\s*([a-z]+\d+)$/.exec(text);
+ const codes = text.split(/\s*[+,]\s*/);
+ const isCode = codes.every((code) => /^[a-z]+\d+$/.test(code));
+ const matchingRooms = new Set(
+ project.rooms
+ .filter((room) => room.name.toLowerCase().replace(/\s+/g, ' ').includes(text))
+ .map((room) => room.id),
+ );
+ const pieceIds: string[] = [];
+ const roomIds = new Set();
+ const boardKeys = new Set();
+ const materials = [
+ { key: 'floor', pieces: flooring.pieces },
+ { key: 'skirting', pieces: skirting.pieces },
+ ];
+ for (const material of materials) {
+ for (const piece of material.pieces) {
+ const matches = qualified
+ ? material.key === qualified[1] && piece.stockId.toLowerCase() === qualified[2]
+ : isCode
+ ? codes.includes(piece.id.toLowerCase()) ||
+ (codes.length === 1 && codes.includes(piece.stockId.toLowerCase()))
+ : matchingRooms.has(piece.roomId);
+ if (!matches) continue;
+ pieceIds.push(piece.id);
+ roomIds.add(piece.roomId);
+ boardKeys.add(`${material.key}:${piece.stockId}`);
+ }
+ }
+ return {
+ pieceIds,
+ roomIds: project.rooms.filter((room) => roomIds.has(room.id)).map((room) => room.id),
+ boardKeys: [...boardKeys],
+ };
+}
+
+/** Source IDs are bookkeeping; show the cut codes when locating a whole source board. */
+export function boardSearchLabel(
+ project: Project,
+ flooring: FlooringPlan,
+ skirting: SkirtingPlan,
+ query: string,
+): string {
+ const qualified = /^(floor|skirting)\s*:\s*([a-z]+\d+)$/i.exec(query.trim());
+ if (!qualified) return query;
+ const matches = new Set(findBoardMatches(project, flooring, skirting, query).pieceIds);
+ const codes = [...flooringCuts(flooring), ...skirtingCuts(skirting)]
+ .filter((piece) => matches.has(piece.id))
+ .map((piece) => piece.id);
+ return codes.length
+ ? codes.join(' + ')
+ : qualified[1].toLowerCase() === 'floor'
+ ? 'Uncut floorboard'
+ : 'Uncut skirting board';
+}
diff --git a/src/lib/cut-preview.ts b/src/lib/cut-preview.ts
index 4c21731..bb70434 100644
--- a/src/lib/cut-preview.ts
+++ b/src/lib/cut-preview.ts
@@ -1,4 +1,5 @@
import type { FlooringPiece, Point, SkirtingPiece } from './types';
+import { cutMm, degree } from './format';
export interface PreviewBox {
x: number;
@@ -6,8 +7,6 @@ export interface PreviewBox {
width: number;
height: number;
}
-export const exactMm = (value: number) =>
- new Intl.NumberFormat('en-GB', { maximumFractionDigits: 3 }).format(value);
export const viewBox = (box: PreviewBox) => `${box.x} ${box.y} ${box.width} ${box.height}`;
export interface EdgeDimension {
@@ -73,12 +72,12 @@ export function dimensionedOutline(points: Point[], edges?: number[]) {
};
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 text = cutMm(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 angleText = angle === undefined ? '' : degree(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(
diff --git a/src/lib/defaults.ts b/src/lib/defaults.ts
index 388a328..52564c4 100644
--- a/src/lib/defaults.ts
+++ b/src/lib/defaults.ts
@@ -37,6 +37,7 @@ export function defaultProject(): Project {
name: 'My flooring project',
rooms: [rectangle()],
tolerance: 10,
+ completedBoards: [],
flooring: {
length: 1200,
width: 190,
@@ -54,7 +55,7 @@ export function defaultProject(): Project {
export function defaultState(): UrlState {
return {
project: defaultProject(),
- view: { roomId: 'room-1', selectedPiece: null },
+ view: { roomId: 'room-1', selectedPiece: null, search: '', collapsedSections: [] },
};
}
export function nextId(prefix: string, existing: string[]): string {
diff --git a/src/lib/dimension-labels.ts b/src/lib/dimension-labels.ts
index 41958dd..665989e 100644
--- a/src/lib/dimension-labels.ts
+++ b/src/lib/dimension-labels.ts
@@ -56,3 +56,19 @@ export function dimensionLabels(
return label;
});
}
+
+/** The drawing must include extension-line endpoints as well as the text itself. */
+export function wallDimensionBox(edge: WallEdge, label: DimensionLabel, span: number): LabelBox {
+ const ends = [edge.start, edge.end].flatMap((point) =>
+ [label.offset - span * 0.012, label.offset + span * 0.012].map((offset) => ({
+ x: point.x + label.normal.x * offset,
+ y: point.y + label.normal.y * offset,
+ })),
+ );
+ return {
+ minX: Math.min(label.box.minX, ...ends.map((point) => point.x)),
+ maxX: Math.max(label.box.maxX, ...ends.map((point) => point.x)),
+ minY: Math.min(label.box.minY, ...ends.map((point) => point.y)),
+ maxY: Math.max(label.box.maxY, ...ends.map((point) => point.y)),
+ };
+}
diff --git a/src/lib/export.ts b/src/lib/export.ts
deleted file mode 100644
index eb2c1e0..0000000
--- a/src/lib/export.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import type { FlooringPlan, Project, SkirtingPlan } from './types';
-import { flooringInstructions } from './cut-instructions';
-import {
- groupFlooringCuts,
- groupSkirtingCuts,
- uncutFlooring,
- uncutSkirting,
- readyOffcutInstructions,
-} from './cut-list';
-
-const cell = (value: string | number) => {
- const str = String(value);
- return `"${(/^[=+@\-]/.test(str) ? "'" : '') + str.replaceAll('"', '""')}"`;
-};
-
-export function cutListCsv(
- project: Project,
- flooring: FlooringPlan,
- skirting: SkirtingPlan,
-): string {
- const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
- const floorCuts = groupFlooringCuts(flooring).flat(),
- skirtCuts = groupSkirtingCuts(skirting).flat();
- const wholeFloor = uncutFlooring(flooring, project.flooring);
- const rows: (string | number)[][] = [
- [
- 'Material',
- 'Room',
- 'Piece',
- 'Stock',
- 'Length mm',
- 'Width mm',
- 'Left mitre deg',
- 'Right mitre deg',
- 'Quantity',
- 'Notes',
- 'Template x:y mm',
- ],
- [
- 'Flooring total',
- '',
- '',
- '',
- project.flooring.length,
- project.flooring.width,
- '',
- '',
- flooring.boardCount,
- `Boards to buy; ${flooring.pieces.length} installed pieces; ${flooring.cuts} saw cuts`,
- '',
- ],
- [
- 'Flooring uncut boards',
- '',
- '',
- '',
- project.flooring.length,
- project.flooring.width,
- '',
- '',
- wholeFloor.wholeBoards,
- 'Full boards laid without cuts',
- '',
- ],
- [
- 'Skirting total',
- '',
- '',
- '',
- '',
- project.skirting.thickness,
- '',
- '',
- skirting.stockCount,
- `Lengths to buy; ${skirting.pieces.length} installed pieces; ${skirting.cuts} saw cuts`,
- '',
- ],
- ];
- if (wholeFloor.readyOffcuts)
- rows.push([
- 'Flooring ready offcuts',
- '',
- '',
- '',
- '',
- '',
- '',
- '',
- wholeFloor.readyOffcuts,
- 'Already sized; no further cuts',
- '',
- ]);
- for (const group of uncutSkirting(skirting)) {
- rows.push([
- 'Skirting uncut lengths',
- '',
- '',
- '',
- group.length,
- project.skirting.thickness,
- '',
- '',
- group.count,
- 'Full stock lengths installed without cuts',
- '',
- ]);
- }
- for (const piece of floorCuts) {
- rows.push([
- 'Flooring',
- roomName(piece.roomId),
- piece.id,
- piece.stockId,
- piece.length,
- piece.width,
- '',
- '',
- 1,
- `${piece.reused ? 'Reused offcut; ' : ''}${piece.cut ? `${piece.shaped ? 'Shaped cut' : 'Straight cut'}; ${flooringInstructions(piece)}` : readyOffcutInstructions(piece)}`,
- piece.template.map((point) => `${point.x.toFixed(1)}:${point.y.toFixed(1)}`).join(' | '),
- ]);
- }
- for (const piece of skirtCuts) {
- rows.push([
- 'Skirting',
- roomName(piece.roomId),
- piece.id,
- piece.stockId,
- piece.length,
- project.skirting.thickness,
- piece.leftMitre,
- piece.rightMitre,
- 1,
- `Back length ${piece.backLength.toFixed(1)} mm; left ${piece.leftType}; right ${piece.rightType}; signed front-to-back bevels ${piece.leftBevel ?? piece.leftMitre} / ${piece.rightBevel ?? piece.rightMitre} deg; template back y=0, front y=thickness`,
- piece.template?.map((point) => `${point.x.toFixed(1)}:${point.y.toFixed(1)}`).join(' | ') ??
- '',
- ]);
- }
- return '\uFEFF' + rows.map((row) => row.map(cell).join(',')).join('\r\n');
-}
-
-export function downloadCsv(project: Project, flooring: FlooringPlan, skirting: SkirtingPlan) {
- const url = URL.createObjectURL(
- new Blob([cutListCsv(project, flooring, skirting)], { type: 'text/csv;charset=utf-8;' }),
- );
- const link = document.createElement('a');
- link.href = url;
- link.download = 'flooring-cut-plan.csv';
- link.click();
- setTimeout(() => URL.revokeObjectURL(url), 1000);
-}
diff --git a/src/lib/flooring.ts b/src/lib/flooring.ts
index 95ba1d9..c7ad9eb 100644
--- a/src/lib/flooring.ts
+++ b/src/lib/flooring.ts
@@ -112,7 +112,7 @@ function intersect(paths: Point[][], rectangle: Bounds): Point[][] {
}
/** Only perimeter rows may be ripped: interior rows must retain both factory long edges. */
-function rowWidthOptions(height: number, s: FlooringSettings): number[][] {
+function rowWidthOptions(height: number, s: FlooringSettings, cornerOffsets: number[] = []): number[][] {
const count = Math.ceil((height - EPSILON) / s.width);
if (count <= 0 || count > 500) return [];
if (count === 1) return height + EPSILON >= s.minWidth ? [[height]] : [];
@@ -139,9 +139,23 @@ function rowWidthOptions(height: number, s: FlooringSettings): number[][] {
add(last);
add(edgeTotal / 2);
}
+ // Place a long-edge seam on an inward corner when both perimeter rows still fit.
+ // This avoids thin toes made solely by a row crossing just beyond that corner.
+ for (const offset of cornerOffsets.slice(0, 8)) {
+ const first = phaseOf(offset, s.width);
+ add(close(first, 0) ? s.width : first);
+ }
return options;
}
+function inwardCorners(paths: Point[][]): Point[] {
+ return paths.flatMap(path => path.filter((point, index) => {
+ const previous = path[(index + path.length - 1) % path.length];
+ const next = path[(index + 1) % path.length];
+ return (point.x - previous.x) * (next.y - point.y) - (point.y - previous.y) * (next.x - point.x) < -EPSILON;
+ }));
+}
+
function hasVerticalEdge(path: Point[], x: number): boolean {
return path.some(
(p, i) =>
@@ -497,7 +511,8 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
continue;
}
const roomBounds = bounds(paths.flat());
- const widthOptions = rowWidthOptions(roomBounds.maxY - roomBounds.minY, s);
+ const widthOptions = rowWidthOptions(roomBounds.maxY - roomBounds.minY, s,
+ inwardCorners(paths).map(point => point.y - roomBounds.minY));
const estimatedBoards = paths.reduce((sum, path) => sum + area(path), 0) / (s.length * s.width);
if (estimatedBoards > MAX_BOARDS || !widthOptions.length) {
issues.push({
diff --git a/src/lib/format.ts b/src/lib/format.ts
index bac59b6..b712bc4 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -2,6 +2,9 @@ export const mm = (value: number) =>
Number.isFinite(value)
? new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1 }).format(value)
: '—';
+/** Round cutting marks up without letting floating-point noise add a half millimetre. */
+export const cutLength = (value: number) => Math.ceil(value * 2 - 1e-9) / 2 + 0;
+export const cutMm = (value: number) => mm(cutLength(value));
export const area = (value: number) =>
new Intl.NumberFormat('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
value / 1e6,
diff --git a/src/lib/skirting.ts b/src/lib/skirting.ts
index 62b9440..35edf41 100644
--- a/src/lib/skirting.ts
+++ b/src/lib/skirting.ts
@@ -1,5 +1,6 @@
import { buildGeometry, normalizeAngle, signedArea } from './geometry';
import { isSkirtingCut } from './cut-list';
+import { cutLength } from './format';
import type {
Issue,
Point,
@@ -86,14 +87,19 @@ function endCuts(piece: SkirtingPiece) {
return Number(Math.abs(piece.leftMitre) > EPS) + Number(Math.abs(piece.rightMitre) > EPS);
}
+function blankLength(piece: SkirtingPiece, available: number) {
+ return Math.abs(available - piece.length) <= EPS ? available : cutLength(piece.length);
+}
+
function requiredCuts(piece: SkirtingPiece, available: number) {
- return endCuts(piece) + Number(Math.abs(available - piece.length) > EPS);
+ return endCuts(piece) + Number(Math.abs(available - blankLength(piece, available)) > EPS);
}
function consumedLength(piece: SkirtingPiece, available: number, kerf: number) {
// Cut a square blank, then bevel within its longest-point envelope. This leaves
// a square reusable offcut. Bevel kerfs remove material inside the blank.
- return Math.min(available, piece.length + (Math.abs(available - piece.length) <= EPS ? 0 : kerf));
+ const length = blankLength(piece, available);
+ return Math.min(available, length + (Math.abs(available - length) <= EPS ? 0 : kerf));
}
function makePiece(
@@ -141,7 +147,7 @@ function shortestEdge(piece: SkirtingPiece) {
function fits(piece: SkirtingPiece, available: number) {
// The final saw pass may extend beyond the stock's tail.
- return piece.length <= available + EPS;
+ return blankLength(piece, available) <= available + EPS;
}
/** Zero means that no number of pieces can satisfy the end allowances. */
diff --git a/src/lib/types.ts b/src/lib/types.ts
index c7b5344..1fbd8a6 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -44,10 +44,15 @@ export interface Project {
flooring: FlooringSettings;
skirting: SkirtingSettings;
tolerance: number;
+ /** Explicit progress marks for purchased boards, e.g. floor:B4 or skirting:S6. */
+ completedBoards: string[];
}
+export type PlannerSection = 'measurements' | 'materials' | 'cuts';
export interface ViewState {
roomId: string;
selectedPiece: string | null;
+ search: string;
+ collapsedSections: PlannerSection[];
}
export interface UrlState {
project: Project;
diff --git a/src/lib/url-state.ts b/src/lib/url-state.ts
index 6b9105c..8ad9f8f 100644
--- a/src/lib/url-state.ts
+++ b/src/lib/url-state.ts
@@ -1,6 +1,14 @@
import LZString from 'lz-string';
import { defaultState } from './defaults';
-import type { Door, FlooringSettings, Room, SkirtingSettings, UrlState, Wall } from './types';
+import type {
+ Door,
+ FlooringSettings,
+ PlannerSection,
+ Room,
+ SkirtingSettings,
+ UrlState,
+ Wall,
+} from './types';
/** Resource limits apply to links as well as edits made inside the app. */
export const URL_LIMITS = {
@@ -13,8 +21,12 @@ export const URL_LIMITS = {
stockLengths: 30,
identifierLength: 120,
nameLength: 200,
+ completedBoards: 12_000,
+ searchLength: 200,
} as const;
+const SECTIONS = ['measurements', 'materials', 'cuts'] as const;
+
function object(value: unknown, field: string): Record {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${field} must be an object.`);
@@ -71,6 +83,34 @@ function uniqueIds(items: T[], field: string): T[] {
return items;
}
+function uniqueValues(items: T[], field: string): T[] {
+ if (new Set(items).size !== items.length) throw new Error(`${field} contains duplicate entries.`);
+ return items.sort();
+}
+
+function completedBoards(value: unknown): string[] {
+ const entries = array(
+ value === undefined ? [] : value,
+ 'Completed boards',
+ URL_LIMITS.completedBoards,
+ ).map((value) => {
+ const key = string(value, 'Completed board');
+ if (!/^(?:floor:B|skirting:S)[1-9]\d*$/.test(key))
+ throw new Error('Completed boards must use floor:B1 or skirting:S1 identifiers.');
+ return key;
+ });
+ return uniqueValues(entries, 'Completed boards');
+}
+
+function collapsedSections(value: unknown): PlannerSection[] {
+ return uniqueValues(
+ array(value === undefined ? [] : value, 'Collapsed sections', SECTIONS.length).map((value) =>
+ choice(value, SECTIONS, 'Collapsed section'),
+ ),
+ 'Collapsed sections',
+ );
+}
+
function door(value: unknown): Door {
const data = object(value, 'Door');
return {
@@ -151,11 +191,18 @@ function parseState(value: unknown): UrlState {
flooring: flooring(project.flooring),
skirting: skirting(project.skirting),
tolerance: number(project.tolerance, 'Measurement tolerance'),
+ completedBoards: completedBoards(project.completedBoards),
},
view: {
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
selectedPiece:
view.selectedPiece === null ? null : string(view.selectedPiece, 'Selected piece'),
+ search: string(
+ view.search === undefined ? '' : view.search,
+ 'Cut search',
+ URL_LIMITS.searchLength,
+ ),
+ collapsedSections: collapsedSections(view.collapsedSections),
},
};
}
@@ -193,9 +240,12 @@ export function encodeState(state: UrlState): string {
});
});
});
+ for (const board of project.completedBoards) put('done', board);
put('selectedRoom', view.roomId);
if (view.selectedPiece !== null) put('selectedCut', view.selectedPiece);
- const query = `?${params}`;
+ if (view.search) put('search', view.search);
+ for (const section of view.collapsedSections) put('collapsed', section);
+ const query = `?${params}`.replaceAll('%3A', ':');
if (query.length > URL_LIMITS.queryLength)
throw new Error('This plan link is too large. Split it into smaller projects.');
return query;
@@ -289,6 +339,7 @@ function parseQuery(query: string): UrlState {
version: numeric('version', 1),
name: text('name', defaults.project.name),
tolerance: numeric('tolerance', defaults.project.tolerance),
+ completedBoards: params.getAll('done'),
rooms,
flooring: Object.fromEntries(
Object.entries(floor).map(([key, value]) => [
@@ -308,6 +359,8 @@ function parseQuery(query: string): UrlState {
view: {
roomId: text('selectedRoom', rooms[0].id),
selectedPiece: params.has('selectedCut') ? text('selectedCut') : null,
+ search: text('search', ''),
+ collapsedSections: params.getAll('collapsed'),
},
});
}
diff --git a/src/lib/use-project.ts b/src/lib/use-project.ts
index 01322ba..9ba08fc 100644
--- a/src/lib/use-project.ts
+++ b/src/lib/use-project.ts
@@ -41,6 +41,15 @@ export function useProject() {
window.history[milestone ? 'pushState' : 'replaceState'](null, '', queryUrl(query));
const canonical = decodeState(query).state;
if (next.project === current.state.project) canonical.project = current.state.project;
+ else {
+ // Progress changes should not rerun geometry and stock allocation.
+ if (next.project.rooms === current.state.project.rooms)
+ canonical.project.rooms = current.state.project.rooms;
+ if (next.project.flooring === current.state.project.flooring)
+ canonical.project.flooring = current.state.project.flooring;
+ if (next.project.skirting === current.state.project.skirting)
+ canonical.project.skirting = current.state.project.skirting;
+ }
return { state: canonical, error: null };
} catch (error) {
return {
diff --git a/src/lib/wall-editing.ts b/src/lib/wall-editing.ts
new file mode 100644
index 0000000..913def0
--- /dev/null
+++ b/src/lib/wall-editing.ts
@@ -0,0 +1,33 @@
+import { nextId } from './defaults';
+import type { Room } from './types';
+
+/** Inserting a segment never renumbers walls or moves their attached doors. */
+export function insertWallAfter(room: Room, wallId: string): Room {
+ const index = room.walls.findIndex((wall) => wall.id === wallId);
+ if (index < 0 || room.walls.length >= 100) return room;
+ const wall = {
+ id: nextId(
+ `${room.id}-w`,
+ room.walls.map((wall) => wall.id),
+ ),
+ length: 1000,
+ turn: 90,
+ doors: [],
+ };
+ return {
+ ...room,
+ walls: [...room.walls.slice(0, index + 1), wall, ...room.walls.slice(index + 1)],
+ };
+}
+
+/** The destination is the wall's final zero-based position, after removing it from the list. */
+export function moveWall(room: Room, wallId: string, destination: number): Room {
+ const index = room.walls.findIndex((wall) => wall.id === wallId);
+ if (index < 0 || !Number.isFinite(destination)) return room;
+ const target = Math.max(0, Math.min(room.walls.length - 1, Math.trunc(destination)));
+ if (target === index) return room;
+ const walls = [...room.walls];
+ const [wall] = walls.splice(index, 1);
+ walls.splice(target, 0, wall);
+ return { ...room, walls };
+}
diff --git a/src/styles.css b/src/styles.css
index c6f7e5d..9bffea7 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -204,29 +204,10 @@ small {
font: 12px var(--mono);
white-space: nowrap;
}
-.page-jumps {
- display: none;
-}
-.workspace {
- display: grid;
- grid-template-columns: minmax(570px, 1fr) minmax(450px, 1fr);
- align-items: start;
- gap: 30px;
-}
-.input-columns {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
- gap: 25px;
- min-width: 0;
-}
.editor-panel {
min-width: 0;
scroll-margin-top: 65px;
}
-.materials-editor {
- border-left: 1px solid var(--line);
- padding-left: 25px;
-}
.editor-heading {
min-height: 64px;
margin-bottom: 14px;
@@ -591,11 +572,6 @@ summary span {
.stock-input + .text-button {
margin-bottom: 10px;
}
-.drawing-column {
- min-width: 0;
- position: static;
- scroll-margin-top: 65px;
-}
.drawing-card {
border: 1px solid var(--ink);
background: white;
@@ -846,10 +822,6 @@ summary span {
padding-left: max(32px, calc((100vw - 1656px) / 2));
padding-right: max(32px, calc((100vw - 1656px) / 2));
}
- .workspace {
- grid-template-columns: minmax(680px, 1fr) minmax(0, 1fr);
- gap: 35px;
- }
}
@media (max-width: 1199px) {
.app-main {
@@ -858,29 +830,10 @@ summary span {
.app-header {
padding: 20px 24px;
}
- .workspace {
- grid-template-columns: 1fr;
- gap: 28px;
- }
- .input-columns {
- gap: 30px;
- }
- .materials-editor {
- padding-left: 30px;
- }
- .drawing-column {
- position: static;
- display: grid;
- grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
- gap: 25px;
- }
.purchase-summary {
align-self: start;
margin-top: 0;
}
- .drawing-column > .issues {
- grid-column: 1/-1;
- }
.cut-list-card {
grid-template-columns: 1fr;
gap: 22px;
@@ -947,41 +900,6 @@ summary span {
padding: 9px;
font-size: 12px;
}
- .page-jumps {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 0;
- position: sticky;
- top: 0;
- z-index: 5;
- background: #fffdf7;
- padding: 6px 0;
- margin-bottom: 16px;
- border-bottom: 1px solid var(--ink);
- }
- .page-jumps button {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 5px;
- font-size: 12px;
- border: 0;
- background: transparent;
- }
- .page-jumps button + button {
- border-left: 1px solid var(--line);
- }
- .workspace,
- .input-columns,
- .drawing-column {
- display: grid;
- grid-template-columns: minmax(0, 1fr);
- gap: 28px;
- }
- .materials-editor {
- border-left: 0;
- padding-left: 0;
- }
.editor-heading {
min-height: 58px;
margin-bottom: 15px;
@@ -1248,6 +1166,7 @@ summary span {
}
.stock-cut-group h4 {
display: flex;
+ flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 12px;
@@ -1262,18 +1181,39 @@ summary span {
text-align: right;
}
.board-cuts {
- padding: 0 16px;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: stretch;
+ gap: 12px;
+ padding: 12px 16px;
margin: 0;
list-style: none;
}
.board-cut {
+ flex: 1 1 170px;
+ min-width: 0;
display: flex;
- align-items: center;
- gap: 16px;
- padding: 12px 0;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 12px;
+ border: 1px solid #e0e4e9;
}
-.board-cut + .board-cut {
- border-top: 1px solid #e0e4e9;
+.board-cut:only-child {
+ flex-direction: row;
+ align-items: center;
+}
+@media (max-width: 699px) {
+ .board-cuts {
+ display: block;
+ }
+ .board-cut + .board-cut {
+ margin-top: 12px;
+ }
+ .board-cut {
+ flex-direction: row;
+ align-items: center;
+ }
}
.cut-size {
font-size: 14px;
@@ -1386,9 +1326,13 @@ summary span {
color: #17263f;
}
.app-header,
- .app-main {
+ .app-main,
+ .cut-dialog {
display: none !important;
}
+ .cut-dialog::backdrop {
+ display: none;
+ }
.print-plan {
display: block;
}
@@ -1504,3 +1448,332 @@ summary span {
-webkit-print-color-adjust: exact;
}
}
+.workspace {
+ display: grid;
+ grid-template-columns: minmax(370px, 0.85fr) minmax(0, 1.15fr);
+ gap: 28px;
+ align-items: start;
+}
+.planning-panels {
+ min-width: 0;
+}
+.planner-panel {
+ border-top: 2px solid var(--ink);
+ margin-bottom: 20px;
+}
+.panel-toggle-heading {
+ margin: 0;
+}
+.panel-toggle {
+ width: 100%;
+ min-height: 56px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 0;
+ border: 0;
+ font-size: 19px;
+ font-weight: 700;
+ text-align: left;
+ background: transparent;
+}
+.panel-toggle > span:last-child {
+ font-size: 25px;
+ font-weight: 400;
+}
+.panel-content {
+ padding-bottom: 12px;
+}
+.panel-content[hidden] {
+ display: none;
+}
+.planner-panel .editor-heading {
+ display: none;
+}
+.planner-panel .materials-editor {
+ border: 0;
+ padding: 0;
+}
+.planner-panel .cut-list-card {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 20px;
+}
+.planner-panel .cut-list-header {
+ border: 0;
+ padding: 0;
+ align-items: start;
+ gap: 12px;
+}
+.planner-panel .cut-list-header h2 {
+ display: none;
+}
+.planner-panel .cut-list-header .helper {
+ font-size: 12px;
+}
+.planner-panel #cut-plan {
+ margin: 0;
+}
+.planner-panel .purchase-summary {
+ margin-top: 20px;
+}
+.drawing-column {
+ position: sticky;
+ top: 16px;
+ min-width: 0;
+ align-self: start;
+ background: #fffdf7;
+}
+.drawing-column .drawing-viewport {
+ height: clamp(180px, calc(100dvh - 330px), 780px);
+}
+.drawing-column:has(.plan-search-status) .drawing-viewport {
+ height: clamp(160px, calc(100dvh - 390px), 720px);
+}
+.drawing-column .room-svg {
+ width: 100%;
+ height: 100%;
+}
+.drawing-column .drawing-header h2 small {
+ display: block;
+ margin: 4px 0 0;
+}
+.plan-search-status {
+ padding: 10px 12px;
+ border: 1px solid var(--ink);
+ border-top: 0;
+ background: white;
+ font-size: 12px;
+}
+.matching-rooms {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 7px;
+}
+.matching-rooms button {
+ padding: 6px 10px;
+ font-size: 12px;
+}
+.matching-rooms button[aria-pressed='true'] {
+ background: var(--ink);
+ color: white;
+}
+.search-muted {
+ opacity: 0.25;
+}
+.is-highlighted > polygon {
+ fill: #ffe69b;
+ stroke: #17263f;
+ stroke-width: 3px;
+ vector-effect: non-scaling-stroke;
+}
+.is-highlighted.is-done > polygon {
+ fill: #c4e6df;
+}
+.is-done .cut-preview-code {
+ color: #20594f;
+}
+.is-done .cut-preview-button {
+ background: #eaf4f1;
+ border-color: #27655d;
+}
+.is-done .cut-location {
+ color: #345c55;
+}
+@media (max-width: 899px) {
+ .app-main {
+ padding-inline: 16px;
+ }
+ .workspace {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ }
+ .planning-panels {
+ width: 100%;
+ }
+ .drawing-column {
+ order: -1;
+ width: 100%;
+ top: 0;
+ z-index: 5;
+ }
+ .drawing-column .drawing-viewport,
+ .drawing-column:has(.plan-search-status) .drawing-viewport {
+ height: clamp(150px, 25dvh, 240px);
+ }
+ .drawing-column .drawing-header {
+ padding: 8px 12px;
+ }
+ .drawing-column .drawing-header h2 {
+ font-size: 14px;
+ }
+ .drawing-column .drawing-header h2 small {
+ display: inline;
+ font-size: 10px;
+ margin-left: 8px;
+ }
+ .drawing-column .drawing-legend {
+ padding: 0 12px 7px;
+ font-size: 10px;
+ gap: 12px;
+ }
+ .drawing-column .drawing-help {
+ display: none;
+ }
+ .plan-search-status {
+ padding: 7px 10px;
+ font-size: 11px;
+ }
+ .panel-toggle {
+ font-size: 18px;
+ min-height: 50px;
+ }
+ .planner-panel {
+ scroll-margin-top: 330px;
+ }
+ .board-cuts {
+ padding-inline: 12px;
+ }
+ .board-cut {
+ gap: 12px;
+ }
+ input,
+ select,
+ button,
+ summary {
+ scroll-margin-top: 350px;
+ scroll-margin-bottom: 24px;
+ }
+}
+@media print {
+ .drawing-column {
+ position: static;
+ }
+}
+.wall-head,
+.wall-row {
+ grid-template-columns: 32px minmax(0, 1fr) minmax(0, 0.8fr);
+}
+.wall-head > span:last-child {
+ display: none;
+}
+.wall-row {
+ padding-bottom: 7px;
+ border-bottom: 1px solid #dde2e9;
+}
+.wall-actions {
+ grid-column: 2 / -1;
+ display: flex;
+ justify-content: flex-end;
+ gap: 4px;
+}
+.wall-actions .icon-button {
+ width: 40px;
+ min-width: 40px;
+ min-height: 40px;
+}
+.wall-drag-handle {
+ cursor: grab;
+}
+.wall-drag-handle:active {
+ cursor: grabbing;
+}
+.wall-row.drag-over {
+ border-top: 3px solid var(--blue);
+ background: #e9f0fc;
+}
+.wall-row input {
+ font-variant-numeric: tabular-nums;
+}
+.cut-search {
+ display: grid;
+ gap: 7px;
+}
+.cut-search label {
+ font-size: 13px;
+ font-weight: 600;
+}
+.cut-search .search-results {
+ font-size: 12px;
+ color: #4b5c73;
+}
+.board-group-header {
+ background: #f1f3f6;
+}
+.board-group-header h4 {
+ padding-bottom: 5px;
+}
+.board-group-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 0 12px 8px;
+}
+.board-done-control {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ min-height: 44px;
+ cursor: pointer;
+ font-size: 12px;
+ white-space: nowrap;
+}
+.board-done-control input {
+ width: 22px;
+ min-width: 22px;
+ height: 22px;
+ min-height: 22px;
+ padding: 0;
+ margin: 0;
+ accent-color: #27655d;
+}
+.board-locate {
+ font-size: 12px;
+ padding: 6px 8px;
+}
+.cut-locate {
+ min-height: 40px;
+ padding: 6px 0;
+ border: 0;
+ font-size: 12px;
+ background: transparent;
+ color: var(--blue);
+}
+.board-cut-info {
+ min-width: 0;
+}
+.board-cut.search-match {
+ box-shadow: inset 3px 0 #17263f;
+ padding-left: 10px;
+}
+.stock-cut-group.is-done .board-group-header {
+ background: #e4efeb;
+}
+.stock-cut-group.is-done h4 {
+ background: transparent;
+}
+.uncut-board-note {
+ padding: 12px 16px;
+ font-size: 13px;
+}
+.cut-search-control {
+ display: flex;
+ gap: 8px;
+}
+.cut-search-control input {
+ flex: 1;
+ min-width: 0;
+}
+.clear-search {
+ flex-shrink: 0;
+ padding-inline: 12px;
+}
+.completion-mark {
+ pointer-events: none;
+ paint-order: stroke;
+ stroke: white;
+ stroke-width: 2px;
+ vector-effect: non-scaling-stroke;
+}
diff --git a/tests/board-tracking.test.ts b/tests/board-tracking.test.ts
new file mode 100644
index 0000000..e287fe5
--- /dev/null
+++ b/tests/board-tracking.test.ts
@@ -0,0 +1,200 @@
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { describe, expect, it } from 'vitest';
+import { defaultProject, rectangle } from '../src/lib/defaults';
+import { boardSearchLabel, findBoardMatches } from '../src/lib/board-tracking';
+import { CutList } from '../src/components/CutList';
+import type { FlooringPiece, FlooringPlan, SkirtingPiece, SkirtingPlan } from '../src/lib/types';
+
+const project = defaultProject();
+project.rooms.push(rectangle('room-2', 'Office'));
+function floorPiece(id: string, stockId: string, roomId = 'room-1', cut = true): FlooringPiece {
+ return {
+ id,
+ stockId,
+ roomId,
+ cut,
+ length: cut ? 400 : 1200,
+ width: 190,
+ row: 1,
+ column: 1,
+ polygon: [],
+ template: [],
+ shaped: false,
+ reused: false,
+ };
+}
+const flooring: FlooringPlan = {
+ pieces: [
+ floorPiece('F1', 'B1'),
+ floorPiece('F12', 'B1', 'room-2'),
+ floorPiece('F10', 'B10'),
+ floorPiece('FU1', 'B3', 'room-2', false),
+ ],
+ rows: [],
+ stocks: [
+ { id: 'B1', pieceIds: ['F1', 'F12'], remaining: 394 },
+ { id: 'B10', pieceIds: ['F10'], remaining: 797 },
+ { id: 'B3', pieceIds: ['FU1'], remaining: 0 },
+ ],
+ boardCount: 3,
+ cuts: 3,
+ wasteArea: 0,
+ wastePercent: 0,
+ reusedCount: 0,
+ coveredArea: 0,
+ issues: [],
+};
+function skirtPiece(id: string, stockId: string, roomId = 'room-1', length = 900): SkirtingPiece {
+ return {
+ id,
+ stockId,
+ roomId,
+ length,
+ backLength: length,
+ wallId: 'w1',
+ wallIndex: 0,
+ start: { x: 0, y: 0 },
+ end: { x: length, y: 0 },
+ leftMitre: 0,
+ rightMitre: 0,
+ leftType: 'square',
+ rightType: 'square',
+ };
+}
+const skirting: SkirtingPlan = {
+ pieces: [
+ skirtPiece('A2', 'S6'),
+ skirtPiece('B1', 'S6', 'room-2', 1497),
+ skirtPiece('B10', 'S60'),
+ skirtPiece('SU1', 'S7', 'room-2', 2400),
+ ],
+ stocks: [
+ { id: 'S6', length: 2400, used: 2397, waste: 3, pieceIds: ['A2', 'B1'] },
+ { id: 'S60', length: 2400, used: 900, waste: 1500, pieceIds: ['B10'] },
+ { id: 'S7', length: 2400, used: 2400, waste: 0, pieceIds: ['SU1'] },
+ ],
+ stockCount: 3,
+ cuts: 3,
+ waste: 1503,
+ issues: [],
+};
+const find = (query: string) => findBoardMatches(project, flooring, skirting, query);
+const noop = () => {};
+function list(search = '', completedBoards: string[] = []) {
+ return renderToStaticMarkup(
+ createElement(CutList, {
+ project,
+ flooring,
+ skirting,
+ select: noop,
+ search,
+ setSearch: noop,
+ completedBoards,
+ toggleBoard: noop,
+ locateBoard: noop,
+ locatePiece: noop,
+ }),
+ );
+}
+
+describe('board tracking search', () => {
+ it('matches cut and stock codes exactly instead of confusing F1 with F10/F12 or B1 with B10', () => {
+ expect(find(' F1 ')).toEqual({
+ pieceIds: ['F1'],
+ roomIds: ['room-1'],
+ boardKeys: ['floor:B1'],
+ });
+ expect(find('b1')).toEqual({
+ pieceIds: ['F1', 'F12', 'B1'],
+ roomIds: ['room-1', 'room-2'],
+ boardKeys: ['floor:B1', 'skirting:S6'],
+ });
+ expect(find('S6').pieceIds).toEqual(['A2', 'B1']);
+ expect(find('B10').pieceIds).toEqual(['F10', 'B10']);
+ });
+
+ it('uses material-qualified source keys for unambiguous board location and includes all its rooms', () => {
+ expect(find(' FLOOR:B1 ')).toEqual({
+ pieceIds: ['F1', 'F12'],
+ roomIds: ['room-1', 'room-2'],
+ boardKeys: ['floor:B1'],
+ });
+ expect(find('skirting:S6')).toEqual({
+ pieceIds: ['A2', 'B1'],
+ roomIds: ['room-1', 'room-2'],
+ boardKeys: ['skirting:S6'],
+ });
+ });
+
+ it('finds full uncut boards as well as cuts, and searches room names without case sensitivity', () => {
+ expect(find('b3')).toEqual({ pieceIds: ['FU1'], roomIds: ['room-2'], boardKeys: ['floor:B3'] });
+ expect(find('s7')).toEqual({
+ pieceIds: ['SU1'],
+ roomIds: ['room-2'],
+ boardKeys: ['skirting:S7'],
+ });
+ expect(find(' OfFiCe ')).toEqual({
+ pieceIds: ['F12', 'FU1', 'B1', 'SU1'],
+ roomIds: ['room-2'],
+ boardKeys: ['floor:B1', 'floor:B3', 'skirting:S6', 'skirting:S7'],
+ });
+ expect(find('living').roomIds).toEqual(['room-1']);
+ });
+
+ it('returns empty matches for empty or unknown queries and never mutates the plans', () => {
+ const before = structuredClone({ project, flooring, skirting });
+ expect(find(' ')).toEqual({ pieceIds: [], roomIds: [], boardKeys: [] });
+ expect(find('missing')).toEqual({ pieceIds: [], roomIds: [], boardKeys: [] });
+ expect(find('B1')).toEqual(find('B1'));
+ expect({ project, flooring, skirting }).toEqual(before);
+ });
+
+ it('keeps all cuts from a matching source together and applies completion to the whole group', () => {
+ const markup = list('F1', ['floor:B1']);
+ expect(markup).toContain('data-board-key="floor:B1"');
+ expect(markup).not.toContain('data-board-key="floor:B10"');
+ expect(markup).not.toContain('data-board-key="skirting:S6"');
+ expect(markup).toContain('Preview flooring cut F1');
+ expect(markup).toContain('Preview flooring cut F12');
+ expect(markup.match(/class="board-cut is-done/g)).toHaveLength(2);
+ expect(markup).toContain('aria-label="Mark cuts F1 + F12 done" checked=""');
+ expect(markup).toContain('Show cuts F1 + F12 on plan');
+ expect(markup).toContain('Show cut F12 on plan');
+ expect(markup).toContain('Done ');
+ });
+
+ it('names board groups and actions using their cuts while keeping bookkeeping IDs internal', () => {
+ const markup = list();
+ expect(markup).toContain('aria-label="Flooring cuts F1 + F12"');
+ expect(markup).toContain('aria-label="Skirting cuts A2 + B1"');
+ expect(markup).toContain('Mark cuts A2 + B1 done');
+ expect(markup).toContain('data-board-key="skirting:S6"');
+ expect(markup).toContain('Find a cut or room');
+ expect(markup).not.toMatch(/(?:Floorboard B\d|Skirting board S\d|Export CSV)/i);
+ expect(markup).not.toMatch(/aria-label="[^"]*(?:floor:B\d|skirting:S\d)/i);
+ });
+
+ it('shows cut names when a located board is stored as a qualified source key', () => {
+ expect(boardSearchLabel(project, flooring, skirting, 'floor:B1')).toBe('F1 + F12');
+ expect(boardSearchLabel(project, flooring, skirting, 'skirting:S6')).toBe('A2 + B1');
+ expect(boardSearchLabel(project, flooring, skirting, 'Office')).toBe('Office');
+ expect(list('floor:B1')).toContain('value="F1 + F12"');
+ expect(list('skirting:S6')).toContain('value="A2 + B1"');
+ expect(find('F1 + F12')).toEqual(find('floor:B1'));
+ // B1 here is a skirting cut, not the hidden flooring stock with the same internal code.
+ expect(find('A2 + B1')).toEqual(find('skirting:S6'));
+ });
+
+ it('exposes an interactive full-board result only when searched, without inventing a cut preview', () => {
+ expect(list()).not.toContain('data-board-key="floor:B3"');
+ const markup = list('floor:B3');
+ expect(markup).toContain('data-board-key="floor:B3"');
+ expect(markup).toContain('Mark uncut floorboard done');
+ expect(markup).toContain('Show uncut floorboard on plan');
+ expect(markup).toContain('Installed uncut');
+ expect(markup).toContain('value="Uncut floorboard"');
+ expect(markup).not.toContain('Preview flooring cut FU1');
+ expect(list('skirting:S7')).toContain('value="Uncut skirting board"');
+ });
+});
diff --git a/tests/cut-preview.test.ts b/tests/cut-preview.test.ts
index 4561f00..ccd8b5c 100644
--- a/tests/cut-preview.test.ts
+++ b/tests/cut-preview.test.ts
@@ -4,12 +4,8 @@ import { describe, expect, it } from 'vitest';
import { FlooringPreview, SkirtingPreview } from '../src/components/CutPreview';
import { CutDialog } from '../src/components/CutDialog';
import { defaultProject } from '../src/lib/defaults';
-import {
- dimensionedOutline,
- exactMm,
- flooringTemplate,
- skirtingEndBoxes,
-} from '../src/lib/cut-preview';
+import { dimensionedOutline, flooringTemplate, skirtingEndBoxes } from '../src/lib/cut-preview';
+import { cutMm, degree } from '../src/lib/format';
import type { FlooringPiece, Point, SkirtingPiece } from '../src/lib/types';
const floor: FlooringPiece = {
@@ -50,7 +46,7 @@ const skirt: SkirtingPiece = {
};
describe('cut shape previews', () => {
- it('draws rectangular floor cuts too, keeping their precise measured dimensions', () => {
+ it('keeps precise geometry while rounding displayed cuts up to half millimetres', () => {
const points = flooringTemplate(floor);
expect(points).toEqual([
{ x: 0, y: 0 },
@@ -61,11 +57,11 @@ describe('cut shape previews', () => {
const markup = renderToStaticMarkup(createElement(FlooringPreview, { piece: floor }));
expect(markup).toContain('aria-label="Flooring shape F1"');
expect(markup).toContain('preserveAspectRatio="xMidYMid meet"');
- expect(markup).toContain('600.001 × 190 mm');
- expect(markup).toContain('>600.001');
+ expect(markup).toContain('600.5 × 190 mm');
+ expect(markup).toContain('>600.5');
expect(markup).toContain('>190');
expect(markup.match(/class="dimension-label"/g)).toHaveLength(2);
- expect(exactMm(600.001)).toBe('600.001');
+ expect(cutMm(600.001)).toBe('600.5');
});
it('keeps skirting endpoints in original coordinates and gives both ends equal square windows', () => {
@@ -166,9 +162,9 @@ describe('cut shape previews', () => {
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);
+ const markedAngle = (Number(degree(angle).replace('°', '')) * Math.PI) / 180;
+ expect(Math.abs(200 * Math.cos(markedAngle) - 120)).toBeLessThan(0.5);
+ expect(Math.abs(200 * Math.sin(markedAngle) - 160)).toBeLessThan(0.5);
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());
@@ -181,7 +177,7 @@ describe('cut shape previews', () => {
piece: { ...floor, length: 920, width: 160, shaped: true, template: points },
}),
);
- expect(markup).toContain('>53.13°');
+ expect(markup).toContain('>53.1°');
expect(markup).toContain('angles from horizontal');
});
diff --git a/tests/dimension-labels.test.ts b/tests/dimension-labels.test.ts
index d5c0877..bdd1e6c 100644
--- a/tests/dimension-labels.test.ts
+++ b/tests/dimension-labels.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import { fireplace } from '../src/lib/defaults';
import { buildGeometry } from '../src/lib/geometry';
-import { dimensionLabels } from '../src/lib/dimension-labels';
+import { dimensionLabels, wallDimensionBox } from '../src/lib/dimension-labels';
+import fixture from './fixtures/angled-room.json';
+import type { Room } from '../src/lib/types';
describe('wall dimensions', () => {
it('keeps fireplace recess measurements apart without changing measured lengths', () => {
const geometry = buildGeometry(fireplace('room-1', 'Fireplace'), 10);
@@ -19,3 +21,32 @@ describe('wall dimensions', () => {
expect(dimensionLabels(geometry.edges, 4800, 1)).toEqual(labels);
});
});
+
+// Regression for the supplied 12-wall plan: W12's outward line crossed the old viewport.
+it('includes the complete diagonal W12 dimension, not only its midpoint label', () => {
+ const room = fixture.project.rooms[0] as Room;
+ const geometry = buildGeometry(room, fixture.project.tolerance);
+ const xs = geometry.vertices.map((p) => p.x),
+ ys = geometry.vertices.map((p) => p.y);
+ const span = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
+ const labels = dimensionLabels(geometry.edges, span, 1);
+ const edge = geometry.edges[11],
+ label = labels[11];
+ const box = wallDimensionBox(edge, label, span);
+ expect(label.text).toBe('W12 · 6,097');
+ const end = {
+ x: edge.end.x + label.normal.x * label.offset,
+ y: edge.end.y + label.normal.y * label.offset,
+ };
+ expect(end.x).toBeLessThan(Math.min(...xs));
+ expect(box.minX).toBeLessThan(end.x);
+ expect(box.maxX).toBeGreaterThan(label.box.maxX - 1e-6);
+ for (const point of [edge.start, edge.end]) {
+ const x = point.x + label.normal.x * label.offset,
+ y = point.y + label.normal.y * label.offset;
+ expect(x).toBeGreaterThanOrEqual(box.minX);
+ expect(x).toBeLessThanOrEqual(box.maxX);
+ expect(y).toBeGreaterThanOrEqual(box.minY);
+ expect(y).toBeLessThanOrEqual(box.maxY);
+ }
+});
diff --git a/tests/export.test.ts b/tests/export.test.ts
deleted file mode 100644
index 8430850..0000000
--- a/tests/export.test.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { defaultProject } from '../src/lib/defaults';
-import { cutListCsv } from '../src/lib/export';
-import { flooringCuts, isSkirtingCut, uncutFlooring, uncutSkirting } from '../src/lib/cut-list';
-import type { FlooringPlan, SkirtingPlan } from '../src/lib/types';
-
-const floor: FlooringPlan = {
- pieces: [
- {
- id: 'F1',
- roomId: 'room-1',
- row: 1,
- column: 1,
- polygon: [],
- length: 450,
- width: 190,
- stockId: 'B1',
- reused: true,
- cut: true,
- shaped: true,
- template: [
- { x: 0, y: 0 },
- { x: 450, y: 0 },
- { x: 450, y: 190 },
- { x: 0, y: 100 },
- ],
- },
- ],
- rows: [],
- stocks: [{ id: 'B1', pieceIds: ['F1'], remaining: 747 }],
- boardCount: 1,
- cuts: 2,
- wasteArea: 0,
- wastePercent: 0,
- reusedCount: 1,
- coveredArea: 65250,
- issues: [{ severity: 'warning', message: 'Check the shaped edge.' }],
-};
-const skirt: SkirtingPlan = {
- pieces: [
- {
- id: 'A1',
- roomId: 'room-1',
- wallId: 'room-1-w1',
- wallIndex: 0,
- start: { x: 0, y: 0 },
- end: { x: 1500, y: 0 },
- length: 1518,
- backLength: 1500,
- leftMitre: 0,
- rightMitre: 45,
- leftType: 'square',
- rightType: 'external',
- stockId: 'S1',
- },
- ],
- stocks: [{ id: 'S1', length: 2400, used: 1521, waste: 879, pieceIds: ['A1'] }],
- stockCount: 1,
- cuts: 1,
- waste: 879,
- issues: [],
-};
-
-describe('cut list CSV', () => {
- it('exports measured cuts, shape coordinates, mitres, board references and purchase counts', () => {
- const csv = cutListCsv(defaultProject(), floor, skirt);
- expect(csv.startsWith('\uFEFF"Material"')).toBe(true);
- expect(csv).toContain('"Flooring","Living room","F1","B1","450","190"');
- expect(csv).toContain('Reused offcut; Shaped cut');
- expect(csv).toContain('0.0:0.0 | 450.0:0.0 | 450.0:190.0 | 0.0:100.0');
- expect(csv).toContain('"Skirting","Living room","A1","S1","1518","18","0","45"');
- expect(csv).toContain('Back length 1500.0 mm; left square; right external');
- expect(csv).toContain('Boards to buy; 1 installed pieces; 2 saw cuts');
- expect(csv).not.toContain('Check the shaped edge.');
- expect(csv.split('\r\n')).toHaveLength(6);
- });
-
- it('counts uncut full boards and lengths without exporting their codes or source allocations', () => {
- const project = defaultProject();
- const wholeFloor = {
- ...floor.pieces[0],
- id: 'FULL-F',
- stockId: 'FULL-B',
- length: 1200,
- width: 190,
- cut: false,
- shaped: false,
- reused: false,
- };
- const readyOffcut = {
- ...floor.pieces[0],
- id: 'READY-F',
- length: 747,
- cut: false,
- shaped: false,
- };
- const floorPlan: FlooringPlan = {
- ...floor,
- boardCount: 2,
- pieces: [...floor.pieces, wholeFloor, readyOffcut],
- stocks: [
- { ...floor.stocks[0], pieceIds: ['F1', 'READY-F'], remaining: 0 },
- { id: 'FULL-B', pieceIds: ['FULL-F'], remaining: 0 },
- ],
- };
- const wholeSkirt = {
- ...skirt.pieces[0],
- id: 'FULL-A',
- stockId: 'FULL-S',
- length: 2400,
- backLength: 2400,
- leftMitre: 0,
- rightMitre: 0,
- leftType: 'square' as const,
- rightType: 'square' as const,
- };
- const skirtPlan: SkirtingPlan = {
- ...skirt,
- stockCount: 2,
- pieces: [...skirt.pieces, wholeSkirt],
- stocks: [
- ...skirt.stocks,
- { id: 'FULL-S', length: 2400, used: 2400, waste: 0, pieceIds: ['FULL-A'] },
- ],
- };
- const csv = cutListCsv(project, floorPlan, skirtPlan);
- for (const code of ['FULL-F', 'FULL-B', 'FULL-A', 'FULL-S']) expect(csv).not.toContain(code);
- expect(csv).toContain('"Flooring uncut boards","","","","1200","190","","","1"');
- expect(csv).toContain('"Flooring ready offcuts","","","","","","","","1"');
- expect(csv).toContain('"Skirting uncut lengths","","","","2400","18","","","1"');
- expect(csv).toContain('"Flooring total","","","","1200","190","","","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('"Skirting",'))).toHaveLength(1);
- expect(csv.split('\r\n').filter((row) => row.startsWith('"Flooring stock",'))).toHaveLength(0);
- expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting stock",'))).toHaveLength(0);
- expect(flooringCuts(floorPlan)).toEqual([...floor.pieces, readyOffcut]);
- expect(csv).toContain('"Flooring","Living room","READY-F","B1","747","190"');
- expect(csv).toContain('Reused offcut; Use offcut; no new cut');
- expect(csv).not.toContain('"Flooring stock"');
- expect(uncutFlooring(floorPlan, project.flooring)).toEqual({ wholeBoards: 1, readyOffcuts: 1 });
- expect(uncutSkirting(skirtPlan)).toEqual([{ length: 2400, count: 1 }]);
- });
-
- it('keeps full-length mitred pieces and shortened square skirting in the cut list', () => {
- const mitred = { ...skirt.pieces[0], length: 2400 };
- const shortened = { ...skirt.pieces[0], leftMitre: 0, rightMitre: 0 };
- const uncut = { ...shortened, length: 2400 };
- expect(isSkirtingCut(mitred, skirt)).toBe(true);
- expect(isSkirtingCut(shortened, skirt)).toBe(true);
- expect(isSkirtingCut(uncut, skirt)).toBe(false);
- expect(isSkirtingCut({ ...uncut, stockId: 'missing' }, skirt)).toBe(true);
- const csv = cutListCsv(defaultProject(), floor, { ...skirt, pieces: [mitred] });
- expect(csv).toContain('"Skirting","Living room","A1","S1","2400","18","0","45"');
- });
-
- it('quotes commas, double quotes, newlines and Unicode room names', () => {
- const project = defaultProject();
- project.rooms[0].name = 'Café 北, "main"\nroom';
- const csv = cutListCsv(project, floor, skirt);
- expect(csv).toContain('"Café 北, ""main""\nroom"');
- });
-
- it.each(['=SUM(1,1)', '+cmd', '-formula', '@formula'])(
- 'neutralizes spreadsheet formulas in user text %s',
- (name) => {
- const project = defaultProject();
- project.rooms[0].name = name;
- expect(cutListCsv(project, floor, skirt)).toContain(`"'${name}"`);
- },
- );
-
- it('produces identical output for identical input without mutating the plans', () => {
- const project = defaultProject();
- const before = structuredClone({ project, floor, skirt });
- expect(cutListCsv(project, floor, skirt)).toBe(cutListCsv(project, floor, skirt));
- expect({ project, floor, skirt }).toEqual(before);
- });
-});
diff --git a/tests/fixtures/angled-room-f8.json b/tests/fixtures/angled-room-f8.json
new file mode 100644
index 0000000..9a4e48e
--- /dev/null
+++ b/tests/fixtures/angled-room-f8.json
@@ -0,0 +1,132 @@
+{
+ "project": {
+ "version": 1,
+ "name": "My flooring project",
+ "rooms": [
+ {
+ "id": "room-2",
+ "name": "Room 2",
+ "walls": [
+ {
+ "id": "room-2-w1",
+ "length": 4800,
+ "turn": 10,
+ "doors": []
+ },
+ {
+ "id": "room-2-w2",
+ "length": 3600,
+ "turn": 90,
+ "doors": []
+ },
+ {
+ "id": "room-2-w3",
+ "length": 1650,
+ "turn": 90,
+ "doors": []
+ },
+ {
+ "id": "room-2-w4",
+ "length": 450,
+ "turn": -90,
+ "doors": []
+ },
+ {
+ "id": "room-2-w5",
+ "length": 1500,
+ "turn": -90,
+ "doors": []
+ },
+ {
+ "id": "room-2-w6",
+ "length": 450,
+ "turn": 90,
+ "doors": []
+ },
+ {
+ "id": "room-2-w8",
+ "length": 3600,
+ "turn": 90,
+ "doors": [
+ {
+ "id": "room-2-door-1",
+ "offset": 500,
+ "width": 826
+ }
+ ]
+ },
+ {
+ "id": "room-2-w7",
+ "length": 1650,
+ "turn": 90,
+ "doors": []
+ },
+ {
+ "id": "room-2-w-3",
+ "length": 1000,
+ "turn": -72.12516087806887,
+ "doors": []
+ },
+ {
+ "id": "room-2-w-1",
+ "length": 1000,
+ "turn": 93.2,
+ "doors": []
+ },
+ {
+ "id": "room-2-w-2",
+ "length": 1000,
+ "turn": -91.7,
+ "doors": []
+ },
+ {
+ "id": "room-2-w-4",
+ "length": 6097,
+ "turn": 59.23323117818302,
+ "doors": [
+ {
+ "id": "room-2-door-2",
+ "offset": 500,
+ "width": 826
+ }
+ ]
+ },
+ {
+ "id": "room-2-w-5",
+ "length": 1687,
+ "turn": 90,
+ "doors": []
+ }
+ ],
+ "direction": 90
+ }
+ ],
+ "flooring": {
+ "length": 1200,
+ "width": 190,
+ "gap": 10,
+ "pattern": "random",
+ "minOffset": 0.1,
+ "maxOffset": 1000,
+ "minLength": 0.1,
+ "minWidth": 0.1,
+ "kerf": 3
+ },
+ "skirting": {
+ "stockLengths": [
+ 2400
+ ],
+ "thickness": 27,
+ "minLength": 200,
+ "kerf": 3
+ },
+ "tolerance": 50,
+ "completedBoards": []
+ },
+ "view": {
+ "roomId": "room-2",
+ "selectedPiece": "F8",
+ "search": "F8",
+ "collapsedSections": []
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/angled-room.json b/tests/fixtures/angled-room.json
new file mode 100644
index 0000000..21b401d
--- /dev/null
+++ b/tests/fixtures/angled-room.json
@@ -0,0 +1,115 @@
+{
+ "project": {
+ "version": 1,
+ "name": "My flooring project",
+ "rooms": [
+ {
+ "id": "room-2",
+ "name": "Room 2",
+ "walls": [
+ {
+ "id": "room-2-w1",
+ "length": 4800,
+ "turn": 10,
+ "doors": []
+ },
+ {
+ "id": "room-2-w2",
+ "length": 3600,
+ "turn": 90,
+ "doors": []
+ },
+ {
+ "id": "room-2-w3",
+ "length": 1650,
+ "turn": 90,
+ "doors": []
+ },
+ {
+ "id": "room-2-w4",
+ "length": 450,
+ "turn": -90,
+ "doors": []
+ },
+ {
+ "id": "room-2-w5",
+ "length": 1500,
+ "turn": -90,
+ "doors": []
+ },
+ {
+ "id": "room-2-w6",
+ "length": 450,
+ "turn": 90,
+ "doors": []
+ },
+ {
+ "id": "room-2-w7",
+ "length": 1650,
+ "turn": 90,
+ "doors": []
+ },
+ {
+ "id": "room-2-w8",
+ "length": 3600,
+ "turn": 90,
+ "doors": [
+ {
+ "id": "room-2-door-1",
+ "offset": 500,
+ "width": 826
+ }
+ ]
+ },
+ {
+ "id": "room-2-w-1",
+ "length": 1000,
+ "turn": 93.2,
+ "doors": []
+ },
+ {
+ "id": "room-2-w-2",
+ "length": 1000,
+ "turn": -91.7,
+ "doors": []
+ },
+ {
+ "id": "room-2-w-3",
+ "length": 1000,
+ "turn": -72.12516087806887,
+ "doors": []
+ },
+ {
+ "id": "room-2-w-4",
+ "length": 6097,
+ "turn": 90,
+ "doors": []
+ }
+ ],
+ "direction": 90
+ }
+ ],
+ "flooring": {
+ "length": 1200,
+ "width": 190,
+ "gap": 10,
+ "pattern": "random",
+ "minOffset": 0.1,
+ "maxOffset": 1000,
+ "minLength": 0.1,
+ "minWidth": 0.1,
+ "kerf": 3
+ },
+ "skirting": {
+ "stockLengths": [2400],
+ "thickness": 27,
+ "minLength": 200,
+ "kerf": 3
+ },
+ "tolerance": 50
+ },
+ "view": {
+ "roomId": "room-2",
+ "selectedPiece": null
+ }
+}
diff --git a/tests/flooring.test.ts b/tests/flooring.test.ts
index 1728215..2fbf5d5 100644
--- a/tests/flooring.test.ts
+++ b/tests/flooring.test.ts
@@ -1,10 +1,11 @@
+import f8Fixture from './fixtures/angled-room-f8.json';
import { describe, expect, it } from 'vitest';
import ClipperLib from 'clipper-lib';
import { planFlooring } from '../src/lib/flooring';
import { buildGeometry, insetPolygon, signedArea } from '../src/lib/geometry';
import { fireplace, rectangle } from '../src/lib/defaults';
import { flooringCuts } from '../src/lib/cut-list';
-import type { FlooringPlan, FlooringSettings, Point, Room } from '../src/lib/types';
+import type { FlooringPlan, FlooringSettings, Point, Project, Room } from '../src/lib/types';
const settings: FlooringSettings = {
length: 1200,
@@ -367,3 +368,5 @@ describe('laminate planning', () => {
expect(errors(planFlooring([valid], { ...settings, width: 0 }, 1))[0].roomId).toBeUndefined();
});
});
+
+it('temporary F8 audit',()=>{const project=f8Fixture.project as Project;const plan=planFlooring(project.rooms,project.flooring,project.tolerance);const atCorner=plan.pieces.filter(p=>p.polygon.some(v=>Math.hypot(v.x-7607.513,v.y-2160.340)<0.01));throw new Error(JSON.stringify({atCorner,first:plan.rows[0],last:plan.rows.at(-1),boards:plan.boardCount,cuts:plan.cuts,area:plan.coveredArea,insetArea:insetPolygon(buildGeometry(project.rooms[0],project.tolerance).vertices,10).reduce((a,p)=>a+Math.abs(signedArea(p)),0)}));});
diff --git a/tests/format.test.ts b/tests/format.test.ts
new file mode 100644
index 0000000..7576f19
--- /dev/null
+++ b/tests/format.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest';
+import { cutMm } from '../src/lib/format';
+
+describe('cut dimensions', () => {
+ it.each([
+ [0, '0'],
+ [89.423, '89.5'],
+ [177.163, '177.5'],
+ [488.648, '489'],
+ [545.948, '546'],
+ [600.5, '600.5'],
+ [600.50000000001, '600.5'],
+ [1200, '1,200'],
+ [NaN, '—'],
+ ])('rounds %s upward to a useful half-millimetre mark', (length, shown) => {
+ expect(cutMm(length)).toBe(shown);
+ });
+});
diff --git a/tests/skirting.test.ts b/tests/skirting.test.ts
index ec5cd2b..b7cb07b 100644
--- a/tests/skirting.test.ts
+++ b/tests/skirting.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { planSkirting } from '../src/lib/skirting';
import { skirtingCuts } from '../src/lib/cut-list';
+import { cutLength } from '../src/lib/format';
import type { Room, SkirtingSettings } from '../src/lib/types';
const settings: SkirtingSettings = { stockLengths: [2400], thickness: 18, minLength: 300, kerf: 3 };
@@ -146,6 +147,22 @@ describe('skirting cutting plan', () => {
expect(plan.cuts).toBe(4);
});
+ it('reserves the displayed half-millimetre blanks so rounded cuts fit their source', () => {
+ const plan = planSkirting(
+ [rectangle(1198.4, 1198.6)],
+ { ...settings, thickness: 0, minLength: 1100 },
+ 5,
+ );
+ expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
+ expect(plan.stockCount).toBe(3);
+ for (const stock of plan.stocks) {
+ const pieces = stock.pieceIds.map((id) => plan.pieces.find((piece) => piece.id === id)!);
+ const marked = pieces.reduce((sum, piece) => sum + cutLength(piece.length), 0);
+ expect(marked + (pieces.length - 1) * settings.kerf).toBeLessThanOrEqual(stock.length);
+ }
+ expect(plan.pieces.reduce((sum, piece) => sum + piece.length, 0)).toBeCloseTo(4794);
+ });
+
it('looks ahead when choosing from different stock lengths', () => {
const plan = planSkirting(
[rectangle(1200, 1200)],
diff --git a/tests/url-state.test.ts b/tests/url-state.test.ts
index 0360810..e485f1d 100644
--- a/tests/url-state.test.ts
+++ b/tests/url-state.test.ts
@@ -45,7 +45,7 @@ describe('URL state', () => {
state.project.rooms[0].walls[0].doors = [{ id: 'wide-door', offset: 123.75, width: 1500 }];
state.project.flooring.pattern = 'random';
state.project.skirting.stockLengths = [2400, 3600, 4800];
- state.view = { roomId: 'room-2', selectedPiece: 'A12' };
+ state.view = { ...state.view, roomId: 'room-2', selectedPiece: 'A12' };
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
});
@@ -64,8 +64,13 @@ describe('URL state', () => {
const decoded = decodeState(rawLink(old));
expect(decoded.error).toBeNull();
expect(decoded.state.project).toEqual(state.project);
- expect(decoded.state.view).toEqual({ roomId: state.view.roomId, selectedPiece: 'A4' });
- expect(Object.keys(decoded.state.view).sort()).toEqual(['roomId', 'selectedPiece']);
+ expect(decoded.state.view).toEqual({ ...state.view, selectedPiece: 'A4' });
+ expect(Object.keys(decoded.state.view).sort()).toEqual([
+ 'collapsedSections',
+ 'roomId',
+ 'search',
+ 'selectedPiece',
+ ]);
});
it('canonicalizes property order and discards derived or unknown fields', () => {
@@ -341,7 +346,7 @@ describe('URL state', () => {
const state = defaultState();
state.project.rooms.push(fireplace('second', 'Fireplace room'));
state.project.skirting.stockLengths = [2400, 3600];
- state.view = { roomId: 'second', selectedPiece: 'B2' };
+ state.view = { ...state.view, roomId: 'second', selectedPiece: 'B2' };
const legacy = decodeState(rawLink(state));
expect(legacy.error).toBeNull();
const query = encodeState(legacy.state);
@@ -350,6 +355,124 @@ describe('URL state', () => {
expect(decodeState(query)).toEqual({ state, error: null });
});
+ it('stores progress and view preferences as optional readable fields', () => {
+ const state = defaultState();
+ state.project.completedBoards = ['floor:B4', 'skirting:S6'];
+ state.view.search = 'F12';
+ state.view.collapsedSections = ['materials', 'measurements'];
+ const query = encodeState(state);
+ expect(query).toContain('done=floor:B4&done=skirting:S6');
+ expect(query).toContain('search=F12&collapsed=materials&collapsed=measurements');
+ expect(decodeState(query)).toEqual({ state, error: null });
+ const empty = new URLSearchParams(encodeState(defaultState()));
+ for (const key of ['done', 'search', 'collapsed']) expect(empty.has(key)).toBe(false);
+ });
+
+ it('canonicalizes completion and collapse sets without mutating their inputs', () => {
+ const state = defaultState();
+ state.project.completedBoards = ['skirting:S6', 'floor:B4'];
+ state.view.collapsedSections = ['measurements', 'cuts', 'materials'];
+ const before = structuredClone(state);
+ const query = encodeState(state);
+ const loaded = decodeState(query);
+ expect(loaded.error).toBeNull();
+ expect(loaded.state.project.completedBoards).toEqual(['floor:B4', 'skirting:S6']);
+ expect(loaded.state.view.collapsedSections).toEqual(['cuts', 'materials', 'measurements']);
+ expect(state).toEqual(before);
+ state.project.completedBoards.reverse();
+ state.view.collapsedSections.reverse();
+ expect(encodeState(state)).toBe(query);
+ expect(encodeState(loaded.state)).toBe(query);
+ });
+
+ it('loads old query and fragment links with empty progress and view preferences', () => {
+ const state = defaultState();
+ const old = {
+ project: { ...state.project, completedBoards: undefined },
+ view: { roomId: state.view.roomId, selectedPiece: null },
+ };
+ expect(decodeState(rawLink(old))).toEqual({ state, error: null });
+ expect(decodeState('?flooring.length=1200').state).toEqual(state);
+ const first = defaultState();
+ first.project.completedBoards.push('floor:B4');
+ first.view.collapsedSections.push('materials');
+ expect(defaultState().project.completedBoards).toEqual([]);
+ expect(defaultState().view.collapsedSections).toEqual([]);
+ });
+
+ it('preserves Unicode and spaces in the search input independently of selected cuts', () => {
+ const state = defaultState();
+ state.view.search = ' Café 北 F12 / A2 ';
+ state.view.selectedPiece = 'F12';
+ expect(decodeState(encodeState(state))).toEqual({ state, error: null });
+ expectInvalid('?search=F1&search=F2', /only once/);
+ });
+
+ it.each([
+ '',
+ 'B4',
+ 'floor:S4',
+ 'skirting:B4',
+ 'floor:B0',
+ 'floor:B04',
+ 'floor:B-1',
+ 'floor:B1.5',
+ ])('rejects invalid completed-board key %s', (key) => {
+ expectInvalid(`?done=${encodeURIComponent(key)}`, /floor:B1 or skirting:S1/);
+ });
+
+ it('rejects duplicate completion and collapse entries rather than counting them twice', () => {
+ expectInvalid('?done=floor:B4&done=floor:B4', /duplicate entries/);
+ expectInvalid('?collapsed=materials&collapsed=materials', /duplicate entries/);
+ const state = defaultState();
+ state.project.completedBoards = ['floor:B4', 'floor:B4'];
+ expect(() => encodeState(state)).toThrow(/duplicate entries/);
+ const distinct = decodeState('?done=floor:B1&done=skirting:S1');
+ expect(distinct.error).toBeNull();
+ expect(distinct.state.project.completedBoards).toEqual(['floor:B1', 'skirting:S1']);
+ });
+
+ it('bounds the search, progress list and collapsed sections', () => {
+ expectInvalid(`?search=${'a'.repeat(URL_LIMITS.searchLength + 1)}`, /at most 200 characters/);
+ expectInvalid('?collapsed=outline', /unsupported value/);
+ expectInvalid(
+ '?collapsed=cuts&collapsed=materials&collapsed=measurements&collapsed=cuts',
+ /at most 3/,
+ );
+ const state = defaultState();
+ state.project.completedBoards = Array.from(
+ { length: URL_LIMITS.completedBoards + 1 },
+ (_, index) => `floor:B${index + 1}`,
+ );
+ expect(() => encodeState(state)).toThrow(/at most 12000/);
+ expectInvalid(rawLink(state), /at most 12000/);
+ expectInvalid(
+ `?done=floor:B${'1'.repeat(URL_LIMITS.identifierLength)}`,
+ /at most 120 characters/,
+ );
+ });
+
+ it.each([null, true, 'floor:B4', [false], [{}]])(
+ 'rejects incorrectly typed completed-board sets %j',
+ (completedBoards) => {
+ const state = defaultState();
+ expectInvalid(rawLink({ ...state, project: { ...state.project, completedBoards } }));
+ },
+ );
+
+ it.each([null, 42, [], {}])('rejects incorrectly typed search input %j', (search) => {
+ const state = defaultState();
+ expectInvalid(rawLink({ ...state, view: { ...state.view, search } }));
+ });
+
+ it.each([null, true, 'materials', [42], ['outline']])(
+ 'rejects incorrectly typed or unsupported collapsed sections %j',
+ (collapsedSections) => {
+ const state = defaultState();
+ expectInvalid(rawLink({ ...state, view: { ...state.view, collapsedSections } }));
+ },
+ );
+
it('rejects oversized query strings before parsing fields', () => {
expectInvalid(`?name=${'x'.repeat(URL_LIMITS.queryLength)}`, /link is too large/);
});
diff --git a/tests/wall-editing.test.ts b/tests/wall-editing.test.ts
new file mode 100644
index 0000000..230ba15
--- /dev/null
+++ b/tests/wall-editing.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from 'vitest';
+import { rectangle } from '../src/lib/defaults';
+import { insertWallAfter, moveWall } from '../src/lib/wall-editing';
+
+const ids = (room: ReturnType) => room.walls.map((wall) => wall.id);
+
+describe('inserting walls', () => {
+ it.each([0, 1, 3])(
+ 'inserts after wall index %i without changing existing measurements or doors',
+ (index) => {
+ const room = rectangle();
+ const original = structuredClone(room);
+ const inserted = insertWallAfter(room, room.walls[index].id);
+ expect(inserted.walls).toHaveLength(room.walls.length + 1);
+ expect(inserted.walls[index + 1]).toMatchObject({ length: 1000, turn: 90, doors: [] });
+ expect(new Set(ids(inserted)).size).toBe(inserted.walls.length);
+ expect(inserted.walls.filter((wall) => room.walls.includes(wall))).toEqual(room.walls);
+ for (const wall of room.walls)
+ expect(inserted.walls.find((candidate) => candidate.id === wall.id)).toBe(wall);
+ expect(room).toEqual(original);
+ expect(inserted.id).toBe(room.id);
+ expect(inserted.name).toBe(room.name);
+ expect(inserted.direction).toBe(room.direction);
+ },
+ );
+
+ it('assigns deterministic, unique IDs when repeatedly inserting in the same position', () => {
+ const room = rectangle();
+ const first = insertWallAfter(room, room.walls[0].id);
+ const second = insertWallAfter(first, room.walls[0].id);
+ expect(insertWallAfter(room, room.walls[0].id)).toEqual(first);
+ expect(new Set(ids(second)).size).toBe(6);
+ expect(second.walls[2]).toBe(first.walls[1]);
+ });
+
+ it('ignores an unknown wall and the wall-count limit without creating replacement state', () => {
+ const room = rectangle();
+ expect(insertWallAfter(room, 'missing')).toBe(room);
+ const full = {
+ ...room,
+ walls: Array.from({ length: 100 }, (_, i) => ({ ...room.walls[0], id: `wall-${i}` })),
+ };
+ expect(insertWallAfter(full, 'wall-3')).toBe(full);
+ });
+});
+
+describe('reordering walls', () => {
+ it.each([
+ [0, 3],
+ [3, 0],
+ [1, 2],
+ [2, 1],
+ ])(
+ 'moves index %i to index %i while preserving all walls and their attached doors',
+ (from, to) => {
+ const room = rectangle();
+ const original = structuredClone(room);
+ const moved = moveWall(room, room.walls[from].id, to);
+ expect(moved.walls[to]).toBe(room.walls[from]);
+ expect([...ids(moved)].sort()).toEqual([...ids(room)].sort());
+ for (const wall of room.walls) {
+ const result = moved.walls.find((candidate) => candidate.id === wall.id)!;
+ expect(result).toBe(wall);
+ expect(result.doors).toBe(wall.doors);
+ }
+ expect(room).toEqual(original);
+ expect(moveWall(moved, room.walls[from].id, from).walls).toEqual(room.walls);
+ },
+ );
+
+ it('clamps destinations and treats missing, unchanged or non-finite positions as no-ops', () => {
+ const room = rectangle();
+ expect(moveWall(room, room.walls[0].id, -10)).toBe(room);
+ expect(moveWall(room, room.walls[0].id, 100).walls.at(-1)).toBe(room.walls[0]);
+ expect(moveWall(room, room.walls[3].id, -10).walls[0]).toBe(room.walls[3]);
+ expect(moveWall(room, 'missing', 2)).toBe(room);
+ expect(moveWall(room, room.walls[1].id, 1)).toBe(room);
+ expect(moveWall(room, room.walls[1].id, Number.NaN)).toBe(room);
+ });
+});