This commit is contained in:
Andras Schmelczer 2026-09-11 16:58:58 +01:00
commit 62a385e4c9
33 changed files with 2649 additions and 809 deletions

View file

@ -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.

View file

@ -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<Page, string[]>();
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(

View file

@ -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 (
<section className={`planner-panel ${open ? 'open' : ''}`} data-section={section}>
<h2 className="panel-toggle-heading">
<button
className="panel-toggle"
aria-expanded={open}
aria-controls={`${section}-content`}
onClick={toggle}
>
<span>{title}</span>
<span aria-hidden="true">{open ? '−' : '+'}</span>
</button>
</h2>
<div id={`${section}-content`} hidden={!open} className="panel-content">
{children}
</div>
</section>
);
}
// 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() {
</div>
<span className="project-area">{area(totalArea)} m² total</span>
</div>
<nav className="page-jumps" aria-label="Jump to section">
<button onClick={() => jump('measurements')}>
Room <ArrowDown size={13} />
</button>
<button onClick={() => jump('materials')}>
Materials <ArrowDown size={13} />
</button>
<button onClick={() => jump('drawing')}>
Drawing <ArrowDown size={13} />
</button>
<button onClick={() => jump('cut-plan')}>
Cuts <ArrowDown size={13} />
</button>
</nav>
<div className="workspace">
<div className="input-columns">
<section
className="editor-panel room-editor"
id="measurements"
aria-label="Room measurements"
<div className="planning-panels">
<PlannerPanel
section="measurements"
title="Room measurements"
open={!view.collapsedSections.includes('measurements')}
toggle={() => togglePanel('measurements')}
>
<OutlineEditor
room={room}
geometry={geometry}
tolerance={project.tolerance}
changeRoom={changeRoom}
changeProject={changeProject}
remove={removeRoom}
canRemove={project.rooms.length > 1}
/>
</section>
<section
className="editor-panel materials-editor"
id="materials"
aria-label="Materials"
<section
className="editor-panel room-editor"
id="measurements"
aria-label="Room measurements"
>
<OutlineEditor
key={room.id}
room={room}
geometry={geometry}
tolerance={project.tolerance}
changeRoom={changeRoom}
changeProject={changeProject}
remove={removeRoom}
canRemove={project.rooms.length > 1}
/>
</section>
<Issues issues={roomErrors} />
</PlannerPanel>
<PlannerPanel
section="materials"
title="Materials"
open={!view.collapsedSections.includes('materials')}
toggle={() => togglePanel('materials')}
>
<MaterialsEditor
project={project}
room={room}
changeRoom={changeRoom}
changeProject={changeProject}
/>
</section>
<section
className="editor-panel materials-editor"
id="materials"
aria-label="Materials"
>
<MaterialsEditor
project={project}
room={room}
changeRoom={changeRoom}
changeProject={changeProject}
/>
</section>
<section className="purchase-summary" aria-label="Materials to buy">
<h2>
Materials to buy{' '}
<small>{project.rooms.length > 1 ? 'All rooms' : room.name}</small>
</h2>
<div className="purchase-item flooring">
<span className="material-key floor-key" aria-hidden="true" />
<div>
<strong>
{floorErrors.length ? '—' : flooring.boardCount} <span>floorboards</span>
</strong>
<p>
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm ·{' '}
{wholeBoards} used whole
</p>
</div>
<span className="waste-stat">
{floorErrors.length ? '—' : mm(flooring.wastePercent)}%<small>waste</small>
</span>
</div>
<div className="purchase-item skirting">
<span className="material-key skirt-key" aria-hidden="true" />
<div>
<strong>
{skirtErrors.length ? '—' : skirting.stockCount} <span>skirting lengths</span>
</strong>
<p>
{[...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
</p>
</div>
<span className="waste-stat">
{skirtErrors.length ? '—' : mm(skirting.waste / 1000)} m<small>waste</small>
</span>
</div>
</section>
</PlannerPanel>
<PlannerPanel
section="cuts"
title="Cut plan"
open={!view.collapsedSections.includes('cuts')}
toggle={() => togglePanel('cuts')}
>
<div id="cut-plan">
<CutList
project={project}
flooring={flooring}
skirting={skirting}
select={select}
completedBoards={project.completedBoards}
toggleBoard={toggleBoard}
search={view.search}
setSearch={setSearch}
locateBoard={setSearch}
locatePiece={locatePiece}
/>
</div>
</PlannerPanel>
</div>
<div className="drawing-column" id="drawing">
<aside className="drawing-column" id="drawing" aria-label="Room drawing">
<FloorPlan
room={room}
geometry={geometry}
@ -200,61 +360,48 @@ export default function App() {
skirting={skirting}
selectedPiece={view.selectedPiece}
select={(id) => select(id)}
completedBoards={project.completedBoards}
highlightedPieces={matches.pieceIds}
searchActive={!!view.search.trim()}
/>
<section className="purchase-summary" aria-label="Materials to buy">
<h2>
Materials to buy <small>{project.rooms.length > 1 ? 'All rooms' : room.name}</small>
</h2>
<div className="purchase-item flooring">
<span className="material-key floor-key" aria-hidden="true" />
<div>
<strong>
{floorErrors.length ? '—' : flooring.boardCount} <span>floorboards</span>
</strong>
<p>
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm · {wholeBoards}{' '}
used whole
</p>
</div>
<span className="waste-stat">
{floorErrors.length ? '—' : mm(flooring.wastePercent)}%<small>waste</small>
</span>
{view.search.trim() && (
<div className="plan-search-status" role="status">
<p>
{matches.pieceIds.length
? `${matches.pieceIds.length} piece${matches.pieceIds.length === 1 ? '' : 's'} found for “${searchLabel}”`
: `No boards or cuts match “${searchLabel}”`}
</p>
{matches.roomIds.length > 1 && (
<div className="matching-rooms">
{project.rooms
.filter((r) => matches.roomIds.includes(r.id))
.map((r) => (
<button
key={r.id}
aria-pressed={r.id === room.id}
onClick={() =>
update((s) => ({
...s,
view: { ...s.view, roomId: r.id, selectedPiece: null },
}))
}
>
{r.name}
</button>
))}
</div>
)}
</div>
<div className="purchase-item skirting">
<span className="material-key skirt-key" aria-hidden="true" />
<div>
<strong>
{skirtErrors.length ? '—' : skirting.stockCount} <span>skirting lengths</span>
</strong>
<p>
{[...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
</p>
</div>
<span className="waste-stat">
{skirtErrors.length ? '—' : mm(skirting.waste / 1000)} m<small>waste</small>
</span>
</div>
</section>
<Issues issues={roomErrors} />
</div>
</div>
<div id="cut-plan">
<CutList
project={project}
flooring={flooring}
skirting={skirting}
selectedPiece={view.selectedPiece}
select={select}
/>
)}
</aside>
</div>
</main>
<CutDialog
floor={flooringCuts(flooring).find((piece) => piece.id === view.selectedPiece)}
skirt={skirtingCuts(skirting).find((piece) => piece.id === view.selectedPiece)}
project={project}
close={() => select(null)}
/>
<PrintPlan
project={project}
geometries={geometries}

View file

@ -1,7 +1,6 @@
import { Download } from 'lucide-react';
import { MapPin, X } from 'lucide-react';
import type { FlooringPlan, Project, SkirtingPlan } from '../lib/types';
import { degree, mm } from '../lib/format';
import { downloadCsv } from '../lib/export';
import { cutMm, degree, mm } from '../lib/format';
import {
flooringCuts,
groupFlooringCuts,
@ -10,26 +9,99 @@ import {
uncutFlooring,
uncutSkirting,
} from '../lib/cut-list';
import { boardSearchLabel, findBoardMatches } from '../lib/board-tracking';
import { FlooringPreview, SkirtingPreview } from './CutPreview';
import { CutDialog } from './CutDialog';
function BoardHeader({
label,
size,
done,
toggle,
locate,
}: {
label: string;
size: string;
done: boolean;
toggle: () => void;
locate: () => void;
}) {
return (
<header className="board-group-header">
<h4>
{label.charAt(0).toUpperCase() + label.slice(1)}
<span>{size}</span>
</h4>
<div className="board-group-actions">
<label className="board-done-control">
<input
type="checkbox"
aria-label={`Mark ${label} done`}
checked={done}
onChange={toggle}
/>
<span>{done ? 'Done' : 'Mark done'}</span>
</label>
<button
className="button board-locate"
aria-label={`Show ${label} on plan`}
onClick={locate}
>
<MapPin size={15} aria-hidden="true" />
Show on plan
</button>
</div>
</header>
);
}
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 (
<section className="cut-list-card" aria-labelledby="cut-plan-title">
<div className="cut-list-header">
@ -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.
</p>
</div>
<button
className="button"
onClick={() => downloadCsv(project, flooring, skirting)}
disabled={errors || (!flooring.pieces.length && !skirting.pieces.length)}
>
<Download size={16} />
Export CSV
</button>
</div>
<div className="cut-search">
<label htmlFor="board-search">Find a cut or room</label>
<div className="cut-search-control">
<input
id="board-search"
type="search"
value={boardSearchLabel(project, flooring, skirting, search)}
maxLength={200}
onChange={(event) => setSearch(event.target.value)}
placeholder="F12, A2, or a room name"
autoComplete="off"
/>
{search.length > 0 && (
<button
className="button clear-search"
aria-label="Clear search"
onClick={() => setSearch('')}
>
<X size={16} aria-hidden="true" />
Clear
</button>
)}
</div>
{searching && (
<p className="search-results" role="status">
{matches.boardKeys.length
? `${matches.boardKeys.length} matching board${matches.boardKeys.length === 1 ? '' : 's'}`
: 'No matching boards or cuts.'}
</p>
)}
</div>
<section className="material-cuts flooring-cuts" aria-labelledby="flooring-cuts-title">
<h3 id="flooring-cuts-title">
Flooring cuts <span className="count-badge">{floorCuts.length}</span>
@ -57,61 +153,98 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
{wholeFloor.readyOffcuts > 0 &&
` · ${wholeFloor.readyOffcuts} offcuts need no further cuts`}
</p>
{groupFlooringCuts(flooring).map((group) => (
<section
className="stock-cut-group"
data-stock-id={group[0].stockId}
key={group[0].stockId}
aria-label={`Floorboard ${group[0].stockId}`}
>
<h4>
Floorboard {group[0].stockId}
<span>
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm
</span>
</h4>
<ul className="board-cuts">
{group.map((piece) => (
<li className="board-cut" key={piece.id} data-cut-id={piece.id}>
<button
className="cut-preview-button flooring-preview-button"
aria-label={`Preview flooring cut ${piece.id}`}
aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)}
>
<span className="cut-thumbnail" aria-hidden="true">
<FlooringPreview piece={piece} compact />
</span>
<span className="cut-preview-code">
{piece.id} <span aria-hidden="true">↗</span>
</span>
</button>
<div>
<p className="cut-size">
{mm(piece.length)} × {mm(piece.width)} mm
</p>
<p className="cut-location">
{roomName(piece.roomId)} · Row {piece.row}
</p>
{piece.reused && (
<p className="cut-note">
{piece.cut ? 'Cut from the remaining offcut' : 'Use the offcut as it is'}
</p>
)}
</div>
</li>
))}
</ul>
</section>
))}
{!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 (
<section
className={`stock-cut-group ${done ? 'is-done' : ''}`}
data-stock-id={stock.id}
data-board-key={key}
key={key}
aria-label={group.length ? `Flooring ${label}` : 'Uncut floorboard'}
>
<BoardHeader
label={label}
size={`${mm(project.flooring.length)} × ${mm(project.flooring.width)} mm`}
done={done}
toggle={() => toggleBoard(key)}
locate={() => locateBoard(key)}
/>
{group.length ? (
<ul className="board-cuts">
{group.map((piece) => (
<li
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(piece.id) ? 'search-match' : ''}`}
key={piece.id}
data-cut-id={piece.id}
>
<button
className="cut-preview-button flooring-preview-button"
aria-label={`Preview flooring cut ${piece.id}`}
aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)}
>
<span className="cut-thumbnail" aria-hidden="true">
<FlooringPreview piece={piece} compact />
</span>
<span className="cut-preview-code">
{piece.id} <span aria-hidden="true">↗</span>
</span>
</button>
<div className="board-cut-info">
<p className="cut-size">
{cutMm(piece.length)} × {cutMm(piece.width)} mm
</p>
<p className="cut-location">
{roomName(piece.roomId)} · Row {piece.row}
</p>
{piece.reused && (
<p className="cut-note">
{piece.cut
? 'Cut from the remaining offcut'
: 'Use the offcut as it is'}
</p>
)}
<button
className="button cut-locate"
aria-label={`Show cut ${piece.id} on plan`}
onClick={() => locatePiece(piece.id, piece.roomId)}
>
<MapPin size={14} aria-hidden="true" />
Show on plan
</button>
</div>
</li>
))}
</ul>
) : (
<p className="uncut-board-note">
Installed uncut ·{' '}
{flooring.pieces
.filter((piece) => piece.stockId === stock.id)
.map((piece) => roomName(piece.roomId))
.join(', ')}
</p>
)}
</section>
);
})}
{!floorStocks.length && (
<p className="empty-state">
{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.'}
</p>
)}
</section>
<section className="material-cuts skirting-cuts" aria-labelledby="skirting-cuts-title">
<h3 id="skirting-cuts-title">
Skirting cuts <span className="count-badge">{skirtCuts.length}</span>
@ -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
</p>
{groupSkirtingCuts(skirting).map((group) => (
<section
className="stock-cut-group"
data-stock-id={group[0].stockId}
key={group[0].stockId}
aria-label={`Skirting board ${group[0].stockId}`}
>
<h4>
Skirting board {group[0].stockId}
<span>
{mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
</span>
</h4>
<ul className="board-cuts">
{group.map((piece) => (
<li className="board-cut" key={piece.id} data-cut-id={piece.id}>
<button
className="cut-preview-button skirting-preview-button"
aria-label={`Preview skirting cut ${piece.id}`}
aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)}
>
<span className="cut-thumbnail" aria-hidden="true">
<SkirtingPreview
piece={piece}
thickness={project.skirting.thickness}
compact
/>
</span>
<span className="cut-preview-code">
{piece.id} <span aria-hidden="true">↗</span>
</span>
</button>
<div>
<p className="cut-size">
{mm(piece.length)} mm <span className="cut-note">long point</span>
</p>
<p className="cut-location">
{roomName(piece.roomId)} · Wall {piece.wallIndex + 1}
</p>
<p className="cut-note">
Left {degree(piece.leftMitre)} · Right {degree(piece.rightMitre)}
</p>
</div>
</li>
))}
</ul>
</section>
))}
{!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 (
<section
className={`stock-cut-group ${done ? 'is-done' : ''}`}
data-stock-id={stock.id}
data-board-key={key}
key={key}
aria-label={group.length ? `Skirting ${label}` : 'Uncut skirting board'}
>
<BoardHeader
label={label}
size={`${mm(stock.length)} mm`}
done={done}
toggle={() => toggleBoard(key)}
locate={() => locateBoard(key)}
/>
{group.length ? (
<ul className="board-cuts">
{group.map((piece) => (
<li
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(piece.id) ? 'search-match' : ''}`}
key={piece.id}
data-cut-id={piece.id}
>
<button
className="cut-preview-button skirting-preview-button"
aria-label={`Preview skirting cut ${piece.id}`}
aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)}
>
<span className="cut-thumbnail" aria-hidden="true">
<SkirtingPreview
piece={piece}
thickness={project.skirting.thickness}
compact
/>
</span>
<span className="cut-preview-code">
{piece.id} <span aria-hidden="true">↗</span>
</span>
</button>
<div className="board-cut-info">
<p className="cut-size">
{cutMm(piece.length)} mm <span className="cut-note">long point</span>
</p>
<p className="cut-location">
{roomName(piece.roomId)} · Wall {piece.wallIndex + 1}
</p>
<p className="cut-note">
Left {degree(piece.leftMitre)} · Right {degree(piece.rightMitre)}
</p>
<button
className="button cut-locate"
aria-label={`Show cut ${piece.id} on plan`}
onClick={() => locatePiece(piece.id, piece.roomId)}
>
<MapPin size={14} aria-hidden="true" />
Show on plan
</button>
</div>
</li>
))}
</ul>
) : (
<p className="uncut-board-note">
Installed uncut ·{' '}
{skirting.pieces
.filter((piece) => piece.stockId === stock.id)
.map((piece) => roomName(piece.roomId))
.join(', ')}
</p>
)}
</section>
);
})}
{!skirtStocks.length && (
<p className="empty-state">
{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.'}
</p>
)}
</section>
<CutDialog
floor={floorCuts.find((piece) => piece.id === selectedPiece)}
skirt={skirtCuts.find((piece) => piece.id === selectedPiece)}
project={project}
close={() => select(null)}
/>
</section>
);
}

