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. 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. 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. 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. 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. 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 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. 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. 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. 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 ## 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. - 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 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. - 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. - 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, 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). - 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. - 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. - 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 ## Code map
| File | Responsibility | | File | Responsibility |
| --------------------------- | ---------------------------------------------------------------- | | ----------------------------- | --------------------------------------------------------------------- |
| `src/lib/types.ts` | Inputs and derived result contracts | | `src/lib/types.ts` | Inputs and derived result contracts |
| `src/lib/defaults.ts` | Starter measurements and deterministic IDs | | `src/lib/defaults.ts` | Starter measurements and deterministic IDs |
| `src/lib/geometry.ts` | Vector tracing, closure, validation and polygon offsets | | `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/flooring.ts` | Row search, clipping, factory-end-aware stock allocation |
| `src/lib/skirting.ts` | Corner allowances, run splitting and 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/url-state.ts` | Versioned payload encoding and import validation |
| `src/lib/limits.ts` | Shared project, persistence and calculation bounds | | `src/lib/legacy-query.ts` | Read-only importer for existing version 2 links |
| `src/lib/piece-labels.ts` | Collision-free cut-code placement and pointers for small pieces | | `src/lib/project-state.ts` | Edit actions and allocation invalidation |
| `src/lib/board-tracking.ts` | Exact board/cut matching and room-name search | | `src/lib/planning.ts` | Reuse geometry and plans when their calculation inputs stay unchanged |
| `src/lib/wall-editing.ts` | Wall insertion and reordering with stable IDs | | `src/lib/drawing-geometry.ts` | Shared bounds and collision primitives for drawing labels |
| `src/lib/use-project.ts` | URL, localStorage and browser-history synchronization | | `src/lib/use-printing.ts` | Mount print sheets on native print events and release them afterward |
| `src/components/` | Editors, dimensioned SVGs, board groups and printable cut sheets | | `src/lib/limits.ts` | Shared project, persistence and calculation bounds |
| `tests/` | Pure calculation and state tests | | `src/lib/piece-labels.ts` | Collision-free cut-code placement and pointers for small pieces |
| `e2e/` | Actual browser workflows at desktop and mobile sizes | | `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. 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 | | 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 | | Shaping passes | 192 | 184 | 188 |
| Rip passes | 414 | 402 | 409 | | Rip passes | 414 | 402 | 411 |
| Legacy penalty | 265 | 2,207 | 346 | | Legacy penalty | 265 | 2,279 | 350 |
| Flooring boards | 3,561 | 3,548 | 3,551 | | Flooring boards | 3,561 | 3,548 | 3,550 |
| Flooring waste | 8.20% | 7.85% | 7.94% | | Flooring waste | 8.20% | 7.85% | 7.91% |
| Invalid cases | 0 | 0 | 0 | | 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 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 4,800 × 3,600 mm room, 38 passes become **24** or **31**, respectively; both options use 76
boards and have approximately 1.2% waste. boards and have approximately 1.2% waste.
Skirting drops from **1,447 to 1,436 passes**. It uses 521 boards instead of 513: 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.68%. cutting effort now takes precedence over waste, which rises from 9.28% to 10.85%.
Eight cases improve and 76 tie. Seven cases improve and 77 tie.
For flooring, 78 cut-priority cases improve and six tie against the original planner. For flooring, 78 cut-priority cases improve and six tie against the original planner.
The bounded search does not establish global optimality. 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 | | Metric | Cuts before | Cuts now | Random before | Random now |
| ------------------------- | ----------: | -------: | ------------: | ---------: | | ------------------------- | ----------: | -------: | ------------: | ---------: |
| Near-H row pairs | 515 | 247 | 557 | **6** | | Near-H row pairs | 515 | 269 | 557 | **6** |
| Saw passes | 1,741 | 1,741 | 1,994 | 2,113 | | Saw passes | 1,741 | 1,741 | 1,994 | 2,122 |
| Boards | 3,537 | 3,548 | 3,579 | 3,551 | | Boards | 3,537 | 3,548 | 3,579 | 3,550 |
| Waste | 7.57% | 7.85% | 8.66% | 7.94% | | Waste | 7.57% | 7.85% | 8.66% | 7.91% |
| Default-room near-H pairs | 17 | 17 | 11 | **0** | | Default-room near-H pairs | 17 | 17 | 11 | **0** |
| Default-room saw passes | 24 | 24 | 30 | 31 | | 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 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 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. 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. searching offcuts for pieces requiring both factory ends.
3. Repack completed boundary blanks, reserving exact complementary factory ends 3. Repack completed boundary blanks, reserving exact complementary factory ends
first. Preserve the original placement and cutting order within each source board. first. Preserve the original placement and cutting order within each source board.
4. Try bounded swaps of interchangeable rectangular rows. Accept only a lower 4. Polish all surviving completed candidates, retaining the best result.
pattern penalty with unchanged cuts, stock and geometry coverage. 5. Preserve end remnants during search instead of trimming them immediately.
5. Polish all surviving completed candidates, retaining the best result.
6. Preserve end remnants during search instead of trimming them immediately.
This alone improves 56 cut-priority cases and saves another 99 passes after the This alone improves 56 cut-priority cases and saves another 99 passes after the
earlier improvements. Final packing can still combine non-exact blanks. 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 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 two UI choices select from the completed results. Selection itself does not rerun
the optimiser. the optimiser.
8. Compare exact three-piece skirting fills with the original packing. This improves 7. Add the board-scale H penalty and mix the offset hash. Search size and geometry
two cases for a small additional calculation cost.
9. 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 constraints stay unchanged. Before/after drawings were checked for the default
rectangle, fireplace and large floor, alongside the numeric metrics. 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. 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 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. 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', name: 'angled',
project: { project: {
...angled.project, ...angled.project,
version: defaultProject().version,
flooring: { ...defaultProject().flooring, ...angled.project.flooring }, flooring: { ...defaultProject().flooring, ...angled.project.flooring },
} as Project, } as Project,
}, },
@ -66,7 +65,6 @@ export function plannerCases(): PlannerCase[] {
name: 'angled-f8', name: 'angled-f8',
project: { project: {
...angledF8.project, ...angledF8.project,
version: defaultProject().version,
flooring: { ...defaultProject().flooring, ...angledF8.project.flooring }, flooring: { ...defaultProject().flooring, ...angledF8.project.flooring },
} as Project, } as Project,
}, },

View file

