Simplify
Some checks failed
Deploy to Pages / build (push) Failing after 10m45s

This commit is contained in:
Andras Schmelczer 2026-09-11 23:27:29 +01:00
commit 8bf3e3b705
40 changed files with 1443 additions and 1497 deletions

View file

@ -28,13 +28,13 @@ npm run benchmark -- --random # Compare appearance-first optimisation with the s
New plans start with a random laying pattern. Click the numbered room, materials and cut-plan headings to expand or collapse each panel. Click **Custom** to build an outline wall by wall; guidance below the shape choices explains how to begin. Advanced flooring and skirting settings expand separately when needed. Saved plans keep their chosen laying pattern.
1. Start with a rectangle, fireplace recess, or custom outline. Enter wall lengths in order. The first wall runs east; the turn **after** each wall is positive for right and negative for left. For example, a clockwise rectangle has 90° turns. The final corner is derived from the closed outline. Insert a wall after any existing wall, drag its handle to reorder it, or use the up/down buttons on a phone or keyboard. Doors stay attached to their wall. Numeric fields allow empty values and a minus sign while typing; unfinished entries revert to the last valid value when you leave the field.
1. Start with a rectangle, fireplace recess, or custom outline. Enter wall lengths in order. The first wall runs east; the turn **after** each wall is positive for right and negative for left. For example, a clockwise rectangle has 90° turns. The final corner is derived from the closed outline. Insert a wall after any existing wall, drag its handle to reorder it, or use the up/down buttons on a phone or keyboard. Doors stay attached to their wall. Numeric fields keep a local draft while typing. Blur or Enter commits a number; Escape discards it. Empty or unfinished entries revert to the last committed value.
2. Add door openings with a width and a distance from the named starting corner. Each doorway shows the neighbouring wall names and an arrow in the measurement direction. Include architrave/trim in the width where skirting must be omitted. Doors terminate skirting; laminate ends at the room outline with a transition and expansion gap. Rooms are planned independently.
3. For a custom outline, the closing-wall suggestion gives the required length and turn. Add it, then replace the suggested length with the actual measured length. An outline must close within the chosen tolerance and have no intersections or invalid doors before a cut plan can be produced.
4. Set material dimensions, spacer, kerf and minimum piece sizes alongside the measurements. Kerf means material removed by the blade: a 3 mm cut leaves 600 mm + 397 mm from a 1,000 mm board. Both kerf fields have keyboard and touch tooltips. Set one purchased skirting board length for the whole project. Materials are shared across rooms; direction is per room.
5. Room measurements, materials and cut plans open and close independently on the same page. The drawing stays pinned on the right while you scroll the panels; on phones a compact drawing stays above them. The **Floorboards / Skirting** switch above the drawing shows one material at a time, with the whole drawing fitting without an internal scrollbar. The choice is saved as `view=flooring` or `view=skirting`. Locating or searching for a cut switches to its material automatically; printing includes both materials. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Cut codes sit above the wall and skirting drawing; short pieces use nearby labels with pointers so their names remain visible. The compact drawing header shows only the room, area and material legend. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Flooring and skirting cuts are grouped by the purchased board they come from, including offcuts reused across rooms. Group headings use the cut codes; internal stock IDs stay out of the interface. Pieces wrap side by side when space allows. Each piece has a small preview that opens dimensions directly on its shape. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Numbered skirting pieces and their labels open cut details; uncut lengths use a neutral fill. Print all room drawings followed by flooring and skirting cut sheets grouped by purchased board. Every cut is included once, with its room and row or wall.
6. Find a cut by its code, or search by room name. Searching several cut codes together, such as `F27 + F28`, finds their pieces together. **Show on plan** highlights the source board’s pieces or an individual cut, with room buttons when a source is reused across rooms. A visible **Search active** banner stays above the pinned drawing; **Show all pieces** clears the search there. Source boards have **Mark done** checkboxes; completion marks appear on their list entries, installed pieces and printed sheets. Room search results also let you locate and mark uncut boards without adding cut labels to them. Measurements, wall order, flooring direction or material changes clear completion because the allocation changes; renaming rooms keeps it.
7. Copy the link to save or share the complete project. Measurements, materials and view choices live in readable version 2 query parameters such as `flooring.length=1200`, `skirting.length=2400`, `room.1.wall.1.length=4800` and `selectedCut=F1`. Progress uses repeated `done=floor:B4` or `done=skirting:S6` parameters; `search=F1` and repeated `collapsed=materials` parameters preserve the search and closed panels. You can edit values directly in the URL. Reloading or opening that link reproduces the plan. Browser back/forward restores room changes. The same parameters are saved automatically in localStorage under `floor-planner.project`. Opening the app without URL parameters restores the last saved plan; an explicit project query takes precedence and becomes the saved plan. If localStorage is unavailable or full, URL saving still works. There is no account, API or database. Fonts are system fonts; the app makes no external font requests.
5. Room measurements, materials and cut plans open and close independently on the same page. The drawing stays pinned on the right while you scroll the panels; on phones a compact drawing stays above them. The **Floorboards / Skirting** switch above the drawing shows one material at a time, with the whole drawing fitting without an internal scrollbar. The material choice is saved with the plan. Locating or searching for a cut switches to its material automatically; printing includes both materials. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Cut codes sit above the wall and skirting drawing; short pieces use nearby labels with pointers so their names remain visible. The compact drawing header shows only the room, area and material legend. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Flooring and skirting cuts are grouped by the purchased board they come from, including offcuts reused across rooms. Group headings use the cut codes; internal stock IDs stay out of the interface. Pieces wrap side by side when space allows. Each piece has a small preview that opens dimensions directly on its shape. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Numbered skirting pieces and their labels open cut details; uncut lengths use a neutral fill. Print all room drawings followed by flooring and skirting cut sheets grouped by purchased board. Every cut is included once, with its room and row or wall.
6. Find a cut by its code, or search by room name. Searching several cut codes together, such as `F27 + F28`, finds their pieces together. **Show on plan** highlights the source board’s pieces or an individual cut, with room buttons when a source is reused across rooms. A **Search active** or **Location active** banner stays above the pinned drawing; **Show all pieces** clears it. Typed search text and explicit board locations are saved separately. Source boards have **Mark done** checkboxes; completion marks appear on their list entries, installed pieces and printed sheets. Room search results also let you locate and mark uncut boards without adding cut labels to them. Measurements, wall order, flooring direction or material changes clear completion because the allocation changes; renaming rooms keeps it.
7. Copy the link to save or share the complete project. A version 3 JSON payload in `?plan=...` stores measurements, materials, completion marks and view choices. Piece selections include their material, so flooring F1 and a later room’s skirting F1 open the correct preview. Existing version 2 query links still open through a compatibility importer and are saved in the new format. Reloading or opening the link reproduces the plan. Browser back/forward restores room changes. The same link is saved automatically in localStorage under `floor-planner.project`. Opening the app without a project query restores the last saved plan; an explicit project query takes precedence. If localStorage is unavailable or full, URL saving still works. There is no account, API or database. Fonts are system fonts; the app makes no external font requests.
## Calculation rules and practical limits
@ -46,28 +46,35 @@ New plans start with a random laying pattern. Click the numbered room, materials
- 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 coped joints. The blank keeps the wall-corner long point instead of subtracting the receiving board’s thickness; the cope preparation is half the measured turn. Earlier walls receive adjoining later walls, so installation order has no cycle. At square corners, a 4,000 mm wall keeps a 4,000 mm blank; with 18 mm skirting at both corners its exposed front length is 3,964 mm. Non-square receiving ends have a wall-fitting bevel. External corners use equal mitres. Minimum sizes check the exposed face and installed body after corner and door allowances. Preparation previews and dashed body edges guide marking; the actual moulding profile must be scribed and back-cut, since thickness alone does not define it. This follows [Gary Katz’s coping and long-point measurement guidance](https://www.finehomebuilding.com/pdf/021174063.pdf); the general-angle allowances are derived from intersecting wall faces.
- Skirting cut lengths are the longest-point envelope. Cut square blanks first, then prepare the mitres and cope the indicated internal ends; this keeps reusable stock remainders square. Back lengths and diagram positions locate the wall-facing edge. The stock allocation includes crosscut kerf and counts additional bevel and coping operations.
- Random flooring offers **Minimise cuts** and **Maximise randomness**, with the actual cut count and waste percentage beside each choice. Minimise cuts ranks saw passes first, then simpler cuts (fewer shaping passes, then fewer rips), visual repetition and finally purchased boards. Maximise randomness ranks visual repetition first, then the same cutting and waste priorities. The choice is saved as `flooring.optimisation=cuts` or `flooring.optimisation=random`; changing it clears completion marks because stock allocation can change. Brick prioritises cutting effort and then waste.
- Flooring uses a bounded beam search that preserves factory-ended offcuts for later exact fits. Completed candidates are repacked, and interchangeable full-width rectangular rows can be reordered without adding cuts. Both options share clipping results, and the best completed candidates are compared before displaying totals. Skirting compares full-stock, balanced and offcut-aware splits, including exact three-piece stock combinations, preferring fewer cuts and then less waste. Available offcuts can move the next wall’s joints. Minimum lengths are reserved on the exposed face and full-thickness body of angled and coped pieces. These are deterministic, auditable heuristics rather than guarantees of a global minimum. Room order remains an input. See [the benchmark definitions and iteration results](benchmarks/README.md).
- Random flooring offers **Minimise cuts** and **Maximise randomness**, with the actual cut count and waste percentage beside each choice. Minimise cuts ranks saw passes first, then simpler cuts (fewer shaping passes, then fewer rips), visual repetition and finally purchased boards. Maximise randomness ranks visual repetition first, then the same cutting and waste priorities. The choice is saved with the project; changing it clears completion marks because stock allocation can change. Brick prioritises cutting effort and then waste.
- Flooring uses a bounded beam search that preserves factory-ended offcuts for later exact fits. Completed candidates are repacked. Both options share clipping results, and the best completed candidates are compared before displaying totals. Skirting compares full-stock, balanced and offcut-aware splits, packed in decreasing length order, preferring fewer cuts and then less waste. Available offcuts can move the next wall’s joints. Minimum lengths are reserved on the exposed face and full-thickness body of angled and coped pieces. These are deterministic, auditable heuristics rather than guarantees of a global minimum. Room order remains an input. See [the benchmark definitions and iteration results](benchmarks/README.md).
- 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` | Readable query schema and input validation |
| `src/lib/limits.ts` | Shared project, persistence and calculation bounds |
| `src/lib/piece-labels.ts` | Collision-free cut-code placement and pointers for small pieces |
| `src/lib/board-tracking.ts` | Exact board/cut matching and room-name search |
| `src/lib/wall-editing.ts` | Wall insertion and reordering with stable IDs |
| `src/lib/use-project.ts` | URL, localStorage and browser-history synchronization |
| `src/components/` | Editors, dimensioned SVGs, board groups and printable cut sheets |
| `tests/` | Pure calculation and state tests |
| `e2e/` | Actual browser workflows at desktop and mobile sizes |
| 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` | Versioned payload encoding and import validation |
| `src/lib/legacy-query.ts` | Read-only importer for existing version 2 links |
| `src/lib/project-state.ts` | Edit actions and allocation invalidation |
| `src/lib/planning.ts` | Reuse geometry and plans when their calculation inputs stay unchanged |
| `src/lib/drawing-geometry.ts` | Shared bounds and collision primitives for drawing labels |
| `src/lib/use-printing.ts` | Mount print sheets on native print events and release them afterward |
| `src/lib/limits.ts` | Shared project, persistence and calculation bounds |
| `src/lib/piece-labels.ts` | Collision-free cut-code placement and pointers for small pieces |
| `src/lib/board-tracking.ts` | Exact board/cut matching and room-name search |
| `src/lib/wall-editing.ts` | Wall insertion and reordering with stable IDs |
| `src/lib/use-project.ts` | URL, localStorage and browser-history synchronization |
| `src/components/` | Editors, dimensioned SVGs, board groups and printable cut sheets |
| `tests/` | Pure calculation and state tests |
| `e2e/` | Actual browser workflows at desktop and mobile sizes |
Only the inputs, completion marks and view selections are persisted. Incomplete numeric drafts exist only while editing a field. Geometry, plans, quantities, warning messages and drawings are derived. There are no timestamps or random-number calls in the planners. `npm run build` also checks unused variables and strict TypeScript types.
Room renames and completion marks reuse existing plans. Geometry is built once per changed outline and shared by both material planners. Cut groups and counts are derived once for the screen and print views. Printed sheets mount only when printing begins, including native keyboard printing.

View file

@ -41,22 +41,22 @@ Totals across the same 84 cases, September 2026:
| Metric | Original | Minimise cuts | Maximise randomness |
| ------------------- | -------: | ------------: | ------------------: |
| Flooring saw passes | 2,177 | **1,741** | **2,113** |
| Flooring saw passes | 2,177 | **1,741** | **2,122** |
| Shaping passes | 192 | 184 | 188 |
| Rip passes | 414 | 402 | 409 |
| Legacy penalty | 265 | 2,207 | 346 |
| Flooring boards | 3,561 | 3,548 | 3,551 |
| Flooring waste | 8.20% | 7.85% | 7.94% |
| Rip passes | 414 | 402 | 411 |
| Legacy penalty | 265 | 2,279 | 350 |
| Flooring boards | 3,561 | 3,548 | 3,550 |
| Flooring waste | 8.20% | 7.85% | 7.91% |
| Invalid cases | 0 | 0 | 0 |
Minimise cuts saves **20.0%** of saw passes. Maximise randomness saves **2.9%** of saw
Minimise cuts saves **20.0%** of saw passes. Maximise randomness saves **2.5%** of saw
passes against the original planner. For random flooring in the default
4,800 × 3,600 mm room, 38 passes become **24** or **31**, respectively; both options use 76
boards and have approximately 1.2% waste.
Skirting drops from **1,447 to 1,436 passes**. It uses 521 boards instead of 513:
cutting effort now takes precedence over waste, which rises from 9.28% to 10.68%.
Eight cases improve and 76 tie.
Skirting drops from **1,447 to 1,437 passes**. It uses 522 boards instead of 513:
cutting effort now takes precedence over waste, which rises from 9.28% to 10.85%.
Seven cases improve and 77 tie.
For flooring, 78 cut-priority cases improve and six tie against the original planner.
The bounded search does not establish global optimality.
@ -70,14 +70,14 @@ numbers. A final bit-mixing step now spreads those candidates across the allowed
| Metric | Cuts before | Cuts now | Random before | Random now |
| ------------------------- | ----------: | -------: | ------------: | ---------: |
| Near-H row pairs | 515 | 247 | 557 | **6** |
| Saw passes | 1,741 | 1,741 | 1,994 | 2,113 |
| Boards | 3,537 | 3,548 | 3,579 | 3,551 |
| Waste | 7.57% | 7.85% | 8.66% | 7.94% |
| Near-H row pairs | 515 | 269 | 557 | **6** |
| Saw passes | 1,741 | 1,741 | 1,994 | 2,122 |
| Boards | 3,537 | 3,548 | 3,579 | 3,550 |
| Waste | 7.57% | 7.85% | 8.66% | 7.91% |
| Default-room near-H pairs | 17 | 17 | 11 | **0** |
| Default-room saw passes | 24 | 24 | 30 | 31 |
Appearance priority removes **98.9%** of near-H pairs for **6.0%** more cuts than the
Appearance priority removes **98.9%** of near-H pairs for **6.4%** more cuts than the
previous iteration. All six remaining pairs are in `forced-alternation`, whose
600 mm minimum pieces force alternating starts. It improves 79 cases and ties five
under the corrected objective. Ten additional room seeds also produce zero near-H pairs.
@ -96,19 +96,15 @@ this beside the laying-pattern choice. Both frozen baselines remain unchanged.
searching offcuts for pieces requiring both factory ends.
3. Repack completed boundary blanks, reserving exact complementary factory ends
first. Preserve the original placement and cutting order within each source board.
4. Try bounded swaps of interchangeable rectangular rows. Accept only a lower
pattern penalty with unchanged cuts, stock and geometry coverage.
5. Polish all surviving completed candidates, retaining the best result.
6. Preserve end remnants during search instead of trimming them immediately.
4. Polish all surviving completed candidates, retaining the best result.
5. Preserve end remnants during search instead of trimming them immediately.
This alone improves 56 cut-priority cases and saves another 99 passes after the
earlier improvements. Final packing can still combine non-exact blanks.
7. When repetition remains, compare an immediate-reuse search as well. This improves
6. When repetition remains, compare an immediate-reuse search as well. This improves
five appearance-priority cases. All searches share their clipping cache, and the
two UI choices select from the completed results. Selection itself does not rerun
the optimiser.
8. Compare exact three-piece skirting fills with the original packing. This improves
two cases for a small additional calculation cost.
9. Add the board-scale H penalty and mix the offset hash. Search size and geometry
7. Add the board-scale H penalty and mix the offset hash. Search size and geometry
constraints stay unchanged. Before/after drawings were checked for the default
rectangle, fireplace and large floor, alongside the numeric metrics.
@ -130,3 +126,22 @@ without an error; temporary stock is repacked before enforcing the board limit.
Tests enforce aggregate cutting budgets, broad H avoidance and physical invariants.
The original metrics and cases remain fixed; the new H metric has its own frozen
baseline so the correction does not rewrite the earlier results.
## Simplification tradeoffs
Removing row-swap polishing preserves all 1,741 cuts-first passes. Across the 84
cases, appearance-first passes change from 2,113 to 2,122 (+0.43%), boards from 3,551
to 3,550, and the legacy penalty from 346 to 350. Cuts-first near-H pairs change
from 247 to 269; appearance-first remains at six, all in the forced-alternation case.
Removing the exact three-piece skirting packing variant changes 1,436 passes to
1,437 and 521 boards to 522. Both frozen historical baselines remain unchanged.
The conditional immediate-reuse search stays: removing it introduced a near-H pair
outside the forced-alternation case. Distinct room-label and cut-preview placement
strategies also stay; their bounds and collision primitives are shared. There is
not enough evidence to replace these strategies without losing useful behavior.
Tests retain physical invariants, the two option priorities, near-H avoidance,
and aggregate budgets relative to the original baseline (at least 19% fewer
cuts-first passes; appearance-first and skirting use fewer passes than originally).
They no longer freeze the exact totals of a particular heuristic iteration.

View file

@ -58,7 +58,6 @@ export function plannerCases(): PlannerCase[] {
name: 'angled',
project: {
...angled.project,
version: defaultProject().version,
flooring: { ...defaultProject().flooring, ...angled.project.flooring },
} as Project,
},
@ -66,7 +65,6 @@ export function plannerCases(): PlannerCase[] {
name: 'angled-f8',
project: {
...angledF8.project,
version: defaultProject().version,
flooring: { ...defaultProject().flooring, ...angledF8.project.flooring },
} as Project,
},

View file

@ -43,8 +43,9 @@ for (const [name, selector] of [
await page.locator(selector).locator('a[href="?planner"]').click();
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800');
await expect(page).toHaveURL(/\?version=\d+&/);
await expect(page).toHaveURL(/\?plan=/);
await page.getByLabel('Room name', { exact: true }).fill('My first floor');
await page.getByLabel('Room name', { exact: true }).blur();
const projectUrl = page.url();
await page.reload();
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('My first floor');
@ -59,7 +60,9 @@ for (const [name, selector] of [
test('returning through the home page resumes the saved project', async ({ page }) => {
await page.goto('/?planner');
await page.getByLabel('Room name', { exact: true }).fill('Weekend project');
await page.getByLabel('Room name', { exact: true }).blur();
await page.getByLabel('Room width', { exact: true }).fill('4200');
await page.getByLabel('Room width', { exact: true }).blur();
await page.getByRole('link', { name: 'Floor planner home' }).click();
await expect(page.getByRole('heading', { level: 1 })).toContainText('A great floor');
await page.getByRole('link', { name: 'Plan my floor' }).first().click();

View file

@ -27,12 +27,14 @@ test('random optimisation choices show actual totals, select the plan and surviv
expect(options.cuts.cuts).toBeLessThan(options.random.cuts);
const checkbox = page.locator('.stock-cut-group input[type="checkbox"]').first();
await checkbox.check();
expect(new URL(page.url()).searchParams.getAll('done')).not.toEqual([]);
expect(decodeState(new URL(page.url()).search).state.project.completedBoards).not.toEqual([]);
await random.click();
await expect(random).toHaveAttribute('aria-pressed', 'true');
await expect(cuts).toHaveAttribute('aria-pressed', 'false');
expect(new URL(page.url()).searchParams.get('flooring.optimisation')).toBe('random');
expect(new URL(page.url()).searchParams.getAll('done')).toEqual([]);
expect(decodeState(new URL(page.url()).search).state.project.flooring.optimisation).toBe(
'random',
);
expect(decodeState(new URL(page.url()).search).state.project.completedBoards).toEqual([]);
const drawn = page.getByRole('region', { name: 'Floor plan preview' }).locator('.floor-piece');
await expect(drawn).toHaveCount(options.random.pieces.length);
const ids = await drawn.evaluateAll((pieces) =>

View file

@ -88,7 +88,7 @@ async function wallDimensionsInsideSvg(page: Page, wallNumber: number) {
.filter({ hasText: new RegExp(`W${wallNumber} ·`) });
await expect(wall.locator('.wall-label text')).toBeVisible();
const result = await wall.evaluate((element) => {
const svg = element.ownerSVGElement!;
const svg = (element as SVGElement).ownerSVGElement!;
const box = svg.viewBox.baseVal;
const inverse = svg.getScreenCTM()!.inverse();
const rootPoint = (element: SVGGraphicsElement, x: number, y: number) =>
@ -109,7 +109,6 @@ async function wallDimensionsInsideSvg(page: Page, wallNumber: number) {
rootPoint(label, bounds.x, bounds.y + bounds.height),
];
return {
lineCount: lines.length,
outside: [...endpoints, ...corners]
.filter(
(point) =>
@ -121,7 +120,6 @@ async function wallDimensionsInsideSvg(page: Page, wallNumber: number) {
.map((point) => ({ x: point.x, y: point.y })),
};
});
expect(result.lineCount).toBe(4);
expect(
result.outside,
`W${wallNumber} lines, ticks and label must fit inside the SVG viewBox`,
@ -266,7 +264,7 @@ test.beforeEach(async ({ page }) => {
page.on('pageerror', (error) => errors.get(page)!.push(error.message));
await page.goto('/?planner');
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
await expect(page).toHaveURL(/\?version=2&/);
await expect(page).toHaveURL(/\?plan=/);
});
test.afterEach(async ({ page }) => {
@ -405,10 +403,12 @@ test('inconsistent final measurements block cuts and small residuals show the ex
}) => {
await openWalls(page);
await page.getByLabel('Wall 4 length', { exact: true }).fill('3700');
await page.getByLabel('Wall 4 length', { exact: true }).blur();
await expect(page.getByText(/The outline is open by 100\.0 mm/).first()).toBeVisible();
await expect(floorEntries(page)).toHaveCount(0);
await expect(skirtEntries(page)).toHaveCount(0);
await page.getByLabel('Wall 4 length', { exact: true }).fill('3605');
await page.getByLabel('Wall 4 length', { exact: true }).blur();
await expect(page.getByText(/non-straight wall/)).toBeVisible();
await expect(page.getByText('Final wall: 3,600 mm expected · 5 mm difference')).toBeVisible();
expect(await floorEntries(page).count()).toBeGreaterThan(0);
@ -417,9 +417,11 @@ test('inconsistent final measurements block cuts and small residuals show the ex
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 page.getByLabel('Wall 4 length', { exact: true }).blur();
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');
await page.getByLabel('Room name', { exact: true }).blur();
expect(state(page).project.rooms.map((room) => room.name)).toEqual([
'Living room',
'Living room',
@ -429,6 +431,7 @@ test('room warnings stay with their room even when two room names match', async
await expect(page.getByText(/non-straight wall/).first()).toBeVisible();
await openWalls(page);
await page.getByLabel('Wall 4 length', { exact: true }).fill('3700');
await page.getByLabel('Wall 4 length', { exact: true }).blur();
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);
@ -440,14 +443,17 @@ test('custom walls close using a calculated wall that can be measured again', as
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.getByLabel('Wall 2 length', { exact: true }).blur();
await page.getByRole('button', { name: 'Add a wall', exact: true }).click();
await page.getByLabel('Wall 3 length', { exact: true }).fill('4000');
await page.getByLabel('Wall 3 length', { exact: true }).blur();
await expect(page.getByText('3,000 mm to close')).toBeVisible();
await page.getByRole('button', { name: 'Add closing wall' }).click();
expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0);
await openWalls(page);
await expect(page.getByLabel('Wall 4 length', { exact: true })).toHaveValue('3000');
await page.getByLabel('Wall 4 length', { exact: true }).fill('3050');
await page.getByLabel('Wall 4 length', { exact: true }).blur();
await expect(page.getByText(/3,000 mm expected; length differs by 50 mm/)).toBeVisible();
});
@ -554,7 +560,6 @@ test('internal skirting corners retain coping blanks and expose the installed mi
await expect(page.locator('.skirting-cuts .cut-summary')).toContainText(
'Buy 4 lengths · 1 installed uncut',
);
const piece = skirt.pieces.find((piece) => piece.id === 'A1')!;
const entry = skirtEntries(page).filter({
has: page.getByRole('button', { name: 'Preview skirting cut A1', exact: true }),
});
@ -582,11 +587,11 @@ test('internal skirting corners retain coping blanks and expose the installed mi
expect(Math.abs(geometry.preparationX)).toBeCloseTo(18, 3);
expect(Math.abs(geometry.preparationY)).toBeCloseTo(18, 3);
expect(geometry.installedX).toBeCloseTo(0, 3);
expect(piece.leftOverlap).toBeCloseTo(18, 3);
await page.keyboard.press('Escape');
const floorCount = await floorEntries(page).count();
await page.getByText('Advanced skirting settings', { exact: true }).click();
await page.getByLabel('Minimum piece', { exact: true }).fill('2365');
await page.getByLabel('Minimum piece', { exact: true }).blur();
await expect(skirtEntries(page)).toHaveCount(0);
await expect(floorEntries(page)).toHaveCount(floorCount);
await expect(page.locator('.issue.error').first()).toBeVisible();
@ -602,7 +607,9 @@ test('door positions and material lengths update skirting immediately', async ({
'W1: measure from W4 corner toward W2 corner.',
);
await movedDoor.getByLabel(/^From W\d+ (corner|start)$/).fill('1200');
await movedDoor.getByLabel(/^From W\d+ (corner|start)$/).blur();
await movedDoor.getByLabel('Door width', { exact: true }).fill('900');
await movedDoor.getByLabel('Door width', { exact: true }).blur();
expect(state(page).project.rooms[0].walls[0].doors[0]).toMatchObject({
offset: 1200,
width: 900,
@ -613,13 +620,16 @@ test('door positions and material lengths update skirting immediately', async ({
);
expect(await page.locator('.skirting-cuts').innerText()).not.toBe(before);
await page.getByLabel('Skirting board length', { exact: true }).fill('3600');
await page.getByLabel('Skirting board length', { exact: true }).blur();
await page.getByLabel('Skirting thickness', { exact: true }).fill('22');
await page.getByLabel('Skirting thickness', { exact: true }).blur();
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 movedDoor.getByLabel('Door width', { exact: true }).fill('6000');
await movedDoor.getByLabel('Door width', { exact: true }).blur();
await expect(page.getByText(/must fit within the wall/).first()).toBeVisible();
await expect(skirtEntries(page)).toHaveCount(0);
});
@ -627,10 +637,12 @@ test('door positions and material lengths update skirting immediately', async ({
test('invalid skirting settings keep flooring results usable', async ({ page }) => {
const floorCount = await floorCodes(page).count();
await page.getByLabel('Skirting board length', { exact: true }).fill('100');
await page.getByLabel('Skirting board length', { exact: true }).blur();
await expect(skirtEntries(page)).toHaveCount(0);
await expect(floorCodes(page)).toHaveCount(floorCount);
await expect(page.locator('.issue.error').first()).toBeVisible();
await page.getByLabel('Skirting board length', { exact: true }).fill('2400');
await page.getByLabel('Skirting board length', { exact: true }).blur();
expect(await skirtEntries(page).count()).toBeGreaterThan(0);
});
@ -665,19 +677,13 @@ test('pattern, direction and spacers persist without steps, layers or stored res
page,
}) => {
await page.getByLabel('Room length', { exact: true }).fill('3650');
await page.getByLabel('Room length', { exact: true }).blur();
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');
await page.getByLabel('Wall spacer', { exact: true }).blur();
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([
'collapsedSections',
'material',
'roomId',
'search',
'selectedPiece',
]);
expect(Object.keys(state(page)).sort()).toEqual(['project', 'view']);
const table = await page.locator('.flooring-cuts').innerText();
await page.reload();
await expect(page.getByLabel('Wall spacer', { exact: true })).toHaveValue('12');
@ -698,7 +704,7 @@ test('localStorage restores all project and view parameters when reopening witho
context,
}) => {
const fixture = sharedRoomFixture();
fixture.project.name = 'Café 北 flooring';
fixture.project.rooms[0].name = 'Café 北 flooring';
fixture.project.tolerance = 7;
fixture.project.flooring = {
...fixture.project.flooring,
@ -717,7 +723,8 @@ test('localStorage restores all project and view parameters when reopening witho
fixture.project.completedBoards = ['floor:B1', 'skirting:S1'];
fixture.view = {
roomId: 'room-2',
selectedPiece: 'F1',
selectedPiece: 'flooring:F1',
focus: null,
search: 'Study',
collapsedSections: ['cuts', 'materials'],
material: 'flooring',
@ -739,6 +746,7 @@ test('localStorage restores all project and view parameters when reopening witho
await expect(reopened.locator('#materials-content')).toBeHidden();
await expect(reopened.locator('#cuts-content')).toBeHidden();
await reopened.getByLabel('Room name', { exact: true }).fill('Saved study');
await reopened.getByLabel('Room name', { exact: true }).blur();
fixture.project.rooms[1].name = 'Saved study';
fixture.view.selectedPiece = null;
expect(await savedQuery(reopened)).toBe(encodeState(fixture));
@ -752,7 +760,9 @@ test('explicit query parameters override the saved plan and default omitted fiel
page,
}) => {
await page.getByLabel('Room name', { exact: true }).fill('Saved room');
await page.getByLabel('Room name', { exact: true }).blur();
await page.getByLabel('Wall spacer', { exact: true }).fill('12');
await page.getByLabel('Wall spacer', { exact: true }).blur();
await page.goto('/?flooring.length=1400');
await expect(page.getByLabel('Board length', { exact: true })).toHaveValue('1400');
const expected = defaultState();
@ -768,7 +778,7 @@ for (const saved of ['damaged data', '?flooring.length=broken']) {
[PROJECT_STORAGE_KEY, saved],
);
await page.goto('/?planner');
await expect(page).toHaveURL(/\?version=2&/);
await expect(page).toHaveURL(/\?plan=/);
await expect(page.locator('.url-error')).toHaveCount(0);
expect(state(page)).toEqual(defaultState());
expect(await savedQuery(page)).toBe(encodeState(defaultState()));
@ -793,6 +803,7 @@ for (const failure of ['blocked', 'full']) {
await page.goto('/?planner');
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Living room');
await page.getByLabel('Room name', { exact: true }).fill('URL still works');
await page.getByLabel('Room name', { exact: true }).blur();
expect(state(page).project.rooms[0].name).toBe('URL still works');
await page.reload();
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('URL still works');
@ -803,6 +814,7 @@ for (const failure of ['blocked', 'full']) {
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');
await page.getByLabel('Room length', { exact: true }).blur();
const { floor } = plans(page);
for (const row of [1, 20]) {
const piece = flooringCuts(floor).find((piece) => piece.row === row);
@ -818,7 +830,9 @@ test('multiple rooms retain independent measurements with combined cut lists', a
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 name', { exact: true }).blur();
await page.getByLabel('Room width', { exact: true }).fill('3200');
await page.getByLabel('Room width', { exact: true }).blur();
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);
@ -839,6 +853,7 @@ test('shared URLs reopen the selected preview and copying the link preserves the
context,
}) => {
await page.getByLabel('Room name', { exact: true }).fill('Café 北');
await page.getByLabel('Room name', { exact: true }).blur();
const { floor } = plans(page);
const pieceCode = flooringCuts(floor)[0].id;
await page
@ -847,7 +862,7 @@ test('shared URLs reopen the selected preview and copying the link preserves the
await expect(
page.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }),
).toBeVisible();
expect(state(page).view.selectedPiece).toBe(pieceCode);
expect(state(page).view.selectedPiece).toBe(`flooring:${pieceCode}`);
const shareUrl = page.url();
const originalTable = await page.locator('.flooring-cuts').innerText();
const shared = await context.newPage();
@ -905,6 +920,7 @@ test('invalid URLs recover when measurements are edited', async ({ page }) => {
await expect(page.getByText(/Could not load this plan/)).toBeVisible();
expect(await savedQuery(page)).toBe(saved);
await page.getByLabel('Room name', { exact: true }).fill('Recovered plan');
await page.getByLabel('Room name', { exact: true }).blur();
await expect(page.getByText(/Could not load this plan/)).toHaveCount(0);
expect(decodeState(new URL(page.url()).search).error).toBeNull();
expect(new URL(page.url()).hash).toBe('');
@ -928,31 +944,32 @@ test('clicking the skirting code itself opens its cutting dialog', async ({ page
await expect(
page.getByRole('dialog', { name: `Skirting cut ${id}`, exact: true }),
).toBeVisible();
expect(state(page).view.selectedPiece).toBe(id);
expect(state(page).view.selectedPiece).toBe(`skirting:${id}`);
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0);
}
});
test('readable query fields and browser history preserve the plan', async ({ page }) => {
test('the versioned payload and browser history preserve the plan', async ({ page }) => {
const initial = defaultState();
initial.project.rooms[0].name = 'Current lounge';
initial.project.flooring.pattern = 'random';
await page.goto(`/${encodeState(initial)}`);
await expect(page).toHaveURL(/\?version=2&/);
await expect(page).toHaveURL(/\?plan=/);
expect(state(page)).toEqual(initial);
await expect.poll(() => savedQuery(page)).toBe(encodeState(initial));
const original = page.url();
await page.getByRole('button', { name: 'Add room', exact: true }).click();
await page.getByLabel('Room name', { exact: true }).fill('Study');
await page.getByLabel('Room name', { exact: true }).blur();
await page.getByLabel('Room width', { exact: true }).fill('3333');
await page.getByLabel('Room width', { exact: true }).blur();
const twoRooms = page.url();
const params = new URL(twoRooms).searchParams;
expect(params.get('room.2.name')).toBe('Study');
expect(params.get('room.2.wall.1.length')).toBe('3333');
expect(params.get('flooring.pattern')).toBe('random');
expect(params.has('plan')).toBe(false);
expect([...params.keys()].some((key) => /boardCount|polygon|pieceIds/.test(key))).toBe(false);
expect(state(page).project.rooms[1]).toMatchObject({
name: 'Study',
walls: [{ length: 3333 }, {}, {}, {}],
});
expect(state(page).project.flooring.pattern).toBe('random');
await page.goBack();
await expect(page).toHaveURL(original);
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Current lounge');
@ -963,8 +980,9 @@ test('readable query fields and browser history preserve the plan', async ({ pag
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study');
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('3333');
expect(await savedQuery(page)).toBe(new URL(twoRooms).search);
params.set('flooring.width', '205');
await page.goto(`/?${params}`);
const edited = state(page);
edited.project.flooring.width = 205;
await page.goto(`/${encodeState(edited)}`);
await expect(page.getByLabel('Board width', { exact: true })).toHaveValue('205');
await page.reload();
await expect(page.getByLabel('Board width', { exact: true })).toHaveValue('205');
@ -992,6 +1010,7 @@ test('printing includes every room and every cut once, grouped by its source boa
).toBe(true);
await expect(page.locator('.print-plan')).toBeHidden();
await page.emulateMedia({ media: 'print' });
await expect(page.locator('.print-plan')).toBeVisible();
const printed = page.locator('.print-plan');
await expect(printed).toBeVisible();
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeHidden();
@ -1043,8 +1062,7 @@ test('panels collapse independently and the room stays pinned while reading cuts
await panel(page, section).locator('.panel-toggle').click();
await expect(page.locator(`#${section}-content`)).toBeHidden();
}
expect(state(page).view.collapsedSections).toEqual(['cuts', 'materials', 'measurements']);
expect(new URL(page.url()).searchParams.getAll('collapsed')).toEqual([
expect(state(page).view.collapsedSections.slice().sort()).toEqual([
'cuts',
'materials',
'measurements',
@ -1083,7 +1101,7 @@ test('drawing cuts still open a usable preview when the cut plan is collapsed',
const dialog = page.getByRole('dialog', { name: 'Flooring cut F1', exact: true });
await expect(dialog).toBeVisible();
await expect(dialog.locator('.cut-dimensions')).toBeVisible();
expect(state(page).view.selectedPiece).toBe('F1');
expect(state(page).view.selectedPiece).toBe('flooring:F1');
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
await expect(page.locator('#cuts-content')).toBeHidden();
@ -1100,7 +1118,7 @@ test('negative angles can be backspaced and retyped without saving unfinished dr
await turn.press('End');
await turn.press('Backspace');
await expect(turn).toHaveValue('-9');
expect(state(page).project.rooms[0].walls[3].turn).toBe(-9);
expect(state(page).project.rooms[0].walls[3].turn).toBe(-90);
const committed = page.url();
await turn.press('Backspace');
await expect(turn).toHaveValue('-');
@ -1108,7 +1126,15 @@ test('negative angles can be backspaced and retyped without saving unfinished dr
await turn.press('Backspace');
await expect(turn).toHaveValue('');
expect(page.url()).toBe(committed);
await turn.pressSequentially('-90');
await turn.pressSequentially('-45');
expect(page.url()).toBe(committed);
await turn.press('Escape');
await expect(turn).toHaveValue('-90');
await turn.fill('-80');
await turn.press('Enter');
expect(state(page).project.rooms[0].walls[3].turn).toBe(-80);
await turn.fill('-90');
await turn.blur();
await expect(turn).toHaveValue('-90');
expect(state(page).project.rooms[0].walls[3].turn).toBe(-90);
expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0);
@ -1390,14 +1416,17 @@ test('completed source boards and locate actions follow shared offcuts across ro
await done.check();
await expect(group).toHaveClass(/is-done/);
expect(state(page).project.completedBoards).toEqual([key]);
expect(new URL(page.url()).searchParams.getAll('done')).toEqual([key]);
await group.getByRole('button', { name: `Show cuts ${groupCodes} on plan`, exact: true }).click();
expect(state(page).view.search).toBe(key);
expect(state(page).view.focus).toEqual({ kind: 'board', material: 'flooring', id: shared.id });
expect(state(page).view.search).toBe('');
const search = page.getByLabel('Find a cut or room', { exact: true });
await expect(search).toHaveValue(groupCodes);
await expect(search).toHaveValue('');
await expect(page.locator('.plan-search-status')).toContainText(groupCodes);
await noVisibleStockIds(page);
await search.fill('');
await search.blur();
await search.fill(groupCodes);
await search.blur();
expect(state(page).view.search).toBe(groupCodes);
await noVisibleStockIds(page);
expect(await highlightedIds(page, 'flooring')).toEqual(
@ -1425,19 +1454,21 @@ test('completed source boards and locate actions follow shared offcuts across ro
const piece = sourcePieces.find((piece) => piece.roomId === 'room-2')!;
await page.getByRole('button', { name: `Show cut ${piece.id} on plan`, exact: true }).click();
expect(state(page).view.roomId).toBe('room-2');
expect(state(page).view.search).toBe(piece.id);
expect(state(page).view.focus).toEqual({ kind: 'piece', material: 'flooring', id: piece.id });
expect(await highlightedIds(page)).toEqual([piece.id]);
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(group.locator('.board-cut')).toHaveCount(sourcePieces.length);
await page.reload();
await expect(page.getByLabel('Find a cut or room', { exact: true })).toHaveValue(piece.id);
await expect(page.getByLabel('Find a cut or room', { exact: true })).toHaveValue('');
await expect(page.locator('.plan-search-status')).toContainText(piece.id);
await expect(done).toBeChecked();
expect(await highlightedIds(page)).toEqual([piece.id]);
await page.getByLabel('Room name', { exact: true }).fill('Office');
await page.getByLabel('Room name', { exact: true }).blur();
expect(state(page).project.completedBoards).toEqual([key]);
await page.locator('.room-tab').first().click();
expect(state(page).project.completedBoards).toEqual([key]);
await page.getByLabel('Find a cut or room', { exact: true }).fill('');
await page.getByRole('button', { name: 'Show all pieces', exact: true }).click();
const skirtPiece = skirtingCuts(skirt).find((piece) => piece.roomId === 'room-1')!;
const skirtKey = `skirting:${skirtPiece.stockId}`;
await page
@ -1451,9 +1482,9 @@ test('completed source boards and locate actions follow shared offcuts across ro
.click();
expect(await preview(page).locator('.skirt-piece[data-done="true"]').count()).toBeGreaterThan(0);
await page.getByLabel('Board width', { exact: true }).fill('201');
await page.getByLabel('Board width', { exact: true }).blur();
expect(state(page).project.completedBoards).toEqual([]);
await expect(preview(page).locator('[data-done="true"]')).toHaveCount(0);
expect(new URL(page.url()).searchParams.getAll('done')).toEqual([]);
});
test('the pinned plan makes active search obvious and can restore every piece', async ({
@ -1461,6 +1492,7 @@ test('the pinned plan makes active search obvious and can restore every piece',
}) => {
const allCuts = await floorEntries(page).count();
await page.getByLabel('Find a cut or room', { exact: true }).fill('F1');
await page.getByLabel('Find a cut or room', { exact: true }).blur();
const banner = page.locator('.drawing-column .plan-search-status');
await expect(banner).toContainText('Search active');
await expect(banner).toContainText('F1');
@ -1508,18 +1540,17 @@ test('cuts from one board share the available width and wrap on a narrow phone',
await noVisibleStockIds(page);
});
test('search can locate and complete a whole board without inventing a cut code', async ({
page,
}) => {
test('locate and complete a whole board without inventing a cut code', async ({ page }) => {
const { floor } = plans(page);
const whole = floor.pieces.find((piece) => !piece.cut && !piece.reused)!;
expect(whole).toBeDefined();
const search = page.getByLabel('Find a cut or room', { exact: true });
await search.fill(`floor:${whole.stockId}`);
await search.fill('Living room');
const group = page.locator(`.stock-cut-group[data-board-key="floor:${whole.stockId}"]`);
await expect(group).toContainText('Installed uncut');
await group.getByRole('button', { name: 'Show uncut floorboard on plan', exact: true }).click();
await expect(search).toHaveValue('Uncut floorboard');
await expect(search).toHaveValue('');
await expect(page.locator('.plan-search-status')).toContainText('Uncut floorboard');
await noVisibleStockIds(page);
await expect(group.locator('.board-cut')).toHaveCount(0);
await expect(group.getByRole('button', { name: /^Preview/ })).toHaveCount(0);
@ -1533,6 +1564,7 @@ test('search can locate and complete a whole board without inventing a cut code'
'true',
);
await search.fill('nothing matches this room');
await search.blur();
await expect(page.locator('.search-results')).toHaveText('No matching boards or cuts.');
expect(await highlightedIds(page)).toEqual([]);
expect(state(page).project.completedBoards).toEqual([`floor:${whole.stockId}`]);
@ -1609,3 +1641,45 @@ for (const width of [320, 390]) {
await noHorizontalOverflow(page);
});
}
test('material-qualified selections disambiguate flooring and sixth-room skirting F1', async ({
page,
}) => {
const fixture = defaultState();
fixture.project.rooms = Array.from({ length: 6 }, (_, i) =>
rectangle(`room-${i + 1}`, `Room ${i + 1}`, 2400, 2400),
);
fixture.view.roomId = 'room-6';
fixture.view.material = 'skirting';
await page.goto(`/${encodeState(fixture)}`);
await preview(page).getByRole('button', { name: 'Select skirting cut F1', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toHaveAccessibleName('Skirting cut F1');
await expect(dialog).toContainText('Room 6');
await expect(dialog.locator('.flooring-preview')).toHaveCount(0);
expect(state(page).view.selectedPiece).toBe('skirting:F1');
await page.reload();
await expect(dialog).toHaveAccessibleName('Skirting cut F1');
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Preview flooring cut F1', exact: true }).click();
await expect(dialog).toHaveAccessibleName('Flooring cut F1');
await expect(dialog).toContainText('Room 1');
await expect(dialog.locator('.skirting-full-preview')).toHaveCount(0);
expect(state(page).view.selectedPiece).toBe('flooring:F1');
});
test('native print events build current sheets synchronously and release them afterward', async ({
page,
}) => {
await expect(page.locator('.print-plan')).toHaveCount(0);
await page.getByLabel('Room name', { exact: true }).fill('Print this room');
const printed = await page.evaluate(() => {
window.dispatchEvent(new Event('beforeprint'));
return document.querySelector('.print-plan')?.textContent;
});
expect(printed).toContain('Print this room');
expect(printed).toContain('Flooring cuts');
expect(printed).toContain('Skirting cuts');
await page.evaluate(() => window.dispatchEvent(new Event('afterprint')));
await expect(page.locator('.print-plan')).toHaveCount(0);
});

View file

@ -23,8 +23,10 @@ test('new plans start random and guide custom measurements through to the cut pl
await expect(page.getByLabel('Wall 1 length', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Add a wall', exact: true }).click();
await page.getByLabel('Wall 2 length', { exact: true }).fill('3000');
await page.getByLabel('Wall 2 length', { exact: true }).blur();
await page.getByRole('button', { name: 'Add a wall', exact: true }).click();
await page.getByLabel('Wall 3 length', { exact: true }).fill('4000');
await page.getByLabel('Wall 3 length', { exact: true }).blur();
await page.getByRole('button', { name: 'Add closing wall', exact: true }).click();
await expect(page.getByText('Outline closed', { exact: true })).toBeVisible();
await expect(page.getByLabel('Wall 4 length', { exact: true })).toBeVisible();
@ -41,6 +43,7 @@ test('new plans start random and guide custom measurements through to the cut pl
await expect(page.getByLabel('Saw kerf', { exact: true })).toBeHidden();
await page.getByText('Advanced flooring settings', { exact: true }).click();
await page.getByLabel('Saw kerf', { exact: true }).fill('4');
await page.getByLabel('Saw kerf', { exact: true }).blur();
expect(decodeState(new URL(page.url()).search).state.project.flooring.kerf).toBe(4);
await page.getByRole('button', { name: 'Cut plan', exact: true }).click();
@ -120,6 +123,7 @@ test('locating cuts changes material automatically and printing includes both ma
expect(await preview.locator('.skirt-piece[data-highlighted="true"]').count()).toBeGreaterThan(0);
await page.getByRole('button', { name: 'Show all pieces', exact: true }).click();
await page.getByLabel('Find a cut or room', { exact: true }).fill('F1');
await page.getByLabel('Find a cut or room', { exact: true }).blur();
await expect(toggle.getByRole('button', { name: 'Floorboards', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
@ -137,6 +141,7 @@ test('locating cuts changes material automatically and printing includes both ma
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await page.emulateMedia({ media: 'print' });
await expect(page.locator('.print-plan')).toBeVisible();
expect(await page.locator('.print-plan .floor-piece').count()).toBeGreaterThan(0);
expect(await page.locator('.print-plan .skirt-piece').count()).toBeGreaterThan(0);
});

View file

@ -1,17 +1,16 @@
import { useMemo, useRef } from 'react';
import { useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { Copy, Plus, Printer, Search, X } from 'lucide-react';
import { usePrinting } from './lib/use-printing';
import { useProject } from './lib/use-project';
import { trackCopyLink, trackPrint, trackRoomAdded, trackRoomRemoved } from './lib/analytics';
import { rectangle, nextId } from './lib/defaults';
import { buildGeometry } from './lib/geometry';
import { planFlooringOptions } from './lib/flooring';
import { planSkirting } from './lib/skirting';
import { createPlanCalculator } from './lib/planning';
import { encodeState } from './lib/url-state';
import { area, mm } from './lib/format';
import { flooringCuts, isSkirtingCut, skirtingCuts } from './lib/cut-list';
import type { PlanMaterial, PlannerSection, Project, Room } from './lib/types';
import { boardSearchLabel, findBoardMatches } from './lib/board-tracking';
import { PRECISION, PROJECT_LIMITS } from './lib/limits';
import { cutData } from './lib/cut-list';
import type { PlanMaterial, PlanFocus, PlannerSection, Room } from './lib/types';
import { focusLabel, findBoardMatches, pieceKey } from './lib/board-tracking';
import { PROJECT_LIMITS } from './lib/limits';
import { OutlineEditor } from './components/OutlineEditor';
import { MaterialsEditor } from './components/MaterialsEditor';
import { FloorPlan } from './components/FloorPlan';
@ -64,173 +63,80 @@ function PlannerPanel({
);
}
// Completion refers to this exact allocation; names and view preferences do not change it.
const allocationInputs = (project: Project) =>
JSON.stringify({
rooms: project.rooms.map(({ id, walls, direction }) => ({ id, walls, direction })),
flooring: project.flooring,
skirting: project.skirting,
tolerance: project.tolerance,
});
export default function App() {
const { state, error, update } = useProject();
const { state, error, dispatch } = useProject();
const { project, view } = state;
const printing = usePrinting();
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 [calculate] = useState(createPlanCalculator);
const { geometries, flooringOptions, skirting } = useMemo(
() => calculate(project),
[calculate, project],
);
const room = project.rooms.find((room) => room.id === view.roomId) ?? project.rooms[0];
const geometry = geometries[project.rooms.indexOf(room)];
// Switching between already calculated options only selects a result.
const flooringInputs = JSON.stringify({ ...project.flooring, optimisation: undefined });
const flooringOptions = useMemo(
() => planFlooringOptions(project.rooms, project.flooring, project.tolerance),
[project.rooms, flooringInputs, project.tolerance],
);
const flooring = flooringOptions[project.flooring.optimisation];
const skirting = useMemo(
() => planSkirting(project.rooms, project.skirting, project.tolerance),
[project.rooms, project.skirting, project.tolerance],
const cuts = useMemo(
() => cutData(flooring, skirting, project.flooring),
[flooring, skirting, project.flooring],
);
const floorErrors = flooring.issues.filter((i) => i.severity === 'error');
const skirtErrors = skirting.issues.filter((i) => i.severity === 'error');
const roomNames = useMemo(
() => new Map(project.rooms.map((room) => [room.id, room.name])),
[project.rooms],
);
const floorErrors = flooring.issues.filter((issue) => issue.severity === 'error');
const skirtErrors = skirting.issues.filter((issue) => issue.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) < PRECISION.millimetres &&
Math.abs(p.width - project.flooring.width) < PRECISION.millimetres,
).length;
const wholeSkirting = skirting.pieces.filter((p) => !isSkirtingCut(p, skirting)).length;
const changeProject = (fn: (p: Project) => Project) =>
update((s) => {
const project = fn(s.project);
const changed = allocationInputs(project) !== allocationInputs(s.project);
return {
...s,
project: changed ? { ...project, completedBoards: [] } : project,
view: { ...s.view, selectedPiece: null, search: changed ? '' : s.view.search },
};
});
const matches = useMemo(
() => findBoardMatches(project, flooring, skirting, view.search),
[project, flooring, skirting, view.search],
const totalArea = geometries.reduce(
(sum, geometry) => sum + (geometry.valid ? geometry.area : 0),
0,
);
const searchLabel = boardSearchLabel(project, flooring, skirting, view.search);
const matches = useMemo(
() => findBoardMatches(project, flooring, skirting, view.search, view.focus),
[project.rooms, flooring, skirting, view.search, view.focus],
);
const searchActive = !!view.search.trim() || view.focus !== null;
const searchLabel = view.focus ? focusLabel(view.focus, cuts) : view.search;
const visibleMatchCount = (view.material === 'flooring' ? flooring : skirting).pieces.filter(
(piece) => matches.pieceIds.includes(piece.id),
(piece) => matches.pieceKeys.has(pieceKey(view.material, piece.id)),
).length;
const materialForPieces = (ids: string[], current: PlanMaterial): PlanMaterial => {
const hasFloor = flooring.pieces.some((piece) => ids.includes(piece.id));
const hasSkirting = skirting.pieces.some((piece) => ids.includes(piece.id));
return hasFloor && !hasSkirting ? 'flooring' : hasSkirting && !hasFloor ? 'skirting' : current;
};
const setSearch = (search: string) =>
update((s) => {
const found = findBoardMatches(s.project, flooring, skirting, search);
const roomId = found.roomIds.includes(s.view.roomId)
? s.view.roomId
: (found.roomIds[0] ?? s.view.roomId);
return {
...s,
view: {
...s.view,
search,
roomId,
selectedPiece: null,
material: materialForPieces(found.pieceIds, s.view.material),
},
};
const setSearch = (text: string) => {
const found = findBoardMatches(project, flooring, skirting, text);
dispatch({
type: 'search',
text,
roomId: found.roomIds.includes(view.roomId) ? view.roomId : (found.roomIds[0] ?? view.roomId),
material: found.materials.size === 1 ? [...found.materials][0] : view.material,
});
const togglePanel = (section: PlannerSection) =>
update((s) => ({
...s,
view: {
...s.view,
collapsedSections: s.view.collapsedSections.includes(section)
? s.view.collapsedSections.filter((item) => item !== section)
: [...s.view.collapsedSections, section],
},
}));
const toggleBoard = (key: string) =>
update((s) => ({
...s,
project: {
...s.project,
completedBoards: s.project.completedBoards.includes(key)
? s.project.completedBoards.filter((item) => item !== key)
: [...s.project.completedBoards, key],
},
}));
const locatePiece = (id: string, roomId: string) =>
update((s) => ({
...s,
view: {
...s.view,
search: id,
roomId,
selectedPiece: null,
material: materialForPieces([id], s.view.material),
},
}));
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,
material: id ? materialForPieces([id], s.view.material) : s.view.material,
},
}));
};
const locate = (focus: PlanFocus) => {
const found = findBoardMatches(project, flooring, skirting, '', focus);
dispatch({
type: 'focus',
focus,
roomId: found.roomIds.includes(view.roomId) ? view.roomId : (found.roomIds[0] ?? view.roomId),
});
};
const select = (material: PlanMaterial, id: string, roomId?: string) =>
dispatch({ type: 'select', material, id, roomId });
const togglePanel = (section: PlannerSection) => dispatch({ type: 'toggleSection', section });
const changeRoom = (change: (room: Room) => Room) =>
dispatch({ type: 'room', id: room.id, change });
const addRoom = () => {
update((s) => {
const id = nextId(
'room',
s.project.rooms.map((r) => r.id),
);
return {
...s,
project: {
...s.project,
completedBoards: [],
rooms: [...s.project.rooms, rectangle(id, `Room ${s.project.rooms.length + 1}`)],
},
view: {
...s.view,
roomId: id,
selectedPiece: null,
search: '',
collapsedSections: s.view.collapsedSections.filter(
(section) => section !== 'measurements',
),
},
};
}, true);
dispatch({ type: 'addRoom' });
trackRoomAdded();
};
const removeRoom = () => {
update((s) => {
const rooms = s.project.rooms.filter((r) => r.id !== room.id);
return {
...s,
project: { ...s.project, rooms, completedBoards: [] },
view: { ...s.view, roomId: rooms[0].id, selectedPiece: null, search: '' },
};
}, true);
dispatch({ type: 'removeRoom', id: room.id });
trackRoomRemoved();
};
const copyLink = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
await navigator.clipboard.writeText(
`${window.location.origin}${window.location.pathname}${encodeState(state)}`,
);
if (copyStatus.current) copyStatus.current.textContent = 'Link copied';
trackCopyLink();
} catch {
@ -282,12 +188,7 @@ export default function App() {
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,
)
}
onClick={() => dispatch({ type: 'visitRoom', id: r.id })}
>
{r.name || 'Untitled room'}
<small>
@ -334,7 +235,7 @@ export default function App() {
geometry={geometry}
tolerance={project.tolerance}
changeRoom={changeRoom}
changeProject={changeProject}
setTolerance={(value) => dispatch({ type: 'tolerance', value })}
remove={removeRoom}
canRemove={project.rooms.length > 1}
/>
@ -359,7 +260,8 @@ export default function App() {
flooringOptions={flooringOptions}
room={room}
changeRoom={changeRoom}
changeProject={changeProject}
setFlooring={(patch) => dispatch({ type: 'flooring', patch })}
setSkirting={(patch) => dispatch({ type: 'skirting', patch })}
/>
</section>
<section className="purchase-summary" aria-label="Materials to buy">
@ -375,7 +277,7 @@ export default function App() {
</strong>
<p>
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm ·{' '}
{wholeBoards} used whole
{cuts.wholeFloor.wholeBoards} used whole
</p>
</div>
<span className="waste-stat">
@ -390,7 +292,7 @@ export default function App() {
</strong>
<p>
{skirting.stockCount} × {mm(project.skirting.stockLength)} mm ·{' '}
{wholeSkirting} used whole
{cuts.wholeSkirt} used whole
</p>
</div>
<span className="waste-stat">
@ -414,11 +316,14 @@ export default function App() {
skirting={skirting}
select={select}
completedBoards={project.completedBoards}
toggleBoard={toggleBoard}
toggleBoard={(key) => dispatch({ type: 'toggleBoard', key })}
cuts={cuts}
matches={matches}
searchActive={searchActive}
roomNames={roomNames}
search={view.search}
setSearch={setSearch}
locateBoard={setSearch}
locatePiece={locatePiece}
locate={locate}
/>
</div>
</PlannerPanel>
@ -436,12 +341,7 @@ export default function App() {
key={material}
className={view.material === material ? 'active' : ''}
aria-pressed={view.material === material}
onClick={() =>
update((s) => ({
...s,
view: { ...s.view, material, selectedPiece: null, search: '' },
}))
}
onClick={() => dispatch({ type: 'material', material })}
>
<i
className={`material-key ${material === 'flooring' ? 'floor-key' : 'skirt-key'}`}
@ -452,13 +352,13 @@ export default function App() {
))}
</div>
</div>
{view.search.trim() && (
{searchActive && (
<div className="plan-search-status" role="status">
<div className="search-mode-heading">
<div className="search-mode-text">
<strong>
<Search size={15} aria-hidden="true" />
Search active
{view.focus ? 'Location active' : 'Search active'}
</strong>
<p title={searchLabel}>
{searchLabel} ·{' '}
@ -480,12 +380,7 @@ export default function App() {
<button
key={r.id}
aria-pressed={r.id === room.id}
onClick={() =>
update((s) => ({
...s,
view: { ...s.view, roomId: r.id, selectedPiece: null },
}))
}
onClick={() => dispatch({ type: 'visitRoom', id: r.id })}
>
{r.name}
</button>
@ -500,27 +395,30 @@ export default function App() {
flooring={flooring}
skirting={skirting}
selectedPiece={view.selectedPiece}
select={(id) => select(id)}
select={select}
completedBoards={project.completedBoards}
highlightedPieces={matches.pieceIds}
searchActive={!!view.search.trim()}
highlightedPieces={matches.pieceKeys}
searchActive={searchActive}
material={view.material}
/>
</aside>
</div>
</main>
<CutDialog
floor={flooringCuts(flooring).find((piece) => piece.id === view.selectedPiece)}
skirt={skirtingCuts(skirting).find((piece) => piece.id === view.selectedPiece)}
selection={view.selectedPiece ? cuts.selections.get(view.selectedPiece) : undefined}
project={project}
close={() => select(null)}
/>
<PrintPlan
project={project}
geometries={geometries}
flooring={flooring}
skirting={skirting}
close={() => dispatch({ type: 'closePreview' })}
/>
{printing && (
<PrintPlan
cuts={cuts}
roomNames={roomNames}
project={project}
geometries={geometries}
flooring={flooring}
skirting={skirting}
/>
)}
</>
);
}

View file

@ -1,18 +1,20 @@
import type { CutSelection } from '../lib/cut-list';
import { useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import type { FlooringPiece, Project, SkirtingPiece } from '../lib/types';
import type { Project } from '../lib/types';
import { flooringInstructions } from '../lib/cut-instructions';
import { readyOffcutInstructions, skirtingInstructions } from '../lib/cut-list';
import { FlooringPreview, SkirtingPreview } from './CutPreview';
interface Props {
floor?: FlooringPiece;
skirt?: SkirtingPiece;
selection?: CutSelection;
project: Project;
close: () => void;
}
export function CutDialog({ floor, skirt, project, close }: Props) {
export function CutDialog({ selection, project, close }: Props) {
const floor = selection?.material === 'flooring' ? selection.piece : undefined;
const skirt = selection?.material === 'skirting' ? selection.piece : undefined;
const dialog = useRef<HTMLDialogElement>(null);
const opener = useRef<HTMLElement | null>(null);
const selected = floor ?? skirt;
@ -27,7 +29,7 @@ export function CutDialog({ floor, skirt, project, close }: Props) {
element.close();
if (opener.current?.isConnected) opener.current.focus({ preventScroll: true });
}
}, [selected?.id]);
}, [selection?.material, selected?.id]);
const room =
selected &&

View file

@ -1,16 +1,10 @@
import { MapPin, X } from 'lucide-react';
import type { FlooringPlan, Project, SkirtingPlan } from '../lib/types';
import type { FlooringPlan, Project, SkirtingPlan, PlanMaterial, PlanFocus } from '../lib/types';
import { cutMm, mm } from '../lib/format';
import {
flooringCuts,
groupFlooringCuts,
groupSkirtingCuts,
skirtingCuts,
skirtingEndLabel,
uncutFlooring,
uncutSkirting,
} from '../lib/cut-list';
import { boardSearchLabel, findBoardMatches } from '../lib/board-tracking';
import { skirtingEndLabel } from '../lib/cut-list';
import type { CutData } from '../lib/cut-list';
import { pieceKey } from '../lib/board-tracking';
import type { BoardMatches } from '../lib/board-tracking';
import { PERSISTENCE_LIMITS } from '../lib/limits';
import { FlooringPreview, SkirtingPreview } from './CutPreview';
@ -60,13 +54,16 @@ interface Props {
project: Project;
flooring: FlooringPlan;
skirting: SkirtingPlan;
select: (id: string | null, roomId?: string) => void;
select: (material: PlanMaterial, id: string, roomId?: string) => void;
completedBoards: string[];
toggleBoard: (key: string) => void;
search: string;
setSearch: (query: string) => void;
locateBoard: (key: string) => void;
locatePiece: (id: string, roomId: string) => void;
locate: (focus: PlanFocus) => void;
cuts: CutData;
matches: BoardMatches;
searchActive: boolean;
roomNames: Map<string, string>;
}
export function CutList({
@ -78,23 +75,15 @@ export function CutList({
toggleBoard,
search,
setSearch,
locateBoard,
locatePiece,
locate,
cuts,
matches,
searchActive: searching,
roomNames,
}: Props) {
const floorCuts = flooringCuts(flooring),
skirtCuts = skirtingCuts(skirting);
const wholeFloor = uncutFlooring(flooring, project.flooring),
wholeSkirt = uncutSkirting(skirting);
const floorGroups = new Map(
groupFlooringCuts(flooring).map((group) => [group[0].stockId, group]),
);
const skirtGroups = new Map(
groupSkirtingCuts(skirting).map((group) => [group[0].stockId, group]),
);
const searching = search.trim().length > 0;
const matches = findBoardMatches(project, flooring, skirting, search);
const matchedBoards = new Set(matches.boardKeys),
matchedPieces = new Set(matches.pieceIds);
const { floorCuts, skirtCuts, floorGroups, skirtGroups, wholeFloor, wholeSkirt } = cuts;
const matchedBoards = matches.boardKeys,
matchedPieces = matches.pieceKeys;
const completed = new Set(completedBoards);
// Keep every cut from a matching source together, even when only one piece or room matched.
const floorStocks = flooring.stocks.filter((stock) =>
@ -103,7 +92,7 @@ export function CutList({
const skirtStocks = skirting.stocks.filter((stock) =>
searching ? matchedBoards.has(`skirting:${stock.id}`) : skirtGroups.has(stock.id),
);
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
const roomName = (id: string) => roomNames.get(id) ?? id;
return (
<section className="cut-list-card" aria-labelledby="cut-plan-title">
<div className="cut-list-header">
@ -120,7 +109,7 @@ export function CutList({
<input
id="board-search"
type="search"
value={boardSearchLabel(project, flooring, skirting, search)}
value={search}
maxLength={PERSISTENCE_LIMITS.searchLength}
onChange={(event) => setSearch(event.target.value)}
placeholder="F12, A2, or a room name"
@ -139,8 +128,8 @@ export function CutList({
</div>
{searching && (
<p className="search-results" role="status">
{matches.boardKeys.length
? `${matches.boardKeys.length} matching board${matches.boardKeys.length === 1 ? '' : 's'}`
{matches.boardKeys.size
? `${matches.boardKeys.size} matching board${matches.boardKeys.size === 1 ? '' : 's'}`
: 'No matching boards or cuts.'}
</p>
)}
@ -175,13 +164,13 @@ export function CutList({
size={`${mm(project.flooring.length)} × ${mm(project.flooring.width)} mm`}
done={done}
toggle={() => toggleBoard(key)}
locate={() => locateBoard(key)}
locate={() => locate({ kind: 'board', material: 'flooring', id: stock.id })}
/>
{group.length ? (
<ul className="board-cuts">
{group.map((piece) => (
<li
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(piece.id) ? 'search-match' : ''}`}
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(pieceKey('flooring', piece.id)) ? 'search-match' : ''}`}
key={piece.id}
data-cut-id={piece.id}
>
@ -189,7 +178,7 @@ export function CutList({
className="cut-preview-button flooring-preview-button"
aria-label={`Preview flooring cut ${piece.id}`}
aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)}
onClick={() => select('flooring', piece.id, piece.roomId)}
>
<span className="cut-thumbnail" aria-hidden="true">
<FlooringPreview piece={piece} compact />
@ -216,7 +205,9 @@ export function CutList({
<button
className="button cut-locate"
aria-label={`Show cut ${piece.id} on plan`}
onClick={() => locatePiece(piece.id, piece.roomId)}
onClick={() =>
locate({ kind: 'piece', material: 'flooring', id: piece.id })
}
>
<MapPin size={14} aria-hidden="true" />
Show on plan
@ -253,8 +244,7 @@ export function CutList({
Skirting cuts <span className="count-badge">{skirtCuts.length}</span>
</h3>
<p className="cut-summary">
Buy {skirting.stockCount} lengths ·{' '}
{wholeSkirt.reduce((sum, group) => sum + group.count, 0)} installed uncut
Buy {skirting.stockCount} lengths · {wholeSkirt} installed uncut
</p>
{skirtStocks.map((stock) => {
const key = `skirting:${stock.id}`,
@ -276,13 +266,13 @@ export function CutList({
size={`${mm(stock.length)} mm`}
done={done}
toggle={() => toggleBoard(key)}
locate={() => locateBoard(key)}
locate={() => locate({ kind: 'board', material: 'skirting', id: stock.id })}
/>
{group.length ? (
<ul className="board-cuts">
{group.map((piece) => (
<li
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(piece.id) ? 'search-match' : ''}`}
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(pieceKey('skirting', piece.id)) ? 'search-match' : ''}`}
key={piece.id}
data-cut-id={piece.id}
>
@ -290,7 +280,7 @@ export function CutList({
className="cut-preview-button skirting-preview-button"
aria-label={`Preview skirting cut ${piece.id}`}
aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)}
onClick={() => select('skirting', piece.id, piece.roomId)}
>
<span className="cut-thumbnail" aria-hidden="true">
<SkirtingPreview
@ -319,7 +309,9 @@ export function CutList({
<button
className="button cut-locate"
aria-label={`Show cut ${piece.id} on plan`}
onClick={() => locatePiece(piece.id, piece.roomId)}
onClick={() =>
locate({ kind: 'piece', material: 'skirting', id: piece.id })
}
>
<MapPin size={14} aria-hidden="true" />
Show on plan

View file

@ -9,46 +9,44 @@ const numberFromDraft = (text: string): number | null => {
return Number.isFinite(number) ? number : null;
};
/** Only unfinished typing is local; every complete numeric value is committed immediately. */
/** Measurements remain local until blur or Enter; Escape discards the draft. */
export function NumericInput({
value,
onChange,
onBlur,
onKeyDown,
signed = false,
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> & {
}: Omit<
InputHTMLAttributes<HTMLInputElement>,
'value' | 'onChange' | 'type' | 'min' | 'max' | 'step'
> & {
value: number;
onChange: (value: number) => void;
signed?: boolean;
}) {
const [draft, setDraft] = useState(String(value));
const emitted = useRef(value);
useEffect(() => {
if (value !== emitted.current) setDraft(String(value));
emitted.current = value;
}, [value]);
const cancel = useRef(false);
useEffect(() => setDraft(String(value)), [value]);
return (
<input
{...props}
type="text"
inputMode={props.inputMode ?? (Number(props.min) < 0 ? 'text' : 'decimal')}
inputMode={signed ? 'text' : 'decimal'}
value={draft}
onChange={(event) => {
const text = event.target.value;
setDraft(text);
const number = numberFromDraft(text);
if (number !== null && number !== value) {
emitted.current = number;
onChange(number);
}
}}
onChange={(event) => setDraft(event.target.value)}
onBlur={(event) => {
setDraft(String(value));
const next = cancel.current ? value : (numberFromDraft(event.currentTarget.value) ?? value);
cancel.current = false;
setDraft(String(next));
if (next !== value) onChange(next);
onBlur?.(event);
}}
onKeyDown={(event) => {
onKeyDown?.(event);
if (!event.defaultPrevented && (event.key === 'Enter' || event.key === 'Escape')) {
event.preventDefault();
cancel.current = event.key === 'Escape';
event.currentTarget.blur();
}
}}
@ -61,9 +59,6 @@ export function NumberField({
value,
onChange,
unit = 'mm',
min = 0,
max,
step = 1,
hint,
tooltip,
}: {
@ -71,9 +66,6 @@ export function NumberField({
value: number;
onChange: (n: number) => void;
unit?: string;
min?: number;
max?: number;
step?: number;
hint?: string;
tooltip?: string;
}) {
@ -112,9 +104,6 @@ export function NumberField({
<NumericInput
id={id}
value={value}
min={min}
max={max}
step={step}
aria-describedby={hint ? `${id}-hint` : undefined}
onChange={onChange}
/>
@ -125,9 +114,11 @@ export function NumberField({
);
}
export function Issues({ issues, compact = false }: { issues: Issue[]; compact?: boolean }) {
const unique = issues.filter(
(issue, i) => issues.findIndex((item) => item.message === issue.message) === i,
);
const unique = [
...new Map(
issues.map((issue) => [`${issue.roomId}:${issue.severity}:${issue.message}`, issue]),
).values(),
];
return (
<div className={`issues ${compact ? 'compact' : ''}`} role="status">
{unique.map((issue, i) => (

View file

@ -1,6 +1,8 @@
import { useId } from 'react';
import { overlaps } from '../lib/drawing-geometry';
import { useId, useMemo } from 'react';
import type {
FlooringPlan,
PieceKey,
Geometry,
PlanMaterial,
Point,
@ -10,7 +12,7 @@ import type {
} from '../lib/types';
import { area, cutMm, mm } from '../lib/format';
import { signedArea } from '../lib/geometry';
import { isSkirtingCut } from '../lib/cut-list';
import { pieceKey } from '../lib/board-tracking';
import { skirtingPolygon } from '../lib/skirting-polygon';
import { dimensionLabels, wallDimensionBox } from '../lib/dimension-labels';
import { floorPieceLabels } from '../lib/piece-labels';
@ -122,11 +124,11 @@ interface Props {
geometry: Geometry;
flooring: FlooringPlan;
skirting: SkirtingPlan;
selectedPiece: string | null;
select?: (id: string) => void;
selectedPiece: PieceKey | null;
select?: (material: PlanMaterial, id: string) => void;
interactive?: boolean;
completedBoards?: string[];
highlightedPieces?: string[];
highlightedPieces?: Set<PieceKey>;
searchActive?: boolean;
material?: PlanMaterial;
}
@ -140,75 +142,95 @@ export function FloorPlan({
select,
interactive = true,
completedBoards = [],
highlightedPieces = [],
highlightedPieces = new Set(),
searchActive = false,
material,
}: 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;
// Printing includes both materials; the interactive drawing shows the chosen view.
const floorPieces =
material === 'skirting' ? [] : flooring.pieces.filter((p) => p.roomId === room.id);
const skirtPieces =
material === 'flooring' ? [] : skirting.pieces.filter((p) => p.roomId === room.id);
const skirtLabels = new Map<string, Point & { width: number; text: string }>();
const reserved: LabelBox[] = [];
for (const piece of skirtPieces) {
if (!isSkirtingCut(piece, skirting)) continue;
const center = midpoint(piece.start, piece.end);
const length = Math.hypot(piece.end.x - piece.start.x, piece.end.y - piece.start.y) || 1;
const text = piece.id + (completedBoards.includes(`skirting:${piece.stockId}`) ? ' ✓' : '');
const width = Math.max(font * 1.8, text.length * font * 0.8 * 0.64 + font * 0.55);
let box!: LabelBox;
for (let lane = 0; lane < 24; lane++) {
const offset = span * (0.032 + lane * 0.035);
const x = center.x + ((orientation * (piece.end.y - piece.start.y)) / length) * offset;
const y = center.y - ((orientation * (piece.end.x - piece.start.x)) / length) * offset;
box = {
minX: x - width / 2,
maxX: x + width / 2,
minY: y - font * 0.7,
maxY: y + font * 0.7,
};
skirtLabels.set(piece.id, { x, y, width, text });
if (
!reserved.some(
(other) =>
box.minX < other.maxX &&
box.maxX > other.minX &&
box.minY < other.maxY &&
box.maxY > other.minY,
)
)
break;
}
reserved.push(box);
}
const labels = dimensionLabels(geometry.edges, span, orientation, reserved);
const floorLabels = floorPieceLabels(
floorPieces,
geometry.vertices,
room.direction,
const {
span,
font,
[...reserved, ...labels.map((label) => label.box)],
completedBoards,
);
const boxes = [
...reserved,
...labels.map((label, i) => wallDimensionBox(geometry.edges[i], label, span)),
...floorLabels.map((label) => label.box),
];
const left = Math.min(minX, ...boxes.map((b) => b.minX)) - span * 0.035;
const top = Math.min(minY, ...boxes.map((b) => b.minY)) - span * 0.035;
const width = Math.max(maxX, ...boxes.map((b) => b.maxX)) + span * 0.035 - left;
const height = Math.max(maxY, ...boxes.map((b) => b.maxY)) + span * 0.035 - top;
orientation,
floorPieces,
skirtPieces,
skirtLabels,
labels,
floorLabels,
left,
top,
width,
height,
} = useMemo(() => {
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;
// Printing includes both materials; the interactive drawing shows the chosen view.
const floorPieces =
material === 'skirting' ? [] : flooring.pieces.filter((p) => p.roomId === room.id);
const skirtPieces =
material === 'flooring' ? [] : skirting.pieces.filter((p) => p.roomId === room.id);
const skirtLabels = new Map<string, Point & { width: number; text: string }>();
const reserved: LabelBox[] = [];
for (const piece of skirtPieces) {
if (!piece.cut) 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 text = piece.id + (completedBoards.includes(`skirting:${piece.stockId}`) ? ' ✓' : '');
const width = Math.max(font * 1.8, text.length * font * 0.8 * 0.64 + font * 0.55);
let box!: LabelBox;
for (let lane = 0; lane < 24; lane++) {
const offset = span * (0.032 + lane * 0.035);
const x = center.x + ((orientation * (piece.end.y - piece.start.y)) / length) * offset;
const y = center.y - ((orientation * (piece.end.x - piece.start.x)) / length) * offset;
box = {
minX: x - width / 2,
maxX: x + width / 2,
minY: y - font * 0.7,
maxY: y + font * 0.7,
};
skirtLabels.set(piece.id, { x, y, width, text });
if (!reserved.some((other) => overlaps(box, other))) break;
}
reserved.push(box);
}
const labels = dimensionLabels(geometry.edges, span, orientation, reserved);
const floorLabels = floorPieceLabels(
floorPieces,
geometry.vertices,
room.direction,
font,
[...reserved, ...labels.map((label) => label.box)],
completedBoards,
);
const boxes = [
...reserved,
...labels.map((label, i) => wallDimensionBox(geometry.edges[i], label, span)),
...floorLabels.map((label) => label.box),
];
const left = Math.min(minX, ...boxes.map((b) => b.minX)) - span * 0.035;
const top = Math.min(minY, ...boxes.map((b) => b.minY)) - span * 0.035;
const width = Math.max(maxX, ...boxes.map((b) => b.maxX)) + span * 0.035 - left;
const height = Math.max(maxY, ...boxes.map((b) => b.maxY)) + span * 0.035 - top;
return {
span,
font,
orientation,
floorPieces,
skirtPieces,
skirtLabels,
labels,
floorLabels,
left,
top,
width,
height,
};
}, [geometry, room.id, room.direction, flooring, skirting, completedBoards, material]);
return (
<section
className="drawing-card"
@ -272,7 +294,7 @@ export function FloorPlan({
{floorPieces.map((piece) => {
const cut = piece.cut || piece.reused;
const done = completedBoards.includes(`floor:${piece.stockId}`);
const highlighted = highlightedPieces.includes(piece.id);
const highlighted = highlightedPieces.has(pieceKey('flooring', piece.id));
const x =
(Math.min(...piece.polygon.map((p) => p.x)) +
Math.max(...piece.polygon.map((p) => p.x))) /
@ -294,14 +316,14 @@ export function FloorPlan({
tabIndex={cut && interactive ? 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 && interactive ? () => select?.(piece.id) : undefined}
aria-pressed={cut ? selectedPiece === pieceKey('flooring', piece.id) : undefined}
onClick={cut && interactive ? () => select?.('flooring', piece.id) : undefined}
onKeyDown={
cut
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
select?.(piece.id);
select?.('flooring', piece.id);
}
}
: undefined
@ -313,13 +335,15 @@ export function FloorPlan({
done
? '#dbe9e7'
: cut
? selectedPiece === piece.id
? selectedPiece === pieceKey('flooring', piece.id)
? '#ceddf9'
: `url(#${hatch})`
: '#f5f6f8'
}
stroke={cut ? '#1647ab' : '#9baabd'}
strokeWidth={span * (selectedPiece === piece.id ? 0.003 : 0.0012)}
strokeWidth={
span * (selectedPiece === pieceKey('flooring', piece.id) ? 0.003 : 0.0012)
}
/>
{done && !cut && (
<text
@ -349,9 +373,9 @@ export function FloorPlan({
<Wall key={edge.wallId} edge={edge} span={span} label={labels[i]} />
))}
{skirtPieces.map((piece) => {
const cut = isSkirtingCut(piece, skirting);
const cut = piece.cut;
const done = completedBoards.includes(`skirting:${piece.stockId}`);
const highlighted = highlightedPieces.includes(piece.id);
const highlighted = highlightedPieces.has(pieceKey('skirting', piece.id));
// Each template uses its true wall-facing and room-facing edges, including mitres.
const polygon = skirtingPolygon(piece, orientation);
return (
@ -367,14 +391,14 @@ export function FloorPlan({
tabIndex={cut && interactive ? 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 && interactive ? () => select?.(piece.id) : undefined}
aria-pressed={cut ? selectedPiece === pieceKey('skirting', piece.id) : undefined}
onClick={cut && interactive ? () => select?.('skirting', piece.id) : undefined}
onKeyDown={
cut
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
select?.(piece.id);
select?.('skirting', piece.id);
}
}
: undefined
@ -386,7 +410,7 @@ export function FloorPlan({
done
? '#27655d'
: cut
? selectedPiece === piece.id
? selectedPiece === pieceKey('skirting', piece.id)
? '#17263f'
: '#b74900'
: '#d8d5cd'
@ -442,14 +466,14 @@ export function FloorPlan({
const label = skirtLabels.get(piece.id);
if (!label) return null;
const done = completedBoards.includes(`skirting:${piece.stockId}`);
const highlighted = highlightedPieces.includes(piece.id);
const highlighted = highlightedPieces.has(pieceKey('skirting', piece.id));
return (
<g
key={piece.id}
className={`skirting-code ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
data-piece-id={piece.id}
transform={`translate(${label.x} ${label.y})`}
onClick={interactive ? () => select?.(piece.id) : undefined}
onClick={interactive ? () => select?.('skirting', piece.id) : undefined}
>
<rect
x={-label.width / 2}
@ -477,13 +501,13 @@ export function FloorPlan({
{floorLabels.map((label) => {
const piece = floorPieces.find((piece) => piece.id === label.id)!;
const done = completedBoards.includes(`floor:${piece.stockId}`);
const highlighted = highlightedPieces.includes(label.id);
const highlighted = highlightedPieces.has(pieceKey('flooring', label.id));
return (
<g
key={label.id}
className={`floor-code ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
data-piece-id={label.id}
onClick={interactive ? () => select?.(label.id) : undefined}
onClick={interactive ? () => select?.('flooring', label.id) : undefined}
>
{label.leader && (
<>

View file

@ -8,7 +8,8 @@ interface Props {
project: Project;
room: Room;
flooringOptions: FlooringOptions;
changeProject: (fn: (p: Project) => Project) => void;
setFlooring: (patch: Partial<FlooringSettings>) => void;
setSkirting: (patch: Partial<SkirtingSettings>) => void;
changeRoom: (fn: (r: Room) => Room) => void;
}
@ -19,15 +20,15 @@ export function MaterialsEditor({
project,
room,
flooringOptions,
changeProject,
setFlooring,
setSkirting,
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) =>
changeProject((p) => ({ ...p, skirting: { ...p.skirting, [key]: value } }));
const floor = <K extends keyof FlooringSettings>(key: K, value: FlooringSettings[K]) =>
setFlooring({ [key]: value });
const skirt = (key: keyof SkirtingSettings, value: number) => setSkirting({ [key]: value });
return (
<>
@ -43,18 +44,8 @@ export function MaterialsEditor({
Use the length and width printed on your flooring pack.
</p>
<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)}
/>
<NumberField label="Board length" value={f.length} onChange={(n) => floor('length', n)} />
<NumberField label="Board width" value={f.width} onChange={(n) => floor('width', n)} />
</div>
<div className="field">
<span>Laying pattern</span>
@ -158,13 +149,11 @@ export function MaterialsEditor({
<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>
@ -181,7 +170,6 @@ export function MaterialsEditor({
<NumberField
label="Skirting board length"
value={s.stockLength}
min={1}
onChange={(n) => skirt('stockLength', n)}
/>
<NumberField
@ -196,7 +184,6 @@ export function MaterialsEditor({
<NumberField
label="Minimum piece"
value={s.minLength}
min={1}
onChange={(n) => skirt('minLength', n)}
/>
<NumberField

View file

@ -9,7 +9,7 @@ import {
ArrowUp,
ArrowDown,
} from 'lucide-react';
import type { Geometry, Project, Room, Wall } from '../lib/types';
import type { Geometry, Room, Wall } from '../lib/types';
import { fireplace, rectangle, nextId } from '../lib/defaults';
import { normalizeAngle } from '../lib/geometry';
import { mm } from '../lib/format';
@ -22,7 +22,7 @@ interface Props {
geometry: Geometry;
tolerance: number;
changeRoom: (fn: (room: Room) => Room) => void;
changeProject: (fn: (project: Project) => Project) => void;
setTolerance: (value: number) => void;
remove: () => void;
canRemove: boolean;
}
@ -31,7 +31,7 @@ export function OutlineEditor({
geometry,
tolerance,
changeRoom,
changeProject,
setTolerance,
remove,
canRemove,
}: Props) {
@ -182,7 +182,6 @@ export function OutlineEditor({
<NumberField
label="Room width"
value={room.walls[0].length}
min={1}
onChange={(n) =>
changeRoom((r) => ({
...r,
@ -193,7 +192,6 @@ export function OutlineEditor({
<NumberField
label="Room length"
value={room.walls[1].length}
min={1}
onChange={(n) =>
changeRoom((r) => ({
...r,
@ -244,16 +242,12 @@ export function OutlineEditor({
<span className="wall-code">W{i + 1}</span>
<NumericInput
aria-label={`Wall ${i + 1} length`}
min={1}
step={1}
value={wall.length}
onChange={(length) => changeWall(wall.id, (w) => ({ ...w, length }))}
/>
<NumericInput
aria-label={`Wall ${i + 1} turn`}
min={-179}
max={179}
step={0.1}
signed
value={
geometry.closed && i === room.walls.length - 1
? Math.round(normalizeAngle(-(lastEdge?.heading ?? 0)) * 10) / 10
@ -408,7 +402,11 @@ export function OutlineEditor({
}
>
{room.walls.map((w, i) => (
<option key={w.id} value={w.id}>
<option
key={w.id}
value={w.id}
disabled={w.id !== wall.id && w.doors.length >= PROJECT_LIMITS.doorsPerWall}
>
W{i + 1}
</option>
))}
@ -447,7 +445,6 @@ export function OutlineEditor({
/>
<NumberField
label="Door width"
min={1}
value={door.width}
onChange={(n) =>
changeWall(wall.id, (w) => ({
@ -473,8 +470,7 @@ export function OutlineEditor({
<NumberField
label="Measurement tolerance"
value={tolerance}
max={PROJECT_LIMITS.maxTolerance}
onChange={(n) => changeProject((p) => ({ ...p, tolerance: n }))}
onChange={setTolerance}
hint="Largest closing gap accepted for the cut plan."
/>
{canRemove && (

View file

@ -1,13 +1,7 @@
import type { CutData } from '../lib/cut-list';
import type { FlooringPlan, Geometry, Project, SkirtingPlan } from '../lib/types';
import { area, mm } from '../lib/format';
import {
groupFlooringCuts,
groupSkirtingCuts,
readyOffcutInstructions,
skirtingInstructions,
uncutFlooring,
uncutSkirting,
} from '../lib/cut-list';
import { readyOffcutInstructions, skirtingInstructions } from '../lib/cut-list';
import { flooringInstructions } from '../lib/cut-instructions';
import { FloorPlan } from './FloorPlan';
import { FlooringPreview, SkirtingPreview } from './CutPreview';
@ -15,18 +9,22 @@ import { FlooringPreview, SkirtingPreview } from './CutPreview';
/** All rooms first, then cutting sheets in purchased-board order, including shared offcuts. */
export function PrintPlan({
project,
cuts,
roomNames,
geometries,
flooring,
skirting,
}: {
project: Project;
cuts: CutData;
roomNames: Map<string, string>;
geometries: Geometry[];
flooring: FlooringPlan;
skirting: SkirtingPlan;
}) {
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
const wholeFloor = uncutFlooring(flooring, project.flooring).wholeBoards;
const wholeSkirt = uncutSkirting(skirting).reduce((sum, group) => sum + group.count, 0);
const roomName = (id: string) => roomNames.get(id) ?? id;
const wholeFloor = cuts.wholeFloor.wholeBoards;
const wholeSkirt = cuts.wholeSkirt;
return (
<section className="print-plan" aria-label="Printable plan">
<header className="print-title">
@ -59,7 +57,7 @@ export function PrintPlan({
))}
<section className="print-material print-flooring" aria-label="Printed flooring cuts">
<h2>Flooring cuts</h2>
{groupFlooringCuts(flooring).map((group) => (
{[...cuts.floorGroups.values()].map((group) => (
<section
className={`print-stock ${project.completedBoards.includes(`floor:${group[0].stockId}`) ? 'is-done' : ''}`}
data-stock-id={group[0].stockId}
@ -90,11 +88,11 @@ export function PrintPlan({
</div>
</section>
))}
{!groupFlooringCuts(flooring).length && <p>No flooring cuts needed.</p>}
{!cuts.floorGroups.size && <p>No flooring cuts needed.</p>}
</section>
<section className="print-material print-skirting" aria-label="Printed skirting cuts">
<h2>Skirting cuts</h2>
{groupSkirtingCuts(skirting).map((group) => (
{[...cuts.skirtGroups.values()].map((group) => (
<section
className={`print-stock ${project.completedBoards.includes(`skirting:${group[0].stockId}`) ? 'is-done' : ''}`}
data-stock-id={group[0].stockId}
@ -105,9 +103,7 @@ export function PrintPlan({
{project.completedBoards.includes(`skirting:${group[0].stockId}`)
? ' · Done ✓'
: ''}{' '}
<span>
{mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
</span>
<span>{mm(project.skirting.stockLength)} mm</span>
</h3>
<div className="print-cut-grid">
{group.map((piece) => (
@ -125,7 +121,7 @@ export function PrintPlan({
</div>
</section>
))}
{!groupSkirtingCuts(skirting).length && <p>No skirting cuts needed.</p>}
{!cuts.skirtGroups.size && <p>No skirting cuts needed.</p>}
</section>
</section>
);

View file

@ -1,73 +1,72 @@
import type { FlooringPlan, Project, SkirtingPlan } from './types';
import { flooringCuts, skirtingCuts } from './cut-list';
import type {
FlooringPlan,
PieceKey,
PlanFocus,
PlanMaterial,
Project,
SkirtingPlan,
} from './types';
import type { CutData } from './cut-list';
export const pieceKey = (material: PlanMaterial, id: string): PieceKey => `${material}:${id}`;
export const boardKey = (material: PlanMaterial, id: string) =>
`${material === 'flooring' ? 'floor' : 'skirting'}:${id}`;
export interface BoardMatches {
pieceIds: string[];
pieceKeys: Set<PieceKey>;
roomIds: string[];
boardKeys: string[];
boardKeys: Set<string>;
materials: Set<PlanMaterial>;
}
/** Codes match exactly; ordinary text searches room names. Qualified keys identify source boards. */
/** User text searches cut codes or room names. Location is explicit, never encoded as a query. */
export function findBoardMatches(
project: Project,
flooring: FlooringPlan,
skirting: SkirtingPlan,
query: string,
focus: PlanFocus | null = null,
): BoardMatches {
const text = query.trim().toLowerCase().replace(/\s+/g, ' ');
if (!text) return { pieceIds: [], roomIds: [], boardKeys: [] };
const qualified = /^(floor|skirting)\s*:\s*([a-z]+\d+)$/.exec(text);
const codes = text.split(/\s*[+,]\s*/);
const isCode = codes.every((code) => /^[a-z]+\d+$/.test(code));
const result: BoardMatches = {
pieceKeys: new Set(),
roomIds: [],
boardKeys: new Set(),
materials: new Set(),
};
if (!text && !focus) return result;
const codes = new Set(text.split(/\s*[+,]\s*/));
const matchingRooms = new Set(
project.rooms
.filter((room) => room.name.toLowerCase().replace(/\s+/g, ' ').includes(text))
.map((room) => room.id),
);
const pieceIds: string[] = [];
const roomIds = new Set<string>();
const boardKeys = new Set<string>();
const materials = [
{ key: 'floor', pieces: flooring.pieces },
{ key: 'skirting', pieces: skirting.pieces },
];
for (const material of materials) {
for (const piece of material.pieces) {
const matches = qualified
? material.key === qualified[1] && piece.stockId.toLowerCase() === qualified[2]
: isCode
? codes.includes(piece.id.toLowerCase()) ||
(codes.length === 1 && codes.includes(piece.stockId.toLowerCase()))
: matchingRooms.has(piece.roomId);
for (const [material, pieces] of [
['flooring', flooring.pieces],
['skirting', skirting.pieces],
] as const) {
for (const piece of pieces) {
const matches = focus
? material === focus.material &&
(focus.kind === 'board' ? piece.stockId : piece.id) === focus.id
: codes.has(piece.id.toLowerCase()) || matchingRooms.has(piece.roomId);
if (!matches) continue;
pieceIds.push(piece.id);
result.pieceKeys.add(pieceKey(material, piece.id));
roomIds.add(piece.roomId);
boardKeys.add(`${material.key}:${piece.stockId}`);
result.boardKeys.add(boardKey(material, piece.stockId));
result.materials.add(material);
}
}
return {
pieceIds,
roomIds: project.rooms.filter((room) => roomIds.has(room.id)).map((room) => room.id),
boardKeys: [...boardKeys],
};
result.roomIds = project.rooms.filter((room) => roomIds.has(room.id)).map((room) => room.id);
return result;
}
/** Source IDs are bookkeeping; show the cut codes when locating a whole source board. */
export function boardSearchLabel(
project: Project,
flooring: FlooringPlan,
skirting: SkirtingPlan,
query: string,
): string {
const qualified = /^(floor|skirting)\s*:\s*([a-z]+\d+)$/i.exec(query.trim());
if (!qualified) return query;
const matches = new Set(findBoardMatches(project, flooring, skirting, query).pieceIds);
const codes = [...flooringCuts(flooring), ...skirtingCuts(skirting)]
.filter((piece) => matches.has(piece.id))
.map((piece) => piece.id);
return codes.length
? codes.join(' + ')
: qualified[1].toLowerCase() === 'floor'
? 'Uncut floorboard'
: 'Uncut skirting board';
export function focusLabel(focus: PlanFocus, cuts: CutData): string {
if (focus.kind === 'piece') return focus.id;
const group = (focus.material === 'flooring' ? cuts.floorGroups : cuts.skirtGroups).get(focus.id);
return (
group?.map((piece) => piece.id).join(' + ') ||
(focus.material === 'flooring' ? 'Uncut floorboard' : 'Uncut skirting board')
);
}

View file

@ -15,20 +15,7 @@ const sameLength = (a: number, b: number) => Math.abs(a - b) < PRECISION.millime
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 (
piece.leftType === 'cope' ||
piece.rightType === 'cope' ||
!stock ||
!sameLength(piece.length, stock.length) ||
Math.abs(piece.leftMitre) > PRECISION.cutAngle ||
Math.abs(piece.rightMitre) > PRECISION.cutAngle
);
}
export const skirtingCuts = (plan: SkirtingPlan) =>
plan.pieces.filter((piece) => isSkirtingCut(piece, plan));
export const skirtingCuts = (plan: SkirtingPlan) => plan.pieces.filter((piece) => piece.cut);
export function uncutFlooring(plan: FlooringPlan, settings: FlooringSettings) {
const uncut = plan.pieces.filter((piece) => !piece.cut);
@ -38,13 +25,8 @@ export function uncutFlooring(plan: FlooringPlan, settings: FlooringSettings) {
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 }));
}
export const uncutSkirting = (plan: SkirtingPlan) =>
plan.pieces.filter((piece) => !piece.cut).length;
/** Ready remnants need placement instructions, never another trimming instruction. */
export function readyOffcutInstructions(piece: FlooringPiece): string {
@ -102,3 +84,37 @@ export function skirtingInstructions(piece: SkirtingPiece): string {
messages.push(`Exposed front length: ${cutMm(piece.visibleLength)} mm.`);
return messages.join(' ');
}
export type CutSelection =
{ material: 'flooring'; piece: FlooringPiece } | { material: 'skirting'; piece: SkirtingPiece };
/** Shared by the drawing, cut list, search, dialog and printed sheets. */
export function cutData(
flooring: FlooringPlan,
skirting: SkirtingPlan,
settings: FlooringSettings,
) {
const floorCuts = flooringCuts(flooring);
const skirtCuts = skirtingCuts(skirting);
const floorGroups = new Map(
groupByStock(floorCuts, flooring.stocks).map((pieces) => [pieces[0].stockId, pieces]),
);
const skirtGroups = new Map(
groupByStock(skirtCuts, skirting.stocks).map((pieces) => [pieces[0].stockId, pieces]),
);
const selections = new Map<string, CutSelection>();
for (const piece of floorCuts)
selections.set(`flooring:${piece.id}`, { material: 'flooring', piece });
for (const piece of skirtCuts)
selections.set(`skirting:${piece.id}`, { material: 'skirting', piece });
return {
floorCuts,
skirtCuts,
floorGroups,
skirtGroups,
selections,
wholeFloor: uncutFlooring(flooring, settings),
wholeSkirt: uncutSkirting(skirting),
};
}
export type CutData = ReturnType<typeof cutData>;

View file

@ -2,12 +2,10 @@ import type { Point } from './types';
import { cutMm, degree } from './format';
import { PRECISION } from './limits';
export interface PreviewBox {
x: number;
y: number;
width: number;
height: number;
}
export type { Rect as PreviewBox } from './drawing-geometry';
import type { Rect as PreviewBox } from './drawing-geometry';
import { overlaps, rectBounds, bounds } from './drawing-geometry';
import { signedArea } from './geometry';
export const viewBox = (box: PreviewBox) => `${box.x} ${box.y} ${box.width} ${box.height}`;
export interface EdgeDimension {
@ -29,12 +27,6 @@ const moved = (point: Point, direction: Point, distance: number): Point => ({
x: point.x + direction.x * distance,
y: point.y + direction.y * distance,
});
const overlaps = (a: PreviewBox, b: PreviewBox, gap: number) =>
a.x < b.x + b.width + gap &&
a.x + a.width + gap > b.x &&
a.y < b.y + b.height + gap &&
a.y + a.height + gap > b.y;
/** Dimensions stay outside the outline. Short adjacent edges share a lane with staggered text. */
export function dimensionedOutline(
points: Point[],
@ -43,19 +35,10 @@ export function dimensionedOutline(
) {
if (points.length < 3)
return { dimensions: [] as EdgeDimension[], font: 1, box: fullPreviewBox(1, 1) };
const span = Math.max(
1,
Math.max(...points.map((point) => point.x)) - Math.min(...points.map((point) => point.x)),
Math.max(...points.map((point) => point.y)) - Math.min(...points.map((point) => point.y)),
);
const box = bounds(points);
const span = Math.max(1, box.maxX - box.minX, box.maxY - box.minY);
const font = span * 0.065;
const orientation =
Math.sign(
points.reduce((sum, point, i) => {
const next = points[(i + 1) % points.length];
return sum + point.x * next.y - next.x * point.y;
}, 0),
) || 1;
const orientation = Math.sign(signedArea(points)) || 1;
const dimensions: EdgeDimension[] = [];
const selected = (edges ?? points.map((_, i) => i))
.map((edge) => ({
@ -109,7 +92,11 @@ export function dimensionedOutline(
const shift = step === 0 ? 0 : Math.ceil(step / 2) * (step % 2 ? -1 : 1) * font * 1.55;
label = moved(base, direction, shift);
labelBox = { x: label.x - width / 2, y: label.y - height / 2, width, height };
if (!dimensions.some((dimension) => overlaps(labelBox, dimension.labelBox, font * 0.25))) {
if (
!dimensions.some((dimension) =>
overlaps(rectBounds(labelBox), rectBounds(dimension.labelBox), font * 0.25),
)
) {
placed = true;
break;
}

View file

@ -33,8 +33,6 @@ export function fireplace(id: string, name: string): Room {
}
export function defaultProject(): Project {
return {
version: 2,
name: 'My flooring project',
rooms: [rectangle()],
tolerance: 10,
completedBoards: [],
@ -59,6 +57,7 @@ export function defaultState(): UrlState {
view: {
roomId: 'room-1',
selectedPiece: null,
focus: null,
search: '',
collapsedSections: [],
material: 'flooring',

View file

@ -1,11 +1,8 @@
import type { Point, WallEdge } from './types';
import { mm } from './format';
export interface LabelBox {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export type { Bounds as LabelBox } from './drawing-geometry';
import type { Bounds as LabelBox } from './drawing-geometry';
import { overlaps } from './drawing-geometry';
export interface DimensionLabel {
x: number;
y: number;
@ -17,9 +14,6 @@ export interface DimensionLabel {
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[],

Some files were not shown because too many files have changed in this diff Show more