wip
This commit is contained in:
commit
d8f2407ddf
44 changed files with 10377 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
*.tsbuildinfo
|
||||||
5
.prettierignore
Normal file
5
.prettierignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
package-lock.json
|
||||||
1
.prettierrc.json
Normal file
1
.prettierrc.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{ "singleQuote": true, "printWidth": 100, "trailingComma": "all" }
|
||||||
59
README.md
Normal file
59
README.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Floor planner
|
||||||
|
|
||||||
|
A single-page flooring and skirting planner built with Vite, React and TypeScript. All measurements use millimetres; areas are displayed in square metres. Run it locally or host `dist/` on any static web host.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a supported Node.js release (22.12+ or 24+). Open the local URL printed by Vite.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test # Pure geometry, planning, URL and CSV tests
|
||||||
|
npx playwright install chromium
|
||||||
|
npm run test:e2e # Desktop Chromium and mobile-sized Chromium
|
||||||
|
npm run build # Strict TypeScript check and production build
|
||||||
|
npm run check # Build, unit tests and browser tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Planning workflow
|
||||||
|
|
||||||
|
1. Start with a rectangle, fireplace recess, or custom outline. Enter wall lengths in order. The first wall runs east; the turn **after** each wall is positive for right and negative for left. For example, a clockwise rectangle has 90° turns. The final corner is derived from the closed outline.
|
||||||
|
2. Add door openings with a width and an offset from the wall's start. Include architrave/trim in the width where skirting must be omitted. Doors terminate skirting; laminate ends at the room outline with a transition and expansion gap. Rooms are planned independently.
|
||||||
|
3. For a custom outline, the closing-wall suggestion gives the required length and turn. Add it, then replace the suggested length with the actual measured length. An outline must close within the chosen tolerance and have no intersections or invalid doors before a cut plan can be produced.
|
||||||
|
4. Set material dimensions, spacer, kerf and minimum piece sizes alongside the measurements. Kerf means material removed by the blade: a 3 mm cut leaves 600 mm + 397 mm from a 1,000 mm board. Both kerf fields have keyboard and touch tooltips. Skirting stock lengths are purchase options, not a fixed inventory. Materials are shared across rooms; direction is per room.
|
||||||
|
5. The drawing and cut plan update on the same page. Flooring and skirting are visible together, with the whole drawing fitting the page without an internal scrollbar. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Identical flooring cuts share a batch row; each piece has a small shape preview that opens exact dimensions in a popup. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Source allocations identify the stock used by each piece. Export CSV for both materials, or print the plan.
|
||||||
|
6. Copy the link to save or share the complete project. Measurements, materials, selected room and selected piece live in a compressed, versioned URL fragment. Old links containing steps, layers and zoom still load; those obsolete view choices are ignored. Reloading or opening that link reproduces the plan. Browser back/forward restores room changes. There is no local storage, account, API, database or analytics. Fonts are system fonts; the app makes no external font requests.
|
||||||
|
|
||||||
|
## Calculation rules and practical limits
|
||||||
|
|
||||||
|
- Geometry follows measured vectors, checks final-wall length/direction, detects crossings and validates opening bounds and overlaps. A small accepted closure residual snaps only the final wall to the origin, with a visible warning. A mismatch can indicate measurement error or an irregular wall; lengths and corner angles alone cannot identify or reconstruct a bow. Remeasure and represent known changes of direction as additional segments.
|
||||||
|
- Polygon offsets and clipping use `clipper-lib` at 0.001 mm internal precision. Displayed dimensions are rounded to 0.1 mm. The supplied cut templates contain board-relative polygon vertices and edge lengths for angled and notched pieces.
|
||||||
|
- Laminate is inset from every wall, including fireplace recesses. Interior rows retain full factory width. When the final row would be narrower than the minimum, the first and last rows share the width equally. A bounded search also compares other feasible edge-row arrangements.
|
||||||
|
- Brick uses a half-board stagger. Random is deterministic, prefers usable offcuts and avoids a repeating two-row sequence when feasible. The offset is the nearest end-joint distance on the adjacent row's grid, not a distance from a wall. Half a board is the largest possible nearest-joint offset. Settings that cannot be met produce an error instead of a partial layout.
|
||||||
|
- Minimum laminate sizes apply to each clipped piece's bounding length/width. Irregular shapes can still have narrow tips or necks; their exact templates are available in the cut popups. Product-specific click systems and assembly sequences are not simulated.
|
||||||
|
- Offcut reuse preserves the needed left/right factory ends. Only full-width end remnants are available for reuse; ripped side strips and shaped scraps are conservatively waste. Crosscuts include kerf. The stock count is actual allocated boards, not an area-only estimate. Waste includes kerf and unused material, including still-usable stock remainders.
|
||||||
|
- Skirting internal corners use butt joints: the previous wall runs through and the next is shortened by thickness, adjusted for non-square corners. Coping is excluded. External corners use equal mitres. Minimum sizes apply after corner allowances and door subtraction. An unavoidable undersized run is an error.
|
||||||
|
- Skirting cut lengths are the longest-point envelope. Cut square blanks first, then bevel their ends as specified; this keeps reusable stock remainders square. Back lengths and diagram positions locate the wall-facing edge. The stock allocation includes crosscut kerf and counts the additional bevel passes.
|
||||||
|
- Flooring uses a bounded beam search, minimizing purchased boards, then saw passes. Skirting compares full-stock, balanced and offcut-aware splits and packs them across offered lengths, preferring less waste, then fewer cuts. Available offcuts can move the next wall’s joints. Minimum lengths are reserved on both front and back edges of angled pieces. These are deterministic, auditable heuristics rather than guarantees of a global minimum. Room order is an input and can affect shared-offcut allocation.
|
||||||
|
- Board counts exclude an extra allowance for damage or mistakes. Spacer, minimum size and staggering defaults are editable starting values; follow the exact product's installation instructions. For example, [Quick-Step's installation guidance](https://www.quick-step.co.uk/en-gb/laminate/installation) provides product-specific manuals, and its [room-transition guidance](https://www.quick-step.co.uk/en-gb/frequently-asked-questions/laminate/preparation-and-installation/do-i-really-need-expansion-gaps-between-every-room) explains separate expansion joints.
|
||||||
|
- Links are bounded to 20 rooms, 100 walls per room and 30 doors per wall. Flooring calculations are bounded to 2,000 purchased boards and 500 rows per room. Large plans may take longer; very large projects should be split into separate links. Long links may exceed the limits of a messaging service.
|
||||||
|
|
||||||
|
## Code map
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
| ------------------------ | ----------------------------------------------------------- |
|
||||||
|
| `src/lib/types.ts` | Inputs and derived result contracts |
|
||||||
|
| `src/lib/defaults.ts` | Starter measurements and deterministic IDs |
|
||||||
|
| `src/lib/geometry.ts` | Vector tracing, closure, validation and polygon offsets |
|
||||||
|
| `src/lib/flooring.ts` | Row search, clipping, factory-end-aware stock allocation |
|
||||||
|
| `src/lib/skirting.ts` | Corner allowances, run splitting and stock allocation |
|
||||||
|
| `src/lib/url-state.ts` | Canonical schema, compression and untrusted-link validation |
|
||||||
|
| `src/lib/use-project.ts` | URL and browser-history synchronization |
|
||||||
|
| `src/lib/export.ts` | CSV generation, including formula-injection protection |
|
||||||
|
| `src/components/` | Editors, SVG drawing and linked cut tables |
|
||||||
|
| `tests/` | Pure calculation and state tests |
|
||||||
|
| `e2e/` | Actual browser workflows at desktop and mobile sizes |
|
||||||
|
|
||||||
|
Only the inputs and view selections are persisted. Geometry, plans, quantities, warning messages and drawings are derived. There are no timestamps or random-number calls in the planners. `npm run build` also checks unused variables and strict TypeScript types.
|
||||||
580
e2e/planner.spec.ts
Normal file
580
e2e/planner.spec.ts
Normal file
|
|
@ -0,0 +1,580 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { decodeState } from '../src/lib/url-state';
|
||||||
|
import { planFlooring } from '../src/lib/flooring';
|
||||||
|
import { planSkirting } from '../src/lib/skirting';
|
||||||
|
import { flooringCuts, skirtingCuts, uncutFlooring } from '../src/lib/cut-list';
|
||||||
|
import { mm } from '../src/lib/format';
|
||||||
|
|
||||||
|
const errors = new WeakMap<Page, string[]>();
|
||||||
|
const preview = (page: Page) => page.getByRole('region', { name: 'Floor plan preview' });
|
||||||
|
const floorRows = (page: Page) =>
|
||||||
|
page.getByRole('table', { name: 'Flooring cuts and offcut placements' }).locator('tbody tr');
|
||||||
|
const skirtRows = (page: Page) =>
|
||||||
|
page.getByRole('table', { name: 'Skirting pieces requiring cuts' }).locator('tbody tr');
|
||||||
|
const floorCodes = (page: Page) =>
|
||||||
|
floorRows(page).getByRole('button', { name: /^Preview flooring cut / });
|
||||||
|
const skirtCodes = (page: Page) =>
|
||||||
|
skirtRows(page).getByRole('button', { name: /^Preview skirting cut / });
|
||||||
|
const state = (page: Page) => decodeState(new URL(page.url()).hash).state;
|
||||||
|
const plans = (page: Page) => {
|
||||||
|
const { project } = state(page);
|
||||||
|
return {
|
||||||
|
project,
|
||||||
|
floor: planFlooring(project.rooms, project.flooring, project.tolerance),
|
||||||
|
skirt: planSkirting(project.rooms, project.skirting, project.tolerance),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function openWalls(page: Page) {
|
||||||
|
if (
|
||||||
|
!(await page
|
||||||
|
.locator('.wall-details')
|
||||||
|
.evaluate((element) => (element as HTMLDetailsElement).open))
|
||||||
|
) {
|
||||||
|
await page.locator('.wall-details summary').click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function noHorizontalOverflow(page: Page) {
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1),
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function noDrawingScroll(page: Page) {
|
||||||
|
const scrollContainers = await preview(page).evaluate((root) =>
|
||||||
|
[root, ...root.querySelectorAll('*')]
|
||||||
|
.filter((element): element is HTMLElement => element instanceof HTMLElement)
|
||||||
|
.filter((element) => {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return (
|
||||||
|
(/auto|scroll/.test(style.overflowX) && element.scrollWidth > element.clientWidth + 1) ||
|
||||||
|
(/auto|scroll/.test(style.overflowY) && element.scrollHeight > element.clientHeight + 1)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((element) => element.className),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
scrollContainers,
|
||||||
|
'The room drawing must use the page scroll, without its own scrollbars',
|
||||||
|
).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function noWallLabelOverlap(page: Page) {
|
||||||
|
const labels = await preview(page)
|
||||||
|
.locator('.wall-label text')
|
||||||
|
.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
text: element.textContent,
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
right: rect.right,
|
||||||
|
bottom: rect.bottom,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(labels).toHaveLength(8);
|
||||||
|
const overlaps: string[] = [];
|
||||||
|
labels.forEach((label, i) =>
|
||||||
|
labels.slice(i + 1).forEach((other) => {
|
||||||
|
if (
|
||||||
|
Math.min(label.right, other.right) - Math.max(label.x, other.x) > 0.5 &&
|
||||||
|
Math.min(label.bottom, other.bottom) - Math.max(label.y, other.y) > 0.5
|
||||||
|
) {
|
||||||
|
overlaps.push(`${label.text} overlaps ${other.text}`);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(overlaps, 'Fireplace wall dimensions should remain independently readable').toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
errors.set(page, []);
|
||||||
|
page.on('pageerror', (error) => errors.get(page)!.push(error.message));
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
expect(errors.get(page), 'The application should not throw browser errors').toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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']) {
|
||||||
|
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();
|
||||||
|
await expect(page.locator('.layer-switch, .step-nav')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.app-header').getByText('mm', { exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: /^Zoom (in|out)$/ })).toHaveCount(0);
|
||||||
|
await noDrawingScroll(page);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: /Choose materials|Get my cut plan|Measure room/ }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByText(
|
||||||
|
/LESS WASTE\. MORE CONFIDENCE|A better floor starts|Thoughtful plans|Saved in your URL|bounded heuristic|inspect narrow tips/i,
|
||||||
|
),
|
||||||
|
).toHaveCount(0);
|
||||||
|
expect(
|
||||||
|
await page.locator('#cut-plan').evaluate((element) => element.getBoundingClientRect().top),
|
||||||
|
).toBeGreaterThan(
|
||||||
|
await page.locator('#materials').evaluate((element) => element.getBoundingClientRect().bottom),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drawing includes full boards, while numbered cut thumbnails stay complete and sequential', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const { project, floor, skirt } = plans(page);
|
||||||
|
const cutFloor = flooringCuts(floor);
|
||||||
|
const cutSkirt = skirtingCuts(skirt);
|
||||||
|
const wholeBoards = uncutFlooring(floor, project.flooring).wholeBoards;
|
||||||
|
expect(wholeBoards).toBeGreaterThan(0);
|
||||||
|
await expect(floorCodes(page)).toHaveCount(cutFloor.length);
|
||||||
|
await expect(skirtCodes(page)).toHaveCount(cutSkirt.length);
|
||||||
|
const shownCodes = await floorCodes(page).evaluateAll((buttons) =>
|
||||||
|
buttons.map((button) =>
|
||||||
|
button.getAttribute('aria-label')!.replace('Preview flooring cut ', ''),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(shownCodes.sort()).toEqual(cutFloor.map((piece) => piece.id).sort());
|
||||||
|
expect(cutFloor.map((piece) => piece.id)).toEqual(cutFloor.map((_, i) => `F${i + 1}`));
|
||||||
|
expect(cutSkirt.map((piece) => piece.id)).toEqual(cutSkirt.map((_, i) => `A${i + 1}`));
|
||||||
|
expect(await floorRows(page).count(), 'Repeated dimensions share one cutting batch').toBeLessThan(
|
||||||
|
cutFloor.length,
|
||||||
|
);
|
||||||
|
const batches = await floorRows(page).evaluateAll((rows) =>
|
||||||
|
rows.map((row) => ({
|
||||||
|
codes: [...row.querySelectorAll('button[aria-label^="Preview flooring cut "]')].map(
|
||||||
|
(button) => button.getAttribute('aria-label')!.replace('Preview flooring cut ', ''),
|
||||||
|
),
|
||||||
|
size: row.querySelector('.batch-size')?.textContent?.trim(),
|
||||||
|
quantity: row.querySelector('.batch-qty')?.textContent?.trim(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
for (const batch of batches) {
|
||||||
|
expect(batch.quantity).toBe(String(batch.codes.length));
|
||||||
|
for (const code of batch.codes) {
|
||||||
|
const piece = cutFloor.find((piece) => piece.id === code)!;
|
||||||
|
expect(batch.size).toBe(`${mm(piece.length)} × ${mm(piece.width)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await expect(preview(page).locator('.floor-piece')).toHaveCount(floor.pieces.length);
|
||||||
|
await expect(preview(page).locator('.skirt-piece')).toHaveCount(skirt.pieces.length);
|
||||||
|
await expect(preview(page).getByRole('button', { name: /^Select flooring cut / })).toHaveCount(
|
||||||
|
cutFloor.length,
|
||||||
|
);
|
||||||
|
await expect(preview(page).getByRole('button', { name: /^Select skirting cut / })).toHaveCount(
|
||||||
|
cutSkirt.length,
|
||||||
|
);
|
||||||
|
await expect(page.locator('.flooring-cuts .cut-summary')).toContainText(
|
||||||
|
`${wholeBoards} full boards laid uncut`,
|
||||||
|
);
|
||||||
|
const drawnCodes = await preview(page).locator('svg text').allTextContents();
|
||||||
|
for (const piece of floor.pieces.filter((piece) => !piece.cut && !piece.reused)) {
|
||||||
|
expect(shownCodes).not.toContain(piece.id);
|
||||||
|
expect(drawnCodes).not.toContain(piece.id);
|
||||||
|
}
|
||||||
|
const skirtCutIds = new Set(cutSkirt.map((piece) => piece.id));
|
||||||
|
for (const piece of skirt.pieces.filter((piece) => !skirtCutIds.has(piece.id))) {
|
||||||
|
expect(drawnCodes).not.toContain(piece.id);
|
||||||
|
await expect(
|
||||||
|
preview(page).locator(`.skirt-piece[data-piece-id="${piece.id}"]`),
|
||||||
|
).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
}
|
||||||
|
const repeatedCode = batches.find((batch) => batch.codes.length > 1)!.codes.at(-1)!;
|
||||||
|
const repeatedPiece = cutFloor.find((piece) => piece.id === repeatedCode)!;
|
||||||
|
const thumbnail = page.getByRole('button', {
|
||||||
|
name: `Preview flooring cut ${repeatedCode}`,
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(thumbnail.locator('svg')).toBeVisible();
|
||||||
|
await thumbnail.click();
|
||||||
|
const dialog = page.getByRole('dialog', { name: `Flooring cut ${repeatedCode}`, exact: true });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog.locator('.cut-origin')).toContainText(`Row ${repeatedPiece.row}`);
|
||||||
|
await expect(dialog.locator('.cut-origin')).toContainText(repeatedPiece.stockId);
|
||||||
|
await expect(dialog.getByRole('img', { name: `Flooring shape ${repeatedCode}` })).toBeVisible();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||||
|
await expect(thumbnail).toBeFocused();
|
||||||
|
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inconsistent final measurements block cuts and small residuals show the expected length', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await openWalls(page);
|
||||||
|
await page.getByLabel('Wall 4 length', { exact: true }).fill('3700');
|
||||||
|
await expect(page.getByText(/The outline is open by 100\.0 mm/).first()).toBeVisible();
|
||||||
|
await expect(floorRows(page)).toHaveCount(0);
|
||||||
|
await expect(skirtRows(page)).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
|
||||||
|
await page.getByLabel('Wall 4 length', { exact: true }).fill('3605');
|
||||||
|
await expect(page.getByText(/non-straight wall/)).toBeVisible();
|
||||||
|
await expect(page.getByText('Final wall: 3,600 mm expected · 5 mm difference')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
|
||||||
|
expect(await floorRows(page).count()).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('room warnings stay with their room even when two room names match', async ({ page }) => {
|
||||||
|
await openWalls(page);
|
||||||
|
await page.getByLabel('Wall 4 length', { exact: true }).fill('3605');
|
||||||
|
await expect(page.getByText(/non-straight wall/).first()).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Add room', exact: true }).click();
|
||||||
|
await page.getByLabel('Room name', { exact: true }).fill('Living room');
|
||||||
|
expect(state(page).project.rooms.map((room) => room.name)).toEqual([
|
||||||
|
'Living room',
|
||||||
|
'Living room',
|
||||||
|
]);
|
||||||
|
await expect(page.getByText(/non-straight wall/)).toHaveCount(0);
|
||||||
|
await page.locator('.room-tab').first().click();
|
||||||
|
await expect(page.getByText(/non-straight wall/).first()).toBeVisible();
|
||||||
|
await openWalls(page);
|
||||||
|
await page.getByLabel('Wall 4 length', { exact: true }).fill('3700');
|
||||||
|
await expect(page.getByText(/outline is open by 100\.0 mm/).first()).toBeVisible();
|
||||||
|
await page.locator('.room-tab').nth(1).click();
|
||||||
|
await expect(page.getByText(/outline is open by 100\.0 mm/)).toHaveCount(0);
|
||||||
|
await page.locator('.room-tab').first().click();
|
||||||
|
await expect(page.getByText(/outline is open by 100\.0 mm/).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('custom walls close using a calculated wall that can be measured again', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: 'Custom', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Add a wall', exact: true }).click();
|
||||||
|
await page.getByLabel('Wall 2 length', { exact: true }).fill('3000');
|
||||||
|
await page.getByRole('button', { name: 'Add a wall', exact: true }).click();
|
||||||
|
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();
|
||||||
|
await openWalls(page);
|
||||||
|
await expect(page.getByLabel('Wall 4 length', { exact: true })).toHaveValue('3000');
|
||||||
|
await page.getByLabel('Wall 4 length', { exact: true }).fill('3050');
|
||||||
|
await expect(page.getByText(/3,000 mm expected; length differs by 50 mm/)).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialogs', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.getByRole('button', { name: 'With fireplace' }).click();
|
||||||
|
await expect(page.getByLabel('Wall 8 length', { exact: true })).toHaveValue('3600');
|
||||||
|
await expect(preview(page).getByRole('img', { name: /floor plan with/ })).toHaveAttribute(
|
||||||
|
'aria-label',
|
||||||
|
/8 walls/,
|
||||||
|
);
|
||||||
|
await noWallLabelOverlap(page);
|
||||||
|
const { floor, skirt } = plans(page);
|
||||||
|
const mitred = skirtingCuts(skirt).find(
|
||||||
|
(piece) => Math.abs(piece.leftMitre) === 45 || Math.abs(piece.rightMitre) === 45,
|
||||||
|
)!;
|
||||||
|
expect(mitred).toBeDefined();
|
||||||
|
const thumbnail = page.getByRole('button', {
|
||||||
|
name: `Preview skirting cut ${mitred.id}`,
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(thumbnail.locator('svg').first()).toBeVisible();
|
||||||
|
await thumbnail.click();
|
||||||
|
const dialog = page.getByRole('dialog', { name: `Skirting cut ${mitred.id}`, exact: true });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog).toContainText(/long point/i);
|
||||||
|
const end = Math.abs(mitred.leftMitre) === 45 ? 'left' : 'right';
|
||||||
|
const projected = await dialog
|
||||||
|
.locator(`.skirting-cut-edge[data-end="${end}"]`)
|
||||||
|
.evaluate((element) => {
|
||||||
|
const line = element as SVGLineElement;
|
||||||
|
const matrix = line.getScreenCTM()!;
|
||||||
|
const start = new DOMPoint(line.x1.baseVal.value, line.y1.baseVal.value).matrixTransform(
|
||||||
|
matrix,
|
||||||
|
);
|
||||||
|
const finish = new DOMPoint(line.x2.baseVal.value, line.y2.baseVal.value).matrixTransform(
|
||||||
|
matrix,
|
||||||
|
);
|
||||||
|
return { dx: Math.abs(finish.x - start.x), dy: Math.abs(finish.y - start.y) };
|
||||||
|
});
|
||||||
|
expect(projected.dx).toBeGreaterThan(5);
|
||||||
|
expect(projected.dy).toBeGreaterThan(5);
|
||||||
|
expect(
|
||||||
|
projected.dx,
|
||||||
|
'A 45° mitre must have equal horizontal and vertical screen distances',
|
||||||
|
).toBeCloseTo(projected.dy, 1);
|
||||||
|
await page.getByRole('button', { name: 'Close cut preview', exact: true }).click();
|
||||||
|
await expect(thumbnail).toBeFocused();
|
||||||
|
const shaped = flooringCuts(floor).find((piece) => piece.shaped)!;
|
||||||
|
expect(shaped).toBeDefined();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: `Preview flooring cut ${shaped.id}`, exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('dialog', { name: `Flooring cut ${shaped.id}`, exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole('img', { name: `Flooring shape ${shaped.id}` })).toBeVisible();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.selected-cut')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
await expect(page.locator('.door-card')).toHaveCount(2);
|
||||||
|
await page.locator('.door-card').last().getByRole('combobox').selectOption({ label: 'W1' });
|
||||||
|
const movedDoor = page.locator('.door-card').first();
|
||||||
|
await movedDoor.getByLabel('Door offset', { exact: true }).fill('1200');
|
||||||
|
await movedDoor.getByLabel('Door width', { exact: true }).fill('900');
|
||||||
|
expect(state(page).project.rooms[0].walls[0].doors[0]).toMatchObject({
|
||||||
|
offset: 1200,
|
||||||
|
width: 900,
|
||||||
|
});
|
||||||
|
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
|
||||||
|
expect(await page.locator('.skirting-cuts').innerText()).not.toBe(before);
|
||||||
|
await page.getByLabel('Stock length 1', { exact: true }).fill('3600');
|
||||||
|
await page.getByLabel('Skirting thickness', { exact: true }).fill('22');
|
||||||
|
const { skirt } = plans(page);
|
||||||
|
await expect(skirtCodes(page)).toHaveCount(skirtingCuts(skirt).length);
|
||||||
|
await expect(page.locator('.skirting-cuts .cut-summary')).toContainText(
|
||||||
|
`Buy ${skirt.stockCount} lengths`,
|
||||||
|
);
|
||||||
|
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
|
||||||
|
await movedDoor.getByLabel('Door width', { exact: true }).fill('6000');
|
||||||
|
await expect(page.getByText(/must fit within the wall/).first()).toBeVisible();
|
||||||
|
await expect(skirtRows(page)).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid skirting settings keep flooring results usable', async ({ page }) => {
|
||||||
|
const floorCount = await floorCodes(page).count();
|
||||||
|
await page.getByLabel('Stock length 1', { exact: true }).fill('100');
|
||||||
|
await expect(skirtRows(page)).toHaveCount(0);
|
||||||
|
await expect(floorCodes(page)).toHaveCount(floorCount);
|
||||||
|
await expect(page.locator('.issue.error').first()).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
|
||||||
|
await page.getByLabel('Stock length 1', { exact: true }).fill('2400');
|
||||||
|
expect(await skirtRows(page).count()).toBeGreaterThan(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pattern, direction and spacers persist without steps, layers or stored results', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.getByLabel('Room length', { exact: true }).fill('3650');
|
||||||
|
await page.getByRole('button', { name: 'Random', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Across first wall', exact: true }).click();
|
||||||
|
await page.getByLabel('Wall spacer', { exact: true }).fill('12');
|
||||||
|
expect(state(page).project.flooring).toMatchObject({ pattern: 'random', gap: 12 });
|
||||||
|
expect(state(page).project.rooms[0].direction).toBe(90);
|
||||||
|
expect(Object.keys(state(page).view).sort()).toEqual(['roomId', 'selectedPiece']);
|
||||||
|
expect(Object.keys(state(page)).sort()).toEqual(['project', 'view']);
|
||||||
|
const table = await page.locator('.flooring-cuts tbody').innerText();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByLabel('Wall spacer', { exact: true })).toHaveValue('12');
|
||||||
|
await expect(page.getByRole('button', { name: 'Random', exact: true })).toHaveAttribute(
|
||||||
|
'aria-pressed',
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Across first wall', exact: true }),
|
||||||
|
).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(page.locator('.flooring-cuts tbody')).toHaveText(table, { useInnerText: true });
|
||||||
|
await noHorizontalOverflow(page);
|
||||||
|
expect(await page.evaluate(() => Object.keys(localStorage))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a narrow remainder is shared between the first and last flooring rows', async ({ page }) => {
|
||||||
|
// 3650 minus two 10 mm spacers leaves 19 full rows and a 20 mm sliver.
|
||||||
|
await page.getByLabel('Room length', { exact: true }).fill('3650');
|
||||||
|
const { floor } = plans(page);
|
||||||
|
for (const row of [1, 20]) {
|
||||||
|
const piece = flooringCuts(floor).find((piece) => piece.row === row);
|
||||||
|
expect(piece).toBeDefined();
|
||||||
|
const group = floorRows(page).filter({
|
||||||
|
has: page.getByRole('button', { name: `Preview flooring cut ${piece!.id}`, exact: true }),
|
||||||
|
});
|
||||||
|
await expect(group.locator('[data-label="Cut size · mm"]')).toContainText('× 105');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple rooms retain independent measurements with combined cut lists', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: 'Add room', exact: true }).click();
|
||||||
|
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Room 2');
|
||||||
|
await page.getByLabel('Room name', { exact: true }).fill('Study');
|
||||||
|
await page.getByLabel('Room width', { exact: true }).fill('3200');
|
||||||
|
expect(state(page).project.rooms).toHaveLength(2);
|
||||||
|
expect(state(page).project.rooms[0].walls[0].length).toBe(4800);
|
||||||
|
expect(state(page).project.rooms[1].walls[0].length).toBe(3200);
|
||||||
|
expect(await floorRows(page).filter({ hasText: 'Living room' }).count()).toBeGreaterThan(0);
|
||||||
|
expect(await skirtRows(page).filter({ hasText: 'Study' }).count()).toBeGreaterThan(0);
|
||||||
|
await page.getByRole('button', { name: /^Living room.*m²/ }).click();
|
||||||
|
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800');
|
||||||
|
await page.getByRole('button', { name: /^Study.*m²/ }).click();
|
||||||
|
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('3200');
|
||||||
|
await page.getByRole('button', { name: 'Remove this room' }).click();
|
||||||
|
expect(state(page).project.rooms).toHaveLength(1);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Living room');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shared URLs reopen the selected preview and CSV contains every cut once', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
await page.getByLabel('Room name', { exact: true }).fill('Café 北');
|
||||||
|
const { floor, skirt } = plans(page);
|
||||||
|
const pieceCode = flooringCuts(floor)[0].id;
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: `Preview flooring cut ${pieceCode}`, exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(state(page).view.selectedPiece).toBe(pieceCode);
|
||||||
|
const shareUrl = page.url();
|
||||||
|
const originalTable = await page.locator('.flooring-cuts tbody').innerText();
|
||||||
|
const shared = await context.newPage();
|
||||||
|
await shared.goto(shareUrl);
|
||||||
|
await expect(
|
||||||
|
shared.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(shared.locator('.flooring-cuts tbody')).toHaveText(originalTable, {
|
||||||
|
useInnerText: true,
|
||||||
|
});
|
||||||
|
await shared.reload();
|
||||||
|
await expect(
|
||||||
|
shared.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await shared.close();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
expect(state(page).view.selectedPiece).toBeNull();
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Export CSV' }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toBe('flooring-cut-plan.csv');
|
||||||
|
const csv = await readFile((await download.path())!, 'utf8');
|
||||||
|
expect(csv).toContain('"Material","Room","Piece","Stock"');
|
||||||
|
expect(csv).toContain(`"Flooring","Café 北","${pieceCode}"`);
|
||||||
|
expect(csv.split('\r\n').filter((line) => line.startsWith('"Flooring",'))).toHaveLength(
|
||||||
|
flooringCuts(floor).length,
|
||||||
|
);
|
||||||
|
expect(csv.split('\r\n').filter((line) => line.startsWith('"Skirting",'))).toHaveLength(
|
||||||
|
skirtingCuts(skirt).length,
|
||||||
|
);
|
||||||
|
for (const piece of floor.pieces.filter((piece) => !piece.cut && !piece.reused))
|
||||||
|
expect(csv).not.toContain(`"${piece.id}"`);
|
||||||
|
expect(csv).not.toContain('bounded heuristic');
|
||||||
|
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||||
|
const closedUrl = page.url();
|
||||||
|
await page.getByRole('button', { name: 'Copy link', exact: true }).click();
|
||||||
|
await expect(page.getByText('Link copied', { exact: true })).toBeVisible();
|
||||||
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(closedUrl);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('.flooring-cuts tbody')).toHaveText(originalTable, {
|
||||||
|
useInnerText: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saw kerf help works with keyboard and touch or mouse', async ({ page, isMobile }) => {
|
||||||
|
for (const name of ['What is saw kerf?', 'What is skirting saw kerf?']) {
|
||||||
|
const button = page.getByRole('button', { name, exact: true });
|
||||||
|
await button.focus();
|
||||||
|
await button.press('Space');
|
||||||
|
await expect(button).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
await expect(page.getByRole('tooltip')).toContainText(
|
||||||
|
'Width of material removed by the saw blade.',
|
||||||
|
);
|
||||||
|
await expect(page.getByRole('tooltip')).toContainText('600 mm + 397 mm');
|
||||||
|
await button.press('Escape');
|
||||||
|
await expect(page.getByRole('tooltip')).toHaveCount(0);
|
||||||
|
if (isMobile) await button.tap();
|
||||||
|
else await button.click();
|
||||||
|
await expect(page.getByRole('tooltip')).toBeVisible();
|
||||||
|
await noHorizontalOverflow(page);
|
||||||
|
await page.getByLabel('Board length', { exact: true }).focus();
|
||||||
|
await expect(page.getByRole('tooltip')).toHaveCount(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid URLs recover when measurements are edited', async ({ page }) => {
|
||||||
|
await page.goto('/#plan=broken');
|
||||||
|
await expect(page.getByText(/Could not load this plan/)).toBeVisible();
|
||||||
|
await page.getByLabel('Room name', { exact: true }).fill('Recovered plan');
|
||||||
|
await expect(page.getByText(/Could not load this plan/)).toHaveCount(0);
|
||||||
|
expect(decodeState(new URL(page.url()).hash).error).toBeNull();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Recovered plan');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const width of [320, 390]) {
|
||||||
|
test(`phone layout works at ${width} px with readable inputs and touch targets`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width, height: 844 });
|
||||||
|
await openWalls(page);
|
||||||
|
await page.getByRole('button', { name: 'What is saw kerf?', exact: true }).click();
|
||||||
|
await noHorizontalOverflow(page);
|
||||||
|
await expect(page.getByRole('tooltip')).toBeVisible();
|
||||||
|
const smallControls = await page
|
||||||
|
.locator('button, input, select, summary')
|
||||||
|
.evaluateAll((elements) =>
|
||||||
|
elements.flatMap((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return rect.width > 0 && rect.height > 0 && (rect.width < 39.5 || rect.height < 39.5)
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: element.getAttribute('aria-label') || element.textContent?.trim(),
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(smallControls, 'Every visible form control should have a usable touch target').toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const smallInputs = await page
|
||||||
|
.locator('input, select')
|
||||||
|
.evaluateAll((elements) =>
|
||||||
|
elements
|
||||||
|
.filter(
|
||||||
|
(element) =>
|
||||||
|
element.getBoundingClientRect().height > 0 &&
|
||||||
|
parseFloat(getComputedStyle(element).fontSize) < 16,
|
||||||
|
)
|
||||||
|
.map((element) => element.getAttribute('aria-label') || element.id),
|
||||||
|
);
|
||||||
|
expect(smallInputs, '16 px input text avoids phone browser zoom').toEqual([]);
|
||||||
|
expect(
|
||||||
|
await floorRows(page)
|
||||||
|
.first()
|
||||||
|
.evaluate((element) => getComputedStyle(element).display),
|
||||||
|
).toMatch(/block|grid/);
|
||||||
|
await page.getByRole('button', { name: 'With fireplace' }).click();
|
||||||
|
await noWallLabelOverlap(page);
|
||||||
|
await noDrawingScroll(page);
|
||||||
|
await skirtCodes(page).first().click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
const box = await dialog.boundingBox();
|
||||||
|
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(box!.y).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
|
||||||
|
expect(box!.y + box!.height).toBeLessThanOrEqual(844);
|
||||||
|
await noHorizontalOverflow(page);
|
||||||
|
await page.mouse.click(1, 1);
|
||||||
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||||
|
expect(state(page).view.selectedPiece).toBeNull();
|
||||||
|
await page.getByText('Source lengths for skirting cuts', { exact: true }).click();
|
||||||
|
await noHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
17
index.html
Normal file
17
index.html
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#17263f" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Room measurements, flooring and skirting materials, and labelled cutting plans."
|
||||||
|
/>
|
||||||
|
<title>Floor planner</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2336
package-lock.json
generated
Normal file
2336
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
36
package.json
Normal file
36
package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"name": "floor-planner",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"check": "npm run build && npm test && npm run test:e2e",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"clipper-lib": "^6.4.2",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"lz-string": "^1.5.0",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.55.0",
|
||||||
|
"@types/clipper-lib": "^6.4.0",
|
||||||
|
"@types/node": "^24.3.0",
|
||||||
|
"@types/react": "^19.1.10",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"prettier": "^3.9.6",
|
||||||
|
"typescript": "~5.9.2",
|
||||||
|
"vite": "^7.1.3",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
playwright.config.ts
Normal file
16
playwright.config.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
reporter: 'list',
|
||||||
|
use: { baseURL: 'http://127.0.0.1:5173', trace: 'retain-on-failure' },
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||||
|
{ name: 'mobile', use: { ...devices['iPhone 13'], defaultBrowserType: 'chromium' } },
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev -- --port 5173 --strictPort',
|
||||||
|
url: 'http://127.0.0.1:5173',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
259
src/App.tsx
Normal file
259
src/App.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
import { useMemo, useRef } from 'react';
|
||||||
|
import { Copy, Plus, Printer, ArrowDown } from 'lucide-react';
|
||||||
|
import { useProject } from './lib/use-project';
|
||||||
|
import { rectangle, nextId } from './lib/defaults';
|
||||||
|
import { buildGeometry } from './lib/geometry';
|
||||||
|
import { planFlooring } from './lib/flooring';
|
||||||
|
import { planSkirting } from './lib/skirting';
|
||||||
|
import { area, mm } from './lib/format';
|
||||||
|
import { isSkirtingCut } from './lib/cut-list';
|
||||||
|
import type { Project, Room } from './lib/types';
|
||||||
|
import { OutlineEditor } from './components/OutlineEditor';
|
||||||
|
import { MaterialsEditor } from './components/MaterialsEditor';
|
||||||
|
import { FloorPlan } from './components/FloorPlan';
|
||||||
|
import { CutList } from './components/CutList';
|
||||||
|
import { Issues } from './components/Fields';
|
||||||
|
|
||||||
|
const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ block: 'start' });
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { state, error, update } = useProject();
|
||||||
|
const { project, view } = state;
|
||||||
|
const copyStatus = useRef<HTMLOutputElement>(null);
|
||||||
|
const room = project.rooms.find((r) => r.id === view.roomId) ?? project.rooms[0];
|
||||||
|
const geometries = useMemo(
|
||||||
|
() => project.rooms.map((r) => buildGeometry(r, project.tolerance)),
|
||||||
|
[project.rooms, project.tolerance],
|
||||||
|
);
|
||||||
|
const geometry = geometries[project.rooms.indexOf(room)];
|
||||||
|
const flooring = useMemo(
|
||||||
|
() => planFlooring(project.rooms, project.flooring, project.tolerance),
|
||||||
|
[project.rooms, project.flooring, project.tolerance],
|
||||||
|
);
|
||||||
|
const skirting = useMemo(
|
||||||
|
() => planSkirting(project.rooms, project.skirting, project.tolerance),
|
||||||
|
[project.rooms, project.skirting, project.tolerance],
|
||||||
|
);
|
||||||
|
const floorErrors = flooring.issues.filter((i) => i.severity === 'error');
|
||||||
|
const skirtErrors = skirting.issues.filter((i) => i.severity === 'error');
|
||||||
|
const roomErrors = [...floorErrors, ...skirtErrors].filter(
|
||||||
|
(issue) => !issue.roomId || issue.roomId === room.id,
|
||||||
|
);
|
||||||
|
const totalArea = geometries.reduce((sum, g) => sum + (g.valid ? g.area : 0), 0);
|
||||||
|
const wholeBoards = flooring.pieces.filter(
|
||||||
|
(p) =>
|
||||||
|
!p.cut &&
|
||||||
|
Math.abs(p.length - project.flooring.length) < 0.01 &&
|
||||||
|
Math.abs(p.width - project.flooring.width) < 0.01,
|
||||||
|
).length;
|
||||||
|
const wholeSkirting = skirting.pieces.filter((p) => !isSkirtingCut(p, skirting)).length;
|
||||||
|
|
||||||
|
const changeProject = (fn: (p: Project) => Project) =>
|
||||||
|
update((s) => ({ ...s, project: fn(s.project), view: { ...s.view, selectedPiece: null } }));
|
||||||
|
const changeRoom = (fn: (r: Room) => Room) =>
|
||||||
|
changeProject((p) => ({ ...p, rooms: p.rooms.map((r) => (r.id === room.id ? fn(r) : r)) }));
|
||||||
|
const select = (id: string | null, roomId?: string) =>
|
||||||
|
update((s) => ({
|
||||||
|
...s,
|
||||||
|
view: { ...s.view, roomId: roomId ?? s.view.roomId, selectedPiece: id },
|
||||||
|
}));
|
||||||
|
const addRoom = () =>
|
||||||
|
update((s) => {
|
||||||
|
const id = nextId(
|
||||||
|
'room',
|
||||||
|
s.project.rooms.map((r) => r.id),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
project: {
|
||||||
|
...s.project,
|
||||||
|
rooms: [...s.project.rooms, rectangle(id, `Room ${s.project.rooms.length + 1}`)],
|
||||||
|
},
|
||||||
|
view: { ...s.view, roomId: id, selectedPiece: null },
|
||||||
|
};
|
||||||
|
}, true);
|
||||||
|
const removeRoom = () =>
|
||||||
|
update((s) => {
|
||||||
|
const rooms = s.project.rooms.filter((r) => r.id !== room.id);
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
project: { ...s.project, rooms },
|
||||||
|
view: { ...s.view, roomId: rooms[0].id, selectedPiece: null },
|
||||||
|
};
|
||||||
|
}, true);
|
||||||
|
const copyLink = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(window.location.href);
|
||||||
|
if (copyStatus.current) copyStatus.current.textContent = 'Link copied';
|
||||||
|
} catch {
|
||||||
|
if (copyStatus.current)
|
||||||
|
copyStatus.current.textContent = 'Copy the address from your browser.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="app-title">
|
||||||
|
<span className="square-mark" aria-hidden="true" />
|
||||||
|
<h1>Floor planner</h1>
|
||||||
|
</div>
|
||||||
|
<div className="header-actions">
|
||||||
|
<button className="button" onClick={copyLink}>
|
||||||
|
<Copy size={17} />
|
||||||
|
Copy link
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => window.print()}
|
||||||
|
disabled={floorErrors.length > 0 || skirtErrors.length > 0}
|
||||||
|
>
|
||||||
|
<Printer size={17} />
|
||||||
|
<span>Print</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="app-main">
|
||||||
|
<output ref={copyStatus} className="copy-status" aria-live="polite" />
|
||||||
|
{error && (
|
||||||
|
<p className="url-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="project-bar">
|
||||||
|
<div className="room-tabs" aria-label="Rooms">
|
||||||
|
{project.rooms.map((r, index) => (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
className={`room-tab ${r.id === room.id ? 'active' : ''}`}
|
||||||
|
aria-pressed={r.id === room.id}
|
||||||
|
onClick={() =>
|
||||||
|
update(
|
||||||
|
(s) => ({ ...s, view: { ...s.view, roomId: r.id, selectedPiece: null } }),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{r.name || 'Untitled room'}
|
||||||
|
<small>
|
||||||
|
{geometries[index].valid ? `${area(geometries[index].area)} m²` : 'Check outline'}
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button className="add-room" onClick={addRoom} disabled={project.rooms.length >= 20}>
|
||||||
|
<Plus size={17} />
|
||||||
|
Add room
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="project-area">{area(totalArea)} m² total</span>
|
||||||
|
</div>
|
||||||
|
<nav className="page-jumps" aria-label="Jump to section">
|
||||||
|
<button onClick={() => jump('measurements')}>
|
||||||
|
Room <ArrowDown size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => jump('materials')}>
|
||||||
|
Materials <ArrowDown size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => jump('drawing')}>
|
||||||
|
Drawing <ArrowDown size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => jump('cut-plan')}>
|
||||||
|
Cuts <ArrowDown size={13} />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<div className="workspace">
|
||||||
|
<div className="input-columns">
|
||||||
|
<section
|
||||||
|
className="editor-panel room-editor"
|
||||||
|
id="measurements"
|
||||||
|
aria-label="Room measurements"
|
||||||
|
>
|
||||||
|
<OutlineEditor
|
||||||
|
room={room}
|
||||||
|
geometry={geometry}
|
||||||
|
tolerance={project.tolerance}
|
||||||
|
changeRoom={changeRoom}
|
||||||
|
changeProject={changeProject}
|
||||||
|
remove={removeRoom}
|
||||||
|
canRemove={project.rooms.length > 1}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
className="editor-panel materials-editor"
|
||||||
|
id="materials"
|
||||||
|
aria-label="Materials"
|
||||||
|
>
|
||||||
|
<MaterialsEditor
|
||||||
|
project={project}
|
||||||
|
room={room}
|
||||||
|
changeRoom={changeRoom}
|
||||||
|
changeProject={changeProject}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div className="drawing-column" id="drawing">
|
||||||
|
<FloorPlan
|
||||||
|
room={room}
|
||||||
|
geometry={geometry}
|
||||||
|
flooring={flooring}
|
||||||
|
skirting={skirting}
|
||||||
|
selectedPiece={view.selectedPiece}
|
||||||
|
select={(id) => select(id)}
|
||||||
|
/>
|
||||||
|
<section className="purchase-summary" aria-label="Materials to buy">
|
||||||
|
<h2>
|
||||||
|
Materials to buy <small>{project.rooms.length > 1 ? 'All rooms' : room.name}</small>
|
||||||
|
</h2>
|
||||||
|
<div className="purchase-item flooring">
|
||||||
|
<span className="material-key floor-key" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{floorErrors.length ? '—' : flooring.boardCount} <span>floorboards</span>
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm · {wholeBoards}{' '}
|
||||||
|
used whole
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="waste-stat">
|
||||||
|
{floorErrors.length ? '—' : mm(flooring.wastePercent)}%<small>waste</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="purchase-item skirting">
|
||||||
|
<span className="material-key skirt-key" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{skirtErrors.length ? '—' : skirting.stockCount} <span>skirting lengths</span>
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
{[...new Set(skirting.stocks.map((s) => s.length))]
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map(
|
||||||
|
(l) =>
|
||||||
|
`${skirting.stocks.filter((s) => s.length === l).length} × ${mm(l)} mm`,
|
||||||
|
)
|
||||||
|
.join(' + ') || 'Check settings'}{' '}
|
||||||
|
· {wholeSkirting} used whole
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="waste-stat">
|
||||||
|
{skirtErrors.length ? '—' : mm(skirting.waste / 1000)} m<small>waste</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<Issues issues={roomErrors} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="cut-plan">
|
||||||
|
<CutList
|
||||||
|
project={project}
|
||||||
|
flooring={flooring}
|
||||||
|
skirting={skirting}
|
||||||
|
selectedPiece={view.selectedPiece}
|
||||||
|
select={select}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/components/CutDialog.tsx
Normal file
160
src/components/CutDialog.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
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 { exactMm, flooringTemplate, skirtingTemplate } from '../lib/cut-preview';
|
||||||
|
import { FlooringPreview, SkirtingPreview } from './CutPreview';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
floor?: FlooringPiece;
|
||||||
|
skirt?: SkirtingPiece;
|
||||||
|
project: Project;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CutDialog({ floor, skirt, project, close }: Props) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const opener = useRef<HTMLElement | null>(null);
|
||||||
|
const selected = floor ?? skirt;
|
||||||
|
useEffect(() => {
|
||||||
|
const element = dialog.current;
|
||||||
|
if (!element) return;
|
||||||
|
if (selected && !element.open) {
|
||||||
|
opener.current =
|
||||||
|
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
element.showModal();
|
||||||
|
} else if (!selected && element.open) {
|
||||||
|
element.close();
|
||||||
|
if (opener.current?.isConnected) opener.current.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}, [selected?.id]);
|
||||||
|
|
||||||
|
const room =
|
||||||
|
selected &&
|
||||||
|
(project.rooms.find((candidate) => candidate.id === selected.roomId)?.name ?? selected.roomId);
|
||||||
|
const template = floor
|
||||||
|
? flooringTemplate(floor)
|
||||||
|
: skirt
|
||||||
|
? skirtingTemplate(skirt, project.skirting.thickness)
|
||||||
|
: [];
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
className="cut-dialog"
|
||||||
|
aria-labelledby="cut-dialog-title"
|
||||||
|
onCancel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target !== event.currentTarget) return;
|
||||||
|
const box = event.currentTarget.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
event.clientX < box.left ||
|
||||||
|
event.clientX > box.right ||
|
||||||
|
event.clientY < box.top ||
|
||||||
|
event.clientY > box.bottom
|
||||||
|
)
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header className="cut-dialog-header">
|
||||||
|
<h2 id="cut-dialog-title">
|
||||||
|
{floor ? `Flooring cut ${floor.id}` : skirt ? `Skirting cut ${skirt.id}` : 'Cut preview'}
|
||||||
|
</h2>
|
||||||
|
<button className="icon-button" aria-label="Close cut preview" onClick={close} autoFocus>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="cut-dialog-body">
|
||||||
|
{floor && (
|
||||||
|
<>
|
||||||
|
<p className="cut-origin">
|
||||||
|
{room} · Row {floor.row} · Source {floor.stockId}
|
||||||
|
{floor.reused ? ' · Reused offcut' : ''}
|
||||||
|
</p>
|
||||||
|
<FlooringPreview piece={floor} />
|
||||||
|
<p className="cut-instructions">
|
||||||
|
{floor.cut ? flooringInstructions(floor) : readyOffcutInstructions(floor)}
|
||||||
|
</p>
|
||||||
|
<p className="helper">
|
||||||
|
Left and right run along the board; top and bottom are its long edges.
|
||||||
|
</p>
|
||||||
|
{floor.shaped && (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Mark these coordinates from the blank’s top-left corner: x along the board, y
|
||||||
|
across it.
|
||||||
|
</p>
|
||||||
|
<div className="coordinates">
|
||||||
|
{template.map((point, index) => (
|
||||||
|
<span key={index}>
|
||||||
|
{index + 1}: ({exactMm(point.x)}, {exactMm(point.y)}) mm
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="cut-edge-lengths">
|
||||||
|
{template
|
||||||
|
.map((point, index) => {
|
||||||
|
const next = template[(index + 1) % template.length];
|
||||||
|
return `${index + 1}–${((index + 1) % template.length) + 1}: ${exactMm(Math.hypot(next.x - point.x, next.y - point.y))} mm`;
|
||||||
|
})
|
||||||
|
.join(' · ')}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{skirt && (
|
||||||
|
<>
|
||||||
|
<p className="cut-origin">
|
||||||
|
{room} · Wall {skirt.wallIndex + 1} · Source {skirt.stockId}
|
||||||
|
</p>
|
||||||
|
<SkirtingPreview piece={skirt} thickness={project.skirting.thickness} />
|
||||||
|
<dl className="cut-measurements">
|
||||||
|
<div>
|
||||||
|
<dt>Long point</dt>
|
||||||
|
<dd>{exactMm(skirt.length)} mm</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Wall-facing length</dt>
|
||||||
|
<dd>{exactMm(skirt.backLength)} mm</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Left mitre</dt>
|
||||||
|
<dd>
|
||||||
|
{exactMm(Math.abs(skirt.leftMitre))}° · {skirt.leftType}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Right mitre</dt>
|
||||||
|
<dd>
|
||||||
|
{exactMm(Math.abs(skirt.rightMitre))}° · {skirt.rightType}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{template.length === 4 && (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Cut the square blank first. Mark these distances from its left end, then join the
|
||||||
|
front and back marks at each end:
|
||||||
|
</p>
|
||||||
|
<div className="coordinates">
|
||||||
|
<span>Back left: {exactMm(template[0].x)} mm</span>
|
||||||
|
<span>Back right: {exactMm(template[1].x)} mm</span>
|
||||||
|
<span>Front left: {exactMm(template[3].x)} mm</span>
|
||||||
|
<span>Front right: {exactMm(template[2].x)} mm</span>
|
||||||
|
</div>
|
||||||
|
<p className="helper">
|
||||||
|
Left and right follow the wall direction. Mitre angles are measured from a square
|
||||||
|
cut; the marked points show which way to lean each cut.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
src/components/CutList.tsx
Normal file
267
src/components/CutList.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
import { Download } from 'lucide-react';
|
||||||
|
import type { FlooringPlan, Project, SkirtingPlan } from '../lib/types';
|
||||||
|
import { degree, mm } from '../lib/format';
|
||||||
|
import { downloadCsv } from '../lib/export';
|
||||||
|
import {
|
||||||
|
flooringCuts,
|
||||||
|
groupFlooringCuts,
|
||||||
|
skirtingCuts,
|
||||||
|
uncutFlooring,
|
||||||
|
uncutSkirting,
|
||||||
|
} from '../lib/cut-list';
|
||||||
|
import { FlooringPreview, SkirtingPreview } from './CutPreview';
|
||||||
|
import { CutDialog } from './CutDialog';
|
||||||
|
|
||||||
|
function StockAllocations({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
items: { id: string; pieces: string[]; size: string; remainder: string }[];
|
||||||
|
}) {
|
||||||
|
if (!items.length) return null;
|
||||||
|
return (
|
||||||
|
<details className="stock-allocations">
|
||||||
|
<summary>{title}</summary>
|
||||||
|
<ul className="allocation-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="stock-card">
|
||||||
|
<strong>
|
||||||
|
{item.id} <span>· {item.size}</span>
|
||||||
|
</strong>
|
||||||
|
<span>{item.pieces.join(' + ')}</span>
|
||||||
|
<small>{item.remainder}</small>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
flooring: FlooringPlan;
|
||||||
|
skirting: SkirtingPlan;
|
||||||
|
selectedPiece: string | null;
|
||||||
|
select: (id: string | null, roomId?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CutList({ project, flooring, skirting, selectedPiece, select }: Props) {
|
||||||
|
const floorCuts = flooringCuts(flooring),
|
||||||
|
skirtCuts = skirtingCuts(skirting);
|
||||||
|
const floorGroups = groupFlooringCuts(flooring);
|
||||||
|
const floorIds = new Set(floorCuts.map((piece) => piece.id));
|
||||||
|
const skirtIds = new Set(skirtCuts.map((piece) => piece.id));
|
||||||
|
const wholeFloor = uncutFlooring(flooring, project.flooring);
|
||||||
|
const wholeSkirt = uncutSkirting(skirting);
|
||||||
|
const selectedFloor = floorCuts.find((piece) => piece.id === selectedPiece);
|
||||||
|
const selectedSkirt = skirtCuts.find((piece) => piece.id === selectedPiece);
|
||||||
|
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
|
||||||
|
const errors = [...flooring.issues, ...skirting.issues].some(
|
||||||
|
(issue) => issue.severity === 'error',
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="cut-list-card" aria-labelledby="cut-plan-title">
|
||||||
|
<div className="cut-list-header">
|
||||||
|
<div>
|
||||||
|
<h2 id="cut-plan-title">Cut plan</h2>
|
||||||
|
<p className="helper">
|
||||||
|
Cut pieces and offcut placements. All measurements are in millimetres.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="button secondary small"
|
||||||
|
onClick={() => downloadCsv(project, flooring, skirting)}
|
||||||
|
disabled={errors || (!flooring.pieces.length && !skirting.pieces.length)}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="material-cuts flooring-cuts" aria-labelledby="flooring-cuts-title">
|
||||||
|
<h3 id="flooring-cuts-title">
|
||||||
|
Flooring cuts <span className="count-badge">{floorCuts.length}</span>
|
||||||
|
</h3>
|
||||||
|
<p className="cut-summary">
|
||||||
|
Buy {flooring.boardCount} boards · {flooring.pieces.length} installed pieces ·{' '}
|
||||||
|
{wholeFloor.wholeBoards} full boards laid uncut
|
||||||
|
{wholeFloor.readyOffcuts > 0 &&
|
||||||
|
` · ${wholeFloor.readyOffcuts} offcuts need no further cuts`}
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
{floorCuts.length ? (
|
||||||
|
<div className="table-scroll">
|
||||||
|
<table className="cut-table">
|
||||||
|
<caption className="sr-only">Flooring cuts and offcut placements</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Pieces</th>
|
||||||
|
<th scope="col">Qty</th>
|
||||||
|
<th scope="col">Cut size · mm</th>
|
||||||
|
<th scope="col">Room</th>
|
||||||
|
<th scope="col">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{floorGroups.map((group) => {
|
||||||
|
const first = group[0];
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={first.id}
|
||||||
|
className={`flooring-batch ${group.some((piece) => piece.id === selectedPiece) ? 'selected' : ''}`}
|
||||||
|
>
|
||||||
|
<td data-label="Pieces" className="batch-codes">
|
||||||
|
<div className="piece-chips">
|
||||||
|
{group.map((piece) => (
|
||||||
|
<button
|
||||||
|
key={piece.id}
|
||||||
|
className={`piece-link piece-chip cut-preview-button flooring-preview-button ${piece.id === selectedPiece ? 'selected' : ''}`}
|
||||||
|
aria-label={`Preview flooring cut ${piece.id}`}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
onClick={() => select(piece.id, piece.roomId)}
|
||||||
|
>
|
||||||
|
<span className="cut-thumbnail" aria-hidden="true">
|
||||||
|
<FlooringPreview piece={piece} compact />
|
||||||
|
</span>
|
||||||
|
<span className="cut-preview-code">{piece.id}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td data-label="Qty" className="batch-qty numeric">
|
||||||
|
{group.length}
|
||||||
|
</td>
|
||||||
|
<td data-label="Cut size · mm" className="batch-size numeric">
|
||||||
|
{mm(first.length)} × {mm(first.width)}
|
||||||
|
</td>
|
||||||
|
<td data-label="Room" className="batch-room">
|
||||||
|
{roomName(first.roomId)}
|
||||||
|
</td>
|
||||||
|
<td data-label="Action" className="batch-action">
|
||||||
|
{!first.cut
|
||||||
|
? 'Use offcut · no new cut'
|
||||||
|
: first.shaped
|
||||||
|
? 'Shaped cut'
|
||||||
|
: 'Rectangular cut'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="empty-state">
|
||||||
|
{flooring.pieces.length
|
||||||
|
? 'No flooring cuts needed.'
|
||||||
|
: 'Complete the outline and flooring measurements to calculate cuts.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<StockAllocations
|
||||||
|
title="Source boards for flooring cuts"
|
||||||
|
items={flooring.stocks
|
||||||
|
.filter((stock) => stock.pieceIds.some((id) => floorIds.has(id)))
|
||||||
|
.map((stock) => ({
|
||||||
|
id: stock.id,
|
||||||
|
pieces: stock.pieceIds.filter((id) => floorIds.has(id)),
|
||||||
|
size: `${mm(project.flooring.length)} × ${mm(project.flooring.width)} mm`,
|
||||||
|
remainder: `${mm(stock.remaining)} mm remaining`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="material-cuts skirting-cuts" aria-labelledby="skirting-cuts-title">
|
||||||
|
<h3 id="skirting-cuts-title">
|
||||||
|
Skirting cuts <span className="count-badge">{skirtCuts.length}</span>
|
||||||
|
</h3>
|
||||||
|
<p className="cut-summary">
|
||||||
|
Buy {skirting.stockCount} lengths · {skirting.pieces.length} installed pieces ·{' '}
|
||||||
|
{wholeSkirt.reduce((sum, group) => sum + group.count, 0)} full lengths installed uncut.
|
||||||
|
</p>
|
||||||
|
<p className="helper">
|
||||||
|
Lengths use the longest point. Mitres are saw settings from square; left and right follow
|
||||||
|
the wall direction.
|
||||||
|
</p>
|
||||||
|
{skirtCuts.length ? (
|
||||||
|
<div className="table-scroll">
|
||||||
|
<table className="cut-table">
|
||||||
|
<caption className="sr-only">Skirting pieces requiring cuts</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Piece</th>
|
||||||
|
<th scope="col">Room / wall</th>
|
||||||
|
<th scope="col">Length · mm</th>
|
||||||
|
<th scope="col">Left / right</th>
|
||||||
|
<th scope="col">Source</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{skirtCuts.map((piece) => (
|
||||||
|
<tr key={piece.id} className={selectedPiece === piece.id ? 'selected' : ''}>
|
||||||
|
<td data-label="Piece">
|
||||||
|
<button
|
||||||
|
className="piece-link cut-preview-button skirting-preview-button"
|
||||||
|
aria-label={`Preview skirting cut ${piece.id}`}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
onClick={() => select(piece.id, piece.roomId)}
|
||||||
|
>
|
||||||
|
<span className="cut-thumbnail" aria-hidden="true">
|
||||||
|
<SkirtingPreview
|
||||||
|
piece={piece}
|
||||||
|
thickness={project.skirting.thickness}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="cut-preview-code">{piece.id}</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td data-label="Room / wall">
|
||||||
|
{roomName(piece.roomId)}
|
||||||
|
<small>Wall {piece.wallIndex + 1}</small>
|
||||||
|
</td>
|
||||||
|
<td data-label="Length · mm" className="numeric">
|
||||||
|
{mm(piece.length)}
|
||||||
|
<small>{mm(piece.backLength)} at wall</small>
|
||||||
|
</td>
|
||||||
|
<td data-label="Left / right">
|
||||||
|
{degree(piece.leftMitre)} / {degree(piece.rightMitre)}
|
||||||
|
<small>
|
||||||
|
{piece.leftType} / {piece.rightType}
|
||||||
|
</small>
|
||||||
|
</td>
|
||||||
|
<td data-label="Source">{piece.stockId}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="empty-state">
|
||||||
|
{skirting.pieces.length
|
||||||
|
? 'No skirting cuts needed.'
|
||||||
|
: 'Complete the outline and skirting measurements to calculate cuts.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<StockAllocations
|
||||||
|
title="Source lengths for skirting cuts"
|
||||||
|
items={skirting.stocks
|
||||||
|
.filter((stock) => stock.pieceIds.some((id) => skirtIds.has(id)))
|
||||||
|
.map((stock) => ({
|
||||||
|
id: stock.id,
|
||||||
|
pieces: stock.pieceIds.filter((id) => skirtIds.has(id)),
|
||||||
|
size: `${mm(stock.length)} mm`,
|
||||||
|
remainder: `${mm(stock.waste)} mm waste including kerf`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<CutDialog
|
||||||
|
floor={selectedFloor}
|
||||||
|
skirt={selectedSkirt}
|
||||||
|
project={project}
|
||||||
|
close={() => select(null)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
src/components/CutPreview.tsx
Normal file
166
src/components/CutPreview.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { Fragment } from 'react';
|
||||||
|
import type { FlooringPiece, SkirtingPiece } from '../lib/types';
|
||||||
|
import {
|
||||||
|
exactMm,
|
||||||
|
flooringTemplate,
|
||||||
|
fullPreviewBox,
|
||||||
|
skirtingEndBoxes,
|
||||||
|
skirtingTemplate,
|
||||||
|
viewBox,
|
||||||
|
} from '../lib/cut-preview';
|
||||||
|
|
||||||
|
export function FlooringPreview({
|
||||||
|
piece,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
piece: FlooringPiece;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const template = flooringTemplate(piece);
|
||||||
|
const Wrapper = compact ? 'span' : 'figure';
|
||||||
|
return (
|
||||||
|
<Wrapper className={`flooring-preview-figure ${compact ? 'compact-preview' : 'exact-preview'}`}>
|
||||||
|
<svg
|
||||||
|
className="flooring-preview"
|
||||||
|
viewBox={viewBox(fullPreviewBox(piece.length, piece.width))}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
aria-hidden={compact || undefined}
|
||||||
|
role={compact ? undefined : 'img'}
|
||||||
|
aria-label={compact ? undefined : `Flooring shape ${piece.id}`}
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points={template.map((point) => `${point.x},${point.y}`).join(' ')}
|
||||||
|
fill="#dbeafe"
|
||||||
|
stroke="#2563eb"
|
||||||
|
strokeWidth={compact ? 1 : 1.5}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
{!compact &&
|
||||||
|
piece.shaped &&
|
||||||
|
template.map((point, index) => (
|
||||||
|
<text
|
||||||
|
key={index}
|
||||||
|
x={point.x}
|
||||||
|
y={point.y - Math.max(piece.length, piece.width) * 0.015}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={Math.max(piece.length, piece.width) * 0.025}
|
||||||
|
fill="#1e40af"
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{!compact && (
|
||||||
|
<figcaption>
|
||||||
|
Length {exactMm(piece.length)} mm × width {exactMm(piece.width)} mm · top view, to scale
|
||||||
|
</figcaption>
|
||||||
|
)}
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkirtingPreview({
|
||||||
|
piece,
|
||||||
|
thickness,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
piece: SkirtingPiece;
|
||||||
|
thickness: number;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const template = skirtingTemplate(piece, thickness);
|
||||||
|
const Wrapper = compact ? 'span' : 'div';
|
||||||
|
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 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 },
|
||||||
|
] as const;
|
||||||
|
return (
|
||||||
|
<Wrapper className={`skirting-preview ${compact ? 'compact-preview' : 'exact-preview'}`}>
|
||||||
|
{!compact && (
|
||||||
|
<figure className="skirting-full-preview">
|
||||||
|
<svg
|
||||||
|
viewBox={viewBox(fullPreviewBox(piece.length, thickness))}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
role="img"
|
||||||
|
aria-label={`Full skirting shape ${piece.id}`}
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points={points}
|
||||||
|
fill="#ffedd5"
|
||||||
|
stroke="#c2410c"
|
||||||
|
strokeWidth={1}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<figcaption>
|
||||||
|
Full length {exactMm(piece.length)} mm × thickness {exactMm(thickness)} mm · top view,
|
||||||
|
to scale
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
)}
|
||||||
|
<Grid className="skirting-end-grid">
|
||||||
|
{ends.map((end, index) => (
|
||||||
|
<Fragment key={end.side}>
|
||||||
|
<EndWrapper className={`skirting-end ${end.side}-end`}>
|
||||||
|
<svg
|
||||||
|
className="skirting-end-preview"
|
||||||
|
data-end={end.side}
|
||||||
|
viewBox={viewBox(end.box)}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
overflow="hidden"
|
||||||
|
aria-hidden={compact || undefined}
|
||||||
|
role={compact ? undefined : 'img'}
|
||||||
|
aria-label={
|
||||||
|
compact
|
||||||
|
? undefined
|
||||||
|
: `${end.side === 'left' ? 'Left' : 'Right'} mitre close-up ${piece.id}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points={points}
|
||||||
|
fill="#ffedd5"
|
||||||
|
stroke="#c2410c"
|
||||||
|
strokeWidth={1}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
className="skirting-cut-edge"
|
||||||
|
data-end={end.side}
|
||||||
|
x1={end.back.x}
|
||||||
|
y1={end.back.y}
|
||||||
|
x2={end.front.x}
|
||||||
|
y2={end.front.y}
|
||||||
|
stroke="#9a3412"
|
||||||
|
strokeWidth={compact ? 1.5 : 2.5}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{!compact && (
|
||||||
|
<figcaption>
|
||||||
|
{index === 0 ? 'Left' : 'Right'} end ·{' '}
|
||||||
|
{exactMm(Math.abs(index === 0 ? piece.leftMitre : piece.rightMitre))}°
|
||||||
|
</figcaption>
|
||||||
|
)}
|
||||||
|
</EndWrapper>
|
||||||
|
{index === 0 && (
|
||||||
|
<span className="board-break" aria-hidden="true">
|
||||||
|
//
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
{!compact && (
|
||||||
|
<p className="helper">
|
||||||
|
End close-ups; the middle is omitted. The back edge faces the wall, the front edge faces
|
||||||
|
the room. Both axes use the same scale.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
src/components/Fields.tsx
Normal file
96
src/components/Fields.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { useId, useState } from 'react';
|
||||||
|
import type { Issue } from '../lib/types';
|
||||||
|
import { AlertCircle, Info, Check } from 'lucide-react';
|
||||||
|
export function NumberField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
unit = 'mm',
|
||||||
|
min = 0,
|
||||||
|
max,
|
||||||
|
step = 1,
|
||||||
|
hint,
|
||||||
|
tooltip,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onChange: (n: number) => void;
|
||||||
|
unit?: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
hint?: string;
|
||||||
|
tooltip?: string;
|
||||||
|
}) {
|
||||||
|
const id = useId();
|
||||||
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
<label htmlFor={id}>{label}</label>
|
||||||
|
{tooltip && (
|
||||||
|
<span className="field-tooltip">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tooltip-button"
|
||||||
|
aria-label={`What is ${label.toLowerCase()}?`}
|
||||||
|
aria-expanded={showTooltip}
|
||||||
|
aria-controls={`${id}-tooltip`}
|
||||||
|
aria-describedby={showTooltip ? `${id}-tooltip` : undefined}
|
||||||
|
onClick={() => setShowTooltip((shown) => !shown)}
|
||||||
|
onBlur={() => setShowTooltip(false)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Escape') setShowTooltip(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
{showTooltip && (
|
||||||
|
<span id={`${id}-tooltip`} role="tooltip" className="tooltip-content">
|
||||||
|
{tooltip}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="number-input">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
aria-describedby={hint ? `${id}-hint` : undefined}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span>{unit}</span>
|
||||||
|
</span>
|
||||||
|
{hint && <small id={`${id}-hint`}>{hint}</small>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function Issues({ issues, compact = false }: { issues: Issue[]; compact?: boolean }) {
|
||||||
|
const unique = issues.filter(
|
||||||
|
(issue, i) => issues.findIndex((item) => item.message === issue.message) === i,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className={`issues ${compact ? 'compact' : ''}`} role="status">
|
||||||
|
{unique.map((issue, i) => (
|
||||||
|
<div className={`issue ${issue.severity}`} key={i}>
|
||||||
|
{issue.severity === 'info' ? <Info size={16} /> : <AlertCircle size={16} />}
|
||||||
|
<span>{issue.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
365
src/components/FloorPlan.tsx
Normal file
365
src/components/FloorPlan.tsx
Normal file
|
|
@ -0,0 +1,365 @@
|
||||||
|
import { useId } from 'react';
|
||||||
|
import type { FlooringPlan, Geometry, Point, Room, SkirtingPlan, WallEdge } from '../lib/types';
|
||||||
|
import { area, mm } from '../lib/format';
|
||||||
|
import { signedArea } from '../lib/geometry';
|
||||||
|
import { isSkirtingCut } from '../lib/cut-list';
|
||||||
|
import { skirtingPolygon } from '../lib/skirting-polygon';
|
||||||
|
import { dimensionLabels } from '../lib/dimension-labels';
|
||||||
|
import 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 });
|
||||||
|
|
||||||
|
function Wall({ edge, span, label }: { edge: WallEdge; span: number; label: DimensionLabel }) {
|
||||||
|
const { normal: n, offset } = label;
|
||||||
|
const dx = edge.end.x - edge.start.x,
|
||||||
|
dy = edge.end.y - edge.start.y;
|
||||||
|
const length = Math.hypot(dx, dy) || 1;
|
||||||
|
return (
|
||||||
|
<g className="wall-drawing">
|
||||||
|
<line
|
||||||
|
x1={edge.start.x}
|
||||||
|
y1={edge.start.y}
|
||||||
|
x2={edge.end.x}
|
||||||
|
y2={edge.end.y}
|
||||||
|
stroke="#27364a"
|
||||||
|
strokeWidth={span * 0.003}
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1={edge.start.x + n.x * offset}
|
||||||
|
y1={edge.start.y + n.y * offset}
|
||||||
|
x2={edge.end.x + n.x * offset}
|
||||||
|
y2={edge.end.y + n.y * offset}
|
||||||
|
stroke="#8591a3"
|
||||||
|
strokeWidth={span * 0.001}
|
||||||
|
/>
|
||||||
|
{[edge.start, edge.end].map((p, i) => (
|
||||||
|
<line
|
||||||
|
key={i}
|
||||||
|
x1={p.x + n.x * (offset - span * 0.01)}
|
||||||
|
y1={p.y + n.y * (offset - span * 0.01)}
|
||||||
|
x2={p.x + n.x * (offset + span * 0.01)}
|
||||||
|
y2={p.y + n.y * (offset + span * 0.01)}
|
||||||
|
stroke="#8591a3"
|
||||||
|
strokeWidth={span * 0.001}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<g
|
||||||
|
className="wall-label"
|
||||||
|
transform={`translate(${label.x} ${label.y}) rotate(${label.angle})`}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x={-label.width / 2}
|
||||||
|
y={-label.font * 0.75}
|
||||||
|
width={label.width}
|
||||||
|
height={label.font * 1.5}
|
||||||
|
fill="#fffdf7"
|
||||||
|
/>
|
||||||
|
<text textAnchor="middle" dominantBaseline="middle" fontSize={label.font} fill="#17263f">
|
||||||
|
{label.text}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
{edge.doors
|
||||||
|
.filter((d) => d.offset >= 0 && d.width > 0 && d.offset + d.width <= edge.length + 0.001)
|
||||||
|
.map((door) => {
|
||||||
|
const a = {
|
||||||
|
x: edge.start.x + (dx / length) * door.offset,
|
||||||
|
y: edge.start.y + (dy / length) * door.offset,
|
||||||
|
};
|
||||||
|
const b = { x: a.x + (dx / length) * door.width, y: a.y + (dy / length) * door.width };
|
||||||
|
const swing = { x: a.x - n.x * door.width, y: a.y - n.y * door.width };
|
||||||
|
const clockwise = dx * -n.y - dy * -n.x > 0;
|
||||||
|
return (
|
||||||
|
<g key={door.id}>
|
||||||
|
<line
|
||||||
|
x1={a.x}
|
||||||
|
y1={a.y}
|
||||||
|
x2={b.x}
|
||||||
|
y2={b.y}
|
||||||
|
stroke="#fffdf7"
|
||||||
|
strokeWidth={span * 0.012}
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1={a.x}
|
||||||
|
y1={a.y}
|
||||||
|
x2={swing.x}
|
||||||
|
y2={swing.y}
|
||||||
|
stroke="#53647c"
|
||||||
|
strokeWidth={span * 0.002}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d={`M ${b.x} ${b.y} A ${door.width} ${door.width} 0 0 ${clockwise ? 1 : 0} ${swing.x} ${swing.y}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="#53647c"
|
||||||
|
strokeWidth={span * 0.0015}
|
||||||
|
strokeDasharray={`${span * 0.01} ${span * 0.008}`}
|
||||||
|
/>
|
||||||
|
<title>
|
||||||
|
Door: {mm(door.width)} mm, {mm(door.offset)} mm from W{edge.index + 1} start
|
||||||
|
</title>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
room: Room;
|
||||||
|
geometry: Geometry;
|
||||||
|
flooring: FlooringPlan;
|
||||||
|
skirting: SkirtingPlan;
|
||||||
|
selectedPiece: string | null;
|
||||||
|
select: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, select }: Props) {
|
||||||
|
const hatch = useId().replaceAll(':', '');
|
||||||
|
const allPoints = geometry.edges.flatMap((edge) => [edge.start, edge.end]);
|
||||||
|
const minX = Math.min(0, ...allPoints.map((p) => p.x)),
|
||||||
|
maxX = Math.max(100, ...allPoints.map((p) => p.x));
|
||||||
|
const minY = Math.min(0, ...allPoints.map((p) => p.y)),
|
||||||
|
maxY = Math.max(100, ...allPoints.map((p) => p.y));
|
||||||
|
const span = Math.max(maxX - minX, maxY - minY, 1000),
|
||||||
|
font = span * 0.024;
|
||||||
|
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 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.2,
|
||||||
|
maxX: x + font * 1.2,
|
||||||
|
minY: y - font * 0.7,
|
||||||
|
maxY: y + font * 0.7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const labels = dimensionLabels(geometry.edges, span, orientation, reserved);
|
||||||
|
const boxes = [...reserved, ...labels.map((label) => label.box)];
|
||||||
|
const 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">
|
||||||
|
<div className="drawing-header">
|
||||||
|
<h2>
|
||||||
|
Floor plan <small>{area(geometry.area)} m²</small>
|
||||||
|
</h2>
|
||||||
|
<ClosedBadge closed={geometry.closed && geometry.valid} />
|
||||||
|
</div>
|
||||||
|
<div className="drawing-legend">
|
||||||
|
<span>
|
||||||
|
<i className="floor-key" />
|
||||||
|
Flooring
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<i className="skirt-key" />
|
||||||
|
Skirting
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="drawing-viewport">
|
||||||
|
<svg
|
||||||
|
className="room-svg"
|
||||||
|
role="img"
|
||||||
|
aria-label={`${room.name} floor plan with ${geometry.edges.length} walls, ${floorPieces.length} floorboards and ${skirtPieces.length} skirting pieces`}
|
||||||
|
viewBox={`${left} ${top} ${width} ${height}`}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<pattern
|
||||||
|
id={hatch}
|
||||||
|
width={span * 0.02}
|
||||||
|
height={span * 0.02}
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
patternTransform="rotate(45)"
|
||||||
|
>
|
||||||
|
<line
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2={span * 0.02}
|
||||||
|
stroke="#1647ab"
|
||||||
|
strokeOpacity=".16"
|
||||||
|
strokeWidth={span * 0.003}
|
||||||
|
/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
{geometry.closed && <polygon points={points(geometry.vertices)} fill="#fff" />}
|
||||||
|
{floorPieces.map((piece) => {
|
||||||
|
const cut = piece.cut || piece.reused;
|
||||||
|
const x =
|
||||||
|
(Math.min(...piece.polygon.map((p) => p.x)) +
|
||||||
|
Math.max(...piece.polygon.map((p) => p.x))) /
|
||||||
|
2;
|
||||||
|
const y =
|
||||||
|
(Math.min(...piece.polygon.map((p) => p.y)) +
|
||||||
|
Math.max(...piece.polygon.map((p) => p.y))) /
|
||||||
|
2;
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={piece.id}
|
||||||
|
className={`floor-piece ${cut ? 'svg-cut floor-cut' : 'uncut'}`}
|
||||||
|
data-material="flooring"
|
||||||
|
data-piece-id={piece.id}
|
||||||
|
role={cut ? 'button' : undefined}
|
||||||
|
tabIndex={cut ? 0 : undefined}
|
||||||
|
aria-hidden={cut ? undefined : true}
|
||||||
|
aria-label={cut ? `Select flooring cut ${piece.id}` : undefined}
|
||||||
|
aria-pressed={cut ? selectedPiece === piece.id : undefined}
|
||||||
|
onClick={cut ? () => select(piece.id) : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
cut
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
select(piece.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points={points(piece.polygon)}
|
||||||
|
fill={
|
||||||
|
cut ? (selectedPiece === piece.id ? '#ceddf9' : `url(#${hatch})`) : '#f5f6f8'
|
||||||
|
}
|
||||||
|
stroke={cut ? '#1647ab' : '#9baabd'}
|
||||||
|
strokeWidth={span * (selectedPiece === piece.id ? 0.003 : 0.0012)}
|
||||||
|
/>
|
||||||
|
{cut && (
|
||||||
|
<>
|
||||||
|
<g
|
||||||
|
transform={`translate(${x} ${y})${room.direction === 90 ? ' rotate(90)' : ''}`}
|
||||||
|
pointerEvents="none"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x={-font * 1.1}
|
||||||
|
y={-font * 0.55}
|
||||||
|
width={font * 2.2}
|
||||||
|
height={font * 1.1}
|
||||||
|
fill="#1647ab"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fill="#fff"
|
||||||
|
fontSize={font * 0.76}
|
||||||
|
fontWeight="600"
|
||||||
|
>
|
||||||
|
{piece.id}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
<title>
|
||||||
|
{piece.id}: {mm(piece.length)} × {mm(piece.width)} mm
|
||||||
|
</title>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{geometry.edges.map((edge, i) => (
|
||||||
|
<Wall key={edge.wallId} edge={edge} span={span} label={labels[i]} />
|
||||||
|
))}
|
||||||
|
{skirtPieces.map((piece) => {
|
||||||
|
const cut = isSkirtingCut(piece, skirting);
|
||||||
|
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);
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={piece.id}
|
||||||
|
className={`skirt-piece ${cut ? 'svg-cut skirt-cut' : 'uncut'}`}
|
||||||
|
data-material="skirting"
|
||||||
|
data-piece-id={piece.id}
|
||||||
|
role={cut ? 'button' : undefined}
|
||||||
|
tabIndex={cut ? 0 : undefined}
|
||||||
|
aria-hidden={cut ? undefined : true}
|
||||||
|
aria-label={cut ? `Select skirting cut ${piece.id}` : undefined}
|
||||||
|
aria-pressed={cut ? selectedPiece === piece.id : undefined}
|
||||||
|
onClick={cut ? () => select(piece.id) : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
cut
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
select(piece.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points={points(polygon)}
|
||||||
|
fill={selectedPiece === piece.id ? '#17263f' : '#b74900'}
|
||||||
|
stroke="#8d3800"
|
||||||
|
strokeWidth={span * 0.001}
|
||||||
|
/>
|
||||||
|
{cut && (
|
||||||
|
<line
|
||||||
|
x1={piece.start.x}
|
||||||
|
y1={piece.start.y}
|
||||||
|
x2={piece.end.x}
|
||||||
|
y2={piece.end.y}
|
||||||
|
stroke="transparent"
|
||||||
|
strokeWidth={span * 0.025}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{label && (
|
||||||
|
<g transform={`translate(${label.x} ${label.y})`} pointerEvents="none">
|
||||||
|
<rect
|
||||||
|
x={-font * 1.1}
|
||||||
|
y={-font * 0.58}
|
||||||
|
width={font * 2.2}
|
||||||
|
height={font * 1.16}
|
||||||
|
fill="#fffdf7"
|
||||||
|
stroke="#b74900"
|
||||||
|
strokeWidth={span * 0.0012}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fill="#8d3800"
|
||||||
|
fontSize={font * 0.8}
|
||||||
|
fontWeight="700"
|
||||||
|
>
|
||||||
|
{piece.id}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
{cut && (
|
||||||
|
<title>
|
||||||
|
{piece.id}: {mm(piece.length)} mm · {mm(piece.leftMitre)}° /{' '}
|
||||||
|
{mm(piece.rightMitre)}°
|
||||||
|
</title>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!geometry.closed && geometry.edges.length > 1 && (
|
||||||
|
<line
|
||||||
|
x1={geometry.edges.at(-1)!.end.x}
|
||||||
|
y1={geometry.edges.at(-1)!.end.y}
|
||||||
|
x2="0"
|
||||||
|
y2="0"
|
||||||
|
stroke="#b74900"
|
||||||
|
strokeWidth={span * 0.003}
|
||||||
|
strokeDasharray={`${span * 0.015} ${span * 0.008}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/components/MaterialsEditor.tsx
Normal file
178
src/components/MaterialsEditor.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import { Plus, Trash2, ArrowRight, ArrowDown } from 'lucide-react';
|
||||||
|
import type { Project, Room, FlooringSettings, SkirtingSettings } from '../lib/types';
|
||||||
|
import { NumberField } from './Fields';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
room: Room;
|
||||||
|
changeProject: (fn: (p: Project) => Project) => void;
|
||||||
|
changeRoom: (fn: (r: Room) => Room) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kerfHelp =
|
||||||
|
'Width of material removed by the saw blade. A 3 mm blade turns a 1,000 mm board into 600 mm + 397 mm after one cut.';
|
||||||
|
|
||||||
|
export function MaterialsEditor({ project, room, changeProject, changeRoom }: Props) {
|
||||||
|
const f = project.flooring;
|
||||||
|
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[]) =>
|
||||||
|
changeProject((p) => ({ ...p, skirting: { ...p.skirting, [key]: value } }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="editor-heading">
|
||||||
|
<h2>Materials</h2>
|
||||||
|
<span>Shared across rooms · mm</span>
|
||||||
|
</div>
|
||||||
|
<section className="material-section flooring" aria-labelledby="flooring-materials">
|
||||||
|
<h3 id="flooring-materials" className="material-heading">
|
||||||
|
Floorboards
|
||||||
|
</h3>
|
||||||
|
<div className="field-grid">
|
||||||
|
<NumberField
|
||||||
|
label="Board length"
|
||||||
|
value={f.length}
|
||||||
|
min={1}
|
||||||
|
onChange={(n) => floor('length', n)}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Board width"
|
||||||
|
value={f.width}
|
||||||
|
min={1}
|
||||||
|
onChange={(n) => floor('width', n)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<span>Laying pattern</span>
|
||||||
|
<div className="segmented" role="group" aria-label="Laying pattern">
|
||||||
|
<button
|
||||||
|
className={f.pattern === 'brick' ? 'active' : ''}
|
||||||
|
aria-pressed={f.pattern === 'brick'}
|
||||||
|
onClick={() => floor('pattern', 'brick')}
|
||||||
|
>
|
||||||
|
Brick
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={f.pattern === 'random' ? 'active' : ''}
|
||||||
|
aria-pressed={f.pattern === 'random'}
|
||||||
|
onClick={() => floor('pattern', 'random')}
|
||||||
|
>
|
||||||
|
Random
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<span>Board direction · {room.name}</span>
|
||||||
|
<div className="segmented" role="group" aria-label="Board direction">
|
||||||
|
<button
|
||||||
|
className={room.direction === 0 ? 'active' : ''}
|
||||||
|
aria-pressed={room.direction === 0}
|
||||||
|
onClick={() => changeRoom((r) => ({ ...r, direction: 0 }))}
|
||||||
|
>
|
||||||
|
<ArrowRight size={16} aria-hidden="true" /> Along first wall
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={room.direction === 90 ? 'active' : ''}
|
||||||
|
aria-pressed={room.direction === 90}
|
||||||
|
onClick={() => changeRoom((r) => ({ ...r, direction: 90 }))}
|
||||||
|
>
|
||||||
|
<ArrowDown size={16} aria-hidden="true" /> Across first wall
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field-grid">
|
||||||
|
<NumberField label="Wall spacer" value={f.gap} onChange={(n) => floor('gap', n)} />
|
||||||
|
<NumberField
|
||||||
|
label="Saw kerf"
|
||||||
|
value={f.kerf}
|
||||||
|
onChange={(n) => floor('kerf', n)}
|
||||||
|
tooltip={kerfHelp}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Minimum joint offset"
|
||||||
|
value={f.minOffset}
|
||||||
|
onChange={(n) => floor('minOffset', n)}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Maximum joint offset"
|
||||||
|
value={f.maxOffset}
|
||||||
|
onChange={(n) => floor('maxOffset', n)}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Shortest end piece"
|
||||||
|
value={f.minLength}
|
||||||
|
min={1}
|
||||||
|
onChange={(n) => floor('minLength', n)}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Narrowest edge row"
|
||||||
|
value={f.minWidth}
|
||||||
|
min={1}
|
||||||
|
onChange={(n) => floor('minWidth', n)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="material-section skirting" aria-labelledby="skirting-materials">
|
||||||
|
<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 thickness"
|
||||||
|
value={s.thickness}
|
||||||
|
onChange={(n) => skirt('thickness', n)}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Minimum piece"
|
||||||
|
value={s.minLength}
|
||||||
|
min={1}
|
||||||
|
onChange={(n) => skirt('minLength', n)}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Skirting saw kerf"
|
||||||
|
value={s.kerf}
|
||||||
|
onChange={(n) => skirt('kerf', n)}
|
||||||
|
tooltip={kerfHelp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
364
src/components/OutlineEditor.tsx
Normal file
364
src/components/OutlineEditor.tsx
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
import { Plus, Trash2, DoorOpen, Check, MoveUpRight } from 'lucide-react';
|
||||||
|
import type { Geometry, Project, Room, Wall } from '../lib/types';
|
||||||
|
import { fireplace, rectangle, nextId } from '../lib/defaults';
|
||||||
|
import { normalizeAngle } from '../lib/geometry';
|
||||||
|
import { mm } from '../lib/format';
|
||||||
|
import { NumberField, Issues } from './Fields';
|
||||||
|
interface Props {
|
||||||
|
room: Room;
|
||||||
|
geometry: Geometry;
|
||||||
|
tolerance: number;
|
||||||
|
changeRoom: (fn: (room: Room) => Room) => void;
|
||||||
|
changeProject: (fn: (project: Project) => Project) => void;
|
||||||
|
remove: () => void;
|
||||||
|
canRemove: boolean;
|
||||||
|
}
|
||||||
|
export function OutlineEditor({
|
||||||
|
room,
|
||||||
|
geometry,
|
||||||
|
tolerance,
|
||||||
|
changeRoom,
|
||||||
|
changeProject,
|
||||||
|
remove,
|
||||||
|
canRemove,
|
||||||
|
}: Props) {
|
||||||
|
const changeWall = (id: string, fn: (wall: Wall) => Wall) =>
|
||||||
|
changeRoom((r) => ({ ...r, walls: r.walls.map((w) => (w.id === id ? fn(w) : w)) }));
|
||||||
|
const isRectangle =
|
||||||
|
room.walls.length === 4 &&
|
||||||
|
room.walls.every((w) => w.turn === 90) &&
|
||||||
|
room.walls[0].length === room.walls[2].length &&
|
||||||
|
room.walls[1].length === room.walls[3].length;
|
||||||
|
const lastEdge = geometry.edges.at(-1);
|
||||||
|
const finalExpectedLength = lastEdge ? Math.hypot(lastEdge.start.x, lastEdge.start.y) : 0;
|
||||||
|
const addWall = () =>
|
||||||
|
changeRoom((r) => ({
|
||||||
|
...r,
|
||||||
|
walls: [
|
||||||
|
...r.walls,
|
||||||
|
{
|
||||||
|
id: nextId(
|
||||||
|
`${r.id}-w`,
|
||||||
|
r.walls.map((w) => w.id),
|
||||||
|
),
|
||||||
|
length: 1000,
|
||||||
|
turn: 90,
|
||||||
|
doors: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
const closeOutline = () =>
|
||||||
|
changeRoom((r) => ({
|
||||||
|
...r,
|
||||||
|
walls: [
|
||||||
|
...r.walls.map((w, i) =>
|
||||||
|
i === r.walls.length - 1 ? { ...w, turn: geometry.closingTurn } : w,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
id: nextId(
|
||||||
|
`${r.id}-w`,
|
||||||
|
r.walls.map((w) => w.id),
|
||||||
|
),
|
||||||
|
length: Math.round(geometry.closingLength * 10) / 10,
|
||||||
|
turn: 90,
|
||||||
|
doors: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
const addDoor = () => {
|
||||||
|
const target = room.walls[room.walls.length - 1];
|
||||||
|
if (!target) return;
|
||||||
|
changeWall(target.id, (w) => ({
|
||||||
|
...w,
|
||||||
|
doors: [
|
||||||
|
...w.doors,
|
||||||
|
{
|
||||||
|
id: nextId(
|
||||||
|
`${room.id}-door`,
|
||||||
|
room.walls.flatMap((w) => w.doors.map((d) => d.id)),
|
||||||
|
),
|
||||||
|
offset: Math.min(500, Math.max(0, w.length - 826)),
|
||||||
|
width: 826,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="editor-heading">
|
||||||
|
<h2>Room measurements</h2>
|
||||||
|
<span>Millimetres</span>
|
||||||
|
</div>
|
||||||
|
<label className="field">
|
||||||
|
<span>Room name</span>
|
||||||
|
<input
|
||||||
|
value={room.name}
|
||||||
|
maxLength={80}
|
||||||
|
onChange={(e) => changeRoom((r) => ({ ...r, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="template-options">
|
||||||
|
<button
|
||||||
|
className={isRectangle ? 'template active' : 'template'}
|
||||||
|
aria-pressed={isRectangle}
|
||||||
|
onClick={() => changeRoom((r) => rectangle(r.id, r.name))}
|
||||||
|
>
|
||||||
|
<span className="rectangle-icon" />
|
||||||
|
Rectangle
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={room.walls.length === 8 ? 'template active' : 'template'}
|
||||||
|
aria-pressed={room.walls.length === 8}
|
||||||
|
onClick={() => changeRoom((r) => fireplace(r.id, r.name))}
|
||||||
|
>
|
||||||
|
<span className="fireplace-icon" />
|
||||||
|
With fireplace
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="template"
|
||||||
|
onClick={() =>
|
||||||
|
changeRoom((r) => ({
|
||||||
|
...r,
|
||||||
|
walls: [{ id: `${r.id}-w1`, length: 4000, turn: 90, doors: [] }],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MoveUpRight size={19} />
|
||||||
|
Custom
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isRectangle && (
|
||||||
|
<div className="field-grid quick-dimensions">
|
||||||
|
<NumberField
|
||||||
|
label="Room width"
|
||||||
|
value={room.walls[0].length}
|
||||||
|
min={1}
|
||||||
|
onChange={(n) =>
|
||||||
|
changeRoom((r) => ({
|
||||||
|
...r,
|
||||||
|
walls: r.walls.map((w, i) => (i % 2 === 0 ? { ...w, length: n } : w)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Room length"
|
||||||
|
value={room.walls[1].length}
|
||||||
|
min={1}
|
||||||
|
onChange={(n) =>
|
||||||
|
changeRoom((r) => ({
|
||||||
|
...r,
|
||||||
|
walls: r.walls.map((w, i) => (i % 2 === 1 ? { ...w, length: n } : w)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<details className="wall-details" open={!isRectangle}>
|
||||||
|
<summary>
|
||||||
|
Walls & corners <span>{room.walls.length} walls</span>
|
||||||
|
</summary>
|
||||||
|
<p className="helper">
|
||||||
|
Start to the right; work clockwise. Right turn: <b>90°</b>. Left turn: <b>−90°</b>.
|
||||||
|
</p>
|
||||||
|
<div className="wall-table">
|
||||||
|
<div className="wall-head">
|
||||||
|
<span>Wall</span>
|
||||||
|
<span>Length · mm</span>
|
||||||
|
<span>Next turn · °</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{room.walls.map((wall, i) => (
|
||||||
|
<div className="wall-row" key={wall.id}>
|
||||||
|
<span className="wall-code">W{i + 1}</span>
|
||||||
|
<input
|
||||||
|
aria-label={`Wall ${i + 1} length`}
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={wall.length}
|
||||||
|
onChange={(e) =>
|
||||||
|
changeWall(wall.id, (w) => ({ ...w, length: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label={`Wall ${i + 1} turn`}
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
min={-179}
|
||||||
|
max={179}
|
||||||
|
step={0.1}
|
||||||
|
value={
|
||||||
|
geometry.closed && i === room.walls.length - 1
|
||||||
|
? Math.round(normalizeAngle(-(lastEdge?.heading ?? 0)) * 10) / 10
|
||||||
|
: wall.turn
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
geometry.closed && i === room.walls.length - 1
|
||||||
|
? 'The final corner is derived from the closed outline.'
|
||||||
|
: 'Turn after this wall; positive turns right.'
|
||||||
|
}
|
||||||
|
disabled={geometry.closed && i === room.walls.length - 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
changeWall(wall.id, (w) => ({ ...w, turn: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
aria-label={`Remove wall ${i + 1}`}
|
||||||
|
disabled={room.walls.length === 1}
|
||||||
|
onClick={() =>
|
||||||
|
changeRoom((r) => ({ ...r, walls: r.walls.filter((w) => w.id !== wall.id) }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className="text-button" onClick={addWall} disabled={room.walls.length >= 100}>
|
||||||
|
<Plus size={15} /> Add a wall
|
||||||
|
</button>
|
||||||
|
</details>
|
||||||
|
{geometry.closed ? (
|
||||||
|
<div className="closure-check">
|
||||||
|
<Check size={17} />
|
||||||
|
<div>
|
||||||
|
<strong>Outline closed</strong>
|
||||||
|
<span>
|
||||||
|
Closing gap {mm(geometry.gap)} mm · tolerance {mm(tolerance)} mm
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Final wall: {mm(finalExpectedLength)} mm expected ·{' '}
|
||||||
|
{mm(Math.abs(geometry.finalLengthError))} mm difference
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
room.walls.length >= 2 &&
|
||||||
|
geometry.closingLength > 0 && (
|
||||||
|
<div className="closure-suggestion">
|
||||||
|
<div>
|
||||||
|
<strong>{mm(geometry.closingLength)} mm to close</strong>
|
||||||
|
<p>Next turn {mm(geometry.closingTurn)}°. After adding, enter the measured length.</p>
|
||||||
|
<button
|
||||||
|
className="text-button"
|
||||||
|
onClick={closeOutline}
|
||||||
|
disabled={room.walls.length >= 100 || Math.abs(geometry.closingTurn) >= 179.99}
|
||||||
|
>
|
||||||
|
Add closing wall <Plus size={14} />
|
||||||
|
</button>
|
||||||
|
{room.walls.length >= 3 && (
|
||||||
|
<p>
|
||||||
|
If W{room.walls.length} is the final wall: {mm(finalExpectedLength)} mm expected;
|
||||||
|
length differs by {mm(Math.abs(geometry.finalLengthError))} mm, direction by{' '}
|
||||||
|
{mm(Math.abs(geometry.finalAngleError))}°.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<Issues issues={geometry.issues.filter((i) => i.severity !== 'info')} compact />
|
||||||
|
<div className="panel-divider" />
|
||||||
|
<div className="subheading">
|
||||||
|
<h3>
|
||||||
|
<DoorOpen size={16} /> Doorways
|
||||||
|
</h3>
|
||||||
|
<span>{room.walls.reduce((n, w) => n + w.doors.length, 0)} openings</span>
|
||||||
|
</div>
|
||||||
|
<p className="helper">
|
||||||
|
Width includes trim where skirting stops. Offset is measured from the wall’s start.
|
||||||
|
</p>
|
||||||
|
{room.walls.flatMap((wall, wallIndex) =>
|
||||||
|
wall.doors.map((door) => (
|
||||||
|
<div className="door-card" key={door.id}>
|
||||||
|
<div className="door-card-title">
|
||||||
|
<label>
|
||||||
|
On wall{' '}
|
||||||
|
<select
|
||||||
|
aria-label={`Door ${door.id} wall`}
|
||||||
|
value={wall.id}
|
||||||
|
onChange={(e) =>
|
||||||
|
changeRoom((r) => ({
|
||||||
|
...r,
|
||||||
|
walls: r.walls.map((w) =>
|
||||||
|
w.id === wall.id
|
||||||
|
? { ...w, doors: w.doors.filter((d) => d.id !== door.id) }
|
||||||
|
: w.id === e.target.value
|
||||||
|
? { ...w, doors: [...w.doors, door] }
|
||||||
|
: w,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{room.walls.map((w, i) => (
|
||||||
|
<option key={w.id} value={w.id}>
|
||||||
|
W{i + 1}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
aria-label={`Remove door on wall ${wallIndex + 1}`}
|
||||||
|
onClick={() =>
|
||||||
|
changeWall(wall.id, (w) => ({
|
||||||
|
...w,
|
||||||
|
doors: w.doors.filter((d) => d.id !== door.id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="field-grid">
|
||||||
|
<NumberField
|
||||||
|
label="Door offset"
|
||||||
|
value={door.offset}
|
||||||
|
onChange={(n) =>
|
||||||
|
changeWall(wall.id, (w) => ({
|
||||||
|
...w,
|
||||||
|
doors: w.doors.map((d) => (d.id === door.id ? { ...d, offset: n } : d)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Door width"
|
||||||
|
min={1}
|
||||||
|
value={door.width}
|
||||||
|
onChange={(n) =>
|
||||||
|
changeWall(wall.id, (w) => ({
|
||||||
|
...w,
|
||||||
|
doors: w.doors.map((d) => (d.id === door.id ? { ...d, width: n } : d)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="text-button"
|
||||||
|
onClick={addDoor}
|
||||||
|
disabled={!room.walls.length || room.walls.at(-1)!.doors.length >= 30}
|
||||||
|
>
|
||||||
|
<Plus size={15} /> Add a doorway
|
||||||
|
</button>
|
||||||
|
<div className="panel-divider" />
|
||||||
|
<NumberField
|
||||||
|
label="Measurement tolerance"
|
||||||
|
value={tolerance}
|
||||||
|
max={50}
|
||||||
|
onChange={(n) => changeProject((p) => ({ ...p, tolerance: n }))}
|
||||||
|
hint="Largest closing gap accepted for the cut plan."
|
||||||
|
/>
|
||||||
|
{canRemove && (
|
||||||
|
<button className="text-button danger remove-room" onClick={remove}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
Remove this room
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/lib/cut-instructions.ts
Normal file
21
src/lib/cut-instructions.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { FlooringPiece } from './types';
|
||||||
|
/** These instructions describe the factory edges needed by the allocation, not a saw brand's orientation. */
|
||||||
|
export function flooringInstructions(piece: FlooringPiece): string {
|
||||||
|
const ends =
|
||||||
|
piece.factoryLeft && piece.factoryRight
|
||||||
|
? 'Keep both factory ends.'
|
||||||
|
: piece.factoryLeft
|
||||||
|
? 'Keep the left factory end; trim from the right.'
|
||||||
|
: piece.factoryRight
|
||||||
|
? 'Keep the right factory end; trim from the left.'
|
||||||
|
: 'Neither short end joins another board.';
|
||||||
|
const rip =
|
||||||
|
piece.ripEdge === 'top'
|
||||||
|
? 'Rip the top long edge; keep the bottom factory edge.'
|
||||||
|
: piece.ripEdge === 'bottom'
|
||||||
|
? 'Rip the bottom long edge; keep the top factory edge.'
|
||||||
|
: piece.ripEdge === 'both'
|
||||||
|
? 'Trim both long edges to the perimeter template.'
|
||||||
|
: 'Keep factory long edges wherever they join the next row.';
|
||||||
|
return `${ends} ${rip}`;
|
||||||
|
}
|
||||||
78
src/lib/cut-list.ts
Normal file
78
src/lib/cut-list.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import type {
|
||||||
|
FlooringPiece,
|
||||||
|
FlooringPlan,
|
||||||
|
FlooringSettings,
|
||||||
|
SkirtingPiece,
|
||||||
|
SkirtingPlan,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
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. */
|
||||||
|
export const flooringCuts = (plan: FlooringPlan) =>
|
||||||
|
plan.pieces.filter((piece) => piece.cut || piece.reused);
|
||||||
|
|
||||||
|
export function isSkirtingCut(piece: SkirtingPiece, plan: SkirtingPlan): boolean {
|
||||||
|
const stock = plan.stocks.find((candidate) => candidate.id === piece.stockId);
|
||||||
|
return (
|
||||||
|
!stock ||
|
||||||
|
!sameLength(piece.length, stock.length) ||
|
||||||
|
Math.abs(piece.leftMitre) > 0.001 ||
|
||||||
|
Math.abs(piece.rightMitre) > 0.001
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const skirtingCuts = (plan: SkirtingPlan) =>
|
||||||
|
plan.pieces.filter((piece) => isSkirtingCut(piece, plan));
|
||||||
|
|
||||||
|
export function uncutFlooring(plan: FlooringPlan, settings: FlooringSettings) {
|
||||||
|
const uncut = plan.pieces.filter((piece) => !piece.cut);
|
||||||
|
const wholeBoards = uncut.filter(
|
||||||
|
(piece) => sameLength(piece.length, settings.length) && sameLength(piece.width, settings.width),
|
||||||
|
).length;
|
||||||
|
return { wholeBoards, readyOffcuts: uncut.length - wholeBoards };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uncutSkirting(plan: SkirtingPlan): { length: number; count: number }[] {
|
||||||
|
const counts = new Map<number, number>();
|
||||||
|
for (const piece of plan.pieces) {
|
||||||
|
if (!isSkirtingCut(piece, plan)) counts.set(piece.length, (counts.get(piece.length) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...counts].sort(([a], [b]) => a - b).map(([length, count]) => ({ length, count }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ready remnants need placement instructions, never another trimming instruction. */
|
||||||
|
export function readyOffcutInstructions(piece: FlooringPiece): string {
|
||||||
|
const ends =
|
||||||
|
piece.factoryLeft && piece.factoryRight
|
||||||
|
? 'both'
|
||||||
|
: piece.factoryLeft
|
||||||
|
? 'left'
|
||||||
|
: piece.factoryRight
|
||||||
|
? 'right'
|
||||||
|
: 'neither';
|
||||||
|
return `Use offcut; no new cut. Required factory short ends: ${ends}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Batch identical cuts without losing any placement codes or changing factory-edge orientation. */
|
||||||
|
export function groupFlooringCuts(plan: FlooringPlan): FlooringPiece[][] {
|
||||||
|
const groups = new Map<string, FlooringPiece[]>();
|
||||||
|
const precise = (value: number) => Math.round(value * 1000);
|
||||||
|
for (const piece of flooringCuts(plan)) {
|
||||||
|
const key = JSON.stringify([
|
||||||
|
piece.roomId,
|
||||||
|
precise(piece.length),
|
||||||
|
precise(piece.width),
|
||||||
|
piece.shaped,
|
||||||
|
piece.cut,
|
||||||
|
piece.factoryLeft,
|
||||||
|
piece.factoryRight,
|
||||||
|
piece.ripEdge,
|
||||||
|
piece.shaped ? piece.template.map((point) => [precise(point.x), precise(point.y)]) : null,
|
||||||
|
]);
|
||||||
|
const group = groups.get(key);
|
||||||
|
if (group) group.push(piece);
|
||||||
|
else groups.set(key, [piece]);
|
||||||
|
}
|
||||||
|
return [...groups.values()];
|
||||||
|
}
|
||||||
60
src/lib/cut-preview.ts
Normal file
60
src/lib/cut-preview.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import type { FlooringPiece, Point, SkirtingPiece } from './types';
|
||||||
|
|
||||||
|
export interface PreviewBox {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
export const exactMm = (value: number) =>
|
||||||
|
new Intl.NumberFormat('en-GB', { maximumFractionDigits: 3 }).format(value);
|
||||||
|
export const viewBox = (box: PreviewBox) => `${box.x} ${box.y} ${box.width} ${box.height}`;
|
||||||
|
|
||||||
|
export function flooringTemplate(piece: FlooringPiece): Point[] {
|
||||||
|
if (piece.template.length) return piece.template;
|
||||||
|
return piece.shaped
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: piece.length, y: 0 },
|
||||||
|
{ x: piece.length, y: piece.width },
|
||||||
|
{ x: 0, y: piece.width },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skirtingTemplate(piece: SkirtingPiece, thickness: number): Point[] {
|
||||||
|
if (piece.template?.length === 4) return piece.template;
|
||||||
|
if (piece.leftMitre !== 0 || piece.rightMitre !== 0) return [];
|
||||||
|
return [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: piece.length, y: 0 },
|
||||||
|
{ x: piece.length, y: thickness },
|
||||||
|
{ x: 0, y: thickness },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Padding changes the viewport only; all vertices stay in their original millimetres. */
|
||||||
|
export function fullPreviewBox(length: number, width: number): PreviewBox {
|
||||||
|
const padding = Math.max(length, width, 1) * 0.06;
|
||||||
|
return { x: -padding, y: -padding, width: length + 2 * padding, height: width + 2 * padding };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Equal square windows enlarge both axes equally; a 45° mitre remains 45°. */
|
||||||
|
export function skirtingEndBoxes(
|
||||||
|
template: Point[],
|
||||||
|
thickness: number,
|
||||||
|
): { left: PreviewBox; right: PreviewBox } {
|
||||||
|
const [backLeft, backRight, frontRight, frontLeft] = template;
|
||||||
|
const side = Math.max(
|
||||||
|
1,
|
||||||
|
thickness * 3,
|
||||||
|
Math.abs(backLeft.x - frontLeft.x) / 0.76,
|
||||||
|
Math.abs(backRight.x - frontRight.x) / 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
64
src/lib/defaults.ts
Normal file
64
src/lib/defaults.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import type { Project, Room, Wall, UrlState } from './types';
|
||||||
|
export function rectangle(id = 'room-1', name = 'Living room', width = 4800, height = 3600): Room {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
direction: 0,
|
||||||
|
walls: [width, height, width, height].map((length, i) => ({
|
||||||
|
id: `${id}-w${i + 1}`,
|
||||||
|
length,
|
||||||
|
turn: 90,
|
||||||
|
doors: i === 3 ? [{ id: `${id}-door-1`, offset: 500, width: 826 }] : [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function fireplace(id: string, name: string): Room {
|
||||||
|
const measures = [
|
||||||
|
[4800, 90],
|
||||||
|
[3600, 90],
|
||||||
|
[1650, 90],
|
||||||
|
[450, -90],
|
||||||
|
[1500, -90],
|
||||||
|
[450, 90],
|
||||||
|
[1650, 90],
|
||||||
|
[3600, 90],
|
||||||
|
];
|
||||||
|
const walls: Wall[] = measures.map(([length, turn], i) => ({
|
||||||
|
id: `${id}-w${i + 1}`,
|
||||||
|
length,
|
||||||
|
turn,
|
||||||
|
doors: i === 7 ? [{ id: `${id}-door-1`, offset: 500, width: 826 }] : [],
|
||||||
|
}));
|
||||||
|
return { id, name, direction: 0, walls };
|
||||||
|
}
|
||||||
|
export function defaultProject(): Project {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
name: 'My flooring project',
|
||||||
|
rooms: [rectangle()],
|
||||||
|
tolerance: 10,
|
||||||
|
flooring: {
|
||||||
|
length: 1200,
|
||||||
|
width: 190,
|
||||||
|
gap: 10,
|
||||||
|
pattern: 'brick',
|
||||||
|
minOffset: 300,
|
||||||
|
maxOffset: 600,
|
||||||
|
minLength: 200,
|
||||||
|
minWidth: 60,
|
||||||
|
kerf: 3,
|
||||||
|
},
|
||||||
|
skirting: { stockLengths: [2400], thickness: 18, minLength: 200, kerf: 3 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function defaultState(): UrlState {
|
||||||
|
return {
|
||||||
|
project: defaultProject(),
|
||||||
|
view: { roomId: 'room-1', selectedPiece: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function nextId(prefix: string, existing: string[]): string {
|
||||||
|
let n = 1;
|
||||||
|
while (existing.includes(`${prefix}-${n}`)) n++;
|
||||||
|
return `${prefix}-${n}`;
|
||||||
|
}
|
||||||
58
src/lib/dimension-labels.ts
Normal file
58
src/lib/dimension-labels.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import type { Point, WallEdge } from './types';
|
||||||
|
import { mm } from './format';
|
||||||
|
export interface LabelBox {
|
||||||
|
minX: number;
|
||||||
|
minY: number;
|
||||||
|
maxX: number;
|
||||||
|
maxY: number;
|
||||||
|
}
|
||||||
|
export interface DimensionLabel {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
angle: number;
|
||||||
|
font: number;
|
||||||
|
width: number;
|
||||||
|
offset: number;
|
||||||
|
text: string;
|
||||||
|
normal: Point;
|
||||||
|
box: LabelBox;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** Move dimension lines outward until their labels clear earlier labels and the skirting codes. */
|
||||||
|
export function dimensionLabels(
|
||||||
|
edges: WallEdge[],
|
||||||
|
span: number,
|
||||||
|
orientation: number,
|
||||||
|
reserved: LabelBox[] = [],
|
||||||
|
): DimensionLabel[] {
|
||||||
|
const occupied = [...reserved];
|
||||||
|
return edges.map((edge) => {
|
||||||
|
const dx = edge.end.x - edge.start.x,
|
||||||
|
dy = edge.end.y - edge.start.y;
|
||||||
|
const length = Math.hypot(dx, dy) || 1;
|
||||||
|
const normal = { x: (orientation * dy) / length, y: (-orientation * dx) / length };
|
||||||
|
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||||
|
if (angle > 90) angle -= 180;
|
||||||
|
if (angle < -90) angle += 180;
|
||||||
|
const font = Math.min(span * 0.023, Math.max(span * 0.014, length / 7));
|
||||||
|
const text = `W${edge.index + 1} · ${mm(edge.length)}`;
|
||||||
|
const width = font * (0.62 * text.length + 1),
|
||||||
|
height = font * 1.5;
|
||||||
|
const radians = (angle * Math.PI) / 180;
|
||||||
|
const halfX = (Math.abs(Math.cos(radians)) * width + Math.abs(Math.sin(radians)) * height) / 2;
|
||||||
|
const halfY = (Math.abs(Math.sin(radians)) * width + Math.abs(Math.cos(radians)) * height) / 2;
|
||||||
|
let label!: DimensionLabel;
|
||||||
|
for (let attempt = 0; attempt < 24; attempt++) {
|
||||||
|
const offset = span * (0.085 + attempt * 0.035);
|
||||||
|
const x = (edge.start.x + edge.end.x) / 2 + normal.x * offset;
|
||||||
|
const y = (edge.start.y + edge.end.y) / 2 + normal.y * offset;
|
||||||
|
const box = { minX: x - halfX, maxX: x + halfX, minY: y - halfY, maxY: y + halfY };
|
||||||
|
label = { x, y, angle, font, width, offset, text, normal, box };
|
||||||
|
if (!occupied.some((other) => overlaps(box, other, span * 0.008))) break;
|
||||||
|
}
|
||||||
|
occupied.push(label.box);
|
||||||
|
return label;
|
||||||
|
});
|
||||||
|
}
|
||||||
187
src/lib/export.ts
Normal file
187
src/lib/export.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import type { FlooringPlan, Project, SkirtingPlan } from './types';
|
||||||
|
import { flooringInstructions } from './cut-instructions';
|
||||||
|
import {
|
||||||
|
flooringCuts,
|
||||||
|
skirtingCuts,
|
||||||
|
uncutFlooring,
|
||||||
|
uncutSkirting,
|
||||||
|
readyOffcutInstructions,
|
||||||
|
} from './cut-list';
|
||||||
|
|
||||||
|
const cell = (value: string | number) => {
|
||||||
|
const str = String(value);
|
||||||
|
return `"${(/^[=+@\-]/.test(str) ? "'" : '') + str.replaceAll('"', '""')}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function cutListCsv(
|
||||||
|
project: Project,
|
||||||
|
flooring: FlooringPlan,
|
||||||
|
skirting: SkirtingPlan,
|
||||||
|
): string {
|
||||||
|
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
|
||||||
|
const floorCuts = flooringCuts(flooring),
|
||||||
|
skirtCuts = skirtingCuts(skirting);
|
||||||
|
const floorIds = new Set(floorCuts.map((piece) => piece.id));
|
||||||
|
const skirtIds = new Set(skirtCuts.map((piece) => piece.id));
|
||||||
|
const wholeFloor = uncutFlooring(flooring, project.flooring);
|
||||||
|
const rows: (string | number)[][] = [
|
||||||
|
[
|
||||||
|
'Material',
|
||||||
|
'Room',
|
||||||
|
'Piece',
|
||||||
|
'Stock',
|
||||||
|
'Length mm',
|
||||||
|
'Width mm',
|
||||||
|
'Left mitre deg',
|
||||||
|
'Right mitre deg',
|
||||||
|
'Quantity',
|
||||||
|
'Notes',
|
||||||
|
'Template x:y mm',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Flooring total',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
project.flooring.length,
|
||||||
|
project.flooring.width,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
flooring.boardCount,
|
||||||
|
`Boards to buy; ${flooring.pieces.length} installed pieces; ${flooring.cuts} saw cuts`,
|
||||||
|
'',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Flooring uncut boards',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
project.flooring.length,
|
||||||
|
project.flooring.width,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
wholeFloor.wholeBoards,
|
||||||
|
'Full boards laid without cuts',
|
||||||
|
'',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Skirting total',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
project.skirting.thickness,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
skirting.stockCount,
|
||||||
|
`Lengths to buy; ${skirting.pieces.length} installed pieces; ${skirting.cuts} saw cuts`,
|
||||||
|
'',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
if (wholeFloor.readyOffcuts)
|
||||||
|
rows.push([
|
||||||
|
'Flooring ready offcuts',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
wholeFloor.readyOffcuts,
|
||||||
|
'Already sized; no further cuts',
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
for (const group of uncutSkirting(skirting)) {
|
||||||
|
rows.push([
|
||||||
|
'Skirting uncut lengths',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
group.length,
|
||||||
|
project.skirting.thickness,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
group.count,
|
||||||
|
'Full stock lengths installed without cuts',
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for (const piece of floorCuts) {
|
||||||
|
rows.push([
|
||||||
|
'Flooring',
|
||||||
|
roomName(piece.roomId),
|
||||||
|
piece.id,
|
||||||
|
piece.stockId,
|
||||||
|
piece.length,
|
||||||
|
piece.width,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
1,
|
||||||
|
`${piece.reused ? 'Reused offcut; ' : ''}${piece.cut ? `${piece.shaped ? 'Shaped cut' : 'Straight cut'}; ${flooringInstructions(piece)}` : readyOffcutInstructions(piece)}`,
|
||||||
|
piece.template.map((point) => `${point.x.toFixed(1)}:${point.y.toFixed(1)}`).join(' | '),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for (const piece of skirtCuts) {
|
||||||
|
rows.push([
|
||||||
|
'Skirting',
|
||||||
|
roomName(piece.roomId),
|
||||||
|
piece.id,
|
||||||
|
piece.stockId,
|
||||||
|
piece.length,
|
||||||
|
project.skirting.thickness,
|
||||||
|
piece.leftMitre,
|
||||||
|
piece.rightMitre,
|
||||||
|
1,
|
||||||
|
`Back length ${piece.backLength.toFixed(1)} mm; left ${piece.leftType}; right ${piece.rightType}; signed front-to-back bevels ${piece.leftBevel ?? piece.leftMitre} / ${piece.rightBevel ?? piece.rightMitre} deg; template back y=0, front y=thickness`,
|
||||||
|
piece.template?.map((point) => `${point.x.toFixed(1)}:${point.y.toFixed(1)}`).join(' | ') ??
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for (const stock of flooring.stocks) {
|
||||||
|
const pieces = stock.pieceIds.filter((id) => floorIds.has(id));
|
||||||
|
if (pieces.length)
|
||||||
|
rows.push([
|
||||||
|
'Flooring stock',
|
||||||
|
'',
|
||||||
|
pieces.join(' / '),
|
||||||
|
stock.id,
|
||||||
|
project.flooring.length,
|
||||||
|
project.flooring.width,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
1,
|
||||||
|
`Remaining length ${stock.remaining.toFixed(1)} mm`,
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for (const stock of skirting.stocks) {
|
||||||
|
const pieces = stock.pieceIds.filter((id) => skirtIds.has(id));
|
||||||
|
if (pieces.length)
|
||||||
|
rows.push([
|
||||||
|
'Skirting stock',
|
||||||
|
'',
|
||||||
|
pieces.join(' / '),
|
||||||
|
stock.id,
|
||||||
|
stock.length,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
1,
|
||||||
|
`Waste including kerf ${stock.waste.toFixed(1)} mm`,
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return '\uFEFF' + rows.map((row) => row.map(cell).join(',')).join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadCsv(project: Project, flooring: FlooringPlan, skirting: SkirtingPlan) {
|
||||||
|
const url = URL.createObjectURL(
|
||||||
|
new Blob([cutListCsv(project, flooring, skirting)], { type: 'text/csv;charset=utf-8;' }),
|
||||||
|
);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = 'flooring-cut-plan.csv';
|
||||||
|
link.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
572
src/lib/flooring.ts
Normal file
572
src/lib/flooring.ts
Normal file
|
|
@ -0,0 +1,572 @@
|
||||||
|
import ClipperLib from 'clipper-lib';
|
||||||
|
import { buildGeometry, insetPolygon } from './geometry';
|
||||||
|
import type {
|
||||||
|
FlooringPiece,
|
||||||
|
FlooringPlan,
|
||||||
|
FlooringRow,
|
||||||
|
FlooringSettings,
|
||||||
|
FlooringStock,
|
||||||
|
Issue,
|
||||||
|
Point,
|
||||||
|
Room,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const SCALE = 1000;
|
||||||
|
const EPSILON = 0.01;
|
||||||
|
const MAX_BOARDS = 2000;
|
||||||
|
|
||||||
|
interface Bounds {
|
||||||
|
minX: number;
|
||||||
|
maxX: number;
|
||||||
|
minY: number;
|
||||||
|
maxY: number;
|
||||||
|
}
|
||||||
|
interface AvailableStock extends FlooringStock {
|
||||||
|
left: boolean;
|
||||||
|
right: boolean;
|
||||||
|
}
|
||||||
|
interface Layout {
|
||||||
|
pieces: FlooringPiece[];
|
||||||
|
rows: FlooringRow[];
|
||||||
|
stocks: AvailableStock[];
|
||||||
|
cuts: number;
|
||||||
|
reusedCount: number;
|
||||||
|
}
|
||||||
|
interface CutPiece {
|
||||||
|
polygon: Point[];
|
||||||
|
bounds: Bounds;
|
||||||
|
needsLeft: boolean;
|
||||||
|
needsRight: boolean;
|
||||||
|
shaped: boolean;
|
||||||
|
}
|
||||||
|
interface RowShape {
|
||||||
|
paths: Point[][];
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
bounds: Bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
||||||
|
const area = (points: Point[]) =>
|
||||||
|
Math.abs(
|
||||||
|
points.reduce((sum, p, i) => {
|
||||||
|
const q = points[(i + 1) % points.length];
|
||||||
|
return sum + p.x * q.y - q.x * p.y;
|
||||||
|
}, 0) / 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
function bounds(points: Point[]): Bounds {
|
||||||
|
return points.reduce(
|
||||||
|
(b, p) => ({
|
||||||
|
minX: Math.min(b.minX, p.x),
|
||||||
|
maxX: Math.max(b.maxX, p.x),
|
||||||
|
minY: Math.min(b.minY, p.y),
|
||||||
|
maxY: Math.max(b.maxY, p.y),
|
||||||
|
}),
|
||||||
|
{ minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boardPoint(p: Point, direction: 0 | 90): Point {
|
||||||
|
return direction === 0 ? p : { x: p.y, y: -p.x };
|
||||||
|
}
|
||||||
|
function worldPoint(p: Point, direction: 0 | 90): Point {
|
||||||
|
return direction === 0 ? p : { x: -p.y, y: p.x };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Integer polygon clipping makes the same millimetre inputs reproducible across browsers. */
|
||||||
|
function intersect(paths: Point[][], rectangle: Bounds): Point[][] {
|
||||||
|
const clipper = new ClipperLib.Clipper();
|
||||||
|
clipper.AddPaths(
|
||||||
|
paths.map((path) =>
|
||||||
|
path.map((p) => ({ X: Math.round(p.x * SCALE), Y: Math.round(p.y * SCALE) })),
|
||||||
|
),
|
||||||
|
ClipperLib.PolyType.ptSubject,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
clipper.AddPath(
|
||||||
|
[
|
||||||
|
{ X: Math.round(rectangle.minX * SCALE), Y: Math.round(rectangle.minY * SCALE) },
|
||||||
|
{ X: Math.round(rectangle.maxX * SCALE), Y: Math.round(rectangle.minY * SCALE) },
|
||||||
|
{ X: Math.round(rectangle.maxX * SCALE), Y: Math.round(rectangle.maxY * SCALE) },
|
||||||
|
{ X: Math.round(rectangle.minX * SCALE), Y: Math.round(rectangle.maxY * SCALE) },
|
||||||
|
],
|
||||||
|
ClipperLib.PolyType.ptClip,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const result: ClipperLib.Paths = [];
|
||||||
|
clipper.Execute(
|
||||||
|
ClipperLib.ClipType.ctIntersection,
|
||||||
|
result,
|
||||||
|
ClipperLib.PolyFillType.pftNonZero,
|
||||||
|
ClipperLib.PolyFillType.pftNonZero,
|
||||||
|
);
|
||||||
|
return result
|
||||||
|
.map((path) => path.map((p) => ({ x: p.X / SCALE, y: p.Y / SCALE })))
|
||||||
|
.filter((path) => area(path) > EPSILON)
|
||||||
|
.sort((a, b) => bounds(a).minX - bounds(b).minX || bounds(a).minY - bounds(b).minY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only perimeter rows may be ripped: interior rows must retain both factory long edges. */
|
||||||
|
function rowWidthOptions(height: number, s: FlooringSettings): number[][] {
|
||||||
|
const count = Math.ceil((height - EPSILON) / s.width);
|
||||||
|
if (count <= 0 || count > 500) return [];
|
||||||
|
if (count === 1) return height + EPSILON >= s.minWidth ? [[height]] : [];
|
||||||
|
const edgeTotal = height - (count - 2) * s.width;
|
||||||
|
if (edgeTotal + EPSILON < 2 * s.minWidth) return [];
|
||||||
|
const last = height - (count - 1) * s.width;
|
||||||
|
const options: number[][] = [];
|
||||||
|
const add = (first: number) => {
|
||||||
|
const final = edgeTotal - first;
|
||||||
|
if (
|
||||||
|
first < s.minWidth - EPSILON ||
|
||||||
|
final < s.minWidth - EPSILON ||
|
||||||
|
first > s.width + EPSILON ||
|
||||||
|
final > s.width + EPSILON
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const widths = [first, ...Array<number>(count - 2).fill(s.width), final];
|
||||||
|
if (!options.some((o) => o.every((width, i) => close(width, widths[i])))) options.push(widths);
|
||||||
|
};
|
||||||
|
// With a sliver, share the missing width equally between the first and last rows.
|
||||||
|
if (last < s.minWidth - EPSILON) add(edgeTotal / 2);
|
||||||
|
else {
|
||||||
|
add(s.width);
|
||||||
|
add(last);
|
||||||
|
add(edgeTotal / 2);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasVerticalEdge(path: Point[], x: number): boolean {
|
||||||
|
return path.some(
|
||||||
|
(p, i) =>
|
||||||
|
close(p.x, x) &&
|
||||||
|
close(path[(i + 1) % path.length].x, x) &&
|
||||||
|
Math.abs(p.y - path[(i + 1) % path.length].y) > EPSILON,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cutRow(
|
||||||
|
shape: RowShape,
|
||||||
|
phase: number,
|
||||||
|
origin: number,
|
||||||
|
s: FlooringSettings,
|
||||||
|
): CutPiece[] | null {
|
||||||
|
const pieces: CutPiece[] = [];
|
||||||
|
const firstCell = Math.floor((shape.bounds.minX - origin - phase + EPSILON) / s.length);
|
||||||
|
const finalCell = Math.ceil((shape.bounds.maxX - origin - phase - EPSILON) / s.length);
|
||||||
|
if (finalCell - firstCell > MAX_BOARDS) return null;
|
||||||
|
for (let column = firstCell; column < finalCell; column++) {
|
||||||
|
const left = round(origin + phase + column * s.length);
|
||||||
|
const right = round(left + s.length);
|
||||||
|
const paths = intersect(shape.paths, {
|
||||||
|
minX: left,
|
||||||
|
maxX: right,
|
||||||
|
minY: shape.start,
|
||||||
|
maxY: shape.end,
|
||||||
|
});
|
||||||
|
for (const polygon of paths) {
|
||||||
|
const b = bounds(polygon);
|
||||||
|
const length = b.maxX - b.minX;
|
||||||
|
const width = b.maxY - b.minY;
|
||||||
|
if (length < s.minLength - EPSILON || width < s.minWidth - 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);
|
||||||
|
pieces.push({
|
||||||
|
polygon,
|
||||||
|
bounds: b,
|
||||||
|
needsLeft,
|
||||||
|
needsRight,
|
||||||
|
shaped: Math.abs(area(polygon) - length * width) > Math.max(0.1, length * width * 1e-7),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pieces;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A cut end cannot replace a factory joint. Offcuts retain their original left/right end flags. */
|
||||||
|
function allocate(
|
||||||
|
layout: Layout,
|
||||||
|
piece: CutPiece,
|
||||||
|
pieceId: string,
|
||||||
|
s: FlooringSettings,
|
||||||
|
): { stockId: string; reused: boolean; crosscut: boolean } {
|
||||||
|
const length = round(piece.bounds.maxX - piece.bounds.minX);
|
||||||
|
let stock: AvailableStock | undefined;
|
||||||
|
for (const candidate of layout.stocks) {
|
||||||
|
if (
|
||||||
|
candidate.remaining < length - EPSILON ||
|
||||||
|
(piece.needsLeft && !candidate.left) ||
|
||||||
|
(piece.needsRight && !candidate.right)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
// A piece joined at both ends must be a complete factory board.
|
||||||
|
if (piece.needsLeft && piece.needsRight && !close(candidate.remaining, length)) continue;
|
||||||
|
if (!stock || candidate.remaining < stock.remaining - EPSILON) stock = candidate;
|
||||||
|
}
|
||||||
|
if (!stock) {
|
||||||
|
stock = {
|
||||||
|
id: `B${layout.stocks.length + 1}`,
|
||||||
|
pieceIds: [],
|
||||||
|
remaining: s.length,
|
||||||
|
left: true,
|
||||||
|
right: true,
|
||||||
|
};
|
||||||
|
layout.stocks.push(stock);
|
||||||
|
}
|
||||||
|
const reused = stock.pieceIds.length > 0;
|
||||||
|
const crosscut = stock.remaining - length > EPSILON;
|
||||||
|
stock.pieceIds.push(pieceId);
|
||||||
|
if (!crosscut) {
|
||||||
|
stock.remaining = 0;
|
||||||
|
stock.left = false;
|
||||||
|
stock.right = false;
|
||||||
|
} else {
|
||||||
|
stock.remaining = round(Math.max(0, stock.remaining - length - 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRow(
|
||||||
|
base: Layout,
|
||||||
|
cuts: CutPiece[],
|
||||||
|
room: Room,
|
||||||
|
rowIndex: number,
|
||||||
|
shape: RowShape,
|
||||||
|
phase: number,
|
||||||
|
s: FlooringSettings,
|
||||||
|
): Layout {
|
||||||
|
const layout: Layout = {
|
||||||
|
pieces: [...base.pieces],
|
||||||
|
rows: [...base.rows],
|
||||||
|
cuts: base.cuts,
|
||||||
|
reusedCount: base.reusedCount,
|
||||||
|
stocks: base.stocks.map((stock) => ({ ...stock, pieceIds: [...stock.pieceIds] })),
|
||||||
|
};
|
||||||
|
const pieceIds: string[] = [];
|
||||||
|
cuts.forEach((piece, column) => {
|
||||||
|
const id = `F${layout.pieces.length + 1}`;
|
||||||
|
const allocation = allocate(layout, piece, id, s);
|
||||||
|
const length = round(piece.bounds.maxX - piece.bounds.minX);
|
||||||
|
const width = round(piece.bounds.maxY - piece.bounds.minY);
|
||||||
|
// Count straight saw passes. For a shape, every additional non-bounding edge needs a pass.
|
||||||
|
const shapeCuts = piece.shaped
|
||||||
|
? piece.polygon.filter((p, i, path) => {
|
||||||
|
const q = path[(i + 1) % path.length];
|
||||||
|
const onBounds =
|
||||||
|
(close(p.x, q.x) && (close(p.x, piece.bounds.minX) || close(p.x, piece.bounds.maxX))) ||
|
||||||
|
(close(p.y, q.y) && (close(p.y, piece.bounds.minY) || close(p.y, piece.bounds.maxY)));
|
||||||
|
return !onBounds;
|
||||||
|
}).length
|
||||||
|
: 0;
|
||||||
|
const stockTop =
|
||||||
|
rowIndex === 0 && shape.end - shape.start < s.width - EPSILON
|
||||||
|
? shape.end - s.width
|
||||||
|
: shape.start;
|
||||||
|
const ripTop = piece.bounds.minY > stockTop + EPSILON;
|
||||||
|
const ripBottom = piece.bounds.maxY < stockTop + s.width - EPSILON;
|
||||||
|
const ripEdge = ripTop && ripBottom ? 'both' : ripTop ? 'top' : ripBottom ? 'bottom' : null;
|
||||||
|
const passes = Number(allocation.crosscut) + Number(ripTop) + Number(ripBottom) + shapeCuts;
|
||||||
|
layout.cuts += passes;
|
||||||
|
layout.pieces.push({
|
||||||
|
id,
|
||||||
|
roomId: room.id,
|
||||||
|
row: rowIndex + 1,
|
||||||
|
column: column + 1,
|
||||||
|
polygon: piece.polygon.map((p) => worldPoint(p, room.direction)),
|
||||||
|
length,
|
||||||
|
width,
|
||||||
|
stockId: allocation.stockId,
|
||||||
|
reused: allocation.reused,
|
||||||
|
cut: passes > 0,
|
||||||
|
shaped: piece.shaped,
|
||||||
|
factoryLeft: piece.needsLeft,
|
||||||
|
factoryRight: piece.needsRight,
|
||||||
|
ripEdge,
|
||||||
|
template: piece.polygon.map((p) => ({
|
||||||
|
x: round(p.x - piece.bounds.minX),
|
||||||
|
y: round(p.y - piece.bounds.minY),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
pieceIds.push(id);
|
||||||
|
});
|
||||||
|
layout.rows.push({
|
||||||
|
roomId: room.id,
|
||||||
|
index: rowIndex + 1,
|
||||||
|
width: round(shape.end - shape.start),
|
||||||
|
offset: phase,
|
||||||
|
pieceIds,
|
||||||
|
});
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
function staggerAllowed(a: number, b: number, s: FlooringSettings): boolean {
|
||||||
|
const delta = Math.abs(a - b);
|
||||||
|
const nearest = Math.min(delta, s.length - delta);
|
||||||
|
return nearest >= s.minOffset - EPSILON && nearest <= s.maxOffset + EPSILON;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small fixed hash, never time or Math.random: edits and shared URLs reproduce the same result. */
|
||||||
|
function fraction(seed: string): number {
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
hash ^= seed.charCodeAt(i);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return (hash >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidatePhases(
|
||||||
|
layout: Layout,
|
||||||
|
shape: RowShape,
|
||||||
|
origin: number,
|
||||||
|
room: Room,
|
||||||
|
row: number,
|
||||||
|
firstPhase: number | undefined,
|
||||||
|
previousPhase: number | undefined,
|
||||||
|
s: FlooringSettings,
|
||||||
|
): number[] {
|
||||||
|
if (s.pattern === 'brick' && firstPhase !== undefined)
|
||||||
|
return [phaseOf(firstPhase + ((row % 2) * s.length) / 2, s.length)];
|
||||||
|
const candidates: number[] = [];
|
||||||
|
const twoRowsBack = layout.rows.filter((r) => r.roomId === room.id).at(-2)?.offset;
|
||||||
|
const avoidRepeatedPair =
|
||||||
|
s.pattern === 'random' && s.minOffset < s.length / 2 - EPSILON && s.maxOffset > EPSILON;
|
||||||
|
const add = (value: number) => {
|
||||||
|
const phase = phaseOf(value, s.length);
|
||||||
|
if (previousPhase !== undefined && !staggerAllowed(phase, previousPhase, s)) return;
|
||||||
|
if (avoidRepeatedPair && twoRowsBack !== undefined && close(phase, twoRowsBack)) return;
|
||||||
|
if (!candidates.some((p) => close(p, phase))) candidates.push(phase);
|
||||||
|
};
|
||||||
|
// Reusing a suitable end is usually the most effective way to save a board and a crosscut.
|
||||||
|
const offcuts = layout.stocks
|
||||||
|
.filter((stock) => stock.remaining >= s.minLength - EPSILON)
|
||||||
|
.sort((a, b) => a.remaining - b.remaining || compareText(a.id, b.id));
|
||||||
|
for (const stock of offcuts.slice(0, 12)) {
|
||||||
|
if (stock.right) add(shape.bounds.minX - origin + stock.remaining);
|
||||||
|
if (stock.left) add(shape.bounds.maxX - origin - stock.remaining);
|
||||||
|
}
|
||||||
|
const minimum = Math.min(s.minOffset, s.length / 2);
|
||||||
|
const maximum = Math.min(s.maxOffset, s.length / 2);
|
||||||
|
if (previousPhase !== undefined) {
|
||||||
|
for (const distance of [minimum, maximum, (minimum + maximum) / 2]) {
|
||||||
|
add(previousPhase + distance);
|
||||||
|
add(previousPhase - distance);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const distance =
|
||||||
|
Math.round((minimum + fraction(`${room.id}:${row}:${i}`) * (maximum - minimum)) * 10) / 10;
|
||||||
|
add(previousPhase + (i % 2 ? -1 : 1) * distance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wall ends and the minimum legal piece size supply useful breakpoints for irregular rooms too.
|
||||||
|
add(0);
|
||||||
|
add(s.length / 2);
|
||||||
|
for (const path of shape.paths) {
|
||||||
|
const b = bounds(path);
|
||||||
|
add(b.minX - origin);
|
||||||
|
add(b.maxX - origin);
|
||||||
|
add(b.minX - origin + s.minLength);
|
||||||
|
add(b.maxX - origin - s.minLength);
|
||||||
|
const remainder = phaseOf(b.maxX - b.minX, s.length);
|
||||||
|
add(b.minX - origin + (s.length + remainder) / 2);
|
||||||
|
}
|
||||||
|
return candidates.slice(0, 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareLayouts(a: Layout, b: Layout): number {
|
||||||
|
return (
|
||||||
|
a.stocks.length - b.stocks.length ||
|
||||||
|
a.cuts - b.cuts ||
|
||||||
|
b.reusedCount - a.reusedCount ||
|
||||||
|
compareText(
|
||||||
|
a.rows.map((r) => r.offset.toFixed(3)).join(','),
|
||||||
|
b.rows.map((r) => r.offset.toFixed(3)).join(','),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSettings(s: FlooringSettings): string | null {
|
||||||
|
if (Object.values(s).some((value) => typeof value === 'number' && !Number.isFinite(value)))
|
||||||
|
return 'All flooring measurements must be finite numbers.';
|
||||||
|
if (Object.values(s).some((value) => typeof value === 'number' && Math.abs(value) > 1e6))
|
||||||
|
return 'Flooring measurements must not exceed 1,000,000 mm. Check your units.';
|
||||||
|
if (s.length < 0.1 || s.width < 0.1 || s.minLength < 0.1 || s.minWidth < 0.1)
|
||||||
|
return 'Board and minimum piece dimensions must be at least 0.1 mm.';
|
||||||
|
if (s.minLength > s.length || s.minWidth > s.width)
|
||||||
|
return 'Minimum piece dimensions cannot exceed a whole board.';
|
||||||
|
if (s.gap < 0 || s.kerf < 0 || s.kerf >= s.length)
|
||||||
|
return 'Expansion gap and saw kerf must be non-negative, and kerf must be smaller than a board.';
|
||||||
|
if (s.minOffset < 0 || s.maxOffset < s.minOffset || s.minOffset > s.length / 2)
|
||||||
|
return 'Joint offset limits must be ordered, with the minimum no greater than half a board.';
|
||||||
|
if (
|
||||||
|
s.pattern === 'brick' &&
|
||||||
|
(s.length / 2 < s.minOffset - EPSILON || s.length / 2 > s.maxOffset + EPSILON)
|
||||||
|
) {
|
||||||
|
return 'Brick layout uses a half-board stagger. Include half the board length in your offset limits, or choose random layout.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic bounded beam search, ordered by boards consumed, then saw passes.
|
||||||
|
* Stock is shared between rooms, but layouts and joint phases are independent.
|
||||||
|
* Only full-width end remnants are reused; ripped strips are conservatively waste.
|
||||||
|
* This is a practical cutting plan, not a claim of a global packing optimum.
|
||||||
|
*/
|
||||||
|
export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: number): FlooringPlan {
|
||||||
|
let layout: Layout = { pieces: [], rows: [], stocks: [], cuts: 0, reusedCount: 0 };
|
||||||
|
const issues: Issue[] = [];
|
||||||
|
const invalid = validateSettings(s);
|
||||||
|
if (invalid) issues.push({ severity: 'error', message: invalid });
|
||||||
|
for (const room of invalid ? [] : rooms) {
|
||||||
|
const geometry = buildGeometry(room, tolerance);
|
||||||
|
if (!geometry.valid) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'error',
|
||||||
|
roomId: room.id,
|
||||||
|
message: `${room.name}: finish a valid, closed outline before planning flooring.`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const paths = insetPolygon(geometry.vertices, s.gap).map((path) =>
|
||||||
|
path.map((p) => boardPoint(p, room.direction)),
|
||||||
|
);
|
||||||
|
if (!paths.length) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'error',
|
||||||
|
roomId: room.id,
|
||||||
|
message: `${room.name}: the expansion gap leaves no usable floor area.`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const roomBounds = bounds(paths.flat());
|
||||||
|
const widthOptions = rowWidthOptions(roomBounds.maxY - roomBounds.minY, s);
|
||||||
|
const estimatedBoards = paths.reduce((sum, path) => sum + area(path), 0) / (s.length * s.width);
|
||||||
|
if (estimatedBoards > MAX_BOARDS || !widthOptions.length) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'error',
|
||||||
|
roomId: room.id,
|
||||||
|
message:
|
||||||
|
estimatedBoards > MAX_BOARDS
|
||||||
|
? `${room.name}: this plan exceeds the ${MAX_BOARDS}-board calculation limit. Check your millimetre measurements.`
|
||||||
|
: `${room.name}: the room width cannot meet the minimum row width using full-width interior rows. Reduce the minimum row width or change board direction/size.`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const beamWidth = estimatedBoards > 300 ? 2 : 5;
|
||||||
|
const complete: Layout[] = [];
|
||||||
|
for (const widths of widthOptions) {
|
||||||
|
let beam: Layout[] = [layout];
|
||||||
|
let y = roomBounds.minY;
|
||||||
|
for (let row = 0; row < widths.length && beam.length; row++) {
|
||||||
|
const end = row === widths.length - 1 ? roomBounds.maxY : round(y + widths[row]);
|
||||||
|
const rowPaths = intersect(paths, { ...roomBounds, minY: y, maxY: end });
|
||||||
|
if (!rowPaths.length) {
|
||||||
|
y = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const shape: RowShape = { paths: rowPaths, start: y, end, bounds: bounds(rowPaths.flat()) };
|
||||||
|
const alternatives: Layout[] = [];
|
||||||
|
const cache = new Map<number, CutPiece[] | null>();
|
||||||
|
for (const base of beam) {
|
||||||
|
const roomRows = base.rows.filter((r) => r.roomId === room.id);
|
||||||
|
const previousPhase = roomRows.at(-1)?.offset;
|
||||||
|
const firstPhase = roomRows[0]?.offset;
|
||||||
|
for (const phase of candidatePhases(
|
||||||
|
base,
|
||||||
|
shape,
|
||||||
|
roomBounds.minX,
|
||||||
|
room,
|
||||||
|
row,
|
||||||
|
firstPhase,
|
||||||
|
previousPhase,
|
||||||
|
s,
|
||||||
|
)) {
|
||||||
|
if (previousPhase !== undefined && !staggerAllowed(phase, previousPhase, s)) continue;
|
||||||
|
let cuts = cache.get(phase);
|
||||||
|
if (cuts === undefined) {
|
||||||
|
cuts = cutRow(shape, phase, roomBounds.minX, s);
|
||||||
|
cache.set(phase, cuts);
|
||||||
|
}
|
||||||
|
if (!cuts?.length) continue;
|
||||||
|
const candidate = appendRow(base, cuts, room, row, shape, phase, s);
|
||||||
|
if (candidate.stocks.length <= MAX_BOARDS) alternatives.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alternatives.sort(compareLayouts);
|
||||||
|
// Keep distinct phases/inventories; identical routes do not earn extra beam slots.
|
||||||
|
beam = alternatives
|
||||||
|
.filter(
|
||||||
|
(candidate, index) =>
|
||||||
|
index === 0 ||
|
||||||
|
candidate.rows.at(-1)?.offset !== alternatives[index - 1].rows.at(-1)?.offset ||
|
||||||
|
candidate.stocks
|
||||||
|
.map((stock) => `${stock.remaining}:${Number(stock.left)}${Number(stock.right)}`)
|
||||||
|
.join(',') !==
|
||||||
|
alternatives[index - 1].stocks
|
||||||
|
.map((stock) => `${stock.remaining}:${Number(stock.left)}${Number(stock.right)}`)
|
||||||
|
.join(','),
|
||||||
|
)
|
||||||
|
.slice(0, beamWidth);
|
||||||
|
y = end;
|
||||||
|
}
|
||||||
|
if (beam.length) complete.push(...beam);
|
||||||
|
}
|
||||||
|
complete.sort(compareLayouts);
|
||||||
|
if (!complete.length) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'error',
|
||||||
|
roomId: room.id,
|
||||||
|
message: `${room.name}: no valid layout found within the piece-size and joint-offset limits. Try reducing minimum piece dimensions, widening the offset range, or changing board direction. No partial floor is counted.`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
layout = complete[0];
|
||||||
|
if (layout.pieces.some((p) => p.roomId === room.id && p.shaped))
|
||||||
|
issues.push({
|
||||||
|
severity: 'warning',
|
||||||
|
roomId: room.id,
|
||||||
|
message: `${room.name}: angled/notched cuts use the supplied shape templates. Minimum sizes check each piece's overall length and width; inspect narrow tips and joints against your flooring instructions.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const coveredArea = layout.pieces.reduce((sum, piece) => sum + area(piece.polygon), 0);
|
||||||
|
const purchasedArea = layout.stocks.length ? layout.stocks.length * s.length * s.width : 0;
|
||||||
|
const wasteArea = Math.max(0, purchasedArea - coveredArea);
|
||||||
|
// Allocate display codes only after choosing the layout. Whole boards have
|
||||||
|
// internal IDs so hiding them leaves no gaps in the cutting sequence.
|
||||||
|
let cutNumber = 0,
|
||||||
|
wholeNumber = 0;
|
||||||
|
const codes = new Map(
|
||||||
|
layout.pieces.map((piece) => [
|
||||||
|
piece.id,
|
||||||
|
piece.cut || piece.reused ? `F${++cutNumber}` : `FU${++wholeNumber}`,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const label = (id: string) => codes.get(id)!;
|
||||||
|
return {
|
||||||
|
pieces: layout.pieces.map((piece) => ({ ...piece, id: label(piece.id) })),
|
||||||
|
rows: layout.rows.map((row) => ({ ...row, pieceIds: row.pieceIds.map(label) })),
|
||||||
|
stocks: layout.stocks.map(({ id, pieceIds, remaining }) => ({
|
||||||
|
id,
|
||||||
|
pieceIds: pieceIds.map(label),
|
||||||
|
remaining,
|
||||||
|
})),
|
||||||
|
boardCount: layout.stocks.length,
|
||||||
|
cuts: layout.cuts,
|
||||||
|
wasteArea: round(wasteArea),
|
||||||
|
wastePercent: purchasedArea > 0 ? (wasteArea / purchasedArea) * 100 : 0,
|
||||||
|
reusedCount: layout.reusedCount,
|
||||||
|
coveredArea: round(coveredArea),
|
||||||
|
issues,
|
||||||
|
};
|
||||||
|
}
|
||||||
9
src/lib/format.ts
Normal file
9
src/lib/format.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export const mm = (value: number) =>
|
||||||
|
Number.isFinite(value)
|
||||||
|
? new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1 }).format(value)
|
||||||
|
: '—';
|
||||||
|
export const area = (value: number) =>
|
||||||
|
new Intl.NumberFormat('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
|
||||||
|
value / 1e6,
|
||||||
|
);
|
||||||
|
export const degree = (value: number) => `${mm(Math.abs(value))}°`;
|
||||||
231
src/lib/geometry.ts
Normal file
231
src/lib/geometry.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
import ClipperLib from 'clipper-lib';
|
||||||
|
import type { Geometry, Issue, Point, Room, WallEdge } from './types';
|
||||||
|
|
||||||
|
const EPS = 1e-6;
|
||||||
|
const SCALE = 1000;
|
||||||
|
const MAX_WALL = 1_000_000;
|
||||||
|
const radians = (degrees: number) => (degrees * Math.PI) / 180;
|
||||||
|
const degrees = (angle: number) => (angle * 180) / Math.PI;
|
||||||
|
const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
export const normalizeAngle = (angle: number) => ((((angle + 180) % 360) + 360) % 360) - 180;
|
||||||
|
export const signedArea = (points: Point[]) =>
|
||||||
|
points.reduce((sum, point, i) => {
|
||||||
|
const next = points[(i + 1) % points.length];
|
||||||
|
return sum + point.x * next.y - next.x * point.y;
|
||||||
|
}, 0) / 2;
|
||||||
|
|
||||||
|
function cross(a: Point, b: Point, c: Point) {
|
||||||
|
return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSegment(a: Point, b: Point, point: Point) {
|
||||||
|
return (
|
||||||
|
Math.abs(cross(a, b, point)) < EPS &&
|
||||||
|
point.x >= Math.min(a.x, b.x) - EPS &&
|
||||||
|
point.x <= Math.max(a.x, b.x) + EPS &&
|
||||||
|
point.y >= Math.min(a.y, b.y) - EPS &&
|
||||||
|
point.y <= Math.max(a.y, b.y) + EPS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersects(a: WallEdge, b: WallEdge) {
|
||||||
|
const ab1 = cross(a.start, a.end, b.start),
|
||||||
|
ab2 = cross(a.start, a.end, b.end);
|
||||||
|
const ba1 = cross(b.start, b.end, a.start),
|
||||||
|
ba2 = cross(b.start, b.end, a.end);
|
||||||
|
return (
|
||||||
|
(ab1 * ab2 < -EPS && ba1 * ba2 < -EPS) ||
|
||||||
|
onSegment(a.start, a.end, b.start) ||
|
||||||
|
onSegment(a.start, a.end, b.end) ||
|
||||||
|
onSegment(b.start, b.end, a.start) ||
|
||||||
|
onSegment(b.start, b.end, a.end)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Measured walls are kept intact until a small accepted closure residual is snapped. */
|
||||||
|
export function buildGeometry(room: Room, tolerance: number): Geometry {
|
||||||
|
const issues: Issue[] = [];
|
||||||
|
const edges: WallEdge[] = [];
|
||||||
|
const origin = { x: 0, y: 0 };
|
||||||
|
let position = origin;
|
||||||
|
let heading = 0;
|
||||||
|
const allowedGap =
|
||||||
|
Number.isFinite(tolerance) && tolerance >= 0 && tolerance <= 50 ? tolerance : 0;
|
||||||
|
if (!Number.isFinite(tolerance) || tolerance < 0 || tolerance > 50) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'error',
|
||||||
|
message: 'Measurement tolerance must be between 0 and 50 mm.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
room.walls.forEach((wall, index) => {
|
||||||
|
if (!Number.isFinite(wall.length) || wall.length <= 0 || wall.length > MAX_WALL) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'error',
|
||||||
|
message: `Wall ${index + 1} needs a positive length no greater than ${MAX_WALL} mm.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// The last stored turn only applies if another wall is subsequently added.
|
||||||
|
if (
|
||||||
|
index < room.walls.length - 1 &&
|
||||||
|
(!Number.isFinite(wall.turn) || Math.abs(wall.turn) >= 180)
|
||||||
|
) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'error',
|
||||||
|
message: `The turn after wall ${index + 1} must be between −180° and 180°.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const length =
|
||||||
|
Number.isFinite(wall.length) && wall.length > 0 && wall.length <= MAX_WALL ? wall.length : 0;
|
||||||
|
const end = {
|
||||||
|
x: position.x + length * Math.cos(radians(heading)),
|
||||||
|
y: position.y + length * Math.sin(radians(heading)),
|
||||||
|
};
|
||||||
|
edges.push({
|
||||||
|
wallId: wall.id,
|
||||||
|
index,
|
||||||
|
start: position,
|
||||||
|
end,
|
||||||
|
length,
|
||||||
|
heading,
|
||||||
|
turn: wall.turn,
|
||||||
|
doors: wall.doors,
|
||||||
|
});
|
||||||
|
position = end;
|
||||||
|
if (Number.isFinite(wall.turn)) heading = normalizeAngle(heading + wall.turn);
|
||||||
|
|
||||||
|
const doors = [...wall.doors].sort(
|
||||||
|
(a, b) => a.offset - b.offset || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
||||||
|
);
|
||||||
|
doors.forEach((door, doorIndex) => {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(door.offset) ||
|
||||||
|
!Number.isFinite(door.width) ||
|
||||||
|
door.offset < 0 ||
|
||||||
|
door.width <= 0 ||
|
||||||
|
door.offset + door.width > length + EPS
|
||||||
|
) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'error',
|
||||||
|
message: `Door ${doorIndex + 1} on wall ${index + 1} must fit within the wall, with a positive opening width.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
doorIndex > 0 &&
|
||||||
|
door.offset < doors[doorIndex - 1].offset + doors[doorIndex - 1].width - EPS
|
||||||
|
) {
|
||||||
|
issues.push({ severity: 'error', message: `Door openings overlap on wall ${index + 1}.` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const last = edges.at(-1);
|
||||||
|
const gap = distance(position, origin);
|
||||||
|
const closingLength = gap;
|
||||||
|
const closingTurn =
|
||||||
|
last && gap > EPS
|
||||||
|
? normalizeAngle(degrees(Math.atan2(-position.y, -position.x)) - last.heading)
|
||||||
|
: 0;
|
||||||
|
const requiredFinalLength = last ? distance(last.start, origin) : 0;
|
||||||
|
const finalLengthError = last ? last.length - requiredFinalLength : 0;
|
||||||
|
const finalAngleError =
|
||||||
|
last && requiredFinalLength > EPS
|
||||||
|
? normalizeAngle(last.heading - degrees(Math.atan2(-last.start.y, -last.start.x)))
|
||||||
|
: 0;
|
||||||
|
const closed = edges.length >= 3 && gap <= allowedGap + EPS;
|
||||||
|
|
||||||
|
if (edges.length < 3) {
|
||||||
|
issues.push({ severity: 'error', message: 'Add at least three walls to finish an outline.' });
|
||||||
|
} else if (!closed) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'error',
|
||||||
|
message: `The outline is open by ${gap.toFixed(1)} mm. Check lengths and turns, or add the closing wall.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (closed && last) {
|
||||||
|
if (gap > 0.05) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'warning',
|
||||||
|
message: `The outline misses by ${gap.toFixed(1)} mm, within your ${allowedGap} mm tolerance. The drawing snaps the final wall to the start. This may be measuring error or a non-straight wall; remeasure before cutting.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
last.end = origin;
|
||||||
|
last.length = distance(last.start, origin);
|
||||||
|
last.heading = degrees(Math.atan2(-last.start.y, -last.start.x));
|
||||||
|
// A snapped wall can make a previously valid opening fall outside its new length.
|
||||||
|
if (last.doors.some((door) => door.offset + door.width > last.length + EPS)) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'error',
|
||||||
|
message: 'A door on the final wall extends beyond its adjusted closing length.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < edges.length; i++) {
|
||||||
|
for (let j = i + 1; j < edges.length; j++) {
|
||||||
|
if (j === i + 1 || (closed && i === 0 && j === edges.length - 1)) continue;
|
||||||
|
if (intersects(edges[i], edges[j])) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'error',
|
||||||
|
message: `Walls ${i + 1} and ${j + 1} cross or touch. Check the wall order and turns.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const vertices = edges.map((edge) => edge.start);
|
||||||
|
if (!closed && last) vertices.push(last.end);
|
||||||
|
const area = closed ? Math.abs(signedArea(vertices)) : 0;
|
||||||
|
if (closed && area <= EPS)
|
||||||
|
issues.push({ severity: 'error', message: 'The outline must enclose an area.' });
|
||||||
|
return {
|
||||||
|
vertices,
|
||||||
|
edges,
|
||||||
|
area,
|
||||||
|
perimeter: edges.reduce((sum, edge) => sum + edge.length, 0),
|
||||||
|
gap,
|
||||||
|
closed,
|
||||||
|
valid: closed && !issues.some((issue) => issue.severity === 'error'),
|
||||||
|
issues,
|
||||||
|
closingLength,
|
||||||
|
closingTurn,
|
||||||
|
finalLengthError,
|
||||||
|
finalAngleError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Integer polygon offset handles concave recesses and narrow passages that split. */
|
||||||
|
export function insetPolygon(vertices: Point[], gap: number): Point[][] {
|
||||||
|
if (
|
||||||
|
vertices.length < 3 ||
|
||||||
|
!Number.isFinite(gap) ||
|
||||||
|
gap < 0 ||
|
||||||
|
gap > MAX_WALL ||
|
||||||
|
vertices.some(
|
||||||
|
(point) =>
|
||||||
|
!Number.isFinite(point.x) ||
|
||||||
|
!Number.isFinite(point.y) ||
|
||||||
|
Math.max(Math.abs(point.x), Math.abs(point.y)) > 1e9,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [];
|
||||||
|
const path = vertices.map((point) => ({
|
||||||
|
X: Math.round(point.x * SCALE),
|
||||||
|
Y: Math.round(point.y * SCALE),
|
||||||
|
}));
|
||||||
|
if (!ClipperLib.Clipper.Orientation(path)) path.reverse();
|
||||||
|
if (gap === 0) return [path.map((point) => ({ x: point.X / SCALE, y: point.Y / SCALE }))];
|
||||||
|
const offset = new ClipperLib.ClipperOffset(20, 0.25 * SCALE);
|
||||||
|
offset.AddPath(path, ClipperLib.JoinType.jtMiter, ClipperLib.EndType.etClosedPolygon);
|
||||||
|
const result: ClipperLib.Paths = [];
|
||||||
|
offset.Execute(result, -gap * SCALE);
|
||||||
|
return result
|
||||||
|
.filter((polygon) => Math.abs(ClipperLib.Clipper.Area(polygon)) > EPS)
|
||||||
|
.map((polygon) => {
|
||||||
|
if (!ClipperLib.Clipper.Orientation(polygon)) polygon.reverse();
|
||||||
|
return polygon.map((point) => ({ x: point.X / SCALE, y: point.Y / SCALE }));
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Math.abs(signedArea(b)) - Math.abs(signedArea(a)) || a[0].x - b[0].x || a[0].y - b[0].y,
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/lib/skirting-polygon.ts
Normal file
28
src/lib/skirting-polygon.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { Point, SkirtingPiece } from './types';
|
||||||
|
|
||||||
|
/** Place the cutting template against its wall, preserving mitres and butt setbacks. */
|
||||||
|
export function skirtingPolygon(
|
||||||
|
piece: SkirtingPiece,
|
||||||
|
thickness: number,
|
||||||
|
orientation: number,
|
||||||
|
): Point[] {
|
||||||
|
const dx = piece.end.x - piece.start.x;
|
||||||
|
const dy = piece.end.y - piece.start.y;
|
||||||
|
const backLength = Math.hypot(dx, dy);
|
||||||
|
if (backLength < 1e-6) return [];
|
||||||
|
const tangent = { x: dx / backLength, y: dy / backLength };
|
||||||
|
const side = orientation < 0 ? -1 : 1;
|
||||||
|
const inward = { x: -tangent.y * side, y: tangent.x * side };
|
||||||
|
const template = piece.template?.length
|
||||||
|
? piece.template
|
||||||
|
: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: backLength, y: 0 },
|
||||||
|
{ x: backLength, y: thickness },
|
||||||
|
{ x: 0, y: thickness },
|
||||||
|
];
|
||||||
|
return template.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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
521
src/lib/skirting.ts
Normal file
521
src/lib/skirting.ts
Normal file
|
|
@ -0,0 +1,521 @@
|
||||||
|
import { buildGeometry, normalizeAngle, signedArea } from './geometry';
|
||||||
|
import { isSkirtingCut } from './cut-list';
|
||||||
|
import type {
|
||||||
|
Issue,
|
||||||
|
Point,
|
||||||
|
Room,
|
||||||
|
SkirtingPiece,
|
||||||
|
SkirtingPlan,
|
||||||
|
SkirtingSettings,
|
||||||
|
SkirtingStock,
|
||||||
|
WallEdge,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const EPS = 1e-6;
|
||||||
|
const rad = (degrees: number) => (degrees * Math.PI) / 180;
|
||||||
|
type End = {
|
||||||
|
type: SkirtingPiece['leftType'];
|
||||||
|
angle: number;
|
||||||
|
bevel: number;
|
||||||
|
back: number;
|
||||||
|
front: number;
|
||||||
|
};
|
||||||
|
type Run = {
|
||||||
|
roomId: string;
|
||||||
|
prefix: string;
|
||||||
|
edge: WallEdge;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
left: End;
|
||||||
|
right: End;
|
||||||
|
thickness: number;
|
||||||
|
};
|
||||||
|
const square = (): End => ({ type: 'square', angle: 0, bevel: 0, back: 0, front: 0 });
|
||||||
|
|
||||||
|
function roomPrefix(index: number): string {
|
||||||
|
let value = index + 1,
|
||||||
|
result = '';
|
||||||
|
while (value > 0) {
|
||||||
|
value--;
|
||||||
|
result = String.fromCharCode(65 + (value % 26)) + result;
|
||||||
|
value = Math.floor(value / 26);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointAt(edge: WallEdge, offset: number): Point {
|
||||||
|
const ratio = offset / edge.length;
|
||||||
|
return {
|
||||||
|
x: edge.start.x + (edge.end.x - edge.start.x) * ratio,
|
||||||
|
y: edge.start.y + (edge.end.y - edge.start.y) * ratio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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();
|
||||||
|
if (turn < 0) {
|
||||||
|
const extension = thickness * Math.tan(rad(-turn / 2));
|
||||||
|
return {
|
||||||
|
type: 'external',
|
||||||
|
angle: -turn / 2,
|
||||||
|
bevel: side === 'left' ? turn / 2 : -turn / 2,
|
||||||
|
back: 0,
|
||||||
|
front: side === 'left' ? -extension : extension,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
back: 0,
|
||||||
|
front: thickness / Math.tan(rad(turn)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function endCuts(piece: SkirtingPiece) {
|
||||||
|
return Number(Math.abs(piece.leftMitre) > EPS) + Number(Math.abs(piece.rightMitre) > EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredCuts(piece: SkirtingPiece, available: number) {
|
||||||
|
return endCuts(piece) + Number(Math.abs(available - piece.length) > EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumedLength(piece: SkirtingPiece, available: number, kerf: number) {
|
||||||
|
// Cut a square blank, then bevel within its longest-point envelope. This leaves
|
||||||
|
// a square reusable offcut. Bevel kerfs remove material inside the blank.
|
||||||
|
return Math.min(available, piece.length + (Math.abs(available - piece.length) <= EPS ? 0 : kerf));
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePiece(
|
||||||
|
run: Run,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
first: boolean,
|
||||||
|
last: boolean,
|
||||||
|
): SkirtingPiece {
|
||||||
|
const left = first ? run.left : square(),
|
||||||
|
right = last ? run.right : square();
|
||||||
|
const backFrom = from + left.back,
|
||||||
|
backTo = to + right.back;
|
||||||
|
const frontFrom = from + left.front,
|
||||||
|
frontTo = to + right.front;
|
||||||
|
const origin = Math.min(backFrom, frontFrom);
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
roomId: run.roomId,
|
||||||
|
wallId: run.edge.wallId,
|
||||||
|
wallIndex: run.edge.index,
|
||||||
|
start: pointAt(run.edge, backFrom),
|
||||||
|
end: pointAt(run.edge, backTo),
|
||||||
|
backLength: backTo - backFrom,
|
||||||
|
length: Math.max(backTo, frontTo) - Math.min(backFrom, frontFrom),
|
||||||
|
leftMitre: left.angle,
|
||||||
|
rightMitre: right.angle,
|
||||||
|
leftType: left.type,
|
||||||
|
rightType: right.type,
|
||||||
|
stockId: '',
|
||||||
|
leftBevel: left.bevel,
|
||||||
|
rightBevel: right.bevel,
|
||||||
|
template: [
|
||||||
|
{ x: backFrom - origin, y: 0 },
|
||||||
|
{ x: backTo - origin, y: 0 },
|
||||||
|
{ x: frontTo - origin, y: run.thickness },
|
||||||
|
{ x: frontFrom - origin, y: run.thickness },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortestEdge(piece: SkirtingPiece) {
|
||||||
|
return Math.min(piece.backLength, piece.template![2].x - piece.template![3].x);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fits(piece: SkirtingPiece, available: number) {
|
||||||
|
// The final saw pass may extend beyond the stock's tail.
|
||||||
|
return piece.length <= available + EPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zero means that no number of pieces can satisfy the end allowances. */
|
||||||
|
function splitCount(run: Run, target: number, minimum: number): number {
|
||||||
|
const full = makePiece(run, run.from, run.to, true, true);
|
||||||
|
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);
|
||||||
|
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
|
||||||
|
)
|
||||||
|
return 0;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split along the wall while reserving both front and back corner allowances. */
|
||||||
|
function splitRun(
|
||||||
|
run: Run,
|
||||||
|
target: number,
|
||||||
|
settings: SkirtingSettings,
|
||||||
|
balanced: boolean,
|
||||||
|
): SkirtingPiece[] | null {
|
||||||
|
const count = splitCount(run, target, settings.minLength);
|
||||||
|
if (!count) return null;
|
||||||
|
if (count === 1) return [makePiece(run, run.from, run.to, true, true)];
|
||||||
|
|
||||||
|
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.
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
const maximums = Array.from(
|
||||||
|
{ length: count },
|
||||||
|
(_, index) =>
|
||||||
|
target +
|
||||||
|
(index === 0 ? Math.min(run.left.back, run.left.front) : 0) -
|
||||||
|
(index === count - 1 ? Math.max(run.right.back, run.right.front) : 0),
|
||||||
|
);
|
||||||
|
let extra = span - minimums.reduce((sum, value) => sum + value, 0);
|
||||||
|
|
||||||
|
const spans = [...minimums];
|
||||||
|
if (balanced) {
|
||||||
|
// Equal extra length, capped by each end's allowance; at most three
|
||||||
|
// distinct capacities exist (first, middle, last).
|
||||||
|
let active = spans.map((_, index) => index);
|
||||||
|
while (extra > EPS && active.length) {
|
||||||
|
const share = extra / active.length;
|
||||||
|
for (const index of active) {
|
||||||
|
const addition = Math.min(share, maximums[index] - spans[index]);
|
||||||
|
spans[index] += addition;
|
||||||
|
extra -= addition;
|
||||||
|
}
|
||||||
|
active = active.filter((index) => maximums[index] - spans[index] > EPS);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
const addition = Math.min(extra, maximums[index] - spans[index]);
|
||||||
|
spans[index] += addition;
|
||||||
|
extra -= addition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let from = run.from;
|
||||||
|
return 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Let useful tails set the next joint instead of fixing every wall's joints first. */
|
||||||
|
function splitWithOffcuts(
|
||||||
|
runs: Run[],
|
||||||
|
target: number,
|
||||||
|
settings: SkirtingSettings,
|
||||||
|
): SkirtingPiece[] | null {
|
||||||
|
const pieces: SkirtingPiece[] = [];
|
||||||
|
const offcuts: number[] = [];
|
||||||
|
for (const run of runs) {
|
||||||
|
if (!splitCount(run, target, settings.minLength)) return null;
|
||||||
|
let from = run.from;
|
||||||
|
while (from < run.to - EPS) {
|
||||||
|
const first = Math.abs(from - run.from) < EPS;
|
||||||
|
const remaining = { ...run, from, left: first ? run.left : square() };
|
||||||
|
const full = makePiece(remaining, from, run.to, true, true);
|
||||||
|
const take = (available: number) => {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
if (to <= from + EPS) return null;
|
||||||
|
const piece = makePiece(remaining, from, to, true, false);
|
||||||
|
if (shortestEdge(piece) < settings.minLength - EPS || !fits(piece, available)) return null;
|
||||||
|
// Reusing a tail must still leave a feasible remainder at the far corner.
|
||||||
|
return splitCount({ ...run, from: to, left: square() }, target, settings.minLength)
|
||||||
|
? { piece, to }
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
// Finish from the smallest fitting offcut; otherwise use the largest
|
||||||
|
// usable tail to avoid adding unnecessary joints.
|
||||||
|
offcuts.sort((a, b) => {
|
||||||
|
const aFits = fits(full, a),
|
||||||
|
bFits = fits(full, b);
|
||||||
|
return aFits !== bFits ? Number(bFits) - Number(aFits) : aFits ? a - b : b - a;
|
||||||
|
});
|
||||||
|
let next: { piece: SkirtingPiece; to: number } | null = null;
|
||||||
|
let available = target;
|
||||||
|
for (let index = 0; index < offcuts.length; index++) {
|
||||||
|
next = take(offcuts[index]);
|
||||||
|
if (next) {
|
||||||
|
available = offcuts.splice(index, 1)[0];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next ??= take(target);
|
||||||
|
if (!next || pieces.length >= 10000) return null;
|
||||||
|
pieces.push(next.piece);
|
||||||
|
const tail = available - consumedLength(next.piece, available, settings.kerf);
|
||||||
|
if (tail >= settings.minLength - EPS) offcuts.push(tail);
|
||||||
|
from = next.to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pieces;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Packed {
|
||||||
|
pieces: SkirtingPiece[];
|
||||||
|
stocks: SkirtingStock[];
|
||||||
|
cuts: number;
|
||||||
|
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 {
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
const assigned: SkirtingPiece[] = [];
|
||||||
|
for (const piece of pending) {
|
||||||
|
if (fits(piece, available)) {
|
||||||
|
const pieceCuts = 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.');
|
||||||
|
const id = `S${stocks.length + 1}`;
|
||||||
|
best.assigned.forEach((piece) => {
|
||||||
|
piece.stockId = id;
|
||||||
|
});
|
||||||
|
stocks.push({
|
||||||
|
id,
|
||||||
|
length: best.length,
|
||||||
|
used: best.used,
|
||||||
|
waste: Math.max(0, best.waste),
|
||||||
|
pieceIds: best.assigned.map((piece) => piece.id),
|
||||||
|
});
|
||||||
|
const assignedIds = new Set(best.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) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planSkirting(
|
||||||
|
rooms: Room[],
|
||||||
|
settings: SkirtingSettings,
|
||||||
|
tolerance: number,
|
||||||
|
): SkirtingPlan {
|
||||||
|
const issues: Issue[] = [];
|
||||||
|
const empty = (): SkirtingPlan => ({
|
||||||
|
pieces: [],
|
||||||
|
stocks: [],
|
||||||
|
stockCount: 0,
|
||||||
|
cuts: 0,
|
||||||
|
waste: 0,
|
||||||
|
issues,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!settings.stockLengths.length ||
|
||||||
|
settings.stockLengths.some(
|
||||||
|
(length) => !Number.isFinite(length) || length < 0.1 || length > 1e6,
|
||||||
|
) ||
|
||||||
|
!Number.isFinite(settings.thickness) ||
|
||||||
|
settings.thickness < 0 ||
|
||||||
|
settings.thickness > 10000 ||
|
||||||
|
!Number.isFinite(settings.minLength) ||
|
||||||
|
settings.minLength < 0.1 ||
|
||||||
|
settings.minLength > 1e6 ||
|
||||||
|
!Number.isFinite(settings.kerf) ||
|
||||||
|
settings.kerf < 0 ||
|
||||||
|
settings.kerf > 10000
|
||||||
|
) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'error',
|
||||||
|
message:
|
||||||
|
'Use skirting stock and minimum piece lengths between 0.1 and 1,000,000 mm, with thickness and saw kerf between 0 and 10,000 mm.',
|
||||||
|
});
|
||||||
|
return empty();
|
||||||
|
}
|
||||||
|
const stockLengths = [...new Set(settings.stockLengths)].sort((a, b) => a - b);
|
||||||
|
let runs: Run[] = [];
|
||||||
|
rooms.forEach((room, roomIndex) => {
|
||||||
|
const geometry = buildGeometry(room, tolerance);
|
||||||
|
if (!geometry.valid) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'error',
|
||||||
|
roomId: room.id,
|
||||||
|
message: `${room.name}: finish a valid outline and door measurements before planning skirting.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const orientation = Math.sign(signedArea(geometry.vertices));
|
||||||
|
geometry.edges.forEach((edge, index, edges) => {
|
||||||
|
const previous = edges[(index + edges.length - 1) % edges.length],
|
||||||
|
next = edges[(index + 1) % edges.length];
|
||||||
|
const previousHasEnd = !previous.doors.some(
|
||||||
|
(door) => door.offset + door.width >= previous.length - EPS,
|
||||||
|
);
|
||||||
|
const nextHasStart = !next.doors.some((door) => door.offset <= EPS);
|
||||||
|
const left = previousHasEnd
|
||||||
|
? corner(
|
||||||
|
normalizeAngle(edge.heading - previous.heading) * orientation,
|
||||||
|
settings.thickness,
|
||||||
|
'left',
|
||||||
|
)
|
||||||
|
: square();
|
||||||
|
const right = nextHasStart
|
||||||
|
? corner(
|
||||||
|
normalizeAngle(next.heading - edge.heading) * orientation,
|
||||||
|
settings.thickness,
|
||||||
|
'right',
|
||||||
|
)
|
||||||
|
: square();
|
||||||
|
const doors = [...edge.doors].sort((a, b) => a.offset - b.offset);
|
||||||
|
let from = 0;
|
||||||
|
for (const door of [...doors, { id: 'end', offset: edge.length, width: 0 }]) {
|
||||||
|
if (door.offset > from + EPS) {
|
||||||
|
const run = {
|
||||||
|
roomId: room.id,
|
||||||
|
prefix: roomPrefix(roomIndex),
|
||||||
|
edge,
|
||||||
|
from,
|
||||||
|
to: door.offset,
|
||||||
|
thickness: settings.thickness,
|
||||||
|
left: from < EPS ? left : square(),
|
||||||
|
right: door.offset >= edge.length - EPS ? right : square(),
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
shortestEdge(makePiece(run, from, door.offset, true, true)) <
|
||||||
|
settings.minLength - EPS
|
||||||
|
) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'error',
|
||||||
|
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)) {
|
||||||
|
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.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
runs.push(run);
|
||||||
|
}
|
||||||
|
from = door.offset + door.width;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// An unfinished or infeasible room must not erase another room's valid plan.
|
||||||
|
const invalidRooms = new Set(
|
||||||
|
issues.filter((issue) => issue.severity === 'error').map((issue) => issue.roomId),
|
||||||
|
);
|
||||||
|
runs = runs.filter((run) => !invalidRooms.has(run.roomId));
|
||||||
|
if (!runs.length) return empty();
|
||||||
|
|
||||||
|
const candidates: Packed[] = [];
|
||||||
|
const prefixes = new Map(runs.map((run) => [run.roomId, run.prefix]));
|
||||||
|
const addCandidate = (source: SkirtingPiece[]) => {
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
// Compare full-stock, balanced, and offcut-led joints for each stock length.
|
||||||
|
for (const target of stockLengths) {
|
||||||
|
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 reused = splitWithOffcuts(runs, target, settings);
|
||||||
|
if (reused) addCandidate(reused);
|
||||||
|
}
|
||||||
|
candidates.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(Math.abs(a.waste - b.waste) > EPS ? a.waste - b.waste : 0) ||
|
||||||
|
a.cuts - b.cuts ||
|
||||||
|
a.stocks.length - b.stocks.length ||
|
||||||
|
a.pieces.length - b.pieces.length,
|
||||||
|
);
|
||||||
|
const best = candidates[0];
|
||||||
|
if (!best) {
|
||||||
|
issues.push({
|
||||||
|
severity: 'error',
|
||||||
|
message:
|
||||||
|
'The stock lengths cannot fit the minimum skirting pieces, corner allowances and saw kerf. Increase stock length or reduce the minimum.',
|
||||||
|
});
|
||||||
|
return empty();
|
||||||
|
}
|
||||||
|
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.',
|
||||||
|
});
|
||||||
|
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.',
|
||||||
|
});
|
||||||
|
const plan = { ...best, stockCount: best.stocks.length, issues };
|
||||||
|
const cutNumbers = new Map<string, number>();
|
||||||
|
let wholeNumber = 0;
|
||||||
|
const codes = new Map(
|
||||||
|
plan.pieces.map((piece) => {
|
||||||
|
if (!isSkirtingCut(piece, plan)) return [piece.id, `SU${++wholeNumber}`];
|
||||||
|
const number = (cutNumbers.get(piece.roomId) ?? 0) + 1;
|
||||||
|
cutNumbers.set(piece.roomId, number);
|
||||||
|
return [piece.id, `${prefixes.get(piece.roomId)}${number}`];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const label = (id: string) => codes.get(id)!;
|
||||||
|
return {
|
||||||
|
...plan,
|
||||||
|
pieces: plan.pieces.map((piece) => ({ ...piece, id: label(piece.id) })),
|
||||||
|
stocks: plan.stocks.map((stock) => ({ ...stock, pieceIds: stock.pieceIds.map(label) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
163
src/lib/types.ts
Normal file
163
src/lib/types.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
export interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
export interface Door {
|
||||||
|
id: string;
|
||||||
|
offset: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
/** Wall direction starts east; turn is the signed turn AFTER this wall (+ = right). Millimetres throughout. */
|
||||||
|
export interface Wall {
|
||||||
|
id: string;
|
||||||
|
length: number;
|
||||||
|
turn: number;
|
||||||
|
doors: Door[];
|
||||||
|
}
|
||||||
|
export interface Room {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
walls: Wall[];
|
||||||
|
direction: 0 | 90;
|
||||||
|
}
|
||||||
|
export interface FlooringSettings {
|
||||||
|
length: number;
|
||||||
|
width: number;
|
||||||
|
gap: number;
|
||||||
|
pattern: 'brick' | 'random';
|
||||||
|
minOffset: number;
|
||||||
|
maxOffset: number;
|
||||||
|
minLength: number;
|
||||||
|
minWidth: number;
|
||||||
|
kerf: number;
|
||||||
|
}
|
||||||
|
export interface SkirtingSettings {
|
||||||
|
stockLengths: number[];
|
||||||
|
thickness: number;
|
||||||
|
minLength: number;
|
||||||
|
kerf: number;
|
||||||
|
}
|
||||||
|
export interface Project {
|
||||||
|
version: 1;
|
||||||
|
name: string;
|
||||||
|
rooms: Room[];
|
||||||
|
flooring: FlooringSettings;
|
||||||
|
skirting: SkirtingSettings;
|
||||||
|
tolerance: number;
|
||||||
|
}
|
||||||
|
export interface ViewState {
|
||||||
|
roomId: string;
|
||||||
|
selectedPiece: string | null;
|
||||||
|
}
|
||||||
|
export interface UrlState {
|
||||||
|
project: Project;
|
||||||
|
view: ViewState;
|
||||||
|
}
|
||||||
|
export interface Issue {
|
||||||
|
severity: 'error' | 'warning' | 'info';
|
||||||
|
message: string;
|
||||||
|
/** Omitted for project-wide material settings. */
|
||||||
|
roomId?: string;
|
||||||
|
}
|
||||||
|
export interface WallEdge {
|
||||||
|
wallId: string;
|
||||||
|
index: number;
|
||||||
|
start: Point;
|
||||||
|
end: Point;
|
||||||
|
length: number;
|
||||||
|
heading: number;
|
||||||
|
turn: number;
|
||||||
|
doors: Door[];
|
||||||
|
}
|
||||||
|
export interface Geometry {
|
||||||
|
vertices: Point[];
|
||||||
|
edges: WallEdge[];
|
||||||
|
area: number;
|
||||||
|
perimeter: number;
|
||||||
|
gap: number;
|
||||||
|
closed: boolean;
|
||||||
|
valid: boolean;
|
||||||
|
issues: Issue[];
|
||||||
|
closingLength: number;
|
||||||
|
closingTurn: number;
|
||||||
|
finalLengthError: number;
|
||||||
|
finalAngleError: number;
|
||||||
|
}
|
||||||
|
export interface FlooringPiece {
|
||||||
|
id: string;
|
||||||
|
roomId: string;
|
||||||
|
row: number;
|
||||||
|
column: number;
|
||||||
|
polygon: Point[];
|
||||||
|
length: number;
|
||||||
|
width: number;
|
||||||
|
stockId: string;
|
||||||
|
reused: boolean;
|
||||||
|
cut: boolean;
|
||||||
|
shaped: boolean;
|
||||||
|
/** Cutting template, relative to the bounding-box origin in board coordinates. */
|
||||||
|
template: Point[];
|
||||||
|
/** Ends that must retain factory joints; ripEdge names the board-axis long edge(s) to trim. */
|
||||||
|
factoryLeft?: boolean;
|
||||||
|
factoryRight?: boolean;
|
||||||
|
ripEdge?: 'top' | 'bottom' | 'both' | null;
|
||||||
|
}
|
||||||
|
export interface FlooringRow {
|
||||||
|
roomId: string;
|
||||||
|
index: number;
|
||||||
|
width: number;
|
||||||
|
offset: number;
|
||||||
|
pieceIds: string[];
|
||||||
|
}
|
||||||
|
export interface FlooringStock {
|
||||||
|
id: string;
|
||||||
|
pieceIds: string[];
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
export interface FlooringPlan {
|
||||||
|
pieces: FlooringPiece[];
|
||||||
|
rows: FlooringRow[];
|
||||||
|
stocks: FlooringStock[];
|
||||||
|
boardCount: number;
|
||||||
|
cuts: number;
|
||||||
|
wasteArea: number;
|
||||||
|
wastePercent: number;
|
||||||
|
reusedCount: number;
|
||||||
|
coveredArea: number;
|
||||||
|
issues: Issue[];
|
||||||
|
}
|
||||||
|
export interface SkirtingPiece {
|
||||||
|
id: string;
|
||||||
|
roomId: string;
|
||||||
|
wallId: string;
|
||||||
|
wallIndex: number;
|
||||||
|
start: Point;
|
||||||
|
end: Point;
|
||||||
|
length: number;
|
||||||
|
backLength: number;
|
||||||
|
leftMitre: number;
|
||||||
|
rightMitre: number;
|
||||||
|
leftType: 'square' | 'external' | 'internal';
|
||||||
|
rightType: 'square' | 'external' | 'internal';
|
||||||
|
stockId: string;
|
||||||
|
/** Plan section: x follows installation direction, back y=0, front y=thickness. */
|
||||||
|
template?: Point[];
|
||||||
|
/** Positive means the front cut point is farther right than the back point. */
|
||||||
|
leftBevel?: number;
|
||||||
|
rightBevel?: number;
|
||||||
|
}
|
||||||
|
export interface SkirtingStock {
|
||||||
|
id: string;
|
||||||
|
length: number;
|
||||||
|
used: number;
|
||||||
|
waste: number;
|
||||||
|
pieceIds: string[];
|
||||||
|
}
|
||||||
|
export interface SkirtingPlan {
|
||||||
|
pieces: SkirtingPiece[];
|
||||||
|
stocks: SkirtingStock[];
|
||||||
|
stockCount: number;
|
||||||
|
cuts: number;
|
||||||
|
waste: number;
|
||||||
|
issues: Issue[];
|
||||||
|
}
|
||||||
211
src/lib/url-state.ts
Normal file
211
src/lib/url-state.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
import LZString from 'lz-string';
|
||||||
|
import { defaultState } from './defaults';
|
||||||
|
import type { Door, FlooringSettings, Room, SkirtingSettings, UrlState, Wall } from './types';
|
||||||
|
|
||||||
|
/** Resource limits apply to links as well as edits made inside the app. */
|
||||||
|
export const URL_LIMITS = {
|
||||||
|
hashLength: 100_000,
|
||||||
|
jsonLength: 1_000_000,
|
||||||
|
rooms: 20,
|
||||||
|
walls: 100,
|
||||||
|
doors: 30,
|
||||||
|
stockLengths: 30,
|
||||||
|
identifierLength: 120,
|
||||||
|
nameLength: 200,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function object(value: unknown, field: string): Record<string, unknown> {
|
||||||
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||||
|
throw new Error(`${field} must be an object.`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function number(value: unknown, field: string): number {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
throw new Error(`${field} must be a finite number.`);
|
||||||
|
}
|
||||||
|
// Keep measurements as entered. Geometry and material validation explain
|
||||||
|
// impossible values instead of silently changing the user's measurements.
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function string(
|
||||||
|
value: unknown,
|
||||||
|
field: string,
|
||||||
|
maxLength: number = URL_LIMITS.identifierLength,
|
||||||
|
): string {
|
||||||
|
if (typeof value !== 'string' || value.length > maxLength) {
|
||||||
|
throw new Error(`${field} must be text of at most ${maxLength} characters.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function identifier(value: unknown, field: string): string {
|
||||||
|
const result = string(value, field);
|
||||||
|
if (!result) throw new Error(`${field} cannot be empty.`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function choice<T extends string | number>(
|
||||||
|
value: unknown,
|
||||||
|
choices: readonly T[],
|
||||||
|
field: string,
|
||||||
|
): T {
|
||||||
|
if (!choices.includes(value as T)) throw new Error(`${field} has an unsupported value.`);
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function array(value: unknown, field: string, maxLength: number): unknown[] {
|
||||||
|
if (!Array.isArray(value) || value.length > maxLength) {
|
||||||
|
throw new Error(`${field} must be a list with at most ${maxLength} entries.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueIds<T extends { id: string }>(items: T[], field: string): T[] {
|
||||||
|
if (new Set(items.map((item) => item.id)).size !== items.length) {
|
||||||
|
throw new Error(`${field} contains duplicate identifiers.`);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function door(value: unknown): Door {
|
||||||
|
const data = object(value, 'Door');
|
||||||
|
return {
|
||||||
|
id: identifier(data.id, 'Door identifier'),
|
||||||
|
offset: number(data.offset, 'Door offset'),
|
||||||
|
width: number(data.width, 'Door width'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wall(value: unknown): Wall {
|
||||||
|
const data = object(value, 'Wall');
|
||||||
|
return {
|
||||||
|
id: identifier(data.id, 'Wall identifier'),
|
||||||
|
length: number(data.length, 'Wall length'),
|
||||||
|
turn: number(data.turn, 'Wall turn'),
|
||||||
|
doors: uniqueIds(array(data.doors, 'Wall doors', URL_LIMITS.doors).map(door), 'Wall doors'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function room(value: unknown): Room {
|
||||||
|
const data = object(value, 'Room');
|
||||||
|
return {
|
||||||
|
id: identifier(data.id, 'Room identifier'),
|
||||||
|
name: string(data.name, 'Room name', URL_LIMITS.nameLength),
|
||||||
|
walls: uniqueIds(array(data.walls, 'Room walls', URL_LIMITS.walls).map(wall), 'Room walls'),
|
||||||
|
direction: choice(data.direction, [0, 90], 'Flooring direction'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function flooring(value: unknown): FlooringSettings {
|
||||||
|
const data = object(value, 'Flooring settings');
|
||||||
|
return {
|
||||||
|
length: number(data.length, 'Board length'),
|
||||||
|
width: number(data.width, 'Board width'),
|
||||||
|
gap: number(data.gap, 'Expansion gap'),
|
||||||
|
pattern: choice(data.pattern, ['brick', 'random'], 'Flooring pattern'),
|
||||||
|
minOffset: number(data.minOffset, 'Minimum offset'),
|
||||||
|
maxOffset: number(data.maxOffset, 'Maximum offset'),
|
||||||
|
minLength: number(data.minLength, 'Minimum board length'),
|
||||||
|
minWidth: number(data.minWidth, 'Minimum row width'),
|
||||||
|
kerf: number(data.kerf, 'Flooring saw kerf'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function skirting(value: unknown): SkirtingSettings {
|
||||||
|
const data = object(value, 'Skirting settings');
|
||||||
|
return {
|
||||||
|
stockLengths: array(data.stockLengths, 'Skirting stock lengths', URL_LIMITS.stockLengths).map(
|
||||||
|
(value) => number(value, 'Skirting stock length'),
|
||||||
|
),
|
||||||
|
thickness: number(data.thickness, 'Skirting thickness'),
|
||||||
|
minLength: number(data.minLength, 'Minimum skirting length'),
|
||||||
|
kerf: number(data.kerf, 'Skirting saw kerf'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Construct a fresh, canonical state containing only inputs and view choices. */
|
||||||
|
function parseState(value: unknown): UrlState {
|
||||||
|
const data = object(value, 'Plan');
|
||||||
|
const project = object(data.project, 'Project');
|
||||||
|
if (project.version !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
'This project version is unsupported. Open it in the app version that created it.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rooms = uniqueIds(
|
||||||
|
array(project.rooms, 'Project rooms', URL_LIMITS.rooms).map(room),
|
||||||
|
'Project rooms',
|
||||||
|
);
|
||||||
|
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');
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
version: 1,
|
||||||
|
name: string(project.name, 'Project name', URL_LIMITS.nameLength),
|
||||||
|
rooms,
|
||||||
|
flooring: flooring(project.flooring),
|
||||||
|
skirting: skirting(project.skirting),
|
||||||
|
tolerance: number(project.tolerance, 'Measurement tolerance'),
|
||||||
|
},
|
||||||
|
view: {
|
||||||
|
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
|
||||||
|
selectedPiece:
|
||||||
|
view.selectedPiece === null ? null : string(view.selectedPiece, 'Selected piece'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable URLs: canonical property order, no timestamps, random seeds or results. */
|
||||||
|
export function encodeState(state: UrlState): string {
|
||||||
|
const json = JSON.stringify(parseState(state));
|
||||||
|
if (json.length > URL_LIMITS.jsonLength)
|
||||||
|
throw new Error('This plan is too large for a link. Split it into smaller projects.');
|
||||||
|
const hash = `#plan=${LZString.compressToEncodedURIComponent(json)}`;
|
||||||
|
if (hash.length > URL_LIMITS.hashLength)
|
||||||
|
throw new Error('This plan link is too large. Split it into smaller projects.');
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Never throw for an untrusted link. Return a usable starter plan and a visible error. */
|
||||||
|
export function decodeState(hash: string): { state: UrlState; error: string | null } {
|
||||||
|
if (hash === '' || hash === '#') return { state: defaultState(), error: null };
|
||||||
|
try {
|
||||||
|
if (hash.length > URL_LIMITS.hashLength)
|
||||||
|
throw new Error('The link is too large. Use a smaller project link.');
|
||||||
|
if (!hash.startsWith('#plan='))
|
||||||
|
throw new Error('The link must start with #plan=. Copy the complete plan link.');
|
||||||
|
const compressed = decodeURIComponent(hash.slice(6));
|
||||||
|
if (!compressed || !/^[A-Za-z0-9+$-]+$/.test(compressed)) {
|
||||||
|
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
|
||||||
|
}
|
||||||
|
const json = LZString.decompressFromEncodedURIComponent(compressed);
|
||||||
|
if (!json)
|
||||||
|
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
|
||||||
|
if (json.length > URL_LIMITS.jsonLength)
|
||||||
|
throw new Error('The project in this link is too large. Use a smaller project link.');
|
||||||
|
// LZ decoding alone accepts ignored trailing characters. Require the exact
|
||||||
|
// encoding of the decoded text so a truncated or appended link is rejected.
|
||||||
|
if (LZString.compressToEncodedURIComponent(json) !== compressed) {
|
||||||
|
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
|
||||||
|
}
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
throw new Error('The project data is damaged. Copy the complete plan link again.');
|
||||||
|
}
|
||||||
|
return { state: parseState(value), error: null };
|
||||||
|
} catch (error) {
|
||||||
|
const detail =
|
||||||
|
error instanceof URIError
|
||||||
|
? 'The link has invalid URL encoding. Copy the complete plan link again.'
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Copy the complete plan link again.';
|
||||||
|
return { state: defaultState(), error: `Could not load this plan. ${detail}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/lib/use-project.ts
Normal file
38
src/lib/use-project.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { UrlState } from './types';
|
||||||
|
import { decodeState, encodeState } from './url-state';
|
||||||
|
|
||||||
|
/** The fragment is the durable source of truth. No local storage or server state. */
|
||||||
|
export function useProject() {
|
||||||
|
const [loaded, setLoaded] = useState(() => decodeState(window.location.hash));
|
||||||
|
useEffect(() => {
|
||||||
|
const restore = () => setLoaded(decodeState(window.location.hash));
|
||||||
|
window.addEventListener('hashchange', restore);
|
||||||
|
window.addEventListener('popstate', restore);
|
||||||
|
if (!window.location.hash) window.history.replaceState(null, '', encodeState(loaded.state));
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('hashchange', restore);
|
||||||
|
window.removeEventListener('popstate', restore);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const update = useCallback((transform: (current: UrlState) => UrlState, milestone = false) => {
|
||||||
|
setLoaded((current) => {
|
||||||
|
const next = transform(current.state);
|
||||||
|
try {
|
||||||
|
const hash = encodeState(next);
|
||||||
|
if (hash !== window.location.hash)
|
||||||
|
window.history[milestone ? 'pushState' : 'replaceState'](null, '', hash);
|
||||||
|
const canonical = decodeState(hash).state;
|
||||||
|
if (next.project === current.state.project) canonical.project = current.state.project;
|
||||||
|
return { state: canonical, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : 'This project could not be saved in the URL.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
return { ...loaded, update };
|
||||||
|
}
|
||||||
4
src/main.tsx
Normal file
4
src/main.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './styles.css';
|
||||||
|
createRoot(document.getElementById('root')!).render(<App />);
|
||||||
1611
src/styles.css
Normal file
1611
src/styles.css
Normal file
File diff suppressed because it is too large
Load diff
119
tests/cut-list.test.ts
Normal file
119
tests/cut-list.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { defaultProject } from '../src/lib/defaults';
|
||||||
|
import { flooringCuts, groupFlooringCuts } from '../src/lib/cut-list';
|
||||||
|
import { planFlooring } from '../src/lib/flooring';
|
||||||
|
import type { FlooringPiece, FlooringPlan } from '../src/lib/types';
|
||||||
|
|
||||||
|
function piece(id: string, changes: Partial<FlooringPiece> = {}): FlooringPiece {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
roomId: 'room-1',
|
||||||
|
row: 1,
|
||||||
|
column: 1,
|
||||||
|
polygon: [],
|
||||||
|
length: 600,
|
||||||
|
width: 190,
|
||||||
|
stockId: 'B1',
|
||||||
|
reused: false,
|
||||||
|
cut: true,
|
||||||
|
shaped: false,
|
||||||
|
template: [],
|
||||||
|
factoryLeft: true,
|
||||||
|
factoryRight: false,
|
||||||
|
ripEdge: null,
|
||||||
|
...changes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function plan(pieces: FlooringPiece[]): FlooringPlan {
|
||||||
|
return {
|
||||||
|
pieces,
|
||||||
|
rows: [],
|
||||||
|
stocks: [],
|
||||||
|
boardCount: 0,
|
||||||
|
cuts: 0,
|
||||||
|
wasteArea: 0,
|
||||||
|
wastePercent: 0,
|
||||||
|
reusedCount: 0,
|
||||||
|
coveredArea: 0,
|
||||||
|
issues: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('flooring cut batches', () => {
|
||||||
|
it('combines matching cuts across rows and stocks, preserving every placement exactly once', () => {
|
||||||
|
const pieces = [
|
||||||
|
piece('F1'),
|
||||||
|
piece('F2', { row: 4, stockId: 'B4', reused: true }),
|
||||||
|
piece('F3', { cut: false, length: 1200 }),
|
||||||
|
piece('F4', { cut: false, reused: true }),
|
||||||
|
piece('F5', { roomId: 'room-2' }),
|
||||||
|
];
|
||||||
|
const source = plan(pieces),
|
||||||
|
before = structuredClone(source);
|
||||||
|
const groups = groupFlooringCuts(source);
|
||||||
|
expect(groups.map((group) => group.map((p) => p.id))).toEqual([['F1', 'F2'], ['F4'], ['F5']]);
|
||||||
|
expect(
|
||||||
|
groups
|
||||||
|
.flat()
|
||||||
|
.map((p) => p.id)
|
||||||
|
.sort(),
|
||||||
|
).toEqual(
|
||||||
|
flooringCuts(source)
|
||||||
|
.map((p) => p.id)
|
||||||
|
.sort(),
|
||||||
|
);
|
||||||
|
expect(source).toEqual(before);
|
||||||
|
expect(groupFlooringCuts(source)).toEqual(groups);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps different factory ends, rip edges and thousandth-millimetre dimensions separate', () => {
|
||||||
|
const groups = groupFlooringCuts(
|
||||||
|
plan([
|
||||||
|
piece('F1'),
|
||||||
|
piece('F2', { factoryLeft: false, factoryRight: true }),
|
||||||
|
piece('F3', { ripEdge: 'top' }),
|
||||||
|
piece('F4', { ripEdge: 'bottom' }),
|
||||||
|
piece('F5', { length: 600.001 }),
|
||||||
|
piece('F6', { width: 190.001 }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(groups).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups shapes only when their complete measured templates match', () => {
|
||||||
|
const template = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 600, y: 0 },
|
||||||
|
{ x: 600, y: 190 },
|
||||||
|
{ x: 0, y: 100 },
|
||||||
|
];
|
||||||
|
const groups = groupFlooringCuts(
|
||||||
|
plan([
|
||||||
|
piece('F1', { shaped: true, template }),
|
||||||
|
piece('F2', { shaped: true, template: structuredClone(template) }),
|
||||||
|
piece('F3', { shaped: true, template: [...template.slice(0, 3), { x: 0, y: 100.001 }] }),
|
||||||
|
piece('F4'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(groups.map((group) => group.map((p) => p.id))).toEqual([['F1', 'F2'], ['F3'], ['F4']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reduces the default room to a few batches while keeping all cut and offcut codes', () => {
|
||||||
|
const project = defaultProject();
|
||||||
|
const floor = planFlooring(project.rooms, project.flooring, project.tolerance);
|
||||||
|
const groups = groupFlooringCuts(floor);
|
||||||
|
expect(groups.length).toBeLessThanOrEqual(7);
|
||||||
|
expect(groups.length).toBeLessThan(flooringCuts(floor).length / 2);
|
||||||
|
expect(
|
||||||
|
groups
|
||||||
|
.flat()
|
||||||
|
.map((p) => p.id)
|
||||||
|
.sort(),
|
||||||
|
).toEqual(
|
||||||
|
flooringCuts(floor)
|
||||||
|
.map((p) => p.id)
|
||||||
|
.sort(),
|
||||||
|
);
|
||||||
|
expect(new Set(groups.flat().map((p) => p.id)).size).toBe(flooringCuts(floor).length);
|
||||||
|
});
|
||||||
|
});
|
||||||
124
tests/cut-preview.test.ts
Normal file
124
tests/cut-preview.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { FlooringPreview, SkirtingPreview } from '../src/components/CutPreview';
|
||||||
|
import { exactMm, flooringTemplate, skirtingEndBoxes } from '../src/lib/cut-preview';
|
||||||
|
import type { FlooringPiece, Point, SkirtingPiece } from '../src/lib/types';
|
||||||
|
|
||||||
|
const floor: FlooringPiece = {
|
||||||
|
id: 'F1',
|
||||||
|
roomId: 'room-1',
|
||||||
|
row: 1,
|
||||||
|
column: 1,
|
||||||
|
polygon: [],
|
||||||
|
length: 600.001,
|
||||||
|
width: 190,
|
||||||
|
stockId: 'B1',
|
||||||
|
reused: false,
|
||||||
|
cut: true,
|
||||||
|
shaped: false,
|
||||||
|
template: [],
|
||||||
|
};
|
||||||
|
const template: Point[] = [
|
||||||
|
{ x: 18, y: 0 },
|
||||||
|
{ x: 1182, y: 0 },
|
||||||
|
{ x: 1200, y: 18 },
|
||||||
|
{ x: 0, y: 18 },
|
||||||
|
];
|
||||||
|
const skirt: SkirtingPiece = {
|
||||||
|
id: 'A1',
|
||||||
|
roomId: 'room-1',
|
||||||
|
wallId: 'w1',
|
||||||
|
wallIndex: 0,
|
||||||
|
start: { x: 0, y: 0 },
|
||||||
|
end: { x: 1164, y: 0 },
|
||||||
|
length: 1200,
|
||||||
|
backLength: 1164,
|
||||||
|
leftMitre: 45,
|
||||||
|
rightMitre: 45,
|
||||||
|
leftType: 'external',
|
||||||
|
rightType: 'external',
|
||||||
|
stockId: 'S1',
|
||||||
|
template,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('cut shape previews', () => {
|
||||||
|
it('draws rectangular floor cuts too, keeping their precise measured dimensions', () => {
|
||||||
|
const points = flooringTemplate(floor);
|
||||||
|
expect(points).toEqual([
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 600.001, y: 0 },
|
||||||
|
{ x: 600.001, y: 190 },
|
||||||
|
{ x: 0, y: 190 },
|
||||||
|
]);
|
||||||
|
const markup = renderToStaticMarkup(createElement(FlooringPreview, { piece: floor }));
|
||||||
|
expect(markup).toContain('aria-label="Flooring shape F1"');
|
||||||
|
expect(markup).toContain('preserveAspectRatio="xMidYMid meet"');
|
||||||
|
expect(markup).toContain('Length 600.001 mm × width 190 mm');
|
||||||
|
expect(exactMm(600.001)).toBe('600.001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps skirting endpoints in original coordinates and gives both ends equal square windows', () => {
|
||||||
|
const original = structuredClone(template);
|
||||||
|
const boxes = skirtingEndBoxes(template, 18);
|
||||||
|
expect(boxes.left.width).toBe(boxes.left.height);
|
||||||
|
expect(boxes.right.width).toBe(boxes.right.height);
|
||||||
|
expect(boxes.left.width).toBe(boxes.right.width);
|
||||||
|
// Equal x/y scales preserve the measured 18-by-18 mm, 45-degree cut.
|
||||||
|
const scaleX = 120 / boxes.left.width,
|
||||||
|
scaleY = 120 / boxes.left.height;
|
||||||
|
const angle =
|
||||||
|
(Math.atan2(
|
||||||
|
Math.abs(template[3].y - template[0].y) * scaleY,
|
||||||
|
Math.abs(template[3].x - template[0].x) * scaleX,
|
||||||
|
) *
|
||||||
|
180) /
|
||||||
|
Math.PI;
|
||||||
|
expect(angle).toBeCloseTo(45, 8);
|
||||||
|
expect(template).toEqual(original);
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
createElement(SkirtingPreview, { piece: skirt, thickness: 18 }),
|
||||||
|
);
|
||||||
|
expect(markup).toContain('points="18,0 1182,0 1200,18 0,18"');
|
||||||
|
expect(markup.match(/preserveAspectRatio="xMidYMid meet"/g)).toHaveLength(3);
|
||||||
|
expect(markup).toContain('Full length 1,200 mm × thickness 18 mm');
|
||||||
|
expect(markup).not.toContain('exaggerat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the complete end cut visible even at shallow mitre angles', () => {
|
||||||
|
const shallow = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 2000, y: 0 },
|
||||||
|
{ x: 2100, y: 18 },
|
||||||
|
{ x: 1000, y: 18 },
|
||||||
|
];
|
||||||
|
const boxes = skirtingEndBoxes(shallow, 18);
|
||||||
|
for (const [name, indices] of [
|
||||||
|
['left', [0, 3]],
|
||||||
|
['right', [1, 2]],
|
||||||
|
] as const) {
|
||||||
|
const box = boxes[name];
|
||||||
|
for (const index of indices) {
|
||||||
|
expect(shallow[index].x).toBeGreaterThan(box.x);
|
||||||
|
expect(shallow[index].x).toBeLessThan(box.x + box.width);
|
||||||
|
expect(shallow[index].y).toBeGreaterThan(box.y);
|
||||||
|
expect(shallow[index].y).toBeLessThan(box.y + box.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes thumbnails decorative while retaining true-scale SVGs and visible end breaks', () => {
|
||||||
|
const floorMarkup = renderToStaticMarkup(
|
||||||
|
createElement(FlooringPreview, { piece: floor, compact: true }),
|
||||||
|
);
|
||||||
|
const skirtMarkup = renderToStaticMarkup(
|
||||||
|
createElement(SkirtingPreview, { piece: skirt, thickness: 18, compact: true }),
|
||||||
|
);
|
||||||
|
expect(floorMarkup).toContain('aria-hidden="true"');
|
||||||
|
expect(skirtMarkup.match(/aria-hidden="true"/g)).toHaveLength(3);
|
||||||
|
expect(skirtMarkup).toContain('>//</span>');
|
||||||
|
expect(skirtMarkup.match(/preserveAspectRatio="xMidYMid meet"/g)).toHaveLength(2);
|
||||||
|
expect(floorMarkup).not.toContain('<figure');
|
||||||
|
expect(skirtMarkup).not.toContain('<figure');
|
||||||
|
});
|
||||||
|
});
|
||||||
21
tests/dimension-labels.test.ts
Normal file
21
tests/dimension-labels.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { fireplace } from '../src/lib/defaults';
|
||||||
|
import { buildGeometry } from '../src/lib/geometry';
|
||||||
|
import { dimensionLabels } from '../src/lib/dimension-labels';
|
||||||
|
describe('wall dimensions', () => {
|
||||||
|
it('keeps fireplace recess measurements apart without changing measured lengths', () => {
|
||||||
|
const geometry = buildGeometry(fireplace('room-1', 'Fireplace'), 10);
|
||||||
|
const labels = dimensionLabels(geometry.edges, 4800, 1);
|
||||||
|
expect(labels).toHaveLength(8);
|
||||||
|
expect(labels[3].text).toBe('W4 · 450');
|
||||||
|
for (let i = 0; i < labels.length; i++)
|
||||||
|
for (let j = i + 1; j < labels.length; j++) {
|
||||||
|
const a = labels[i].box,
|
||||||
|
b = labels[j].box;
|
||||||
|
expect(a.minX >= b.maxX || a.maxX <= b.minX || a.minY >= b.maxY || a.maxY <= b.minY).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(dimensionLabels(geometry.edges, 4800, 1)).toEqual(labels);
|
||||||
|
});
|
||||||
|
});
|
||||||
181
tests/export.test.ts
Normal file
181
tests/export.test.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { defaultProject } from '../src/lib/defaults';
|
||||||
|
import { cutListCsv } from '../src/lib/export';
|
||||||
|
import { flooringCuts, isSkirtingCut, uncutFlooring, uncutSkirting } from '../src/lib/cut-list';
|
||||||
|
import type { FlooringPlan, SkirtingPlan } from '../src/lib/types';
|
||||||
|
|
||||||
|
const floor: FlooringPlan = {
|
||||||
|
pieces: [
|
||||||
|
{
|
||||||
|
id: 'F1',
|
||||||
|
roomId: 'room-1',
|
||||||
|
row: 1,
|
||||||
|
column: 1,
|
||||||
|
polygon: [],
|
||||||
|
length: 450,
|
||||||
|
width: 190,
|
||||||
|
stockId: 'B1',
|
||||||
|
reused: true,
|
||||||
|
cut: true,
|
||||||
|
shaped: true,
|
||||||
|
template: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 450, y: 0 },
|
||||||
|
{ x: 450, y: 190 },
|
||||||
|
{ x: 0, y: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rows: [],
|
||||||
|
stocks: [{ id: 'B1', pieceIds: ['F1'], remaining: 747 }],
|
||||||
|
boardCount: 1,
|
||||||
|
cuts: 2,
|
||||||
|
wasteArea: 0,
|
||||||
|
wastePercent: 0,
|
||||||
|
reusedCount: 1,
|
||||||
|
coveredArea: 65250,
|
||||||
|
issues: [{ severity: 'warning', message: 'Check the shaped edge.' }],
|
||||||
|
};
|
||||||
|
const skirt: SkirtingPlan = {
|
||||||
|
pieces: [
|
||||||
|
{
|
||||||
|
id: 'A1',
|
||||||
|
roomId: 'room-1',
|
||||||
|
wallId: 'room-1-w1',
|
||||||
|
wallIndex: 0,
|
||||||
|
start: { x: 0, y: 0 },
|
||||||
|
end: { x: 1500, y: 0 },
|
||||||
|
length: 1518,
|
||||||
|
backLength: 1500,
|
||||||
|
leftMitre: 0,
|
||||||
|
rightMitre: 45,
|
||||||
|
leftType: 'square',
|
||||||
|
rightType: 'external',
|
||||||
|
stockId: 'S1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stocks: [{ id: 'S1', length: 2400, used: 1521, waste: 879, pieceIds: ['A1'] }],
|
||||||
|
stockCount: 1,
|
||||||
|
cuts: 1,
|
||||||
|
waste: 879,
|
||||||
|
issues: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('cut list CSV', () => {
|
||||||
|
it('exports measured cuts, shape coordinates, mitres, allocations and purchase counts', () => {
|
||||||
|
const csv = cutListCsv(defaultProject(), floor, skirt);
|
||||||
|
expect(csv.startsWith('\uFEFF"Material"')).toBe(true);
|
||||||
|
expect(csv).toContain('"Flooring","Living room","F1","B1","450","190"');
|
||||||
|
expect(csv).toContain('Reused offcut; Shaped cut');
|
||||||
|
expect(csv).toContain('0.0:0.0 | 450.0:0.0 | 450.0:190.0 | 0.0:100.0');
|
||||||
|
expect(csv).toContain('"Skirting","Living room","A1","S1","1518","18","0","45"');
|
||||||
|
expect(csv).toContain('Back length 1500.0 mm; left square; right external');
|
||||||
|
expect(csv).toContain('Remaining length 747.0 mm');
|
||||||
|
expect(csv).toContain('Waste including kerf 879.0 mm');
|
||||||
|
expect(csv).toContain('Boards to buy; 1 installed pieces; 2 saw cuts');
|
||||||
|
expect(csv).not.toContain('Check the shaped edge.');
|
||||||
|
expect(csv.split('\r\n')).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts uncut full boards and lengths without exporting their codes or source allocations', () => {
|
||||||
|
const project = defaultProject();
|
||||||
|
const wholeFloor = {
|
||||||
|
...floor.pieces[0],
|
||||||
|
id: 'FULL-F',
|
||||||
|
stockId: 'FULL-B',
|
||||||
|
length: 1200,
|
||||||
|
width: 190,
|
||||||
|
cut: false,
|
||||||
|
shaped: false,
|
||||||
|
reused: false,
|
||||||
|
};
|
||||||
|
const readyOffcut = {
|
||||||
|
...floor.pieces[0],
|
||||||
|
id: 'READY-F',
|
||||||
|
length: 747,
|
||||||
|
cut: false,
|
||||||
|
shaped: false,
|
||||||
|
};
|
||||||
|
const floorPlan: FlooringPlan = {
|
||||||
|
...floor,
|
||||||
|
boardCount: 2,
|
||||||
|
pieces: [...floor.pieces, wholeFloor, readyOffcut],
|
||||||
|
stocks: [
|
||||||
|
{ ...floor.stocks[0], pieceIds: ['F1', 'READY-F'], remaining: 0 },
|
||||||
|
{ id: 'FULL-B', pieceIds: ['FULL-F'], remaining: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const wholeSkirt = {
|
||||||
|
...skirt.pieces[0],
|
||||||
|
id: 'FULL-A',
|
||||||
|
stockId: 'FULL-S',
|
||||||
|
length: 2400,
|
||||||
|
backLength: 2400,
|
||||||
|
leftMitre: 0,
|
||||||
|
rightMitre: 0,
|
||||||
|
leftType: 'square' as const,
|
||||||
|
rightType: 'square' as const,
|
||||||
|
};
|
||||||
|
const skirtPlan: SkirtingPlan = {
|
||||||
|
...skirt,
|
||||||
|
stockCount: 2,
|
||||||
|
pieces: [...skirt.pieces, wholeSkirt],
|
||||||
|
stocks: [
|
||||||
|
...skirt.stocks,
|
||||||
|
{ id: 'FULL-S', length: 2400, used: 2400, waste: 0, pieceIds: ['FULL-A'] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const csv = cutListCsv(project, floorPlan, skirtPlan);
|
||||||
|
for (const code of ['FULL-F', 'FULL-B', 'FULL-A', 'FULL-S']) expect(csv).not.toContain(code);
|
||||||
|
expect(csv).toContain('"Flooring uncut boards","","","","1200","190","","","1"');
|
||||||
|
expect(csv).toContain('"Flooring ready offcuts","","","","","","","","1"');
|
||||||
|
expect(csv).toContain('"Skirting uncut lengths","","","","2400","18","","","1"');
|
||||||
|
expect(csv).toContain('"Flooring total","","","","1200","190","","","2"');
|
||||||
|
expect(csv).toContain('"Skirting total","","","","","18","","","2"');
|
||||||
|
expect(csv.split('\r\n').filter((row) => row.startsWith('"Flooring",'))).toHaveLength(2);
|
||||||
|
expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting",'))).toHaveLength(1);
|
||||||
|
expect(csv.split('\r\n').filter((row) => row.startsWith('"Flooring stock",'))).toHaveLength(1);
|
||||||
|
expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting stock",'))).toHaveLength(1);
|
||||||
|
expect(flooringCuts(floorPlan)).toEqual([...floor.pieces, readyOffcut]);
|
||||||
|
expect(csv).toContain('"Flooring","Living room","READY-F","B1","747","190"');
|
||||||
|
expect(csv).toContain('Reused offcut; Use offcut; no new cut');
|
||||||
|
expect(csv).toContain('"Flooring stock","","F1 / READY-F","B1"');
|
||||||
|
expect(uncutFlooring(floorPlan, project.flooring)).toEqual({ wholeBoards: 1, readyOffcuts: 1 });
|
||||||
|
expect(uncutSkirting(skirtPlan)).toEqual([{ length: 2400, count: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps full-length mitred pieces and shortened square skirting in the cut list', () => {
|
||||||
|
const mitred = { ...skirt.pieces[0], length: 2400 };
|
||||||
|
const shortened = { ...skirt.pieces[0], leftMitre: 0, rightMitre: 0 };
|
||||||
|
const uncut = { ...shortened, length: 2400 };
|
||||||
|
expect(isSkirtingCut(mitred, skirt)).toBe(true);
|
||||||
|
expect(isSkirtingCut(shortened, skirt)).toBe(true);
|
||||||
|
expect(isSkirtingCut(uncut, skirt)).toBe(false);
|
||||||
|
expect(isSkirtingCut({ ...uncut, stockId: 'missing' }, skirt)).toBe(true);
|
||||||
|
const csv = cutListCsv(defaultProject(), floor, { ...skirt, pieces: [mitred] });
|
||||||
|
expect(csv).toContain('"Skirting","Living room","A1","S1","2400","18","0","45"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quotes commas, double quotes, newlines and Unicode room names', () => {
|
||||||
|
const project = defaultProject();
|
||||||
|
project.rooms[0].name = 'Café 北, "main"\nroom';
|
||||||
|
const csv = cutListCsv(project, floor, skirt);
|
||||||
|
expect(csv).toContain('"Café 北, ""main""\nroom"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['=SUM(1,1)', '+cmd', '-formula', '@formula'])(
|
||||||
|
'neutralizes spreadsheet formulas in user text %s',
|
||||||
|
(name) => {
|
||||||
|
const project = defaultProject();
|
||||||
|
project.rooms[0].name = name;
|
||||||
|
expect(cutListCsv(project, floor, skirt)).toContain(`"'${name}"`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('produces identical output for identical input without mutating the plans', () => {
|
||||||
|
const project = defaultProject();
|
||||||
|
const before = structuredClone({ project, floor, skirt });
|
||||||
|
expect(cutListCsv(project, floor, skirt)).toBe(cutListCsv(project, floor, skirt));
|
||||||
|
expect({ project, floor, skirt }).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
297
tests/flooring.test.ts
Normal file
297
tests/flooring.test.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import ClipperLib from 'clipper-lib';
|
||||||
|
import { planFlooring } from '../src/lib/flooring';
|
||||||
|
import { buildGeometry, insetPolygon, signedArea } from '../src/lib/geometry';
|
||||||
|
import { fireplace, rectangle } from '../src/lib/defaults';
|
||||||
|
import { flooringCuts } from '../src/lib/cut-list';
|
||||||
|
import type { FlooringPlan, FlooringSettings, Point, Room } from '../src/lib/types';
|
||||||
|
|
||||||
|
const settings: FlooringSettings = {
|
||||||
|
length: 1200,
|
||||||
|
width: 200,
|
||||||
|
gap: 10,
|
||||||
|
pattern: 'brick',
|
||||||
|
minOffset: 300,
|
||||||
|
maxOffset: 600,
|
||||||
|
minLength: 200,
|
||||||
|
minWidth: 60,
|
||||||
|
kerf: 3,
|
||||||
|
};
|
||||||
|
function room(width = 4020, height = 2220, id = '1'): Room {
|
||||||
|
const result = rectangle(id, `Room ${id}`, width, height);
|
||||||
|
result.walls.forEach((wall) => {
|
||||||
|
wall.doors = [];
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function errors(plan: FlooringPlan) {
|
||||||
|
return plan.issues.filter((issue) => issue.severity === 'error');
|
||||||
|
}
|
||||||
|
function overlap(a: Point[], b: Point[]): number {
|
||||||
|
const clipper = new ClipperLib.Clipper();
|
||||||
|
const path = (points: Point[]) =>
|
||||||
|
points.map((p) => ({ X: Math.round(p.x * 1000), Y: Math.round(p.y * 1000) }));
|
||||||
|
clipper.AddPath(path(a), ClipperLib.PolyType.ptSubject, true);
|
||||||
|
clipper.AddPath(path(b), ClipperLib.PolyType.ptClip, true);
|
||||||
|
const result: ClipperLib.Paths = [];
|
||||||
|
clipper.Execute(ClipperLib.ClipType.ctIntersection, result);
|
||||||
|
return result.reduce((sum, polygon) => sum + Math.abs(ClipperLib.Clipper.Area(polygon)) / 1e6, 0);
|
||||||
|
}
|
||||||
|
function expectCoverage(plan: FlooringPlan, r: Room, gap: number) {
|
||||||
|
const inset = insetPolygon(buildGeometry(r, 1).vertices, gap);
|
||||||
|
const expectedArea = inset.reduce((sum, p) => sum + Math.abs(signedArea(p)), 0);
|
||||||
|
const pieces = plan.pieces.filter((p) => p.roomId === r.id);
|
||||||
|
// Clipping rounds intersections to 0.001 mm; permit less than 1 mm² area drift.
|
||||||
|
expect(
|
||||||
|
Math.abs(pieces.reduce((sum, p) => sum + Math.abs(signedArea(p.polygon)), 0) - expectedArea),
|
||||||
|
).toBeLessThan(1);
|
||||||
|
for (let i = 0; i < pieces.length; i++) {
|
||||||
|
expect(
|
||||||
|
Math.abs(
|
||||||
|
inset.reduce((sum, p) => sum + overlap(pieces[i].polygon, p), 0) -
|
||||||
|
Math.abs(signedArea(pieces[i].polygon)),
|
||||||
|
),
|
||||||
|
).toBeLessThan(1);
|
||||||
|
for (let j = i + 1; j < pieces.length; j++)
|
||||||
|
expect(overlap(pieces[i].polygon, pieces[j].polygon)).toBeLessThan(0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function expectStockFits(plan: FlooringPlan, s: FlooringSettings) {
|
||||||
|
const ids = plan.pieces.map((piece) => piece.id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
expect(plan.stocks.flatMap((stock) => stock.pieceIds).sort()).toEqual([...ids].sort());
|
||||||
|
for (const stock of plan.stocks) {
|
||||||
|
const pieces = stock.pieceIds.map((id) => plan.pieces.find((piece) => piece.id === id)!);
|
||||||
|
const used = pieces.reduce((sum, piece) => sum + piece.length, 0);
|
||||||
|
expect(used + Math.max(0, pieces.length - 1) * s.kerf).toBeLessThanOrEqual(s.length + 0.02);
|
||||||
|
expect(stock.remaining).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(used + stock.remaining).toBeLessThanOrEqual(s.length + 0.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function expectOffsets(plan: FlooringPlan, s: FlooringSettings) {
|
||||||
|
for (let i = 1; i < plan.rows.length; i++) {
|
||||||
|
const a = plan.rows[i - 1],
|
||||||
|
b = plan.rows[i];
|
||||||
|
if (a.roomId !== b.roomId) continue;
|
||||||
|
const difference = Math.abs(a.offset - b.offset);
|
||||||
|
const distance = Math.min(difference, s.length - difference);
|
||||||
|
expect(distance).toBeGreaterThanOrEqual(s.minOffset - 0.01);
|
||||||
|
expect(distance).toBeLessThanOrEqual(s.maxOffset + 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('laminate planning', () => {
|
||||||
|
it('covers a rectangle exactly, without overlap, and stays inside its expansion gap', () => {
|
||||||
|
const r = room();
|
||||||
|
const plan = planFlooring([r], settings, 1);
|
||||||
|
expect(errors(plan)).toEqual([]);
|
||||||
|
expect(plan.coveredArea).toBe(4000 * 2200);
|
||||||
|
expectCoverage(plan, r, settings.gap);
|
||||||
|
expect(
|
||||||
|
plan.pieces.every((p) => p.length >= settings.minLength && p.width >= settings.minWidth),
|
||||||
|
).toBe(true);
|
||||||
|
expectOffsets(plan, settings);
|
||||||
|
expectStockFits(plan, settings);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits a narrow remainder between first and last rows', () => {
|
||||||
|
const plan = planFlooring([room(4020, 2040)], settings, 1);
|
||||||
|
expect(errors(plan)).toEqual([]);
|
||||||
|
expect(plan.rows).toHaveLength(11);
|
||||||
|
expect(plan.rows[0].width).toBe(110);
|
||||||
|
expect(plan.rows.at(-1)?.width).toBe(110);
|
||||||
|
expect(plan.rows.slice(1, -1).every((row) => row.width === 200)).toBe(true);
|
||||||
|
expect(
|
||||||
|
plan.pieces.filter((piece) => piece.row === 1).every((piece) => piece.ripEdge === 'top'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
plan.pieces.filter((piece) => piece.row === 11).every((piece) => piece.ripEdge === 'bottom'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects impossible edge widths instead of ripping factory joints off middle rows', () => {
|
||||||
|
const plan = planFlooring([room(4020, 450)], { ...settings, minWidth: 120 }, 1);
|
||||||
|
expect(plan.boardCount).toBe(0);
|
||||||
|
expect(errors(plan)[0].message).toContain('minimum row width');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses offcuts without double counting stock or ignoring saw kerf', () => {
|
||||||
|
const r = room(4020, 2020);
|
||||||
|
const plan = planFlooring([r], settings, 1);
|
||||||
|
expect(errors(plan)).toEqual([]);
|
||||||
|
expect(plan.reusedCount).toBeGreaterThan(0);
|
||||||
|
expectStockFits(plan, settings);
|
||||||
|
// A 600 mm starter and 600 mm finisher cannot share a 1200 mm board with a 3 mm kerf.
|
||||||
|
for (const stock of plan.stocks) {
|
||||||
|
const lengths = stock.pieceIds.map((id) => plan.pieces.find((p) => p.id === id)!.length);
|
||||||
|
expect(lengths.filter((length) => Math.abs(length - 600) < 0.01).length).toBeLessThan(2);
|
||||||
|
}
|
||||||
|
expect(plan.boardCount).toBeGreaterThanOrEqual(
|
||||||
|
Math.ceil(plan.coveredArea / (settings.length * settings.width)),
|
||||||
|
);
|
||||||
|
expect(plan.wasteArea).toBeCloseTo(
|
||||||
|
plan.boardCount * settings.length * settings.width - plan.coveredArea,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reuse a cut end where an interior board needs two factory ends', () => {
|
||||||
|
const plan = planFlooring([room()], settings, 1);
|
||||||
|
for (const row of plan.rows) {
|
||||||
|
const pieces = row.pieceIds
|
||||||
|
.map((id) => plan.pieces.find((piece) => piece.id === id)!)
|
||||||
|
.sort((a, b) => a.column - b.column);
|
||||||
|
for (const interior of pieces.slice(1, -1)) {
|
||||||
|
expect(interior.length).toBe(settings.length);
|
||||||
|
expect(interior.reused).toBe(false);
|
||||||
|
expect(interior.factoryLeft).toBe(true);
|
||||||
|
expect(interior.factoryRight).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces exact concave fireplace templates and keeps the gap at its external corners', () => {
|
||||||
|
const r = fireplace('fire', 'Fireplace room');
|
||||||
|
const plan = planFlooring([r], { ...settings, width: 190 }, 1);
|
||||||
|
expect(errors(plan)).toEqual([]);
|
||||||
|
expectCoverage(plan, r, settings.gap);
|
||||||
|
expect(plan.pieces.some((piece) => piece.shaped)).toBe(true);
|
||||||
|
for (const piece of plan.pieces) {
|
||||||
|
expect(
|
||||||
|
piece.template.every(
|
||||||
|
(p) =>
|
||||||
|
p.x >= -0.01 && p.y >= -0.01 && p.x <= piece.length + 0.01 && p.y <= piece.width + 0.01,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
expectStockFits(plan, { ...settings, width: 190 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clips an angled wall exactly and provides shaped cut coordinates', () => {
|
||||||
|
const points = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 4000, y: 0 },
|
||||||
|
{ x: 4200, y: 2200 },
|
||||||
|
{ x: 0, y: 2200 },
|
||||||
|
];
|
||||||
|
const headings = points.map(
|
||||||
|
(p, i) =>
|
||||||
|
(Math.atan2(
|
||||||
|
points[(i + 1) % points.length].y - p.y,
|
||||||
|
points[(i + 1) % points.length].x - p.x,
|
||||||
|
) *
|
||||||
|
180) /
|
||||||
|
Math.PI,
|
||||||
|
);
|
||||||
|
const r: Room = {
|
||||||
|
id: 'angled',
|
||||||
|
name: 'Angled room',
|
||||||
|
direction: 0,
|
||||||
|
walls: points.map((p, i) => ({
|
||||||
|
id: `w${i}`,
|
||||||
|
length: Math.hypot(
|
||||||
|
points[(i + 1) % points.length].x - p.x,
|
||||||
|
points[(i + 1) % points.length].y - p.y,
|
||||||
|
),
|
||||||
|
turn: ((headings[(i + 1) % points.length] - headings[i] + 540) % 360) - 180,
|
||||||
|
doors: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const plan = planFlooring([r], settings, 1);
|
||||||
|
expect(errors(plan)).toEqual([]);
|
||||||
|
expectCoverage(plan, r, settings.gap);
|
||||||
|
expect(plan.pieces.filter((piece) => piece.shaped).length).toBeGreaterThan(2);
|
||||||
|
expectStockFits(plan, settings);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives identical random plans for identical input and respects both stagger limits', () => {
|
||||||
|
const s = { ...settings, pattern: 'random' as const, minOffset: 250, maxOffset: 450 };
|
||||||
|
const a = planFlooring([room()], s, 1);
|
||||||
|
expect(errors(a)).toEqual([]);
|
||||||
|
expect(a).toEqual(planFlooring([room()], s, 1));
|
||||||
|
expect(new Set(a.rows.map((row) => row.offset)).size).toBeGreaterThan(2);
|
||||||
|
expectOffsets(a, s);
|
||||||
|
expectStockFits(a, s);
|
||||||
|
expectCoverage(a, room(), s.gap);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects impossible brick stagger constraints', () => {
|
||||||
|
const plan = planFlooring([room()], { ...settings, maxOffset: 400 }, 1);
|
||||||
|
expect(plan.boardCount).toBe(0);
|
||||||
|
expect(errors(plan)[0].message).toContain('half-board');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plans each room independently and can reuse compatible end offcuts across directions', () => {
|
||||||
|
const a = room(1820, 1220, '1');
|
||||||
|
const b = room(1220, 1820, '2');
|
||||||
|
b.direction = 90;
|
||||||
|
const combined = planFlooring([a, b], settings, 1);
|
||||||
|
const separately =
|
||||||
|
planFlooring([a], settings, 1).boardCount + planFlooring([b], settings, 1).boardCount;
|
||||||
|
expect(errors(combined)).toEqual([]);
|
||||||
|
expect(combined.boardCount).toBeLessThanOrEqual(separately);
|
||||||
|
expect(
|
||||||
|
combined.stocks.some(
|
||||||
|
(stock) =>
|
||||||
|
new Set(stock.pieceIds.map((id) => combined.pieces.find((p) => p.id === id)!.roomId))
|
||||||
|
.size > 1,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expectCoverage(combined, a, settings.gap);
|
||||||
|
expectCoverage(combined, b, settings.gap);
|
||||||
|
expectStockFits(combined, settings);
|
||||||
|
expectOffsets(combined, settings);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an unclosed room and never counts a partial floor', () => {
|
||||||
|
const r = room();
|
||||||
|
r.walls[0].length += 100;
|
||||||
|
const plan = planFlooring([r], settings, 1);
|
||||||
|
expect(plan.boardCount).toBe(0);
|
||||||
|
expect(plan.pieces).toEqual([]);
|
||||||
|
expect(errors(plan)[0].message).toContain('closed outline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects zero and nonfinite settings without unbounded loops', () => {
|
||||||
|
expect(errors(planFlooring([room()], { ...settings, width: 0 }, 1))).toHaveLength(1);
|
||||||
|
const nonfinite = planFlooring([room()], { ...settings, length: NaN }, 1);
|
||||||
|
expect(errors(nonfinite)).toHaveLength(1);
|
||||||
|
expect(nonfinite.wasteArea).toBe(0);
|
||||||
|
expect(errors(planFlooring([room()], { ...settings, length: 1e308 }, 1))).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
errors(planFlooring([room()], { ...settings, width: 0.001, minWidth: 0.001 }, 1)),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('numbers cuts and ready offcuts continuously while preserving all row and stock references', () => {
|
||||||
|
const s = { ...settings, pattern: 'random' as const, minOffset: 250, maxOffset: 450 };
|
||||||
|
const plan = planFlooring([room()], s, 1);
|
||||||
|
const cuts = flooringCuts(plan);
|
||||||
|
expect(plan.pieces.length).toBeGreaterThan(cuts.length);
|
||||||
|
expect(cuts.some((piece) => piece.reused && !piece.cut)).toBe(true);
|
||||||
|
expect(cuts.map((piece) => piece.id)).toEqual(cuts.map((_, index) => `F${index + 1}`));
|
||||||
|
expect(
|
||||||
|
plan.pieces
|
||||||
|
.filter((piece) => !piece.cut && !piece.reused)
|
||||||
|
.every((piece) => piece.id.startsWith('FU')),
|
||||||
|
).toBe(true);
|
||||||
|
const ids = plan.pieces.map((piece) => piece.id);
|
||||||
|
expect(plan.rows.flatMap((row) => row.pieceIds).sort()).toEqual([...ids].sort());
|
||||||
|
expectStockFits(plan, s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes room failures and shape warnings without scoping global material errors', () => {
|
||||||
|
const valid = room(4020, 2220, 'valid');
|
||||||
|
const open = room(4020, 2220, 'open');
|
||||||
|
open.walls[0].length += 100;
|
||||||
|
const shaped = fireplace('shaped', 'Fireplace room');
|
||||||
|
const plan = planFlooring([valid, open, shaped], { ...settings, width: 190 }, 1);
|
||||||
|
expect(plan.pieces.some((piece) => piece.roomId === valid.id)).toBe(true);
|
||||||
|
expect(plan.pieces.some((piece) => piece.roomId === open.id)).toBe(false);
|
||||||
|
expect(errors(plan).map((issue) => issue.roomId)).toEqual(['open']);
|
||||||
|
expect(
|
||||||
|
plan.issues.filter((issue) => issue.severity === 'warning').map((issue) => issue.roomId),
|
||||||
|
).toEqual(['shaped']);
|
||||||
|
expect(errors(planFlooring([valid], { ...settings, width: 0 }, 1))[0].roomId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
173
tests/geometry.test.ts
Normal file
173
tests/geometry.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildGeometry, insetPolygon, signedArea } from '../src/lib/geometry';
|
||||||
|
import type { Point, Room } from '../src/lib/types';
|
||||||
|
|
||||||
|
const rectangle = (last = 3000): Room => ({
|
||||||
|
id: 'r1',
|
||||||
|
name: 'Room',
|
||||||
|
direction: 0,
|
||||||
|
walls: [4000, 3000, 4000, last].map((length, index) => ({
|
||||||
|
id: `w${index}`,
|
||||||
|
length,
|
||||||
|
turn: 90,
|
||||||
|
doors: [],
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
function roomFromPoints(vertices: Point[]): Room {
|
||||||
|
const headings = vertices.map((point, index) => {
|
||||||
|
const next = vertices[(index + 1) % vertices.length];
|
||||||
|
return (Math.atan2(next.y - point.y, next.x - point.x) * 180) / Math.PI;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: 'shape',
|
||||||
|
name: 'Shape',
|
||||||
|
direction: 0,
|
||||||
|
walls: vertices.map((point, index) => {
|
||||||
|
const next = vertices[(index + 1) % vertices.length];
|
||||||
|
const turn = ((headings[(index + 1) % headings.length] - headings[index] + 540) % 360) - 180;
|
||||||
|
return {
|
||||||
|
id: `w${index}`,
|
||||||
|
length: Math.hypot(next.x - point.x, next.y - point.y),
|
||||||
|
turn,
|
||||||
|
doors: [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('measured room geometry', () => {
|
||||||
|
it('closes a rectangle and ignores the unused final turn', () => {
|
||||||
|
const room = rectangle();
|
||||||
|
room.walls[3].turn = 0;
|
||||||
|
const result = buildGeometry(room, 5);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.vertices).toHaveLength(4);
|
||||||
|
expect(result.area).toBeCloseTo(12_000_000);
|
||||||
|
expect(result.perimeter).toBeCloseTo(14_000);
|
||||||
|
expect(result.finalLengthError).toBeCloseTo(0);
|
||||||
|
expect(result.finalAngleError).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('predicts the next closing length and signed turn for an unfinished room', () => {
|
||||||
|
const room = rectangle();
|
||||||
|
room.walls.pop();
|
||||||
|
const result = buildGeometry(room, 5);
|
||||||
|
expect(result.closed).toBe(false);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.vertices).toHaveLength(4);
|
||||||
|
expect(result.closingLength).toBeCloseTo(3000);
|
||||||
|
expect(result.closingTurn).toBeCloseTo(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the measured final wall mismatch, accepting only residuals inside tolerance', () => {
|
||||||
|
const result = buildGeometry(rectangle(3003), 5);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.gap).toBeCloseTo(3);
|
||||||
|
expect(result.finalLengthError).toBeCloseTo(3);
|
||||||
|
expect(result.edges[3].length).toBeCloseTo(3000);
|
||||||
|
expect(
|
||||||
|
result.issues.some(
|
||||||
|
(issue) => issue.severity === 'warning' && issue.message.includes('remeasure'),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(buildGeometry(rectangle(3020), 5).valid).toBe(false);
|
||||||
|
expect(buildGeometry(rectangle(3020), 1e308).closed).toBe(false);
|
||||||
|
expect(buildGeometry(rectangle(), 51).valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports direction mismatch rather than assigning the residual to a bowed wall', () => {
|
||||||
|
const room = rectangle();
|
||||||
|
room.walls[2].turn = 88;
|
||||||
|
const result = buildGeometry(room, 5);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(Math.abs(result.finalAngleError)).toBeCloseTo(2);
|
||||||
|
expect(result.gap).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports a concave fireplace and a slanted outer wall', () => {
|
||||||
|
const room = roomFromPoints([
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 5000, y: 0 },
|
||||||
|
{ x: 5000, y: 4000 },
|
||||||
|
{ x: 3000, y: 4000 },
|
||||||
|
{ x: 3000, y: 3400 },
|
||||||
|
{ x: 2000, y: 3400 },
|
||||||
|
{ x: 2000, y: 4000 },
|
||||||
|
{ x: 0, y: 3500 },
|
||||||
|
]);
|
||||||
|
const result = buildGeometry(room, 0.1);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.vertices).toHaveLength(8);
|
||||||
|
expect(result.area).toBeCloseTo(18_900_000);
|
||||||
|
const inset = insetPolygon(result.vertices, 10);
|
||||||
|
expect(inset).toHaveLength(1);
|
||||||
|
expect(Math.abs(signedArea(inset[0]))).toBeLessThan(result.area);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects crossing outlines, invalid lengths and impossible turns', () => {
|
||||||
|
const crossed = roomFromPoints([
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 4000, y: 0 },
|
||||||
|
{ x: 0, y: 3000 },
|
||||||
|
{ x: 4000, y: 3000 },
|
||||||
|
]);
|
||||||
|
expect(buildGeometry(crossed, 5).issues.some((issue) => issue.message.includes('cross'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const room = rectangle();
|
||||||
|
room.walls[0].length = -1;
|
||||||
|
room.walls[1].turn = 180;
|
||||||
|
expect(buildGeometry(room, 5).valid).toBe(false);
|
||||||
|
room.walls[0].length = 1e308;
|
||||||
|
expect(Number.isFinite(buildGeometry(room, 5).area)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates door boundaries and overlapping openings', () => {
|
||||||
|
const room = rectangle();
|
||||||
|
room.walls[0].doors = [
|
||||||
|
{ id: 'd1', offset: 300, width: 900 },
|
||||||
|
{ id: 'd2', offset: 1000, width: 900 },
|
||||||
|
];
|
||||||
|
expect(buildGeometry(room, 5).issues.some((issue) => issue.message.includes('overlap'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
room.walls[0].doors = [{ id: 'd1', offset: 3500, width: 900 }];
|
||||||
|
expect(buildGeometry(room, 5).valid).toBe(false);
|
||||||
|
room.walls[0].doors = [{ id: 'd1', offset: 0, width: 900 }];
|
||||||
|
expect(buildGeometry(room, 5).valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('insets clockwise and anticlockwise rectangles and detects an erased room', () => {
|
||||||
|
const vertices = buildGeometry(rectangle(), 5).vertices;
|
||||||
|
for (const points of [vertices, [...vertices].reverse()]) {
|
||||||
|
const inset = insetPolygon(points, 10);
|
||||||
|
expect(inset).toHaveLength(1);
|
||||||
|
expect(Math.abs(signedArea(inset[0]))).toBeCloseTo(3980 * 2980);
|
||||||
|
}
|
||||||
|
expect(insetPolygon(vertices, 1600)).toEqual([]);
|
||||||
|
expect(insetPolygon(vertices, 1e308)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns separate floor regions when a narrow connecting passage disappears', () => {
|
||||||
|
const inset = insetPolygon(
|
||||||
|
[
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 2000, y: 0 },
|
||||||
|
{ x: 2000, y: 900 },
|
||||||
|
{ x: 3000, y: 900 },
|
||||||
|
{ x: 3000, y: 0 },
|
||||||
|
{ x: 5000, y: 0 },
|
||||||
|
{ x: 5000, y: 2000 },
|
||||||
|
{ x: 3000, y: 2000 },
|
||||||
|
{ x: 3000, y: 1100 },
|
||||||
|
{ x: 2000, y: 1100 },
|
||||||
|
{ x: 2000, y: 2000 },
|
||||||
|
{ x: 0, y: 2000 },
|
||||||
|
],
|
||||||
|
150,
|
||||||
|
);
|
||||||
|
expect(inset).toHaveLength(2);
|
||||||
|
expect(inset.every((polygon) => signedArea(polygon) > 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
111
tests/skirting-polygon.test.ts
Normal file
111
tests/skirting-polygon.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import ClipperLib from 'clipper-lib';
|
||||||
|
import { fireplace, rectangle } from '../src/lib/defaults';
|
||||||
|
import { buildGeometry, insetPolygon, normalizeAngle, signedArea } from '../src/lib/geometry';
|
||||||
|
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 };
|
||||||
|
|
||||||
|
function overlap(a: Point[], b: Point[]): number {
|
||||||
|
const clipper = new ClipperLib.Clipper();
|
||||||
|
const path = (points: Point[]) =>
|
||||||
|
points.map((point) => ({
|
||||||
|
X: Math.round(point.x * 1000),
|
||||||
|
Y: Math.round(point.y * 1000),
|
||||||
|
}));
|
||||||
|
clipper.AddPath(path(a), ClipperLib.PolyType.ptSubject, true);
|
||||||
|
clipper.AddPath(path(b), ClipperLib.PolyType.ptClip, true);
|
||||||
|
const intersection: ClipperLib.Paths = [];
|
||||||
|
clipper.Execute(ClipperLib.ClipType.ctIntersection, intersection);
|
||||||
|
return intersection.reduce(
|
||||||
|
(sum, polygon) => sum + Math.abs(ClipperLib.Clipper.Area(polygon)) / 1e6,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectCompleteSkirting(room: Room) {
|
||||||
|
room.walls.forEach((wall) => {
|
||||||
|
wall.doors = [];
|
||||||
|
});
|
||||||
|
const geometry = buildGeometry(room, 1);
|
||||||
|
const plan = planSkirting([room], settings, 1);
|
||||||
|
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||||
|
const polygons = plan.pieces.map((piece) =>
|
||||||
|
skirtingPolygon(piece, settings.thickness, signedArea(geometry.vertices)),
|
||||||
|
);
|
||||||
|
const inner = insetPolygon(geometry.vertices, settings.thickness);
|
||||||
|
// Exact area of the mitred perimeter band; unlike clipping, this does not
|
||||||
|
// round angled intersections to the nearest 0.001 mm.
|
||||||
|
const orientation = Math.sign(signedArea(geometry.vertices));
|
||||||
|
const cornerArea = geometry.edges.reduce((sum, edge, index, edges) => {
|
||||||
|
const turn =
|
||||||
|
normalizeAngle(edges[(index + 1) % edges.length].heading - edge.heading) * orientation;
|
||||||
|
return sum + settings.thickness ** 2 * Math.tan((turn * Math.PI) / 360);
|
||||||
|
}, 0);
|
||||||
|
const expectedArea = geometry.perimeter * settings.thickness - cornerArea;
|
||||||
|
expect(polygons.reduce((sum, polygon) => sum + Math.abs(signedArea(polygon)), 0)).toBeCloseTo(
|
||||||
|
expectedArea,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
for (let i = 0; i < polygons.length; i++) {
|
||||||
|
const polygon = polygons[i];
|
||||||
|
// Inset and intersection coordinates each round to a 0.001 mm grid.
|
||||||
|
const clippingTolerance = polygon.reduce((sum, point, index) => {
|
||||||
|
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);
|
||||||
|
expect(
|
||||||
|
Math.abs(overlap(polygon, geometry.vertices) - Math.abs(signedArea(polygon))),
|
||||||
|
).toBeLessThan(clippingTolerance);
|
||||||
|
for (const innerPolygon of inner)
|
||||||
|
expect(overlap(polygon, innerPolygon)).toBeLessThan(clippingTolerance);
|
||||||
|
for (let j = i + 1; j < polygons.length; j++)
|
||||||
|
expect(overlap(polygon, polygons[j])).toBeLessThan(clippingTolerance);
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('installed skirting polygons', () => {
|
||||||
|
it.each([1, -1])(
|
||||||
|
'covers a rectangle including uncut stock without overlapping butt joints (orientation %i)',
|
||||||
|
(orientation) => {
|
||||||
|
const room = rectangle();
|
||||||
|
room.walls.forEach((wall) => {
|
||||||
|
wall.turn *= orientation;
|
||||||
|
});
|
||||||
|
const plan = expectCompleteSkirting(room);
|
||||||
|
expect(plan.pieces.some((piece) => piece.id.startsWith('SU'))).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([1, -1])(
|
||||||
|
'joins fireplace mitres without gaps or overlaps (orientation %i)',
|
||||||
|
(orientation) => {
|
||||||
|
const room = fireplace('fire', 'Fireplace');
|
||||||
|
room.walls.forEach((wall) => {
|
||||||
|
wall.turn *= orientation;
|
||||||
|
});
|
||||||
|
const plan = expectCompleteSkirting(room);
|
||||||
|
expect(
|
||||||
|
plan.pieces.some(
|
||||||
|
(piece) => piece.leftType === 'external' && piece.rightType === 'external',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('matches the room boundary at angled internal butt joints', () => {
|
||||||
|
expectCompleteSkirting({
|
||||||
|
id: 'triangle',
|
||||||
|
name: 'Triangle',
|
||||||
|
direction: 0,
|
||||||
|
walls: [0, 1, 2].map((index) => ({ id: `w${index}`, length: 3000, turn: 120, doors: [] })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
323
tests/skirting.test.ts
Normal file
323
tests/skirting.test.ts
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { planSkirting } from '../src/lib/skirting';
|
||||||
|
import { skirtingCuts } from '../src/lib/cut-list';
|
||||||
|
import type { Room, SkirtingSettings } from '../src/lib/types';
|
||||||
|
|
||||||
|
const settings: SkirtingSettings = { stockLengths: [2400], thickness: 18, minLength: 300, kerf: 3 };
|
||||||
|
const rectangle = (width = 4000, height = 3000, id = 'r1'): Room => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
direction: 0,
|
||||||
|
walls: [width, height, width, height].map((length, index) => ({
|
||||||
|
id: `w${index}`,
|
||||||
|
length,
|
||||||
|
turn: 90,
|
||||||
|
doors: [],
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('skirting cutting plan', () => {
|
||||||
|
it('subtracts internal butt thickness and preserves every installed wall 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.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes doors and cuts square at each opening', () => {
|
||||||
|
const room = rectangle();
|
||||||
|
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.some((piece) => Math.abs(piece.end.x - 1000) < 1e-6 && piece.rightType === 'square'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
pieces.some((piece) => Math.abs(piece.start.x - 1900) < 1e-6 && piece.leftType === 'square'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
pieces.every((piece) => piece.end.x <= 1000 + 1e-6 || piece.start.x >= 1900 - 1e-6),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not silently produce undersized pieces next to doors', () => {
|
||||||
|
const room = rectangle();
|
||||||
|
room.walls[0].doors = [{ id: 'd1', offset: 200, width: 900 }];
|
||||||
|
const plan = planSkirting([room], settings, 5);
|
||||||
|
expect(plan.pieces).toEqual([]);
|
||||||
|
expect(
|
||||||
|
plan.issues.some((issue) => issue.severity === 'error' && issue.message.includes('minimum')),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects crossing angled end cuts even when the back length meets the minimum', () => {
|
||||||
|
const room: Room = {
|
||||||
|
id: 'acute',
|
||||||
|
name: 'Acute corner',
|
||||||
|
direction: 0,
|
||||||
|
walls: [1000, 1000, 2000 * Math.sin((5 * Math.PI) / 180)].map((length, index) => ({
|
||||||
|
id: `w${index}`,
|
||||||
|
length,
|
||||||
|
turn: [170, 95, 95][index],
|
||||||
|
doors: index === 1 ? [{ id: 'd1', offset: 200, width: 300 }] : [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const plan = planSkirting([room], { ...settings, minLength: 50 }, 5);
|
||||||
|
expect(plan.pieces).toEqual([]);
|
||||||
|
expect(
|
||||||
|
plan.issues.some((issue) => issue.message.includes('shorter than the 50 mm minimum')),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extends the long point at external fireplace corners and specifies 45° mitres', () => {
|
||||||
|
const room: Room = {
|
||||||
|
id: 'r1',
|
||||||
|
name: 'Fireplace',
|
||||||
|
direction: 0,
|
||||||
|
walls: [5000, 4000, 2000, 600, 1000, 600, 2000, 4000].map((length, index) => ({
|
||||||
|
id: `w${index}`,
|
||||||
|
length,
|
||||||
|
turn: [90, 90, 90, -90, -90, 90, 90, 90][index],
|
||||||
|
doors: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const plan = planSkirting([room], { ...settings, stockLengths: [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);
|
||||||
|
expect(front.length).toBeCloseTo(1036);
|
||||||
|
expect(front.leftMitre).toBeCloseTo(45);
|
||||||
|
expect(front.rightMitre).toBeCloseTo(45);
|
||||||
|
expect(front.leftType).toBe('external');
|
||||||
|
expect(front.leftBevel).toBeCloseTo(-45);
|
||||||
|
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 reverseFront = reversePlan.pieces.find((piece) => piece.wallIndex === 4)!;
|
||||||
|
expect(reverseFront.length).toBeCloseTo(1036);
|
||||||
|
expect(reverseFront.leftBevel).toBeCloseTo(-45);
|
||||||
|
expect(reverseFront.rightBevel).toBeCloseTo(45);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
expect(plan.pieces.every((piece) => piece.id.startsWith('SU'))).toBe(true);
|
||||||
|
expect(plan.stockCount).toBe(8);
|
||||||
|
expect(plan.cuts).toBe(0);
|
||||||
|
expect(plan.waste).toBeCloseTo(0);
|
||||||
|
const trimmed = planSkirting(
|
||||||
|
[rectangle(1199, 1199)],
|
||||||
|
{ ...settings, stockLengths: [2400], thickness: 0, minLength: 1199 },
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
expect(trimmed.stockCount).toBe(4); // 1199 + 3 + 1199 cannot fit into 2400.
|
||||||
|
expect(trimmed.cuts).toBe(4);
|
||||||
|
for (const stock of trimmed.stocks) expect(stock.used + stock.waste).toBeCloseTo(stock.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the final saw pass to extend beyond a short stock tail', () => {
|
||||||
|
const plan = planSkirting(
|
||||||
|
[rectangle(1199, 1199)],
|
||||||
|
{ ...settings, stockLengths: [1200], thickness: 0 },
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
expect(plan.pieces).toHaveLength(4);
|
||||||
|
expect(plan.stockCount).toBe(4);
|
||||||
|
expect(plan.waste).toBeCloseTo(4);
|
||||||
|
expect(plan.cuts).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('looks ahead when choosing from different stock lengths', () => {
|
||||||
|
const plan = planSkirting(
|
||||||
|
[rectangle(1200, 1200)],
|
||||||
|
{ ...settings, stockLengths: [1800, 2400], thickness: 0, kerf: 0 },
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
expect(plan.stockCount).toBe(2);
|
||||||
|
expect(plan.stocks.every((stock) => stock.length === 2400)).toBe(true);
|
||||||
|
expect(plan.waste).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets reused tails set wall joints to save a stock length without extra saw cuts', () => {
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
pieces.slice(1).forEach((piece, index) => {
|
||||||
|
expect(piece.start.x).toBeCloseTo(pieces[index].end.x);
|
||||||
|
expect(piece.start.y).toBeCloseTo(pieces[index].end.y);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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)!;
|
||||||
|
expect(piece.length).toBeLessThanOrEqual(available + 1e-6);
|
||||||
|
expect(piece.backLength).toBeGreaterThanOrEqual(settings.minLength - 1e-6);
|
||||||
|
available = Math.max(0, available - piece.length - settings.kerf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reserves the shorter front edge when splitting a run at angled internal corners', () => {
|
||||||
|
const room: Room = {
|
||||||
|
id: 'triangle',
|
||||||
|
name: 'Triangle',
|
||||||
|
direction: 0,
|
||||||
|
walls: [0, 1, 2].map((index) => ({
|
||||||
|
id: `w${index}`,
|
||||||
|
length: 3100,
|
||||||
|
turn: 120,
|
||||||
|
doors: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const plan = planSkirting([room], { ...settings, stockLengths: [311] }, 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.backLength).toBeGreaterThanOrEqual(300 - 1e-6);
|
||||||
|
expect(piece.template![2].x - piece.template![3].x).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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates angled butt cuts and back setbacks for non-square internal corners', () => {
|
||||||
|
const room: Room = {
|
||||||
|
id: 'triangle',
|
||||||
|
name: 'Triangle',
|
||||||
|
direction: 0,
|
||||||
|
walls: [0, 1, 2].map((index) => ({
|
||||||
|
id: `w${index}`,
|
||||||
|
length: 3000,
|
||||||
|
turn: 120,
|
||||||
|
doors: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const plan = planSkirting([room], { ...settings, stockLengths: [4000] }, 5);
|
||||||
|
expect(plan.pieces).toHaveLength(3);
|
||||||
|
expect(plan.pieces[0].backLength).toBeCloseTo(3000 - 18 / Math.sin((120 * Math.PI) / 180));
|
||||||
|
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(plan.cuts).toBe(9); // Three blank crosscuts and six end bevels.
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can leave a minimum-sized final piece without making a tiny join', () => {
|
||||||
|
const plan = planSkirting([rectangle(2500, 2000)], { ...settings, thickness: 0 }, 5);
|
||||||
|
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||||
|
expect(plan.pieces.every((piece) => piece.backLength >= 300 - 1e-6)).toBe(true);
|
||||||
|
for (const wall of [0, 2])
|
||||||
|
expect(
|
||||||
|
plan.pieces
|
||||||
|
.filter((piece) => piece.wallIndex === wall)
|
||||||
|
.reduce((sum, piece) => sum + piece.backLength, 0),
|
||||||
|
).toBeCloseTo(2500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses invalid outlines and stock settings', () => {
|
||||||
|
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, minLength: 1e-10 }, 5).issues[0].severity,
|
||||||
|
).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('numbers visible cuts continuously per room and remaps stock references', () => {
|
||||||
|
const rooms = [rectangle(4000, 3000, 'one'), rectangle(4000, 3000, 'two')];
|
||||||
|
const plan = planSkirting(rooms, settings, 5);
|
||||||
|
const cuts = skirtingCuts(plan);
|
||||||
|
expect(plan.pieces.length).toBeGreaterThan(cuts.length);
|
||||||
|
for (const [roomIndex, room] of rooms.entries()) {
|
||||||
|
const ids = cuts.filter((piece) => piece.roomId === room.id).map((piece) => piece.id);
|
||||||
|
expect(ids.length).toBeGreaterThan(0);
|
||||||
|
expect(ids).toEqual(ids.map((_, index) => `${roomIndex === 0 ? 'A' : 'B'}${index + 1}`));
|
||||||
|
}
|
||||||
|
const ids = plan.pieces.map((piece) => piece.id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
expect(plan.stocks.flatMap((stock) => stock.pieceIds).sort()).toEqual([...ids].sort());
|
||||||
|
expect(plan).toEqual(planSkirting(rooms, settings, 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps valid rooms when another room has an undersized run or unclosed outline', () => {
|
||||||
|
const valid = rectangle(4000, 3000, 'valid');
|
||||||
|
const small = rectangle(4000, 3000, 'small');
|
||||||
|
small.walls[0].doors = [{ id: 'door', offset: 200, width: 900 }];
|
||||||
|
const open = rectangle(4000, 3000, 'open');
|
||||||
|
open.walls[0].length += 100;
|
||||||
|
const plan = planSkirting([valid, small, open], settings, 5);
|
||||||
|
expect(plan.pieces.length).toBeGreaterThan(0);
|
||||||
|
expect(plan.pieces.every((piece) => piece.roomId === valid.id)).toBe(true);
|
||||||
|
expect(
|
||||||
|
plan.issues.filter((issue) => issue.severity === 'error').map((issue) => issue.roomId),
|
||||||
|
).toEqual(['small', 'open']);
|
||||||
|
expect(plan.stockCount).toBe(planSkirting([valid], settings, 5).stockCount);
|
||||||
|
expect(
|
||||||
|
planSkirting([valid], { ...settings, stockLengths: [] }, 5).issues[0].roomId,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attributes corner stock-fit failures to the affected room', () => {
|
||||||
|
const valid = rectangle(4000, 3000, 'valid');
|
||||||
|
const triangle: Room = {
|
||||||
|
id: 'triangle',
|
||||||
|
name: 'Triangle',
|
||||||
|
direction: 0,
|
||||||
|
walls: [0, 1, 2].map((index) => ({ id: `w${index}`, length: 3000, turn: 120, doors: [] })),
|
||||||
|
};
|
||||||
|
const plan = planSkirting(
|
||||||
|
[valid, triangle],
|
||||||
|
{ ...settings, stockLengths: [500], minLength: 490 },
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
expect(plan.pieces.length).toBeGreaterThan(0);
|
||||||
|
expect(plan.pieces.every((piece) => piece.roomId === valid.id)).toBe(true);
|
||||||
|
const failures = plan.issues.filter((issue) => issue.severity === 'error');
|
||||||
|
expect(failures.length).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
failures.every(
|
||||||
|
(issue) => issue.roomId === triangle.id && issue.message.includes('stock lengths'),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
236
tests/url-state.test.ts
Normal file
236
tests/url-state.test.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
import LZString from 'lz-string';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { defaultState, fireplace, rectangle } from '../src/lib/defaults';
|
||||||
|
import { decodeState, encodeState, URL_LIMITS } from '../src/lib/url-state';
|
||||||
|
|
||||||
|
function rawLink(value: unknown): string {
|
||||||
|
return `#plan=${LZString.compressToEncodedURIComponent(JSON.stringify(value))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectInvalid(hash: string, message?: RegExp) {
|
||||||
|
const result = decodeState(hash);
|
||||||
|
expect(result.state).toEqual(defaultState());
|
||||||
|
expect(result.error).toMatch(message ?? /Could not load this plan/);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('URL state', () => {
|
||||||
|
it('starts with independent defaults when no link is provided', () => {
|
||||||
|
for (const hash of ['', '#']) {
|
||||||
|
const first = decodeState(hash);
|
||||||
|
expect(first).toEqual({ state: defaultState(), error: null });
|
||||||
|
first.state.project.rooms[0].name = 'Edited';
|
||||||
|
expect(decodeState(hash).state.project.rooms[0].name).toBe('Living room');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips all default inputs exactly and produces the same link every time', () => {
|
||||||
|
const state = defaultState();
|
||||||
|
const hash = encodeState(state);
|
||||||
|
expect(hash).toMatch(/^#plan=[A-Za-z0-9+$-]+$/);
|
||||||
|
expect(decodeState(hash)).toEqual({ state, error: null });
|
||||||
|
expect(encodeState(decodeState(hash).state)).toBe(hash);
|
||||||
|
expect(encodeState(structuredClone(state))).toBe(hash);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips Unicode, multiple rooms, doors, materials and view selections', () => {
|
||||||
|
const state = defaultState();
|
||||||
|
state.project.name = 'Zoë’s 家 🏡 – mõõtmised';
|
||||||
|
state.project.rooms.push(fireplace('room-2', 'Slaapkamer 北'));
|
||||||
|
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.view = { roomId: 'room-2', selectedPiece: 'A12' };
|
||||||
|
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads legacy step, layer and zoom links into the same workspace', () => {
|
||||||
|
const state = defaultState();
|
||||||
|
const old = {
|
||||||
|
...state,
|
||||||
|
view: {
|
||||||
|
roomId: state.view.roomId,
|
||||||
|
selectedPiece: 'A4',
|
||||||
|
step: 'plan',
|
||||||
|
layer: 'skirting',
|
||||||
|
zoom: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const decoded = decodeState(rawLink(old));
|
||||||
|
expect(decoded.error).toBeNull();
|
||||||
|
expect(decoded.state.project).toEqual(state.project);
|
||||||
|
expect(decoded.state.view).toEqual({ roomId: state.view.roomId, selectedPiece: 'A4' });
|
||||||
|
expect(Object.keys(decoded.state.view).sort()).toEqual(['roomId', 'selectedPiece']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canonicalizes property order and discards derived or unknown fields', () => {
|
||||||
|
const state = defaultState();
|
||||||
|
const reordered = {
|
||||||
|
view: { ...state.view, derived: 6 },
|
||||||
|
project: { ...state.project, boardCount: 500 },
|
||||||
|
cache: [],
|
||||||
|
};
|
||||||
|
expect(encodeState(reordered)).toBe(encodeState(state));
|
||||||
|
expect(decodeState(rawLink(reordered))).toEqual({ state, error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves finite but impossible measurements for domain-level feedback', () => {
|
||||||
|
const state = defaultState();
|
||||||
|
state.project.rooms[0].walls[0].length = -1;
|
||||||
|
state.project.rooms[0].walls[0].turn = 999;
|
||||||
|
state.project.flooring.width = 0;
|
||||||
|
state.project.flooring.minOffset = 1000;
|
||||||
|
state.project.flooring.maxOffset = 100;
|
||||||
|
state.project.skirting.thickness = -20;
|
||||||
|
state.project.tolerance = -1;
|
||||||
|
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an unfinished room and an empty stock list without inventing inputs', () => {
|
||||||
|
const state = defaultState();
|
||||||
|
state.project.rooms[0].walls = [];
|
||||||
|
state.project.skirting.stockLengths = [];
|
||||||
|
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the first room for a stale selected room', () => {
|
||||||
|
const state = defaultState();
|
||||||
|
state.view.roomId = 'deleted-room';
|
||||||
|
const result = decodeState(rawLink(state));
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.state.view.roomId).toBe(state.project.rooms[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts URL-escaped compression characters', () => {
|
||||||
|
const hash = encodeState(defaultState());
|
||||||
|
expect(decodeState(`#plan=${encodeURIComponent(hash.slice(6))}`).state).toEqual(defaultState());
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['#other=value', '#plan=', '#plan=garbage', '#plan=%', '#plan=%20', '#plan=abc&more=1'])(
|
||||||
|
'rejects malformed link %s with an actionable error',
|
||||||
|
(hash) => expectInvalid(hash),
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects truncated and appended payloads even when the decoder ignores trailing data', () => {
|
||||||
|
const hash = encodeState(defaultState());
|
||||||
|
expectInvalid(hash.slice(0, -4));
|
||||||
|
expectInvalid(`${hash}AAAA`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a valid compression payload containing invalid JSON', () => {
|
||||||
|
expectInvalid(
|
||||||
|
`#plan=${LZString.compressToEncodedURIComponent('{broken')}`,
|
||||||
|
/project data is damaged/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported schema versions', () => {
|
||||||
|
const state = defaultState();
|
||||||
|
expectInvalid(
|
||||||
|
rawLink({ ...state, project: { ...state.project, version: 2 } }),
|
||||||
|
/version is unsupported/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([null, [], {}, { project: null }, { project: { version: 1 } }])(
|
||||||
|
'rejects missing project data',
|
||||||
|
(value) => {
|
||||||
|
expectInvalid(rawLink(value));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([null, '1200', true, {}, []])('rejects incorrectly typed measurements', (length) => {
|
||||||
|
const state = defaultState();
|
||||||
|
expectInvalid(
|
||||||
|
rawLink({
|
||||||
|
...state,
|
||||||
|
project: { ...state.project, flooring: { ...state.project.flooring, length } },
|
||||||
|
}),
|
||||||
|
/finite number/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects nonfinite numbers both when reading and writing', () => {
|
||||||
|
const state = defaultState();
|
||||||
|
const json = JSON.stringify(state).replace('"length":4800', '"length":1e999');
|
||||||
|
expectInvalid(`#plan=${LZString.compressToEncodedURIComponent(json)}`, /finite number/);
|
||||||
|
for (const invalid of [NaN, Infinity, -Infinity]) {
|
||||||
|
state.project.flooring.length = invalid;
|
||||||
|
expect(() => encodeState(state)).toThrow(/finite number/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['selectedPiece', false],
|
||||||
|
['selectedPiece', {}],
|
||||||
|
['selectedPiece', 12],
|
||||||
|
['roomId', null],
|
||||||
|
])('rejects invalid view field %s', (field, value) => {
|
||||||
|
const state = defaultState();
|
||||||
|
expectInvalid(rawLink({ ...state, view: { ...state.view, [field]: value } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid flooring enums', () => {
|
||||||
|
const state = defaultState();
|
||||||
|
expectInvalid(
|
||||||
|
rawLink({
|
||||||
|
...state,
|
||||||
|
project: { ...state.project, flooring: { ...state.project.flooring, pattern: 'diagonal' } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expectInvalid(
|
||||||
|
rawLink({
|
||||||
|
...state,
|
||||||
|
project: { ...state.project, rooms: [{ ...state.project.rooms[0], direction: 45 }] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects too many rooms, walls, doors or stock lengths', () => {
|
||||||
|
const rooms = defaultState();
|
||||||
|
rooms.project.rooms = Array.from({ length: URL_LIMITS.rooms + 1 }, (_, i) =>
|
||||||
|
rectangle(`room-${i}`),
|
||||||
|
);
|
||||||
|
expectInvalid(rawLink(rooms), /at most 20/);
|
||||||
|
const walls = defaultState();
|
||||||
|
walls.project.rooms[0].walls = Array.from({ length: URL_LIMITS.walls + 1 }, (_, i) => ({
|
||||||
|
id: `wall-${i}`,
|
||||||
|
length: 1,
|
||||||
|
turn: 90,
|
||||||
|
doors: [],
|
||||||
|
}));
|
||||||
|
expectInvalid(rawLink(walls), /at most 100/);
|
||||||
|
const doors = defaultState();
|
||||||
|
doors.project.rooms[0].walls[0].doors = Array.from(
|
||||||
|
{ length: URL_LIMITS.doors + 1 },
|
||||||
|
(_, i) => ({ id: `door-${i}`, offset: i * 10, width: 5 }),
|
||||||
|
);
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects no rooms, duplicate identifiers and oversized strings', () => {
|
||||||
|
const empty = defaultState();
|
||||||
|
empty.project.rooms = [];
|
||||||
|
expectInvalid(rawLink(empty), /at least one room/);
|
||||||
|
const duplicate = defaultState();
|
||||||
|
duplicate.project.rooms.push(rectangle());
|
||||||
|
expectInvalid(rawLink(duplicate), /duplicate identifiers/);
|
||||||
|
const duplicateWall = defaultState();
|
||||||
|
duplicateWall.project.rooms[0].walls.push(duplicateWall.project.rooms[0].walls[0]);
|
||||||
|
expectInvalid(rawLink(duplicateWall), /duplicate identifiers/);
|
||||||
|
const largeName = defaultState();
|
||||||
|
largeName.project.name = 'a'.repeat(URL_LIMITS.nameLength + 1);
|
||||||
|
expectInvalid(rawLink(largeName), /at most 200 characters/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects oversized compressed and expanded payloads', () => {
|
||||||
|
expectInvalid(`#plan=${'a'.repeat(URL_LIMITS.hashLength)}`, /link is too large/);
|
||||||
|
expectInvalid(
|
||||||
|
rawLink('a'.repeat(URL_LIMITS.jsonLength + 1)),
|
||||||
|
/project in this link is too large/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
|
},
|
||||||
|
"include": ["src", "tests", "vite.config.ts", "playwright.config.ts"]
|
||||||
|
}
|
||||||
3
vite.config.ts
Normal file
3
vite.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
export default defineConfig({ plugins: [react()], test: { include: ['tests/**/*.test.ts'] } });
|
||||||
Loading…
Reference in a new issue