This commit is contained in:
Andras Schmelczer 2026-09-11 17:57:03 +01:00
commit 7959fe85cc
29 changed files with 1679 additions and 498 deletions

View file

@ -22,10 +22,10 @@ npm run check # Build, unit tests and browser tests
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. 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.
4. Set material dimensions, spacer, kerf and minimum piece sizes alongside the measurements. Kerf means material removed by the blade: a 3 mm cut leaves 600 mm + 397 mm from a 1,000 mm board. Both kerf fields have keyboard and touch tooltips. Set one purchased skirting board length for the whole project. Materials are shared across rooms; direction is per room.
5. Room measurements, materials and cut plans open and close independently on the same page. The drawing stays pinned on the right while you scroll the panels; on phones a compact drawing stays above them. Flooring and skirting are visible together, with the whole drawing fitting without an internal scrollbar. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Cut codes sit above the wall and skirting drawing; short pieces use nearby labels with pointers so their names remain visible. The compact drawing header shows only the room, area and material legend. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Flooring and skirting cuts are grouped by the purchased board they come from, including offcuts reused across rooms. Group headings use the cut codes; internal stock IDs stay out of the interface. Pieces wrap side by side when space allows. Each piece has a small preview that opens dimensions directly on its shape. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Numbered skirting pieces and their labels open cut details; uncut lengths use a neutral fill. Print all room drawings followed by flooring and skirting cut sheets grouped by purchased board. Every cut is included once, with its room and row or wall.
6. Find a cut by its code, or search by room name. Searching several cut codes together, such as `F27 + F28`, finds their pieces together. **Show on plan** highlights the source board’s pieces or an individual cut, with room buttons when a source is reused across rooms. A visible **Search active** banner stays above the pinned drawing; **Show all pieces** clears the search there. Source boards have **Mark done** checkboxes; completion marks appear on their list entries, installed pieces and printed sheets. Room search results also let you locate and mark uncut boards without adding cut labels to them. Measurements, wall order, flooring direction or material changes clear completion because the allocation changes; renaming rooms keeps it.
7. Copy the link to save or share the complete project. Measurements, materials and view choices live in readable query parameters such as `flooring.length=1200`, `skirting.length=2400`, `room.1.wall.1.length=4800` and `selectedCut=F1`. Older stock-list links retain their first length; their completion marks are cleared because coping blanks and rounded stock allocation have changed, including for single-size links. Progress uses repeated `done=floor:B4` or `done=skirting:S6` parameters; `search=F1` and repeated `collapsed=materials` parameters preserve the search and closed panels. You can edit values directly in the URL. Older compressed fragment links still load and migrate automatically; obsolete step, layer and zoom choices are ignored. Reloading or opening that link reproduces the plan. Browser back/forward restores room changes. 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
@ -35,9 +35,9 @@ npm run check # Build, unit tests and browser tests
- Brick uses a half-board stagger. Random is deterministic and discourages staircase runs, near-equal successive steps and repeating joint alignments, including steps that wrap across a board length. Pattern preferences never invalidate a physically valid plan when tight offset limits force repetition. The offset is the nearest end-joint distance on the adjacent row's grid, not a distance from a wall. Half a board is the largest possible nearest-joint offset. Settings that cannot be met produce an error instead of a partial layout.
- Minimum laminate sizes apply to each clipped piece's bounding length/width. Irregular shapes can still have narrow tips or necks; their exact templates are available in the cut popups. Product-specific click systems and assembly sequences are not simulated.
- Offcut reuse preserves the needed left/right factory ends. Only full-width end remnants are available for reuse; ripped side strips and shaped scraps are conservatively waste. Crosscuts include kerf. The stock count is actual allocated boards, not an area-only estimate. Waste includes kerf and unused material, including still-usable stock remainders.
- Skirting internal corners use butt joints: the previous wall runs through and the next is shortened by thickness, adjusted for non-square corners. Coping is excluded. External corners use equal mitres. Minimum sizes apply after corner allowances and door subtraction. An unavoidable undersized run is an error.
- Skirting cut lengths are the longest-point envelope. Cut square blanks first, then bevel their ends as specified; this keeps reusable stock remainders square. Back lengths and diagram positions locate the wall-facing edge. The stock allocation includes crosscut kerf and counts the additional bevel passes.
- Flooring uses a bounded beam search, minimizing purchased boards first. Random layouts then prefer less visual repetition before fewer saw passes; brick layouts prioritize fewer saw passes. This can add cuts to break a staircase without increasing the selected board count. Skirting compares full-stock, balanced and offcut-aware splits and packs them across offered lengths, preferring less waste, then fewer cuts. Available offcuts can move the next wall’s joints. Minimum lengths are reserved on both front and back edges of angled pieces. These are deterministic, auditable heuristics rather than guarantees of a global minimum. Room order is an input and can affect shared-offcut allocation.
- Skirting internal corners use coped joints. The blank keeps the wall-corner long point instead of subtracting the receiving board’s thickness; the cope preparation is half the measured turn. Earlier walls receive adjoining later walls, so installation order has no cycle. At square corners, a 4,000 mm wall keeps a 4,000 mm blank; with 18 mm skirting at both corners its exposed front length is 3,964 mm. Non-square receiving ends have a wall-fitting bevel. External corners use equal mitres. Minimum sizes check the exposed face and installed body after corner and door allowances. Preparation previews and dashed body edges guide marking; the actual moulding profile must be scribed and back-cut, since thickness alone does not define it. This follows [Gary Katz’s coping and long-point measurement guidance](https://www.finehomebuilding.com/pdf/021174063.pdf); the general-angle allowances are derived from intersecting wall faces.
- Skirting cut lengths are the longest-point envelope. Cut square blanks first, then prepare the mitres and cope the indicated internal ends; this keeps reusable stock remainders square. Back lengths and diagram positions locate the wall-facing edge. The stock allocation includes crosscut kerf and counts additional bevel and coping operations.
- Flooring uses a bounded beam search, minimizing purchased boards first. Random layouts then prefer less visual repetition before fewer saw passes; brick layouts prioritize fewer saw passes. This can add cuts to break a staircase without increasing the selected board count. Skirting compares full-stock, balanced and offcut-aware splits and packs them into the single purchased length, preferring less waste, then fewer cuts. Available offcuts can move the next wall’s joints. Minimum lengths are reserved on the exposed face and full-thickness body of angled and coped pieces. These are deterministic, auditable heuristics rather than guarantees of a global minimum. Room order is an input and can affect shared-offcut allocation.
- Board counts exclude an extra allowance for damage or mistakes. Spacer, minimum size and staggering defaults are editable starting values; follow the exact product's installation instructions. For example, [Quick-Step's installation guidance](https://www.quick-step.co.uk/en-gb/laminate/installation) provides product-specific manuals, and its [room-transition guidance](https://www.quick-step.co.uk/en-gb/frequently-asked-questions/laminate/preparation-and-installation/do-i-really-need-expansion-gaps-between-every-room) explains separate expansion joints.
- Links are bounded to 20 rooms, 100 walls per room and 30 doors per wall. Flooring calculations are bounded to 2,000 purchased boards and 500 rows per room. Large plans may take longer; very large projects should be split into separate links. Long links may exceed the limits of a messaging service.
@ -51,6 +51,7 @@ npm run check # Build, unit tests and browser tests
| `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/piece-labels.ts` | Collision-free cut-code placement and pointers for small pieces |
| `src/lib/board-tracking.ts` | Exact board/cut matching and room-name search |
| `src/lib/wall-editing.ts` | Wall insertion and reordering with stable IDs |
| `src/lib/use-project.ts` | URL and browser-history synchronization |

View file

@ -3,7 +3,7 @@ import type { Locator, Page } from '@playwright/test';
import LZString from 'lz-string';
import { readFile } from 'node:fs/promises';
import { decodeState, encodeState } from '../src/lib/url-state';
import { defaultState, rectangle } from '../src/lib/defaults';
import { defaultState, fireplace, 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';
@ -49,6 +49,25 @@ function sharedRoomFixture() {
return fixture;
}
function shortFireplaceFixture() {
const fixture = defaultState();
fixture.project.rooms = [fireplace('room-2', 'Room 2')];
fixture.project.flooring = {
length: 1200,
width: 190,
pattern: 'random',
minOffset: 100,
maxOffset: 1000,
minLength: 10,
minWidth: 0.1,
gap: 10,
kerf: 3,
};
fixture.project.skirting = { stockLength: 2400, thickness: 27, minLength: 200, kerf: 3 };
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 (
@ -252,15 +271,25 @@ test.afterEach(async ({ page }) => {
test('measurements, materials, drawing and both cut lists share one page', async ({ page }) => {
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800');
await expect(page.getByLabel('Room length', { exact: true })).toHaveValue('3600');
for (const label of ['Board length', 'Board width', 'Stock length 1', 'Skirting thickness']) {
for (const label of [
'Board length',
'Board width',
'Skirting board length',
'Skirting thickness',
]) {
await expect(page.getByLabel(label, { exact: true })).toBeVisible();
}
await expect(page.getByRole('heading', { name: 'Materials', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: /^Flooring cuts/ })).toBeVisible();
await expect(page.getByRole('heading', { name: /^Skirting cuts/ })).toBeVisible();
await expect(preview(page).getByText('Outline closed', { exact: true })).toBeVisible();
expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0);
await expect(page.locator('.layer-switch, .step-nav')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Export CSV' })).toHaveCount(0);
await expect(preview(page).getByText('Outline closed', { exact: true })).toHaveCount(0);
await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveCount(1);
await expect(
page.getByRole('button', { name: /Add stock length|Remove stock length/ }),
).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);
@ -406,7 +435,7 @@ test('custom walls close using a calculated wall that can be measured again', as
await page.getByLabel('Wall 3 length', { exact: true }).fill('4000');
await expect(page.getByText('3,000 mm to close')).toBeVisible();
await page.getByRole('button', { name: 'Add closing wall' }).click();
await expect(preview(page).getByText('Outline closed', { exact: true })).toBeVisible();
expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0);
await openWalls(page);
await expect(page.getByLabel('Wall 4 length', { exact: true })).toHaveValue('3000');
await page.getByLabel('Wall 4 length', { exact: true }).fill('3050');
@ -489,6 +518,70 @@ test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialo
await expect(page.locator('.selected-cut')).toHaveCount(0);
});
test('internal skirting corners retain coping blanks and expose the installed minimum', async ({
page,
}) => {
const fixture = defaultState();
fixture.project.rooms = [rectangle('room-1', 'Coped room', 2400, 2400)];
fixture.project.rooms[0].walls.forEach((wall) => {
wall.doors = [];
});
fixture.project.skirting = { stockLength: 2400, thickness: 18, minLength: 200, kerf: 3 };
await page.goto(`/${encodeState(fixture)}`);
const { skirt } = plans(page);
expect(skirt.stockCount).toBe(4);
expect(skirt.cuts).toBe(8);
expect(skirt.pieces).toHaveLength(4);
expect(
skirt.pieces.every(
(piece) =>
Math.abs(piece.length - 2400) < 0.001 && Math.abs(piece.visibleLength! - 2364) < 0.001,
),
).toBe(true);
const receiver = skirt.pieces.find((piece) => piece.wallIndex === 0)!;
expect([receiver.leftType, receiver.rightType]).toEqual(['internal', 'internal']);
expect(skirtingCuts(skirt).map((piece) => piece.id)).toEqual(['A1', 'A2', 'A3']);
await expect(skirtCodes(page)).toHaveCount(3);
await expect(page.locator('.skirting-cuts .cut-summary')).toContainText(
'Buy 4 lengths · 1 installed uncut',
);
const piece = skirt.pieces.find((piece) => piece.id === 'A1')!;
const entry = skirtEntries(page).filter({
has: page.getByRole('button', { name: 'Preview skirting cut A1', exact: true }),
});
await expect(entry.locator('.cut-size')).toContainText('2,400 mm');
await expect(entry).toContainText('Left: Cope · prepare 45°');
await expect(entry).toContainText('Right: Receive cope · square');
await entry.getByRole('button', { name: 'Preview skirting cut A1', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Skirting cut A1', exact: true });
await expect(dialog).toContainText('preparation before coping');
await expect(dialog).toContainText('mark your moulding’s exposed profile');
await expect(dialog).toContainText('Exposed front length: 2,364 mm.');
const end = dialog.getByRole('img', { name: 'Left cope preparation close-up A1', exact: true });
await expect(end).toBeVisible();
const body = end.locator('.skirting-cope-body');
await expect(body).toHaveAttribute('stroke-dasharray', '3 2');
const geometry = await end.evaluate((element) => {
const prep = element.querySelector('.skirting-cut-edge') as SVGLineElement;
const body = element.querySelector('.skirting-cope-body') as SVGLineElement;
return {
preparationX: prep.x2.baseVal.value - prep.x1.baseVal.value,
preparationY: prep.y2.baseVal.value - prep.y1.baseVal.value,
installedX: body.x2.baseVal.value - body.x1.baseVal.value,
};
});
expect(Math.abs(geometry.preparationX)).toBeCloseTo(18, 3);
expect(Math.abs(geometry.preparationY)).toBeCloseTo(18, 3);
expect(geometry.installedX).toBeCloseTo(0, 3);
expect(piece.leftOverlap).toBeCloseTo(18, 3);
await page.keyboard.press('Escape');
const floorCount = await floorEntries(page).count();
await page.getByLabel('Minimum piece', { exact: true }).fill('2365');
await expect(skirtEntries(page)).toHaveCount(0);
await expect(floorEntries(page)).toHaveCount(floorCount);
await expect(page.locator('.issue.error').first()).toBeVisible();
});
test('door positions and material lengths update skirting immediately', async ({ page }) => {
const before = await page.locator('.skirting-cuts').innerText();
await page.getByRole('button', { name: 'Add a doorway' }).click();
@ -509,7 +602,7 @@ test('door positions and material lengths update skirting immediately', async ({
'Door on W1: 1,200 mm from W4 corner, then 900 mm opening toward W2 corner',
);
expect(await page.locator('.skirting-cuts').innerText()).not.toBe(before);
await page.getByLabel('Stock length 1', { exact: true }).fill('3600');
await page.getByLabel('Skirting board length', { exact: true }).fill('3600');
await page.getByLabel('Skirting thickness', { exact: true }).fill('22');
const { skirt } = plans(page);
await expect(skirtCodes(page)).toHaveCount(skirtingCuts(skirt).length);
@ -523,14 +616,41 @@ test('door positions and material lengths update skirting immediately', async ({
test('invalid skirting settings keep flooring results usable', async ({ page }) => {
const floorCount = await floorCodes(page).count();
await page.getByLabel('Stock length 1', { exact: true }).fill('100');
await page.getByLabel('Skirting board length', { exact: true }).fill('100');
await expect(skirtEntries(page)).toHaveCount(0);
await expect(floorCodes(page)).toHaveCount(floorCount);
await expect(page.locator('.issue.error').first()).toBeVisible();
await page.getByLabel('Stock length 1', { exact: true }).fill('2400');
await page.getByLabel('Skirting board length', { exact: true }).fill('2400');
expect(await skirtEntries(page).count()).toBeGreaterThan(0);
});
test('fractional stock keeps the planner usable and displays the blanks that actually fit', async ({
page,
}) => {
const fixture = defaultState();
fixture.project.rooms = [rectangle('room-1', 'Fractional room', 2400.1, 2400.1)];
fixture.project.rooms[0].walls.forEach((wall) => {
wall.doors = [];
});
fixture.project.skirting = { stockLength: 1200.2, thickness: 18, minLength: 300, kerf: 3 };
await page.goto(`/${encodeState(fixture)}`);
await expect(preview(page)).toBeVisible();
await expect(skirtCodes(page)).toHaveCount(7);
const code = skirtingCuts(plans(page).skirt).find(
(piece) => Math.abs(piece.length - 1200.2) < 1e-6,
)!.id;
const entry = skirtEntries(page).filter({
has: page.getByRole('button', { name: `Preview skirting cut ${code}`, exact: true }),
});
await expect(entry.locator('.cut-size')).toContainText('1,200.2 mm');
await entry.getByRole('button', { name: `Preview skirting cut ${code}`, exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.locator('.skirting-full-preview figcaption')).toContainText(
'1,200.2 × 18 mm',
);
expect(await dialog.locator('.dimension-label').allTextContents()).not.toContain('1,200.5');
});
test('pattern, direction and spacers persist without steps, layers or stored results', async ({
page,
}) => {
@ -676,7 +796,8 @@ test('clicking the skirting code itself opens its cutting dialog', async ({ page
const id = await code.evaluate((element) =>
element.closest('[data-piece-id]')!.getAttribute('data-piece-id'),
);
await code.locator(target).click();
// The text receives centre clicks; use the exposed corner to test the backing rectangle.
await code.locator(target).click(target === 'rect' ? { position: { x: 1, y: 1 } } : {});
await expect(
page.getByRole('dialog', { name: `Skirting cut ${id}`, exact: true }),
).toBeVisible();
@ -724,6 +845,35 @@ test('readable query fields, legacy links and browser history preserve the plan'
expect(state(page).project.rooms).toHaveLength(2);
});
test('legacy skirting sizes migrate to one editable length and reset changed allocations', async ({
page,
}) => {
const initial = defaultState();
const legacy = {
...initial,
project: {
...initial.project,
completedBoards: ['skirting:S1'],
skirting: { ...initial.project.skirting, stockLength: undefined, stockLengths: [3600, 2400] },
},
};
await page.goto(`/#plan=${LZString.compressToEncodedURIComponent(JSON.stringify(legacy))}`);
await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('3600');
expect(state(page).project.skirting.stockLength).toBe(3600);
expect(state(page).project.completedBoards).toEqual([]);
const params = new URL(page.url()).searchParams;
expect(params.get('skirting.length')).toBe('3600');
expect(params.has('skirting.stock')).toBe(false);
await page.getByLabel('Skirting board length', { exact: true }).fill('3000');
await page.reload();
await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('3000');
await page.goto('/?skirting.stock=2400&skirting.stock=2400&done=skirting:S1');
await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('2400');
expect(state(page).project.completedBoards).toEqual([]);
expect(new URL(page.url()).searchParams.get('skirting.length')).toBe('2400');
expect(new URL(page.url()).searchParams.has('skirting.stock')).toBe(false);
});
test('printing includes every room and every cut once, grouped by its source board', async ({
page,
}) => {
@ -832,7 +982,7 @@ test('drawing cuts still open a usable preview when the cut plan is collapsed',
}) => {
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();
await preview(page).locator('.floor-code[data-piece-id="F1"] text').click();
const dialog = page.getByRole('dialog', { name: 'Flooring cut F1', exact: true });
await expect(dialog).toBeVisible();
await expect(dialog.locator('.cut-dimensions')).toBeVisible();
@ -864,7 +1014,7 @@ test('negative angles can be backspaced and retyped without saving unfinished dr
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();
expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0);
const restored = page.url();
await turn.fill('-');
expect(page.url()).toBe(restored);
@ -947,6 +1097,103 @@ test('the supplied angled room keeps W12 lines and labels inside the drawing', a
}
});
test('short fireplace cuts retain positive shapes and readable labels above the walls', async ({
page,
isMobile,
}) => {
await page.setViewportSize({ width: isMobile ? 320 : 1280, height: 844 });
await page.goto(`/${encodeState(shortFireplaceFixture())}`);
const { floor } = plans(page);
const cuts = flooringCuts(floor);
expect(cuts.some((piece) => piece.length < 100)).toBe(true);
for (const piece of floor.pieces) {
expect(piece.length).toBeGreaterThan(0);
expect(piece.width).toBeGreaterThan(0);
const twiceArea = piece.polygon.reduce((sum, point, i) => {
const next = piece.polygon[(i + 1) % piece.polygon.length];
return sum + point.x * next.y - next.x * point.y;
}, 0);
expect(Math.abs(twiceArea)).toBeGreaterThan(0.001);
}
const labels = preview(page).locator('.floor-code');
await expect(labels).toHaveCount(cuts.length);
const failures = await preview(page)
.locator('svg.room-svg')
.evaluate((element) => {
const svg = element as SVGSVGElement;
const box = svg.viewBox.baseVal;
const inverse = svg.getScreenCTM()!.inverse();
const geometry = [...svg.querySelectorAll('.wall-drawing, .skirt-piece')];
const failures: string[] = [];
const rectangles: { id: string | null; rect: DOMRect }[] = [];
for (const label of svg.querySelectorAll('.floor-code')) {
const id = label.getAttribute('data-piece-id');
const rect = label.querySelector('rect') as SVGRectElement;
const text = label.querySelector('text') as SVGTextElement;
const rectBox = rect.getBoundingClientRect(),
textBox = text.getBoundingClientRect();
if (
textBox.left < rectBox.left - 0.5 ||
textBox.right > rectBox.right + 0.5 ||
textBox.top < rectBox.top - 0.5 ||
textBox.bottom > rectBox.bottom + 0.5
)
failures.push(`${id}: text exceeds its backing rectangle`);
rectangles.push({ id, rect: rectBox });
const bounds = rect.getBBox();
const transform = inverse.multiply(rect.getScreenCTM()!);
const corners = [
[bounds.x, bounds.y],
[bounds.x + bounds.width, bounds.y],
[bounds.x + bounds.width, bounds.y + bounds.height],
[bounds.x, bounds.y + bounds.height],
].map(([x, y]) => new DOMPoint(x, y).matrixTransform(transform));
if (
corners.some(
(point) =>
point.x < box.x - 0.01 ||
point.y < box.y - 0.01 ||
point.x > box.x + box.width + 0.01 ||
point.y > box.y + box.height + 0.01,
)
)
failures.push(`${id}: label extends outside the SVG viewBox`);
if (
!geometry.every((shape) =>
Boolean(shape.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING),
)
)
failures.push(`${id}: wall or skirting paints above label`);
}
rectangles.forEach((first, i) =>
rectangles.slice(i + 1).forEach((second) => {
if (
Math.min(first.rect.right, second.rect.right) -
Math.max(first.rect.left, second.rect.left) >
0.5 &&
Math.min(first.rect.bottom, second.rect.bottom) -
Math.max(first.rect.top, second.rect.top) >
0.5
)
failures.push(`${first.id} and ${second.id}: label rectangles overlap`);
}),
);
return failures;
});
expect(failures).toEqual([]);
expect(await labels.locator('.piece-label-leader').count()).toBeGreaterThan(0);
const short = cuts.find((piece) => piece.length < 100)!;
await preview(page).locator(`.floor-code[data-piece-id="${short.id}"] text`).click();
await expect(
page.getByRole('dialog', { name: `Flooring cut ${short.id}`, exact: true }),
).toBeVisible();
await page.keyboard.press('Escape');
const header = (await preview(page).locator('.drawing-header').boundingBox())!;
const legend = (await preview(page).locator('.drawing-legend').boundingBox())!;
expect(legend.y + legend.height - header.y).toBeLessThanOrEqual(isMobile ? 70 : 60);
await noHorizontalOverflow(page);
});
test('the supplied F8 room aligns a row seam with its fireplace corner and keeps exact templates', async ({
page,
}) => {
@ -1108,6 +1355,32 @@ test('completed source boards and locate actions follow shared offcuts across ro
expect(new URL(page.url()).searchParams.getAll('done')).toEqual([]);
});
test('the pinned plan makes active search obvious and can restore every piece', async ({
page,
}) => {
const allCuts = await floorEntries(page).count();
await page.getByLabel('Find a cut or room', { exact: true }).fill('F1');
const banner = page.locator('.drawing-column .plan-search-status');
await expect(banner).toContainText('Search active');
await expect(banner).toContainText('F1');
await expect(banner.getByRole('button', { name: 'Show all pieces', exact: true })).toBeVisible();
expect(await highlightedIds(page, 'flooring')).toEqual(['F1']);
await panel(page, 'cuts').locator('.panel-toggle').click();
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
const bounds = (await banner.boundingBox())!;
expect(bounds.y).toBeGreaterThanOrEqual(0);
expect(bounds.y + bounds.height).toBeLessThanOrEqual(page.viewportSize()!.height);
await banner.getByRole('button', { name: 'Show all pieces', exact: true }).click();
await expect(banner).toHaveCount(0);
expect(state(page).view.search).toBe('');
expect(await highlightedIds(page)).toEqual([]);
await expect(preview(page).locator('.search-muted')).toHaveCount(0);
await panel(page, 'cuts').locator('.panel-toggle').click();
await expect(page.getByLabel('Find a cut or room', { exact: true })).toHaveValue('');
await expect(floorEntries(page)).toHaveCount(allCuts);
await noHorizontalOverflow(page);
});
test('cuts from one board share the available width and wrap on a narrow phone', async ({
page,
isMobile,

View file

@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import type { ReactNode } from 'react';
import { Copy, Plus, Printer } from 'lucide-react';
import { Copy, Plus, Printer, Search, X } from 'lucide-react';
import { useProject } from './lib/use-project';
import { rectangle, nextId } from './lib/defaults';
import { buildGeometry } from './lib/geometry';
@ -314,14 +314,8 @@ export default function App() {
{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
{skirting.stockCount} × {mm(project.skirting.stockLength)} mm ·{' '}
{wholeSkirting} used whole
</p>
</div>
<span className="waste-stat">
@ -353,24 +347,26 @@ export default function App() {
</PlannerPanel>
</div>
<aside className="drawing-column" id="drawing" aria-label="Room drawing">
<FloorPlan
room={room}
geometry={geometry}
flooring={flooring}
skirting={skirting}
selectedPiece={view.selectedPiece}
select={(id) => select(id)}
completedBoards={project.completedBoards}
highlightedPieces={matches.pieceIds}
searchActive={!!view.search.trim()}
/>
{view.search.trim() && (
<div className="plan-search-status" role="status">
<p>
<div className="search-mode-heading">
<div className="search-mode-text">
<strong>
<Search size={15} aria-hidden="true" />
Search active
</strong>
<p title={searchLabel}>
{searchLabel} ·{' '}
{matches.pieceIds.length
? `${matches.pieceIds.length} piece${matches.pieceIds.length === 1 ? '' : 's'} found for “${searchLabel}”`
: `No boards or cuts match “${searchLabel}”`}
? `${matches.pieceIds.length} piece${matches.pieceIds.length === 1 ? '' : 's'}`
: 'No matching pieces'}
</p>
</div>
<button className="button clear-plan-search" onClick={() => setSearch('')}>
<X size={16} aria-hidden="true" />
Show all pieces
</button>
</div>
{matches.roomIds.length > 1 && (
<div className="matching-rooms">
{project.rooms
@ -393,6 +389,17 @@ export default function App() {
)}
</div>
)}
<FloorPlan
room={room}
geometry={geometry}
flooring={flooring}
skirting={skirting}
selectedPiece={view.selectedPiece}
select={(id) => select(id)}
completedBoards={project.completedBoards}
highlightedPieces={matches.pieceIds}
searchActive={!!view.search.trim()}
/>
</aside>
</div>
</main>

View file

@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import type { FlooringPiece, Project, SkirtingPiece } from '../lib/types';
import { flooringInstructions } from '../lib/cut-instructions';
import { readyOffcutInstructions } from '../lib/cut-list';
import { readyOffcutInstructions, skirtingInstructions } from '../lib/cut-list';
import { FlooringPreview, SkirtingPreview } from './CutPreview';
interface Props {
@ -79,10 +79,7 @@ export function CutDialog({ floor, skirt, project, close }: Props) {
{room} · Wall {skirt.wallIndex + 1}
</p>
<SkirtingPreview piece={skirt} thickness={project.skirting.thickness} />
<p className="cut-instructions">
Measure the longest points. Mitre angles are measured from square; the end views show
the cut direction.
</p>
<p className="cut-instructions">{skirtingInstructions(skirt)}</p>
</>
)}
</div>

View file

@ -1,11 +1,12 @@
import { MapPin, X } from 'lucide-react';
import type { FlooringPlan, Project, SkirtingPlan } from '../lib/types';
import { cutMm, degree, mm } from '../lib/format';
import { cutMm, mm } from '../lib/format';
import {
flooringCuts,
groupFlooringCuts,
groupSkirtingCuts,
skirtingCuts,
skirtingEndLabel,
uncutFlooring,
uncutSkirting,
} from '../lib/cut-list';
@ -198,7 +199,8 @@ export function CutList({
</button>
<div className="board-cut-info">
<p className="cut-size">
{cutMm(piece.length)} × {cutMm(piece.width)} mm
{cutMm(piece.length, piece.blankLength)} ×{' '}
{cutMm(piece.width, piece.blankWidth)} mm
</p>
<p className="cut-location">
{roomName(piece.roomId)} · Row {piece.row}
@ -302,13 +304,16 @@ export function CutList({
</button>
<div className="board-cut-info">
<p className="cut-size">
{cutMm(piece.length)} mm <span className="cut-note">long point</span>
{cutMm(piece.length, piece.blankLength)} 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)}
Left: {skirtingEndLabel(piece, 'left')}
<br />
Right: {skirtingEndLabel(piece, 'right')}
</p>
<button
className="button cut-locate"

View file

@ -1,6 +1,7 @@
import { Fragment } from 'react';
import type { FlooringPiece, SkirtingPiece } from '../lib/types';
import { cutMm, degree } from '../lib/format';
import { skirtingEndLabel } from '../lib/cut-list';
import {
dimensionedOutline,
flooringTemplate,
@ -114,7 +115,10 @@ export function FlooringPreview({
const template = flooringTemplate(piece);
const dimensions = compact
? undefined
: dimensionedOutline(template, piece.shaped ? undefined : [0, 1]);
: dimensionedOutline(template, piece.shaped ? undefined : [0, 1], {
length: piece.blankLength,
width: piece.blankWidth,
});
const Wrapper = compact ? 'span' : 'figure';
return (
<Wrapper className={`flooring-preview-figure ${compact ? 'compact-preview' : 'exact-preview'}`}>
@ -137,7 +141,7 @@ export function FlooringPreview({
</svg>
{!compact && (
<figcaption>
{cutMm(piece.length)} × {cutMm(piece.width)} mm
{cutMm(piece.length, piece.blankLength)} × {cutMm(piece.width, piece.blankWidth)} mm
{dimensions?.dimensions.some((dimension) => dimension.angle !== undefined) &&
' · angles from horizontal'}
</figcaption>
@ -160,12 +164,13 @@ export function SkirtingPreview({
const EndWrapper = compact ? 'span' : 'figure';
const Grid = compact ? 'span' : 'div';
if (template.length !== 4) return <span className="helper">Shape template unavailable</span>;
const boxes = skirtingEndBoxes(template, thickness);
const boxes = skirtingEndBoxes(template, thickness, piece.installedTemplate);
const dimensions = compact
? undefined
: dimensionedOutline(
[template[0], template[1], { x: piece.length, y: thickness }, { x: 0, y: thickness }],
[0, 2],
{ length: piece.blankLength, width: thickness },
);
// The lower dimension measures the overall blank, between its actual longest points.
const overall = dimensions?.dimensions.find((dimension) => dimension.edge === 2);
@ -175,8 +180,24 @@ export function SkirtingPreview({
}
const points = template.map((point) => `${point.x},${point.y}`).join(' ');
const ends = [
{ side: 'left', back: template[0], front: template[3], box: boxes.left },
{ side: 'right', back: template[1], front: template[2], box: boxes.right },
{
side: 'left',
back: template[0],
front: template[3],
box: boxes.left,
type: piece.leftType,
installedBack: piece.installedTemplate?.[0],
installedFront: piece.installedTemplate?.[3],
},
{
side: 'right',
back: template[1],
front: template[2],
box: boxes.right,
type: piece.rightType,
installedBack: piece.installedTemplate?.[1],
installedFront: piece.installedTemplate?.[2],
},
] as const;
return (
<Wrapper className={`skirting-preview ${compact ? 'compact-preview' : 'exact-preview'}`}>
@ -198,7 +219,10 @@ export function SkirtingPreview({
<Dimensions dimensions={dimensions!.dimensions} font={dimensions!.font} />
</svg>
<figcaption>
{cutMm(piece.length)} × {cutMm(thickness)} mm · wall side above
{cutMm(piece.length, piece.blankLength)} × {cutMm(thickness, thickness)} mm · wall side
above
{(piece.leftType === 'cope' || piece.rightType === 'cope') &&
' · preparation before coping'}
</figcaption>
</figure>
)}
@ -217,7 +241,7 @@ export function SkirtingPreview({
aria-label={
compact
? undefined
: `${end.side === 'left' ? 'Left' : 'Right'} mitre close-up ${piece.id}`
: `${end.side === 'left' ? 'Left' : 'Right'} ${end.type === 'cope' ? 'cope preparation' : 'mitre'} close-up ${piece.id}`
}
>
<polygon
@ -238,12 +262,24 @@ export function SkirtingPreview({
strokeWidth={compact ? 1.5 : 2.5}
vectorEffect="non-scaling-stroke"
/>
{end.type === 'cope' && end.installedBack && end.installedFront && (
<line
className="skirting-cope-body"
data-end={end.side}
x1={end.installedBack.x}
y1={end.installedBack.y}
x2={end.installedFront.x}
y2={end.installedFront.y}
stroke="#9a3412"
strokeWidth={1.5}
strokeDasharray="3 2"
vectorEffect="non-scaling-stroke"
/>
)}
</svg>
{!compact && (
<figcaption>
{index === 0 ? 'Left' : 'Right'} ·{' '}
{degree(index === 0 ? piece.leftMitre : piece.rightMitre)}{' '}
{index === 0 ? piece.leftType : piece.rightType}
{index === 0 ? 'Left' : 'Right'} · {skirtingEndLabel(piece, end.side)}
</figcaption>
)}
</EndWrapper>

View file

@ -1,7 +1,7 @@
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';
import { AlertCircle, Info } from 'lucide-react';
const numberFromDraft = (text: string): number | null => {
if (!/^-?(?:\d+(?:\.\d*)?|\.\d+)$/.test(text)) return null;
@ -139,11 +139,3 @@ export function Issues({ issues, compact = false }: { issues: Issue[]; compact?:
</div>
);
}
export function ClosedBadge({ closed }: { closed: boolean }) {
return (
<span className={`badge ${closed ? 'success' : 'pending'}`}>
{closed ? <Check size={12} /> : <AlertCircle size={12} />}{' '}
{closed ? 'Outline closed' : 'Check outline'}
</span>
);
}

View file

@ -5,8 +5,8 @@ import { signedArea } from '../lib/geometry';
import { isSkirtingCut } from '../lib/cut-list';
import { skirtingPolygon } from '../lib/skirting-polygon';
import { dimensionLabels, wallDimensionBox } from '../lib/dimension-labels';
import { floorPieceLabels } from '../lib/piece-labels';
import type { DimensionLabel, LabelBox } from '../lib/dimension-labels';
import { ClosedBadge } from './Fields';
const points = (path: Point[]) => path.map((p) => `${p.x},${p.y}`).join(' ');
const midpoint = (a: Point, b: Point): Point => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
@ -141,42 +141,67 @@ export function FloorPlan({
const orientation = Math.sign(signedArea(geometry.vertices)) || 1;
const floorPieces = flooring.pieces.filter((p) => p.roomId === room.id);
const skirtPieces = skirting.pieces.filter((p) => p.roomId === room.id);
const skirtLabels = new Map<string, Point>();
const skirtLabels = new Map<string, Point & { width: number; text: string }>();
const reserved: LabelBox[] = [];
for (const piece of skirtPieces) {
if (!isSkirtingCut(piece, skirting)) continue;
const center = midpoint(piece.start, piece.end);
const length = Math.hypot(piece.end.x - piece.start.x, piece.end.y - piece.start.y) || 1;
const x = center.x + ((orientation * (piece.end.y - piece.start.y)) / length) * span * 0.032;
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.4,
maxX: x + font * 1.4,
const text = piece.id + (completedBoards.includes(`skirting:${piece.stockId}`) ? ' ✓' : '');
const width = Math.max(font * 1.8, text.length * font * 0.8 * 0.64 + font * 0.55);
let box!: LabelBox;
for (let lane = 0; lane < 24; lane++) {
const offset = span * (0.032 + lane * 0.035);
const x = center.x + ((orientation * (piece.end.y - piece.start.y)) / length) * offset;
const y = center.y - ((orientation * (piece.end.x - piece.start.x)) / length) * offset;
box = {
minX: x - width / 2,
maxX: x + width / 2,
minY: y - font * 0.7,
maxY: y + font * 0.7,
});
};
skirtLabels.set(piece.id, { x, y, width, text });
if (
!reserved.some(
(other) =>
box.minX < other.maxX &&
box.maxX > other.minX &&
box.minY < other.maxY &&
box.maxY > other.minY,
)
)
break;
}
reserved.push(box);
}
const labels = dimensionLabels(geometry.edges, span, orientation, reserved);
const floorLabels = floorPieceLabels(
floorPieces,
geometry.vertices,
room.direction,
font,
[...reserved, ...labels.map((label) => label.box)],
completedBoards,
);
const boxes = [
...reserved,
...labels.map((label, i) => wallDimensionBox(geometry.edges[i], label, span)),
...floorLabels.map((label) => label.box),
];
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;
const height = Math.max(maxY, ...boxes.map((b) => b.maxY)) + span * 0.035 - top;
return (
<section className="drawing-card" aria-label="Floor plan preview">
<section
className="drawing-card"
aria-label="Floor plan preview"
data-search-active={searchActive}
>
<div className="drawing-header">
<h2>
Floor plan{' '}
<small>
{room.name} · {area(geometry.area)} m²
</small>
{room.name} <small>{area(geometry.area)} m²</small>
</h2>
<ClosedBadge closed={geometry.closed && geometry.valid} />
</div>
<div className="drawing-legend">
<span>
<i className="floor-key" />
@ -191,7 +216,7 @@ export function FloorPlan({
Uncut
</span>
</div>
{interactive && <p className="drawing-help">Select a numbered piece for cut details.</p>}
</div>
<div className="drawing-viewport">
<svg
className="room-svg"
@ -289,30 +314,9 @@ export function FloorPlan({
)}
{cut && (
<>
<g
transform={`translate(${x} ${y})${room.direction === 90 ? ' rotate(90)' : ''}`}
pointerEvents="none"
>
<rect
x={-font * 1.3}
y={-font * 0.55}
width={font * 2.6}
height={font * 1.1}
fill={done ? '#27655d' : '#1647ab'}
/>
<text
textAnchor="middle"
dominantBaseline="middle"
fill="#fff"
fontSize={font * 0.76}
fontWeight="600"
>
{piece.id}
{done ? ' ✓' : ''}
</text>
</g>
<title>
{piece.id}: {cutMm(piece.length)} × {cutMm(piece.width)} mm
{piece.id}: {cutMm(piece.length, piece.blankLength)} ×{' '}
{cutMm(piece.width, piece.blankWidth)} mm
</title>
</>
)}
@ -326,7 +330,6 @@ export function FloorPlan({
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;
const polygon = skirtingPolygon(piece, thickness, orientation);
@ -393,33 +396,10 @@ export function FloorPlan({
✓
</text>
)}
{label && (
<g className="skirting-code" transform={`translate(${label.x} ${label.y})`}>
<rect
x={-font * 1.3}
y={-font * 0.58}
width={font * 2.6}
height={font * 1.16}
fill={done ? '#27655d' : '#b74900'}
stroke="#8d3800"
strokeWidth={span * 0.0012}
/>
<text
textAnchor="middle"
dominantBaseline="middle"
fill="#fff"
fontSize={font * 0.8}
fontWeight="700"
>
{piece.id}
{done ? ' ✓' : ''}
</text>
</g>
)}
{cut && (
<title>
{piece.id}: {cutMm(piece.length)} mm · {mm(piece.leftMitre)}° /{' '}
{mm(piece.rightMitre)}°
{piece.id}: {cutMm(piece.length, piece.blankLength)} mm · {mm(piece.leftMitre)}°
/ {mm(piece.rightMitre)}°
</title>
)}
</g>
@ -436,6 +416,97 @@ export function FloorPlan({
strokeDasharray={`${span * 0.015} ${span * 0.008}`}
/>
)}
<g className="skirting-labels" aria-hidden="true">
{skirtPieces.map((piece) => {
const label = skirtLabels.get(piece.id);
if (!label) return null;
const done = completedBoards.includes(`skirting:${piece.stockId}`);
const highlighted = highlightedPieces.includes(piece.id);
return (
<g
key={piece.id}
className={`skirting-code ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
data-piece-id={piece.id}
transform={`translate(${label.x} ${label.y})`}
onClick={interactive ? () => select?.(piece.id) : undefined}
>
<rect
x={-label.width / 2}
y={-font * 0.58}
width={label.width}
height={font * 1.16}
fill={done ? '#27655d' : '#b74900'}
stroke="#8d3800"
strokeWidth={span * 0.0012}
/>
<text
textAnchor="middle"
dominantBaseline="middle"
fill="#fff"
fontSize={font * 0.8}
fontWeight="700"
>
{label.text}
</text>
</g>
);
})}
</g>
<g className="floor-labels" aria-hidden="true">
{floorLabels.map((label) => {
const piece = floorPieces.find((piece) => piece.id === label.id)!;
const done = completedBoards.includes(`floor:${piece.stockId}`);
const highlighted = highlightedPieces.includes(label.id);
return (
<g
key={label.id}
className={`floor-code ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
data-piece-id={label.id}
onClick={interactive ? () => select?.(label.id) : undefined}
>
{label.leader && (
<>
<line
className="piece-label-leader"
x1={label.anchor.x}
y1={label.anchor.y}
x2={label.x}
y2={label.y}
stroke="#1647ab"
strokeWidth={span * 0.0015}
pointerEvents="none"
/>
<circle
cx={label.anchor.x}
cy={label.anchor.y}
r={font * 0.09}
fill="#1647ab"
pointerEvents="none"
/>
</>
)}
<g transform={`translate(${label.x} ${label.y}) rotate(${label.angle})`}>
<rect
x={-label.width / 2}
y={-label.height / 2}
width={label.width}
height={label.height}
fill={done ? '#27655d' : '#1647ab'}
/>
<text
textAnchor="middle"
dominantBaseline="middle"
fill="#fff"
fontSize={label.font}
fontWeight="600"
>
{label.text}
</text>
</g>
</g>
);
})}
</g>
</svg>
</div>
</section>

View file

@ -1,4 +1,4 @@
import { Plus, Trash2, ArrowRight, ArrowDown } from 'lucide-react';
import { ArrowRight, ArrowDown } from 'lucide-react';
import type { Project, Room, FlooringSettings, SkirtingSettings } from '../lib/types';
import { NumberField } from './Fields';
@ -17,7 +17,7 @@ export function MaterialsEditor({ project, room, changeProject, changeRoom }: Pr
const s = project.skirting;
const floor = (key: keyof FlooringSettings, value: number | string) =>
changeProject((p) => ({ ...p, flooring: { ...p.flooring, [key]: value } }));
const skirt = (key: keyof SkirtingSettings, value: number | number[]) =>
const skirt = (key: keyof SkirtingSettings, value: number) =>
changeProject((p) => ({ ...p, skirting: { ...p.skirting, [key]: value } }));
return (
@ -118,42 +118,13 @@ export function MaterialsEditor({ project, room, changeProject, changeRoom }: Pr
<h3 id="skirting-materials" className="material-heading">
Skirting boards
</h3>
{s.stockLengths.map((length, i) => (
<div className="stock-input" key={i}>
<NumberField
label={`Stock length ${i + 1}`}
value={length}
min={1}
onChange={(n) =>
skirt(
'stockLengths',
s.stockLengths.map((v, j) => (i === j ? n : v)),
)
}
/>
<button
className="icon-button"
aria-label={`Remove stock length ${i + 1}`}
disabled={s.stockLengths.length === 1}
onClick={() =>
skirt(
'stockLengths',
s.stockLengths.filter((_, j) => i !== j),
)
}
>
<Trash2 size={16} />
</button>
</div>
))}
<button
className="text-button"
onClick={() => skirt('stockLengths', [...s.stockLengths, 3600])}
disabled={s.stockLengths.length >= 10}
>
<Plus size={16} /> Add a stock length
</button>
<div className="field-grid">
<NumberField
label="Skirting board length"
value={s.stockLength}
min={1}
onChange={(n) => skirt('stockLength', n)}
/>
<NumberField
label="Skirting thickness"
value={s.thickness}

View file

@ -4,6 +4,7 @@ import {
groupFlooringCuts,
groupSkirtingCuts,
readyOffcutInstructions,
skirtingInstructions,
uncutFlooring,
uncutSkirting,
} from '../lib/cut-list';
@ -118,6 +119,7 @@ export function PrintPlan({
</span>
</h4>
<SkirtingPreview piece={piece} thickness={project.skirting.thickness} />
<p>{skirtingInstructions(piece)}</p>
</article>
))}
</div>

View file

@ -6,6 +6,8 @@ import type {
SkirtingPlan,
} from './types';
import { cutMm, degree } from './format';
const sameLength = (a: number, b: number) => Math.abs(a - b) < 0.01;
/** A ready offcut still needs a placement code; only fresh, uncut whole boards are omitted. */
@ -15,6 +17,8 @@ export const flooringCuts = (plan: FlooringPlan) =>
export function isSkirtingCut(piece: SkirtingPiece, plan: SkirtingPlan): boolean {
const stock = plan.stocks.find((candidate) => candidate.id === piece.stockId);
return (
piece.leftType === 'cope' ||
piece.rightType === 'cope' ||
!stock ||
!sameLength(piece.length, stock.length) ||
Math.abs(piece.leftMitre) > 0.001 ||
@ -69,3 +73,32 @@ export const groupFlooringCuts = (plan: FlooringPlan) =>
groupByStock(flooringCuts(plan), plan.stocks);
export const groupSkirtingCuts = (plan: SkirtingPlan) =>
groupByStock(skirtingCuts(plan), plan.stocks);
export function skirtingEndLabel(piece: SkirtingPiece, side: 'left' | 'right'): string {
const type = side === 'left' ? piece.leftType : piece.rightType;
const angle = side === 'left' ? piece.leftMitre : piece.rightMitre;
if (type === 'cope') return `Cope · prepare ${degree(angle)}`;
if (type === 'internal')
return `Receive cope · ${Math.abs(angle) < 0.001 ? 'square' : `wall-fit ${degree(angle)}`}`;
return type === 'external' ? `Mitre ${degree(angle)}` : 'Square';
}
/** The profile is supplied by the user's real moulding, not by its thickness. */
export function skirtingInstructions(piece: SkirtingPiece): string {
const copes = [
piece.leftType === 'cope' ? 'left' : '',
piece.rightType === 'cope' ? 'right' : '',
].filter(Boolean);
const receiving = piece.leftType === 'internal' || piece.rightType === 'internal';
const messages = [
'Measure the longest points of the preparation blank. Angles are measured from square.',
];
if (receiving) messages.push('Fit the receiving end before the adjoining coped piece.');
if (copes.length)
messages.push(
`Prepare the ${copes.join(' and ')} inside mitre, mark your moulding’s exposed profile, then cope and back-cut to fit the receiving face. Keep the front profile line; the dashed end shows the full-thickness body after coping, not the decorative profile.`,
);
if (piece.visibleLength !== undefined)
messages.push(`Exposed front length: ${cutMm(piece.visibleLength)} mm.`);
return messages.join(' ');
}

View file

@ -35,7 +35,11 @@ const overlaps = (a: PreviewBox, b: PreviewBox, gap: number) =>
a.y + a.height + gap > b.y;
/** Dimensions stay outside the outline. Short adjacent edges share a lane with staggered text. */
export function dimensionedOutline(points: Point[], edges?: number[]) {
export function dimensionedOutline(
points: Point[],
edges?: number[],
blank: { length?: number; width?: number } = {},
) {
if (points.length < 3)
return { dimensions: [] as EdgeDimension[], font: 1, box: fullPreviewBox(1, 1) };
const span = Math.max(
@ -72,7 +76,13 @@ 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 = cutMm(edge.length);
const limit =
Math.abs(direction.y) < 1e-6
? blank.length
: Math.abs(direction.x) < 1e-6
? blank.width
: undefined;
const text = cutMm(edge.length, limit);
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
@ -178,18 +188,28 @@ export function fullPreviewBox(length: number, width: number): PreviewBox {
export function skirtingEndBoxes(
template: Point[],
thickness: number,
installed?: Point[],
): { left: PreviewBox; right: PreviewBox } {
const [backLeft, backRight, frontRight, frontLeft] = template;
const left = [
template[0].x,
template[3].x,
...(installed ? [installed[0].x, installed[3].x] : []),
];
const right = [
template[1].x,
template[2].x,
...(installed ? [installed[1].x, installed[2].x] : []),
];
const side = Math.max(
1,
thickness * 3,
Math.abs(backLeft.x - frontLeft.x) / 0.76,
Math.abs(backRight.x - frontRight.x) / 0.76,
(Math.max(...left) - Math.min(...left)) / 0.76,
(Math.max(...right) - Math.min(...right)) / 0.76,
);
const padding = side * 0.12;
const common = { y: (thickness - side) / 2, width: side, height: side };
return {
left: { ...common, x: Math.min(backLeft.x, frontLeft.x) - padding },
right: { ...common, x: Math.max(backRight.x, frontRight.x) - side + padding },
left: { ...common, x: Math.min(...left) - padding },
right: { ...common, x: Math.max(...right) - side + padding },
};
}

View file

@ -49,7 +49,7 @@ export function defaultProject(): Project {
minWidth: 60,
kerf: 3,
},
skirting: { stockLengths: [2400], thickness: 18, minLength: 200, kerf: 3 },
skirting: { stockLength: 2400, thickness: 18, minLength: 200, kerf: 3 },
};
}
export function defaultState(): UrlState {

View file

@ -1,5 +1,6 @@
import ClipperLib from 'clipper-lib';
import { buildGeometry, insetPolygon } from './geometry';
import { cutLength } from './format';
import type {
FlooringPiece,
FlooringPlan,
@ -50,6 +51,10 @@ interface RowShape {
const round = (n: number) => Math.round(n * SCALE) / SCALE;
const phaseOf = (n: number, length: number) => round(((n % length) + length) % length) % length;
const close = (a: number, b: number) => Math.abs(a - b) <= EPSILON;
// A complete board or an exact-fit remnant needs no crosscut, even for stock
// supplied in fractions of a millimetre. New blanks reserve the displayed saw mark.
const blankLength = (length: number, available: number) =>
close(length, available) ? available : cutLength(length);
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
const area = (points: Point[]) =>
Math.abs(
@ -112,7 +117,11 @@ 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, cornerOffsets: number[] = []): 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]] : [];
@ -149,11 +158,16 @@ function rowWidthOptions(height: number, s: FlooringSettings, cornerOffsets: num
}
function inwardCorners(paths: Point[][]): Point[] {
return paths.flatMap(path => path.filter((point, index) => {
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;
}));
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 {
@ -189,6 +203,7 @@ function cutRow(
const length = b.maxX - b.minX;
const width = b.maxY - b.minY;
if (length < s.minLength - EPSILON || width < s.minWidth - EPSILON) return null;
if (blankLength(round(length), s.length) > s.length + EPSILON) return null;
// An edge on a board-grid line needs its factory end, unless it is the room boundary.
const needsLeft = left > shape.bounds.minX + EPSILON && hasVerticalEdge(polygon, left);
const needsRight = right < shape.bounds.maxX - EPSILON && hasVerticalEdge(polygon, right);
@ -210,12 +225,12 @@ function allocate(
piece: CutPiece,
pieceId: string,
s: FlooringSettings,
): { stockId: string; reused: boolean; crosscut: boolean } {
): { stockId: string; reused: boolean; crosscut: boolean; blankLength: number } {
const length = round(piece.bounds.maxX - piece.bounds.minX);
let stock: AvailableStock | undefined;
for (const candidate of layout.stocks) {
if (
candidate.remaining < length - EPSILON ||
candidate.remaining < blankLength(length, candidate.remaining) - EPSILON ||
(piece.needsLeft && !candidate.left) ||
(piece.needsRight && !candidate.right)
)
@ -235,6 +250,7 @@ function allocate(
layout.stocks.push(stock);
}
const reused = stock.pieceIds.length > 0;
const reserved = blankLength(length, stock.remaining);
const crosscut = stock.remaining - length > EPSILON;
stock.pieceIds.push(pieceId);
if (!crosscut) {
@ -242,13 +258,13 @@ function allocate(
stock.left = false;
stock.right = false;
} else {
stock.remaining = round(Math.max(0, stock.remaining - length - s.kerf));
stock.remaining = round(Math.max(0, stock.remaining - reserved - s.kerf));
// Take starters from the right; take finishers from the left. The remainder has one new cut end.
if (piece.needsRight) stock.right = false;
else stock.left = false;
}
if (reused) layout.reusedCount++;
return { stockId: stock.id, reused, crosscut };
return { stockId: stock.id, reused, crosscut, blankLength: reserved };
}
function appendRow(
@ -301,6 +317,8 @@ function appendRow(
polygon: piece.polygon.map((p) => worldPoint(p, room.direction)),
length,
width,
blankLength: allocation.blankLength,
blankWidth: Math.min(cutLength(width), s.width),
stockId: allocation.stockId,
reused: allocation.reused,
cut: passes > 0,
@ -511,8 +529,11 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
continue;
}
const roomBounds = bounds(paths.flat());
const widthOptions = rowWidthOptions(roomBounds.maxY - roomBounds.minY, s,
inwardCorners(paths).map(point => point.y - roomBounds.minY));
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

@ -4,7 +4,9 @@ export const mm = (value: number) =>
: '—';
/** 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));
/** An exact-fit stock edge or remnant needs no rounded-up preparation cut. */
export const cutMm = (value: number, available = Infinity) =>
mm(Math.min(cutLength(value), available));
export const area = (value: number) =>
new Intl.NumberFormat('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
value / 1e6,

163
src/lib/piece-labels.ts Normal file
View file

@ -0,0 +1,163 @@
import type { FlooringPiece, Point } from './types';
import type { LabelBox } from './dimension-labels';
export interface PieceLabel {
id: string;
text: string;
x: number;
y: number;
angle: number;
width: number;
height: number;
font: number;
box: LabelBox;
anchor: Point;
leader: boolean;
}
const bounds = (points: Point[]): LabelBox => ({
minX: Math.min(...points.map((point) => point.x)),
maxX: Math.max(...points.map((point) => point.x)),
minY: Math.min(...points.map((point) => point.y)),
maxY: Math.max(...points.map((point) => point.y)),
});
const overlaps = (a: LabelBox, b: LabelBox, gap: number) =>
a.minX < b.maxX + gap && a.maxX > b.minX - gap && a.minY < b.maxY + gap && a.maxY > b.minY - gap;
const corners = (box: LabelBox) => [
{ x: box.minX, y: box.minY },
{ x: box.maxX, y: box.minY },
{ x: box.maxX, y: box.maxY },
{ x: box.minX, y: box.maxY },
];
const inside = (point: Point, polygon: Point[]) => {
let result = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const a = polygon[i],
b = polygon[j];
if (
a.y > point.y !== b.y > point.y &&
point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x
)
result = !result;
}
return result;
};
/** A midpoint of an interior scanline stays on the piece, including concave cut shapes. */
function anchorPoint(polygon: Point[], box: LabelBox): Point {
const y = (box.minY + box.maxY) / 2;
const xs: number[] = [];
for (let i = 0; i < polygon.length; i++) {
const a = polygon[i],
b = polygon[(i + 1) % polygon.length];
if (a.y > y !== b.y > y) xs.push(a.x + ((y - a.y) * (b.x - a.x)) / (b.y - a.y));
}
xs.sort((a, b) => a - b);
let widest = -1,
x = (box.minX + box.maxX) / 2;
for (let i = 0; i + 1 < xs.length; i += 2) {
if (xs[i + 1] - xs[i] > widest) {
widest = xs[i + 1] - xs[i];
x = (xs[i] + xs[i + 1]) / 2;
}
}
return { x, y };
}
/** Short pieces borrow nearby empty label space; a pointer keeps their exact location clear. */
export function floorPieceLabels(
pieces: FlooringPiece[],
room: Point[],
direction: 0 | 90,
font: number,
reserved: LabelBox[] = [],
completedBoards: string[] = [],
): PieceLabel[] {
const occupied = [...reserved];
const roomBox = bounds(room.length ? room : [{ x: 0, y: 0 }]);
const result: PieceLabel[] = [];
const cuts = pieces.filter((piece) => (piece.cut || piece.reused) && piece.polygon.length >= 3);
for (const piece of cuts) {
const box = bounds(piece.polygon);
const anchor = anchorPoint(piece.polygon, box);
const text = piece.id + (completedBoards.includes(`floor:${piece.stockId}`) ? ' ✓' : '');
const textSize = font * 0.76;
const width = Math.max(font * 1.8, text.length * textSize * 0.64 + font * 0.55);
const height = font * 1.15;
const halfX = (direction === 90 ? height : width) / 2;
const halfY = (direction === 90 ? width : height) / 2;
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
const preferred = {
x: clamp(anchor.x, roomBox.minX + halfX + font * 0.15, roomBox.maxX - halfX - font * 0.15),
y: clamp(anchor.y, roomBox.minY + halfY + font * 0.15, roomBox.maxY - halfY - font * 0.15),
};
const candidate = (x: number, y: number) => {
const labelBox = { minX: x - halfX, maxX: x + halfX, minY: y - halfY, maxY: y + halfY };
const inPiece = corners(labelBox).every((point) => inside(point, piece.polygon));
const inRoom = corners(labelBox).every((point) => inside(point, room));
const coversAnchor =
anchor.x >= labelBox.minX &&
anchor.x <= labelBox.maxX &&
anchor.y >= labelBox.minY &&
anchor.y <= labelBox.maxY;
return {
x,
y,
box: labelBox,
inPiece,
score:
Math.hypot(x - anchor.x, y - anchor.y) +
(inRoom ? 0 : font * 30) +
(!inPiece && coversAnchor ? font * 40 : 0),
};
};
const candidates = [candidate(anchor.x, anchor.y), candidate(preferred.x, preferred.y)];
for (const sign of [-1, 1]) {
candidates.push(
candidate(
direction === 0 ? anchor.x + sign * (halfX + font * 0.4) : preferred.x,
direction === 90 ? anchor.y + sign * (halfY + font * 0.4) : preferred.y,
),
);
}
for (let across = -3; across <= 3; across++) {
for (let along = -4; along <= 4; along++) {
const alongStep = width + font * 0.2;
const acrossStep = Math.max(
height + font * 0.2,
direction === 90 ? box.maxX - box.minX : box.maxY - box.minY,
);
candidates.push(
candidate(
preferred.x + (direction === 90 ? across * acrossStep : along * alongStep),
preferred.y + (direction === 90 ? along * alongStep : across * acrossStep),
),
);
}
}
candidates.sort((a, b) => a.score - b.score);
let placed = candidates.find(
(item) => !occupied.some((other) => overlaps(item.box, other, font * 0.12)),
);
// Very crowded outlines get a clear outside label; its bounds expand the SVG viewport.
for (let lane = 1; !placed; lane++) {
const item = candidate(roomBox.maxX + halfX + lane * (width + font), anchor.y);
if (!occupied.some((other) => overlaps(item.box, other, font * 0.12))) placed = item;
}
occupied.push(placed.box);
result.push({
id: piece.id,
text,
x: placed.x,
y: placed.y,
angle: direction,
width,
height,
font: textSize,
box: placed.box,
anchor,
leader: !placed.inPiece || Math.hypot(placed.x - anchor.x, placed.y - anchor.y) > font * 0.1,
});
}
return result;
}

View file

@ -1,6 +1,6 @@
import type { Point, SkirtingPiece } from './types';
/** Place the cutting template against its wall, preserving mitres and butt setbacks. */
/** Place the cutting template against its wall, preserving mitres and the coped body setbacks. */
export function skirtingPolygon(
piece: SkirtingPiece,
thickness: number,
@ -21,7 +21,8 @@ export function skirtingPolygon(
{ x: backLength, y: thickness },
{ x: 0, y: thickness },
];
return template.map((point) => ({
const installed = piece.installedTemplate?.length ? piece.installedTemplate : template;
return installed.map((point) => ({
x: piece.start.x + tangent.x * (point.x - template[0].x) + inward.x * point.y,
y: piece.start.y + tangent.y * (point.x - template[0].x) + inward.y * point.y,
}));

View file

@ -20,6 +20,9 @@ type End = {
bevel: number;
back: number;
front: number;
installedBack: number;
installedFront: number;
visible: number;
};
type Run = {
roomId: string;
@ -31,7 +34,18 @@ type Run = {
right: End;
thickness: number;
};
const square = (): End => ({ type: 'square', angle: 0, bevel: 0, back: 0, front: 0 });
const square = (): End => ({
type: 'square',
angle: 0,
bevel: 0,
back: 0,
front: 0,
installedBack: 0,
installedFront: 0,
visible: 0,
});
const firstAllowance = (end: End) => Math.max(end.installedBack, end.installedFront, end.visible);
const lastAllowance = (end: End) => Math.min(end.installedBack, end.installedFront, end.visible);
function roomPrefix(index: number): string {
let value = index + 1,
@ -52,39 +66,64 @@ function pointAt(edge: WallEdge, offset: number): Point {
};
}
/** At internal corners the previous wall runs through; the next butts against its face. */
function corner(turn: number, thickness: number, side: 'left' | 'right'): End {
if (Math.abs(turn) < EPS) return square();
/**
* Copes retain the inside-mitre long point at the wall corner. The moulding's
* actual profile is then scribed and back-cut to fit the receiving piece.
* https://www.finehomebuilding.com/pdf/021174063.pdf (Gary Katz): inside cuts use the back
* long point and fit the receiving end first. For a turn t, intersecting faces
* meet thickness*tan(t/2) from the corner; the full-depth body starts at thickness*csc(t).
*/
function corner(turn: number, thickness: number, side: 'left' | 'right', cope: boolean): End {
if (Math.abs(turn) < EPS || thickness < EPS) return square();
const sign = side === 'left' ? 1 : -1;
if (turn < 0) {
const extension = thickness * Math.tan(rad(-turn / 2));
const front = sign * thickness * Math.tan(rad(turn / 2));
return {
type: 'external',
angle: -turn / 2,
bevel: side === 'left' ? turn / 2 : -turn / 2,
bevel: (sign * turn) / 2,
back: 0,
front: side === 'left' ? -extension : extension,
front,
installedBack: 0,
installedFront: front,
visible: front,
};
}
const angle = Math.abs(90 - turn);
return side === 'left'
? {
type: 'internal',
angle,
bevel: turn - 90,
back: thickness / Math.sin(rad(turn)),
front: thickness * Math.tan(rad(turn / 2)),
}
: {
type: 'internal',
angle,
bevel: 90 - turn,
const faceJoint = sign * thickness * Math.tan(rad(turn / 2));
if (cope) {
return {
type: 'cope',
angle: turn / 2,
bevel: (sign * turn) / 2,
back: 0,
front: thickness / Math.tan(rad(turn)),
front: faceJoint,
installedBack: (sign * thickness) / Math.sin(rad(turn)),
installedFront: faceJoint,
visible: faceJoint,
};
}
// A square receiving end fits a right-angle wall; an oblique wall needs a
// wall-fitting bevel. Its face extends past the visible joint under the cope.
const front = (-sign * thickness) / Math.tan(rad(turn));
return {
type: 'internal',
angle: Math.abs(90 - turn),
bevel: sign * (turn - 90),
back: 0,
front,
installedBack: 0,
installedFront: front,
visible: faceJoint,
};
}
function endCuts(piece: SkirtingPiece) {
return Number(Math.abs(piece.leftMitre) > EPS) + Number(Math.abs(piece.rightMitre) > EPS);
return (
Number(Math.abs(piece.leftMitre) > EPS) +
Number(Math.abs(piece.rightMitre) > EPS) +
Number(piece.leftType === 'cope') +
Number(piece.rightType === 'cope')
);
}
function blankLength(piece: SkirtingPiece, available: number) {
@ -92,7 +131,7 @@ function blankLength(piece: SkirtingPiece, available: number) {
}
function requiredCuts(piece: SkirtingPiece, available: number) {
return endCuts(piece) + Number(Math.abs(available - blankLength(piece, available)) > EPS);
return endCuts(piece) + Number(Math.abs(available - piece.length) > EPS);
}
function consumedLength(piece: SkirtingPiece, available: number, kerf: number) {
@ -130,6 +169,15 @@ function makePiece(
leftType: left.type,
rightType: right.type,
stockId: '',
visibleLength: to + right.visible - from - left.visible,
leftOverlap: Math.abs(left.visible - (left.type === 'cope' ? left.back : left.front)),
rightOverlap: Math.abs(right.visible - (right.type === 'cope' ? right.back : right.front)),
installedTemplate: [
{ x: from + left.installedBack - origin, y: 0 },
{ x: to + right.installedBack - origin, y: 0 },
{ x: to + right.installedFront - origin, y: run.thickness },
{ x: from + left.installedFront - origin, y: run.thickness },
],
leftBevel: left.bevel,
rightBevel: right.bevel,
template: [
@ -142,7 +190,12 @@ function makePiece(
}
function shortestEdge(piece: SkirtingPiece) {
return Math.min(piece.backLength, piece.template![2].x - piece.template![3].x);
const installed = piece.installedTemplate!;
return Math.min(
installed[1].x - installed[0].x,
installed[2].x - installed[3].x,
piece.visibleLength!,
);
}
function fits(piece: SkirtingPiece, available: number) {
@ -156,15 +209,12 @@ function splitCount(run: Run, target: number, minimum: number): number {
if (shortestEdge(full) < minimum - EPS) return 0;
if (fits(full, target)) return 1;
const count = Math.max(2, Math.ceil((full.length - EPS) / target));
const minimumSpan =
count * minimum +
Math.max(run.left.back, run.left.front) -
Math.min(run.right.back, run.right.front);
const minimumSpan = count * minimum + firstAllowance(run.left) - lastAllowance(run.right);
if (
count > 10000 ||
run.to - run.from < minimumSpan - EPS ||
Math.abs(run.left.back - run.left.front) > target - minimum + EPS ||
Math.abs(run.right.back - run.right.front) > target - minimum + EPS
firstAllowance(run.left) - Math.min(run.left.back, run.left.front) > target - minimum + EPS ||
Math.max(run.right.back, run.right.front) - lastAllowance(run.right) > target - minimum + EPS
)
return 0;
return count;
@ -183,13 +233,13 @@ function splitRun(
const span = run.to - run.from;
// A piece's shortest edge sets its minimum span. Its longest point sets
// its maximum span. These differ at angled corners, even for butt joints.
// its maximum span. These differ at angled corners, at receiving and coped corners.
const minimums = Array.from(
{ length: count },
(_, index) =>
settings.minLength +
(index === 0 ? Math.max(run.left.back, run.left.front) : 0) -
(index === count - 1 ? Math.min(run.right.back, run.right.front) : 0),
(index === 0 ? firstAllowance(run.left) : 0) -
(index === count - 1 ? lastAllowance(run.right) : 0),
);
const maximums = Array.from(
{ length: count },
@ -223,12 +273,17 @@ function splitRun(
}
let from = run.from;
return spans.map((span, index) => {
const pieces = spans.map((span, index) => {
const to = index === count - 1 ? run.to : from + span;
const piece = makePiece(run, from, to, index === 0, index === count - 1);
from = to;
return piece;
});
return pieces.every(
(piece) => fits(piece, target) && shortestEdge(piece) >= settings.minLength - EPS,
)
? pieces
: null;
}
/** Let useful tails set the next joint instead of fixing every wall's joints first. */
@ -250,7 +305,7 @@ function splitWithOffcuts(
if (fits(full, available)) return { piece: full, to: run.to };
const to = Math.min(
from + available + Math.min(remaining.left.back, remaining.left.front),
run.to + Math.min(run.right.back, run.right.front) - settings.minLength,
run.to + lastAllowance(run.right) - settings.minLength,
);
if (to <= from + EPS) return null;
const piece = makePiece(remaining, from, to, true, false);
@ -294,8 +349,8 @@ interface Packed {
waste: number;
}
/** Best-fit decreasing with one-stock lookahead across all rooms and available stock sizes. */
function pack(source: SkirtingPiece[], stockLengths: number[], kerf: number): Packed {
/** Best-fit decreasing across all rooms, using one purchased board length. */
function pack(source: SkirtingPiece[], stockLength: number, kerf: number): Packed {
const pieces = source.map((piece) => ({ ...piece }));
let pending = [...pieces].sort(
(a, b) => b.length - a.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
@ -303,47 +358,28 @@ function pack(source: SkirtingPiece[], stockLengths: number[], kerf: number): Pa
const stocks: SkirtingStock[] = [];
let cuts = 0;
while (pending.length) {
const options = stockLengths
.filter((length) => fits(pending[0], length))
.map((length) => {
let available = length,
optionCuts = 0;
let available = stockLength;
const assigned: SkirtingPiece[] = [];
for (const piece of pending) {
if (fits(piece, available)) {
const pieceCuts = requiredCuts(piece, available);
if (!fits(piece, available)) continue;
piece.blankLength = blankLength(piece, available);
cuts += requiredCuts(piece, available);
available -= consumedLength(piece, available, kerf);
optionCuts += pieceCuts;
assigned.push(piece);
}
}
const used = assigned.reduce((sum, piece) => sum + piece.length, 0);
return { length, assigned, used, cuts: optionCuts, waste: length - used };
})
.sort(
(a, b) =>
a.waste / a.used - b.waste / b.used ||
a.waste - b.waste ||
a.cuts - b.cuts ||
a.length - b.length,
);
const best = options[0];
// Candidates are pre-checked against a stock length, so this is an invariant.
if (!best) throw new Error('A skirting piece does not fit the available stock.');
if (!assigned.length) throw new Error('A skirting piece does not fit the available stock.');
const id = `S${stocks.length + 1}`;
best.assigned.forEach((piece) => {
piece.stockId = id;
});
for (const piece of assigned) piece.stockId = id;
const used = assigned.reduce((sum, piece) => sum + piece.length, 0);
stocks.push({
id,
length: best.length,
used: best.used,
waste: Math.max(0, best.waste),
pieceIds: best.assigned.map((piece) => piece.id),
length: stockLength,
used,
waste: Math.max(0, stockLength - used),
pieceIds: assigned.map((piece) => piece.id),
});
const assignedIds = new Set(best.assigned.map((piece) => piece.id));
const assignedIds = new Set(assigned.map((piece) => piece.id));
pending = pending.filter((piece) => !assignedIds.has(piece.id));
cuts += best.cuts;
}
return { pieces, stocks, cuts, waste: stocks.reduce((sum, stock) => sum + stock.waste, 0) };
}
@ -363,10 +399,9 @@ export function planSkirting(
issues,
});
if (
!settings.stockLengths.length ||
settings.stockLengths.some(
(length) => !Number.isFinite(length) || length < 0.1 || length > 1e6,
) ||
!Number.isFinite(settings.stockLength) ||
settings.stockLength < 0.1 ||
settings.stockLength > 1e6 ||
!Number.isFinite(settings.thickness) ||
settings.thickness < 0 ||
settings.thickness > 10000 ||
@ -384,8 +419,9 @@ export function planSkirting(
});
return empty();
}
const stockLengths = [...new Set(settings.stockLengths)].sort((a, b) => a - b);
const stockLength = settings.stockLength;
let runs: Run[] = [];
const fallbackSplits = new Map<Run, SkirtingPiece[]>();
rooms.forEach((room, roomIndex) => {
const geometry = buildGeometry(room, tolerance);
if (!geometry.valid) {
@ -409,6 +445,7 @@ export function planSkirting(
normalizeAngle(edge.heading - previous.heading) * orientation,
settings.thickness,
'left',
index > previous.index,
)
: square();
const right = nextHasStart
@ -416,6 +453,7 @@ export function planSkirting(
normalizeAngle(next.heading - edge.heading) * orientation,
settings.thickness,
'right',
index > next.index,
)
: square();
const doors = [...edge.doors].sort((a, b) => a.offset - b.offset);
@ -441,11 +479,19 @@ export function planSkirting(
roomId: room.id,
message: `${room.name}, wall ${index + 1}: a skirting run after corner allowances is shorter than the ${settings.minLength} mm minimum. Change the minimum or opening measurements.`,
});
} else if (!splitCount(run, stockLengths.at(-1)!, settings.minLength)) {
} else {
// Establish a feasible rounded split per run before combining rooms.
// A fractional stock length may need offcut-led extra joints.
const split =
splitRun(run, stockLength, settings, false) ??
splitRun(run, stockLength, settings, true) ??
splitWithOffcuts([run], stockLength, settings);
if (split) fallbackSplits.set(run, split);
else
issues.push({
severity: 'error',
roomId: room.id,
message: `${room.name}, wall ${index + 1}: the stock lengths cannot fit the minimum skirting pieces and corner allowances. Increase stock length or reduce the minimum.`,
message: `${room.name}, wall ${index + 1}: the stock lengths cannot fit the rounded minimum skirting pieces and corner allowances. Increase stock length or reduce the minimum.`,
});
}
runs.push(run);
@ -464,19 +510,30 @@ export function planSkirting(
const candidates: Packed[] = [];
const prefixes = new Map(runs.map((run) => [run.roomId, run.prefix]));
const addCandidate = (source: SkirtingPiece[]) => {
// Raw spans can fit while their displayed half-millimetre blanks do not.
// Reject this split without losing other feasible joint arrangements.
if (
source.some(
(piece) => !fits(piece, stockLength) || shortestEdge(piece) < settings.minLength - EPS,
)
)
return;
const counts = new Map<string, number>();
for (const piece of source) {
const count = (counts.get(piece.roomId) ?? 0) + 1;
counts.set(piece.roomId, count);
piece.id = `${prefixes.get(piece.roomId)}${count}`;
}
candidates.push(pack(source, stockLengths, settings.kerf));
candidates.push(pack(source, stockLength, settings.kerf));
};
// Compare full-stock, balanced, and offcut-led joints for each stock length.
for (const target of stockLengths) {
// Compare full-stock, balanced, and offcut-led wall joints.
{
const target = stockLength;
for (const balanced of [false, true]) {
const splits = runs.map((run) => splitRun(run, target, settings, balanced));
if (splits.every((split) => split !== null)) addCandidate(splits.flat());
const splits = runs.map(
(run) => splitRun(run, target, settings, balanced) ?? fallbackSplits.get(run)!,
);
addCandidate(splits.flat());
}
const reused = splitWithOffcuts(runs, target, settings);
if (reused) addCandidate(reused);
@ -500,12 +557,12 @@ export function planSkirting(
issues.push({
severity: 'info',
message:
'Lengths are longest-point cuts; back lengths locate pieces on walls. Minimum length applies to both front and back edges. Internal corners use butt joints: the previous wall runs through and the next is shortened by the skirting thickness (adjusted for angled walls). Coping is excluded. External corners use equal mitres. Templates show the back at y=0 and the front at y=thickness, with x following the wall direction. Positive bevel means the front cut point is farther right than the back; use the template to orient the cut for your saw setup. Door widths include any trim to omit.',
'Lengths are longest-point preparation blanks, including the material retained for internal coping. The earlier wall receives each cope; install it first. A cope uses an inside mitre to expose the profile, then a scribed back-cut. The actual decorative profile must be marked from your moulding. Templates show preparation cuts; the plan shows the full-thickness installed body. Minimum length applies to the exposed face and installed body. External corners use equal mitres. Door widths include any trim to omit.',
});
issues.push({
severity: 'info',
message:
'Deterministic stock packing shares offcuts across rooms. It compares full-stock, balanced and offcut-led joins, then prefers less waste and fewer cuts; it is a heuristic, not a guaranteed global optimum. Cut square blanks to the longest-point lengths, then bevel their ends. Crosscut kerf is allowed between blanks; bevel kerfs stay inside the blanks. Waste is unused stock length including crosscut kerf.',
'Deterministic stock packing shares offcuts across rooms. It compares full-stock, balanced and offcut-led joins, then prefers less waste and fewer cuts; it is a heuristic, not a guaranteed global optimum. Cut square blanks to the longest-point lengths, then prepare their mitres and cope marked ends. Each cope counts one additional shaping operation; profile-specific relief passes are not estimated. Crosscut kerf is allowed between blanks; bevel kerfs stay inside the blanks. Waste is unused stock length including crosscut kerf.',
});
const plan = { ...best, stockCount: best.stocks.length, issues };
const cutNumbers = new Map<string, number>();

View file

@ -32,7 +32,7 @@ export interface FlooringSettings {
kerf: number;
}
export interface SkirtingSettings {
stockLengths: number[];
stockLength: number;
thickness: number;
minLength: number;
kerf: number;
@ -96,6 +96,9 @@ export interface FlooringPiece {
polygon: Point[];
length: number;
width: number;
/** Preparation dimensions reserved from stock, including exact-fit remnants. */
blankLength?: number;
blankWidth?: number;
stockId: string;
reused: boolean;
cut: boolean;
@ -139,14 +142,23 @@ export interface SkirtingPiece {
start: Point;
end: Point;
length: number;
/** Preparation length reserved from stock; a whole fractional length stays exact. */
blankLength?: number;
backLength: number;
leftMitre: number;
rightMitre: number;
leftType: 'square' | 'external' | 'internal';
rightType: 'square' | 'external' | 'internal';
leftType: 'square' | 'external' | 'internal' | 'cope';
rightType: 'square' | 'external' | 'internal' | 'cope';
stockId: string;
/** Plan section: x follows installation direction, back y=0, front y=thickness. */
template?: Point[];
/** Full-thickness installed footprint after coping, separate from the cutting blank. */
installedTemplate?: Point[];
/** Exposed front run used to enforce the minimum installed piece length. */
visibleLength?: number;
/** Extra blank length retained at each end for a coped internal joint. */
leftOverlap?: number;
rightOverlap?: number;
/** Positive means the front cut point is farther right than the back point. */
leftBevel?: number;
rightBevel?: number;

View file

@ -18,7 +18,7 @@ export const URL_LIMITS = {
rooms: 20,
walls: 100,
doors: 30,
stockLengths: 30,
legacyStockLengths: 30,
identifierLength: 120,
nameLength: 200,
completedBoards: 12_000,
@ -155,15 +155,28 @@ function flooring(value: unknown): FlooringSettings {
};
}
function skirting(value: unknown): SkirtingSettings {
function skirting(value: unknown): { settings: SkirtingSettings; legacyCalculations: boolean } {
const data = object(value, 'Skirting settings');
return {
stockLengths: array(data.stockLengths, 'Skirting stock lengths', URL_LIMITS.stockLengths).map(
// Earlier links allowed several purchased lengths. The first remains the chosen size.
const legacyLengths =
data.stockLength === undefined
? array(data.stockLengths, 'Skirting stock lengths', URL_LIMITS.legacyStockLengths).map(
(value) => number(value, 'Skirting stock length'),
),
)
: [];
return {
settings: {
stockLength:
data.stockLength === undefined
? (legacyLengths[0] ?? 0)
: number(data.stockLength, 'Skirting board length'),
thickness: number(data.thickness, 'Skirting thickness'),
minLength: number(data.minLength, 'Minimum skirting length'),
kerf: number(data.kerf, 'Skirting saw kerf'),
},
// This format predates coping blanks and rounded stock allocation for both
// materials. Even a single stock size can now assign different cuts to an ID.
legacyCalculations: data.stockLength === undefined,
};
}
@ -183,15 +196,17 @@ function parseState(value: unknown): UrlState {
if (rooms.length === 0) throw new Error('A project needs at least one room.');
const view = object(data.view, 'View settings');
const roomId = string(view.roomId, 'Selected room');
const skirt = skirting(project.skirting);
const completed = completedBoards(project.completedBoards);
return {
project: {
version: 1,
name: string(project.name, 'Project name', URL_LIMITS.nameLength),
rooms,
flooring: flooring(project.flooring),
skirting: skirting(project.skirting),
skirting: skirt.settings,
tolerance: number(project.tolerance, 'Measurement tolerance'),
completedBoards: completedBoards(project.completedBoards),
completedBoards: skirt.legacyCalculations ? [] : completed,
},
view: {
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
@ -216,9 +231,7 @@ export function encodeState(state: UrlState): string {
put('name', project.name);
put('tolerance', project.tolerance);
for (const [key, value] of Object.entries(project.flooring)) put(`flooring.${key}`, value);
// An empty value explicitly represents an empty stock list during editing.
if (!project.skirting.stockLengths.length) put('skirting.stock', '');
for (const length of project.skirting.stockLengths) put('skirting.stock', length);
put('skirting.length', project.skirting.stockLength);
put('skirting.thickness', project.skirting.thickness);
put('skirting.minLength', project.skirting.minLength);
put('skirting.kerf', project.skirting.kerf);
@ -325,14 +338,19 @@ function parseQuery(query: string): UrlState {
})
: defaults.project.rooms;
const stockValues = params.getAll('skirting.stock');
if (stockValues.length > URL_LIMITS.stockLengths)
throw new Error(`Skirting stock lengths supports at most ${URL_LIMITS.stockLengths} entries.`);
const stockLengths =
stockValues.length === 1 && stockValues[0] === ''
if (stockValues.length > URL_LIMITS.legacyStockLengths)
throw new Error(
`Skirting stock lengths supports at most ${URL_LIMITS.legacyStockLengths} entries.`,
);
const stock = params.has('skirting.length')
? { stockLength: numeric('skirting.length') }
: {
stockLengths: !stockValues.length
? [defaults.project.skirting.stockLength]
: stockValues.length === 1 && stockValues[0] === ''
? []
: stockValues.length
? stockValues.map((value) => measurement(value, 'skirting.stock'))
: defaults.project.skirting.stockLengths;
: stockValues.map((value) => measurement(value, 'skirting.stock')),
};
const floor = defaults.project.flooring;
return parseState({
project: {
@ -350,7 +368,7 @@ function parseQuery(query: string): UrlState {
]),
),
skirting: {
stockLengths,
...stock,
thickness: numeric('skirting.thickness', defaults.project.skirting.thickness),
minLength: numeric('skirting.minLength', defaults.project.skirting.minLength),
kerf: numeric('skirting.kerf', defaults.project.skirting.kerf),

View file

@ -557,41 +557,46 @@ summary span {
color: white;
font-weight: 600;
}
.stock-input {
display: flex;
align-items: flex-end;
gap: 7px;
}
.stock-input .field {
flex: 1;
margin-bottom: 0;
}
.stock-input + .stock-input {
margin-top: 12px;
}
.stock-input + .text-button {
margin-bottom: 10px;
}
.drawing-card {
border: 1px solid var(--ink);
background: white;
}
.drawing-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 17px 20px 12px;
border-top: 4px solid var(--ink);
gap: 8px 16px;
padding: 10px 14px;
border-top: 3px solid var(--ink);
}
.drawing-header h2 {
font-size: 17px;
font-size: 14px;
min-width: 0;
overflow-wrap: anywhere;
}
.drawing-header h2 small {
display: inline-block;
margin-left: 8px;
font: 11px var(--mono);
font-size: 11px;
font-weight: 400;
white-space: nowrap;
}
.drawing-legend {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
font-size: 11px;
}
.floor-code {
cursor: pointer;
}
.floor-code:hover rect {
fill: #17263f;
}
.floor-code.is-highlighted rect {
stroke: #17263f;
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
.badge {
display: inline-flex;
@ -603,12 +608,6 @@ summary span {
.badge.pending {
color: #a13e00;
}
.drawing-legend {
display: flex;
gap: 25px;
padding: 0 20px 16px;
font-size: 11px;
}
.drawing-legend > span {
display: flex;
gap: 7px;
@ -953,23 +952,9 @@ summary span {
.tooltip-content {
right: 0;
}
.drawing-header {
padding: 14px 13px 12px;
}
.drawing-header h2 {
font-size: 16px;
}
.drawing-header h2 small {
font-size: 10px;
margin-left: 5px;
}
.badge {
font-size: 9px;
}
.drawing-legend {
padding: 0 13px 13px;
gap: 23px;
}
.purchase-summary h2 {
font-size: 17px;
}
@ -1246,11 +1231,6 @@ summary span {
.cut-preview-code > span {
margin-left: 4px;
}
.drawing-legend {
flex-wrap: wrap;
gap: 8px 16px;
padding-bottom: 8px;
}
.uncut-key {
width: 17px;
height: 10px;
@ -1258,11 +1238,6 @@ summary span {
background: #deddd8;
border: 1px solid #79756b;
}
.drawing-help {
font-size: 11px;
padding: 0 20px 12px;
color: #4b5c73;
}
.svg-cut:hover > polygon,
.svg-cut:focus-visible > polygon {
stroke: #17263f;
@ -1272,6 +1247,9 @@ summary span {
.skirting-code {
cursor: pointer;
}
.skirting-code:hover rect {
fill: #17263f;
}
.skirt-cut:hover .skirting-code rect,
.skirt-cut:focus-visible .skirting-code rect {
fill: #17263f;
@ -1524,26 +1502,49 @@ summary span {
background: #fffdf7;
}
.drawing-column .drawing-viewport {
height: clamp(180px, calc(100dvh - 330px), 780px);
height: clamp(180px, calc(100dvh - 260px), 780px);
}
.drawing-column:has(.plan-search-status) .drawing-viewport {
height: clamp(160px, calc(100dvh - 390px), 720px);
height: clamp(160px, calc(100dvh - 335px), 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;
padding: 8px 12px;
border: 1px solid var(--ink);
border-top: 0;
background: white;
border-bottom: 0;
background: #ffe6a3;
color: #17263f;
font-size: 12px;
}
.search-mode-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.search-mode-text {
min-width: 0;
}
.search-mode-text strong {
display: flex;
align-items: center;
gap: 6px;
}
.search-mode-text p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 3px;
}
.clear-plan-search {
flex-shrink: 0;
border-color: #17263f;
font-size: 12px;
padding-inline: 9px;
}
.matching-rooms {
display: flex;
flex-wrap: wrap;
@ -1581,6 +1582,26 @@ summary span {
color: #345c55;
}
@media (max-width: 899px) {
.drawing-header {
padding: 8px 10px;
gap: 6px;
}
.drawing-header h2 {
font-size: 13px;
}
.drawing-legend {
font-size: 10px;
gap: 10px;
}
.plan-search-status {
padding: 6px 9px;
font-size: 11px;
}
.clear-plan-search {
font-size: 11px;
padding-inline: 7px;
gap: 3px;
}
.app-main {
padding-inline: 16px;
}
@ -1602,29 +1623,6 @@ summary span {
.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;

View file

@ -3,6 +3,8 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { FlooringPreview, SkirtingPreview } from '../src/components/CutPreview';
import { CutDialog } from '../src/components/CutDialog';
import { planSkirting } from '../src/lib/skirting';
import { skirtingEndLabel } from '../src/lib/cut-list';
import { defaultProject } from '../src/lib/defaults';
import { dimensionedOutline, flooringTemplate, skirtingEndBoxes } from '../src/lib/cut-preview';
import { cutMm, degree } from '../src/lib/format';
@ -64,6 +66,23 @@ describe('cut shape previews', () => {
expect(cutMm(600.001)).toBe('600.5');
});
it('preserves exact preparation dimensions in previews of fractional stock and remnants', () => {
const piece = {
...floor,
length: 597.2,
width: 190.1,
blankLength: 597.2,
blankWidth: 190.2,
reused: true,
};
const markup = renderToStaticMarkup(createElement(FlooringPreview, { piece }));
expect(markup).toContain('597.2 × 190.2 mm');
expect(markup).toContain('>597.2</text>');
expect(markup).toContain('>190.2</text>');
expect(markup).not.toContain('597.5');
expect(markup).not.toContain('190.5');
});
it('keeps skirting endpoints in original coordinates and gives both ends equal square windows', () => {
const original = structuredClone(template);
const boxes = skirtingEndBoxes(template, 18);
@ -208,7 +227,7 @@ describe('cut shape previews', () => {
expect(markup).toContain('Living room · Wall 1');
expect(markup).toContain('>1,164</text>');
expect(markup).toContain('>1,200</text>');
expect(markup).toContain('45° external');
expect(markup).toContain('Mitre 45°');
expect(markup).not.toContain('Source');
expect(markup).not.toContain('coordinates');
expect(markup).not.toContain('<dl');
@ -250,4 +269,60 @@ describe('cut shape previews', () => {
expect(floorMarkup).not.toContain('<figure');
expect(skirtMarkup).not.toContain('<figure');
});
it('shows cope preparation and its full-thickness body without inventing a decorative profile', () => {
const project = defaultProject();
const plan = planSkirting(project.rooms, project.skirting, project.tolerance);
const piece = plan.pieces.find((piece) => piece.leftType === 'cope')!;
expect(piece).toBeDefined();
const markup = renderToStaticMarkup(
createElement(CutDialog, { skirt: piece, project, close: () => {} }),
);
expect(markup).toContain(`Left cope preparation close-up ${piece.id}`);
expect(markup).toContain('Cope · prepare 45°');
expect(markup).toContain('preparation before coping');
expect(markup).toContain('class="skirting-cope-body"');
expect(markup).toContain('mark your moulding’s exposed profile');
expect(markup).toContain('Exposed front length:');
expect(skirtingEndLabel({ ...piece, rightType: 'internal', rightMitre: 0 }, 'right')).toBe(
'Receive cope · square',
);
expect(skirtingEndLabel({ ...piece, rightType: 'internal', rightMitre: 30 }, 'right')).toBe(
'Receive cope · wall-fit 30°',
);
const prepared = piece.template!;
const installed = piece.installedTemplate!;
expect(prepared[0].x).toBeLessThan(installed[0].x);
expect(prepared[3].x).toBeCloseTo(installed[3].x);
const compact = renderToStaticMarkup(
createElement(SkirtingPreview, {
piece,
thickness: project.skirting.thickness,
compact: true,
}),
);
expect(compact).toContain('class="skirting-cope-body"');
});
it('keeps an oblique coped body inside the uniformly scaled end close-up', () => {
const prepared = [
{ x: 0, y: 0 },
{ x: 1000, y: 0 },
{ x: 1000, y: 18 },
{ x: 2, y: 18 },
];
const installed = [
{ x: 100, y: 0 },
{ x: 1000, y: 0 },
{ x: 1000, y: 18 },
{ x: 2, y: 18 },
];
const boxes = skirtingEndBoxes(prepared, 18, installed);
expect(boxes.left.width).toBe(boxes.left.height);
for (const point of [prepared[0], prepared[3], installed[0], installed[3]]) {
expect(point.x).toBeGreaterThan(boxes.left.x);
expect(point.x).toBeLessThan(boxes.left.x + boxes.left.width);
expect(point.y).toBeGreaterThan(boxes.left.y);
expect(point.y).toBeLessThan(boxes.left.y + boxes.left.height);
}
});
});

View file

@ -113,9 +113,7 @@
"kerf": 3
},
"skirting": {
"stockLengths": [
2400
],
"stockLength": 2400,
"thickness": 27,
"minLength": 200,
"kerf": 3

View file

@ -101,7 +101,7 @@
"kerf": 3
},
"skirting": {
"stockLengths": [2400],
"stockLength": 2400,
"thickness": 27,
"minLength": 200,
"kerf": 3

View file

@ -5,6 +5,7 @@ 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 { cutLength } from '../src/lib/format';
import type { FlooringPlan, FlooringSettings, Point, Project, Room } from '../src/lib/types';
const settings: FlooringSettings = {
@ -369,4 +370,106 @@ describe('laminate planning', () => {
});
});
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)}));});
describe('angled room cut allocation', () => {
const project = f8Fixture.project as Project;
const plan = planFlooring(project.rooms, project.flooring, project.tolerance);
it('aligns a row seam with the inward corner that previously left F8 a thin toe', () => {
expect(errors(plan)).toEqual([]);
expect(project.rooms[0].walls).toHaveLength(13);
expect(project.rooms[0].walls.flatMap((wall) => wall.doors)).toHaveLength(2);
const corner = { x: 7607.513, y: 2160.34 };
const adjoining = plan.pieces.filter((piece) =>
piece.polygon.some((point) => Math.hypot(point.x - corner.x, point.y - corner.y) < 0.01),
);
expect(adjoining.length).toBeGreaterThanOrEqual(2);
expect(new Set(adjoining.map((piece) => piece.row)).size).toBe(2);
for (const piece of adjoining) {
// Boards run north/south: an x-boundary is the uncut factory long-edge seam.
const xs = piece.polygon.map((point) => point.x);
expect(
Math.min(Math.abs(Math.min(...xs) - corner.x), Math.abs(Math.max(...xs) - corner.x)),
).toBeLessThan(0.01);
const turns = piece.polygon.map((point, index, polygon) => {
const previous = polygon[(index + polygon.length - 1) % polygon.length];
const next = polygon[(index + 1) % polygon.length];
return (
(point.x - previous.x) * (next.y - point.y) - (point.y - previous.y) * (next.x - point.x)
);
});
expect(turns.every((turn) => turn >= -0.01) || turns.every((turn) => turn <= 0.01)).toBe(
true,
);
}
expectCoverage(plan, project.rooms[0], project.flooring.gap);
expectOffsets(plan, project.flooring);
expectStockFits(plan, project.flooring);
expect(plan).toEqual(planFlooring(project.rooms, project.flooring, project.tolerance));
});
it('fits all displayed half-millimetre blanks and their kerfs on each purchased board', () => {
expect(errors(plan)).toEqual([]);
expect(plan.pieces.some((piece) => cutLength(piece.length) > piece.length + 0.01)).toBe(true);
for (const stock of plan.stocks) {
const pieces = stock.pieceIds.map((id) => plan.pieces.find((piece) => piece.id === id)!);
const rounded = pieces.reduce((sum, piece) => sum + cutLength(piece.length), 0);
expect(
rounded + (pieces.length - 1) * project.flooring.kerf,
`${stock.id}: ${pieces.map((piece) => `${piece.id}=${cutLength(piece.length)}`).join(', ')}`,
).toBeLessThanOrEqual(project.flooring.length + 0.001);
}
});
it('preserves whole factory boards whose purchased length is not a half millimetre', () => {
const s = {
...settings,
length: 1200.2,
minOffset: 0,
maxOffset: 1200.2,
pattern: 'random' as const,
};
const r = room(1220.2, 220);
const fitted = planFlooring([r], s, 1);
expect(errors(fitted)).toEqual([]);
expect(fitted.boardCount).toBe(1);
expect(fitted.cuts).toBe(0);
expect(fitted.pieces[0].length).toBe(1200.2);
expect(fitted.pieces[0].blankLength).toBe(1200.2);
expect(fitted.stocks[0].remaining).toBe(0);
expectCoverage(fitted, r, s.gap);
});
it('uses an exact-fit fractional offcut without pretending it needs a new cut', () => {
const s = { ...settings, length: 1200.2, pattern: 'random' as const };
const fitted = planFlooring([room(620, 220, 'first'), room(617.2, 220, 'second')], s, 1);
expect(errors(fitted)).toEqual([]);
expect(fitted.boardCount).toBe(1);
expect(fitted.cuts).toBe(1);
expect(fitted.pieces.map((piece) => piece.length)).toEqual([600, 597.2]);
expect(fitted.pieces.map((piece) => piece.blankLength)).toEqual([600, 597.2]);
expect(fitted.pieces[1].reused).toBe(true);
expect(fitted.pieces[1].cut).toBe(false);
expect(fitted.stocks[0].remaining).toBe(0);
expectStockFits(fitted, s);
});
it('keeps a finishing cut visible when its rounded blank consumes a complete board', () => {
const s = { ...settings, pattern: 'random' as const };
const fitted = planFlooring([room(1219.6, 220)], s, 1);
expect(errors(fitted)).toEqual([]);
expect(fitted.boardCount).toBe(1);
expect(fitted.pieces[0].length).toBe(1199.6);
expect(fitted.pieces[0].cut).toBe(true);
expect(fitted.cuts).toBe(1);
expect(fitted.stocks[0].remaining).toBe(0);
});
it('caps a rounded rip blank at the available fractional factory width', () => {
const s = { ...settings, width: 200.2, pattern: 'random' as const };
const fitted = planFlooring([room(1220, 220.1)], s, 1);
expect(errors(fitted)).toEqual([]);
expect(fitted.boardCount).toBe(1);
expect(fitted.pieces[0].width).toBe(200.1);
expect(fitted.pieces[0].blankWidth).toBe(200.2);
expect(fitted.pieces[0].cut).toBe(true);
});
});

View file

@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { defaultProject, fireplace } from '../src/lib/defaults';
import { buildGeometry } from '../src/lib/geometry';
import { planFlooring } from '../src/lib/flooring';
import { floorPieceLabels } from '../src/lib/piece-labels';
describe('floorplan piece labels', () => {
const room = fireplace('room-2', 'Room 2');
const settings = {
...defaultProject().flooring,
pattern: 'random' as const,
minOffset: 100,
maxOffset: 1000,
minLength: 10,
minWidth: 0.1,
};
const plan = planFlooring([room], settings, 10);
const geometry = buildGeometry(room, 10);
const cuts = plan.pieces.filter((piece) => piece.cut || piece.reused);
it('labels short positive cuts without covering neighboring labels or changing their geometry', () => {
const before = structuredClone(plan.pieces);
const labels = floorPieceLabels(plan.pieces, geometry.vertices, 0, 4800 * 0.024);
expect(labels.map((label) => label.id)).toEqual(cuts.map((piece) => piece.id));
const small = cuts.filter((piece) => piece.length < 180);
expect(small.length).toBeGreaterThan(0);
for (const piece of small) {
expect(piece.length).toBeGreaterThan(0);
expect(piece.width).toBeGreaterThan(0);
expect(labels.find((label) => label.id === piece.id)!.leader).toBe(true);
}
for (const [index, label] of labels.entries()) {
expect(label.box.minX).toBeGreaterThanOrEqual(0);
expect(label.box.maxX).toBeLessThanOrEqual(4800);
for (const other of labels.slice(index + 1)) {
expect(
label.box.minX >= other.box.maxX ||
label.box.maxX <= other.box.minX ||
label.box.minY >= other.box.maxY ||
label.box.maxY <= other.box.minY,
).toBe(true);
}
}
expect(plan.pieces).toEqual(before);
expect(floorPieceLabels(plan.pieces, geometry.vertices, 0, 4800 * 0.024)).toEqual(labels);
});
it('allows space for complete three-digit codes and completion marks in either direction', () => {
const piece = { ...cuts[0], id: 'F999' };
for (const direction of [0, 90] as const) {
const plain = floorPieceLabels([piece], geometry.vertices, direction, 100)[0];
const done = floorPieceLabels(
[piece],
geometry.vertices,
direction,
100,
[],
[`floor:${piece.stockId}`],
)[0];
expect(done.text).toBe('F999 ✓');
expect(done.width).toBeGreaterThan(plain.width);
expect(done.angle).toBe(direction);
expect(done.box.maxX - done.box.minX).toBeCloseTo(direction === 0 ? done.width : done.height);
expect(done.box.maxY - done.box.minY).toBeCloseTo(direction === 0 ? done.height : done.width);
}
});
});

View file

@ -6,7 +6,7 @@ import { planSkirting } from '../src/lib/skirting';
import { skirtingPolygon } from '../src/lib/skirting-polygon';
import type { Point, Room } from '../src/lib/types';
const settings = { stockLengths: [2400], thickness: 18, minLength: 200, kerf: 3 };
const settings = { stockLength: 2400, thickness: 18, minLength: 200, kerf: 3 };
function overlap(a: Point[], b: Point[]): number {
const clipper = new ClipperLib.Clipper();
@ -56,10 +56,14 @@ function expectCompleteSkirting(room: Room) {
const next = polygon[(index + 1) % polygon.length];
return sum + Math.hypot(next.x - point.x, next.y - point.y) / 1000;
}, 0);
expect(polygon[0].x).toBeCloseTo(plan.pieces[i].start.x);
expect(polygon[0].y).toBeCloseTo(plan.pieces[i].start.y);
expect(polygon[1].x).toBeCloseTo(plan.pieces[i].end.x);
expect(polygon[1].y).toBeCloseTo(plan.pieces[i].end.y);
const piece = plan.pieces[i];
const installed = piece.installedTemplate!;
expect(Math.hypot(polygon[0].x - piece.start.x, polygon[0].y - piece.start.y)).toBeCloseTo(
Math.abs(installed[0].x - piece.template![0].x),
);
expect(Math.hypot(polygon[1].x - piece.end.x, polygon[1].y - piece.end.y)).toBeCloseTo(
Math.abs(installed[1].x - piece.template![1].x),
);
expect(
Math.abs(overlap(polygon, geometry.vertices) - Math.abs(signedArea(polygon))),
).toBeLessThan(clippingTolerance);
@ -73,7 +77,7 @@ function expectCompleteSkirting(room: Room) {
describe('installed skirting polygons', () => {
it.each([1, -1])(
'covers a rectangle including uncut stock without overlapping butt joints (orientation %i)',
'covers a rectangle including uncut stock without overlapping coped bodies (orientation %i)',
(orientation) => {
const room = rectangle();
room.walls.forEach((wall) => {
@ -93,19 +97,29 @@ describe('installed skirting polygons', () => {
});
const plan = expectCompleteSkirting(room);
expect(
plan.pieces.some(
(piece) => piece.leftType === 'external' && piece.rightType === 'external',
plan.pieces.reduce(
(sum, piece) =>
sum + Number(piece.leftType === 'external') + Number(piece.rightType === 'external'),
0,
),
).toBe(true);
).toBe(4);
},
);
it('matches the room boundary at angled internal butt joints', () => {
it.each([60, -60, 120, -120])(
'matches the room boundary at angled coped internal joints (%i degrees)',
(turn) => {
expectCompleteSkirting({
id: 'triangle',
name: 'Triangle',
direction: 0,
walls: [0, 1, 2].map((index) => ({ id: `w${index}`, length: 3000, turn: 120, doors: [] })),
});
walls: Array.from({ length: 360 / Math.abs(turn) }, (_, index) => ({
id: `w${index}`,
length: 3000,
turn,
doors: [],
})),
});
},
);
});

View file

@ -4,7 +4,7 @@ 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 };
const settings: SkirtingSettings = { stockLength: 2400, thickness: 18, minLength: 300, kerf: 3 };
const rectangle = (width = 4000, height = 3000, id = 'r1'): Room => ({
id,
name: id,
@ -18,23 +18,20 @@ const rectangle = (width = 4000, height = 3000, id = 'r1'): Room => ({
});
describe('skirting cutting plan', () => {
it('subtracts internal butt thickness and preserves every installed wall run', () => {
it('retains coping overlap in the wall-length blanks and preserves every visible run', () => {
const plan = planSkirting([rectangle()], settings, 5);
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
expect(plan.pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(
14_000 - 4 * 18,
);
expect(plan.pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(14_000);
expect(plan.pieces.every((piece) => piece.length >= settings.minLength)).toBe(true);
expect(skirtingCuts(plan)[0].id).toBe('A1');
for (let wall = 0; wall < 4; wall++) {
const pieces = plan.pieces.filter((piece) => piece.wallIndex === wall);
expect(pieces[0].leftType).toBe('internal');
expect(pieces.at(-1)?.rightType).toBe('internal');
expect(
pieces.every(
(piece) => Math.abs(piece.leftMitre) < 1e-6 && Math.abs(piece.rightMitre) < 1e-6,
),
).toBe(true);
expect(pieces[0].leftType).toBe(wall === 0 ? 'internal' : 'cope');
expect(pieces.at(-1)?.rightType).toBe(wall === 3 ? 'cope' : 'internal');
expect(pieces.every((piece) => piece.visibleLength! >= settings.minLength - 1e-6)).toBe(true);
expect(pieces.reduce((sum, piece) => sum + piece.visibleLength!, 0)).toBeCloseTo(
[4000, 3000, 4000, 3000][wall] - 36,
);
}
});
@ -43,7 +40,7 @@ describe('skirting cutting plan', () => {
room.walls[0].doors = [{ id: 'd1', offset: 1000, width: 900 }];
const plan = planSkirting([room], settings, 5);
const pieces = plan.pieces.filter((piece) => piece.wallIndex === 0);
expect(pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(4000 - 900 - 18);
expect(pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(4000 - 900);
expect(
pieces.some((piece) => Math.abs(piece.end.x - 1000) < 1e-6 && piece.rightType === 'square'),
).toBe(true);
@ -96,7 +93,7 @@ describe('skirting cutting plan', () => {
doors: [],
})),
};
const plan = planSkirting([room], { ...settings, stockLengths: [6000] }, 5);
const plan = planSkirting([room], { ...settings, stockLength: 6000 }, 5);
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
const front = plan.pieces.find((piece) => piece.wallIndex === 4)!;
expect(front.backLength).toBeCloseTo(1000);
@ -108,7 +105,7 @@ describe('skirting cutting plan', () => {
expect(front.rightBevel).toBeCloseTo(45);
expect(front.template?.map((point) => Math.round(point.x))).toEqual([18, 1018, 1036, 0]);
const reversed = { ...room, walls: room.walls.map((wall) => ({ ...wall, turn: -wall.turn })) };
const reversePlan = planSkirting([reversed], { ...settings, stockLengths: [6000] }, 5);
const reversePlan = planSkirting([reversed], { ...settings, stockLength: 6000 }, 5);
const reverseFront = reversePlan.pieces.find((piece) => piece.wallIndex === 4)!;
expect(reverseFront.length).toBeCloseTo(1036);
expect(reverseFront.leftBevel).toBeCloseTo(-45);
@ -117,7 +114,7 @@ describe('skirting cutting plan', () => {
it('packs across rooms with deterministic unique IDs and accounts for kerf', () => {
const rooms = [rectangle(1800, 1800, 'r1'), rectangle(1800, 1800, 'r2')];
const options = { ...settings, stockLengths: [1800, 3600], thickness: 0, kerf: 3 };
const options = { ...settings, stockLength: 1800, thickness: 0, kerf: 3 };
const plan = planSkirting(rooms, options, 5);
expect(plan).toEqual(planSkirting(rooms, options, 5));
expect(new Set(plan.pieces.map((piece) => piece.id)).size).toBe(plan.pieces.length);
@ -127,7 +124,7 @@ describe('skirting cutting plan', () => {
expect(plan.waste).toBeCloseTo(0);
const trimmed = planSkirting(
[rectangle(1199, 1199)],
{ ...settings, stockLengths: [2400], thickness: 0, minLength: 1199 },
{ ...settings, stockLength: 2400, thickness: 0, minLength: 1199 },
5,
);
expect(trimmed.stockCount).toBe(4); // 1199 + 3 + 1199 cannot fit into 2400.
@ -138,7 +135,7 @@ describe('skirting cutting plan', () => {
it('allows the final saw pass to extend beyond a short stock tail', () => {
const plan = planSkirting(
[rectangle(1199, 1199)],
{ ...settings, stockLengths: [1200], thickness: 0 },
{ ...settings, stockLength: 1200, thickness: 0 },
5,
);
expect(plan.pieces).toHaveLength(4);
@ -163,10 +160,10 @@ describe('skirting cutting plan', () => {
expect(plan.pieces.reduce((sum, piece) => sum + piece.length, 0)).toBeCloseTo(4794);
});
it('looks ahead when choosing from different stock lengths', () => {
it('packs multiple cuts into the single purchased stock length', () => {
const plan = planSkirting(
[rectangle(1200, 1200)],
{ ...settings, stockLengths: [1800, 2400], thickness: 0, kerf: 0 },
{ ...settings, stockLength: 2400, thickness: 0, kerf: 0 },
5,
);
expect(plan.stockCount).toBe(2);
@ -174,17 +171,66 @@ describe('skirting cutting plan', () => {
expect(plan.waste).toBeCloseTo(0);
});
it('lets reused tails set wall joints to save a stock length without extra saw cuts', () => {
it('discards rounded splits that exceed fractional stock while retaining a fitting candidate', () => {
const plan = planSkirting([rectangle(2400.1, 2400.1)], { ...settings, stockLength: 1200.2 }, 5);
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
expect(plan.pieces).toHaveLength(8);
for (let wall = 0; wall < 4; wall++) {
expect(
plan.pieces
.filter((piece) => piece.wallIndex === wall)
.reduce((sum, piece) => sum + piece.backLength, 0),
).toBeCloseTo(2400.1);
}
for (const stock of plan.stocks) {
let available = stock.length;
for (const id of stock.pieceIds) {
const piece = plan.pieces.find((piece) => piece.id === id)!;
const blank =
Math.abs(piece.length - available) < 1e-6 ? available : cutLength(piece.length);
expect(blank).toBeLessThanOrEqual(available + 1e-6);
expect(piece.blankLength).toBeCloseTo(blank);
expect(piece.visibleLength).toBeGreaterThanOrEqual(settings.minLength - 1e-6);
available = Math.max(0, available - blank - settings.kerf);
}
}
});
it('adds a joint when fractional stock cannot supply the rounded two-piece split', () => {
const plan = planSkirting([rectangle(2400.3, 2400.3)], { ...settings, stockLength: 1200.2 }, 5);
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
expect(plan.pieces.length).toBeGreaterThan(8);
for (let wall = 0; wall < 4; wall++) {
expect(
plan.pieces
.filter((piece) => piece.wallIndex === wall)
.reduce((sum, piece) => sum + piece.backLength, 0),
).toBeCloseTo(2400.3);
}
});
it('keeps another room when rounding makes a minimum-sized split impossible', () => {
const valid = rectangle(2400.4, 2400.4, 'valid');
const invalid = rectangle(2400.1, 2400.1, 'invalid');
const options = { ...settings, stockLength: 1200.2, thickness: 0, minLength: 1200.05 };
const plan = planSkirting([valid, invalid], options, 5);
expect(plan.pieces).toEqual(planSkirting([valid], options, 5).pieces);
expect(plan.pieces).toHaveLength(8);
const failures = plan.issues.filter((issue) => issue.severity === 'error');
expect(failures.length).toBeGreaterThan(0);
expect(failures.every((issue) => issue.roomId === invalid.id)).toBe(true);
});
it('lets reused tails set wall joints while counting preparation and coping operations', () => {
const room = rectangle(4000, 4000);
const plan = planSkirting([room], settings, 5);
expect(plan).toEqual(planSkirting([room], settings, 5));
expect(plan.stockCount).toBe(7); // Fixed joints used eight lengths for this simple square.
expect(plan.cuts).toBe(4);
expect(plan.waste).toBeCloseTo(7 * 2400 - (16000 - 4 * 18));
expect(plan.pieces.some((piece) => Math.abs(piece.length - 815) < 1e-6)).toBe(true);
expect(plan.cuts).toBe(13);
expect(plan.waste).toBeCloseTo(7 * 2400 - 16000);
for (let wallIndex = 0; wallIndex < 4; wallIndex++) {
const pieces = plan.pieces.filter((piece) => piece.wallIndex === wallIndex);
expect(pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(3982);
expect(pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(4000);
pieces.slice(1).forEach((piece, index) => {
expect(piece.start.x).toBeCloseTo(pieces[index].end.x);
expect(piece.start.y).toBeCloseTo(pieces[index].end.y);
@ -213,24 +259,25 @@ describe('skirting cutting plan', () => {
doors: [],
})),
};
const plan = planSkirting([room], { ...settings, stockLengths: [311] }, 5);
const plan = planSkirting([room], { ...settings, stockLength: 340 }, 5);
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
expect(plan.pieces).toHaveLength(30);
for (const piece of plan.pieces) {
expect(piece.length).toBeLessThanOrEqual(311 + 1e-6);
expect(piece.length).toBeLessThanOrEqual(340 + 1e-6);
expect(piece.backLength).toBeGreaterThanOrEqual(300 - 1e-6);
expect(piece.template![2].x - piece.template![3].x).toBeGreaterThanOrEqual(300 - 1e-6);
expect(piece.visibleLength).toBeGreaterThanOrEqual(300 - 1e-6);
}
for (let wallIndex = 0; wallIndex < 3; wallIndex++) {
expect(
plan.pieces
.filter((piece) => piece.wallIndex === wallIndex)
.reduce((sum, piece) => sum + piece.backLength, 0),
).toBeCloseTo(3100 - 18 / Math.sin((120 * Math.PI) / 180));
).toBeCloseTo(3100);
}
});
it('calculates angled butt cuts and back setbacks for non-square internal corners', () => {
it('prepares oblique copes at half-turn angles and wall-fits their receiving ends', () => {
const room: Room = {
id: 'triangle',
name: 'Triangle',
@ -242,18 +289,19 @@ describe('skirting cutting plan', () => {
doors: [],
})),
};
const plan = planSkirting([room], { ...settings, stockLengths: [4000] }, 5);
const plan = planSkirting([room], { ...settings, stockLength: 4000 }, 5);
expect(plan.pieces).toHaveLength(3);
expect(plan.pieces[0].backLength).toBeCloseTo(3000 - 18 / Math.sin((120 * Math.PI) / 180));
expect(plan.pieces.every((piece) => Math.abs(piece.backLength - 3000) < 1e-6)).toBe(true);
for (const piece of plan.pieces) {
expect(piece.leftMitre).toBeCloseTo(30);
expect(piece.rightMitre).toBeCloseTo(30);
expect(piece.leftType).toBe('internal');
expect(piece.leftBevel).toBeCloseTo(30);
expect(piece.rightBevel).toBeCloseTo(-30);
expect(piece.template![2].x - piece.template![3].x).toBeGreaterThan(settings.minLength);
expect(piece.leftMitre).toBeCloseTo(piece.wallIndex === 0 ? 30 : 60);
expect(piece.rightMitre).toBeCloseTo(piece.wallIndex === 2 ? 60 : 30);
expect(piece.leftType).toBe(piece.wallIndex === 0 ? 'internal' : 'cope');
expect(piece.visibleLength).toBeCloseTo(3000 - 36 * Math.tan(Math.PI / 3));
expect(piece.installedTemplate![1].x - piece.installedTemplate![0].x).toBeGreaterThan(
settings.minLength,
);
}
expect(plan.cuts).toBe(9); // Three blank crosscuts and six end bevels.
expect(plan.cuts).toBe(12); // Three blank crosscuts, six preparation bevels, three copes.
});
it('can leave a minimum-sized final piece without making a tiny join', () => {
@ -272,9 +320,9 @@ describe('skirting cutting plan', () => {
const room = rectangle();
room.walls[3].length += 100;
expect(planSkirting([room], settings, 5).pieces).toEqual([]);
expect(
planSkirting([rectangle()], { ...settings, stockLengths: [] }, 5).issues[0].severity,
).toBe('error');
expect(planSkirting([rectangle()], { ...settings, stockLength: 0 }, 5).issues[0].severity).toBe(
'error',
);
expect(
planSkirting([rectangle()], { ...settings, minLength: 1e-10 }, 5).issues[0].severity,
).toBe('error');
@ -310,12 +358,15 @@ describe('skirting cutting plan', () => {
).toEqual(['small', 'open']);
expect(plan.stockCount).toBe(planSkirting([valid], settings, 5).stockCount);
expect(
planSkirting([valid], { ...settings, stockLengths: [] }, 5).issues[0].roomId,
planSkirting([valid], { ...settings, stockLength: 0 }, 5).issues[0].roomId,
).toBeUndefined();
});
it('attributes corner stock-fit failures to the affected room', () => {
const valid = rectangle(4000, 3000, 'valid');
const valid = rectangle(4100, 4100, 'valid');
valid.walls.forEach((wall) => {
wall.doors = [{ id: `${wall.id}-door`, offset: 0, width: 100 }];
});
const triangle: Room = {
id: 'triangle',
name: 'Triangle',
@ -324,7 +375,7 @@ describe('skirting cutting plan', () => {
};
const plan = planSkirting(
[valid, triangle],
{ ...settings, stockLengths: [500], minLength: 490 },
{ ...settings, stockLength: 500, minLength: 490 },
5,
);
expect(plan.pieces.length).toBeGreaterThan(0);
@ -337,4 +388,101 @@ describe('skirting cutting plan', () => {
),
).toBe(true);
});
it('counts full-stock copes as cuts and uses a receiving first wall to close the installation order', () => {
const plan = planSkirting([rectangle(2400, 2400)], settings, 1);
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
expect(plan.stockCount).toBe(4);
expect(plan.cuts).toBe(8); // Four preparation mitres and four profile-coping operations.
for (const piece of plan.pieces) expect(piece.length).toBeCloseTo(2400);
expect(plan.pieces.map((piece) => [piece.leftType, piece.rightType])).toEqual([
['internal', 'internal'],
['cope', 'internal'],
['cope', 'internal'],
['cope', 'cope'],
]);
expect(skirtingCuts(plan).map((piece) => piece.id)).toEqual(['A1', 'A2', 'A3']);
expect(plan.pieces[0].id).toMatch(/^SU/);
for (const piece of plan.pieces) {
expect(piece.visibleLength).toBeCloseTo(2364);
expect(piece.leftOverlap).toBeCloseTo(18);
expect(piece.rightOverlap).toBeCloseTo(18);
}
const two = planSkirting(
[rectangle(2400, 2400, 'one'), rectangle(2400, 2400, 'two')],
settings,
1,
);
const ids = two.pieces.map((piece) => piece.id);
expect(new Set(ids).size).toBe(8);
expect(two.stocks.flatMap((stock) => stock.pieceIds).sort()).toEqual([...ids].sort());
});
it('does not cope across a door opening that removes the receiving corner', () => {
const r = rectangle(2400, 2400);
r.walls[0].doors = [{ id: 'corner-door', offset: 0, width: 900 }];
const plan = planSkirting([r], settings, 1);
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
expect(plan.pieces.filter((piece) => piece.wallIndex === 0)[0].leftType).toBe('square');
expect(plan.pieces.filter((piece) => piece.wallIndex === 3).at(-1)!.rightType).toBe('square');
expect(
plan.pieces.reduce(
(sum, piece) =>
sum + Number(piece.leftType === 'cope') + Number(piece.rightType === 'cope'),
0,
),
).toBe(3);
});
it('rejects an exposed run shorter than the minimum even when the preparation blank is long enough', () => {
const plan = planSkirting([rectangle(330, 2400)], settings, 1);
expect(plan.pieces).toEqual([]); // 330 mm blank, but only 330 - 2*18 = 294 mm is visible.
expect(
plan.issues.some((issue) => issue.severity === 'error' && issue.message.includes('minimum')),
).toBe(true);
});
it.each([1, -1])(
'reserves oblique overlap at obtuse internal corners (orientation %i)',
(orientation) => {
const r: Room = {
id: 'hexagon',
name: 'Hexagon',
direction: 0,
walls: Array.from({ length: 6 }, (_, index) => ({
id: `w${index}`,
length: 1000.2,
turn: 60 * orientation,
doors: [],
})),
};
const plan = planSkirting([r], { ...settings, stockLength: 2400 }, 1);
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
const allowance = 18 / Math.tan(Math.PI / 3);
expect(plan.pieces[0].length).toBeCloseTo(1000.2 + 2 * allowance);
expect(plan.pieces.at(-1)!.length).toBeCloseTo(1000.2);
expect(plan.pieces.at(-1)!.leftMitre).toBeCloseTo(30);
expect(plan.pieces.at(-1)!.rightMitre).toBeCloseTo(30);
for (const piece of plan.pieces)
expect(piece.visibleLength).toBeCloseTo(1000.2 - 36 * Math.tan(Math.PI / 6));
for (const stock of plan.stocks) {
const pieces = stock.pieceIds.map((id) => plan.pieces.find((piece) => piece.id === id)!);
expect(
pieces.reduce((sum, piece) => sum + cutLength(piece.length), 0) +
(pieces.length - 1) * settings.kerf,
).toBeLessThanOrEqual(stock.length);
}
expect(plan).toEqual(planSkirting([r], { ...settings, stockLength: 2400 }, 1));
},
);
it('counts a finishing crosscut when the rounded blank uses the complete available stock', () => {
const plan = planSkirting(
[rectangle(1199.6, 1199.6)],
{ ...settings, stockLength: 1200, thickness: 0 },
1,
);
expect(plan.stockCount).toBe(4);
expect(plan.cuts).toBe(4);
expect(skirtingCuts(plan)).toHaveLength(4);
expect(plan.pieces.every((piece) => cutLength(piece.length) === 1200)).toBe(true);
});
});

View file

@ -29,6 +29,8 @@ describe('URL state', () => {
expect(hash).toMatch(/^\?version=1&name=/);
const params = new URLSearchParams(hash);
expect(params.get('flooring.length')).toBe('1200');
expect(params.get('skirting.length')).toBe('2400');
expect(params.has('skirting.stock')).toBe(false);
expect(params.get('room.1.wall.1.length')).toBe('4800');
expect(params.get('room.1.wall.4.door.1.width')).toBe('826');
expect(hash).not.toContain('#plan=');
@ -44,7 +46,7 @@ describe('URL state', () => {
state.project.rooms[1].direction = 90;
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.project.skirting.stockLength = 3600;
state.view = { ...state.view, roomId: 'room-2', selectedPiece: 'A12' };
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
});
@ -96,10 +98,10 @@ describe('URL state', () => {
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
});
it('allows an unfinished room and an empty stock list without inventing inputs', () => {
it('allows an unfinished room and invalid board length without inventing inputs', () => {
const state = defaultState();
state.project.rooms[0].walls = [];
state.project.skirting.stockLengths = [];
state.project.skirting.stockLength = 0;
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
});
@ -196,7 +198,7 @@ describe('URL state', () => {
);
});
it('rejects too many rooms, walls, doors or stock lengths', () => {
it('rejects too many rooms, walls, doors or legacy stock lengths', () => {
const rooms = defaultState();
rooms.project.rooms = Array.from({ length: URL_LIMITS.rooms + 1 }, (_, i) =>
rectangle(`room-${i}`),
@ -217,8 +219,20 @@ describe('URL state', () => {
);
expectInvalid(rawLink(doors), /at most 30/);
const stock = defaultState();
stock.project.skirting.stockLengths = Array(URL_LIMITS.stockLengths + 1).fill(2400);
expectInvalid(rawLink(stock), /at most 30/);
expectInvalid(
rawLink({
...stock,
project: {
...stock.project,
skirting: {
...stock.project.skirting,
stockLength: undefined,
stockLengths: Array(URL_LIMITS.legacyStockLengths + 1).fill(2400),
},
},
}),
/at most 30/,
);
});
it('rejects no rooms, duplicate identifiers and oversized strings', () => {
@ -243,19 +257,16 @@ describe('URL state', () => {
params.set('flooring.maxOffset', '700');
params.set('room.1.wall.1.length', '5123.5');
params.set('room.1.wall.4.door.1.width', '900');
params.append('skirting.stock', '3600');
params.set('skirting.length', '3600');
params.set('selectedCut', 'F12');
const result = decodeState(`?${params}`);
expect(result.error).toBeNull();
expect(result.state.project.flooring.length).toBe(1400);
expect(result.state.project.rooms[0].walls[0].length).toBe(5123.5);
expect(result.state.project.rooms[0].walls[3].doors[0].width).toBe(900);
expect(result.state.project.skirting.stockLengths).toEqual([2400, 3600]);
expect(result.state.project.skirting.stockLength).toBe(3600);
expect(result.state.view.selectedPiece).toBe('F12');
const reversed = new URLSearchParams([...params].reverse());
// Repeated stock values retain their order; every other key can move freely.
reversed.delete('skirting.stock');
params.getAll('skirting.stock').forEach((length) => reversed.append('skirting.stock', length));
expect(encodeState(decodeState(`?${reversed}`).state)).toBe(encodeState(result.state));
});
@ -286,12 +297,12 @@ describe('URL state', () => {
expect(new URLSearchParams(encoded).has('room.1.wall.2.length')).toBe(false);
});
it('preserves explicitly empty stocks and rooms while omitting an unselected cut', () => {
it('preserves a zero board length and empty room while omitting an unselected cut', () => {
const state = defaultState();
state.project.skirting.stockLengths = [];
state.project.skirting.stockLength = 0;
state.project.rooms[0].walls = [];
const query = encodeState(state);
expect(query).toContain('skirting.stock=');
expect(query).toContain('skirting.length=0');
expect(query).not.toContain('selectedCut=');
expect(decodeState(query)).toEqual({ state, error: null });
});
@ -313,15 +324,100 @@ describe('URL state', () => {
'?name=%C0%AF',
])('rejects an invalid editable query %s', (query) => expectInvalid(query));
it('rejects ambiguous duplicate single-value fields but permits multiple stock lengths', () => {
it('rejects duplicate single-value fields and migrates repeated legacy stock lengths', () => {
expectInvalid('?flooring.length=1200&flooring.length=1400', /only once/);
expectInvalid('?selectedRoom=one&selectedRoom=two', /only once/);
expectInvalid('?room.1.name=Office&room.1.name=Kitchen', /only once/);
const result = decodeState('?skirting.stock=2400&skirting.stock=3600');
expect(result.error).toBeNull();
expect(result.state.project.skirting.stockLengths).toEqual([2400, 3600]);
expect(result.state.project.skirting.stockLength).toBe(2400);
expectInvalid('?skirting.length=2400&skirting.length=3600', /only once/);
});
it.each([
{ lengths: [3600, 2400], chosen: 3600, completed: [] },
{ lengths: [2400, 3600], chosen: 2400, completed: [] },
{ lengths: [3600], chosen: 3600, completed: [] },
{ lengths: [3600, 3600], chosen: 3600, completed: [] },
{ lengths: [], chosen: 0, completed: [] },
])(
'migrates old stock sizes $lengths consistently in query and compressed links',
({ lengths, chosen, completed }) => {
const state = defaultState();
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
const old = {
...state,
project: {
...state.project,
skirting: { ...state.project.skirting, stockLength: undefined, stockLengths: lengths },
},
};
const query = new URLSearchParams();
for (const length of lengths.length ? lengths : [''])
query.append('skirting.stock', String(length));
for (const board of state.project.completedBoards) query.append('done', board);
state.project.skirting.stockLength = chosen;
state.project.completedBoards = completed;
for (const link of [rawLink(old), `?${query}`]) {
const result = decodeState(link);
expect(result).toEqual({ state, error: null });
const canonical = encodeState(result.state);
expect(canonical).toContain(`skirting.length=${chosen}`);
expect(canonical).not.toContain('skirting.stock');
expect(decodeState(canonical)).toEqual(result);
}
},
);
it.each(['?done=skirting:S4', '?skirting.stock=2400&done=skirting:S4'])(
'clears completion from earlier calculations in %s and preserves new progress after migration',
(link) => {
const migrated = decodeState(link);
expect(migrated.error).toBeNull();
expect(migrated.state.project.completedBoards).toEqual([]);
migrated.state.project.completedBoards = ['skirting:S4'];
expect(decodeState(encodeState(migrated.state))).toEqual(migrated);
},
);
it('prefers the current length field over legacy stock lists without clearing progress', () => {
const state = defaultState();
state.project.skirting.stockLength = 4800;
state.project.completedBoards = ['skirting:S6'];
const legacy = {
...state,
project: {
...state.project,
skirting: { ...state.project.skirting, stockLengths: [3600, 2400] },
},
};
expect(decodeState(rawLink(legacy))).toEqual({ state, error: null });
expect(
decodeState('?skirting.length=4800&skirting.stock=3600&skirting.stock=2400&done=skirting:S6'),
).toEqual({ state, error: null });
});
it.each([null, '2400', true, {}, [], NaN, Infinity])(
'rejects invalid skirting board length %j',
(stockLength) => {
const state = defaultState();
expectInvalid(
rawLink({
...state,
project: { ...state.project, skirting: { ...state.project.skirting, stockLength } },
}),
/finite number/,
);
},
);
it.each(['', 'NaN', 'Infinity', 'true', '0x10'])(
'rejects invalid editable skirting length %s',
(length) => {
expectInvalid(`?skirting.length=${length}`, /finite number/);
},
);
it('rejects sparse, zero, malformed and excessive room, wall and door indices', () => {
expectInvalid('?room.2.id=second', /consecutive/);
expectInvalid('?room.0.id=zero', /positive whole numbers/);
@ -345,7 +441,7 @@ describe('URL state', () => {
it('converts a legacy shared plan to readable fields without losing inputs or selections', () => {
const state = defaultState();
state.project.rooms.push(fireplace('second', 'Fireplace room'));
state.project.skirting.stockLengths = [2400, 3600];
state.project.skirting.stockLength = 3600;
state.view = { ...state.view, roomId: 'second', selectedPiece: 'B2' };
const legacy = decodeState(rawLink(state));
expect(legacy.error).toBeNull();
@ -427,7 +523,7 @@ describe('URL state', () => {
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');
const distinct = decodeState('?skirting.length=2400&done=floor:B1&done=skirting:S1');
expect(distinct.error).toBeNull();
expect(distinct.state.project.completedBoards).toEqual(['floor:B1', 'skirting:S1']);
});