View file

@ -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}
</tspan>{' '}
<tspan x={dimension.label.x} dy={font * 1.2}>
{exactMm(dimension.angle)}°
{degree(dimension.angle)}
</tspan>
</>
)}
@ -137,7 +137,7 @@ export function FlooringPreview({
</svg>
{!compact && (
<figcaption>
{exactMm(piece.length)} × {exactMm(piece.width)} mm
{cutMm(piece.length)} × {cutMm(piece.width)} mm
{dimensions?.dimensions.some((dimension) => dimension.angle !== undefined) &&
' · angles from horizontal'}
</figcaption>
@ -198,7 +198,7 @@ export function SkirtingPreview({
<Dimensions dimensions={dimensions!.dimensions} font={dimensions!.font} />
</svg>
<figcaption>
{exactMm(piece.length)} × {exactMm(thickness)} mm · wall side above
{cutMm(piece.length)} × {cutMm(thickness)} mm · wall side above
</figcaption>
</figure>
)}
@ -242,7 +242,7 @@ export function SkirtingPreview({
{!compact && (
<figcaption>
{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}
</figcaption>
)}

View file

@ -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<InputHTMLAttributes<HTMLInputElement>, '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 (
<input
{...props}
type="text"
inputMode={props.inputMode ?? (Number(props.min) < 0 ? 'text' : 'decimal')}
value={draft}
onChange={(event) => {
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({
)}
</span>
<span className="number-input">
<input
<NumericInput
id={id}
type="number"
inputMode="decimal"
value={value}
min={min}
max={max}
step={step}
aria-describedby={hint ? `${id}-hint` : undefined}
onChange={(e) => onChange(Number(e.target.value))}
onChange={onChange}
/>
<span>{unit}</span>
</span>

View file

@ -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({
<section className="drawing-card" aria-label="Floor plan preview">
<div className="drawing-header">
<h2>
Floor plan <small>{area(geometry.area)} m²</small>
Floor plan{' '}
<small>
{room.name} · {area(geometry.area)} m²
</small>
</h2>
<ClosedBadge closed={geometry.closed && geometry.valid} />
</div>
@ -212,6 +224,8 @@ export function FloorPlan({
{geometry.closed && <polygon points={points(geometry.vertices)} fill="#fff" />}
{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 (
<g
key={piece.id}
className={`floor-piece ${cut ? 'svg-cut floor-cut' : 'uncut'}`}
className={`floor-piece ${cut ? 'svg-cut floor-cut' : 'uncut'} ${done ? 'is-done' : ''} ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
data-material="flooring"
data-board-key={`floor:${piece.stockId}`}
data-done={done}
data-highlighted={highlighted}
data-piece-id={piece.id}
role={cut && interactive ? 'button' : undefined}
tabIndex={cut && interactive ? 0 : undefined}
@ -246,11 +263,30 @@ export function FloorPlan({
<polygon
points={points(piece.polygon)}
fill={
cut ? (selectedPiece === piece.id ? '#ceddf9' : `url(#${hatch})`) : '#f5f6f8'
done
? '#dbe9e7'
: cut
? selectedPiece === piece.id
? '#ceddf9'
: `url(#${hatch})`
: '#f5f6f8'
}
stroke={cut ? '#1647ab' : '#9baabd'}
strokeWidth={span * (selectedPiece === piece.id ? 0.003 : 0.0012)}
/>
{done && !cut && (
<text
className="completion-mark"
x={x}
y={y}
textAnchor="middle"
dominantBaseline="middle"
fontSize={font}
fill="#20594f"
>
✓
</text>
)}
{cut && (
<>
<g
@ -258,11 +294,11 @@ export function FloorPlan({
pointerEvents="none"
>
<rect
x={-font * 1.1}
x={-font * 1.3}
y={-font * 0.55}
width={font * 2.2}
width={font * 2.6}
height={font * 1.1}
fill="#1647ab"
fill={done ? '#27655d' : '#1647ab'}
/>
<text
textAnchor="middle"
@ -272,10 +308,11 @@ export function FloorPlan({
fontWeight="600"
>
{piece.id}
{done ? ' ✓' : ''}
</text>
</g>
<title>
{piece.id}: {mm(piece.length)} × {mm(piece.width)} mm
{piece.id}: {cutMm(piece.length)} × {cutMm(piece.width)} mm
</title>
</>
)}
@ -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 (
<g
key={piece.id}
className={`skirt-piece ${cut ? 'svg-cut skirt-cut' : 'uncut'}`}
className={`skirt-piece ${cut ? 'svg-cut skirt-cut' : 'uncut'} ${done ? 'is-done' : ''} ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
data-material="skirting"
data-board-key={`skirting:${piece.stockId}`}
data-done={done}
data-highlighted={highlighted}
data-piece-id={piece.id}
role={cut && interactive ? 'button' : undefined}
tabIndex={cut && interactive ? 0 : undefined}
@ -316,7 +358,15 @@ export function FloorPlan({
>
<polygon
points={points(polygon)}
fill={cut ? (selectedPiece === piece.id ? '#17263f' : '#b74900') : '#d8d5cd'}
fill={
done
? '#27655d'
: cut
? selectedPiece === piece.id
? '#17263f'
: '#b74900'
: '#d8d5cd'
}
stroke={cut ? '#8d3800' : '#79756b'}
strokeWidth={span * 0.001}
/>
@ -330,14 +380,27 @@ export function FloorPlan({
strokeWidth={span * 0.025}
/>
)}
{done && !cut && (
<text
className="completion-mark"
x={(piece.start.x + piece.end.x) / 2}
y={(piece.start.y + piece.end.y) / 2}
textAnchor="middle"
dominantBaseline="middle"
fontSize={font}
fill="#20594f"
>
✓
</text>
)}
{label && (
<g className="skirting-code" transform={`translate(${label.x} ${label.y})`}>
<rect
x={-font * 1.1}
x={-font * 1.3}
y={-font * 0.58}
width={font * 2.2}
width={font * 2.6}
height={font * 1.16}
fill="#b74900"
fill={done ? '#27655d' : '#b74900'}
stroke="#8d3800"
strokeWidth={span * 0.0012}
/>
@ -349,12 +412,13 @@ export function FloorPlan({
fontWeight="700"
>
{piece.id}
{done ? ' ✓' : ''}
</text>
</g>
)}
{cut && (
<title>
{piece.id}: {mm(piece.length)} mm · {mm(piece.leftMitre)}° /{' '}
{piece.id}: {cutMm(piece.length)} mm · {mm(piece.leftMitre)}° /{' '}
{mm(piece.rightMitre)}°
</title>
)}

View file

@ -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<string | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(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 (
<>
<Fragment key={room.id}>
<div className="editor-heading">
<h2>Room measurements</h2>
</div>
@ -168,23 +181,39 @@ export function OutlineEditor({
<span />
</div>
{room.walls.map((wall, i) => (
<div className="wall-row" key={wall.id}>
<div
className={`wall-row${dropTarget === wall.id ? ' drag-over' : ''}`}
key={wall.id}
data-wall-id={wall.id}
onDragOver={(event) => {
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);
}}
>
<span className="wall-code">W{i + 1}</span>
<input
<NumericInput
aria-label={`Wall ${i + 1} length`}
type="number"
inputMode="decimal"
min={1}
step={1}
value={wall.length}
onChange={(e) =>
changeWall(wall.id, (w) => ({ ...w, length: Number(e.target.value) }))
}
onChange={(length) => changeWall(wall.id, (w) => ({ ...w, length }))}
/>
<input
<NumericInput
aria-label={`Wall ${i + 1} turn`}
type="number"
inputMode="decimal"
min={-179}
max={179}
step={0.1}
@ -199,20 +228,64 @@ export function OutlineEditor({
: 'Turn after this wall; positive turns right.'
}
disabled={geometry.closed && i === room.walls.length - 1}
onChange={(e) =>
changeWall(wall.id, (w) => ({ ...w, turn: Number(e.target.value) }))
}
onChange={(turn) => changeWall(wall.id, (w) => ({ ...w, turn }))}
/>
<button
className="icon-button"
aria-label={`Remove wall ${i + 1}`}
disabled={room.walls.length === 1}
onClick={() =>
changeRoom((r) => ({ ...r, walls: r.walls.filter((w) => w.id !== wall.id) }))
}
>
<Trash2 size={14} />
</button>
<div className="wall-actions">
<button
className="icon-button wall-drag-handle"
draggable
aria-label={`Drag wall ${i + 1} to reorder`}
title="Drag to reorder"
onDragStart={(event) => {
draggedWall.current = wall.id;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', wall.id);
}}
onDragEnd={() => {
draggedWall.current = null;
setDropTarget(null);
}}
>
<GripVertical size={16} />
</button>
<button
className="icon-button"
aria-label={`Move wall ${i + 1} up`}
title="Move wall up"
disabled={i === 0}
onClick={() => changeRoom((r) => moveWall(r, wall.id, i - 1))}
>
<ArrowUp size={16} />
</button>
<button
className="icon-button"
aria-label={`Move wall ${i + 1} down`}
title="Move wall down"
disabled={i === room.walls.length - 1}
onClick={() => changeRoom((r) => moveWall(r, wall.id, i + 1))}
>
<ArrowDown size={16} />
</button>
<button
className="icon-button"
aria-label={`Insert wall after W${i + 1}`}
title={`Insert wall after W${i + 1}`}
disabled={room.walls.length >= 100}
onClick={() => changeRoom((r) => insertWallAfter(r, wall.id))}
>
<Plus size={16} />
</button>
<button
className="icon-button"
aria-label={`Remove wall ${i + 1}`}
disabled={room.walls.length === 1}
onClick={() =>
changeRoom((r) => ({ ...r, walls: r.walls.filter((w) => w.id !== wall.id) }))
}
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
@ -364,6 +437,6 @@ export function OutlineEditor({
Remove this room
</button>
)}
</>
</Fragment>
);
}

View file

@ -38,6 +38,7 @@ export function PrintPlan({
Buy {flooring.boardCount} floorboards and {skirting.stockCount} skirting lengths. Install{' '}
{wholeFloor} floorboards and {wholeSkirt} skirting lengths uncut.
</p>
<p>Cut each group from one board.</p>
</header>
{project.rooms.map((room, index) => (
<section className="print-room" key={room.id} data-room-id={room.id}>
@ -51,15 +52,23 @@ export function PrintPlan({
skirting={skirting}
selectedPiece={null}
interactive={false}
completedBoards={project.completedBoards}
/>
</section>
))}
<section className="print-material print-flooring" aria-label="Printed flooring cuts">
<h2>Flooring cuts</h2>
{groupFlooringCuts(flooring).map((group) => (
<section className="print-stock" data-stock-id={group[0].stockId} key={group[0].stockId}>
<section
className={`print-stock ${project.completedBoards.includes(`floor:${group[0].stockId}`) ? 'is-done' : ''}`}
data-stock-id={group[0].stockId}
key={group[0].stockId}
>
<h3>
Floorboard {group[0].stockId}{' '}
Cuts {group.map((piece) => piece.id).join(' + ')}
{project.completedBoards.includes(`floor:${group[0].stockId}`)
? ' · Done ✓'
: ''}{' '}
<span>
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm
</span>
@ -85,9 +94,16 @@ export function PrintPlan({
<section className="print-material print-skirting" aria-label="Printed skirting cuts">
<h2>Skirting cuts</h2>
{groupSkirtingCuts(skirting).map((group) => (
<section className="print-stock" data-stock-id={group[0].stockId} key={group[0].stockId}>
<section
className={`print-stock ${project.completedBoards.includes(`skirting:${group[0].stockId}`) ? 'is-done' : ''}`}
data-stock-id={group[0].stockId}
key={group[0].stockId}
>
<h3>
Skirting board {group[0].stockId}{' '}
Cuts {group.map((piece) => piece.id).join(' + ')}
{project.completedBoards.includes(`skirting:${group[0].stockId}`)
? ' · Done ✓'
: ''}{' '}
<span>
{mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
</span>

73
src/lib/board-tracking.ts Normal file
View file

@ -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<string>();
const boardKeys = new Set<string>();
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';
}

View file

@ -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(

View file

@ -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 {

View file

@ -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)),
};
}

View file

@ -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);
}

View file

@ -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({

View file

@ -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,

View file

@ -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. */

View file

@ -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;

View file

@ -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<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${field} must be an object.`);
@ -71,6 +83,34 @@ function uniqueIds<T extends { id: string }>(items: T[], field: string): T[] {
return items;
}
function uniqueValues<T extends string>(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'),
},
});
}

View file

@ -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 {

33
src/lib/wall-editing.ts Normal file
View file

@ -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 };
}

View file

@ -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;
}

View file

@ -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('<span>Done</span>');
});
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"');
});
});

View file

@ -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</text>');
expect(markup).toContain('600.5 × 190 mm');
expect(markup).toContain('>600.5</text>');
expect(markup).toContain('>190</text>');
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°</tspan>');
expect(markup).toContain('>53.1°</tspan>');
expect(markup).toContain('angles from horizontal');
});

View file

@ -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);
}
});

View file

@ -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);
});
});

132
tests/fixtures/angled-room-f8.json vendored Normal file
View file

@ -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": []
}
}

115
tests/fixtures/angled-room.json vendored Normal file
View file

@ -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
}
}

View file

@ -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)}));});

18
tests/format.test.ts Normal file
View file

@ -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);
});
});

View file

@ -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)],

View file

@ -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/);
});

View file

@ -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<typeof rectangle>) => 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);
});
});