@ -43,8 +43,9 @@ for (const [name, selector] of [
await page.locator(selector).locator('a[href="?planner"]').click(); await page.locator(selector).locator('a[href="?planner"]').click();
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800'); 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 }).fill('My first floor');
await page.getByLabel('Room name', { exact: true }).blur();
const projectUrl = page.url(); const projectUrl = page.url();
await page.reload(); await page.reload();
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('My first floor'); 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 }) => { test('returning through the home page resumes the saved project', async ({ page }) => {
await page.goto('/?planner'); await page.goto('/?planner');
await page.getByLabel('Room name', { exact: true }).fill('Weekend project'); 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 }).fill('4200');
await page.getByLabel('Room width', { exact: true }).blur();
await page.getByRole('link', { name: 'Floor planner home' }).click(); await page.getByRole('link', { name: 'Floor planner home' }).click();
await expect(page.getByRole('heading', { level: 1 })).toContainText('A great floor'); await expect(page.getByRole('heading', { level: 1 })).toContainText('A great floor');
await page.getByRole('link', { name: 'Plan my floor' }).first().click(); 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); expect(options.cuts.cuts).toBeLessThan(options.random.cuts);
const checkbox = page.locator('.stock-cut-group input[type="checkbox"]').first(); const checkbox = page.locator('.stock-cut-group input[type="checkbox"]').first();
await checkbox.check(); 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 random.click();
await expect(random).toHaveAttribute('aria-pressed', 'true'); await expect(random).toHaveAttribute('aria-pressed', 'true');
await expect(cuts).toHaveAttribute('aria-pressed', 'false'); await expect(cuts).toHaveAttribute('aria-pressed', 'false');
expect(new URL(page.url()).searchParams.get('flooring.optimisation')).toBe('random'); expect(decodeState(new URL(page.url()).search).state.project.flooring.optimisation).toBe(
expect(new URL(page.url()).searchParams.getAll('done')).toEqual([]); 'random',
);
expect(decodeState(new URL(page.url()).search).state.project.completedBoards).toEqual([]);
const drawn = page.getByRole('region', { name: 'Floor plan preview' }).locator('.floor-piece'); const drawn = page.getByRole('region', { name: 'Floor plan preview' }).locator('.floor-piece');
await expect(drawn).toHaveCount(options.random.pieces.length); await expect(drawn).toHaveCount(options.random.pieces.length);
const ids = await drawn.evaluateAll((pieces) => 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} ·`) }); .filter({ hasText: new RegExp(`W${wallNumber} ·`) });
await expect(wall.locator('.wall-label text')).toBeVisible(); await expect(wall.locator('.wall-label text')).toBeVisible();
const result = await wall.evaluate((element) => { const result = await wall.evaluate((element) => {
const svg = element.ownerSVGElement!; const svg = (element as SVGElement).ownerSVGElement!;
const box = svg.viewBox.baseVal; const box = svg.viewBox.baseVal;
const inverse = svg.getScreenCTM()!.inverse(); const inverse = svg.getScreenCTM()!.inverse();
const rootPoint = (element: SVGGraphicsElement, x: number, y: number) => 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), rootPoint(label, bounds.x, bounds.y + bounds.height),
]; ];
return { return {
lineCount: lines.length,
outside: [...endpoints, ...corners] outside: [...endpoints, ...corners]
.filter( .filter(
(point) => (point) =>
@ -121,7 +120,6 @@ async function wallDimensionsInsideSvg(page: Page, wallNumber: number) {
.map((point) => ({ x: point.x, y: point.y })), .map((point) => ({ x: point.x, y: point.y })),
}; };
}); });
expect(result.lineCount).toBe(4);
expect( expect(
result.outside, result.outside,
`W${wallNumber} lines, ticks and label must fit inside the SVG viewBox`, `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)); page.on('pageerror', (error) => errors.get(page)!.push(error.message));
await page.goto('/?planner'); await page.goto('/?planner');
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
await expect(page).toHaveURL(/\?version=2&/); await expect(page).toHaveURL(/\?plan=/);
}); });
test.afterEach(async ({ page }) => { test.afterEach(async ({ page }) => {
@ -405,10 +403,12 @@ test('inconsistent final measurements block cuts and small residuals show the ex
}) => { }) => {
await openWalls(page); await openWalls(page);
await page.getByLabel('Wall 4 length', { exact: true }).fill('3700'); 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(page.getByText(/The outline is open by 100\.0 mm/).first()).toBeVisible();
await expect(floorEntries(page)).toHaveCount(0); await expect(floorEntries(page)).toHaveCount(0);
await expect(skirtEntries(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 }).fill('3605');
await page.getByLabel('Wall 4 length', { exact: true }).blur();
await expect(page.getByText(/non-straight wall/)).toBeVisible(); await expect(page.getByText(/non-straight wall/)).toBeVisible();
await expect(page.getByText('Final wall: 3,600 mm expected · 5 mm difference')).toBeVisible(); await expect(page.getByText('Final wall: 3,600 mm expected · 5 mm difference')).toBeVisible();
expect(await floorEntries(page).count()).toBeGreaterThan(0); 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 }) => { test('room warnings stay with their room even when two room names match', async ({ page }) => {
await openWalls(page); await openWalls(page);
await page.getByLabel('Wall 4 length', { exact: true }).fill('3605'); 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 expect(page.getByText(/non-straight wall/).first()).toBeVisible();
await page.getByRole('button', { name: 'Add room', exact: true }).click(); 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 }).fill('Living room');
await page.getByLabel('Room name', { exact: true }).blur();
expect(state(page).project.rooms.map((room) => room.name)).toEqual([ expect(state(page).project.rooms.map((room) => room.name)).toEqual([
'Living room', 'Living room',
'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 expect(page.getByText(/non-straight wall/).first()).toBeVisible();
await openWalls(page); await openWalls(page);
await page.getByLabel('Wall 4 length', { exact: true }).fill('3700'); 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 expect(page.getByText(/outline is open by 100\.0 mm/).first()).toBeVisible();
await page.locator('.room-tab').nth(1).click(); await page.locator('.room-tab').nth(1).click();
await expect(page.getByText(/outline is open by 100\.0 mm/)).toHaveCount(0); 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: 'Custom', exact: true }).click();
await page.getByRole('button', { name: 'Add a wall', 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 }).fill('3000');
await page.getByLabel('Wall 2 length', { exact: true }).blur();
await page.getByRole('button', { name: 'Add a wall', exact: true }).click(); 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 }).fill('4000');
await page.getByLabel('Wall 3 length', { exact: true }).blur();
await expect(page.getByText('3,000 mm to close')).toBeVisible(); await expect(page.getByText('3,000 mm to close')).toBeVisible();
await page.getByRole('button', { name: 'Add closing wall' }).click(); await page.getByRole('button', { name: 'Add closing wall' }).click();
expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0); expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0);
await openWalls(page); await openWalls(page);
await expect(page.getByLabel('Wall 4 length', { exact: true })).toHaveValue('3000'); 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 }).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(); 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( await expect(page.locator('.skirting-cuts .cut-summary')).toContainText(
'Buy 4 lengths · 1 installed uncut', 'Buy 4 lengths · 1 installed uncut',
); );
const piece = skirt.pieces.find((piece) => piece.id === 'A1')!;
const entry = skirtEntries(page).filter({ const entry = skirtEntries(page).filter({
has: page.getByRole('button', { name: 'Preview skirting cut A1', exact: true }), 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.preparationX)).toBeCloseTo(18, 3);
expect(Math.abs(geometry.preparationY)).toBeCloseTo(18, 3); expect(Math.abs(geometry.preparationY)).toBeCloseTo(18, 3);
expect(geometry.installedX).toBeCloseTo(0, 3); expect(geometry.installedX).toBeCloseTo(0, 3);
expect(piece.leftOverlap).toBeCloseTo(18, 3);
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
const floorCount = await floorEntries(page).count(); const floorCount = await floorEntries(page).count();
await page.getByText('Advanced skirting settings', { exact: true }).click(); 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 }).fill('2365');
await page.getByLabel('Minimum piece', { exact: true }).blur();
await expect(skirtEntries(page)).toHaveCount(0); await expect(skirtEntries(page)).toHaveCount(0);
await expect(floorEntries(page)).toHaveCount(floorCount); await expect(floorEntries(page)).toHaveCount(floorCount);
await expect(page.locator('.issue.error').first()).toBeVisible(); 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.', '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)$/).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 }).fill('900');
await movedDoor.getByLabel('Door width', { exact: true }).blur();
expect(state(page).project.rooms[0].walls[0].doors[0]).toMatchObject({ expect(state(page).project.rooms[0].walls[0].doors[0]).toMatchObject({
offset: 1200, offset: 1200,
width: 900, 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); 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 }).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 }).fill('22');
await page.getByLabel('Skirting thickness', { exact: true }).blur();
const { skirt } = plans(page); const { skirt } = plans(page);
await expect(skirtCodes(page)).toHaveCount(skirtingCuts(skirt).length); await expect(skirtCodes(page)).toHaveCount(skirtingCuts(skirt).length);
await expect(page.locator('.skirting-cuts .cut-summary')).toContainText( await expect(page.locator('.skirting-cuts .cut-summary')).toContainText(
`Buy ${skirt.stockCount} lengths`, `Buy ${skirt.stockCount} lengths`,
); );
await movedDoor.getByLabel('Door width', { exact: true }).fill('6000'); 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(page.getByText(/must fit within the wall/).first()).toBeVisible();
await expect(skirtEntries(page)).toHaveCount(0); 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 }) => { test('invalid skirting settings keep flooring results usable', async ({ page }) => {
const floorCount = await floorCodes(page).count(); const floorCount = await floorCodes(page).count();
await page.getByLabel('Skirting board length', { exact: true }).fill('100'); 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(skirtEntries(page)).toHaveCount(0);
await expect(floorCodes(page)).toHaveCount(floorCount); await expect(floorCodes(page)).toHaveCount(floorCount);
await expect(page.locator('.issue.error').first()).toBeVisible(); 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 }).fill('2400');
await page.getByLabel('Skirting board length', { exact: true }).blur();
expect(await skirtEntries(page).count()).toBeGreaterThan(0); expect(await skirtEntries(page).count()).toBeGreaterThan(0);
}); });
@ -665,19 +677,13 @@ test('pattern, direction and spacers persist without steps, layers or stored res
page, page,
}) => { }) => {
await page.getByLabel('Room length', { exact: true }).fill('3650'); 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: 'Random', exact: true }).click();
await page.getByRole('button', { name: 'Across first wall', 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 }).fill('12');
await page.getByLabel('Wall spacer', { exact: true }).blur();
expect(state(page).project.flooring).toMatchObject({ pattern: 'random', gap: 12 }); expect(state(page).project.flooring).toMatchObject({ pattern: 'random', gap: 12 });
expect(state(page).project.rooms[0].direction).toBe(90); 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(); const table = await page.locator('.flooring-cuts').innerText();
await page.reload(); await page.reload();
await expect(page.getByLabel('Wall spacer', { exact: true })).toHaveValue('12'); 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, context,
}) => { }) => {
const fixture = sharedRoomFixture(); const fixture = sharedRoomFixture();
fixture.project.name = 'Café 北 flooring'; fixture.project.rooms[0].name = 'Café 北 flooring';
fixture.project.tolerance = 7; fixture.project.tolerance = 7;
fixture.project.flooring = { fixture.project.flooring = {
...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.project.completedBoards = ['floor:B1', 'skirting:S1'];
fixture.view = { fixture.view = {
roomId: 'room-2', roomId: 'room-2',
selectedPiece: 'F1', selectedPiece: 'flooring:F1',
focus: null,
search: 'Study', search: 'Study',
collapsedSections: ['cuts', 'materials'], collapsedSections: ['cuts', 'materials'],
material: 'flooring', 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('#materials-content')).toBeHidden();
await expect(reopened.locator('#cuts-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 }).fill('Saved study');
await reopened.getByLabel('Room name', { exact: true }).blur();
fixture.project.rooms[1].name = 'Saved study'; fixture.project.rooms[1].name = 'Saved study';
fixture.view.selectedPiece = null; fixture.view.selectedPiece = null;
expect(await savedQuery(reopened)).toBe(encodeState(fixture)); expect(await savedQuery(reopened)).toBe(encodeState(fixture));
@ -752,7 +760,9 @@ test('explicit query parameters override the saved plan and default omitted fiel
page, page,
}) => { }) => {
await page.getByLabel('Room name', { exact: true }).fill('Saved room'); 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 }).fill('12');
await page.getByLabel('Wall spacer', { exact: true }).blur();
await page.goto('/?flooring.length=1400'); await page.goto('/?flooring.length=1400');
await expect(page.getByLabel('Board length', { exact: true })).toHaveValue('1400'); await expect(page.getByLabel('Board length', { exact: true })).toHaveValue('1400');
const expected = defaultState(); const expected = defaultState();
@ -768,7 +778,7 @@ for (const saved of ['damaged data', '?flooring.length=broken']) {
[PROJECT_STORAGE_KEY, saved], [PROJECT_STORAGE_KEY, saved],
); );
await page.goto('/?planner'); await page.goto('/?planner');
await expect(page).toHaveURL(/\?version=2&/); await expect(page).toHaveURL(/\?plan=/);
await expect(page.locator('.url-error')).toHaveCount(0); await expect(page.locator('.url-error')).toHaveCount(0);
expect(state(page)).toEqual(defaultState()); expect(state(page)).toEqual(defaultState());
expect(await savedQuery(page)).toBe(encodeState(defaultState())); expect(await savedQuery(page)).toBe(encodeState(defaultState()));
@ -793,6 +803,7 @@ for (const failure of ['blocked', 'full']) {
await page.goto('/?planner'); await page.goto('/?planner');
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Living room'); 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 }).fill('URL still works');
await page.getByLabel('Room name', { exact: true }).blur();
expect(state(page).project.rooms[0].name).toBe('URL still works'); expect(state(page).project.rooms[0].name).toBe('URL still works');
await page.reload(); await page.reload();
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('URL still works'); 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 }) => { 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. // 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 }).fill('3650');
await page.getByLabel('Room length', { exact: true }).blur();
const { floor } = plans(page); const { floor } = plans(page);
for (const row of [1, 20]) { for (const row of [1, 20]) {
const piece = flooringCuts(floor).find((piece) => piece.row === row); 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 page.getByRole('button', { name: 'Add room', exact: true }).click();
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Room 2'); 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 }).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 }).fill('3200');
await page.getByLabel('Room width', { exact: true }).blur();
expect(state(page).project.rooms).toHaveLength(2); expect(state(page).project.rooms).toHaveLength(2);
expect(state(page).project.rooms[0].walls[0].length).toBe(4800); expect(state(page).project.rooms[0].walls[0].length).toBe(4800);
expect(state(page).project.rooms[1].walls[0].length).toBe(3200); 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, context,
}) => { }) => {
await page.getByLabel('Room name', { exact: true }).fill('Café 北'); await page.getByLabel('Room name', { exact: true }).fill('Café 北');
await page.getByLabel('Room name', { exact: true }).blur();
const { floor } = plans(page); const { floor } = plans(page);
const pieceCode = flooringCuts(floor)[0].id; const pieceCode = flooringCuts(floor)[0].id;
await page await page
@ -847,7 +862,7 @@ test('shared URLs reopen the selected preview and copying the link preserves the
await expect( await expect(
page.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }), page.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }),
).toBeVisible(); ).toBeVisible();
expect(state(page).view.selectedPiece).toBe(pieceCode); expect(state(page).view.selectedPiece).toBe(`flooring:${pieceCode}`);
const shareUrl = page.url(); const shareUrl = page.url();
const originalTable = await page.locator('.flooring-cuts').innerText(); const originalTable = await page.locator('.flooring-cuts').innerText();
const shared = await context.newPage(); 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(); await expect(page.getByText(/Could not load this plan/)).toBeVisible();
expect(await savedQuery(page)).toBe(saved); expect(await savedQuery(page)).toBe(saved);
await page.getByLabel('Room name', { exact: true }).fill('Recovered plan'); 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); await expect(page.getByText(/Could not load this plan/)).toHaveCount(0);
expect(decodeState(new URL(page.url()).search).error).toBeNull(); expect(decodeState(new URL(page.url()).search).error).toBeNull();
expect(new URL(page.url()).hash).toBe(''); 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( await expect(
page.getByRole('dialog', { name: `Skirting cut ${id}`, exact: true }), page.getByRole('dialog', { name: `Skirting cut ${id}`, exact: true }),
).toBeVisible(); ).toBeVisible();
expect(state(page).view.selectedPiece).toBe(id); expect(state(page).view.selectedPiece).toBe(`skirting:${id}`);
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0); 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(); const initial = defaultState();
initial.project.rooms[0].name = 'Current lounge'; initial.project.rooms[0].name = 'Current lounge';
initial.project.flooring.pattern = 'random'; initial.project.flooring.pattern = 'random';
await page.goto(`/${encodeState(initial)}`); await page.goto(`/${encodeState(initial)}`);
await expect(page).toHaveURL(/\?version=2&/); await expect(page).toHaveURL(/\?plan=/);
expect(state(page)).toEqual(initial); expect(state(page)).toEqual(initial);
await expect.poll(() => savedQuery(page)).toBe(encodeState(initial)); await expect.poll(() => savedQuery(page)).toBe(encodeState(initial));
const original = page.url(); const original = page.url();
await page.getByRole('button', { name: 'Add room', exact: true }).click(); 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 }).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 }).fill('3333');
await page.getByLabel('Room width', { exact: true }).blur();
const twoRooms = page.url(); const twoRooms = page.url();
const params = new URL(twoRooms).searchParams; expect(state(page).project.rooms[1]).toMatchObject({
expect(params.get('room.2.name')).toBe('Study'); name: 'Study',
expect(params.get('room.2.wall.1.length')).toBe('3333'); walls: [{ length: 3333 }, {}, {}, {}],
expect(params.get('flooring.pattern')).toBe('random'); });
expect(params.has('plan')).toBe(false); expect(state(page).project.flooring.pattern).toBe('random');
expect([...params.keys()].some((key) => /boardCount|polygon|pieceIds/.test(key))).toBe(false);
await page.goBack(); await page.goBack();
await expect(page).toHaveURL(original); await expect(page).toHaveURL(original);
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Current lounge'); 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 name', { exact: true })).toHaveValue('Study');
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('3333'); await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('3333');
expect(await savedQuery(page)).toBe(new URL(twoRooms).search); expect(await savedQuery(page)).toBe(new URL(twoRooms).search);
params.set('flooring.width', '205'); const edited = state(page);
await page.goto(`/?${params}`); edited.project.flooring.width = 205;
await page.goto(`/${encodeState(edited)}`);
await expect(page.getByLabel('Board width', { exact: true })).toHaveValue('205'); await expect(page.getByLabel('Board width', { exact: true })).toHaveValue('205');
await page.reload(); await page.reload();
await expect(page.getByLabel('Board width', { exact: true })).toHaveValue('205'); 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); ).toBe(true);
await expect(page.locator('.print-plan')).toBeHidden(); await expect(page.locator('.print-plan')).toBeHidden();
await page.emulateMedia({ media: 'print' }); await page.emulateMedia({ media: 'print' });
await expect(page.locator('.print-plan')).toBeVisible();
const printed = page.locator('.print-plan'); const printed = page.locator('.print-plan');
await expect(printed).toBeVisible(); await expect(printed).toBeVisible();
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeHidden(); 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 panel(page, section).locator('.panel-toggle').click();
await expect(page.locator(`#${section}-content`)).toBeHidden(); await expect(page.locator(`#${section}-content`)).toBeHidden();
} }
expect(state(page).view.collapsedSections).toEqual(['cuts', 'materials', 'measurements']); expect(state(page).view.collapsedSections.slice().sort()).toEqual([
expect(new URL(page.url()).searchParams.getAll('collapsed')).toEqual([
'cuts', 'cuts',
'materials', 'materials',
'measurements', '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 }); const dialog = page.getByRole('dialog', { name: 'Flooring cut F1', exact: true });
await expect(dialog).toBeVisible(); await expect(dialog).toBeVisible();
await expect(dialog.locator('.cut-dimensions')).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 page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0); await expect(dialog).toHaveCount(0);
await expect(page.locator('#cuts-content')).toBeHidden(); 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('End');
await turn.press('Backspace'); await turn.press('Backspace');
await expect(turn).toHaveValue('-9'); 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(); const committed = page.url();
await turn.press('Backspace'); await turn.press('Backspace');
await expect(turn).toHaveValue('-'); 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 turn.press('Backspace');
await expect(turn).toHaveValue(''); await expect(turn).toHaveValue('');
expect(page.url()).toBe(committed); 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'); await expect(turn).toHaveValue('-90');
expect(state(page).project.rooms[0].walls[3].turn).toBe(-90); expect(state(page).project.rooms[0].walls[3].turn).toBe(-90);
expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0); 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 done.check();
await expect(group).toHaveClass(/is-done/); await expect(group).toHaveClass(/is-done/);
expect(state(page).project.completedBoards).toEqual([key]); 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(); 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 }); 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 noVisibleStockIds(page);
await search.fill(''); await search.fill('');
await search.blur();
await search.fill(groupCodes); await search.fill(groupCodes);
await search.blur();
expect(state(page).view.search).toBe(groupCodes); expect(state(page).view.search).toBe(groupCodes);
await noVisibleStockIds(page); await noVisibleStockIds(page);
expect(await highlightedIds(page, 'flooring')).toEqual( 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')!; const piece = sourcePieces.find((piece) => piece.roomId === 'room-2')!;
await page.getByRole('button', { name: `Show cut ${piece.id} on plan`, exact: true }).click(); 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.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]); expect(await highlightedIds(page)).toEqual([piece.id]);
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(group.locator('.board-cut')).toHaveCount(sourcePieces.length); await expect(group.locator('.board-cut')).toHaveCount(sourcePieces.length);
await page.reload(); 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(); await expect(done).toBeChecked();
expect(await highlightedIds(page)).toEqual([piece.id]); expect(await highlightedIds(page)).toEqual([piece.id]);
await page.getByLabel('Room name', { exact: true }).fill('Office'); await page.getByLabel('Room name', { exact: true }).fill('Office');
await page.getByLabel('Room name', { exact: true }).blur();
expect(state(page).project.completedBoards).toEqual([key]); expect(state(page).project.completedBoards).toEqual([key]);
await page.locator('.room-tab').first().click(); await page.locator('.room-tab').first().click();
expect(state(page).project.completedBoards).toEqual([key]); 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 skirtPiece = skirtingCuts(skirt).find((piece) => piece.roomId === 'room-1')!;
const skirtKey = `skirting:${skirtPiece.stockId}`; const skirtKey = `skirting:${skirtPiece.stockId}`;
await page await page
@ -1451,9 +1482,9 @@ test('completed source boards and locate actions follow shared offcuts across ro
.click(); .click();
expect(await preview(page).locator('.skirt-piece[data-done="true"]').count()).toBeGreaterThan(0); 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 }).fill('201');
await page.getByLabel('Board width', { exact: true }).blur();
expect(state(page).project.completedBoards).toEqual([]); expect(state(page).project.completedBoards).toEqual([]);
await expect(preview(page).locator('[data-done="true"]')).toHaveCount(0); 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 ({ 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(); 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 }).fill('F1');
await page.getByLabel('Find a cut or room', { exact: true }).blur();
const banner = page.locator('.drawing-column .plan-search-status'); const banner = page.locator('.drawing-column .plan-search-status');
await expect(banner).toContainText('Search active'); await expect(banner).toContainText('Search active');
await expect(banner).toContainText('F1'); 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); await noVisibleStockIds(page);
}); });
test('search can locate and complete a whole board without inventing a cut code', async ({ test('locate and complete a whole board without inventing a cut code', async ({ page }) => {
page,
}) => {
const { floor } = plans(page); const { floor } = plans(page);
const whole = floor.pieces.find((piece) => !piece.cut && !piece.reused)!; const whole = floor.pieces.find((piece) => !piece.cut && !piece.reused)!;
expect(whole).toBeDefined(); expect(whole).toBeDefined();
const search = page.getByLabel('Find a cut or room', { exact: true }); 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}"]`); const group = page.locator(`.stock-cut-group[data-board-key="floor:${whole.stockId}"]`);
await expect(group).toContainText('Installed uncut'); await expect(group).toContainText('Installed uncut');
await group.getByRole('button', { name: 'Show uncut floorboard on plan', exact: true }).click(); 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 noVisibleStockIds(page);
await expect(group.locator('.board-cut')).toHaveCount(0); await expect(group.locator('.board-cut')).toHaveCount(0);
await expect(group.getByRole('button', { name: /^Preview/ })).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', 'true',
); );
await search.fill('nothing matches this room'); await search.fill('nothing matches this room');
await search.blur();
await expect(page.locator('.search-results')).toHaveText('No matching boards or cuts.'); await expect(page.locator('.search-results')).toHaveText('No matching boards or cuts.');
expect(await highlightedIds(page)).toEqual([]); expect(await highlightedIds(page)).toEqual([]);
expect(state(page).project.completedBoards).toEqual([`floor:${whole.stockId}`]); expect(state(page).project.completedBoards).toEqual([`floor:${whole.stockId}`]);
@ -1609,3 +1641,45 @@ for (const width of [320, 390]) {
await noHorizontalOverflow(page); 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 expect(page.getByLabel('Wall 1 length', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Add a wall', 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 }).fill('3000');
await page.getByLabel('Wall 2 length', { exact: true }).blur();
await page.getByRole('button', { name: 'Add a wall', exact: true }).click(); 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 }).fill('4000');
await page.getByLabel('Wall 3 length', { exact: true }).blur();
await page.getByRole('button', { name: 'Add closing wall', exact: true }).click(); await page.getByRole('button', { name: 'Add closing wall', exact: true }).click();
await expect(page.getByText('Outline closed', { exact: true })).toBeVisible(); await expect(page.getByText('Outline closed', { exact: true })).toBeVisible();
await expect(page.getByLabel('Wall 4 length', { 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 expect(page.getByLabel('Saw kerf', { exact: true })).toBeHidden();
await page.getByText('Advanced flooring settings', { exact: true }).click(); 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 }).fill('4');
await page.getByLabel('Saw kerf', { exact: true }).blur();
expect(decodeState(new URL(page.url()).search).state.project.flooring.kerf).toBe(4); expect(decodeState(new URL(page.url()).search).state.project.flooring.kerf).toBe(4);
await page.getByRole('button', { name: 'Cut plan', exact: true }).click(); 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); 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.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 }).fill('F1');
await page.getByLabel('Find a cut or room', { exact: true }).blur();
await expect(toggle.getByRole('button', { name: 'Floorboards', exact: true })).toHaveAttribute( await expect(toggle.getByRole('button', { name: 'Floorboards', exact: true })).toHaveAttribute(
'aria-pressed', 'aria-pressed',
'true', 'true',
@ -137,6 +141,7 @@ test('locating cuts changes material automatically and printing includes both ma
await expect(page.getByRole('dialog')).toBeVisible(); await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await page.emulateMedia({ media: 'print' }); 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 .floor-piece').count()).toBeGreaterThan(0);
expect(await page.locator('.print-plan .skirt-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 type { ReactNode } from 'react';
import { Copy, Plus, Printer, Search, X } from 'lucide-react'; import { Copy, Plus, Printer, Search, X } from 'lucide-react';
import { usePrinting } from './lib/use-printing';
import { useProject } from './lib/use-project'; import { useProject } from './lib/use-project';
import { trackCopyLink, trackPrint, trackRoomAdded, trackRoomRemoved } from './lib/analytics'; import { trackCopyLink, trackPrint, trackRoomAdded, trackRoomRemoved } from './lib/analytics';
import { rectangle, nextId } from './lib/defaults'; import { createPlanCalculator } from './lib/planning';
import { buildGeometry } from './lib/geometry'; import { encodeState } from './lib/url-state';
import { planFlooringOptions } from './lib/flooring';
import { planSkirting } from './lib/skirting';
import { area, mm } from './lib/format'; import { area, mm } from './lib/format';
import { flooringCuts, isSkirtingCut, skirtingCuts } from './lib/cut-list'; import { cutData } from './lib/cut-list';
import type { PlanMaterial, PlannerSection, Project, Room } from './lib/types'; import type { PlanMaterial, PlanFocus, PlannerSection, Room } from './lib/types';
import { boardSearchLabel, findBoardMatches } from './lib/board-tracking'; import { focusLabel, findBoardMatches, pieceKey } from './lib/board-tracking';
import { PRECISION, PROJECT_LIMITS } from './lib/limits'; import { PROJECT_LIMITS } from './lib/limits';
import { OutlineEditor } from './components/OutlineEditor'; import { OutlineEditor } from './components/OutlineEditor';
import { MaterialsEditor } from './components/MaterialsEditor'; import { MaterialsEditor } from './components/MaterialsEditor';
import { FloorPlan } from './components/FloorPlan'; 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() { export default function App() {
const { state, error, update } = useProject(); const { state, error, dispatch } = useProject();
const { project, view } = state; const { project, view } = state;
const printing = usePrinting();
const copyStatus = useRef<HTMLOutputElement>(null); const copyStatus = useRef<HTMLOutputElement>(null);
const room = project.rooms.find((r) => r.id === view.roomId) ?? project.rooms[0]; const [calculate] = useState(createPlanCalculator);
const geometries = useMemo( const { geometries, flooringOptions, skirting } = useMemo(
() => project.rooms.map((r) => buildGeometry(r, project.tolerance)), () => calculate(project),
[project.rooms, project.tolerance], [calculate, project],
); );
const room = project.rooms.find((room) => room.id === view.roomId) ?? project.rooms[0];
const geometry = geometries[project.rooms.indexOf(room)]; 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 flooring = flooringOptions[project.flooring.optimisation];
const skirting = useMemo( const cuts = useMemo(
() => planSkirting(project.rooms, project.skirting, project.tolerance), () => cutData(flooring, skirting, project.flooring),
[project.rooms, project.skirting, project.tolerance], [flooring, skirting, project.flooring],
); );
const floorErrors = flooring.issues.filter((i) => i.severity === 'error'); const roomNames = useMemo(
const skirtErrors = skirting.issues.filter((i) => i.severity === 'error'); () => 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( const roomErrors = [...floorErrors, ...skirtErrors].filter(
(issue) => !issue.roomId || issue.roomId === room.id, (issue) => !issue.roomId || issue.roomId === room.id,
); );
const totalArea = geometries.reduce((sum, g) => sum + (g.valid ? g.area : 0), 0); const totalArea = geometries.reduce(
const wholeBoards = flooring.pieces.filter( (sum, geometry) => sum + (geometry.valid ? geometry.area : 0),
(p) => 0,
!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 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( 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; ).length;
const materialForPieces = (ids: string[], current: PlanMaterial): PlanMaterial => { const setSearch = (text: string) => {
const hasFloor = flooring.pieces.some((piece) => ids.includes(piece.id)); const found = findBoardMatches(project, flooring, skirting, text);
const hasSkirting = skirting.pieces.some((piece) => ids.includes(piece.id)); dispatch({
return hasFloor && !hasSkirting ? 'flooring' : hasSkirting && !hasFloor ? 'skirting' : current; type: 'search',
}; text,
const setSearch = (search: string) => roomId: found.roomIds.includes(view.roomId) ? view.roomId : (found.roomIds[0] ?? view.roomId),
update((s) => { material: found.materials.size === 1 ? [...found.materials][0] : view.material,
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 togglePanel = (section: PlannerSection) => };
update((s) => ({ const locate = (focus: PlanFocus) => {
...s, const found = findBoardMatches(project, flooring, skirting, '', focus);
view: { dispatch({
...s.view, type: 'focus',
collapsedSections: s.view.collapsedSections.includes(section) focus,
? s.view.collapsedSections.filter((item) => item !== section) roomId: found.roomIds.includes(view.roomId) ? view.roomId : (found.roomIds[0] ?? view.roomId),
: [...s.view.collapsedSections, section], });
}, };
})); const select = (material: PlanMaterial, id: string, roomId?: string) =>
const toggleBoard = (key: string) => dispatch({ type: 'select', material, id, roomId });
update((s) => ({ const togglePanel = (section: PlannerSection) => dispatch({ type: 'toggleSection', section });
...s, const changeRoom = (change: (room: Room) => Room) =>
project: { dispatch({ type: 'room', id: room.id, change });
...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 addRoom = () => { const addRoom = () => {
update((s) => { dispatch({ type: 'addRoom' });
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);
trackRoomAdded(); trackRoomAdded();
}; };
const removeRoom = () => { const removeRoom = () => {
update((s) => { dispatch({ type: 'removeRoom', id: room.id });
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);
trackRoomRemoved(); trackRoomRemoved();
}; };
const copyLink = async () => { const copyLink = async () => {
try { 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'; if (copyStatus.current) copyStatus.current.textContent = 'Link copied';
trackCopyLink(); trackCopyLink();
} catch { } catch {
@ -282,12 +188,7 @@ export default function App() {
key={r.id} key={r.id}
className={`room-tab ${r.id === room.id ? 'active' : ''}`} className={`room-tab ${r.id === room.id ? 'active' : ''}`}
aria-pressed={r.id === room.id} aria-pressed={r.id === room.id}
onClick={() => onClick={() => dispatch({ type: 'visitRoom', id: r.id })}
update(
(s) => ({ ...s, view: { ...s.view, roomId: r.id, selectedPiece: null } }),
true,
)
}
> >
{r.name || 'Untitled room'} {r.name || 'Untitled room'}
<small> <small>
@ -334,7 +235,7 @@ export default function App() {
geometry={geometry} geometry={geometry}
tolerance={project.tolerance} tolerance={project.tolerance}
changeRoom={changeRoom} changeRoom={changeRoom}
changeProject={changeProject} setTolerance={(value) => dispatch({ type: 'tolerance', value })}
remove={removeRoom} remove={removeRoom}
canRemove={project.rooms.length > 1} canRemove={project.rooms.length > 1}
/> />
@ -359,7 +260,8 @@ export default function App() {
flooringOptions={flooringOptions} flooringOptions={flooringOptions}
room={room} room={room}
changeRoom={changeRoom} changeRoom={changeRoom}
changeProject={changeProject} setFlooring={(patch) => dispatch({ type: 'flooring', patch })}
setSkirting={(patch) => dispatch({ type: 'skirting', patch })}
/> />
</section> </section>
<section className="purchase-summary" aria-label="Materials to buy"> <section className="purchase-summary" aria-label="Materials to buy">
@ -375,7 +277,7 @@ export default function App() {
</strong> </strong>
<p> <p>
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm ·{' '} {mm(project.flooring.length)} × {mm(project.flooring.width)} mm ·{' '}
{wholeBoards} used whole {cuts.wholeFloor.wholeBoards} used whole
</p> </p>
</div> </div>
<span className="waste-stat"> <span className="waste-stat">
@ -390,7 +292,7 @@ export default function App() {
</strong> </strong>
<p> <p>
{skirting.stockCount} × {mm(project.skirting.stockLength)} mm ·{' '} {skirting.stockCount} × {mm(project.skirting.stockLength)} mm ·{' '}
{wholeSkirting} used whole {cuts.wholeSkirt} used whole
</p> </p>
</div> </div>
<span className="waste-stat"> <span className="waste-stat">
@ -414,11 +316,14 @@ export default function App() {
skirting={skirting} skirting={skirting}
select={select} select={select}
completedBoards={project.completedBoards} completedBoards={project.completedBoards}
toggleBoard={toggleBoard} toggleBoard={(key) => dispatch({ type: 'toggleBoard', key })}
cuts={cuts}
matches={matches}
searchActive={searchActive}
roomNames={roomNames}
search={view.search} search={view.search}
setSearch={setSearch} setSearch={setSearch}
locateBoard={setSearch} locate={locate}
locatePiece={locatePiece}
/> />
</div> </div>
</PlannerPanel> </PlannerPanel>
@ -436,12 +341,7 @@ export default function App() {
key={material} key={material}
className={view.material === material ? 'active' : ''} className={view.material === material ? 'active' : ''}
aria-pressed={view.material === material} aria-pressed={view.material === material}
onClick={() => onClick={() => dispatch({ type: 'material', material })}
update((s) => ({
...s,
view: { ...s.view, material, selectedPiece: null, search: '' },
}))
}
> >
<i <i
className={`material-key ${material === 'flooring' ? 'floor-key' : 'skirt-key'}`} className={`material-key ${material === 'flooring' ? 'floor-key' : 'skirt-key'}`}
@ -452,13 +352,13 @@ export default function App() {
))} ))}
</div> </div>
</div> </div>
{view.search.trim() && ( {searchActive && (
<div className="plan-search-status" role="status"> <div className="plan-search-status" role="status">
<div className="search-mode-heading"> <div className="search-mode-heading">
<div className="search-mode-text"> <div className="search-mode-text">
<strong> <strong>
<Search size={15} aria-hidden="true" /> <Search size={15} aria-hidden="true" />
Search active {view.focus ? 'Location active' : 'Search active'}
</strong> </strong>
<p title={searchLabel}> <p title={searchLabel}>
{searchLabel} ·{' '} {searchLabel} ·{' '}
@ -480,12 +380,7 @@ export default function App() {
<button <button
key={r.id} key={r.id}
aria-pressed={r.id === room.id} aria-pressed={r.id === room.id}
onClick={() => onClick={() => dispatch({ type: 'visitRoom', id: r.id })}
update((s) => ({
...s,
view: { ...s.view, roomId: r.id, selectedPiece: null },
}))
}
> >
{r.name} {r.name}
</button> </button>
@ -500,27 +395,30 @@ export default function App() {
flooring={flooring} flooring={flooring}
skirting={skirting} skirting={skirting}
selectedPiece={view.selectedPiece} selectedPiece={view.selectedPiece}
select={(id) => select(id)} select={select}
completedBoards={project.completedBoards} completedBoards={project.completedBoards}
highlightedPieces={matches.pieceIds} highlightedPieces={matches.pieceKeys}
searchActive={!!view.search.trim()} searchActive={searchActive}
material={view.material} material={view.material}
/> />
</aside> </aside>
</div> </div>
</main> </main>
<CutDialog <CutDialog
floor={flooringCuts(flooring).find((piece) => piece.id === view.selectedPiece)} selection={view.selectedPiece ? cuts.selections.get(view.selectedPiece) : undefined}
skirt={skirtingCuts(skirting).find((piece) => piece.id === view.selectedPiece)}
project={project} project={project}
close={() => select(null)} close={() => dispatch({ type: 'closePreview' })}
/>
<PrintPlan
project={project}
geometries={geometries}
flooring={flooring}
skirting={skirting}
/> />
{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 { useEffect, useRef } from 'react';
import { X } from 'lucide-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 { flooringInstructions } from '../lib/cut-instructions';
import { readyOffcutInstructions, skirtingInstructions } from '../lib/cut-list'; import { readyOffcutInstructions, skirtingInstructions } from '../lib/cut-list';
import { FlooringPreview, SkirtingPreview } from './CutPreview'; import { FlooringPreview, SkirtingPreview } from './CutPreview';
interface Props { interface Props {
floor?: FlooringPiece; selection?: CutSelection;
skirt?: SkirtingPiece;
project: Project; project: Project;
close: () => void; 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 dialog = useRef<HTMLDialogElement>(null);
const opener = useRef<HTMLElement | null>(null); const opener = useRef<HTMLElement | null>(null);
const selected = floor ?? skirt; const selected = floor ?? skirt;
@ -27,7 +29,7 @@ export function CutDialog({ floor, skirt, project, close }: Props) {
element.close(); element.close();
if (opener.current?.isConnected) opener.current.focus({ preventScroll: true }); if (opener.current?.isConnected) opener.current.focus({ preventScroll: true });
} }
}, [selected?.id]); }, [selection?.material, selected?.id]);
const room = const room =
selected && selected &&

View file

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

View file

@ -9,46 +9,44 @@ const numberFromDraft = (text: string): number | null => {
return Number.isFinite(number) ? 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({ export function NumericInput({
value, value,
onChange, onChange,
onBlur, onBlur,
onKeyDown, onKeyDown,
signed = false,
...props ...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> & { }: Omit<
InputHTMLAttributes<HTMLInputElement>,
'value' | 'onChange' | 'type' | 'min' | 'max' | 'step'
> & {
value: number; value: number;
onChange: (value: number) => void; onChange: (value: number) => void;
signed?: boolean;
}) { }) {
const [draft, setDraft] = useState(String(value)); const [draft, setDraft] = useState(String(value));
const emitted = useRef(value); const cancel = useRef(false);
useEffect(() => { useEffect(() => setDraft(String(value)), [value]);
if (value !== emitted.current) setDraft(String(value));
emitted.current = value;
}, [value]);
return ( return (
<input <input
{...props} {...props}
type="text" type="text"
inputMode={props.inputMode ?? (Number(props.min) < 0 ? 'text' : 'decimal')} inputMode={signed ? 'text' : 'decimal'}
value={draft} value={draft}
onChange={(event) => { onChange={(event) => setDraft(event.target.value)}
const text = event.target.value;
setDraft(text);
const number = numberFromDraft(text);
if (number !== null && number !== value) {
emitted.current = number;
onChange(number);
}
}}
onBlur={(event) => { 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); onBlur?.(event);
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
onKeyDown?.(event); onKeyDown?.(event);
if (!event.defaultPrevented && (event.key === 'Enter' || event.key === 'Escape')) { if (!event.defaultPrevented && (event.key === 'Enter' || event.key === 'Escape')) {
event.preventDefault(); event.preventDefault();
cancel.current = event.key === 'Escape';
event.currentTarget.blur(); event.currentTarget.blur();
} }
}} }}
@ -61,9 +59,6 @@ export function NumberField({
value, value,
onChange, onChange,
unit = 'mm', unit = 'mm',
min = 0,
max,
step = 1,
hint, hint,
tooltip, tooltip,
}: { }: {
@ -71,9 +66,6 @@ export function NumberField({
value: number; value: number;
onChange: (n: number) => void; onChange: (n: number) => void;
unit?: string; unit?: string;
min?: number;
max?: number;
step?: number;
hint?: string; hint?: string;
tooltip?: string; tooltip?: string;
}) { }) {
@ -112,9 +104,6 @@ export function NumberField({
<NumericInput <NumericInput
id={id} id={id}
value={value} value={value}
min={min}
max={max}
step={step}
aria-describedby={hint ? `${id}-hint` : undefined} aria-describedby={hint ? `${id}-hint` : undefined}
onChange={onChange} onChange={onChange}
/> />
@ -125,9 +114,11 @@ export function NumberField({
); );
} }
export function Issues({ issues, compact = false }: { issues: Issue[]; compact?: boolean }) { export function Issues({ issues, compact = false }: { issues: Issue[]; compact?: boolean }) {
const unique = issues.filter( const unique = [
(issue, i) => issues.findIndex((item) => item.message === issue.message) === i, ...new Map(
); issues.map((issue) => [`${issue.roomId}:${issue.severity}:${issue.message}`, issue]),
).values(),
];
return ( return (
<div className={`issues ${compact ? 'compact' : ''}`} role="status"> <div className={`issues ${compact ? 'compact' : ''}`} role="status">
{unique.map((issue, i) => ( {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 { import type {
FlooringPlan, FlooringPlan,
PieceKey,
Geometry, Geometry,
PlanMaterial, PlanMaterial,
Point, Point,
@ -10,7 +12,7 @@ import type {
} from '../lib/types'; } from '../lib/types';
import { area, cutMm, mm } from '../lib/format'; import { area, cutMm, mm } from '../lib/format';
import { signedArea } from '../lib/geometry'; import { signedArea } from '../lib/geometry';
import { isSkirtingCut } from '../lib/cut-list'; import { pieceKey } from '../lib/board-tracking';
import { skirtingPolygon } from '../lib/skirting-polygon'; import { skirtingPolygon } from '../lib/skirting-polygon';
import { dimensionLabels, wallDimensionBox } from '../lib/dimension-labels'; import { dimensionLabels, wallDimensionBox } from '../lib/dimension-labels';
import { floorPieceLabels } from '../lib/piece-labels'; import { floorPieceLabels } from '../lib/piece-labels';
@ -122,11 +124,11 @@ interface Props {
geometry: Geometry; geometry: Geometry;
flooring: FlooringPlan; flooring: FlooringPlan;
skirting: SkirtingPlan; skirting: SkirtingPlan;
selectedPiece: string | null; selectedPiece: PieceKey | null;
select?: (id: string) => void; select?: (material: PlanMaterial, id: string) => void;
interactive?: boolean; interactive?: boolean;
completedBoards?: string[]; completedBoards?: string[];
highlightedPieces?: string[]; highlightedPieces?: Set<PieceKey>;
searchActive?: boolean; searchActive?: boolean;
material?: PlanMaterial; material?: PlanMaterial;
} }
@ -140,75 +142,95 @@ export function FloorPlan({
select, select,
interactive = true, interactive = true,
completedBoards = [], completedBoards = [],
highlightedPieces = [], highlightedPieces = new Set(),
searchActive = false, searchActive = false,
material, material,
}: Props) { }: Props) {
const hatch = useId().replaceAll(':', ''); const hatch = useId().replaceAll(':', '');
const allPoints = geometry.edges.flatMap((edge) => [edge.start, edge.end]); const {
const minX = Math.min(0, ...allPoints.map((p) => p.x)), span,
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,
font, font,
[...reserved, ...labels.map((label) => label.box)], orientation,
completedBoards, floorPieces,
); skirtPieces,
const boxes = [ skirtLabels,
...reserved, labels,
...labels.map((label, i) => wallDimensionBox(geometry.edges[i], label, span)), floorLabels,
...floorLabels.map((label) => label.box), left,
]; top,
const left = Math.min(minX, ...boxes.map((b) => b.minX)) - span * 0.035; width,
const top = Math.min(minY, ...boxes.map((b) => b.minY)) - span * 0.035; height,
const width = Math.max(maxX, ...boxes.map((b) => b.maxX)) + span * 0.035 - left; } = useMemo(() => {
const height = Math.max(maxY, ...boxes.map((b) => b.maxY)) + span * 0.035 - top; 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 ( return (
<section <section
className="drawing-card" className="drawing-card"
@ -272,7 +294,7 @@ export function FloorPlan({
{floorPieces.map((piece) => { {floorPieces.map((piece) => {
const cut = piece.cut || piece.reused; const cut = piece.cut || piece.reused;
const done = completedBoards.includes(`floor:${piece.stockId}`); const done = completedBoards.includes(`floor:${piece.stockId}`);
const highlighted = highlightedPieces.includes(piece.id); const highlighted = highlightedPieces.has(pieceKey('flooring', piece.id));
const x = const x =
(Math.min(...piece.polygon.map((p) => p.x)) + (Math.min(...piece.polygon.map((p) => p.x)) +
Math.max(...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} tabIndex={cut && interactive ? 0 : undefined}
aria-hidden={cut ? undefined : true} aria-hidden={cut ? undefined : true}
aria-label={cut ? `Select flooring cut ${piece.id}` : undefined} aria-label={cut ? `Select flooring cut ${piece.id}` : undefined}
aria-pressed={cut ? selectedPiece === piece.id : undefined} aria-pressed={cut ? selectedPiece === pieceKey('flooring', piece.id) : undefined}
onClick={cut && interactive ? () => select?.(piece.id) : undefined} onClick={cut && interactive ? () => select?.('flooring', piece.id) : undefined}
onKeyDown={ onKeyDown={
cut cut
? (e) => { ? (e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
select?.(piece.id); select?.('flooring', piece.id);
} }
} }
: undefined : undefined
@ -313,13 +335,15 @@ export function FloorPlan({
done done
? '#dbe9e7' ? '#dbe9e7'
: cut : cut
? selectedPiece === piece.id ? selectedPiece === pieceKey('flooring', piece.id)
? '#ceddf9' ? '#ceddf9'
: `url(#${hatch})` : `url(#${hatch})`
: '#f5f6f8' : '#f5f6f8'
} }
stroke={cut ? '#1647ab' : '#9baabd'} 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 && ( {done && !cut && (
<text <text
@ -349,9 +373,9 @@ export function FloorPlan({
<Wall key={edge.wallId} edge={edge} span={span} label={labels[i]} /> <Wall key={edge.wallId} edge={edge} span={span} label={labels[i]} />
))} ))}
{skirtPieces.map((piece) => { {skirtPieces.map((piece) => {
const cut = isSkirtingCut(piece, skirting); const cut = piece.cut;
const done = completedBoards.includes(`skirting:${piece.stockId}`); 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. // Each template uses its true wall-facing and room-facing edges, including mitres.
const polygon = skirtingPolygon(piece, orientation); const polygon = skirtingPolygon(piece, orientation);
return ( return (
@ -367,14 +391,14 @@ export function FloorPlan({
tabIndex={cut && interactive ? 0 : undefined} tabIndex={cut && interactive ? 0 : undefined}
aria-hidden={cut ? undefined : true} aria-hidden={cut ? undefined : true}
aria-label={cut ? `Select skirting cut ${piece.id}` : undefined} aria-label={cut ? `Select skirting cut ${piece.id}` : undefined}
aria-pressed={cut ? selectedPiece === piece.id : undefined} aria-pressed={cut ? selectedPiece === pieceKey('skirting', piece.id) : undefined}
onClick={cut && interactive ? () => select?.(piece.id) : undefined} onClick={cut && interactive ? () => select?.('skirting', piece.id) : undefined}
onKeyDown={ onKeyDown={
cut cut
? (e) => { ? (e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
select?.(piece.id); select?.('skirting', piece.id);
} }
} }
: undefined : undefined
@ -386,7 +410,7 @@ export function FloorPlan({
done done
? '#27655d' ? '#27655d'
: cut : cut
? selectedPiece === piece.id ? selectedPiece === pieceKey('skirting', piece.id)
? '#17263f' ? '#17263f'
: '#b74900' : '#b74900'
: '#d8d5cd' : '#d8d5cd'
@ -442,14 +466,14 @@ export function FloorPlan({
const label = skirtLabels.get(piece.id); const label = skirtLabels.get(piece.id);
if (!label) return null; if (!label) return null;
const done = completedBoards.includes(`skirting:${piece.stockId}`); const done = completedBoards.includes(`skirting:${piece.stockId}`);
const highlighted = highlightedPieces.includes(piece.id); const highlighted = highlightedPieces.has(pieceKey('skirting', piece.id));
return ( return (
<g <g
key={piece.id} key={piece.id}
className={`skirting-code ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`} className={`skirting-code ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
data-piece-id={piece.id} data-piece-id={piece.id}
transform={`translate(${label.x} ${label.y})`} transform={`translate(${label.x} ${label.y})`}
onClick={interactive ? () => select?.(piece.id) : undefined} onClick={interactive ? () => select?.('skirting', piece.id) : undefined}
> >
<rect <rect
x={-label.width / 2} x={-label.width / 2}
@ -477,13 +501,13 @@ export function FloorPlan({
{floorLabels.map((label) => { {floorLabels.map((label) => {
const piece = floorPieces.find((piece) => piece.id === label.id)!; const piece = floorPieces.find((piece) => piece.id === label.id)!;
const done = completedBoards.includes(`floor:${piece.stockId}`); const done = completedBoards.includes(`floor:${piece.stockId}`);
const highlighted = highlightedPieces.includes(label.id); const highlighted = highlightedPieces.has(pieceKey('flooring', label.id));
return ( return (
<g <g
key={label.id} key={label.id}
className={`floor-code ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`} className={`floor-code ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
data-piece-id={label.id} data-piece-id={label.id}
onClick={interactive ? () => select?.(label.id) : undefined} onClick={interactive ? () => select?.('flooring', label.id) : undefined}
> >
{label.leader && ( {label.leader && (
<> <>

View file

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

View file

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

View file

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

View file

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

View file

@ -15,20 +15,7 @@ const sameLength = (a: number, b: number) => Math.abs(a - b) < PRECISION.millime
export const flooringCuts = (plan: FlooringPlan) => export const flooringCuts = (plan: FlooringPlan) =>
plan.pieces.filter((piece) => piece.cut || piece.reused); plan.pieces.filter((piece) => piece.cut || piece.reused);
export function isSkirtingCut(piece: SkirtingPiece, plan: SkirtingPlan): boolean { export const skirtingCuts = (plan: SkirtingPlan) => plan.pieces.filter((piece) => piece.cut);
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 function uncutFlooring(plan: FlooringPlan, settings: FlooringSettings) { export function uncutFlooring(plan: FlooringPlan, settings: FlooringSettings) {
const uncut = plan.pieces.filter((piece) => !piece.cut); 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 }; return { wholeBoards, readyOffcuts: uncut.length - wholeBoards };
} }
export function uncutSkirting(plan: SkirtingPlan): { length: number; count: number }[] { export const uncutSkirting = (plan: SkirtingPlan) =>
const counts = new Map<number, number>(); plan.pieces.filter((piece) => !piece.cut).length;
for (const piece of plan.pieces) {
if (!isSkirtingCut(piece, plan)) counts.set(piece.length, (counts.get(piece.length) ?? 0) + 1);
}
return [...counts].sort(([a], [b]) => a - b).map(([length, count]) => ({ length, count }));
}
/** Ready remnants need placement instructions, never another trimming instruction. */ /** Ready remnants need placement instructions, never another trimming instruction. */
export function readyOffcutInstructions(piece: FlooringPiece): string { 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.`); messages.push(`Exposed front length: ${cutMm(piece.visibleLength)} mm.`);
return messages.join(' '); 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 { cutMm, degree } from './format';
import { PRECISION } from './limits'; import { PRECISION } from './limits';
export interface PreviewBox { export type { Rect as PreviewBox } from './drawing-geometry';
x: number; import type { Rect as PreviewBox } from './drawing-geometry';
y: number; import { overlaps, rectBounds, bounds } from './drawing-geometry';
width: number; import { signedArea } from './geometry';
height: number;
}
export const viewBox = (box: PreviewBox) => `${box.x} ${box.y} ${box.width} ${box.height}`; export const viewBox = (box: PreviewBox) => `${box.x} ${box.y} ${box.width} ${box.height}`;
export interface EdgeDimension { export interface EdgeDimension {
@ -29,12 +27,6 @@ const moved = (point: Point, direction: Point, distance: number): Point => ({
x: point.x + direction.x * distance, x: point.x + direction.x * distance,
y: point.y + direction.y * 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. */ /** Dimensions stay outside the outline. Short adjacent edges share a lane with staggered text. */
export function dimensionedOutline( export function dimensionedOutline(
points: Point[], points: Point[],
@ -43,19 +35,10 @@ export function dimensionedOutline(
) { ) {
if (points.length < 3) if (points.length < 3)
return { dimensions: [] as EdgeDimension[], font: 1, box: fullPreviewBox(1, 1) }; return { dimensions: [] as EdgeDimension[], font: 1, box: fullPreviewBox(1, 1) };
const span = Math.max( const box = bounds(points);
1, const span = Math.max(1, box.maxX - box.minX, box.maxY - box.minY);
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 font = span * 0.065; const font = span * 0.065;
const orientation = const orientation = Math.sign(signedArea(points)) || 1;
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 dimensions: EdgeDimension[] = []; const dimensions: EdgeDimension[] = [];
const selected = (edges ?? points.map((_, i) => i)) const selected = (edges ?? points.map((_, i) => i))
.map((edge) => ({ .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; const shift = step === 0 ? 0 : Math.ceil(step / 2) * (step % 2 ? -1 : 1) * font * 1.55;
label = moved(base, direction, shift); label = moved(base, direction, shift);
labelBox = { x: label.x - width / 2, y: label.y - height / 2, width, height }; 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; placed = true;
break; break;
} }

View file

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

View file

@ -1,11 +1,8 @@
import type { Point, WallEdge } from './types'; import type { Point, WallEdge } from './types';
import { mm } from './format'; import { mm } from './format';
export interface LabelBox { export type { Bounds as LabelBox } from './drawing-geometry';
minX: number; import type { Bounds as LabelBox } from './drawing-geometry';
minY: number; import { overlaps } from './drawing-geometry';
maxX: number;
maxY: number;
}
export interface DimensionLabel { export interface DimensionLabel {
x: number; x: number;
y: number; y: number;
@ -17,9 +14,6 @@ export interface DimensionLabel {
normal: Point; normal: Point;
box: LabelBox; 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. */ /** Move dimension lines outward until their labels clear earlier labels and the skirting codes. */
export function dimensionLabels( export function dimensionLabels(
edges: WallEdge[], edges: WallEdge[],

View file

@ -0,0 +1,34 @@
import type { Point } from './types';
export interface Bounds {
minX: number;
maxX: number;
minY: number;
maxY: number;
}
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
export const bounds = (points: Point[]): Bounds => ({
minX: Math.min(...points.map((point) => point.x)),
maxX: Math.max(...points.map((point) => point.x)),
minY: Math.min(...points.map((point) => point.y)),
maxY: Math.max(...points.map((point) => point.y)),
});
export const rectBounds = (box: Rect): Bounds => ({
minX: box.x,
minY: box.y,
maxX: box.x + box.width,
maxY: box.y + box.height,
});
export const overlaps = (a: Bounds, b: Bounds, gap = 0) =>
a.minX < b.maxX + gap && a.maxX > b.minX - gap && a.minY < b.maxY + gap && a.maxY > b.minY - gap;
export const corners = (box: Bounds): Point[] => [
{ x: box.minX, y: box.minY },
{ x: box.maxX, y: box.minY },
{ x: box.maxX, y: box.maxY },
{ x: box.minX, y: box.maxY },
];

View file

@ -1,8 +1,11 @@
import { bounds } from './drawing-geometry';
import type { Bounds } from './drawing-geometry';
import ClipperLib from 'clipper-lib'; import ClipperLib from 'clipper-lib';
import { buildGeometry, insetPolygon } from './geometry'; import { buildGeometry, insetPolygon, signedArea } from './geometry';
import { cutLength } from './format'; import { cutLength } from './format';
import { PLANNER_LIMITS, PRECISION, PROJECT_LIMITS } from './limits'; import { PLANNER_LIMITS, PRECISION, PROJECT_LIMITS } from './limits';
import type { import type {
Geometry,
FlooringPiece, FlooringPiece,
FlooringPlan, FlooringPlan,
FlooringRow, FlooringRow,
@ -24,14 +27,15 @@ export function planFlooringOptions(
rooms: Room[], rooms: Room[],
s: FlooringSettings, s: FlooringSettings,
tolerance: number, tolerance: number,
geometries = rooms.map((room) => buildGeometry(room, tolerance)),
): FlooringOptions { ): FlooringOptions {
const shapes: ShapeCache = new Map(); const shapes: ShapeCache = new Map();
const cutsSettings = { ...s, optimisation: 'cuts' as const }; const cutsSettings = { ...s, optimisation: 'cuts' as const };
const randomSettings = { ...s, optimisation: 'random' as const }; const randomSettings = { ...s, optimisation: 'random' as const };
const cuts = solveFlooring(rooms, cutsSettings, tolerance, shapes); const cuts = solveFlooring(rooms, cutsSettings, geometries, shapes);
const variablePattern = s.pattern === 'random' && s.minOffset < s.length / 2 - EPSILON; const variablePattern = s.pattern === 'random' && s.minOffset < s.length / 2 - EPSILON;
const candidates = [cuts]; const candidates = [cuts];
if (variablePattern) candidates.push(solveFlooring(rooms, randomSettings, tolerance, shapes)); if (variablePattern) candidates.push(solveFlooring(rooms, randomSettings, geometries, shapes));
const errors = (solution: Solution) => const errors = (solution: Solution) =>
solution.issues.filter((issue) => issue.severity === 'error').length; solution.issues.filter((issue) => issue.severity === 'error').length;
const better = (settings: FlooringSettings) => const better = (settings: FlooringSettings) =>
@ -46,7 +50,7 @@ export function planFlooringOptions(
variablePattern && variablePattern &&
(better(randomSettings).layout.patternPenalty > 0 || errors(better(randomSettings))) (better(randomSettings).layout.patternPenalty > 0 || errors(better(randomSettings)))
) { ) {
candidates.push(solveFlooring(rooms, randomSettings, tolerance, shapes, false)); candidates.push(solveFlooring(rooms, randomSettings, geometries, shapes, false));
} }
const cutWinner = better(cutsSettings); const cutWinner = better(cutsSettings);
const randomWinner = better(randomSettings); const randomWinner = better(randomSettings);
@ -61,12 +65,6 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
return planFlooringOptions(rooms, s, tolerance)[s.optimisation]; return planFlooringOptions(rooms, s, tolerance)[s.optimisation];
} }
interface Bounds {
minX: number;
maxX: number;
minY: number;
maxY: number;
}
interface AvailableStock extends FlooringStock { interface AvailableStock extends FlooringStock {
left: boolean; left: boolean;
right: boolean; right: boolean;
@ -109,25 +107,7 @@ const close = (a: number, b: number) => Math.abs(a - b) <= EPSILON;
const blankLength = (length: number, available: number) => const blankLength = (length: number, available: number) =>
close(length, available) ? available : cutLength(length); close(length, available) ? available : cutLength(length);
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
const area = (points: Point[]) => const area = (points: Point[]) => Math.abs(signedArea(points));
Math.abs(
points.reduce((sum, p, i) => {
const q = points[(i + 1) % points.length];
return sum + p.x * q.y - q.x * p.y;
}, 0) / 2,
);
function bounds(points: Point[]): Bounds {
return points.reduce(
(b, p) => ({
minX: Math.min(b.minX, p.x),
maxX: Math.max(b.maxX, p.x),
minY: Math.min(b.minY, p.y),
maxY: Math.max(b.maxY, p.y),
}),
{ minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity },
);
}
function boardPoint(p: Point, direction: 0 | 90): Point { function boardPoint(p: Point, direction: 0 | 90): Point {
return direction === 0 ? p : { x: p.y, y: -p.x }; return direction === 0 ? p : { x: p.y, y: -p.x };
@ -434,7 +414,7 @@ function appendRow(
), ),
}; };
const pieceIds: string[] = []; const pieceIds: string[] = [];
cuts.forEach((piece, column) => { cuts.forEach((piece) => {
const id = `F${layout.pieces.length + 1}`; const id = `F${layout.pieces.length + 1}`;
const allocation = allocate(layout, piece, id, s, preserveEnds); const allocation = allocate(layout, piece, id, s, preserveEnds);
const length = round(piece.bounds.maxX - piece.bounds.minX); const length = round(piece.bounds.maxX - piece.bounds.minX);
@ -464,7 +444,6 @@ function appendRow(
id, id,
roomId: room.id, roomId: room.id,
row: rowIndex + 1, row: rowIndex + 1,
column: column + 1,
polygon: piece.polygon.map((p) => worldPoint(p, room.direction)), polygon: piece.polygon.map((p) => worldPoint(p, room.direction)),
length, length,
width, width,
@ -541,104 +520,6 @@ function randomPatternPenalty(
return penalty; return penalty;
} }
/** Reorder interchangeable full-width rectangular rows without adding any cutting operations. */
function improveRowOrder(layout: Layout, rooms: Room[], s: FlooringSettings): Layout {
if (s.pattern !== 'random' || layout.patternPenalty === 0) return layout;
const rows = [...layout.rows];
const byId = new Map(layout.pieces.map((piece) => [piece.id, piece]));
const moves = new Map<string, { row: number; shift: Point }>();
let improvement = 0;
for (const room of rooms) {
const positions = rows.flatMap((row, index) => (row.roomId === room.id ? [index] : []));
const original = positions.map((index) => rows[index]);
const order = [...original];
const shapes = new Map(
original.map((row) => {
const pieces = row.pieceIds.map((id) => byId.get(id)!);
const b = bounds(
pieces.flatMap((p) => p.polygon.map((point) => boardPoint(point, room.direction))),
);
const rectangular =
pieces.every((p) => !p.shaped && !p.ripEdge && close(p.width, s.width)) &&
close(
pieces.reduce((sum, p) => sum + p.length, 0),
b.maxX - b.minX,
);
return [row, { y: b.minY, key: rectangular ? `${round(b.minX)}:${round(b.maxX)}` : '' }];
}),
);
const scoreAt = (index: number) =>
randomPatternPenalty(
order.slice(Math.max(0, index - 4), index),
room.id,
order[index].offset,
s,
);
let attempts = 0;
for (let pass = 0; pass < 4; pass++) {
let best: { i: number; j: number; delta: number } | undefined;
for (let i = 0; i < order.length; i++) {
const key = shapes.get(order[i])!.key;
if (!key) continue;
for (let j = i + 1; j < order.length; j++) {
if (++attempts > 4096) break;
if (shapes.get(order[j])!.key !== key || close(order[i].offset, order[j].offset))
continue;
const affected = [
...new Set(
[i, j].flatMap((index) =>
Array.from({ length: Math.min(5, order.length - index) }, (_, n) => index + n),
),
),
];
const before = affected.reduce((sum, index) => sum + scoreAt(index), 0);
[order[i], order[j]] = [order[j], order[i]];
const valid = [i, i + 1, j, j + 1].every(
(index) =>
index === 0 ||
index >= order.length ||
staggerAllowed(order[index - 1].offset, order[index].offset, s),
);
const delta = valid
? affected.reduce((sum, index) => sum + scoreAt(index), 0) - before
: 0;
[order[i], order[j]] = [order[j], order[i]];
if (delta < (best?.delta ?? 0)) best = { i, j, delta };
}
}
if (!best) break;
[order[best.i], order[best.j]] = [order[best.j], order[best.i]];
improvement -= best.delta;
}
order.forEach((source, index) => {
const target = original[index];
if (source === target) return;
rows[positions[index]] = { ...source, index: target.index };
const shift = worldPoint(
{ x: 0, y: shapes.get(target)!.y - shapes.get(source)!.y },
room.direction,
);
source.pieceIds.forEach((id) => moves.set(id, { row: target.index, shift }));
});
}
if (!improvement) return layout;
return {
...layout,
rows,
patternPenalty: layout.patternPenalty - improvement,
pieces: layout.pieces.map((piece) => {
const move = moves.get(piece.id);
return move
? {
...piece,
row: move.row,
polygon: piece.polygon.map((p) => ({ x: p.x + move.shift.x, y: p.y + move.shift.y })),
}
: piece;
}),
};
}
/** Small fixed hash, never time or Math.random: edits and shared URLs reproduce the same result. */ /** Small fixed hash, never time or Math.random: edits and shared URLs reproduce the same result. */
function fraction(seed: string): number { function fraction(seed: string): number {
let hash = 2166136261; let hash = 2166136261;
@ -764,7 +645,7 @@ function validateSettings(s: FlooringSettings): string | null {
function solveFlooring( function solveFlooring(
rooms: Room[], rooms: Room[],
s: FlooringSettings, s: FlooringSettings,
tolerance: number, geometries: Geometry[],
shapes: ShapeCache, shapes: ShapeCache,
preserveEnds = true, preserveEnds = true,
): Solution { ): Solution {
@ -782,13 +663,13 @@ function solveFlooring(
const issues: Issue[] = []; const issues: Issue[] = [];
const invalid = validateSettings(s); const invalid = validateSettings(s);
if (invalid) issues.push({ severity: 'error', message: invalid }); if (invalid) issues.push({ severity: 'error', message: invalid });
for (const room of invalid ? [] : rooms) { for (const [roomIndex, room] of (invalid ? [] : rooms).entries()) {
const geometry = buildGeometry(room, tolerance); const geometry = geometries[roomIndex];
if (!geometry.valid) { if (!geometry.valid) {
issues.push({ issues.push({
severity: 'error', severity: 'error',
roomId: room.id, roomId: room.id,
message: `${room.name}: finish a valid, closed outline before planning flooring.`, message: `Finish a valid, closed outline before planning flooring.`,
}); });
continue; continue;
} }
@ -799,7 +680,7 @@ function solveFlooring(
issues.push({ issues.push({
severity: 'error', severity: 'error',
roomId: room.id, roomId: room.id,
message: `${room.name}: the expansion gap leaves no usable floor area.`, message: `The expansion gap leaves no usable floor area.`,
}); });
continue; continue;
} }
@ -816,8 +697,8 @@ function solveFlooring(
roomId: room.id, roomId: room.id,
message: message:
estimatedBoards > MAX_BOARDS estimatedBoards > MAX_BOARDS
? `${room.name}: this plan exceeds the ${MAX_BOARDS}-board calculation limit. Check your millimetre measurements.` ? `This plan exceeds the ${MAX_BOARDS}-board calculation limit. Check your millimetre measurements.`
: `${room.name}: the room width cannot meet the minimum row width using full-width interior rows. Reduce the minimum row width or change board direction/size.`, : `The room width cannot meet the minimum row width using full-width interior rows. Reduce the minimum row width or change board direction/size.`,
}); });
continue; continue;
} }
@ -900,7 +781,7 @@ function solveFlooring(
issues.push({ issues.push({
severity: 'error', severity: 'error',
roomId: room.id, roomId: room.id,
message: `${room.name}: no valid layout found within the piece-size and joint-offset limits. Try reducing minimum piece dimensions, widening the offset range, or changing board direction. No partial floor is counted.`, message: `No valid layout found within the piece-size and joint-offset limits. Try reducing minimum piece dimensions, widening the offset range, or changing board direction. No partial floor is counted.`,
}); });
continue; continue;
} }
@ -908,7 +789,7 @@ function solveFlooring(
finalists = complete; finalists = complete;
} }
for (const candidate of finalists) { for (const candidate of finalists) {
const improved = improveRowOrder(repack(candidate, s), rooms, s); const improved = repack(candidate, s);
if (compareLayouts(improved, layout, s) < 0) layout = improved; if (compareLayouts(improved, layout, s) < 0) layout = improved;
} }
for (const room of rooms) { for (const room of rooms) {
@ -916,7 +797,7 @@ function solveFlooring(
issues.push({ issues.push({
severity: 'warning', severity: 'warning',
roomId: room.id, roomId: room.id,
message: `${room.name}: angled/notched cuts use the supplied shape templates. Minimum sizes check each piece's overall length and width; inspect narrow tips and joints against your flooring instructions.`, message: `Angled/notched cuts use the supplied shape templates. Minimum sizes check each piece's overall length and width; inspect narrow tips and joints against your flooring instructions.`,
}); });
} }
return { layout, issues }; return { layout, issues };

109
src/lib/legacy-query.ts Normal file
View file

@ -0,0 +1,109 @@
import { defaultState } from './defaults';
import { PROJECT_LIMITS } from './limits';
/** Import version 2 links. This format is never written by the application. */
export function readLegacyQuery(query: string): unknown {
const params = new URLSearchParams(query);
const defaults = defaultState();
const roomFields = [...params.keys()].filter((key) =>
/^room\.[^.]+\.(?:id|name|direction|wall\.[^.]+\.(?:id|length|turn|door\.[^.]+\.(?:id|offset|width)))$/.test(
key,
),
);
const text = (key: string, fallback?: string): string => {
const values = params.getAll(key);
if (values.length > 1) throw new Error(`${key} must appear only once.`);
if (!values.length) {
if (fallback !== undefined) return fallback;
throw new Error(`The link is missing ${key}.`);
}
return values[0];
};
const measurement = (value: string, key: string) => {
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value.trim()))
throw new Error(`${key} must be a finite number.`);
const result = Number(value);
if (!Number.isFinite(result)) throw new Error(`${key} must be a finite number.`);
return result;
};
const numeric = (key: string, fallback?: number) =>
measurement(text(key, fallback === undefined ? undefined : String(fallback)), key);
const indices = (prefix: string, limit: number) => {
const found = new Set<number>();
for (const key of roomFields) {
if (!key.startsWith(`${prefix}.`)) continue;
const index = key.slice(prefix.length + 1).split('.')[0];
if (!/^[1-9]\d*$/.test(index))
throw new Error(`${prefix} indices must be positive whole numbers, starting at 1.`);
const value = Number(index);
if (value > limit) throw new Error(`${prefix} supports at most ${limit} entries.`);
found.add(value);
}
const ordered = [...found].sort((a, b) => a - b);
if (ordered.some((value, index) => value !== index + 1))
throw new Error(`${prefix} indices must be consecutive, starting at 1.`);
return ordered;
};
const roomIndices = indices('room', PROJECT_LIMITS.rooms);
const rooms = roomIndices.length
? roomIndices.map((roomIndex) => {
const prefix = `room.${roomIndex}`;
const id = text(`${prefix}.id`, `room-${roomIndex}`);
return {
id,
name: text(`${prefix}.name`, `Room ${roomIndex}`),
direction: numeric(`${prefix}.direction`, 0),
walls: indices(`${prefix}.wall`, PROJECT_LIMITS.wallsPerRoom).map((wallIndex) => {
const wallPrefix = `${prefix}.wall.${wallIndex}`;
const wallId = text(`${wallPrefix}.id`, `${id}-w${wallIndex}`);
return {
id: wallId,
length: numeric(`${wallPrefix}.length`),
turn: numeric(`${wallPrefix}.turn`, 90),
doors: indices(`${wallPrefix}.door`, PROJECT_LIMITS.doorsPerWall).map((doorIndex) => {
const doorPrefix = `${wallPrefix}.door.${doorIndex}`;
return {
id: text(`${doorPrefix}.id`, `${wallId}-door-${doorIndex}`),
offset: numeric(`${doorPrefix}.offset`),
width: numeric(`${doorPrefix}.width`),
};
}),
};
}),
};
})
: defaults.project.rooms;
if (params.has('skirting.stock'))
throw new Error('This link uses the retired skirting stock-list format.');
const floor = defaults.project.flooring;
return {
project: {
version: numeric('version', 2),
name: text('name', ''),
tolerance: numeric('tolerance', defaults.project.tolerance),
completedBoards: params.getAll('done'),
rooms,
flooring: Object.fromEntries(
Object.entries(floor).map(([key, value]) => [
key,
typeof value === 'number'
? numeric(`flooring.${key}`, value)
: text(`flooring.${key}`, value),
]),
),
skirting: {
stockLength: numeric('skirting.length', defaults.project.skirting.stockLength),
thickness: numeric('skirting.thickness', defaults.project.skirting.thickness),
minLength: numeric('skirting.minLength', defaults.project.skirting.minLength),
kerf: numeric('skirting.kerf', defaults.project.skirting.kerf),
},
},
view: {
roomId: text('selectedRoom', rooms[0].id),
selectedPiece: params.has('selectedCut') ? text('selectedCut') : null,
search: text('search', ''),
collapsedSections: params.getAll('collapsed'),
material: text('view', 'flooring'),
},
};
}

View file

@ -1,3 +1,4 @@
import { bounds, overlaps, corners } from './drawing-geometry';
import type { FlooringPiece, Point } from './types'; import type { FlooringPiece, Point } from './types';
import type { LabelBox } from './dimension-labels'; import type { LabelBox } from './dimension-labels';
@ -15,20 +16,6 @@ export interface PieceLabel {
leader: boolean; leader: boolean;
} }
const bounds = (points: Point[]): LabelBox => ({
minX: Math.min(...points.map((point) => point.x)),
maxX: Math.max(...points.map((point) => point.x)),
minY: Math.min(...points.map((point) => point.y)),
maxY: Math.max(...points.map((point) => point.y)),
});
const overlaps = (a: LabelBox, b: LabelBox, gap: number) =>
a.minX < b.maxX + gap && a.maxX > b.minX - gap && a.minY < b.maxY + gap && a.maxY > b.minY - gap;
const corners = (box: LabelBox) => [
{ x: box.minX, y: box.minY },
{ x: box.maxX, y: box.minY },
{ x: box.maxX, y: box.maxY },
{ x: box.minX, y: box.maxY },
];
const inside = (point: Point, polygon: Point[]) => { const inside = (point: Point, polygon: Point[]) => {
let result = false; let result = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {

49
src/lib/planning.ts Normal file
View file

@ -0,0 +1,49 @@
import { buildGeometry } from './geometry';
import { planFlooringOptions } from './flooring';
import { planSkirting } from './skirting';
import type { FlooringOptions } from './flooring';
import type { Geometry, Project, SkirtingPlan } from './types';
/** One calculator per editor. Compare calculation inputs, excluding names and progress. */
export function createPlanCalculator() {
let previous: Project | undefined;
let geometries: Geometry[] = [];
let flooringOptions: FlooringOptions;
let skirting: SkirtingPlan;
return (project: Project) => {
const sameOutlines =
previous &&
previous.tolerance === project.tolerance &&
previous.rooms.length === project.rooms.length &&
project.rooms.every(
(room, i) => room.id === previous!.rooms[i].id && room.walls === previous!.rooms[i].walls,
);
const sameDirections =
sameOutlines &&
project.rooms.every((room, i) => room.direction === previous!.rooms[i].direction);
const sameFlooring =
previous &&
Object.entries(project.flooring).every(
([key, value]) => key === 'optimisation' || Reflect.get(previous!.flooring, key) === value,
);
if (!sameOutlines)
geometries = project.rooms.map((room, i) =>
previous?.tolerance === project.tolerance &&
previous.rooms[i]?.id === room.id &&
previous.rooms[i]?.walls === room.walls
? geometries[i]
: buildGeometry(room, project.tolerance),
);
if (!sameDirections || !sameFlooring)
flooringOptions = planFlooringOptions(
project.rooms,
project.flooring,
project.tolerance,
geometries,
);
if (!sameOutlines || previous?.skirting !== project.skirting)
skirting = planSkirting(project.rooms, project.skirting, project.tolerance, geometries);
previous = project;
return { geometries, flooringOptions: flooringOptions!, skirting: skirting! };
};
}

162
src/lib/project-state.ts Normal file
View file

@ -0,0 +1,162 @@
import { nextId, rectangle } from './defaults';
import { PROJECT_LIMITS } from './limits';
import type {
FlooringSettings,
PlanFocus,
PlanMaterial,
PlannerSection,
Project,
Room,
SkirtingSettings,
UrlState,
} from './types';
export type ProjectAction =
| { type: 'room'; id: string; change: (room: Room) => Room }
| { type: 'flooring'; patch: Partial<FlooringSettings> }
| { type: 'skirting'; patch: Partial<SkirtingSettings> }
| { type: 'tolerance'; value: number }
| { type: 'addRoom' }
| { type: 'removeRoom'; id: string }
| { type: 'visitRoom'; id: string }
| { type: 'material'; material: PlanMaterial }
| { type: 'select'; material: PlanMaterial; id: string; roomId?: string }
| { type: 'closePreview' }
| { type: 'search'; text: string; roomId: string; material: PlanMaterial }
| { type: 'focus'; focus: PlanFocus; roomId: string }
| { type: 'toggleBoard'; key: string }
| { type: 'toggleSection'; section: PlannerSection };
const toggle = <T>(items: T[], value: T) =>
items.includes(value) ? items.filter((item) => item !== value) : [...items, value];
/** Allocation changes invalidate progress and locations together. Renames leave them intact. */
function changedPlan(state: UrlState, project: Project): UrlState {
return {
project: { ...project, completedBoards: [] },
view: { ...state.view, selectedPiece: null, search: '', focus: null },
};
}
export function reduceProject(state: UrlState, action: ProjectAction): UrlState {
const { project, view } = state;
switch (action.type) {
case 'room': {
const room = project.rooms.find((room) => room.id === action.id);
if (!room) return state;
const next = action.change(room);
if (next === room) return state;
const updated = {
...project,
rooms: project.rooms.map((item) => (item === room ? next : item)),
};
return next.walls === room.walls && next.direction === room.direction
? { ...state, project: updated }
: changedPlan(state, updated);
}
case 'flooring':
case 'skirting': {
if (
Object.entries(action.patch).every(
([key, value]) => Reflect.get(project[action.type], key) === value,
)
)
return state;
return changedPlan(state, {
...project,
[action.type]: { ...project[action.type], ...action.patch },
});
}
case 'tolerance':
return action.value === project.tolerance
? state
: changedPlan(state, { ...project, tolerance: action.value });
case 'addRoom': {
if (project.rooms.length >= PROJECT_LIMITS.rooms) return state;
const id = nextId(
'room',
project.rooms.map((room) => room.id),
);
const next = changedPlan(state, {
...project,
rooms: [...project.rooms, rectangle(id, `Room ${project.rooms.length + 1}`)],
});
return {
...next,
view: {
...next.view,
roomId: id,
collapsedSections: view.collapsedSections.filter((section) => section !== 'measurements'),
},
};
}
case 'removeRoom': {
if (project.rooms.length <= 1) return state;
const rooms = project.rooms.filter((room) => room.id !== action.id);
if (rooms.length === project.rooms.length) return state;
const next = changedPlan(state, { ...project, rooms });
return {
...next,
view: {
...next.view,
roomId: rooms.some((room) => room.id === view.roomId) ? view.roomId : rooms[0].id,
},
};
}
case 'visitRoom':
return project.rooms.some((room) => room.id === action.id)
? { ...state, view: { ...view, roomId: action.id, selectedPiece: null } }
: state;
case 'material':
return {
...state,
view: { ...view, material: action.material, selectedPiece: null, search: '', focus: null },
};
case 'select':
return {
...state,
view: {
...view,
selectedPiece: `${action.material}:${action.id}`,
material: action.material,
roomId: action.roomId ?? view.roomId,
},
};
case 'closePreview':
return { ...state, view: { ...view, selectedPiece: null } };
case 'search':
return {
...state,
view: {
...view,
search: action.text,
roomId: action.roomId,
material: action.material,
focus: null,
selectedPiece: null,
},
};
case 'focus':
return {
...state,
view: {
...view,
focus: action.focus,
material: action.focus.material,
roomId: action.roomId,
search: '',
selectedPiece: null,
},
};
case 'toggleBoard':
return {
...state,
project: { ...project, completedBoards: toggle(project.completedBoards, action.key) },
};
case 'toggleSection':
return {
...state,
view: { ...view, collapsedSections: toggle(view.collapsedSections, action.section) },
};
}
}

View file

@ -1,5 +1,4 @@
import { buildGeometry, normalizeAngle, signedArea } from './geometry'; import { buildGeometry, normalizeAngle, signedArea } from './geometry';
import { isSkirtingCut } from './cut-list';
import { cutLength } from './format'; import { cutLength } from './format';
import { PLANNER_LIMITS, PRECISION, PROJECT_LIMITS } from './limits'; import { PLANNER_LIMITS, PRECISION, PROJECT_LIMITS } from './limits';
import type { import type {
@ -14,12 +13,11 @@ import type {
} from './types'; } from './types';
const EPS = PRECISION.geometry; const EPS = PRECISION.geometry;
type UnpackedPiece = Omit<SkirtingPiece, 'blankLength'>; type UnpackedPiece = Omit<SkirtingPiece, 'blankLength' | 'cut'>;
const rad = (degrees: number) => (degrees * Math.PI) / 180; const rad = (degrees: number) => (degrees * Math.PI) / 180;
type End = { type End = {
type: SkirtingPiece['leftType']; type: SkirtingPiece['leftType'];
angle: number; angle: number;
bevel: number;
back: number; back: number;
front: number; front: number;
installedBack: number; installedBack: number;
@ -39,7 +37,6 @@ type Run = {
const square = (): End => ({ const square = (): End => ({
type: 'square', type: 'square',
angle: 0, angle: 0,
bevel: 0,
back: 0, back: 0,
front: 0, front: 0,
installedBack: 0, installedBack: 0,
@ -83,7 +80,6 @@ function corner(turn: number, thickness: number, side: 'left' | 'right', cope: b
return { return {
type: 'external', type: 'external',
angle: -turn / 2, angle: -turn / 2,
bevel: (sign * turn) / 2,
back: 0, back: 0,
front, front,
installedBack: 0, installedBack: 0,
@ -96,7 +92,6 @@ function corner(turn: number, thickness: number, side: 'left' | 'right', cope: b
return { return {
type: 'cope', type: 'cope',
angle: turn / 2, angle: turn / 2,
bevel: (sign * turn) / 2,
back: 0, back: 0,
front: faceJoint, front: faceJoint,
installedBack: (sign * thickness) / Math.sin(rad(turn)), installedBack: (sign * thickness) / Math.sin(rad(turn)),
@ -110,7 +105,6 @@ function corner(turn: number, thickness: number, side: 'left' | 'right', cope: b
return { return {
type: 'internal', type: 'internal',
angle: Math.abs(90 - turn), angle: Math.abs(90 - turn),
bevel: sign * (turn - 90),
back: 0, back: 0,
front, front,
installedBack: 0, installedBack: 0,
@ -172,16 +166,12 @@ function makePiece(
rightType: right.type, rightType: right.type,
stockId: '', stockId: '',
visibleLength: to + right.visible - from - left.visible, visibleLength: to + right.visible - from - left.visible,
leftOverlap: Math.abs(left.visible - (left.type === 'cope' ? left.back : left.front)),
rightOverlap: Math.abs(right.visible - (right.type === 'cope' ? right.back : right.front)),
installedTemplate: [ installedTemplate: [
{ x: from + left.installedBack - origin, y: 0 }, { x: from + left.installedBack - origin, y: 0 },
{ x: to + right.installedBack - origin, y: 0 }, { x: to + right.installedBack - origin, y: 0 },
{ x: to + right.installedFront - origin, y: run.thickness }, { x: to + right.installedFront - origin, y: run.thickness },
{ x: from + left.installedFront - origin, y: run.thickness }, { x: from + left.installedFront - origin, y: run.thickness },
], ],
leftBevel: left.bevel,
rightBevel: right.bevel,
template: [ template: [
{ x: backFrom - origin, y: 0 }, { x: backFrom - origin, y: 0 },
{ x: backTo - origin, y: 0 }, { x: backTo - origin, y: 0 },
@ -351,14 +341,9 @@ interface Packed {
waste: number; waste: number;
} }
/** Greedy decreasing packing, optionally reserving exact three-piece combinations. */ /** Greedy decreasing packing into shared stock. */
function pack( function pack(source: UnpackedPiece[], stockLength: number, kerf: number): Packed {
source: UnpackedPiece[], const pieces: SkirtingPiece[] = source.map((piece) => ({ ...piece, blankLength: 0, cut: false }));
stockLength: number,
kerf: number,
exactTails = false,
): Packed {
const pieces: SkirtingPiece[] = source.map((piece) => ({ ...piece, blankLength: 0 }));
let pending = [...pieces].sort( let pending = [...pieces].sort(
(a, b) => b.length - a.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), (a, b) => b.length - a.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
); );
@ -367,29 +352,12 @@ function pack(
while (pending.length) { while (pending.length) {
let available = stockLength; let available = stockLength;
const assigned: SkirtingPiece[] = []; const assigned: SkirtingPiece[] = [];
if (exactTails && pending.length > 2) {
const head = pending[0];
const tail = stockLength - consumedLength(head, stockLength, kerf);
const options = pending.slice(1, 129).filter((piece) => fits(piece, tail));
for (const second of options) {
const remaining = tail - consumedLength(second, tail, kerf);
const third = options.find(
(piece) => piece !== second && Math.abs(piece.length - remaining) <= EPS,
);
if (!third) continue;
pending = [
head,
second,
third,
...pending.filter((piece) => piece !== head && piece !== second && piece !== third),
];
break;
}
}
for (const piece of pending) { for (const piece of pending) {
if (!fits(piece, available)) continue; if (!fits(piece, available)) continue;
piece.blankLength = blankLength(piece, available); piece.blankLength = blankLength(piece, available);
cuts += requiredCuts(piece, available); const passes = requiredCuts(piece, available);
piece.cut = passes > 0;
cuts += passes;
available -= consumedLength(piece, available, kerf); available -= consumedLength(piece, available, kerf);
assigned.push(piece); assigned.push(piece);
} }
@ -414,6 +382,7 @@ export function planSkirting(
rooms: Room[], rooms: Room[],
settings: SkirtingSettings, settings: SkirtingSettings,
tolerance: number, tolerance: number,
geometries = rooms.map((room) => buildGeometry(room, tolerance)),
): SkirtingPlan { ): SkirtingPlan {
const issues: Issue[] = []; const issues: Issue[] = [];
const empty = (): SkirtingPlan => ({ const empty = (): SkirtingPlan => ({
@ -449,12 +418,12 @@ export function planSkirting(
let runs: Run[] = []; let runs: Run[] = [];
const fallbackSplits = new Map<Run, UnpackedPiece[]>(); const fallbackSplits = new Map<Run, UnpackedPiece[]>();
rooms.forEach((room, roomIndex) => { rooms.forEach((room, roomIndex) => {
const geometry = buildGeometry(room, tolerance); const geometry = geometries[roomIndex];
if (!geometry.valid) { if (!geometry.valid) {
issues.push({ issues.push({
severity: 'error', severity: 'error',
roomId: room.id, roomId: room.id,
message: `${room.name}: finish a valid outline and door measurements before planning skirting.`, message: `Finish a valid outline and door measurements before planning skirting.`,
}); });
return; return;
} }
@ -503,7 +472,7 @@ export function planSkirting(
issues.push({ issues.push({
severity: 'error', severity: 'error',
roomId: room.id, roomId: room.id,
message: `${room.name}, wall ${index + 1}: a skirting run after corner allowances is shorter than the ${settings.minLength} mm minimum. Change the minimum or opening measurements.`, message: `Wall ${index + 1}: a skirting run after corner allowances is shorter than the ${settings.minLength} mm minimum. Change the minimum or opening measurements.`,
}); });
} else { } else {
// Establish a feasible rounded split per run before combining rooms. // Establish a feasible rounded split per run before combining rooms.
@ -517,7 +486,7 @@ export function planSkirting(
issues.push({ issues.push({
severity: 'error', severity: 'error',
roomId: room.id, roomId: room.id,
message: `${room.name}, wall ${index + 1}: the stock lengths cannot fit the rounded minimum skirting pieces and corner allowances. Increase stock length or reduce the minimum.`, message: `Wall ${index + 1}: the stock lengths cannot fit the rounded minimum skirting pieces and corner allowances. Increase stock length or reduce the minimum.`,
}); });
} }
runs.push(run); runs.push(run);
@ -551,7 +520,6 @@ export function planSkirting(
piece.id = `${prefixes.get(piece.roomId)}${count}`; piece.id = `${prefixes.get(piece.roomId)}${count}`;
} }
candidates.push(pack(source, stockLength, settings.kerf)); candidates.push(pack(source, stockLength, settings.kerf));
candidates.push(pack(source, stockLength, settings.kerf, true));
}; };
// Compare full-stock, balanced, and offcut-led wall joints. // Compare full-stock, balanced, and offcut-led wall joints.
{ {
@ -596,7 +564,7 @@ export function planSkirting(
let wholeNumber = 0; let wholeNumber = 0;
const codes = new Map( const codes = new Map(
plan.pieces.map((piece) => { plan.pieces.map((piece) => {
if (!isSkirtingCut(piece, plan)) return [piece.id, `SU${++wholeNumber}`]; if (!piece.cut) return [piece.id, `SU${++wholeNumber}`];
const number = (cutNumbers.get(piece.roomId) ?? 0) + 1; const number = (cutNumbers.get(piece.roomId) ?? 0) + 1;
cutNumbers.set(piece.roomId, number); cutNumbers.set(piece.roomId, number);
return [piece.id, `${prefixes.get(piece.roomId)}${number}`]; return [piece.id, `${prefixes.get(piece.roomId)}${number}`];

View file

@ -40,8 +40,6 @@ export interface SkirtingSettings {
kerf: number; kerf: number;
} }
export interface Project { export interface Project {
version: 2;
name: string;
rooms: Room[]; rooms: Room[];
flooring: FlooringSettings; flooring: FlooringSettings;
skirting: SkirtingSettings; skirting: SkirtingSettings;
@ -51,9 +49,16 @@ export interface Project {
} }
export type PlannerSection = 'measurements' | 'materials' | 'cuts'; export type PlannerSection = 'measurements' | 'materials' | 'cuts';
export type PlanMaterial = 'flooring' | 'skirting'; export type PlanMaterial = 'flooring' | 'skirting';
export type PieceKey = `${PlanMaterial}:${string}`;
export interface PlanFocus {
kind: 'board' | 'piece';
material: PlanMaterial;
id: string;
}
export interface ViewState { export interface ViewState {
roomId: string; roomId: string;
selectedPiece: string | null; selectedPiece: PieceKey | null;
focus: PlanFocus | null;
search: string; search: string;
collapsedSections: PlannerSection[]; collapsedSections: PlannerSection[];
material: PlanMaterial; material: PlanMaterial;
@ -96,7 +101,6 @@ export interface FlooringPiece {
id: string; id: string;
roomId: string; roomId: string;
row: number; row: number;
column: number;
polygon: Point[]; polygon: Point[];
length: number; length: number;
width: number; width: number;
@ -148,6 +152,7 @@ export interface SkirtingPiece {
length: number; length: number;
/** Preparation length reserved from stock; a whole fractional length stays exact. */ /** Preparation length reserved from stock; a whole fractional length stays exact. */
blankLength: number; blankLength: number;
cut: boolean;
backLength: number; backLength: number;
leftMitre: number; leftMitre: number;
rightMitre: number; rightMitre: number;
@ -160,12 +165,6 @@ export interface SkirtingPiece {
installedTemplate: [Point, Point, Point, Point]; installedTemplate: [Point, Point, Point, Point];
/** Exposed front run used to enforce the minimum installed piece length. */ /** Exposed front run used to enforce the minimum installed piece length. */
visibleLength: number; visibleLength: number;
/** Extra blank length retained at each end for a coped internal joint. */
leftOverlap: number;
rightOverlap: number;
/** Positive means the front cut point is farther right than the back point. */
leftBevel: number;
rightBevel: number;
} }
export interface SkirtingStock { export interface SkirtingStock {
id: string; id: string;

View file

@ -1,3 +1,4 @@
import { readLegacyQuery } from './legacy-query';
import { defaultState } from './defaults'; import { defaultState } from './defaults';
import { PERSISTENCE_LIMITS, PROJECT_LIMITS } from './limits'; import { PERSISTENCE_LIMITS, PROJECT_LIMITS } from './limits';
import type { import type {
@ -8,6 +9,8 @@ import type {
SkirtingSettings, SkirtingSettings,
UrlState, UrlState,
Wall, Wall,
PieceKey,
PlanFocus,
} from './types'; } from './types';
const SECTIONS = ['measurements', 'materials', 'cuts'] as const; const SECTIONS = ['measurements', 'materials', 'cuts'] as const;
@ -70,7 +73,7 @@ function uniqueIds<T extends { id: string }>(items: T[], field: string): T[] {
function uniqueValues<T extends string>(items: T[], field: string): T[] { function uniqueValues<T extends string>(items: T[], field: string): T[] {
if (new Set(items).size !== items.length) throw new Error(`${field} contains duplicate entries.`); if (new Set(items).size !== items.length) throw new Error(`${field} contains duplicate entries.`);
return items.sort(); return items;
} }
function completedBoards(value: unknown): string[] { function completedBoards(value: unknown): string[] {
@ -159,7 +162,8 @@ function skirting(value: unknown): SkirtingSettings {
function parseState(value: unknown): UrlState { function parseState(value: unknown): UrlState {
const data = object(value, 'Plan'); const data = object(value, 'Plan');
const project = object(data.project, 'Project'); const project = object(data.project, 'Project');
if (project.version !== 2) { const legacy = data.version === undefined && project.version === 2;
if (data.version !== 3 && !legacy) {
throw new Error( throw new Error(
'This project version is unsupported. Open it in the app version that created it.', 'This project version is unsupported. Open it in the app version that created it.',
); );
@ -171,10 +175,39 @@ function parseState(value: unknown): UrlState {
if (rooms.length === 0) throw new Error('A project needs at least one room.'); if (rooms.length === 0) throw new Error('A project needs at least one room.');
const view = object(data.view, 'View settings'); const view = object(data.view, 'View settings');
const roomId = string(view.roomId, 'Selected room'); const roomId = string(view.roomId, 'Selected room');
const material = choice(view.material, ['flooring', 'skirting'] as const, 'Plan view');
let selectedPiece = view.selectedPiece;
let focus = view.focus ?? null;
let search = string(view.search, 'Cut search', PERSISTENCE_LIMITS.searchLength);
if (legacy) {
if (selectedPiece !== null)
selectedPiece = `${material}:${string(selectedPiece, 'Selected piece')}`;
const board = /^(floor|skirting):([a-z]+\d+)$/i.exec(search.trim());
if (board) {
focus = {
kind: 'board',
material: board[1].toLowerCase() === 'floor' ? 'flooring' : 'skirting',
id: board[2].toUpperCase(),
};
search = '';
}
}
if (
selectedPiece !== null &&
!/^(flooring|skirting):.+$/.test(string(selectedPiece, 'Selected piece'))
)
throw new Error('Selected piece needs a material and an identifier.');
let parsedFocus: PlanFocus | null = null;
if (focus !== null) {
const data = object(focus, 'Plan focus');
parsedFocus = {
kind: choice(data.kind, ['board', 'piece'], 'Focus kind'),
material: choice(data.material, ['flooring', 'skirting'], 'Focus material'),
id: identifier(data.id, 'Focus identifier'),
};
}
return { return {
project: { project: {
version: 2,
name: string(project.name, 'Project name', PROJECT_LIMITS.nameLength),
rooms, rooms,
flooring: flooring(project.flooring), flooring: flooring(project.flooring),
skirting: skirting(project.skirting), skirting: skirting(project.skirting),
@ -183,176 +216,44 @@ function parseState(value: unknown): UrlState {
}, },
view: { view: {
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id, roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
selectedPiece: selectedPiece: selectedPiece as PieceKey | null,
view.selectedPiece === null ? null : string(view.selectedPiece, 'Selected piece'), focus: parsedFocus,
search: string(view.search, 'Cut search', PERSISTENCE_LIMITS.searchLength), search,
collapsedSections: collapsedSections(view.collapsedSections), collapsedSections: collapsedSections(view.collapsedSections),
material: choice(view.material, ['flooring', 'skirting'], 'Plan view'), material,
}, },
}; };
} }
/** Explicit keys in a fixed order: no compressed payload or derived results. */ /** Serialize trusted application state; imported data is validated only when read. */
export function encodeState(state: UrlState): string { export function encodeState(state: UrlState): string {
const { project, view } = parseState(state); const query = `?plan=${encodeURIComponent(
const params = new URLSearchParams(); JSON.stringify({ version: 3, ...state }, (_key, value) =>
const put = (key: string, value: string | number) => params.append(key, String(value)); value && typeof value === 'object' && !Array.isArray(value)
put('version', project.version); ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))
put('name', project.name); : value,
put('tolerance', project.tolerance); ),
for (const [key, value] of Object.entries(project.flooring)) put(`flooring.${key}`, value); )}`;
put('skirting.length', project.skirting.stockLength);
put('skirting.thickness', project.skirting.thickness);
put('skirting.minLength', project.skirting.minLength);
put('skirting.kerf', project.skirting.kerf);
project.rooms.forEach((room, roomIndex) => {
const prefix = `room.${roomIndex + 1}`;
put(`${prefix}.id`, room.id);
put(`${prefix}.name`, room.name);
put(`${prefix}.direction`, room.direction);
room.walls.forEach((wall, wallIndex) => {
const wallPrefix = `${prefix}.wall.${wallIndex + 1}`;
put(`${wallPrefix}.id`, wall.id);
put(`${wallPrefix}.length`, wall.length);
put(`${wallPrefix}.turn`, wall.turn);
wall.doors.forEach((door, doorIndex) => {
const doorPrefix = `${wallPrefix}.door.${doorIndex + 1}`;
put(`${doorPrefix}.id`, door.id);
put(`${doorPrefix}.offset`, door.offset);
put(`${doorPrefix}.width`, door.width);
});
});
});
for (const board of project.completedBoards) put('done', board);
put('selectedRoom', view.roomId);
put('view', view.material);
if (view.selectedPiece !== null) put('selectedCut', view.selectedPiece);
if (view.search) put('search', view.search);
for (const section of view.collapsedSections) put('collapsed', section);
const query = `?${params}`.replaceAll('%3A', ':');
if (query.length > PERSISTENCE_LIMITS.queryLength) if (query.length > PERSISTENCE_LIMITS.queryLength)
throw new Error('This plan link is too large. Split it into smaller projects.'); throw new Error('This plan link is too large. Split it into smaller projects.');
return query; return query;
} }
function parseQuery(query: string): UrlState {
if (query.length > PERSISTENCE_LIMITS.queryLength)
throw new Error('The link is too large. Use a smaller project link.');
// URLSearchParams otherwise replaces damaged percent escapes silently.
decodeURIComponent(query);
const params = new URLSearchParams(query);
const defaults = defaultState();
const roomFields = [...params.keys()].filter((key) =>
/^room\.[^.]+\.(?:id|name|direction|wall\.[^.]+\.(?:id|length|turn|door\.[^.]+\.(?:id|offset|width)))$/.test(
key,
),
);
const text = (key: string, fallback?: string): string => {
const values = params.getAll(key);
if (values.length > 1) throw new Error(`${key} must appear only once.`);
if (!values.length) {
if (fallback !== undefined) return fallback;
throw new Error(`The link is missing ${key}.`);
}
return values[0];
};
const measurement = (value: string, key: string) => {
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value.trim()))
throw new Error(`${key} must be a finite number.`);
return number(Number(value), key);
};
const numeric = (key: string, fallback?: number) =>
measurement(text(key, fallback === undefined ? undefined : String(fallback)), key);
const indices = (prefix: string, limit: number) => {
const found = new Set<number>();
for (const key of roomFields) {
if (!key.startsWith(`${prefix}.`)) continue;
const index = key.slice(prefix.length + 1).split('.')[0];
if (!/^[1-9]\d*$/.test(index))
throw new Error(`${prefix} indices must be positive whole numbers, starting at 1.`);
const value = Number(index);
if (value > limit) throw new Error(`${prefix} supports at most ${limit} entries.`);
found.add(value);
}
const ordered = [...found].sort((a, b) => a - b);
if (ordered.some((value, index) => value !== index + 1))
throw new Error(`${prefix} indices must be consecutive, starting at 1.`);
return ordered;
};
const roomIndices = indices('room', PROJECT_LIMITS.rooms);
const rooms = roomIndices.length
? roomIndices.map((roomIndex) => {
const prefix = `room.${roomIndex}`;
const id = text(`${prefix}.id`, `room-${roomIndex}`);
return {
id,
name: text(`${prefix}.name`, `Room ${roomIndex}`),
direction: numeric(`${prefix}.direction`, 0),
walls: indices(`${prefix}.wall`, PROJECT_LIMITS.wallsPerRoom).map((wallIndex) => {
const wallPrefix = `${prefix}.wall.${wallIndex}`;
const wallId = text(`${wallPrefix}.id`, `${id}-w${wallIndex}`);
return {
id: wallId,
length: numeric(`${wallPrefix}.length`),
turn: numeric(`${wallPrefix}.turn`, 90),
doors: indices(`${wallPrefix}.door`, PROJECT_LIMITS.doorsPerWall).map((doorIndex) => {
const doorPrefix = `${wallPrefix}.door.${doorIndex}`;
return {
id: text(`${doorPrefix}.id`, `${wallId}-door-${doorIndex}`),
offset: numeric(`${doorPrefix}.offset`),
width: numeric(`${doorPrefix}.width`),
};
}),
};
}),
};
})
: defaults.project.rooms;
if (params.has('skirting.stock'))
throw new Error('This link uses the retired skirting stock-list format.');
const floor = defaults.project.flooring;
return parseState({
project: {
version: numeric('version', defaults.project.version),
name: text('name', defaults.project.name),
tolerance: numeric('tolerance', defaults.project.tolerance),
completedBoards: params.getAll('done'),
rooms,
flooring: Object.fromEntries(
Object.entries(floor).map(([key, value]) => [
key,
typeof value === 'number'
? numeric(`flooring.${key}`, value)
: text(`flooring.${key}`, value),
]),
),
skirting: {
stockLength: numeric('skirting.length', defaults.project.skirting.stockLength),
thickness: numeric('skirting.thickness', defaults.project.skirting.thickness),
minLength: numeric('skirting.minLength', defaults.project.skirting.minLength),
kerf: numeric('skirting.kerf', defaults.project.skirting.kerf),
},
},
view: {
roomId: text('selectedRoom', rooms[0].id),
selectedPiece: params.has('selectedCut') ? text('selectedCut') : null,
search: text('search', ''),
collapsedSections: params.getAll('collapsed'),
material: text('view', 'flooring'),
},
});
}
/** Accept a readable query and never throw for an untrusted link. */
export function decodeState(link: string): { state: UrlState; error: string | null } { export function decodeState(link: string): { state: UrlState; error: string | null } {
if (link === '' || link === '#' || link === '?') return { state: defaultState(), error: null }; if (link === '' || link === '#' || link === '?' || link === '?planner')
return { state: defaultState(), error: null };
try { try {
if (!link.startsWith('?')) throw new Error('Use the complete plan query, starting with ?.'); if (!link.startsWith('?')) throw new Error('Use the complete plan query, starting with ?.');
return { state: parseQuery(link), error: null }; if (link.length > PERSISTENCE_LIMITS.queryLength) throw new Error('The link is too large.');
decodeURIComponent(link);
const params = new URLSearchParams(link);
if (params.getAll('plan').length > 1) throw new Error('The link must contain one plan.');
const value = params.has('plan') ? JSON.parse(params.get('plan')!) : readLegacyQuery(link);
return { state: parseState(value), error: null };
} catch (error) { } catch (error) {
const detail = const detail =
error instanceof URIError error instanceof URIError
? 'The link has invalid URL encoding. Copy the complete plan link again.' ? 'The link has invalid URL encoding.'
: error instanceof Error : error instanceof Error
? error.message ? error.message
: 'Copy the complete plan link again.'; : 'Copy the complete plan link again.';

22
src/lib/use-printing.ts Normal file
View file

@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
import { flushSync } from 'react-dom';
/** Build print sheets for native printing (including Ctrl+P), then release them. */
export function usePrinting() {
const [printing, setPrinting] = useState(false);
useEffect(() => {
const media = window.matchMedia('print');
const start = () => flushSync(() => setPrinting(true));
const finish = () => setPrinting(false);
const changed = () => (media.matches ? start() : finish());
window.addEventListener('beforeprint', start);
window.addEventListener('afterprint', finish);
media.addEventListener('change', changed);
return () => {
window.removeEventListener('beforeprint', start);
window.removeEventListener('afterprint', finish);
media.removeEventListener('change', changed);
};
}, []);
return printing;
}

View file

@ -1,85 +1,64 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import type { UrlState } from './types';
import { decodeState, encodeState } from './url-state'; import { decodeState, encodeState } from './url-state';
import { reduceProject } from './project-state';
import type { ProjectAction } from './project-state';
export const PROJECT_STORAGE_KEY = 'floor-planner.project'; export const PROJECT_STORAGE_KEY = 'floor-planner.project';
const readLocation = () => { function readLocation() {
// The landing-page CTA opens the last project; it is not a project override. const link = window.location.search;
const link = window.location.search === '?planner' ? '' : window.location.search; if (link && link !== '?' && link !== '?planner') return decodeState(link);
if (link && link !== '?') return decodeState(link);
try { try {
const saved = decodeState(window.localStorage.getItem(PROJECT_STORAGE_KEY) ?? ''); const saved = decodeState(window.localStorage.getItem(PROJECT_STORAGE_KEY) ?? '');
if (!saved.error) return saved; if (!saved.error) return saved;
} catch { } catch {
// Browser privacy settings can make localStorage unavailable. /* Storage may be unavailable. */
} }
return decodeState(''); return decodeState('');
}; }
const saveLocal = (query: string) => { /** React owns live state. Persistence is an output between navigations. */
try { export function useProject() {
window.localStorage.setItem(PROJECT_STORAGE_KEY, query); const [loaded, setLoaded] = useState(() => ({ ...readLocation(), milestone: false }));
} catch { useEffect(() => {
// Keep URL persistence working if storage is blocked or full. const restore = () => setLoaded({ ...readLocation(), milestone: false });
} window.addEventListener('popstate', restore);
}; return () => window.removeEventListener('popstate', restore);
}, []);
const queryUrl = (query: string) => `${window.location.pathname}${query}`; useEffect(() => {
if (loaded.error) return;
/** Persist every parameter in the URL and locally; explicit links take precedence. */ try {
export function useProject() { const query = encodeState(loaded.state);
const [loaded, setLoaded] = useState(readLocation); if (query !== window.location.search || window.location.hash)
useEffect(() => { window.history[loaded.milestone ? 'pushState' : 'replaceState'](
const restore = () => { null,
const next = readLocation(); '',
if (!next.error) { `${window.location.pathname}${query}`,
try { );
// Canonicalize editable queries without creating an extra history entry. try {
const query = encodeState(next.state); window.localStorage.setItem(PROJECT_STORAGE_KEY, query);
if (query !== window.location.search || window.location.hash) } catch {
window.history.replaceState(null, '', queryUrl(query)); /* The URL remains available if storage is blocked or full. */
saveLocal(query); }
} catch (error) { } catch (error) {
next.error = error instanceof Error ? error.message : 'This plan could not be saved.'; // Keep edits usable even if the browser cannot save this link.
} setLoaded((current) =>
} current === loaded
setLoaded(next); ? {
}; ...current,
window.addEventListener('popstate', restore); error: error instanceof Error ? error.message : 'This plan could not be saved.',
restore(); }
return () => { : current,
window.removeEventListener('popstate', restore); );
}; }
}, []); }, [loaded]);
const update = useCallback((transform: (current: UrlState) => UrlState, milestone = false) => { const dispatch = useCallback((action: ProjectAction) => {
setLoaded((current) => { setLoaded((current) => ({
const next = transform(current.state); state: reduceProject(current.state, action),
try { error: null,
const query = encodeState(next); milestone:
if (query !== window.location.search || window.location.hash) action.type === 'addRoom' || action.type === 'removeRoom' || action.type === 'visitRoom',
window.history[milestone ? 'pushState' : 'replaceState'](null, '', queryUrl(query)); }));
saveLocal(query); }, []);
const canonical = decodeState(query).state; return { ...loaded, dispatch };
if (next.project === current.state.project) canonical.project = current.state.project;
else {
// Progress changes should not rerun geometry and stock allocation.
if (next.project.rooms === current.state.project.rooms)
canonical.project.rooms = current.state.project.rooms;
if (next.project.flooring === current.state.project.flooring)
canonical.project.flooring = current.state.project.flooring;
if (next.project.skirting === current.state.project.skirting)
canonical.project.skirting = current.state.project.skirting;
}
return { state: canonical, error: null };
} catch (error) {
return {
...current,
error:
error instanceof Error ? error.message : 'This project could not be saved in the URL.',
};
}
});
}, []);
return { ...loaded, update };
} }

View file

@ -59,15 +59,9 @@ select {
border-radius: 2px; border-radius: 2px;
font-size: 16px; font-size: 16px;
} }
input[type='number'] { input[inputmode] {
font-family: var(--mono); font-family: var(--mono);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
appearance: textfield;
}
input::-webkit-inner-spin-button,
input::-webkit-outer-spin-button {
appearance: none;
margin: 0;
} }
input:disabled { input:disabled {
color: #617085; color: #617085;
@ -149,9 +143,10 @@ small {
font-weight: 600; font-weight: 600;
} }
.app-main { .app-main {
--planner-bottom-space: 40px;
max-width: 1720px; max-width: 1720px;
margin: auto; margin: auto;
padding: 0 32px 40px; padding: 0 32px var(--planner-bottom-space);
} }
.copy-status { .copy-status {
display: block; display: block;
@ -853,7 +848,8 @@ summary span {
} }
@media (max-width: 1199px) { @media (max-width: 1199px) {
.app-main { .app-main {
padding: 0 24px 30px; --planner-bottom-space: 30px;
padding: 0 24px var(--planner-bottom-space);
} }
.app-header { .app-header {
padding: 20px 24px; padding: 20px 24px;
@ -898,7 +894,8 @@ summary span {
display: none; display: none;
} }
.app-main { .app-main {
padding: 0 14px 25px; --planner-bottom-space: 25px;
padding: 0 14px var(--planner-bottom-space);
} }
.project-bar { .project-bar {
margin: 16px 0; margin: 16px 0;
@ -1410,6 +1407,7 @@ summary span {
} }
} }
.workspace { .workspace {
--drawing-height: min(900px, calc(100dvh - 16px - var(--planner-bottom-space)));
display: grid; display: grid;
grid-template-columns: minmax(370px, 0.85fr) minmax(0, 1.15fr); grid-template-columns: minmax(370px, 0.85fr) minmax(0, 1.15fr);
gap: 28px; gap: 28px;
@ -1540,17 +1538,24 @@ summary span {
margin-top: 20px; margin-top: 20px;
} }
.drawing-column { .drawing-column {
display: flex;
flex-direction: column;
height: var(--drawing-height);
position: sticky; position: sticky;
top: 16px; top: 16px;
min-width: 0; min-width: 0;
align-self: start; align-self: start;
background: #fffdf7; background: #fffdf7;
} }
.drawing-column .drawing-viewport { .drawing-column .drawing-card {
height: clamp(180px, calc(100dvh - 316px), 780px); display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
} }
.drawing-column:has(.plan-search-status) .drawing-viewport { .drawing-column .drawing-viewport {
height: clamp(160px, calc(100dvh - 391px), 720px); flex: 1;
min-height: 0;
} }
.drawing-column .room-svg { .drawing-column .room-svg {
width: 100%; width: 100%;
@ -1592,11 +1597,13 @@ summary span {
} }
.matching-rooms { .matching-rooms {
display: flex; display: flex;
flex-wrap: wrap; overflow-x: auto;
gap: 6px; gap: 6px;
margin-top: 7px; margin-top: 7px;
} }
.matching-rooms button { .matching-rooms button {
flex-shrink: 0;
white-space: nowrap;
padding: 6px 10px; padding: 6px 10px;
font-size: 12px; font-size: 12px;
} }
@ -1662,6 +1669,7 @@ summary span {
padding-inline: 16px; padding-inline: 16px;
} }
.workspace { .workspace {
--drawing-height: clamp(300px, 45dvh, 420px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
@ -1675,17 +1683,13 @@ summary span {
top: 0; top: 0;
z-index: 5; z-index: 5;
} }
.drawing-column .drawing-viewport,
.drawing-column:has(.plan-search-status) .drawing-viewport {
height: clamp(150px, 25dvh, 240px);
}
.panel-toggle { .panel-toggle {
font-size: 18px; font-size: 18px;
min-height: 50px; min-height: 50px;
scroll-margin-top: 390px; scroll-margin-top: calc(var(--drawing-height) + 16px);
} }
.planner-panel { .planner-panel {
scroll-margin-top: 390px; scroll-margin-top: calc(var(--drawing-height) + 16px);
} }
.board-cuts { .board-cuts {
padding-inline: 12px; padding-inline: 12px;
@ -1697,7 +1701,7 @@ summary span {
select, select,
button, button,
summary { summary {
scroll-margin-top: 410px; scroll-margin-top: calc(var(--drawing-height, 0px) + 16px);
scroll-margin-bottom: 24px; scroll-margin-bottom: 24px;
} }
} }

View file

@ -1,9 +1,7 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { defaultProject, rectangle } from '../src/lib/defaults'; import { defaultProject, rectangle } from '../src/lib/defaults';
import { boardSearchLabel, findBoardMatches } from '../src/lib/board-tracking'; import { focusLabel, findBoardMatches } from '../src/lib/board-tracking';
import { CutList } from '../src/components/CutList'; import { cutData } from '../src/lib/cut-list';
import type { FlooringPiece, FlooringPlan, SkirtingPiece, SkirtingPlan } from '../src/lib/types'; import type { FlooringPiece, FlooringPlan, SkirtingPiece, SkirtingPlan } from '../src/lib/types';
const project = defaultProject(); const project = defaultProject();
@ -20,7 +18,6 @@ function floorPiece(id: string, stockId: string, roomId = 'room-1', cut = true):
blankLength: length, blankLength: length,
blankWidth: 190, blankWidth: 190,
row: 1, row: 1,
column: 1,
polygon: [], polygon: [],
template: [ template: [
{ x: 0, y: 0 }, { x: 0, y: 0 },
@ -69,6 +66,7 @@ function skirtPiece(id: string, stockId: string, roomId = 'room-1', length = 900
roomId, roomId,
length, length,
blankLength: length, blankLength: length,
cut: length !== 2400,
backLength: length, backLength: length,
wallId: 'w1', wallId: 'w1',
wallIndex: 0, wallIndex: 0,
@ -81,10 +79,6 @@ function skirtPiece(id: string, stockId: string, roomId = 'room-1', length = 900
template, template,
installedTemplate: template, installedTemplate: template,
visibleLength: length, visibleLength: length,
leftOverlap: 0,
rightOverlap: 0,
leftBevel: 0,
rightBevel: 0,
}; };
} }
const skirting: SkirtingPlan = { const skirting: SkirtingPlan = {
@ -104,122 +98,57 @@ const skirting: SkirtingPlan = {
waste: 1503, waste: 1503,
issues: [], issues: [],
}; };
const find = (query: string) => findBoardMatches(project, flooring, skirting, query); const find = (query: string) => findBoardMatches(project, flooring, skirting, query);
const noop = () => {}; const cuts = cutData(flooring, skirting, project.flooring);
function list(search = '', completedBoards: string[] = []) {
return renderToStaticMarkup(
createElement(CutList, {
project,
flooring,
skirting,
select: noop,
search,
setSearch: noop,
completedBoards,
toggleBoard: noop,
locateBoard: noop,
locatePiece: noop,
}),
);
}
describe('board tracking search', () => { describe('board and cut location', () => {
it('matches cut and stock codes exactly instead of confusing F1 with F10/F12 or B1 with B10', () => { it('matches complete cut codes and multiple codes without interpreting stock commands', () => {
expect(find(' F1 ')).toEqual({ expect([...find('f1').pieceKeys]).toEqual(['flooring:F1']);
pieceIds: ['F1'], expect([...find('F1 + F12').pieceKeys]).toEqual(['flooring:F1', 'flooring:F12']);
roomIds: ['room-1'], expect([...find('B1').pieceKeys]).toEqual(['skirting:B1']);
boardKeys: ['floor:B1'], expect(find('floor:B1').pieceKeys.size).toBe(0);
});
expect(find('b1')).toEqual({
pieceIds: ['F1', 'F12', 'B1'],
roomIds: ['room-1', 'room-2'],
boardKeys: ['floor:B1', 'skirting:S6'],
});
expect(find('S6').pieceIds).toEqual(['A2', 'B1']);
expect(find('B10').pieceIds).toEqual(['F10', 'B10']);
}); });
it('keeps identical display labels in different materials distinct', () => {
it('uses material-qualified source keys for unambiguous board location and includes all its rooms', () => { const skirt = { ...skirting, pieces: [...skirting.pieces, skirtPiece('F1', 'S8', 'room-2')] };
expect(find(' FLOOR:B1 ')).toEqual({ const result = findBoardMatches(project, flooring, skirt, 'F1');
pieceIds: ['F1', 'F12'], expect([...result.pieceKeys]).toEqual(['flooring:F1', 'skirting:F1']);
roomIds: ['room-1', 'room-2'], expect(result.materials).toEqual(new Set(['flooring', 'skirting']));
boardKeys: ['floor:B1'], const data = cutData(flooring, skirt, project.flooring);
}); expect(data.selections.get('flooring:F1')?.piece.roomId).toBe('room-1');
expect(find('skirting:S6')).toEqual({ expect(data.selections.get('skirting:F1')?.piece.roomId).toBe('room-2');
pieceIds: ['A2', 'B1'],
roomIds: ['room-1', 'room-2'],
boardKeys: ['skirting:S6'],
});
}); });
it('locates all pieces of a source board across rooms without setting search text', () => {
it('finds full uncut boards as well as cuts, and searches room names without case sensitivity', () => { const focus = { kind: 'board', material: 'flooring', id: 'B1' } as const;
expect(find('b3')).toEqual({ pieceIds: ['FU1'], roomIds: ['room-2'], boardKeys: ['floor:B3'] }); const result = findBoardMatches(project, flooring, skirting, '', focus);
expect(find('s7')).toEqual({ expect([...result.pieceKeys]).toEqual(['flooring:F1', 'flooring:F12']);
pieceIds: ['SU1'], expect(result.roomIds).toEqual(['room-1', 'room-2']);
roomIds: ['room-2'], expect(result.boardKeys).toEqual(new Set(['floor:B1']));
boardKeys: ['skirting:S7'], expect(focusLabel(focus, cuts)).toBe('F1 + F12');
});
expect(find(' OfFiCe ')).toEqual({
pieceIds: ['F12', 'FU1', 'B1', 'SU1'],
roomIds: ['room-2'],
boardKeys: ['floor:B1', 'floor:B3', 'skirting:S6', 'skirting:S7'],
});
expect(find('living').roomIds).toEqual(['room-1']);
}); });
it('locates uncut boards and searches room names including code-like names', () => {
it('returns empty matches for empty or unknown queries and never mutates the plans', () => { const focus = { kind: 'board', material: 'flooring', id: 'B3' } as const;
expect([...findBoardMatches(project, flooring, skirting, '', focus).pieceKeys]).toEqual([
'flooring:FU1',
]);
expect(focusLabel(focus, cuts)).toBe('Uncut floorboard');
expect(find(' OfFiCe ').roomIds).toEqual(['room-2']);
const renamed = { ...project, rooms: project.rooms.map((room) => ({ ...room, name: 'A101' })) };
expect(findBoardMatches(renamed, flooring, skirting, 'A101').pieceKeys.size).toBe(8);
});
it('keeps a focused piece distinct from a focused source board', () => {
expect([
...findBoardMatches(project, flooring, skirting, '', {
kind: 'piece',
material: 'flooring',
id: 'F12',
}).pieceKeys,
]).toEqual(['flooring:F12']);
});
it('returns no matches for empty or unknown input and does not mutate plans', () => {
const before = structuredClone({ project, flooring, skirting }); const before = structuredClone({ project, flooring, skirting });
expect(find(' ')).toEqual({ pieceIds: [], roomIds: [], boardKeys: [] }); expect(find(' ').pieceKeys.size).toBe(0);
expect(find('missing')).toEqual({ pieceIds: [], roomIds: [], boardKeys: [] }); expect(find('missing').pieceKeys.size).toBe(0);
expect(find('B1')).toEqual(find('B1'));
expect({ project, flooring, skirting }).toEqual(before); expect({ project, flooring, skirting }).toEqual(before);
}); });
it('keeps all cuts from a matching source together and applies completion to the whole group', () => {
const markup = list('F1', ['floor:B1']);
expect(markup).toContain('data-board-key="floor:B1"');
expect(markup).not.toContain('data-board-key="floor:B10"');
expect(markup).not.toContain('data-board-key="skirting:S6"');
expect(markup).toContain('Preview flooring cut F1');
expect(markup).toContain('Preview flooring cut F12');
expect(markup.match(/class="board-cut is-done/g)).toHaveLength(2);
expect(markup).toContain('aria-label="Mark cuts F1 + F12 done" checked=""');
expect(markup).toContain('Show cuts F1 + F12 on plan');
expect(markup).toContain('Show cut F12 on plan');
expect(markup).toContain('<span>Done</span>');
});
it('names board groups and actions using their cuts while keeping bookkeeping IDs internal', () => {
const markup = list();
expect(markup).toContain('aria-label="Flooring cuts F1 + F12"');
expect(markup).toContain('aria-label="Skirting cuts A2 + B1"');
expect(markup).toContain('Mark cuts A2 + B1 done');
expect(markup).toContain('data-board-key="skirting:S6"');
expect(markup).toContain('Find a cut or room');
expect(markup).not.toMatch(/(?:Floorboard B\d|Skirting board S\d|Export CSV)/i);
expect(markup).not.toMatch(/aria-label="[^"]*(?:floor:B\d|skirting:S\d)/i);
});
it('shows cut names when a located board is stored as a qualified source key', () => {
expect(boardSearchLabel(project, flooring, skirting, 'floor:B1')).toBe('F1 + F12');
expect(boardSearchLabel(project, flooring, skirting, 'skirting:S6')).toBe('A2 + B1');
expect(boardSearchLabel(project, flooring, skirting, 'Office')).toBe('Office');
expect(list('floor:B1')).toContain('value="F1 + F12"');
expect(list('skirting:S6')).toContain('value="A2 + B1"');
expect(find('F1 + F12')).toEqual(find('floor:B1'));
// B1 here is a skirting cut, not the hidden flooring stock with the same internal code.
expect(find('A2 + B1')).toEqual(find('skirting:S6'));
});
it('exposes an interactive full-board result only when searched, without inventing a cut preview', () => {
expect(list()).not.toContain('data-board-key="floor:B3"');
const markup = list('floor:B3');
expect(markup).toContain('data-board-key="floor:B3"');
expect(markup).toContain('Mark uncut floorboard done');
expect(markup).toContain('Show uncut floorboard on plan');
expect(markup).toContain('Installed uncut');
expect(markup).toContain('value="Uncut floorboard"');
expect(markup).not.toContain('Preview flooring cut FU1');
expect(list('skirting:S7')).toContain('value="Uncut skirting board"');
});
}); });

View file

@ -17,7 +17,7 @@ function piece(id: string, stockId: string, changes: Partial<FlooringPiece> = {}
id, id,
roomId: 'room-1', roomId: 'room-1',
row: 1, row: 1,
column: 1,
polygon: [], polygon: [],
length, length,
width, width,

View file

@ -2,7 +2,6 @@ import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server'; import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { FlooringPreview, SkirtingPreview } from '../src/components/CutPreview'; import { FlooringPreview, SkirtingPreview } from '../src/components/CutPreview';
import { CutDialog } from '../src/components/CutDialog';
import { planSkirting } from '../src/lib/skirting'; import { planSkirting } from '../src/lib/skirting';
import { skirtingEndLabel } from '../src/lib/cut-list'; import { skirtingEndLabel } from '../src/lib/cut-list';
import { defaultProject } from '../src/lib/defaults'; import { defaultProject } from '../src/lib/defaults';
@ -14,7 +13,6 @@ const floor: FlooringPiece = {
id: 'F1', id: 'F1',
roomId: 'room-1', roomId: 'room-1',
row: 1, row: 1,
column: 1,
polygon: [], polygon: [],
length: 600.001, length: 600.001,
width: 190, width: 190,
@ -41,6 +39,7 @@ const template: SkirtingPiece['template'] = [
{ x: 0, y: 18 }, { x: 0, y: 18 },
]; ];
const skirt: SkirtingPiece = { const skirt: SkirtingPiece = {
cut: true,
id: 'A1', id: 'A1',
roomId: 'room-1', roomId: 'room-1',
wallId: 'w1', wallId: 'w1',
@ -58,10 +57,6 @@ const skirt: SkirtingPiece = {
template, template,
installedTemplate: template, installedTemplate: template,
visibleLength: 1200, visibleLength: 1200,
leftOverlap: 0,
rightOverlap: 0,
leftBevel: -45,
rightBevel: 45,
}; };
describe('cut shape previews', () => { describe('cut shape previews', () => {
@ -223,39 +218,6 @@ describe('cut shape previews', () => {
expect(markup).toContain('angles from horizontal'); expect(markup).toContain('angles from horizontal');
}); });
it('keeps dialog instructions useful without source codes or numbered coordinate lists', () => {
const markup = renderToStaticMarkup(
createElement(CutDialog, {
floor: { ...floor, factoryLeft: true, ripEdge: 'top' },
project: defaultProject(),
close: () => {},
}),
);
expect(markup).toContain('Living room · Row 1');
expect(markup).toContain('Keep the left factory end; trim from the right.');
expect(markup).toContain('Rip the top long edge; keep the bottom factory edge.');
expect(markup).not.toContain('Source');
expect(markup).not.toContain('coordinates');
expect(markup).not.toContain('cut-edge-lengths');
});
it('shows skirting dimensions and mitre directions without a second measurement list', () => {
const markup = renderToStaticMarkup(
createElement(CutDialog, {
skirt,
project: defaultProject(),
close: () => {},
}),
);
expect(markup).toContain('Living room · Wall 1');
expect(markup).toContain('>1,164</text>');
expect(markup).toContain('>1,200</text>');
expect(markup).toContain('Mitre 45°');
expect(markup).not.toContain('Source');
expect(markup).not.toContain('coordinates');
expect(markup).not.toContain('<dl');
});
it('keeps the complete end cut visible even at shallow mitre angles', () => { it('keeps the complete end cut visible even at shallow mitre angles', () => {
const shallow = [ const shallow = [
{ x: 0, y: 0 }, { x: 0, y: 0 },
@ -278,34 +240,11 @@ describe('cut shape previews', () => {
} }
}); });
it('makes thumbnails decorative while retaining true-scale SVGs and visible end breaks', () => {
const floorMarkup = renderToStaticMarkup(
createElement(FlooringPreview, { piece: floor, compact: true }),
);
const skirtMarkup = renderToStaticMarkup(
createElement(SkirtingPreview, { piece: skirt, thickness: 18, compact: true }),
);
expect(floorMarkup).toContain('aria-hidden="true"');
expect(skirtMarkup.match(/aria-hidden="true"/g)).toHaveLength(3);
expect(skirtMarkup).toContain('>//</span>');
expect(skirtMarkup.match(/preserveAspectRatio="xMidYMid meet"/g)).toHaveLength(2);
expect(floorMarkup).not.toContain('<figure');
expect(skirtMarkup).not.toContain('<figure');
});
it('shows cope preparation and its full-thickness body without inventing a decorative profile', () => { it('shows cope preparation and its full-thickness body without inventing a decorative profile', () => {
const project = defaultProject(); const project = defaultProject();
const plan = planSkirting(project.rooms, project.skirting, project.tolerance); const plan = planSkirting(project.rooms, project.skirting, project.tolerance);
const piece = plan.pieces.find((piece) => piece.leftType === 'cope')!; const piece = plan.pieces.find((piece) => piece.leftType === 'cope')!;
expect(piece).toBeDefined(); expect(piece).toBeDefined();
const markup = renderToStaticMarkup(
createElement(CutDialog, { skirt: piece, project, close: () => {} }),
);
expect(markup).toContain(`Left cope preparation close-up ${piece.id}`);
expect(markup).toContain('Cope · prepare 45°');
expect(markup).toContain('preparation before coping');
expect(markup).toContain('class="skirting-cope-body"');
expect(markup).toContain('mark your moulding’s exposed profile');
expect(markup).toContain('Exposed front length:');
expect(skirtingEndLabel({ ...piece, rightType: 'internal', rightMitre: 0 }, 'right')).toBe( expect(skirtingEndLabel({ ...piece, rightType: 'internal', rightMitre: 0 }, 'right')).toBe(
'Receive cope · square', 'Receive cope · square',
); );
@ -316,14 +255,6 @@ describe('cut shape previews', () => {
const installed = piece.installedTemplate; const installed = piece.installedTemplate;
expect(prepared[0].x).toBeLessThan(installed[0].x); expect(prepared[0].x).toBeLessThan(installed[0].x);
expect(prepared[3].x).toBeCloseTo(installed[3].x); expect(prepared[3].x).toBeCloseTo(installed[3].x);
const compact = renderToStaticMarkup(
createElement(SkirtingPreview, {
piece,
thickness: project.skirting.thickness,
compact: true,
}),
);
expect(compact).toContain('class="skirting-cope-body"');
}); });
it('keeps an oblique coped body inside the uniformly scaled end close-up', () => { it('keeps an oblique coped body inside the uniformly scaled end close-up', () => {

View file

@ -1,14 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { plannerCases } from '../benchmarks/cases'; import { plannerCases } from '../benchmarks/cases';
import { import { compareCurrentFlooring, flooringMetrics, skirtingMetrics } from '../benchmarks/metrics';
compareFlooring,
compareCurrentFlooring,
compareSkirting,
flooringMetrics,
skirtingMetrics,
} from '../benchmarks/metrics';
import baseline from '../benchmarks/baseline.json'; import baseline from '../benchmarks/baseline.json';
import hBaseline from '../benchmarks/h-pattern-baseline.json';
import { planFlooringOptions } from '../src/lib/flooring'; import { planFlooringOptions } from '../src/lib/flooring';
import { planSkirting } from '../src/lib/skirting'; import { planSkirting } from '../src/lib/skirting';
import { defaultProject, fireplace, rectangle } from '../src/lib/defaults'; import { defaultProject, fireplace, rectangle } from '../src/lib/defaults';
@ -118,41 +111,31 @@ describe('measured optimiser quality', () => {
const { options, skirt } = resultFor({ name, project }); const { options, skirt } = resultFor({ name, project });
const cuts = flooringMetrics(options.cuts, project.flooring); const cuts = flooringMetrics(options.cuts, project.flooring);
const random = flooringMetrics(options.random, project.flooring); const random = flooringMetrics(options.random, project.flooring);
const before = baseline.find((row) => row.name === name)!;
expect(compareFlooring(cuts, before.flooring)).toBeLessThanOrEqual(0);
expect(compareCurrentFlooring(cuts, random)).toBeLessThanOrEqual(0); expect(compareCurrentFlooring(cuts, random)).toBeLessThanOrEqual(0);
expect(compareCurrentFlooring(random, cuts, true)).toBeLessThanOrEqual(0); expect(compareCurrentFlooring(random, cuts, true)).toBeLessThanOrEqual(0);
const previousAppearance = hBaseline.find((row) => row.name === name)!;
expect(compareCurrentFlooring(random, previousAppearance.random, true)).toBeLessThanOrEqual(
0,
);
expect(random.nearH).toBeLessThanOrEqual(previousAppearance.random.nearH);
// Only this fixture's 600 mm minimum pieces force alternating starts. // Only this fixture's 600 mm minimum pieces force alternating starts.
if (name !== 'forced-alternation') expect(random.nearH).toBe(0); if (name !== 'forced-alternation') expect(random.nearH).toBe(0);
validateFlooring(options.cuts, project); validateFlooring(options.cuts, project);
if (options.random !== options.cuts) validateFlooring(options.random, project); if (options.random !== options.cuts) validateFlooring(options.random, project);
expect(compareSkirting(skirt, before.skirting)).toBeLessThanOrEqual(0);
expect(skirt.errors).toBe(0); expect(skirt.errors).toBe(0);
expect(JSON.stringify(project)).toBe(input); expect(JSON.stringify(project)).toBe(input);
}, },
); );
it('retains the measured improvements across the complete fixed corpus', () => { it('retains the measured improvements across the complete fixed corpus', () => {
const totals = { cuts: 0, randomCuts: 0, pattern: 0, nearH: 0, skirtingCuts: 0 }; const totals = { cuts: 0, randomCuts: 0, skirtingCuts: 0 };
for (const c of cases) { for (const c of cases) {
const { options, skirt } = resultFor(c); const { options, skirt } = resultFor(c);
totals.cuts += options.cuts.cuts; totals.cuts += options.cuts.cuts;
totals.randomCuts += options.random.cuts; totals.randomCuts += options.random.cuts;
totals.pattern += flooringMetrics(options.random, c.project.flooring).patternPenalty;
totals.nearH += flooringMetrics(options.random, c.project.flooring).nearH;
totals.skirtingCuts += skirt.cuts; totals.skirtingCuts += skirt.cuts;
} }
expect(totals.cuts).toBeLessThanOrEqual(1741); // Original: 2177. // Keep a substantial cuts-first gain and let appearance-first spend cuts on joint spacing.
// Broader H avoidance costs more cuts, while still beating the original 2177 passes. const originalCuts = baseline.reduce((sum, row) => sum + row.flooring.cuts, 0);
expect(totals.randomCuts).toBeLessThanOrEqual(2113); const originalSkirting = baseline.reduce((sum, row) => sum + row.skirting.cuts, 0);
expect(totals.nearH).toBeLessThanOrEqual(6); // Before the visual correction: 557. expect(totals.cuts).toBeLessThan(originalCuts * 0.81);
expect(totals.pattern).toBeLessThanOrEqual(346); // Retain the separate legacy diagnostic. expect(totals.randomCuts).toBeLessThan(originalCuts);
expect(totals.skirtingCuts).toBeLessThanOrEqual(1436); // Original: 1447. expect(totals.skirtingCuts).toBeLessThan(originalSkirting);
}); });
it('packs reserved offcuts before applying the 2000-board cap on a 500-row floor', () => { it('packs reserved offcuts before applying the 2000-board cap on a 500-row floor', () => {

View file

@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import { defaultState, rectangle } from '../src/lib/defaults';
import { reduceProject } from '../src/lib/project-state';
import { createPlanCalculator } from '../src/lib/planning';
describe('project edits', () => {
it('preserves progress when renaming but clears all allocation locations when measurements change', () => {
const state = defaultState();
state.project.completedBoards = ['floor:B1'];
state.view.selectedPiece = 'flooring:F1';
state.view.focus = { kind: 'board', material: 'flooring', id: 'B1' };
state.view.search = 'F1';
const renamed = reduceProject(state, {
type: 'room',
id: 'room-1',
change: (room) => ({ ...room, name: 'New name' }),
});
expect(renamed.project.completedBoards).toBe(state.project.completedBoards);
expect(renamed.view).toBe(state.view);
const changed = reduceProject(renamed, { type: 'flooring', patch: { gap: 12 } });
expect(changed.project.completedBoards).toEqual([]);
expect(changed.view).toMatchObject({ selectedPiece: null, search: '', focus: null });
expect(reduceProject(state, { type: 'flooring', patch: { optimisation: 'cuts' } })).toBe(state);
});
it('keeps piece selection material-qualified and clears location when typing', () => {
const state = reduceProject(defaultState(), {
type: 'select',
material: 'skirting',
id: 'F1',
roomId: 'room-6',
});
expect(state.view.selectedPiece).toBe('skirting:F1');
const focused = reduceProject(state, {
type: 'focus',
focus: { kind: 'board', material: 'skirting', id: 'S1' },
roomId: 'room-6',
});
expect(focused.view).toMatchObject({ selectedPiece: null, search: '' });
const searched = reduceProject(focused, {
type: 'search',
text: 'Kitchen',
material: 'flooring',
roomId: 'room-1',
});
expect(searched.view.focus).toBeNull();
});
it('keeps at least one room and navigates away from a removed room', () => {
const state = defaultState();
expect(reduceProject(state, { type: 'removeRoom', id: 'room-1' })).toBe(state);
const added = reduceProject(state, { type: 'addRoom' });
expect(added.project.rooms).toHaveLength(2);
const removed = reduceProject(added, { type: 'removeRoom', id: added.view.roomId });
expect(removed.view.roomId).toBe('room-1');
});
});
describe('calculation invalidation', () => {
it('reuses geometry and plans for names, progress, and selecting an already calculated option', () => {
const calculate = createPlanCalculator();
let state = defaultState();
const initial = calculate(state.project);
for (const action of [
{
type: 'room',
id: 'room-1',
change: (room: ReturnType<typeof rectangle>) => ({ ...room, name: 'Renamed' }),
},
{ type: 'toggleBoard', key: 'floor:B1' },
{ type: 'flooring', patch: { optimisation: 'random' } },
] as const) {
state = reduceProject(state, action);
const next = calculate(state.project);
expect(next.flooringOptions).toBe(initial.flooringOptions);
expect(next.skirting).toBe(initial.skirting);
expect(next.geometries).toBe(initial.geometries);
}
state = reduceProject(state, { type: 'flooring', patch: { gap: 12 } });
const edited = calculate(state.project);
expect(edited.flooringOptions).not.toBe(initial.flooringOptions);
expect(edited.geometries).toBe(initial.geometries);
expect(edited.skirting).toBe(initial.skirting);
});
it('rebuilds changed outlines and leaves another room geometry intact', () => {
const calculate = createPlanCalculator();
const state = defaultState();
state.project.rooms.push(rectangle('room-2'));
const first = calculate(state.project);
const changed = reduceProject(state, {
type: 'room',
id: 'room-2',
change: () => rectangle('room-2', 'Second', 4200, 3600),
});
const second = calculate(changed.project);
expect(second.geometries[0]).toBe(first.geometries[0]);
expect(second.geometries[1]).not.toBe(first.geometries[1]);
expect(second.flooringOptions).not.toBe(first.flooringOptions);
expect(second.skirting).not.toBe(first.skirting);
});
});

View file

@ -101,15 +101,12 @@ describe('skirting cutting plan', () => {
expect(front.leftMitre).toBeCloseTo(45); expect(front.leftMitre).toBeCloseTo(45);
expect(front.rightMitre).toBeCloseTo(45); expect(front.rightMitre).toBeCloseTo(45);
expect(front.leftType).toBe('external'); expect(front.leftType).toBe('external');
expect(front.leftBevel).toBeCloseTo(-45);
expect(front.rightBevel).toBeCloseTo(45);
expect(front.template.map((point) => Math.round(point.x))).toEqual([18, 1018, 1036, 0]); expect(front.template.map((point) => Math.round(point.x))).toEqual([18, 1018, 1036, 0]);
const reversed = { ...room, walls: room.walls.map((wall) => ({ ...wall, turn: -wall.turn })) }; const reversed = { ...room, walls: room.walls.map((wall) => ({ ...wall, turn: -wall.turn })) };
const reversePlan = planSkirting([reversed], { ...settings, stockLength: 6000 }, 5); const reversePlan = planSkirting([reversed], { ...settings, stockLength: 6000 }, 5);
const reverseFront = reversePlan.pieces.find((piece) => piece.wallIndex === 4)!; const reverseFront = reversePlan.pieces.find((piece) => piece.wallIndex === 4)!;
expect(reverseFront.length).toBeCloseTo(1036); expect(reverseFront.length).toBeCloseTo(1036);
expect(reverseFront.leftBevel).toBeCloseTo(-45);
expect(reverseFront.rightBevel).toBeCloseTo(45);
}); });
it('packs across rooms with deterministic unique IDs and accounts for kerf', () => { it('packs across rooms with deterministic unique IDs and accounts for kerf', () => {
@ -404,8 +401,6 @@ describe('skirting cutting plan', () => {
expect(plan.pieces[0].id).toMatch(/^SU/); expect(plan.pieces[0].id).toMatch(/^SU/);
for (const piece of plan.pieces) { for (const piece of plan.pieces) {
expect(piece.visibleLength).toBeCloseTo(2364); expect(piece.visibleLength).toBeCloseTo(2364);
expect(piece.leftOverlap).toBeCloseTo(18);
expect(piece.rightOverlap).toBeCloseTo(18);
} }
const two = planSkirting( const two = planSkirting(
[rectangle(2400, 2400, 'one'), rectangle(2400, 2400, 'two')], [rectangle(2400, 2400, 'one'), rectangle(2400, 2400, 'two')],
@ -486,3 +481,18 @@ describe('skirting cutting plan', () => {
expect(plan.pieces.every((piece) => cutLength(piece.length) === 1200)).toBe(true); expect(plan.pieces.every((piece) => cutLength(piece.length) === 1200)).toBe(true);
}); });
}); });
it('uses the allocator cut decision at fractional stock boundaries', () => {
const room = rectangle(999.995, 999.995, 'precision');
room.walls.forEach((wall) => {
wall.doors = [];
});
const plan = planSkirting(
[room],
{ stockLength: 1000, thickness: 0, minLength: 100, kerf: 3 },
1,
);
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
expect(plan.cuts).toBe(4);
expect(skirtingCuts(plan)).toHaveLength(4);
});

View file

@ -3,76 +3,47 @@ import { defaultState, fireplace, rectangle } from '../src/lib/defaults';
import { PERSISTENCE_LIMITS, PROJECT_LIMITS } from '../src/lib/limits'; import { PERSISTENCE_LIMITS, PROJECT_LIMITS } from '../src/lib/limits';
import { decodeState, encodeState } from '../src/lib/url-state'; import { decodeState, encodeState } from '../src/lib/url-state';
const payload = (state: unknown) =>
`?plan=${encodeURIComponent(JSON.stringify({ version: 3, ...(state as object) }))}`;
function expectInvalid(link: string, message?: RegExp) { function expectInvalid(link: string, message?: RegExp) {
const result = decodeState(link); const result = decodeState(link);
expect(result.state).toEqual(defaultState()); expect(result.state).toEqual(defaultState());
expect(result.error).toMatch(message ?? /Could not load this plan/); expect(result.error).toMatch(message ?? /Could not load this plan/);
} }
describe('URL state', () => { describe('saved plans', () => {
it('starts with independent defaults when no link is provided', () => { it('starts with independent defaults', () => {
for (const link of ['', '#', '?']) { for (const link of ['', '#', '?', '?planner']) {
const first = decodeState(link); const first = decodeState(link);
expect(first).toEqual({ state: defaultState(), error: null }); expect(first).toEqual({ state: defaultState(), error: null });
first.state.project.rooms[0].name = 'Edited'; first.state.project.rooms[0].name = 'Edited';
expect(decodeState(link).state.project.rooms[0].name).toBe('Living room'); expect(decodeState(link).state.project.rooms[0].name).toBe('Living room');
} }
}); });
it('round-trips one versioned payload, including Unicode and material-qualified selection', () => {
it('round-trips current inputs exactly and produces a stable readable link', () => {
const state = defaultState(); const state = defaultState();
const query = encodeState(state); state.project.rooms[0].name = 'Zoë’s 家 🏡 – mõõtmised';
expect(query).toMatch(/^\?version=2&name=/);
const params = new URLSearchParams(query);
expect(params.get('flooring.length')).toBe('1200');
expect(params.get('flooring.optimisation')).toBe('cuts');
expect(params.get('skirting.length')).toBe('2400');
expect(params.get('room.1.wall.1.length')).toBe('4800');
expect(params.get('room.1.wall.4.door.1.width')).toBe('826');
expect(query).not.toContain('#plan=');
expect(decodeState(query)).toEqual({ state, error: null });
expect(encodeState(decodeState(query).state)).toBe(query);
expect(encodeState(structuredClone(state))).toBe(query);
});
it('round-trips Unicode, rooms, doors, materials, progress and view selections', () => {
const state = defaultState();
state.project.name = 'Zoë’s 家 🏡 – mõõtmised';
state.project.rooms.push(fireplace('room-2', 'Slaapkamer 北')); state.project.rooms.push(fireplace('room-2', 'Slaapkamer 北'));
state.project.rooms[1].direction = 90; state.project.rooms[1].direction = 90;
state.project.rooms[0].walls[0].doors = [{ id: 'wide-door', offset: 123.75, width: 1500 }]; state.project.rooms[0].walls[0].doors = [{ id: 'wide-door', offset: 123.75, width: 1500 }];
state.project.flooring.pattern = 'random';
state.project.flooring.optimisation = 'random'; state.project.flooring.optimisation = 'random';
state.project.skirting.stockLength = 3600;
state.project.completedBoards = ['floor:B4', 'skirting:S6']; state.project.completedBoards = ['floor:B4', 'skirting:S6'];
state.view = { state.view = {
roomId: 'room-2', roomId: 'room-2',
selectedPiece: 'A12', selectedPiece: 'skirting:B12',
search: 'A12', search: '',
focus: { kind: 'board', material: 'skirting', id: 'S6' },
collapsedSections: ['materials'], collapsedSections: ['materials'],
material: 'skirting', material: 'skirting',
}; };
expect(decodeState(encodeState(state))).toEqual({ state, error: null }); const query = encodeState(state);
expect([...new URLSearchParams(query).keys()]).toEqual(['plan']);
expect(decodeState(query)).toEqual({ state, error: null });
expect(encodeState(decodeState(query).state)).toBe(query);
}); });
it('keeps finite invalid measurements and unfinished outlines for editor feedback', () => {
it('canonicalizes property order and discards unknown derived fields', () => {
const state = defaultState(); const state = defaultState();
const reordered = { state.project.rooms[0].walls = [];
view: { ...state.view, derived: 6 },
project: { ...state.project, boardCount: 500 },
cache: [],
};
expect(encodeState(reordered)).toBe(encodeState(state));
const params = new URLSearchParams(encodeState(state));
params.set('boardCount', '500');
params.set('room.1.polygon', 'derived');
expect(decodeState(`?${params}`)).toEqual({ state, error: null });
});
it('preserves finite domain-invalid measurements for planner feedback', () => {
const state = defaultState();
state.project.rooms[0].walls[0].length = -1;
state.project.rooms[0].walls[0].turn = 999;
state.project.flooring.width = 0; state.project.flooring.width = 0;
state.project.flooring.minOffset = 1000; state.project.flooring.minOffset = 1000;
state.project.flooring.maxOffset = 100; state.project.flooring.maxOffset = 100;
@ -80,186 +51,129 @@ describe('URL state', () => {
state.project.tolerance = -1; state.project.tolerance = -1;
expect(decodeState(encodeState(state))).toEqual({ state, error: null }); expect(decodeState(encodeState(state))).toEqual({ state, error: null });
}); });
it('validates untrusted payloads and discards unknown derived fields', () => {
it('allows unfinished current projects without inventing inputs', () => {
const state = defaultState(); const state = defaultState();
state.project.rooms[0].walls = []; expect(
state.project.skirting.stockLength = 0; decodeState(payload({ ...state, project: { ...state.project, boardCount: 500 } })).state,
expect(decodeState(encodeState(state))).toEqual({ state, error: null }); ).toEqual(state);
}); for (const invalid of ['1200', null, true, {}, []]) {
expectInvalid(
it('falls back to the first room for a stale selected room', () => { payload({
const params = new URLSearchParams(encodeState(defaultState())); ...state,
params.set('selectedRoom', 'deleted-room'); project: { ...state.project, flooring: { ...state.project.flooring, length: invalid } },
const result = decodeState(`?${params}`); }),
expect(result.error).toBeNull(); /finite number/,
expect(result.state.view.roomId).toBe(result.state.project.rooms[0].id); );
});
it.each(['#plan=old', '#other=value', 'version=2'])('rejects non-query links %s', (link) => {
expectInvalid(link, /starting with \?/);
});
it.each(['?version=1', '?version=3'])('rejects unsupported schema link %s', (link) => {
expectInvalid(link, /version is unsupported/);
});
it('requires every field in the current in-memory contract', () => {
const missingOptimisation = defaultState();
delete (
missingOptimisation.project.flooring as Partial<typeof missingOptimisation.project.flooring>
).optimisation;
expect(() => encodeState(missingOptimisation)).toThrow(/optimisation.*unsupported/i);
const missingStockLength = defaultState();
delete (
missingStockLength.project.skirting as Partial<typeof missingStockLength.project.skirting>
).stockLength;
expect(() => encodeState(missingStockLength)).toThrow(/finite number/);
});
it('rejects incorrectly typed and nonfinite measurements when reading or writing', () => {
for (const query of [
'?flooring.length=NaN',
'?flooring.length=Infinity',
'?flooring.length=1e999',
'?flooring.length=',
'?flooring.length=true',
'?flooring.length=0x10',
])
expectInvalid(query, /finite number/);
const state = defaultState();
state.project.flooring.length = '1200' as never;
expect(() => encodeState(state)).toThrow(/finite number/);
for (const invalid of [NaN, Infinity, -Infinity]) {
state.project.flooring.length = invalid;
expect(() => encodeState(state)).toThrow(/finite number/);
} }
expectInvalid(payload({ ...state, version: 99 }), /version is unsupported/);
expectInvalid(
payload({ ...state, view: { ...state.view, selectedPiece: 'F1' } }),
/material and an identifier/,
);
expectInvalid(
payload({
...state,
view: { ...state.view, focus: { kind: 'mystery', material: 'flooring', id: 'F1' } },
}),
);
expectInvalid('?plan=%7Bbroken');
expectInvalid('?plan={}&plan={}');
}); });
it('rejects duplicate identities, excessive collections and long text at import', () => {
it.each(['?flooring.pattern=diagonal', '?flooring.optimisation=quick', '?room.1.direction=45'])(
'rejects an invalid current enum in %s',
(query) => expectInvalid(query),
);
it('uses individually editable fields and canonical parameter order', () => {
const state = defaultState(); const state = defaultState();
const params = new URLSearchParams(encodeState(state)); for (const rooms of [
params.set('flooring.length', '1400'); [],
params.set('flooring.maxOffset', '700'); [rectangle(), rectangle()],
params.set('room.1.wall.1.length', '5123.5'); Array.from({ length: PROJECT_LIMITS.rooms + 1 }, (_, i) => rectangle(`room-${i}`)),
params.set('room.1.wall.4.door.1.width', '900'); ])
params.set('skirting.length', '3600'); expectInvalid(payload({ ...state, project: { ...state.project, rooms } }));
params.set('selectedCut', 'F12'); const room = rectangle();
const result = decodeState(`?${params}`); room.walls = Array.from({ length: PROJECT_LIMITS.wallsPerRoom + 1 }, (_, i) => ({
id: `w${i}`,
length: 1,
turn: 90,
doors: [],
}));
expectInvalid(payload({ ...state, project: { ...state.project, rooms: [room] } }));
const longName = rectangle();
longName.name = 'a'.repeat(PROJECT_LIMITS.nameLength + 1);
expectInvalid(payload({ ...state, project: { ...state.project, rooms: [longName] } }));
const doors = rectangle();
doors.walls[0].doors = Array.from({ length: PROJECT_LIMITS.doorsPerWall + 1 }, (_, i) => ({
id: `d${i}`,
offset: 0,
width: 1,
}));
expectInvalid(payload({ ...state, project: { ...state.project, rooms: [doors] } }));
});
it('bounds progress, search and link size', () => {
const state = defaultState();
state.project.completedBoards = Array.from(
{ length: PERSISTENCE_LIMITS.completedBoards + 1 },
(_, i) => `floor:B${i + 1}`,
);
expectInvalid(payload(state));
expectInvalid(
payload({ ...defaultState(), view: { ...defaultState().view, search: 'a'.repeat(201) } }),
);
expectInvalid(`?plan=${'x'.repeat(PERSISTENCE_LIMITS.queryLength)}`, /link is too large/);
expect(() =>
encodeState({
...defaultState(),
view: { ...defaultState().view, search: 'x'.repeat(PERSISTENCE_LIMITS.queryLength) },
}),
).toThrow(/too large/);
});
it('falls back to the first room for stale navigation', () => {
const state = defaultState();
state.view.roomId = 'deleted';
expect(decodeState(payload(state)).state.view.roomId).toBe('room-1');
});
});
describe('version 2 link compatibility', () => {
it('imports measurements, defaults, progress, and explicit locations', () => {
const result = decodeState(
'?version=2&flooring.length=1400&flooring.pattern=random&selectedCut=A2&view=skirting&search=skirting:S6&done=skirting:S6',
);
expect(result.error).toBeNull(); expect(result.error).toBeNull();
expect(result.state.project.flooring.length).toBe(1400); expect(result.state.project.flooring.length).toBe(1400);
expect(result.state.project.rooms[0].walls[0].length).toBe(5123.5); expect(result.state.project.flooring.width).toBe(190);
expect(result.state.project.rooms[0].walls[3].doors[0].width).toBe(900); expect(result.state.project.completedBoards).toEqual(['skirting:S6']);
expect(result.state.project.skirting.stockLength).toBe(3600); expect(result.state.view.selectedPiece).toBe('skirting:A2');
expect(result.state.view.selectedPiece).toBe('F12'); expect(result.state.view.focus).toEqual({ kind: 'board', material: 'skirting', id: 'S6' });
const reversed = new URLSearchParams([...params].reverse()); expect(result.state.view.search).toBe('');
expect(encodeState(decodeState(`?${reversed}`).state)).toBe(encodeState(result.state)); expect(decodeState(encodeState(result.state))).toEqual(result);
}); });
it('imports an unfinished custom room', () => {
it('accepts a hand-written partial query with current defaults', () => {
const result = decodeState('?flooring.length=1400&flooring.pattern=random&selectedCut=F2');
const state = defaultState();
state.project.flooring.length = 1400;
state.project.flooring.pattern = 'random';
state.view.selectedPiece = 'F2';
expect(result).toEqual({ state, error: null });
});
it('can describe an unfinished room without array counts or generated plans', () => {
const result = decodeState( const result = decodeState(
'?room.1.name=Office&room.1.wall.1.length=4000&room.1.wall.1.turn=-90', '?room.1.name=Office&room.1.wall.1.length=4000&room.1.wall.1.turn=-90',
); );
expect(result.error).toBeNull(); expect(result.error).toBeNull();
expect(result.state.project.rooms).toEqual([ expect(result.state.project.rooms[0]).toEqual({
{ id: 'room-1',
id: 'room-1', name: 'Office',
name: 'Office', direction: 0,
direction: 0, walls: [{ id: 'room-1-w1', length: 4000, turn: -90, doors: [] }],
walls: [{ id: 'room-1-w1', length: 4000, turn: -90, doors: [] }], });
},
]);
const encoded = encodeState(result.state);
expect(encoded).not.toMatch(/count|polygon|stocks|pieces|JSON|plan=/);
expect(new URLSearchParams(encoded).has('room.1.wall.2.length')).toBe(false);
});
it('rejects duplicate fields and the retired stock-list field', () => {
expectInvalid('?flooring.length=1200&flooring.length=1400', /only once/);
expectInvalid('?selectedRoom=one&selectedRoom=two', /only once/);
expectInvalid('?room.1.name=Office&room.1.name=Kitchen', /only once/);
expectInvalid('?skirting.length=2400&skirting.length=3600', /only once/);
expectInvalid('?skirting.stock=2400', /retired skirting stock-list/);
});
it('rejects sparse, zero, malformed and excessive indices', () => {
expectInvalid('?room.2.id=second', /consecutive/);
expectInvalid('?room.0.id=zero', /positive whole numbers/);
expectInvalid('?room.one.id=one', /positive whole numbers/);
expectInvalid(`?room.${PROJECT_LIMITS.rooms + 1}.id=excess`, /at most 20/);
expectInvalid(`?room.1.wall.${PROJECT_LIMITS.wallsPerRoom + 1}.length=4000`, /at most 100/);
expectInvalid(
`?room.1.wall.1.length=4000&room.1.wall.1.door.${PROJECT_LIMITS.doorsPerWall + 1}.width=500`,
/at most 30/,
);
});
it('rejects impossible collections and oversized strings before encoding', () => {
const empty = defaultState();
empty.project.rooms = [];
expect(() => encodeState(empty)).toThrow(/at least one room/);
const duplicate = defaultState();
duplicate.project.rooms.push(rectangle());
expect(() => encodeState(duplicate)).toThrow(/duplicate identifiers/);
const rooms = defaultState();
rooms.project.rooms = Array.from({ length: PROJECT_LIMITS.rooms + 1 }, (_, index) =>
rectangle(`room-${index}`),
);
expect(() => encodeState(rooms)).toThrow(/at most 20/);
const walls = defaultState();
walls.project.rooms[0].walls = Array.from(
{ length: PROJECT_LIMITS.wallsPerRoom + 1 },
(_, index) => ({ id: `wall-${index}`, length: 1, turn: 90, doors: [] }),
);
expect(() => encodeState(walls)).toThrow(/at most 100/);
const doors = defaultState();
doors.project.rooms[0].walls[0].doors = Array.from(
{ length: PROJECT_LIMITS.doorsPerWall + 1 },
(_, index) => ({ id: `door-${index}`, offset: index * 10, width: 5 }),
);
expect(() => encodeState(doors)).toThrow(/at most 30/);
const name = defaultState();
name.project.name = 'a'.repeat(PROJECT_LIMITS.nameLength + 1);
expect(() => encodeState(name)).toThrow(/at most 200 characters/);
});
it('bounds progress, search and total query length', () => {
const state = defaultState();
state.project.completedBoards = Array.from(
{ length: PERSISTENCE_LIMITS.completedBoards + 1 },
(_, index) => `floor:B${index + 1}`,
);
expect(() => encodeState(state)).toThrow(/at most 12000/);
expectInvalid(
`?search=${'a'.repeat(PERSISTENCE_LIMITS.searchLength + 1)}`,
/at most 200 characters/,
);
expectInvalid(`?name=${'x'.repeat(PERSISTENCE_LIMITS.queryLength)}`, /link is too large/);
});
it('rejects damaged percent encoding', () => {
expectInvalid('?name=%', /invalid URL encoding/);
expectInvalid('?name=%C0%AF', /invalid URL encoding/);
}); });
it.each([
'?flooring.length=NaN',
'?flooring.length=Infinity',
'?flooring.length=',
'?flooring.length=0x10',
'?flooring.pattern=diagonal',
'?flooring.optimisation=quick',
'?version=1',
'?version=3',
'?room.2.id=second',
'?room.0.id=zero',
'?room.one.id=one',
'?room.21.id=excess',
'?flooring.length=1200&flooring.length=1400',
'?skirting.stock=2400',
'?name=%',
'?name=%C0%AF',
'#plan=old',
])('rejects damaged legacy input %s', (link) => expectInvalid(link));
}); });

View file

@ -17,5 +17,5 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true "noUnusedParameters": true
}, },
"include": ["src", "tests", "vite.config.ts", "playwright.config.ts"] "include": ["src", "tests", "e2e", "vite.config.ts", "playwright.config.ts"]
} }