better
This commit is contained in:
parent
b9485e4097
commit
0c0f08b350
45 changed files with 1565 additions and 1579 deletions
17
README.md
17
README.md
|
|
@ -11,8 +11,7 @@ Use a supported Node.js release (22.12+ or 24+). Open the local URL printed by V
|
|||
|
||||
The home page introduces the planner, a short DIY laminate fitting guide, and the difference
|
||||
between scotia and skirting boards. **Plan my floor** opens the app at `?planner`, resuming the
|
||||
last locally saved project when available. Existing project queries and legacy `#plan=` links
|
||||
still open the planner directly. Both pages work on
|
||||
last locally saved project when available. Current project queries open the planner directly. Both pages work on
|
||||
static hosting without server-side routing. The planner loads only when opened.
|
||||
|
||||
```sh
|
||||
|
|
@ -21,17 +20,21 @@ npx playwright install chromium
|
|||
npm run test:e2e # Desktop Chromium and mobile-sized Chromium
|
||||
npm run build # Strict TypeScript check and production build
|
||||
npm run check # Build, unit tests and browser tests
|
||||
npm run benchmark # Compare cut-first optimisation with the fixed 84-case baseline
|
||||
npm run benchmark -- --random # Compare appearance-first optimisation with the same baseline
|
||||
```
|
||||
|
||||
## Planning workflow
|
||||
|
||||
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.
|
||||
2. Add door openings with a width and a distance from the named starting corner. Each doorway shows the neighbouring wall names and an arrow in the measurement direction. Include architrave/trim in the width where skirting must be omitted. Doors terminate skirting; laminate ends at the room outline with a transition and expansion gap. Rooms are planned independently.
|
||||
3. For a custom outline, the closing-wall suggestion gives the required length and turn. Add it, then replace the suggested length with the actual measured length. An outline must close within the chosen tolerance and have no intersections or invalid doors before a cut plan can be produced.
|
||||
4. Set material dimensions, spacer, kerf and minimum piece sizes alongside the measurements. Kerf means material removed by the blade: a 3 mm cut leaves 600 mm + 397 mm from a 1,000 mm board. Both kerf fields have keyboard and touch tooltips. Set one purchased skirting board length for the whole project. Materials are shared across rooms; direction is per room.
|
||||
5. Room measurements, materials and cut plans open and close independently on the same page. The drawing stays pinned on the right while you scroll the panels; on phones a compact drawing stays above them. Flooring and skirting are visible together, with the whole drawing fitting without an internal scrollbar. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Cut codes sit above the wall and skirting drawing; short pieces use nearby labels with pointers so their names remain visible. The compact drawing header shows only the room, area and material legend. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Flooring and skirting cuts are grouped by the purchased board they come from, including offcuts reused across rooms. Group headings use the cut codes; internal stock IDs stay out of the interface. Pieces wrap side by side when space allows. Each piece has a small preview that opens dimensions directly on its shape. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Numbered skirting pieces and their labels open cut details; uncut lengths use a neutral fill. Print all room drawings followed by flooring and skirting cut sheets grouped by purchased board. Every cut is included once, with its room and row or wall.
|
||||
5. Room measurements, materials and cut plans open and close independently on the same page. The drawing stays pinned on the right while you scroll the panels; on phones a compact drawing stays above them. The **Floorboards / Skirting** switch above the drawing shows one material at a time, with the whole drawing fitting without an internal scrollbar. The choice is saved as `view=flooring` or `view=skirting`. Locating or searching for a cut switches to its material automatically; printing includes both materials. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Cut codes sit above the wall and skirting drawing; short pieces use nearby labels with pointers so their names remain visible. The compact drawing header shows only the room, area and material legend. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Flooring and skirting cuts are grouped by the purchased board they come from, including offcuts reused across rooms. Group headings use the cut codes; internal stock IDs stay out of the interface. Pieces wrap side by side when space allows. Each piece has a small preview that opens dimensions directly on its shape. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Numbered skirting pieces and their labels open cut details; uncut lengths use a neutral fill. Print all room drawings followed by flooring and skirting cut sheets grouped by purchased board. Every cut is included once, with its room and row or wall.
|
||||
6. Find a cut by its code, or search by room name. Searching several cut codes together, such as `F27 + F28`, finds their pieces together. **Show on plan** highlights the source board’s pieces or an individual cut, with room buttons when a source is reused across rooms. A visible **Search active** banner stays above the pinned drawing; **Show all pieces** clears the search there. Source boards have **Mark done** checkboxes; completion marks appear on their list entries, installed pieces and printed sheets. Room search results also let you locate and mark uncut boards without adding cut labels to them. Measurements, wall order, flooring direction or material changes clear completion because the allocation changes; renaming rooms keeps it.
|
||||
7. Copy the link to save or share the complete project. Measurements, materials and view choices live in readable query parameters such as `flooring.length=1200`, `skirting.length=2400`, `room.1.wall.1.length=4800` and `selectedCut=F1`. Older stock-list links retain their first length; their completion marks are cleared because coping blanks and rounded stock allocation have changed, including for single-size links. Progress uses repeated `done=floor:B4` or `done=skirting:S6` parameters; `search=F1` and repeated `collapsed=materials` parameters preserve the search and closed panels. You can edit values directly in the URL. Older compressed fragment links still load and migrate automatically; obsolete step, layer and zoom choices are ignored. Reloading or opening that link reproduces the plan. Browser back/forward restores room changes. The same parameters are saved automatically in localStorage under `floor-planner.project`. Opening the app without URL parameters restores the last saved plan; explicit query or legacy fragment links take precedence and become the saved plan. If localStorage is unavailable or full, URL saving still works. There is no account, API, database or analytics. Fonts are system fonts; the app makes no external font requests.
|
||||
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.
|
||||
|
||||
## Calculation rules and practical limits
|
||||
|
||||
|
|
@ -43,7 +46,8 @@ npm run check # Build, unit tests and browser tests
|
|||
- Offcut reuse preserves the needed left/right factory ends. Only full-width end remnants are available for reuse; ripped side strips and shaped scraps are conservatively waste. Crosscuts include kerf. The stock count is actual allocated boards, not an area-only estimate. Waste includes kerf and unused material, including still-usable stock remainders.
|
||||
- Skirting internal corners use coped joints. The blank keeps the wall-corner long point instead of subtracting the receiving board’s thickness; the cope preparation is half the measured turn. Earlier walls receive adjoining later walls, so installation order has no cycle. At square corners, a 4,000 mm wall keeps a 4,000 mm blank; with 18 mm skirting at both corners its exposed front length is 3,964 mm. Non-square receiving ends have a wall-fitting bevel. External corners use equal mitres. Minimum sizes check the exposed face and installed body after corner and door allowances. Preparation previews and dashed body edges guide marking; the actual moulding profile must be scribed and back-cut, since thickness alone does not define it. This follows [Gary Katz’s coping and long-point measurement guidance](https://www.finehomebuilding.com/pdf/021174063.pdf); the general-angle allowances are derived from intersecting wall faces.
|
||||
- Skirting cut lengths are the longest-point envelope. Cut square blanks first, then prepare the mitres and cope the indicated internal ends; this keeps reusable stock remainders square. Back lengths and diagram positions locate the wall-facing edge. The stock allocation includes crosscut kerf and counts additional bevel and coping operations.
|
||||
- Flooring uses a bounded beam search, minimizing purchased boards first. Random layouts then prefer less visual repetition before fewer saw passes; brick layouts prioritize fewer saw passes. This can add cuts to break a staircase without increasing the selected board count. Skirting compares full-stock, balanced and offcut-aware splits and packs them into the single purchased length, preferring less waste, then fewer cuts. Available offcuts can move the next wall’s joints. Minimum lengths are reserved on the exposed face and full-thickness body of angled and coped pieces. These are deterministic, auditable heuristics rather than guarantees of a global minimum. Room order is an input and can affect shared-offcut allocation.
|
||||
- Random flooring offers **Minimise cuts** and **Maximise randomness**, with the actual cut count and waste percentage beside each choice. Minimise cuts ranks saw passes first, then simpler cuts (fewer shaping passes, then fewer rips), visual repetition and finally purchased boards. Maximise randomness ranks visual repetition first, then the same cutting and waste priorities. The choice is saved as `flooring.optimisation=cuts` or `flooring.optimisation=random`; changing it clears completion marks because stock allocation can change. Brick prioritises cutting effort and then waste.
|
||||
- Flooring uses a bounded beam search that preserves factory-ended offcuts for later exact fits. Completed candidates are repacked, and interchangeable full-width rectangular rows can be reordered without adding cuts. Both options share clipping results, and the best completed candidates are compared before displaying totals. Skirting compares full-stock, balanced and offcut-aware splits, including exact three-piece stock combinations, preferring fewer cuts and then less waste. Available offcuts can move the next wall’s joints. Minimum lengths are reserved on the exposed face and full-thickness body of angled and coped pieces. These are deterministic, auditable heuristics rather than guarantees of a global minimum. Room order remains an input. See [the benchmark definitions and iteration results](benchmarks/README.md).
|
||||
- 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.
|
||||
|
||||
|
|
@ -56,7 +60,8 @@ npm run check # Build, unit tests and browser tests
|
|||
| `src/lib/geometry.ts` | Vector tracing, closure, validation and polygon offsets |
|
||||
| `src/lib/flooring.ts` | Row search, clipping, factory-end-aware stock allocation |
|
||||
| `src/lib/skirting.ts` | Corner allowances, run splitting and stock allocation |
|
||||
| `src/lib/url-state.ts` | Readable query schema, legacy links and input validation |
|
||||
| `src/lib/url-state.ts` | Readable query schema and input validation |
|
||||
| `src/lib/limits.ts` | Shared project, persistence and calculation bounds |
|
||||
| `src/lib/piece-labels.ts` | Collision-free cut-code placement and pointers for small pieces |
|
||||
| `src/lib/board-tracking.ts` | Exact board/cut matching and room-name search |
|
||||
| `src/lib/wall-editing.ts` | Wall insertion and reordering with stable IDs |
|
||||
|
|
|
|||
94
benchmarks/README.md
Normal file
94
benchmarks/README.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Optimiser measurements
|
||||
|
||||
Run `npm run benchmark` for **Minimise cuts**, or `npm run benchmark -- --random` for
|
||||
**Maximise randomness**. Both are compared with the original stock-first optimiser's
|
||||
frozen results in `baseline.json`. The 84 inputs in `cases.ts` cover rectangular
|
||||
remainders, fireplaces in both directions, room-order permutations, tight and exact
|
||||
stagger limits, fractional stock, large floors, and both supplied angled rooms.
|
||||
`--output=/tmp/results.json` saves a run; `--compare=/tmp/results.json` compares
|
||||
against a previous iteration. Do not overwrite the baseline to hide regressions.
|
||||
|
||||
## Fixed metrics
|
||||
|
||||
- **Saw passes:** crosscuts actually required by the allocation, long-edge rips,
|
||||
and additional shaping edges. Skirting includes bevel and coping operations.
|
||||
- **Cut difficulty:** shaping passes, then rip passes, used to break equal saw-count
|
||||
ties. This is an explicit proxy, not an estimate of minutes or tool-specific effort.
|
||||
- **Random appearance:** the existing repetition penalty, evaluated independently
|
||||
from the output rows. It penalises nearby repeating joints, similar consecutive
|
||||
steps and staircases, including steps that wrap across a board length. Raw repeat,
|
||||
similar-step and staircase counts are also reported. A lower score is better.
|
||||
- **Waste:** purchased stock minus installed material, including kerf. The report
|
||||
gives board counts and waste percentages weighted by purchased stock.
|
||||
- **Validity and runtime:** errors, elapsed time, p95 and maximum case time. Tests
|
||||
separately verify coverage, non-overlapping row bands, minimum sizes, stagger,
|
||||
factory ends, rounded stock consumption, cut accounting, references and input purity.
|
||||
|
||||
Minimise cuts orders `(passes, shaping, rips, pattern penalty, boards)`.
|
||||
Maximise randomness orders `(pattern penalty, passes, shaping, rips, boards)`.
|
||||
Validity precedes either objective. Skirting orders `(passes, waste)`.
|
||||
|
||||
## Measured results
|
||||
|
||||
Totals across the same 84 cases, September 2026:
|
||||
|
||||
| Metric | Original | Minimise cuts | Maximise randomness |
|
||||
| ------------------- | -------: | ------------: | ------------------: |
|
||||
| Flooring saw passes | 2,177 | **1,741** | **1,994** |
|
||||
| Shaping passes | 192 | 184 | 185 |
|
||||
| Rip passes | 414 | 402 | 405 |
|
||||
| Pattern penalty | 265 | 1,349 | **113** |
|
||||
| Flooring boards | 3,561 | 3,537 | 3,579 |
|
||||
| Flooring waste | 8.20% | 7.57% | 8.66% |
|
||||
| Invalid cases | 0 | 0 | 0 |
|
||||
|
||||
Minimise cuts saves **20.0%** of saw passes. Maximise randomness reduces the pattern
|
||||
penalty **57.4%**, while also saving **8.4%** of saw passes. For random flooring in the
|
||||
default 4,800 × 3,600 mm room, 38 passes become **24** or **30**, respectively; both options use 76
|
||||
boards and have approximately 1.2% waste.
|
||||
|
||||
Skirting drops from **1,447 to 1,436 passes**. It uses 521 boards instead of 513:
|
||||
cutting effort now takes precedence over waste, which rises from 9.28% to 10.68%.
|
||||
Eight cases improve and 76 tie.
|
||||
For flooring, 78 cut-priority cases improve and six tie. Appearance priority improves
|
||||
72, ties 11, and uses one extra cut in `rectangle-38` despite the same zero pattern
|
||||
penalty. The bounded search does not establish global optimality.
|
||||
|
||||
## Iterations retained
|
||||
|
||||
1. Correct the objective order; measuring stock first contradicted the requested priorities.
|
||||
2. Deduplicate the complete relevant state, including recent joint history. Stop
|
||||
computing keys once the beam is full; share immutable exhausted stocks and avoid
|
||||
searching offcuts for pieces requiring both factory ends.
|
||||
3. Repack completed boundary blanks, reserving exact complementary factory ends
|
||||
first. Preserve the original placement and cutting order within each source board.
|
||||
4. Try bounded swaps of interchangeable rectangular rows. Accept only a lower
|
||||
pattern penalty with unchanged cuts, stock and geometry coverage.
|
||||
5. Polish all surviving completed candidates, retaining the best result.
|
||||
6. Preserve end remnants during search instead of trimming them immediately.
|
||||
This alone improves 56 cut-priority cases and saves another 99 passes after the
|
||||
earlier improvements. Final packing can still combine non-exact blanks.
|
||||
7. When repetition remains, compare an immediate-reuse search as well. This improves
|
||||
five appearance-priority cases. All searches share their clipping cache, and the
|
||||
two UI choices select from the completed results. Selection itself does not rerun
|
||||
the optimiser.
|
||||
8. Compare exact three-piece skirting fills with the original packing. This improves
|
||||
two cases for a small additional calculation cost.
|
||||
|
||||
## Experiments rejected
|
||||
|
||||
Increasing beam widths from 5/2 to 12/4 roughly tripled search time, with regressions
|
||||
in both modes. Carrying two room results into the next room worsened two appearance
|
||||
cases for a single waste improvement. Expanding the offcut candidate pool with
|
||||
distinct remnants had no cut-priority benefit and regressed a large-room result,
|
||||
including after the end-preservation change. These variants were removed.
|
||||
|
||||
The original single-result benchmark took about 1.5 seconds for the corpus. Producing
|
||||
both choices, including the conditional extra appearance search, takes about 3 seconds
|
||||
on the same machine; p95 is around 110 ms and the most complex case around 0.7 seconds.
|
||||
These are indicative timings, not CI thresholds. Ordinary option changes reuse the
|
||||
already calculated plans. A separate 500-row, 2,000-board boundary check completes
|
||||
without an error; temporary stock is repacked before enforcing the board limit.
|
||||
|
||||
Tests enforce the retained aggregate improvements as well as physical invariants.
|
||||
Metric definitions and input cases stay fixed while search strategies change.
|
||||
|
|
@ -54,8 +54,22 @@ export function plannerCases(): PlannerCase[] {
|
|||
add('large-rectangle', [room(14020, 8020)]);
|
||||
add('long-room', [room(30020, 1820)]);
|
||||
cases.push(
|
||||
{ name: 'angled', project: angled.project as Project },
|
||||
{ name: 'angled-f8', project: angledF8.project as Project },
|
||||
{
|
||||
name: 'angled',
|
||||
project: {
|
||||
...angled.project,
|
||||
version: defaultProject().version,
|
||||
flooring: { ...defaultProject().flooring, ...angled.project.flooring },
|
||||
} as Project,
|
||||
},
|
||||
{
|
||||
name: 'angled-f8',
|
||||
project: {
|
||||
...angledF8.project,
|
||||
version: defaultProject().version,
|
||||
flooring: { ...defaultProject().flooring, ...angledF8.project.flooring },
|
||||
} as Project,
|
||||
},
|
||||
);
|
||||
return cases;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,16 @@ test('the home page explains the app and its guides fit the screen', async ({ pa
|
|||
'A great floor starts with a little planning.',
|
||||
);
|
||||
await expect(page.getByRole('heading', { name: 'Scotia or skirting board?' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Plan my floor' })).toHaveCount(2);
|
||||
await expect(page.getByRole('link', { name: 'Plan my floor' })).toHaveCount(1);
|
||||
await expect(page.getByRole('link', { name: 'Plan my floor' }).first()).toBeInViewport();
|
||||
await page.getByRole('link', { name: 'First time fitting a floor?' }).click();
|
||||
await expect(page).toHaveURL(/#guide$/);
|
||||
await expect(page.getByRole('heading', { name: /Your first floor/ })).toBeInViewport();
|
||||
await expect(page.getByRole('link', { name: 'Open planner' })).toBeInViewport();
|
||||
const header = await page.getByRole('banner').boundingBox();
|
||||
const guide = await page.getByRole('heading', { name: /Your first floor/ }).boundingBox();
|
||||
expect(header!.y).toBe(0);
|
||||
expect(guide!.y).toBeGreaterThanOrEqual(header!.height);
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: /Your first floor/ })).toBeInViewport();
|
||||
await page.getByRole('link', { name: 'Back to top' }).click();
|
||||
|
|
@ -26,16 +31,19 @@ test('the home page explains the app and its guides fit the screen', async ({ pa
|
|||
for (const [name, selector] of [
|
||||
['header', '.landing-header'],
|
||||
['hero', '.landing-hero'],
|
||||
['closing', '.landing-final-cta'],
|
||||
]) {
|
||||
test(`the ${name} CTA opens a working planner and browser history returns home`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
if (name === 'header') {
|
||||
await page.getByRole('contentinfo').scrollIntoViewIfNeeded();
|
||||
await expect(page.getByRole('link', { name: 'Open planner' })).toBeInViewport();
|
||||
}
|
||||
await page.locator(selector).locator('a[href="?planner"]').click();
|
||||
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
|
||||
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800');
|
||||
await expect(page).toHaveURL(/\?version=1&/);
|
||||
await expect(page).toHaveURL(/\?version=\d+&/);
|
||||
await page.getByLabel('Room name', { exact: true }).fill('My first floor');
|
||||
const projectUrl = page.url();
|
||||
await page.reload();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import LZString from 'lz-string';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { decodeState, encodeState } from '../src/lib/url-state';
|
||||
import { PROJECT_STORAGE_KEY } from '../src/lib/use-project';
|
||||
|
|
@ -9,6 +8,7 @@ import { planFlooring } from '../src/lib/flooring';
|
|||
import { planSkirting } from '../src/lib/skirting';
|
||||
import { flooringCuts, skirtingCuts, uncutFlooring } from '../src/lib/cut-list';
|
||||
import { cutMm } from '../src/lib/format';
|
||||
import type { UrlState } from '../src/lib/types';
|
||||
|
||||
const errors = new WeakMap<Page, string[]>();
|
||||
const panel = (page: Page, section: string) =>
|
||||
|
|
@ -22,7 +22,7 @@ const skirtCodes = (page: Page) =>
|
|||
skirtEntries(page).getByRole('button', { name: /^Preview skirting cut / });
|
||||
const state = (page: Page) => {
|
||||
const url = new URL(page.url());
|
||||
return decodeState(url.search || url.hash).state;
|
||||
return decodeState(url.search).state;
|
||||
};
|
||||
const savedQuery = (page: Page) =>
|
||||
page.evaluate((key) => localStorage.getItem(key), PROJECT_STORAGE_KEY);
|
||||
|
|
@ -60,6 +60,7 @@ function shortFireplaceFixture() {
|
|||
length: 1200,
|
||||
width: 190,
|
||||
pattern: 'random',
|
||||
optimisation: 'cuts',
|
||||
minOffset: 100,
|
||||
maxOffset: 1000,
|
||||
minLength: 10,
|
||||
|
|
@ -265,7 +266,7 @@ test.beforeEach(async ({ page }) => {
|
|||
page.on('pageerror', (error) => errors.get(page)!.push(error.message));
|
||||
await page.goto('/?planner');
|
||||
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
|
||||
await expect(page).toHaveURL(/\?version=1&/);
|
||||
await expect(page).toHaveURL(/\?version=2&/);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
|
|
@ -345,10 +346,14 @@ test('source-board groups contain every numbered cut once and leave full boards
|
|||
}
|
||||
await expect(page.locator('.material-cuts table')).toHaveCount(0);
|
||||
await expect(preview(page).locator('.floor-piece')).toHaveCount(floor.pieces.length);
|
||||
await expect(preview(page).locator('.skirt-piece')).toHaveCount(skirt.pieces.length);
|
||||
await expect(preview(page).getByRole('button', { name: /^Select flooring cut / })).toHaveCount(
|
||||
cutFloor.length,
|
||||
);
|
||||
await page
|
||||
.getByRole('group', { name: 'Plan view' })
|
||||
.getByRole('button', { name: 'Skirting', exact: true })
|
||||
.click();
|
||||
await expect(preview(page).locator('.skirt-piece')).toHaveCount(skirt.pieces.length);
|
||||
await expect(preview(page).getByRole('button', { name: /^Select skirting cut / })).toHaveCount(
|
||||
cutSkirt.length,
|
||||
);
|
||||
|
|
@ -539,7 +544,7 @@ test('internal skirting corners retain coping blanks and expose the installed mi
|
|||
expect(
|
||||
skirt.pieces.every(
|
||||
(piece) =>
|
||||
Math.abs(piece.length - 2400) < 0.001 && Math.abs(piece.visibleLength! - 2364) < 0.001,
|
||||
Math.abs(piece.length - 2400) < 0.001 && Math.abs(piece.visibleLength - 2364) < 0.001,
|
||||
),
|
||||
).toBe(true);
|
||||
const receiver = skirt.pieces.find((piece) => piece.wallIndex === 0)!;
|
||||
|
|
@ -580,6 +585,7 @@ test('internal skirting corners retain coping blanks and expose the installed mi
|
|||
expect(piece.leftOverlap).toBeCloseTo(18, 3);
|
||||
await page.keyboard.press('Escape');
|
||||
const floorCount = await floorEntries(page).count();
|
||||
await page.getByText('Advanced skirting settings', { exact: true }).click();
|
||||
await page.getByLabel('Minimum piece', { exact: true }).fill('2365');
|
||||
await expect(skirtEntries(page)).toHaveCount(0);
|
||||
await expect(floorEntries(page)).toHaveCount(floorCount);
|
||||
|
|
@ -666,6 +672,7 @@ test('pattern, direction and spacers persist without steps, layers or stored res
|
|||
expect(state(page).project.rooms[0].direction).toBe(90);
|
||||
expect(Object.keys(state(page).view).sort()).toEqual([
|
||||
'collapsedSections',
|
||||
'material',
|
||||
'roomId',
|
||||
'search',
|
||||
'selectedPiece',
|
||||
|
|
@ -713,6 +720,7 @@ test('localStorage restores all project and view parameters when reopening witho
|
|||
selectedPiece: 'F1',
|
||||
search: 'Study',
|
||||
collapsedSections: ['cuts', 'materials'],
|
||||
material: 'flooring',
|
||||
};
|
||||
const query = encodeState(fixture);
|
||||
await page.goto(`/${query}`);
|
||||
|
|
@ -760,7 +768,7 @@ for (const saved of ['damaged data', '?flooring.length=broken']) {
|
|||
[PROJECT_STORAGE_KEY, saved],
|
||||
);
|
||||
await page.goto('/?planner');
|
||||
await expect(page).toHaveURL(/\?version=1&/);
|
||||
await expect(page).toHaveURL(/\?version=2&/);
|
||||
await expect(page.locator('.url-error')).toHaveCount(0);
|
||||
expect(state(page)).toEqual(defaultState());
|
||||
expect(await savedQuery(page)).toBe(encodeState(defaultState()));
|
||||
|
|
@ -869,6 +877,8 @@ test('shared URLs reopen the selected preview and copying the link preserves the
|
|||
});
|
||||
|
||||
test('saw kerf help works with keyboard and touch or mouse', async ({ page, isMobile }) => {
|
||||
await page.getByText('Advanced flooring settings', { exact: true }).click();
|
||||
await page.getByText('Advanced skirting settings', { exact: true }).click();
|
||||
for (const name of ['What is saw kerf?', 'What is skirting saw kerf?']) {
|
||||
const button = page.getByRole('button', { name, exact: true });
|
||||
await button.focus();
|
||||
|
|
@ -904,6 +914,10 @@ test('invalid URLs recover when measurements are edited', async ({ page }) => {
|
|||
});
|
||||
|
||||
test('clicking the skirting code itself opens its cutting dialog', async ({ page }) => {
|
||||
await page
|
||||
.getByRole('group', { name: 'Plan view' })
|
||||
.getByRole('button', { name: 'Skirting', exact: true })
|
||||
.click();
|
||||
for (const target of ['rect', 'text']) {
|
||||
const code = preview(page).locator('.skirting-code').first();
|
||||
const id = await code.evaluate((element) =>
|
||||
|
|
@ -920,18 +934,14 @@ test('clicking the skirting code itself opens its cutting dialog', async ({ page
|
|||
}
|
||||
});
|
||||
|
||||
test('readable query fields, legacy links and browser history preserve the plan', async ({
|
||||
page,
|
||||
}) => {
|
||||
const legacy = defaultState();
|
||||
legacy.project.rooms[0].name = 'Legacy lounge';
|
||||
legacy.project.flooring.pattern = 'random';
|
||||
const hash = `#plan=${LZString.compressToEncodedURIComponent(JSON.stringify(legacy))}`;
|
||||
await page.goto(`/${hash}`);
|
||||
await expect(page).toHaveURL(/\?version=1&/);
|
||||
expect(new URL(page.url()).hash).toBe('');
|
||||
expect(state(page)).toEqual(legacy);
|
||||
expect(await savedQuery(page)).toBe(encodeState(legacy));
|
||||
test('readable query fields and browser history preserve the plan', async ({ page }) => {
|
||||
const initial = defaultState();
|
||||
initial.project.rooms[0].name = 'Current lounge';
|
||||
initial.project.flooring.pattern = 'random';
|
||||
await page.goto(`/${encodeState(initial)}`);
|
||||
await expect(page).toHaveURL(/\?version=2&/);
|
||||
expect(state(page)).toEqual(initial);
|
||||
await expect.poll(() => savedQuery(page)).toBe(encodeState(initial));
|
||||
const original = page.url();
|
||||
await page.getByRole('button', { name: 'Add room', exact: true }).click();
|
||||
await page.getByLabel('Room name', { exact: true }).fill('Study');
|
||||
|
|
@ -945,7 +955,7 @@ test('readable query fields, legacy links and browser history preserve the plan'
|
|||
expect([...params.keys()].some((key) => /boardCount|polygon|pieceIds/.test(key))).toBe(false);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(original);
|
||||
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Legacy lounge');
|
||||
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Current lounge');
|
||||
expect(state(page).project.rooms).toHaveLength(1);
|
||||
expect(await savedQuery(page)).toBe(new URL(original).search);
|
||||
await page.goForward();
|
||||
|
|
@ -961,35 +971,6 @@ test('readable query fields, legacy links and browser history preserve the plan'
|
|||
expect(state(page).project.rooms).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('legacy skirting sizes migrate to one editable length and reset changed allocations', async ({
|
||||
page,
|
||||
}) => {
|
||||
const initial = defaultState();
|
||||
const legacy = {
|
||||
...initial,
|
||||
project: {
|
||||
...initial.project,
|
||||
completedBoards: ['skirting:S1'],
|
||||
skirting: { ...initial.project.skirting, stockLength: undefined, stockLengths: [3600, 2400] },
|
||||
},
|
||||
};
|
||||
await page.goto(`/#plan=${LZString.compressToEncodedURIComponent(JSON.stringify(legacy))}`);
|
||||
await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('3600');
|
||||
expect(state(page).project.skirting.stockLength).toBe(3600);
|
||||
expect(state(page).project.completedBoards).toEqual([]);
|
||||
const params = new URL(page.url()).searchParams;
|
||||
expect(params.get('skirting.length')).toBe('3600');
|
||||
expect(params.has('skirting.stock')).toBe(false);
|
||||
await page.getByLabel('Skirting board length', { exact: true }).fill('3000');
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('3000');
|
||||
await page.goto('/?skirting.stock=2400&skirting.stock=2400&done=skirting:S1');
|
||||
await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('2400');
|
||||
expect(state(page).project.completedBoards).toEqual([]);
|
||||
expect(new URL(page.url()).searchParams.get('skirting.length')).toBe('2400');
|
||||
expect(new URL(page.url()).searchParams.has('skirting.stock')).toBe(false);
|
||||
});
|
||||
|
||||
test('printing includes every room and every cut once, grouped by its source board', async ({
|
||||
page,
|
||||
}) => {
|
||||
|
|
@ -1173,6 +1154,7 @@ test('inserting and reordering walls keeps stable wall IDs and attached doors',
|
|||
if (!isMobile) {
|
||||
const source = page.locator(`.wall-row[data-wall-id="${doorWall.id}"] .wall-drag-handle`);
|
||||
const target = page.locator(`.wall-row[data-wall-id="${originalIds[0]}"]`);
|
||||
await page.locator('.wall-table').evaluate((table) => table.scrollIntoView({ block: 'start' }));
|
||||
await source.dragTo(target);
|
||||
expect(state(page).project.rooms[0].walls[0]).toEqual(doorWall);
|
||||
}
|
||||
|
|
@ -1201,9 +1183,8 @@ test('the supplied angled room keeps W12 lines and labels inside the drawing', a
|
|||
new URL('../tests/fixtures/angled-room.json', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const fixture = decodeState(`#plan=${LZString.compressToEncodedURIComponent(angledRoom)}`);
|
||||
expect(fixture.error).toBeNull();
|
||||
await page.goto(`/${encodeState(fixture.state)}`);
|
||||
const fixture = JSON.parse(angledRoom) as UrlState;
|
||||
await page.goto(`/${encodeState(fixture)}`);
|
||||
await expect(preview(page).locator('.wall-drawing')).toHaveCount(12);
|
||||
for (const width of isMobile ? [320, 390] : [1280]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
|
|
@ -1317,11 +1298,10 @@ test('the supplied F8 room aligns a row seam with its fireplace corner and keeps
|
|||
new URL('../tests/fixtures/angled-room-f8.json', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const fixture = decodeState(`#plan=${LZString.compressToEncodedURIComponent(json)}`);
|
||||
expect(fixture.error).toBeNull();
|
||||
fixture.state.view.selectedPiece = null;
|
||||
fixture.state.view.search = '';
|
||||
await page.goto(`/${encodeState(fixture.state)}`);
|
||||
const fixture = JSON.parse(json) as UrlState;
|
||||
fixture.view.selectedPiece = null;
|
||||
fixture.view.search = '';
|
||||
await page.goto(`/${encodeState(fixture)}`);
|
||||
const { floor, skirt } = plans(page);
|
||||
const corner = { x: 7607.513, y: 2160.34 };
|
||||
const atCorner = floor.pieces.filter((piece) =>
|
||||
|
|
@ -1355,7 +1335,7 @@ test('the supplied F8 room aligns a row seam with its fireplace corner and keeps
|
|||
dimensions.some(({ actual }) => Math.abs(actual * 2 - Math.round(actual * 2)) > 0.001),
|
||||
).toBe(true);
|
||||
expectHalfMillimetreCuts(dimensions);
|
||||
expect(state(page).project.rooms[0].walls).toEqual(fixture.state.project.rooms[0].walls);
|
||||
expect(state(page).project.rooms[0].walls).toEqual(fixture.project.rooms[0].walls);
|
||||
await page.keyboard.press('Escape');
|
||||
const skirtCut = skirtingCuts(skirt).find(
|
||||
(piece) =>
|
||||
|
|
@ -1399,6 +1379,7 @@ test('completed source boards and locate actions follow shared offcuts across ro
|
|||
const sourcePieces = floor.pieces.filter((piece) => piece.stockId === shared.id);
|
||||
const key = `floor:${shared.id}`;
|
||||
const group = page.locator(`.stock-cut-group[data-board-key="${key}"]`);
|
||||
await expect(group).toBeVisible();
|
||||
const groupCodes = await group
|
||||
.locator('.board-cut')
|
||||
.evaluateAll((cuts) => cuts.map((cut) => cut.getAttribute('data-cut-id')).join(' + '));
|
||||
|
|
@ -1464,6 +1445,10 @@ test('completed source boards and locate actions follow shared offcuts across ro
|
|||
.getByRole('checkbox')
|
||||
.check();
|
||||
expect(state(page).project.completedBoards).toEqual([key, skirtKey]);
|
||||
await page
|
||||
.getByRole('group', { name: 'Plan view' })
|
||||
.getByRole('button', { name: 'Skirting', exact: true })
|
||||
.click();
|
||||
expect(await preview(page).locator('.skirt-piece[data-done="true"]').count()).toBeGreaterThan(0);
|
||||
await page.getByLabel('Board width', { exact: true }).fill('201');
|
||||
expect(state(page).project.completedBoards).toEqual([]);
|
||||
|
|
@ -1508,6 +1493,7 @@ test('cuts from one board share the available width and wrap on a narrow phone',
|
|||
.filter({ has: page.locator('.board-cut:nth-child(2)') })
|
||||
.first();
|
||||
const pieces = group.locator('.board-cut');
|
||||
await expect(group).toBeVisible();
|
||||
expect(await pieces.count()).toBeGreaterThan(1);
|
||||
const first = (await pieces.nth(0).boundingBox())!;
|
||||
const second = (await pieces.nth(1).boundingBox())!;
|
||||
|
|
@ -1558,6 +1544,7 @@ for (const width of [320, 390]) {
|
|||
}) => {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await openWalls(page);
|
||||
await page.getByText('Advanced flooring settings', { exact: true }).click();
|
||||
await page.getByRole('button', { name: 'What is saw kerf?', exact: true }).click();
|
||||
await noHorizontalOverflow(page);
|
||||
await expect(page.getByRole('tooltip')).toBeVisible();
|
||||
|
|
|
|||
160
e2e/usability.spec.ts
Normal file
160
e2e/usability.spec.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import { defaultState } from '../src/lib/defaults';
|
||||
import { decodeState, encodeState } from '../src/lib/url-state';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/?planner');
|
||||
await expect(page.getByRole('heading', { name: 'Room measurements', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('new plans start random and guide custom measurements through to the cut plan', async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.getByRole('button', { name: 'Random', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
await expect(page.locator('#custom-shape-help')).toContainText('Click Custom');
|
||||
await page.getByRole('button', { name: 'Custom', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Custom', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
await expect(page.getByLabel('Wall 1 length', { exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Add a wall', exact: true }).click();
|
||||
await page.getByLabel('Wall 2 length', { exact: true }).fill('3000');
|
||||
await page.getByRole('button', { name: 'Add a wall', exact: true }).click();
|
||||
await page.getByLabel('Wall 3 length', { exact: true }).fill('4000');
|
||||
await page.getByRole('button', { name: 'Add closing wall', exact: true }).click();
|
||||
await expect(page.getByText('Outline closed', { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel('Wall 4 length', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Custom', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Materials', exact: true }).click();
|
||||
await expect(page.getByLabel('Board length', { exact: true })).toBeHidden();
|
||||
await page.getByRole('button', { name: 'Materials', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Materials', exact: true })).toBeFocused();
|
||||
await expect(page.getByLabel('Board length', { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel('Saw kerf', { exact: true })).toBeHidden();
|
||||
await page.getByText('Advanced flooring settings', { exact: true }).click();
|
||||
await page.getByLabel('Saw kerf', { exact: true }).fill('4');
|
||||
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();
|
||||
await expect(page.getByRole('button', { name: 'Cut plan', exact: true })).toBeFocused();
|
||||
await expect(page.getByLabel('Find a cut or room', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('the top switch shows one material and preserves inputs and progress across reloads', async ({
|
||||
page,
|
||||
}) => {
|
||||
const preview = page.getByRole('region', { name: 'Floor plan preview' });
|
||||
const toggle = page.getByRole('group', { name: 'Plan view', exact: true });
|
||||
await expect(toggle.getByRole('button', { name: 'Floorboards', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
expect(await preview.locator('.floor-piece').count()).toBeGreaterThan(0);
|
||||
await expect(preview.locator('.skirt-piece')).toHaveCount(0);
|
||||
await page.locator('.stock-cut-group input[type="checkbox"]').first().check();
|
||||
const project = decodeState(new URL(page.url()).search).state.project;
|
||||
await toggle.getByRole('button', { name: 'Skirting', exact: true }).click();
|
||||
await expect(preview.locator('.floor-piece')).toHaveCount(0);
|
||||
expect(await preview.locator('.skirt-piece').count()).toBeGreaterThan(0);
|
||||
await expect(preview.getByText('Flooring cuts', { exact: true })).toHaveCount(0);
|
||||
expect(decodeState(new URL(page.url()).search).state.project).toEqual(project);
|
||||
await page.reload();
|
||||
await expect(toggle.getByRole('button', { name: 'Skirting', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
await expect(preview.locator('.floor-piece')).toHaveCount(0);
|
||||
await page.goto('/?planner');
|
||||
await expect(toggle.getByRole('button', { name: 'Skirting', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
expect(decodeState(new URL(page.url()).search).state.project).toEqual(project);
|
||||
await toggle.getByRole('button', { name: 'Floorboards', exact: true }).click();
|
||||
expect(await preview.locator('.floor-piece').count()).toBeGreaterThan(0);
|
||||
await expect(preview.locator('.skirt-piece')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('saved brick plans keep their pattern and explicit links restore the selected material', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = defaultState();
|
||||
state.project.flooring.pattern = 'brick';
|
||||
state.view.material = 'skirting';
|
||||
await page.goto(`/${encodeState(state)}`);
|
||||
await expect(page.getByRole('button', { name: 'Brick', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
await expect(
|
||||
page
|
||||
.getByRole('group', { name: 'Plan view' })
|
||||
.getByRole('button', { name: 'Skirting', exact: true }),
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.goto('/?planner');
|
||||
await expect(page.getByRole('button', { name: 'Brick', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
test('locating cuts changes material automatically and printing includes both materials', async ({
|
||||
page,
|
||||
}) => {
|
||||
const preview = page.getByRole('region', { name: 'Floor plan preview' });
|
||||
const toggle = page.getByRole('group', { name: 'Plan view', exact: true });
|
||||
await page.locator('.skirting-cuts .board-group-actions .board-locate').first().click();
|
||||
await expect(toggle.getByRole('button', { name: 'Skirting', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
expect(await preview.locator('.skirt-piece[data-highlighted="true"]').count()).toBeGreaterThan(0);
|
||||
await page.getByRole('button', { name: 'Show all pieces', exact: true }).click();
|
||||
await page.getByLabel('Find a cut or room', { exact: true }).fill('F1');
|
||||
await expect(toggle.getByRole('button', { name: 'Floorboards', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
await expect(preview.locator('.floor-piece[data-piece-id="F1"]')).toHaveAttribute(
|
||||
'data-highlighted',
|
||||
'true',
|
||||
);
|
||||
await toggle.getByRole('button', { name: 'Skirting', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Show all pieces', exact: true })).toHaveCount(0);
|
||||
await preview
|
||||
.getByRole('button', { name: /^Select skirting cut / })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
expect(await page.locator('.print-plan .floor-piece').count()).toBeGreaterThan(0);
|
||||
expect(await page.locator('.print-plan .skirt-piece').count()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('setup and the pinned view switch fit a narrow phone', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 844 });
|
||||
await page.getByRole('button', { name: 'Materials', exact: true }).scrollIntoViewIfNeeded();
|
||||
const heading = await page.getByRole('button', { name: 'Materials', exact: true }).boundingBox();
|
||||
const drawing = await page.getByRole('complementary', { name: 'Room drawing' }).boundingBox();
|
||||
expect(heading!.y).toBeGreaterThanOrEqual(drawing!.y + drawing!.height);
|
||||
await page
|
||||
.getByRole('group', { name: 'Plan view' })
|
||||
.getByRole('button', { name: 'Skirting', exact: true })
|
||||
.click();
|
||||
await page.getByText('Advanced flooring settings', { exact: true }).click();
|
||||
await page.getByText('Advanced skirting settings', { exact: true }).click();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320);
|
||||
const toggle = await page.getByRole('group', { name: 'Plan view' }).boundingBox();
|
||||
expect(toggle!.y).toBeGreaterThanOrEqual(0);
|
||||
expect(toggle!.y + toggle!.height).toBeLessThan(844);
|
||||
});
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -11,7 +11,6 @@
|
|||
"@plausible-analytics/tracker": "^0.4.6",
|
||||
"clipper-lib": "^6.4.2",
|
||||
"lucide-react": "^0.468.0",
|
||||
"lz-string": "^1.5.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
|
|
@ -1757,15 +1756,6 @@
|
|||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
"@plausible-analytics/tracker": "^0.4.6",
|
||||
"clipper-lib": "^6.4.2",
|
||||
"lucide-react": "^0.468.0",
|
||||
"lz-string": "^1.5.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const server = await createServer({ server: { middlewareMode: true }, appType: 'custom' });
|
||||
const server = await createServer({
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom',
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
});
|
||||
try {
|
||||
const [{ planFlooring }, { planSkirting }, { plannerCases }, metrics] = await Promise.all([
|
||||
server.ssrLoadModule('/src/lib/flooring.ts'),
|
||||
|
|
@ -65,6 +69,22 @@ try {
|
|||
if (regressions.length) console.log(`${kind} regressions: ${regressions.join(', ')}`);
|
||||
}
|
||||
console.table(summary);
|
||||
for (const kind of ['flooring', 'skirting']) {
|
||||
const wastePercent = (rows) => {
|
||||
const purchased = rows.reduce((total, row) => {
|
||||
const { project } = cases.find((c) => c.name === row.name);
|
||||
const size =
|
||||
kind === 'flooring'
|
||||
? project.flooring.length * project.flooring.width
|
||||
: project.skirting.stockLength;
|
||||
return total + row[kind].boards * size;
|
||||
}, 0);
|
||||
return ((sum(rows, kind, 'waste') / purchased) * 100).toFixed(2);
|
||||
};
|
||||
console.log(
|
||||
`${kind} waste: ${wastePercent(baseline)}% → ${wastePercent(results)}% (weighted by purchased stock)`,
|
||||
);
|
||||
}
|
||||
console.table(
|
||||
['shapingCuts', 'ripCuts', 'patternPenalty', 'repeats', 'similarSteps', 'staircases'].map(
|
||||
(metric) => ({
|
||||
|
|
|
|||
113
src/App.tsx
113
src/App.tsx
|
|
@ -9,8 +9,9 @@ import { planFlooringOptions } from './lib/flooring';
|
|||
import { planSkirting } from './lib/skirting';
|
||||
import { area, mm } from './lib/format';
|
||||
import { flooringCuts, isSkirtingCut, skirtingCuts } from './lib/cut-list';
|
||||
import type { PlannerSection, Project, Room } from './lib/types';
|
||||
import type { PlanMaterial, PlannerSection, Project, Room } from './lib/types';
|
||||
import { boardSearchLabel, findBoardMatches } from './lib/board-tracking';
|
||||
import { PRECISION, PROJECT_LIMITS } from './lib/limits';
|
||||
import { OutlineEditor } from './components/OutlineEditor';
|
||||
import { MaterialsEditor } from './components/MaterialsEditor';
|
||||
import { FloorPlan } from './components/FloorPlan';
|
||||
|
|
@ -22,12 +23,16 @@ import { Issues } from './components/Fields';
|
|||
function PlannerPanel({
|
||||
section,
|
||||
title,
|
||||
step,
|
||||
description,
|
||||
open,
|
||||
toggle,
|
||||
children,
|
||||
}: {
|
||||
section: PlannerSection;
|
||||
title: string;
|
||||
step: number;
|
||||
description: string;
|
||||
open: boolean;
|
||||
toggle: () => void;
|
||||
children: ReactNode;
|
||||
|
|
@ -37,15 +42,22 @@ function PlannerPanel({
|
|||
<h2 className="panel-toggle-heading">
|
||||
<button
|
||||
className="panel-toggle"
|
||||
id={`${section}-toggle`}
|
||||
aria-expanded={open}
|
||||
aria-controls={`${section}-content`}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span>{title}</span>
|
||||
<span className="panel-title">
|
||||
<span className="step-number" aria-hidden="true">
|
||||
{step}
|
||||
</span>
|
||||
{title}
|
||||
</span>
|
||||
<span aria-hidden="true">{open ? '−' : '+'}</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id={`${section}-content`} hidden={!open} className="panel-content">
|
||||
<p className="helper panel-description">{description}</p>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -77,7 +89,7 @@ export default function App() {
|
|||
() => planFlooringOptions(project.rooms, project.flooring, project.tolerance),
|
||||
[project.rooms, flooringInputs, project.tolerance],
|
||||
);
|
||||
const flooring = flooringOptions[project.flooring.optimisation ?? 'cuts'];
|
||||
const flooring = flooringOptions[project.flooring.optimisation];
|
||||
const skirting = useMemo(
|
||||
() => planSkirting(project.rooms, project.skirting, project.tolerance),
|
||||
[project.rooms, project.skirting, project.tolerance],
|
||||
|
|
@ -91,8 +103,8 @@ export default function App() {
|
|||
const wholeBoards = flooring.pieces.filter(
|
||||
(p) =>
|
||||
!p.cut &&
|
||||
Math.abs(p.length - project.flooring.length) < 0.01 &&
|
||||
Math.abs(p.width - project.flooring.width) < 0.01,
|
||||
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;
|
||||
|
||||
|
|
@ -111,13 +123,30 @@ export default function App() {
|
|||
[project, flooring, skirting, view.search],
|
||||
);
|
||||
const searchLabel = boardSearchLabel(project, flooring, skirting, view.search);
|
||||
const visibleMatchCount = (view.material === 'flooring' ? flooring : skirting).pieces.filter(
|
||||
(piece) => matches.pieceIds.includes(piece.id),
|
||||
).length;
|
||||
const materialForPieces = (ids: string[], current: PlanMaterial): PlanMaterial => {
|
||||
const hasFloor = flooring.pieces.some((piece) => ids.includes(piece.id));
|
||||
const hasSkirting = skirting.pieces.some((piece) => ids.includes(piece.id));
|
||||
return hasFloor && !hasSkirting ? 'flooring' : hasSkirting && !hasFloor ? 'skirting' : current;
|
||||
};
|
||||
const setSearch = (search: string) =>
|
||||
update((s) => {
|
||||
const found = findBoardMatches(s.project, flooring, skirting, search);
|
||||
const roomId = found.roomIds.includes(s.view.roomId)
|
||||
? s.view.roomId
|
||||
: (found.roomIds[0] ?? s.view.roomId);
|
||||
return { ...s, view: { ...s.view, search, roomId, selectedPiece: null } };
|
||||
return {
|
||||
...s,
|
||||
view: {
|
||||
...s.view,
|
||||
search,
|
||||
roomId,
|
||||
selectedPiece: null,
|
||||
material: materialForPieces(found.pieceIds, s.view.material),
|
||||
},
|
||||
};
|
||||
});
|
||||
const togglePanel = (section: PlannerSection) =>
|
||||
update((s) => ({
|
||||
|
|
@ -140,13 +169,27 @@ export default function App() {
|
|||
},
|
||||
}));
|
||||
const locatePiece = (id: string, roomId: string) =>
|
||||
update((s) => ({ ...s, view: { ...s.view, search: id, roomId, selectedPiece: null } }));
|
||||
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 },
|
||||
view: {
|
||||
...s.view,
|
||||
roomId: roomId ?? s.view.roomId,
|
||||
selectedPiece: id,
|
||||
material: id ? materialForPieces([id], s.view.material) : s.view.material,
|
||||
},
|
||||
}));
|
||||
const addRoom = () => {
|
||||
update((s) => {
|
||||
|
|
@ -252,7 +295,11 @@ export default function App() {
|
|||
</small>
|
||||
</button>
|
||||
))}
|
||||
<button className="add-room" onClick={addRoom} disabled={project.rooms.length >= 20}>
|
||||
<button
|
||||
className="add-room"
|
||||
onClick={addRoom}
|
||||
disabled={project.rooms.length >= PROJECT_LIMITS.rooms}
|
||||
>
|
||||
<Plus size={17} />
|
||||
Add room
|
||||
</button>
|
||||
|
|
@ -261,9 +308,18 @@ export default function App() {
|
|||
</div>
|
||||
<div className="workspace">
|
||||
<div className="planning-panels">
|
||||
<div className="setup-intro">
|
||||
<h2>Make this plan yours</h2>
|
||||
<p>
|
||||
Measure your room, choose your boards, then review the cuts. Your plan updates as
|
||||
you go.
|
||||
</p>
|
||||
</div>
|
||||
<PlannerPanel
|
||||
section="measurements"
|
||||
title="Room measurements"
|
||||
step={1}
|
||||
description="Choose a starting shape, then replace the example dimensions with your measurements. All sizes are in millimetres."
|
||||
open={!view.collapsedSections.includes('measurements')}
|
||||
toggle={() => togglePanel('measurements')}
|
||||
>
|
||||
|
|
@ -288,6 +344,8 @@ export default function App() {
|
|||
<PlannerPanel
|
||||
section="materials"
|
||||
title="Materials"
|
||||
step={2}
|
||||
description="Enter the sizes of the boards you will buy. Material settings apply to every room; board direction applies to the selected room."
|
||||
open={!view.collapsedSections.includes('materials')}
|
||||
toggle={() => togglePanel('materials')}
|
||||
>
|
||||
|
|
@ -344,6 +402,8 @@ export default function App() {
|
|||
<PlannerPanel
|
||||
section="cuts"
|
||||
title="Cut plan"
|
||||
step={3}
|
||||
description="Review your boards and cuts. Click a numbered piece in the drawing or below for its cutting dimensions."
|
||||
open={!view.collapsedSections.includes('cuts')}
|
||||
toggle={() => togglePanel('cuts')}
|
||||
>
|
||||
|
|
@ -364,6 +424,34 @@ export default function App() {
|
|||
</PlannerPanel>
|
||||
</div>
|
||||
<aside className="drawing-column" id="drawing" aria-label="Room drawing">
|
||||
<div className="plan-view-toolbar">
|
||||
<span id="plan-view-label">Plan view</span>
|
||||
<div
|
||||
className="segmented plan-view-switch"
|
||||
role="group"
|
||||
aria-labelledby="plan-view-label"
|
||||
>
|
||||
{(['flooring', 'skirting'] as const).map((material) => (
|
||||
<button
|
||||
key={material}
|
||||
className={view.material === material ? 'active' : ''}
|
||||
aria-pressed={view.material === material}
|
||||
onClick={() =>
|
||||
update((s) => ({
|
||||
...s,
|
||||
view: { ...s.view, material, selectedPiece: null, search: '' },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<i
|
||||
className={`material-key ${material === 'flooring' ? 'floor-key' : 'skirt-key'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{material === 'flooring' ? 'Floorboards' : 'Skirting'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{view.search.trim() && (
|
||||
<div className="plan-search-status" role="status">
|
||||
<div className="search-mode-heading">
|
||||
|
|
@ -374,9 +462,9 @@ export default function App() {
|
|||
</strong>
|
||||
<p title={searchLabel}>
|
||||
{searchLabel} ·{' '}
|
||||
{matches.pieceIds.length
|
||||
? `${matches.pieceIds.length} piece${matches.pieceIds.length === 1 ? '' : 's'}`
|
||||
: 'No matching pieces'}
|
||||
{visibleMatchCount
|
||||
? `${visibleMatchCount} ${view.material === 'flooring' ? 'floorboard' : 'skirting'} piece${visibleMatchCount === 1 ? '' : 's'}`
|
||||
: 'No matching pieces in this view'}
|
||||
</p>
|
||||
</div>
|
||||
<button className="button clear-plan-search" onClick={() => setSearch('')}>
|
||||
|
|
@ -416,6 +504,7 @@ export default function App() {
|
|||
completedBoards={project.completedBoards}
|
||||
highlightedPieces={matches.pieceIds}
|
||||
searchActive={!!view.search.trim()}
|
||||
material={view.material}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
|
|
|||
16
src/Site.tsx
16
src/Site.tsx
|
|
@ -1,20 +1,18 @@
|
|||
import { lazy, Suspense, useEffect, useSyncExternalStore } from 'react';
|
||||
import { LandingPage } from './components/LandingPage';
|
||||
import { PlannerLoading } from './components/PlannerLoading';
|
||||
|
||||
const Planner = lazy(() => import('./App'));
|
||||
|
||||
function subscribeToLocation(onChange: () => void) {
|
||||
window.addEventListener('popstate', onChange);
|
||||
window.addEventListener('hashchange', onChange);
|
||||
return () => {
|
||||
window.removeEventListener('popstate', onChange);
|
||||
window.removeEventListener('hashchange', onChange);
|
||||
};
|
||||
}
|
||||
|
||||
// Query links work on static hosts and keep all existing shared plans accessible.
|
||||
const isPlannerLocation = () =>
|
||||
Boolean(window.location.search) || window.location.hash.startsWith('#plan=');
|
||||
// Query links work on static hosts without server-side routing.
|
||||
const isPlannerLocation = () => Boolean(window.location.search);
|
||||
|
||||
export default function Site() {
|
||||
const showPlanner = useSyncExternalStore(subscribeToLocation, isPlannerLocation);
|
||||
|
|
@ -24,13 +22,7 @@ export default function Site() {
|
|||
}, [showPlanner]);
|
||||
|
||||
return showPlanner ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<p className="planner-loading" role="status">
|
||||
Opening your planner…
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<PlannerLoading />}>
|
||||
<Planner />
|
||||
</Suspense>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
uncutSkirting,
|
||||
} from '../lib/cut-list';
|
||||
import { boardSearchLabel, findBoardMatches } from '../lib/board-tracking';
|
||||
import { PERSISTENCE_LIMITS } from '../lib/limits';
|
||||
import { FlooringPreview, SkirtingPreview } from './CutPreview';
|
||||
|
||||
function BoardHeader({
|
||||
|
|
@ -120,7 +121,7 @@ export function CutList({
|
|||
id="board-search"
|
||||
type="search"
|
||||
value={boardSearchLabel(project, flooring, skirting, search)}
|
||||
maxLength={200}
|
||||
maxLength={PERSISTENCE_LIMITS.searchLength}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="F12, A2, or a room name"
|
||||
autoComplete="off"
|
||||
|
|
|
|||
|
|
@ -2,14 +2,7 @@ import { Fragment } from 'react';
|
|||
import type { FlooringPiece, SkirtingPiece } from '../lib/types';
|
||||
import { cutMm, degree } from '../lib/format';
|
||||
import { skirtingEndLabel } from '../lib/cut-list';
|
||||
import {
|
||||
dimensionedOutline,
|
||||
flooringTemplate,
|
||||
fullPreviewBox,
|
||||
skirtingEndBoxes,
|
||||
skirtingTemplate,
|
||||
viewBox,
|
||||
} from '../lib/cut-preview';
|
||||
import { dimensionedOutline, fullPreviewBox, skirtingEndBoxes, viewBox } from '../lib/cut-preview';
|
||||
import type { EdgeDimension } from '../lib/cut-preview';
|
||||
|
||||
function Dimensions({ dimensions, font }: { dimensions: EdgeDimension[]; font: number }) {
|
||||
|
|
@ -112,7 +105,7 @@ export function FlooringPreview({
|
|||
piece: FlooringPiece;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const template = flooringTemplate(piece);
|
||||
const template = piece.template;
|
||||
const dimensions = compact
|
||||
? undefined
|
||||
: dimensionedOutline(template, piece.shaped ? undefined : [0, 1], {
|
||||
|
|
@ -159,11 +152,10 @@ export function SkirtingPreview({
|
|||
thickness: number;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const template = skirtingTemplate(piece, thickness);
|
||||
const template = piece.template;
|
||||
const Wrapper = compact ? 'span' : 'div';
|
||||
const EndWrapper = compact ? 'span' : 'figure';
|
||||
const Grid = compact ? 'span' : 'div';
|
||||
if (template.length !== 4) return <span className="helper">Shape template unavailable</span>;
|
||||
const boxes = skirtingEndBoxes(template, thickness, piece.installedTemplate);
|
||||
const dimensions = compact
|
||||
? undefined
|
||||
|
|
@ -186,8 +178,8 @@ export function SkirtingPreview({
|
|||
front: template[3],
|
||||
box: boxes.left,
|
||||
type: piece.leftType,
|
||||
installedBack: piece.installedTemplate?.[0],
|
||||
installedFront: piece.installedTemplate?.[3],
|
||||
installedBack: piece.installedTemplate[0],
|
||||
installedFront: piece.installedTemplate[3],
|
||||
},
|
||||
{
|
||||
side: 'right',
|
||||
|
|
@ -195,8 +187,8 @@ export function SkirtingPreview({
|
|||
front: template[2],
|
||||
box: boxes.right,
|
||||
type: piece.rightType,
|
||||
installedBack: piece.installedTemplate?.[1],
|
||||
installedFront: piece.installedTemplate?.[2],
|
||||
installedBack: piece.installedTemplate[1],
|
||||
installedFront: piece.installedTemplate[2],
|
||||
},
|
||||
] as const;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
import { useId } from 'react';
|
||||
import type { FlooringPlan, Geometry, Point, Room, SkirtingPlan, WallEdge } from '../lib/types';
|
||||
import type {
|
||||
FlooringPlan,
|
||||
Geometry,
|
||||
PlanMaterial,
|
||||
Point,
|
||||
Room,
|
||||
SkirtingPlan,
|
||||
WallEdge,
|
||||
} from '../lib/types';
|
||||
import { area, cutMm, mm } from '../lib/format';
|
||||
import { signedArea } from '../lib/geometry';
|
||||
import { isSkirtingCut } from '../lib/cut-list';
|
||||
import { skirtingPolygon } from '../lib/skirting-polygon';
|
||||
import { dimensionLabels, wallDimensionBox } from '../lib/dimension-labels';
|
||||
import { floorPieceLabels } from '../lib/piece-labels';
|
||||
import { PRECISION } from '../lib/limits';
|
||||
import type { DimensionLabel, LabelBox } from '../lib/dimension-labels';
|
||||
|
||||
const points = (path: Point[]) => path.map((p) => `${p.x},${p.y}`).join(' ');
|
||||
|
|
@ -61,7 +70,10 @@ function Wall({ edge, span, label }: { edge: WallEdge; span: number; label: Dime
|
|||
</text>
|
||||
</g>
|
||||
{edge.doors
|
||||
.filter((d) => d.offset >= 0 && d.width > 0 && d.offset + d.width <= edge.length + 0.001)
|
||||
.filter(
|
||||
(d) =>
|
||||
d.offset >= 0 && d.width > 0 && d.offset + d.width <= edge.length + PRECISION.clipping,
|
||||
)
|
||||
.map((door) => {
|
||||
const a = {
|
||||
x: edge.start.x + (dx / length) * door.offset,
|
||||
|
|
@ -116,6 +128,7 @@ interface Props {
|
|||
completedBoards?: string[];
|
||||
highlightedPieces?: string[];
|
||||
searchActive?: boolean;
|
||||
material?: PlanMaterial;
|
||||
}
|
||||
|
||||
export function FloorPlan({
|
||||
|
|
@ -129,6 +142,7 @@ export function FloorPlan({
|
|||
completedBoards = [],
|
||||
highlightedPieces = [],
|
||||
searchActive = false,
|
||||
material,
|
||||
}: Props) {
|
||||
const hatch = useId().replaceAll(':', '');
|
||||
const allPoints = geometry.edges.flatMap((edge) => [edge.start, edge.end]);
|
||||
|
|
@ -139,8 +153,11 @@ export function FloorPlan({
|
|||
const span = Math.max(maxX - minX, maxY - minY, 1000),
|
||||
font = span * 0.024;
|
||||
const orientation = Math.sign(signedArea(geometry.vertices)) || 1;
|
||||
const floorPieces = flooring.pieces.filter((p) => p.roomId === room.id);
|
||||
const skirtPieces = skirting.pieces.filter((p) => p.roomId === room.id);
|
||||
// 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) {
|
||||
|
|
@ -197,20 +214,25 @@ export function FloorPlan({
|
|||
className="drawing-card"
|
||||
aria-label="Floor plan preview"
|
||||
data-search-active={searchActive}
|
||||
data-view={material ?? 'both'}
|
||||
>
|
||||
<div className="drawing-header">
|
||||
<h2>
|
||||
{room.name} <small>{area(geometry.area)} m²</small>
|
||||
</h2>
|
||||
<div className="drawing-legend">
|
||||
{material !== 'skirting' && (
|
||||
<span>
|
||||
<i className="floor-key" />
|
||||
Flooring cuts
|
||||
</span>
|
||||
)}
|
||||
{material !== 'flooring' && (
|
||||
<span>
|
||||
<i className="skirt-key" />
|
||||
Skirting cuts
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
<i className="uncut-key" />
|
||||
Uncut
|
||||
|
|
@ -331,8 +353,7 @@ export function FloorPlan({
|
|||
const done = completedBoards.includes(`skirting:${piece.stockId}`);
|
||||
const highlighted = highlightedPieces.includes(piece.id);
|
||||
// Each template uses its true wall-facing and room-facing edges, including mitres.
|
||||
const thickness = piece.template ? Math.max(...piece.template.map((p) => p.y)) : 0;
|
||||
const polygon = skirtingPolygon(piece, thickness, orientation);
|
||||
const polygon = skirtingPolygon(piece, orientation);
|
||||
return (
|
||||
<g
|
||||
key={piece.id}
|
||||
|
|
|
|||
|
|
@ -24,12 +24,7 @@ function PlannerLink({ compact = false }: { compact?: boolean }) {
|
|||
function PlanIllustration() {
|
||||
return (
|
||||
<figure className="landing-plan">
|
||||
<figcaption className="landing-plan-heading">
|
||||
<span>
|
||||
<span className="landing-status-dot" /> A little planning goes a long way.
|
||||
</span>
|
||||
<span className="landing-plan-index">FIG. 01</span>
|
||||
</figcaption>
|
||||
<figcaption className="landing-plan-heading">Example floor layout</figcaption>
|
||||
<div className="landing-plan-paper">
|
||||
<div className="landing-plan-room">
|
||||
<span>Living room</span>
|
||||
|
|
@ -55,7 +50,7 @@ function PlanIllustration() {
|
|||
<path d="M80 48V23M460 48V23M80 32H460M75 37L85 27M455 37L465 27" />
|
||||
<path d="M62 57H37M62 343H37M46 57V343M41 62L51 52M41 348L51 338" />
|
||||
</g>
|
||||
<g fill="#55685d" fontSize="12" fontFamily="monospace" textAnchor="middle">
|
||||
<g fill="#55685d" textAnchor="middle">
|
||||
<rect x="224" y="21" width="92" height="22" fill="#f6f7f2" />
|
||||
<text x="270" y="36">
|
||||
4,800 mm
|
||||
|
|
@ -104,7 +99,7 @@ function PlanIllustration() {
|
|||
<rect x="91" y="112" width="72" height="22" fill="#c66935" stroke="#a74617" />
|
||||
<rect x="403" y="178" width="46" height="22" fill="#c66935" stroke="#a74617" />
|
||||
</g>
|
||||
<g fontSize="11" fontFamily="monospace" fill="#fff" textAnchor="middle">
|
||||
<g fill="#fff" textAnchor="middle">
|
||||
<text x="127" y="127">
|
||||
F1
|
||||
</text>
|
||||
|
|
@ -113,11 +108,11 @@ function PlanIllustration() {
|
|||
</text>
|
||||
</g>
|
||||
<rect x="226" y="177" width="114" height="48" rx="2" fill="#fffdf7" stroke="#b49a72" />
|
||||
<text x="283" y="198" fontSize="13" fontWeight="600" fill="#17263f" textAnchor="middle">
|
||||
<text x="283" y="198" fill="#17263f" textAnchor="middle">
|
||||
Living room
|
||||
</text>
|
||||
<text x="283" y="214" fontSize="10" letterSpacing="1" fill="#58655e" textAnchor="middle">
|
||||
EXAMPLE LAYOUT
|
||||
<text x="283" y="214" fill="#58655e" textAnchor="middle">
|
||||
Example layout
|
||||
</text>
|
||||
<path d="M79 257V317" stroke="#f6f7f2" strokeWidth="15" />
|
||||
<path
|
||||
|
|
@ -127,8 +122,8 @@ function PlanIllustration() {
|
|||
strokeWidth="1.2"
|
||||
/>
|
||||
<path d="M471 321H496M490 315L496 321L490 327" fill="none" stroke="#748279" />
|
||||
<text x="470" y="307" fontSize="10" fontFamily="monospace" fill="#55685d">
|
||||
LAY
|
||||
<text x="470" y="307" fill="#55685d">
|
||||
Lay
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
|
@ -179,11 +174,11 @@ function TrimIllustration({ scotia }: { scotia: boolean }) {
|
|||
strokeWidth="1.5"
|
||||
/>
|
||||
<path d="M142 143L173 113H225" fill="none" stroke="#a4471c" />
|
||||
<text x="233" y="117" fill="#a4471c" fontSize="13" fontWeight="600">
|
||||
<text x="233" y="117" fill="#a4471c">
|
||||
Scotia bead
|
||||
</text>
|
||||
<path d="M102 85H225" stroke="#7f8e84" />
|
||||
<text x="233" y="89" fill="#526156" fontSize="12">
|
||||
<text x="233" y="89" fill="#526156">
|
||||
Existing skirting
|
||||
</text>
|
||||
</>
|
||||
|
|
@ -196,11 +191,11 @@ function TrimIllustration({ scotia }: { scotia: boolean }) {
|
|||
strokeWidth="1.5"
|
||||
/>
|
||||
<path d="M113 85H225" stroke="#a4471c" />
|
||||
<text x="233" y="89" fill="#a4471c" fontSize="13" fontWeight="600">
|
||||
<text x="233" y="89" fill="#a4471c">
|
||||
New skirting
|
||||
</text>
|
||||
<path d="M108 154L175 127H225" fill="none" stroke="#7f8e84" />
|
||||
<text x="233" y="131" fill="#526156" fontSize="12">
|
||||
<text x="233" y="131" fill="#526156">
|
||||
Floor tucks under
|
||||
</text>
|
||||
</>
|
||||
|
|
@ -211,14 +206,14 @@ function TrimIllustration({ scotia }: { scotia: boolean }) {
|
|||
stroke="#526156"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text x="197" y="210" fill="#526156" fontSize="11">
|
||||
<text x="197" y="210" fill="#526156">
|
||||
Expansion gap
|
||||
</text>
|
||||
<text x="48" y="15" fill="#526156" fontSize="10" letterSpacing="1">
|
||||
WALL
|
||||
<text x="48" y="15" fill="#526156">
|
||||
Wall
|
||||
</text>
|
||||
<text x="278" y="151" fill="#526156" fontSize="10" letterSpacing="1">
|
||||
FLOORING
|
||||
<text x="278" y="151" fill="#526156">
|
||||
Flooring
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
|
|
@ -267,38 +262,24 @@ export function LandingPage() {
|
|||
<a className="landing-skip" href="#main">
|
||||
Skip to content
|
||||
</a>
|
||||
<header className="landing-header landing-container">
|
||||
<header className="landing-header">
|
||||
<div className="landing-header-content landing-container">
|
||||
<a
|
||||
className="landing-brand"
|
||||
href={window.location.pathname}
|
||||
aria-label="Floor planner home"
|
||||
>
|
||||
<span className="square-mark" aria-hidden="true" />
|
||||
<span>
|
||||
Floor planner<span className="landing-brand-dot">.</span>
|
||||
</span>
|
||||
<span>Floor planner</span>
|
||||
</a>
|
||||
<nav className="landing-nav" aria-label="Main navigation">
|
||||
<a href="#why">Why plan?</a>
|
||||
<a href="#guide">DIY guide</a>
|
||||
<a href="#finishing">The finishing touches</a>
|
||||
</nav>
|
||||
<PlannerLink compact />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section className="landing-hero landing-container" aria-labelledby="landing-title">
|
||||
<div className="landing-hero-copy">
|
||||
<p className="landing-eyebrow">
|
||||
<span /> MEASURE. PLAN. LAY.
|
||||
</p>
|
||||
<h1 id="landing-title">
|
||||
A great floor
|
||||
<br />
|
||||
starts with
|
||||
<br />
|
||||
<em>a little planning.</em>
|
||||
</h1>
|
||||
<h1 id="landing-title">A great floor starts with a little planning.</h1>
|
||||
<p className="landing-intro">
|
||||
From room measurements to the last cut. Plan your floorboards and skirting, make the
|
||||
most of your materials, and start your DIY project with confidence.
|
||||
|
|
@ -309,10 +290,6 @@ export function LandingPage() {
|
|||
First time fitting a floor? <ArrowDown size={16} aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
<p className="landing-reassurance">
|
||||
<Check size={14} aria-hidden="true" /> Free to use <span>·</span> No sign-up{' '}
|
||||
<span>·</span> Right in your browser
|
||||
</p>
|
||||
</div>
|
||||
<PlanIllustration />
|
||||
</section>
|
||||
|
|
@ -322,16 +299,12 @@ export function LandingPage() {
|
|||
id="why"
|
||||
aria-labelledby="benefits-title"
|
||||
>
|
||||
<div className="landing-section-kicker">
|
||||
<span className="landing-eyebrow">LESS GUESSWORK. MORE GETTING IT DONE.</span>
|
||||
<h2 id="benefits-title">Your tape measure’s new best friend.</h2>
|
||||
</div>
|
||||
<h2 id="benefits-title">Why plan your floor?</h2>
|
||||
<div className="landing-benefit-grid">
|
||||
{benefits.map(({ icon: Icon, title, text }, index) => (
|
||||
{benefits.map(({ icon: Icon, title, text }) => (
|
||||
<article className="landing-benefit" key={title}>
|
||||
<div className="landing-benefit-top">
|
||||
<Icon size={24} strokeWidth={1.5} aria-hidden="true" />
|
||||
<span>0{index + 1}</span>
|
||||
</div>
|
||||
<h3>{title}</h3>
|
||||
<p>{text}</p>
|
||||
|
|
@ -343,12 +316,7 @@ export function LandingPage() {
|
|||
<section className="landing-guide" id="guide" aria-labelledby="guide-title">
|
||||
<div className="landing-container landing-guide-layout">
|
||||
<div className="landing-guide-intro">
|
||||
<p className="landing-eyebrow">THE DIY STARTING POINT</p>
|
||||
<h2 id="guide-title">
|
||||
Your first floor?
|
||||
<br />
|
||||
You’ve got this.
|
||||
</h2>
|
||||
<h2 id="guide-title">Your first floor? You’ve got this.</h2>
|
||||
<p>
|
||||
A little patience, a good plan, and one row at a time. Here’s the journey for a
|
||||
floating click-fit laminate floor.
|
||||
|
|
@ -374,7 +342,7 @@ export function LandingPage() {
|
|||
<ol className="landing-steps">
|
||||
{steps.map(({ title, text }, index) => (
|
||||
<li key={title}>
|
||||
<span className="landing-step-number">0{index + 1}</span>
|
||||
<span className="landing-step-number">{index + 1}.</span>
|
||||
<div>
|
||||
<h3>{title}</h3>
|
||||
<p>{text}</p>
|
||||
|
|
@ -397,10 +365,7 @@ export function LandingPage() {
|
|||
aria-labelledby="finishing-title"
|
||||
>
|
||||
<div className="landing-finishing-heading">
|
||||
<div>
|
||||
<p className="landing-eyebrow">SMALL DETAILS. A PROPER FINISH.</p>
|
||||
<h2 id="finishing-title">Scotia or skirting board?</h2>
|
||||
</div>
|
||||
<p>
|
||||
Both can hide the expansion gap around a floating floor. The difference is what you’re
|
||||
keeping, and the finish you want.
|
||||
|
|
@ -408,15 +373,10 @@ export function LandingPage() {
|
|||
</div>
|
||||
<div className="landing-trim-grid">
|
||||
<article className="landing-trim-card">
|
||||
<div className="landing-trim-label">
|
||||
<span>KEEP YOUR EXISTING SKIRTING</span>
|
||||
<span>01</span>
|
||||
</div>
|
||||
<p className="landing-trim-label">Keep your existing skirting</p>
|
||||
<TrimIllustration scotia />
|
||||
<div className="landing-trim-copy">
|
||||
<h3>
|
||||
Scotia <span>the small finishing bead</span>
|
||||
</h3>
|
||||
<h3>Scotia</h3>
|
||||
<p>
|
||||
A narrow, curved moulding fitted at the foot of your existing skirting. It covers
|
||||
the flooring’s expansion gap without removing the boards already on your wall.
|
||||
|
|
@ -428,18 +388,14 @@ export function LandingPage() {
|
|||
</div>
|
||||
</article>
|
||||
<article className="landing-trim-card">
|
||||
<div className="landing-trim-label">
|
||||
<span>REPLACE OR REFIT YOUR SKIRTING</span>
|
||||
<span>02</span>
|
||||
</div>
|
||||
<p className="landing-trim-label">Replace or refit your skirting</p>
|
||||
<TrimIllustration scotia={false} />
|
||||
<div className="landing-trim-copy">
|
||||
<h3>
|
||||
Skirting board <span>also called a baseboard</span>
|
||||
</h3>
|
||||
<h3>Skirting board</h3>
|
||||
<p>
|
||||
The taller board along the base of the wall. When fitted after the flooring, it
|
||||
can cover the expansion gap itself, giving you a clean edge without an extra bead.
|
||||
Also called a baseboard, this is the taller board along the base of the wall. When
|
||||
fitted after the flooring, it can cover the expansion gap itself, giving you a
|
||||
clean edge without an extra bead.
|
||||
</p>
|
||||
<p className="landing-trim-choice">
|
||||
<Check size={16} aria-hidden="true" /> A neat finish when you’re renovating the
|
||||
|
|
@ -460,30 +416,10 @@ export function LandingPage() {
|
|||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="landing-final-cta landing-container" aria-labelledby="ready-title">
|
||||
<div>
|
||||
<p className="landing-eyebrow">YOUR NEXT PROJECT STARTS HERE</p>
|
||||
<h2 id="ready-title">
|
||||
Measure twice.
|
||||
<br />
|
||||
<em>Start with a plan.</em>
|
||||
</h2>
|
||||
<p>Bring your room measurements. We’ll help with the layout.</p>
|
||||
</div>
|
||||
<div className="landing-final-actions">
|
||||
<PlannerLink />
|
||||
<span>Open the planner. Make it your room.</span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="landing-footer landing-container">
|
||||
<a className="landing-brand" href={window.location.pathname}>
|
||||
<span className="square-mark" aria-hidden="true" />
|
||||
<span>Floor planner.</span>
|
||||
</a>
|
||||
<p>A little preparation for a floor you’ll be proud of.</p>
|
||||
<span>Floor planner</span>
|
||||
<a href="#main">Back to top ↑</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ export function MaterialsEditor({
|
|||
<h3 id="flooring-materials" className="material-heading">
|
||||
Floorboards
|
||||
</h3>
|
||||
<p className="helper material-help">
|
||||
Use the length and width printed on your flooring pack.
|
||||
</p>
|
||||
<div className="field-grid">
|
||||
<NumberField
|
||||
label="Board length"
|
||||
|
|
@ -56,13 +59,6 @@ export function MaterialsEditor({
|
|||
<div className="field">
|
||||
<span>Laying pattern</span>
|
||||
<div className="segmented" role="group" aria-label="Laying pattern">
|
||||
<button
|
||||
className={f.pattern === 'brick' ? 'active' : ''}
|
||||
aria-pressed={f.pattern === 'brick'}
|
||||
onClick={() => floor('pattern', 'brick')}
|
||||
>
|
||||
Brick
|
||||
</button>
|
||||
<button
|
||||
className={f.pattern === 'random' ? 'active' : ''}
|
||||
aria-pressed={f.pattern === 'random'}
|
||||
|
|
@ -70,16 +66,28 @@ export function MaterialsEditor({
|
|||
>
|
||||
Random
|
||||
</button>
|
||||
<button
|
||||
className={f.pattern === 'brick' ? 'active' : ''}
|
||||
aria-pressed={f.pattern === 'brick'}
|
||||
onClick={() => floor('pattern', 'brick')}
|
||||
>
|
||||
Brick
|
||||
</button>
|
||||
</div>
|
||||
<p className="helper">
|
||||
{f.pattern === 'random'
|
||||
? 'Varied joints for a natural look. Choose fewer cuts or a more varied arrangement below.'
|
||||
: 'A regular pattern with each row staggered by half a board.'}
|
||||
</p>
|
||||
</div>
|
||||
{f.pattern === 'random' && (
|
||||
<div className="field">
|
||||
<span>Optimise random flooring · all rooms</span>
|
||||
<span>What matters most? · all rooms</span>
|
||||
<div className="optimisation-options" role="group" aria-label="Flooring optimisation">
|
||||
{(['cuts', 'random'] as const).map((priority) => {
|
||||
const plan = flooringOptions[priority];
|
||||
const valid = !plan.issues.some((issue) => issue.severity === 'error');
|
||||
const selected = (f.optimisation ?? 'cuts') === priority;
|
||||
const selected = f.optimisation === priority;
|
||||
return (
|
||||
<button
|
||||
key={priority}
|
||||
|
|
@ -119,8 +127,18 @@ export function MaterialsEditor({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<NumberField
|
||||
label="Wall spacer"
|
||||
value={f.gap}
|
||||
onChange={(n) => floor('gap', n)}
|
||||
hint="Expansion gap between the floorboards and walls. Check your flooring instructions."
|
||||
/>
|
||||
<details className="advanced-settings">
|
||||
<summary>Advanced flooring settings</summary>
|
||||
<p className="helper material-help">
|
||||
Adjust the saw blade width, joint spacing and smallest pieces to suit your flooring.
|
||||
</p>
|
||||
<div className="field-grid">
|
||||
<NumberField label="Wall spacer" value={f.gap} onChange={(n) => floor('gap', n)} />
|
||||
<NumberField
|
||||
label="Saw kerf"
|
||||
value={f.kerf}
|
||||
|
|
@ -150,11 +168,15 @@ export function MaterialsEditor({
|
|||
onChange={(n) => floor('minWidth', n)}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
<section className="material-section skirting" aria-labelledby="skirting-materials">
|
||||
<h3 id="skirting-materials" className="material-heading">
|
||||
Skirting boards
|
||||
</h3>
|
||||
<p className="helper material-help">
|
||||
Enter one purchased board length. Door openings are left clear automatically.
|
||||
</p>
|
||||
<div className="field-grid">
|
||||
<NumberField
|
||||
label="Skirting board length"
|
||||
|
|
@ -167,6 +189,10 @@ export function MaterialsEditor({
|
|||
value={s.thickness}
|
||||
onChange={(n) => skirt('thickness', n)}
|
||||
/>
|
||||
</div>
|
||||
<details className="advanced-settings">
|
||||
<summary>Advanced skirting settings</summary>
|
||||
<div className="field-grid">
|
||||
<NumberField
|
||||
label="Minimum piece"
|
||||
value={s.minLength}
|
||||
|
|
@ -180,6 +206,7 @@ export function MaterialsEditor({
|
|||
tooltip={kerfHelp}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { mm } from '../lib/format';
|
|||
import { NumberField, NumericInput, Issues } from './Fields';
|
||||
import { DoorPosition } from './DoorPosition';
|
||||
import { insertWallAfter, moveWall } from '../lib/wall-editing';
|
||||
import { PRECISION, PROJECT_LIMITS } from '../lib/limits';
|
||||
interface Props {
|
||||
room: Room;
|
||||
geometry: Geometry;
|
||||
|
|
@ -36,6 +37,7 @@ export function OutlineEditor({
|
|||
}: Props) {
|
||||
const draggedWall = useRef<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const [customSelected, setCustomSelected] = useState(false);
|
||||
const changeWall = (id: string, fn: (wall: Wall) => Wall) =>
|
||||
changeRoom((r) => ({ ...r, walls: r.walls.map((w) => (w.id === id ? fn(w) : w)) }));
|
||||
const isRectangle =
|
||||
|
|
@ -43,6 +45,10 @@ export function OutlineEditor({
|
|||
room.walls.every((w) => w.turn === 90) &&
|
||||
room.walls[0].length === room.walls[2].length &&
|
||||
room.walls[1].length === room.walls[3].length;
|
||||
const isFireplace =
|
||||
room.walls.length === 8 &&
|
||||
room.walls.every((wall, index) => wall.turn === [90, 90, 90, -90, -90, 90, 90, 90][index]);
|
||||
const isCustom = customSelected || (!isRectangle && !isFireplace);
|
||||
const lastEdge = geometry.edges.at(-1);
|
||||
const finalExpectedLength = lastEdge ? Math.hypot(lastEdge.start.x, lastEdge.start.y) : 0;
|
||||
const addWall = () =>
|
||||
|
|
@ -106,41 +112,72 @@ export function OutlineEditor({
|
|||
<span>Room name</span>
|
||||
<input
|
||||
value={room.name}
|
||||
maxLength={80}
|
||||
maxLength={PROJECT_LIMITS.nameLength}
|
||||
onChange={(e) => changeRoom((r) => ({ ...r, name: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<div className="template-options">
|
||||
<div className="subheading shape-heading">
|
||||
<h3>Room shape</h3>
|
||||
</div>
|
||||
<div
|
||||
className="template-options"
|
||||
role="group"
|
||||
aria-label="Room shape"
|
||||
aria-describedby="custom-shape-help"
|
||||
>
|
||||
<button
|
||||
className={isRectangle ? 'template active' : 'template'}
|
||||
aria-pressed={isRectangle}
|
||||
onClick={() => changeRoom((r) => rectangle(r.id, r.name))}
|
||||
className={isRectangle && !isCustom ? 'template active' : 'template'}
|
||||
aria-pressed={isRectangle && !isCustom}
|
||||
onClick={() => {
|
||||
setCustomSelected(false);
|
||||
changeRoom((r) => rectangle(r.id, r.name));
|
||||
}}
|
||||
>
|
||||
<span className="rectangle-icon" />
|
||||
Rectangle
|
||||
</button>
|
||||
<button
|
||||
className={room.walls.length === 8 ? 'template active' : 'template'}
|
||||
aria-pressed={room.walls.length === 8}
|
||||
onClick={() => changeRoom((r) => fireplace(r.id, r.name))}
|
||||
className={isFireplace && !isCustom ? 'template active' : 'template'}
|
||||
aria-pressed={isFireplace && !isCustom}
|
||||
onClick={() => {
|
||||
setCustomSelected(false);
|
||||
changeRoom((r) => fireplace(r.id, r.name));
|
||||
}}
|
||||
>
|
||||
<span className="fireplace-icon" />
|
||||
With fireplace
|
||||
</button>
|
||||
<button
|
||||
className="template"
|
||||
onClick={() =>
|
||||
className={isCustom ? 'template active' : 'template'}
|
||||
aria-pressed={isCustom}
|
||||
onClick={() => {
|
||||
setCustomSelected(true);
|
||||
changeRoom((r) => ({
|
||||
...r,
|
||||
walls: [{ id: `${r.id}-w1`, length: 4000, turn: 90, doors: [] }],
|
||||
}))
|
||||
}
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<MoveUpRight size={19} />
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
{isRectangle && (
|
||||
<p className="shape-help" id="custom-shape-help">
|
||||
{isCustom && geometry.closed ? (
|
||||
<>Your outline is complete. Adjust individual walls, corners or doorways below.</>
|
||||
) : isCustom ? (
|
||||
<>
|
||||
Add your walls in order below, then use <strong>Add closing wall</strong> to complete
|
||||
the outline.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Have an irregular room? Click <strong>Custom</strong> to start a new outline and enter
|
||||
each wall and corner. Choosing a shape replaces the current outline and doorways.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{isRectangle && !isCustom && (
|
||||
<div className="field-grid quick-dimensions">
|
||||
<NumberField
|
||||
label="Room width"
|
||||
|
|
@ -166,7 +203,7 @@ export function OutlineEditor({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
<details className="wall-details" open={!isRectangle}>
|
||||
<details className="wall-details" open={isCustom || !isRectangle}>
|
||||
<summary>
|
||||
Walls & corners <span>{room.walls.length} walls</span>
|
||||
</summary>
|
||||
|
|
@ -270,7 +307,7 @@ export function OutlineEditor({
|
|||
className="icon-button"
|
||||
aria-label={`Insert wall after W${i + 1}`}
|
||||
title={`Insert wall after W${i + 1}`}
|
||||
disabled={room.walls.length >= 100}
|
||||
disabled={room.walls.length >= PROJECT_LIMITS.wallsPerRoom}
|
||||
onClick={() => changeRoom((r) => insertWallAfter(r, wall.id))}
|
||||
>
|
||||
<Plus size={16} />
|
||||
|
|
@ -289,7 +326,11 @@ export function OutlineEditor({
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="text-button" onClick={addWall} disabled={room.walls.length >= 100}>
|
||||
<button
|
||||
className="text-button"
|
||||
onClick={addWall}
|
||||
disabled={room.walls.length >= PROJECT_LIMITS.wallsPerRoom}
|
||||
>
|
||||
<Plus size={15} /> Add a wall
|
||||
</button>
|
||||
</details>
|
||||
|
|
@ -317,7 +358,10 @@ export function OutlineEditor({
|
|||
<button
|
||||
className="text-button"
|
||||
onClick={closeOutline}
|
||||
disabled={room.walls.length >= 100 || Math.abs(geometry.closingTurn) >= 179.99}
|
||||
disabled={
|
||||
room.walls.length >= PROJECT_LIMITS.wallsPerRoom ||
|
||||
Math.abs(geometry.closingTurn) >= PROJECT_LIMITS.maxTurn - PRECISION.millimetres
|
||||
}
|
||||
>
|
||||
Add closing wall <Plus size={14} />
|
||||
</button>
|
||||
|
|
@ -419,7 +463,9 @@ export function OutlineEditor({
|
|||
<button
|
||||
className="text-button"
|
||||
onClick={addDoor}
|
||||
disabled={!room.walls.length || room.walls.at(-1)!.doors.length >= 30}
|
||||
disabled={
|
||||
!room.walls.length || room.walls.at(-1)!.doors.length >= PROJECT_LIMITS.doorsPerWall
|
||||
}
|
||||
>
|
||||
<Plus size={15} /> Add a doorway
|
||||
</button>
|
||||
|
|
@ -427,7 +473,7 @@ export function OutlineEditor({
|
|||
<NumberField
|
||||
label="Measurement tolerance"
|
||||
value={tolerance}
|
||||
max={50}
|
||||
max={PROJECT_LIMITS.maxTolerance}
|
||||
onChange={(n) => changeProject((p) => ({ ...p, tolerance: n }))}
|
||||
hint="Largest closing gap accepted for the cut plan."
|
||||
/>
|
||||
|
|
|
|||
66
src/components/PlannerLoading.css
Normal file
66
src/components/PlannerLoading.css
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
.planner-loading {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: 32px;
|
||||
background: #fdfcf7;
|
||||
}
|
||||
.planner-loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.planner-loading-floor {
|
||||
display: block;
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
}
|
||||
.planner-loading-outline {
|
||||
fill: none;
|
||||
stroke: #d9ddd3;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
.planner-loading-row {
|
||||
fill: #d7b68a;
|
||||
opacity: 0.25;
|
||||
animation: planner-lay-boards 2.4s ease-in-out infinite;
|
||||
}
|
||||
.planner-loading-row rect:last-child {
|
||||
fill: #ead9bc;
|
||||
}
|
||||
.planner-loading-row:nth-of-type(2) {
|
||||
animation-delay: 180ms;
|
||||
}
|
||||
.planner-loading-row:nth-of-type(3) {
|
||||
animation-delay: 360ms;
|
||||
}
|
||||
.planner-loading-row:nth-of-type(4) {
|
||||
animation-delay: 540ms;
|
||||
}
|
||||
.planner-loading-row:first-of-type rect:first-child {
|
||||
fill: #ac451d;
|
||||
}
|
||||
@keyframes planner-lay-boards {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
25%,
|
||||
65% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.planner-loading-row {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
35
src/components/PlannerLoading.tsx
Normal file
35
src/components/PlannerLoading.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import './PlannerLoading.css';
|
||||
|
||||
export function PlannerLoading() {
|
||||
return (
|
||||
<main className="planner-loading">
|
||||
<div className="planner-loading-content" role="status" aria-live="polite" aria-atomic="true">
|
||||
<svg
|
||||
className="planner-loading-floor"
|
||||
viewBox="0 0 112 112"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<rect className="planner-loading-outline" x="4" y="4" width="104" height="104" rx="4" />
|
||||
<g className="planner-loading-row">
|
||||
<rect x="14" y="14" width="30" height="18" rx="1" />
|
||||
<rect x="48" y="14" width="50" height="18" rx="1" />
|
||||
</g>
|
||||
<g className="planner-loading-row">
|
||||
<rect x="14" y="36" width="50" height="18" rx="1" />
|
||||
<rect x="68" y="36" width="30" height="18" rx="1" />
|
||||
</g>
|
||||
<g className="planner-loading-row">
|
||||
<rect x="14" y="58" width="30" height="18" rx="1" />
|
||||
<rect x="48" y="58" width="50" height="18" rx="1" />
|
||||
</g>
|
||||
<g className="planner-loading-row">
|
||||
<rect x="14" y="80" width="50" height="18" rx="1" />
|
||||
<rect x="68" y="80" width="30" height="18" rx="1" />
|
||||
</g>
|
||||
</svg>
|
||||
<p>Opening your planner…</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
426
src/landing.css
426
src/landing.css
|
|
@ -2,17 +2,55 @@
|
|||
--landing-muted: #5e665f;
|
||||
--landing-line: #d9ddd3;
|
||||
--landing-accent: #ac451d;
|
||||
--landing-header-height: 80px;
|
||||
--landing-title-size: 48px;
|
||||
--landing-heading-size: 32px;
|
||||
--landing-subheading-size: 18px;
|
||||
--landing-body-size: 16px;
|
||||
--landing-caption-size: 14px;
|
||||
padding-top: var(--landing-header-height);
|
||||
background: #fdfcf7;
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: var(--landing-body-size);
|
||||
line-height: 1.65;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.landing-page h1,
|
||||
.landing-page h2,
|
||||
.landing-page h3 {
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.landing-page h1 {
|
||||
font-size: var(--landing-title-size);
|
||||
}
|
||||
.landing-page h2 {
|
||||
font-size: var(--landing-heading-size);
|
||||
}
|
||||
.landing-page h3 {
|
||||
font-size: var(--landing-subheading-size);
|
||||
}
|
||||
.landing-page p {
|
||||
color: var(--landing-muted);
|
||||
}
|
||||
.landing-page strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
.landing-page svg text {
|
||||
font-family: inherit;
|
||||
font-size: var(--landing-caption-size);
|
||||
}
|
||||
.landing-container {
|
||||
width: min(1180px, calc(100% - 96px));
|
||||
margin-inline: auto;
|
||||
}
|
||||
.landing-page a {
|
||||
color: inherit;
|
||||
scroll-margin-top: calc(var(--landing-header-height) + 24px);
|
||||
}
|
||||
.landing-page a:focus-visible {
|
||||
outline: 3px solid var(--blue);
|
||||
|
|
@ -20,41 +58,38 @@
|
|||
}
|
||||
.landing-page section[id],
|
||||
.landing-page main {
|
||||
scroll-margin-top: 24px;
|
||||
scroll-margin-top: calc(var(--landing-header-height) + 24px);
|
||||
}
|
||||
.landing-header {
|
||||
min-height: 100px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
inset-inline: 0;
|
||||
z-index: 20;
|
||||
height: var(--landing-header-height);
|
||||
background: #fdfcf7;
|
||||
border-bottom: 1px solid var(--landing-line);
|
||||
}
|
||||
.landing-header-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
border-bottom: 1px solid var(--landing-line);
|
||||
}
|
||||
.landing-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 22px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.9px;
|
||||
font-size: var(--landing-subheading-size);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.landing-brand-dot {
|
||||
color: var(--landing-accent);
|
||||
}
|
||||
.landing-nav {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
}
|
||||
.landing-nav a,
|
||||
.landing-footer > a:last-child {
|
||||
.landing-footer a {
|
||||
padding-block: 12px;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.landing-nav a:hover,
|
||||
.landing-footer > a:last-child:hover {
|
||||
.landing-footer a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 5px;
|
||||
}
|
||||
|
|
@ -69,7 +104,7 @@
|
|||
border: 1px solid var(--landing-accent);
|
||||
color: #fffdf7;
|
||||
border-radius: 3px;
|
||||
font-size: 17px;
|
||||
font-size: var(--landing-body-size);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
|
|
@ -83,7 +118,7 @@
|
|||
.landing-page .landing-cta-small {
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-size: var(--landing-caption-size);
|
||||
gap: 18px;
|
||||
}
|
||||
.landing-hero {
|
||||
|
|
@ -93,46 +128,9 @@
|
|||
gap: 48px;
|
||||
padding-block: 77px 80px;
|
||||
}
|
||||
.landing-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font: 10px var(--mono);
|
||||
line-height: 1.6;
|
||||
letter-spacing: 1.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.landing-hero .landing-eyebrow > span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--landing-accent);
|
||||
}
|
||||
.landing-hero h1,
|
||||
.landing-guide h2,
|
||||
.landing-finishing h2,
|
||||
.landing-final-cta h2 {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-weight: 400;
|
||||
letter-spacing: -2.3px;
|
||||
line-height: 1.07;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.landing-hero h1 {
|
||||
margin-top: 23px;
|
||||
font-size: clamp(46px, 4.7vw, 65px);
|
||||
}
|
||||
.landing-hero h1 em,
|
||||
.landing-final-cta h2 em {
|
||||
color: var(--landing-accent);
|
||||
font-weight: 400;
|
||||
}
|
||||
.landing-intro {
|
||||
max-width: 445px;
|
||||
margin-top: 26px;
|
||||
color: var(--landing-muted);
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.landing-hero-actions {
|
||||
display: flex;
|
||||
|
|
@ -146,66 +144,32 @@
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
font-size: 12px;
|
||||
font-size: var(--landing-caption-size);
|
||||
text-underline-offset: 5px;
|
||||
text-decoration-color: #a3aca2;
|
||||
}
|
||||
.landing-reassurance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 19px;
|
||||
color: var(--landing-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.landing-reassurance svg {
|
||||
color: #426751;
|
||||
}
|
||||
.landing-plan {
|
||||
margin: 16px 0 0;
|
||||
border: 1px solid #cbd2c7;
|
||||
background: #f6f7f2;
|
||||
border-radius: 4px;
|
||||
box-shadow: 9px 9px 0 #eaece4;
|
||||
transform: rotate(-1.5deg);
|
||||
}
|
||||
.landing-plan-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid #cbd2c7;
|
||||
font-size: 11px;
|
||||
}
|
||||
.landing-plan-heading > span:first-child {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.landing-status-dot {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
border-radius: 50%;
|
||||
background: #5a7960;
|
||||
}
|
||||
.landing-plan-index {
|
||||
font: 9px var(--mono);
|
||||
color: #687265;
|
||||
white-space: nowrap;
|
||||
font-size: var(--landing-caption-size);
|
||||
font-weight: 600;
|
||||
}
|
||||
.landing-plan-room {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: 4px 12px;
|
||||
padding: 22px 24px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-size: var(--landing-caption-size);
|
||||
}
|
||||
.landing-plan-room > span:last-child {
|
||||
font: 10px var(--mono);
|
||||
color: #657260;
|
||||
color: var(--landing-muted);
|
||||
align-self: center;
|
||||
}
|
||||
.landing-plan-paper > svg {
|
||||
|
|
@ -219,8 +183,8 @@
|
|||
gap: 20px;
|
||||
padding: 17px 24px;
|
||||
border-top: 1px solid #dbe0d5;
|
||||
color: #526156;
|
||||
font-size: 10px;
|
||||
color: var(--landing-muted);
|
||||
font-size: var(--landing-caption-size);
|
||||
}
|
||||
.landing-plan-legend span {
|
||||
display: inline-flex;
|
||||
|
|
@ -246,21 +210,6 @@
|
|||
padding-bottom: 68px;
|
||||
border-top: 1px solid var(--landing-line);
|
||||
}
|
||||
.landing-section-kicker {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 20px;
|
||||
}
|
||||
.landing-section-kicker > .landing-eyebrow {
|
||||
color: var(--landing-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
.landing-section-kicker h2 {
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.landing-benefit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
|
@ -284,22 +233,9 @@
|
|||
margin-bottom: 19px;
|
||||
color: var(--landing-accent);
|
||||
}
|
||||
.landing-benefit-top span {
|
||||
color: #667064;
|
||||
font: 10px var(--mono);
|
||||
}
|
||||
.landing-benefit h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.3px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.landing-benefit p,
|
||||
.landing-trim-copy > p {
|
||||
color: var(--landing-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.landing-guide {
|
||||
padding-block: 70px;
|
||||
background: #eef0e8;
|
||||
|
|
@ -310,24 +246,15 @@
|
|||
grid-template-columns: 0.8fr 1.2fr;
|
||||
gap: 110px;
|
||||
}
|
||||
.landing-guide h2,
|
||||
.landing-finishing h2 {
|
||||
font-size: 43px;
|
||||
letter-spacing: -1.5px;
|
||||
margin-top: 17px;
|
||||
}
|
||||
.landing-guide-intro > p:not(.landing-eyebrow) {
|
||||
color: var(--landing-muted);
|
||||
line-height: 1.8;
|
||||
.landing-guide-intro > p {
|
||||
margin-top: 22px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.landing-text-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
font-size: 12px;
|
||||
font-size: var(--landing-caption-size);
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
.landing-guide-intro > .landing-text-link {
|
||||
|
|
@ -341,8 +268,7 @@
|
|||
margin-top: 30px;
|
||||
border-top: 1px solid #cdd5c5;
|
||||
color: var(--landing-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
font-size: var(--landing-caption-size);
|
||||
}
|
||||
.landing-tool-note strong {
|
||||
display: block;
|
||||
|
|
@ -366,25 +292,17 @@
|
|||
padding-top: 0;
|
||||
}
|
||||
.landing-step-number {
|
||||
font: 13px var(--mono);
|
||||
color: var(--landing-accent);
|
||||
padding-top: 4px;
|
||||
min-width: 18px;
|
||||
font-size: var(--landing-caption-size);
|
||||
font-weight: 600;
|
||||
color: var(--landing-muted);
|
||||
}
|
||||
.landing-steps h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.landing-steps p {
|
||||
font-size: 13px;
|
||||
color: var(--landing-muted);
|
||||
line-height: 1.75;
|
||||
}
|
||||
.landing-guide-note {
|
||||
margin: 18px 0 0 39px;
|
||||
font-size: 11px;
|
||||
color: var(--landing-muted);
|
||||
line-height: 1.8;
|
||||
margin: 18px 0 0 41px;
|
||||
font-size: var(--landing-caption-size);
|
||||
}
|
||||
.landing-finishing {
|
||||
padding-block: 73px 65px;
|
||||
|
|
@ -396,11 +314,6 @@
|
|||
gap: 80px;
|
||||
margin-bottom: 35px;
|
||||
}
|
||||
.landing-finishing-heading > p {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: var(--landing-muted);
|
||||
}
|
||||
.landing-trim-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
|
@ -412,14 +325,9 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
.landing-trim-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 15px;
|
||||
padding: 16px 23px;
|
||||
background: #f0f1e9;
|
||||
font: 9px var(--mono);
|
||||
letter-spacing: 1px;
|
||||
color: #586154;
|
||||
font-size: var(--landing-caption-size);
|
||||
border-bottom: 1px solid var(--landing-line);
|
||||
}
|
||||
.landing-trim-diagram {
|
||||
|
|
@ -435,30 +343,14 @@
|
|||
border-top: 1px solid var(--landing-line);
|
||||
}
|
||||
.landing-trim-copy h3 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px 12px;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.6px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.landing-trim-copy h3 span {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
color: var(--landing-muted);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.landing-trim-copy .landing-trim-choice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
color: var(--ink);
|
||||
font-size: 11px;
|
||||
font-size: var(--landing-caption-size);
|
||||
}
|
||||
.landing-trim-choice svg {
|
||||
margin-top: 2px;
|
||||
|
|
@ -470,9 +362,7 @@
|
|||
justify-content: space-between;
|
||||
gap: 40px;
|
||||
margin-top: 23px;
|
||||
font-size: 11px;
|
||||
color: var(--landing-muted);
|
||||
line-height: 1.8;
|
||||
font-size: var(--landing-caption-size);
|
||||
}
|
||||
.landing-finishing-note p {
|
||||
max-width: 780px;
|
||||
|
|
@ -483,69 +373,14 @@
|
|||
.landing-finishing-note a {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.landing-final-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 40px;
|
||||
padding: 47px 55px;
|
||||
background: #172c25;
|
||||
color: #faf9ef;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.landing-final-cta .landing-eyebrow {
|
||||
color: #c0cbb8;
|
||||
font-size: 9px;
|
||||
}
|
||||
.landing-final-cta h2 {
|
||||
font-size: 48px;
|
||||
margin-top: 20px;
|
||||
letter-spacing: -1.7px;
|
||||
}
|
||||
.landing-final-cta h2 em {
|
||||
color: #dfb888;
|
||||
}
|
||||
.landing-final-cta p:not(.landing-eyebrow) {
|
||||
color: #c0cbb8;
|
||||
font-size: 12px;
|
||||
margin-top: 17px;
|
||||
}
|
||||
.landing-final-actions {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
.landing-final-cta .landing-cta {
|
||||
background: #f6ecd9;
|
||||
border-color: #f6ecd9;
|
||||
color: #172c25;
|
||||
min-width: 260px;
|
||||
}
|
||||
.landing-final-cta .landing-cta:hover {
|
||||
background: #e5cfac;
|
||||
}
|
||||
.landing-final-actions > span {
|
||||
color: #c0cbb8;
|
||||
font-size: 10px;
|
||||
}
|
||||
.landing-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding-block: 38px;
|
||||
}
|
||||
.landing-footer .landing-brand {
|
||||
font-size: 17px;
|
||||
}
|
||||
.landing-footer .square-mark {
|
||||
transform: scale(0.75);
|
||||
transform-origin: left center;
|
||||
margin-right: -6px;
|
||||
}
|
||||
.landing-footer p,
|
||||
.landing-footer > a:last-child {
|
||||
font-size: 11px;
|
||||
padding-block: 16px;
|
||||
border-top: 1px solid var(--landing-line);
|
||||
font-size: var(--landing-caption-size);
|
||||
color: var(--landing-muted);
|
||||
}
|
||||
.landing-sr-only {
|
||||
|
|
@ -558,27 +393,21 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
.landing-skip {
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 12px;
|
||||
background: white;
|
||||
z-index: 10;
|
||||
z-index: 30;
|
||||
transform: translateY(-150%);
|
||||
}
|
||||
.landing-skip:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.planner-loading {
|
||||
padding: 32px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.landing-container {
|
||||
width: calc(100% - 64px);
|
||||
}
|
||||
.landing-nav {
|
||||
gap: 20px;
|
||||
}
|
||||
.landing-hero {
|
||||
gap: 32px;
|
||||
}
|
||||
|
|
@ -599,20 +428,14 @@
|
|||
}
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.landing-nav {
|
||||
display: none;
|
||||
}
|
||||
.landing-header {
|
||||
min-height: 82px;
|
||||
.landing-page {
|
||||
--landing-header-height: 72px;
|
||||
}
|
||||
.landing-hero {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 35px;
|
||||
padding-block: 48px 57px;
|
||||
}
|
||||
.landing-hero h1 {
|
||||
font-size: clamp(48px, 8vw, 65px);
|
||||
}
|
||||
.landing-intro {
|
||||
max-width: 540px;
|
||||
}
|
||||
|
|
@ -624,11 +447,6 @@
|
|||
.landing-plan {
|
||||
width: min(100%, 540px);
|
||||
margin: 10px auto 0;
|
||||
transform: rotate(-1deg);
|
||||
}
|
||||
.landing-section-kicker {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.landing-benefit {
|
||||
padding-inline: 20px;
|
||||
|
|
@ -656,41 +474,23 @@
|
|||
}
|
||||
.landing-trim-label {
|
||||
padding-inline: 16px;
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.landing-trim-copy {
|
||||
margin-inline: 16px;
|
||||
}
|
||||
.landing-final-cta {
|
||||
padding: 38px;
|
||||
gap: 30px;
|
||||
}
|
||||
.landing-final-cta h2 {
|
||||
font-size: 39px;
|
||||
}
|
||||
.landing-final-cta .landing-cta {
|
||||
min-width: 210px;
|
||||
gap: 25px;
|
||||
}
|
||||
.landing-footer {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 24px;
|
||||
}
|
||||
.landing-footer p {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.landing-page {
|
||||
--landing-title-size: 36px;
|
||||
--landing-heading-size: 28px;
|
||||
}
|
||||
.landing-container {
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
.landing-header {
|
||||
.landing-header-content {
|
||||
gap: 12px;
|
||||
}
|
||||
.landing-brand {
|
||||
font-size: 19px;
|
||||
gap: 8px;
|
||||
}
|
||||
.landing-brand .square-mark {
|
||||
|
|
@ -699,16 +499,10 @@
|
|||
margin-right: -4px;
|
||||
}
|
||||
.landing-page .landing-cta-small {
|
||||
font-size: 12px;
|
||||
padding-inline: 12px;
|
||||
gap: 9px;
|
||||
}
|
||||
.landing-hero h1 {
|
||||
font-size: clamp(41px, 11vw, 58px);
|
||||
letter-spacing: -1.8px;
|
||||
}
|
||||
.landing-intro {
|
||||
font-size: 15px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
.landing-hero-actions {
|
||||
|
|
@ -717,29 +511,15 @@
|
|||
.landing-hero-actions .landing-cta {
|
||||
width: 100%;
|
||||
}
|
||||
.landing-reassurance {
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.landing-plan-heading {
|
||||
font-size: 10px;
|
||||
padding: 14px 12px;
|
||||
}
|
||||
.landing-plan-index {
|
||||
font-size: 8px;
|
||||
}
|
||||
.landing-plan-room {
|
||||
font-size: 11px;
|
||||
padding: 16px 14px 8px;
|
||||
}
|
||||
.landing-plan-room > span:last-child {
|
||||
font-size: 9px;
|
||||
}
|
||||
.landing-plan-legend {
|
||||
padding: 14px;
|
||||
gap: 15px;
|
||||
font-size: 9px;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
.landing-benefits {
|
||||
padding-bottom: 42px;
|
||||
|
|
@ -765,16 +545,11 @@
|
|||
.landing-guide {
|
||||
padding-block: 44px;
|
||||
}
|
||||
.landing-guide h2,
|
||||
.landing-finishing h2 {
|
||||
font-size: 35px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.landing-steps li {
|
||||
gap: 18px;
|
||||
}
|
||||
.landing-guide-note {
|
||||
margin-left: 34px;
|
||||
margin-left: 36px;
|
||||
}
|
||||
.landing-finishing {
|
||||
padding-block: 46px 36px;
|
||||
|
|
@ -784,7 +559,6 @@
|
|||
gap: 22px;
|
||||
}
|
||||
.landing-trim-label {
|
||||
font-size: 9px;
|
||||
padding: 15px 20px;
|
||||
}
|
||||
.landing-trim-copy {
|
||||
|
|
@ -797,18 +571,6 @@
|
|||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.landing-final-cta {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 32px 25px;
|
||||
gap: 27px;
|
||||
}
|
||||
.landing-final-cta h2 {
|
||||
font-size: 40px;
|
||||
}
|
||||
.landing-footer {
|
||||
padding-block: 28px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.landing-page .landing-cta {
|
||||
|
|
@ -821,7 +583,6 @@
|
|||
@media print {
|
||||
.landing-header,
|
||||
.landing-footer,
|
||||
.landing-final-cta,
|
||||
.landing-hero-actions,
|
||||
.landing-skip {
|
||||
display: none;
|
||||
|
|
@ -829,9 +590,8 @@
|
|||
.landing-container {
|
||||
width: 100%;
|
||||
}
|
||||
.landing-plan {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
.landing-page {
|
||||
padding-top: 0;
|
||||
}
|
||||
.landing-trim-card,
|
||||
.landing-steps li {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export const initAnalytics = () => {
|
|||
endpoint: ANALYTICS_ENDPOINT,
|
||||
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
|
||||
logging: ANALYTICS_LOGGING,
|
||||
// Query strings and legacy fragments contain the saved plan, including room names.
|
||||
// Query strings contain the saved plan, including room names.
|
||||
transformRequest: (payload) => ({
|
||||
...payload,
|
||||
u: payload.u.split(/[?#]/)[0],
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ import type {
|
|||
} from './types';
|
||||
|
||||
import { cutMm, degree } from './format';
|
||||
import { PRECISION } from './limits';
|
||||
|
||||
const sameLength = (a: number, b: number) => Math.abs(a - b) < 0.01;
|
||||
const sameLength = (a: number, b: number) => Math.abs(a - b) < PRECISION.millimetres;
|
||||
|
||||
/** A ready offcut still needs a placement code; only fresh, uncut whole boards are omitted. */
|
||||
export const flooringCuts = (plan: FlooringPlan) =>
|
||||
|
|
@ -21,8 +22,8 @@ export function isSkirtingCut(piece: SkirtingPiece, plan: SkirtingPlan): boolean
|
|||
piece.rightType === 'cope' ||
|
||||
!stock ||
|
||||
!sameLength(piece.length, stock.length) ||
|
||||
Math.abs(piece.leftMitre) > 0.001 ||
|
||||
Math.abs(piece.rightMitre) > 0.001
|
||||
Math.abs(piece.leftMitre) > PRECISION.cutAngle ||
|
||||
Math.abs(piece.rightMitre) > PRECISION.cutAngle
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +80,7 @@ export function skirtingEndLabel(piece: SkirtingPiece, side: 'left' | 'right'):
|
|||
const angle = side === 'left' ? piece.leftMitre : piece.rightMitre;
|
||||
if (type === 'cope') return `Cope · prepare ${degree(angle)}`;
|
||||
if (type === 'internal')
|
||||
return `Receive cope · ${Math.abs(angle) < 0.001 ? 'square' : `wall-fit ${degree(angle)}`}`;
|
||||
return `Receive cope · ${Math.abs(angle) < PRECISION.cutAngle ? 'square' : `wall-fit ${degree(angle)}`}`;
|
||||
return type === 'external' ? `Mitre ${degree(angle)}` : 'Square';
|
||||
}
|
||||
|
||||
|
|
@ -98,7 +99,6 @@ export function skirtingInstructions(piece: SkirtingPiece): string {
|
|||
messages.push(
|
||||
`Prepare the ${copes.join(' and ')} inside mitre, mark your moulding’s exposed profile, then cope and back-cut to fit the receiving face. Keep the front profile line; the dashed end shows the full-thickness body after coping, not the decorative profile.`,
|
||||
);
|
||||
if (piece.visibleLength !== undefined)
|
||||
messages.push(`Exposed front length: ${cutMm(piece.visibleLength)} mm.`);
|
||||
return messages.join(' ');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { FlooringPiece, Point, SkirtingPiece } from './types';
|
||||
import type { Point } from './types';
|
||||
import { cutMm, degree } from './format';
|
||||
import { PRECISION } from './limits';
|
||||
|
||||
export interface PreviewBox {
|
||||
x: number;
|
||||
|
|
@ -66,7 +67,7 @@ export function dimensionedOutline(
|
|||
...edge,
|
||||
length: Math.hypot(edge.end.x - edge.start.x, edge.end.y - edge.start.y),
|
||||
}))
|
||||
.filter((edge) => edge.length > 0.000001)
|
||||
.filter((edge) => edge.length > PRECISION.geometry)
|
||||
.sort((a, b) => b.length - a.length || a.edge - b.edge);
|
||||
|
||||
for (const edge of selected) {
|
||||
|
|
@ -77,14 +78,14 @@ export function dimensionedOutline(
|
|||
const normal = { x: orientation * direction.y, y: -orientation * direction.x };
|
||||
const center = { x: (edge.start.x + edge.end.x) / 2, y: (edge.start.y + edge.end.y) / 2 };
|
||||
const limit =
|
||||
Math.abs(direction.y) < 1e-6
|
||||
Math.abs(direction.y) < PRECISION.geometry
|
||||
? blank.length
|
||||
: Math.abs(direction.x) < 1e-6
|
||||
: Math.abs(direction.x) < PRECISION.geometry
|
||||
? blank.width
|
||||
: undefined;
|
||||
const text = cutMm(edge.length, limit);
|
||||
const angle =
|
||||
Math.abs(direction.x) > 0.000001 && Math.abs(direction.y) > 0.000001
|
||||
Math.abs(direction.x) > PRECISION.geometry && Math.abs(direction.y) > PRECISION.geometry
|
||||
? (Math.atan2(Math.abs(direction.y), Math.abs(direction.x)) * 180) / Math.PI
|
||||
: undefined;
|
||||
const angleText = angle === undefined ? '' : degree(angle);
|
||||
|
|
@ -155,29 +156,6 @@ export function dimensionedOutline(
|
|||
};
|
||||
}
|
||||
|
||||
export function flooringTemplate(piece: FlooringPiece): Point[] {
|
||||
if (piece.template.length) return piece.template;
|
||||
return piece.shaped
|
||||
? []
|
||||
: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: piece.length, y: 0 },
|
||||
{ x: piece.length, y: piece.width },
|
||||
{ x: 0, y: piece.width },
|
||||
];
|
||||
}
|
||||
|
||||
export function skirtingTemplate(piece: SkirtingPiece, thickness: number): Point[] {
|
||||
if (piece.template?.length === 4) return piece.template;
|
||||
if (piece.leftMitre !== 0 || piece.rightMitre !== 0) return [];
|
||||
return [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: piece.length, y: 0 },
|
||||
{ x: piece.length, y: thickness },
|
||||
{ x: 0, y: thickness },
|
||||
];
|
||||
}
|
||||
|
||||
/** Padding changes the viewport only; all vertices stay in their original millimetres. */
|
||||
export function fullPreviewBox(length: number, width: number): PreviewBox {
|
||||
const padding = Math.max(length, width, 1) * 0.06;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export function fireplace(id: string, name: string): Room {
|
|||
}
|
||||
export function defaultProject(): Project {
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
name: 'My flooring project',
|
||||
rooms: [rectangle()],
|
||||
tolerance: 10,
|
||||
|
|
@ -42,7 +42,7 @@ export function defaultProject(): Project {
|
|||
length: 1200,
|
||||
width: 190,
|
||||
gap: 10,
|
||||
pattern: 'brick',
|
||||
pattern: 'random',
|
||||
optimisation: 'cuts',
|
||||
minOffset: 300,
|
||||
maxOffset: 600,
|
||||
|
|
@ -56,7 +56,13 @@ export function defaultProject(): Project {
|
|||
export function defaultState(): UrlState {
|
||||
return {
|
||||
project: defaultProject(),
|
||||
view: { roomId: 'room-1', selectedPiece: null, search: '', collapsedSections: [] },
|
||||
view: {
|
||||
roomId: 'room-1',
|
||||
selectedPiece: null,
|
||||
search: '',
|
||||
collapsedSections: [],
|
||||
material: 'flooring',
|
||||
},
|
||||
};
|
||||
}
|
||||
export function nextId(prefix: string, existing: string[]): string {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import ClipperLib from 'clipper-lib';
|
||||
import { buildGeometry, insetPolygon } from './geometry';
|
||||
import { cutLength } from './format';
|
||||
import { PLANNER_LIMITS, PRECISION, PROJECT_LIMITS } from './limits';
|
||||
import type {
|
||||
FlooringPiece,
|
||||
FlooringPlan,
|
||||
|
|
@ -12,9 +13,9 @@ import type {
|
|||
Room,
|
||||
} from './types';
|
||||
|
||||
const SCALE = 1000;
|
||||
const EPSILON = 0.01;
|
||||
const MAX_BOARDS = 2000;
|
||||
const SCALE = PRECISION.clipperScale;
|
||||
const EPSILON = PRECISION.millimetres;
|
||||
const MAX_BOARDS = PLANNER_LIMITS.flooringBoards;
|
||||
|
||||
export type FlooringOptions = Record<'cuts' | 'random', FlooringPlan>;
|
||||
|
||||
|
|
@ -28,14 +29,25 @@ export function planFlooringOptions(
|
|||
const cutsSettings = { ...s, optimisation: 'cuts' as const };
|
||||
const randomSettings = { ...s, optimisation: 'random' as const };
|
||||
const cuts = solveFlooring(rooms, cutsSettings, tolerance, shapes);
|
||||
const random =
|
||||
s.pattern === 'random' ? solveFlooring(rooms, randomSettings, tolerance, shapes) : cuts;
|
||||
const variablePattern = s.pattern === 'random' && s.minOffset < s.length / 2 - EPSILON;
|
||||
const candidates = [cuts];
|
||||
if (variablePattern) candidates.push(solveFlooring(rooms, randomSettings, tolerance, shapes));
|
||||
const errors = (solution: Solution) =>
|
||||
solution.issues.filter((issue) => issue.severity === 'error').length;
|
||||
const better = (settings: FlooringSettings) =>
|
||||
(errors(random) - errors(cuts) || compareLayouts(random.layout, cuts.layout, settings)) < 0
|
||||
? random
|
||||
: cuts;
|
||||
candidates.reduce((best, candidate) =>
|
||||
(errors(candidate) - errors(best) ||
|
||||
compareLayouts(candidate.layout, best.layout, settings)) < 0
|
||||
? candidate
|
||||
: best,
|
||||
);
|
||||
// If repetition remains, try immediate reuse too. Keep the best completed result from either route.
|
||||
if (
|
||||
variablePattern &&
|
||||
(better(randomSettings).layout.patternPenalty > 0 || errors(better(randomSettings)))
|
||||
) {
|
||||
candidates.push(solveFlooring(rooms, randomSettings, tolerance, shapes, false));
|
||||
}
|
||||
const cutWinner = better(cutsSettings);
|
||||
const randomWinner = better(randomSettings);
|
||||
const cutPlan = finishFlooring(cutWinner, s);
|
||||
|
|
@ -46,7 +58,7 @@ export function planFlooringOptions(
|
|||
}
|
||||
|
||||
export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: number): FlooringPlan {
|
||||
return planFlooringOptions(rooms, s, tolerance)[s.optimisation ?? 'cuts'];
|
||||
return planFlooringOptions(rooms, s, tolerance)[s.optimisation];
|
||||
}
|
||||
|
||||
interface Bounds {
|
||||
|
|
@ -164,7 +176,7 @@ function rowWidthOptions(
|
|||
cornerOffsets: number[] = [],
|
||||
): number[][] {
|
||||
const count = Math.ceil((height - EPSILON) / s.width);
|
||||
if (count <= 0 || count > 500) return [];
|
||||
if (count <= 0 || count > PLANNER_LIMITS.flooringRowsPerRoom) return [];
|
||||
if (count === 1) return height + EPSILON >= s.minWidth ? [[height]] : [];
|
||||
const edgeTotal = height - (count - 2) * s.width;
|
||||
if (edgeTotal + EPSILON < 2 * s.minWidth) return [];
|
||||
|
|
@ -260,17 +272,21 @@ function cutRow(
|
|||
return pieces;
|
||||
}
|
||||
|
||||
/** A cut end cannot replace a factory joint. Offcuts retain their original left/right end flags. */
|
||||
/** Preserve factory-ended tails for exact fits; final packing can combine blanks that need trimming. */
|
||||
function allocate(
|
||||
layout: Layout,
|
||||
piece: CutPiece,
|
||||
pieceId: string,
|
||||
s: FlooringSettings,
|
||||
preserveEnds: boolean,
|
||||
): { stockId: string; reused: boolean; crosscut: boolean; blankLength: number } {
|
||||
const length = round(piece.bounds.maxX - piece.bounds.minX);
|
||||
let stock: AvailableStock | undefined;
|
||||
for (const candidate of piece.needsLeft && piece.needsRight ? [] : layout.stocks) {
|
||||
if (
|
||||
(preserveEnds &&
|
||||
(piece.needsLeft || piece.needsRight) &&
|
||||
!close(candidate.remaining, length)) ||
|
||||
candidate.remaining < blankLength(length, candidate.remaining) - EPSILON ||
|
||||
(piece.needsLeft && !candidate.left) ||
|
||||
(piece.needsRight && !candidate.right)
|
||||
|
|
@ -403,6 +419,7 @@ function appendRow(
|
|||
shape: RowShape,
|
||||
phase: number,
|
||||
s: FlooringSettings,
|
||||
preserveEnds: boolean,
|
||||
): Layout {
|
||||
const layout: Layout = {
|
||||
pieces: [...base.pieces],
|
||||
|
|
@ -419,7 +436,7 @@ function appendRow(
|
|||
const pieceIds: string[] = [];
|
||||
cuts.forEach((piece, column) => {
|
||||
const id = `F${layout.pieces.length + 1}`;
|
||||
const allocation = allocate(layout, piece, id, s);
|
||||
const allocation = allocate(layout, piece, id, s, preserveEnds);
|
||||
const length = round(piece.bounds.maxX - piece.bounds.minX);
|
||||
const width = round(piece.bounds.maxY - piece.bounds.minY);
|
||||
// Count straight saw passes. For a shape, every additional non-bounding edge needs a pass.
|
||||
|
|
@ -705,7 +722,11 @@ function compareLayouts(a: Layout, b: Layout, s: FlooringSettings): number {
|
|||
function validateSettings(s: FlooringSettings): string | null {
|
||||
if (Object.values(s).some((value) => typeof value === 'number' && !Number.isFinite(value)))
|
||||
return 'All flooring measurements must be finite numbers.';
|
||||
if (Object.values(s).some((value) => typeof value === 'number' && Math.abs(value) > 1e6))
|
||||
if (
|
||||
Object.values(s).some(
|
||||
(value) => typeof value === 'number' && Math.abs(value) > PROJECT_LIMITS.maxMeasurement,
|
||||
)
|
||||
)
|
||||
return 'Flooring measurements must not exceed 1,000,000 mm. Check your units.';
|
||||
if (s.length < 0.1 || s.width < 0.1 || s.minLength < 0.1 || s.minWidth < 0.1)
|
||||
return 'Board and minimum piece dimensions must be at least 0.1 mm.';
|
||||
|
|
@ -725,9 +746,9 @@ function validateSettings(s: FlooringSettings): string | null {
|
|||
}
|
||||
|
||||
/**
|
||||
* Deterministic bounded beam search: boards consumed first; random layouts then
|
||||
* prefer less visual repetition before fewer saw passes. This can spend an extra
|
||||
* crosscut to break a staircase without buying an extra board.
|
||||
* Deterministic bounded beam search with selectable cut-first or pattern-first priorities.
|
||||
* Easier cuts break saw-count ties, then visual repetition and finally purchased boards.
|
||||
* Completed candidates are repacked and interchangeable rows can be reordered without new cuts.
|
||||
* Stock is shared between rooms, but layouts and joint phases are independent.
|
||||
* Only full-width end remnants are reused; ripped strips are conservatively waste.
|
||||
* This is a practical cutting plan, not a claim of a global packing optimum.
|
||||
|
|
@ -737,6 +758,7 @@ function solveFlooring(
|
|||
s: FlooringSettings,
|
||||
tolerance: number,
|
||||
shapes: ShapeCache,
|
||||
preserveEnds = true,
|
||||
): Solution {
|
||||
let layout: Layout = {
|
||||
pieces: [],
|
||||
|
|
@ -835,7 +857,8 @@ function solveFlooring(
|
|||
cache.set(phase, cuts);
|
||||
}
|
||||
if (!cuts?.length) continue;
|
||||
const candidate = appendRow(base, cuts, room, row, shape, phase, s);
|
||||
let candidate = appendRow(base, cuts, room, row, shape, phase, s, preserveEnds);
|
||||
if (candidate.stocks.length > MAX_BOARDS) candidate = repack(candidate, s);
|
||||
if (candidate.stocks.length <= MAX_BOARDS) alternatives.push(candidate);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { PRECISION } from './limits';
|
||||
|
||||
export const mm = (value: number) =>
|
||||
Number.isFinite(value)
|
||||
? new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1 }).format(value)
|
||||
: '—';
|
||||
/** Round cutting marks up without letting floating-point noise add a half millimetre. */
|
||||
export const cutLength = (value: number) => Math.ceil(value * 2 - 1e-9) / 2 + 0;
|
||||
export const cutLength = (value: number) => Math.ceil(value * 2 - PRECISION.roundingNoise) / 2 + 0;
|
||||
/** An exact-fit stock edge or remnant needs no rounded-up preparation cut. */
|
||||
export const cutMm = (value: number, available = Infinity) =>
|
||||
mm(Math.min(cutLength(value), available));
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import ClipperLib from 'clipper-lib';
|
||||
import { PRECISION, PROJECT_LIMITS } from './limits';
|
||||
import type { Geometry, Issue, Point, Room, WallEdge } from './types';
|
||||
|
||||
const EPS = 1e-6;
|
||||
const SCALE = 1000;
|
||||
const MAX_WALL = 1_000_000;
|
||||
const EPS = PRECISION.geometry;
|
||||
const SCALE = PRECISION.clipperScale;
|
||||
const MAX_WALL = PROJECT_LIMITS.maxMeasurement;
|
||||
const radians = (degrees: number) => (degrees * Math.PI) / 180;
|
||||
const degrees = (angle: number) => (angle * 180) / Math.PI;
|
||||
const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
|
|
@ -50,11 +51,13 @@ export function buildGeometry(room: Room, tolerance: number): Geometry {
|
|||
let position = origin;
|
||||
let heading = 0;
|
||||
const allowedGap =
|
||||
Number.isFinite(tolerance) && tolerance >= 0 && tolerance <= 50 ? tolerance : 0;
|
||||
if (!Number.isFinite(tolerance) || tolerance < 0 || tolerance > 50) {
|
||||
Number.isFinite(tolerance) && tolerance >= 0 && tolerance <= PROJECT_LIMITS.maxTolerance
|
||||
? tolerance
|
||||
: 0;
|
||||
if (!Number.isFinite(tolerance) || tolerance < 0 || tolerance > PROJECT_LIMITS.maxTolerance) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
message: 'Measurement tolerance must be between 0 and 50 mm.',
|
||||
message: `Measurement tolerance must be between 0 and ${PROJECT_LIMITS.maxTolerance} mm.`,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +71,7 @@ export function buildGeometry(room: Room, tolerance: number): Geometry {
|
|||
// The last stored turn only applies if another wall is subsequently added.
|
||||
if (
|
||||
index < room.walls.length - 1 &&
|
||||
(!Number.isFinite(wall.turn) || Math.abs(wall.turn) >= 180)
|
||||
(!Number.isFinite(wall.turn) || Math.abs(wall.turn) >= PROJECT_LIMITS.maxTurn)
|
||||
) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
|
|
|
|||
35
src/lib/limits.ts
Normal file
35
src/lib/limits.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/** Limits shared by editing, persisted state validation and the planners. */
|
||||
export const PROJECT_LIMITS = {
|
||||
rooms: 20,
|
||||
wallsPerRoom: 100,
|
||||
doorsPerWall: 30,
|
||||
nameLength: 200,
|
||||
maxMeasurement: 1_000_000,
|
||||
maxTolerance: 50,
|
||||
maxTurn: 180,
|
||||
maxSkirtingThickness: 10_000,
|
||||
maxSkirtingKerf: 10_000,
|
||||
} as const;
|
||||
|
||||
export const PERSISTENCE_LIMITS = {
|
||||
queryLength: 100_000,
|
||||
identifierLength: 120,
|
||||
completedBoards: 12_000,
|
||||
searchLength: 200,
|
||||
} as const;
|
||||
|
||||
export const PLANNER_LIMITS = {
|
||||
flooringBoards: 2_000,
|
||||
flooringRowsPerRoom: 500,
|
||||
skirtingPieces: 10_000,
|
||||
} as const;
|
||||
|
||||
/** Named numeric tolerances; each represents a distinct calculation boundary. */
|
||||
export const PRECISION = {
|
||||
geometry: 1e-6,
|
||||
millimetres: 0.01,
|
||||
cutAngle: 0.001,
|
||||
clipping: 0.001,
|
||||
clipperScale: 1_000,
|
||||
roundingNoise: 1e-9,
|
||||
} as const;
|
||||
|
|
@ -1,29 +1,17 @@
|
|||
import type { Point, SkirtingPiece } from './types';
|
||||
import { PRECISION } from './limits';
|
||||
|
||||
/** Place the cutting template against its wall, preserving mitres and the coped body setbacks. */
|
||||
export function skirtingPolygon(
|
||||
piece: SkirtingPiece,
|
||||
thickness: number,
|
||||
orientation: number,
|
||||
): Point[] {
|
||||
export function skirtingPolygon(piece: SkirtingPiece, orientation: number): Point[] {
|
||||
const dx = piece.end.x - piece.start.x;
|
||||
const dy = piece.end.y - piece.start.y;
|
||||
const backLength = Math.hypot(dx, dy);
|
||||
if (backLength < 1e-6) return [];
|
||||
if (backLength < PRECISION.geometry) return [];
|
||||
const tangent = { x: dx / backLength, y: dy / backLength };
|
||||
const side = orientation < 0 ? -1 : 1;
|
||||
const inward = { x: -tangent.y * side, y: tangent.x * side };
|
||||
const template = piece.template?.length
|
||||
? piece.template
|
||||
: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: backLength, y: 0 },
|
||||
{ x: backLength, y: thickness },
|
||||
{ x: 0, y: thickness },
|
||||
];
|
||||
const installed = piece.installedTemplate?.length ? piece.installedTemplate : template;
|
||||
return installed.map((point) => ({
|
||||
x: piece.start.x + tangent.x * (point.x - template[0].x) + inward.x * point.y,
|
||||
y: piece.start.y + tangent.y * (point.x - template[0].x) + inward.y * point.y,
|
||||
return piece.installedTemplate.map((point) => ({
|
||||
x: piece.start.x + tangent.x * (point.x - piece.template[0].x) + inward.x * point.y,
|
||||
y: piece.start.y + tangent.y * (point.x - piece.template[0].x) + inward.y * point.y,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { buildGeometry, normalizeAngle, signedArea } from './geometry';
|
||||
import { isSkirtingCut } from './cut-list';
|
||||
import { cutLength } from './format';
|
||||
import { PLANNER_LIMITS, PRECISION, PROJECT_LIMITS } from './limits';
|
||||
import type {
|
||||
Issue,
|
||||
Point,
|
||||
|
|
@ -12,7 +13,8 @@ import type {
|
|||
WallEdge,
|
||||
} from './types';
|
||||
|
||||
const EPS = 1e-6;
|
||||
const EPS = PRECISION.geometry;
|
||||
type UnpackedPiece = Omit<SkirtingPiece, 'blankLength'>;
|
||||
const rad = (degrees: number) => (degrees * Math.PI) / 180;
|
||||
type End = {
|
||||
type: SkirtingPiece['leftType'];
|
||||
|
|
@ -117,7 +119,7 @@ function corner(turn: number, thickness: number, side: 'left' | 'right', cope: b
|
|||
};
|
||||
}
|
||||
|
||||
function endCuts(piece: SkirtingPiece) {
|
||||
function endCuts(piece: UnpackedPiece) {
|
||||
return (
|
||||
Number(Math.abs(piece.leftMitre) > EPS) +
|
||||
Number(Math.abs(piece.rightMitre) > EPS) +
|
||||
|
|
@ -126,15 +128,15 @@ function endCuts(piece: SkirtingPiece) {
|
|||
);
|
||||
}
|
||||
|
||||
function blankLength(piece: SkirtingPiece, available: number) {
|
||||
function blankLength(piece: UnpackedPiece, available: number) {
|
||||
return Math.abs(available - piece.length) <= EPS ? available : cutLength(piece.length);
|
||||
}
|
||||
|
||||
function requiredCuts(piece: SkirtingPiece, available: number) {
|
||||
function requiredCuts(piece: UnpackedPiece, available: number) {
|
||||
return endCuts(piece) + Number(Math.abs(available - piece.length) > EPS);
|
||||
}
|
||||
|
||||
function consumedLength(piece: SkirtingPiece, available: number, kerf: number) {
|
||||
function consumedLength(piece: UnpackedPiece, available: number, kerf: number) {
|
||||
// Cut a square blank, then bevel within its longest-point envelope. This leaves
|
||||
// a square reusable offcut. Bevel kerfs remove material inside the blank.
|
||||
const length = blankLength(piece, available);
|
||||
|
|
@ -147,7 +149,7 @@ function makePiece(
|
|||
to: number,
|
||||
first: boolean,
|
||||
last: boolean,
|
||||
): SkirtingPiece {
|
||||
): UnpackedPiece {
|
||||
const left = first ? run.left : square(),
|
||||
right = last ? run.right : square();
|
||||
const backFrom = from + left.back,
|
||||
|
|
@ -189,16 +191,16 @@ function makePiece(
|
|||
};
|
||||
}
|
||||
|
||||
function shortestEdge(piece: SkirtingPiece) {
|
||||
const installed = piece.installedTemplate!;
|
||||
function shortestEdge(piece: UnpackedPiece) {
|
||||
const installed = piece.installedTemplate;
|
||||
return Math.min(
|
||||
installed[1].x - installed[0].x,
|
||||
installed[2].x - installed[3].x,
|
||||
piece.visibleLength!,
|
||||
piece.visibleLength,
|
||||
);
|
||||
}
|
||||
|
||||
function fits(piece: SkirtingPiece, available: number) {
|
||||
function fits(piece: UnpackedPiece, available: number) {
|
||||
// The final saw pass may extend beyond the stock's tail.
|
||||
return blankLength(piece, available) <= available + EPS;
|
||||
}
|
||||
|
|
@ -211,7 +213,7 @@ function splitCount(run: Run, target: number, minimum: number): number {
|
|||
const count = Math.max(2, Math.ceil((full.length - EPS) / target));
|
||||
const minimumSpan = count * minimum + firstAllowance(run.left) - lastAllowance(run.right);
|
||||
if (
|
||||
count > 10000 ||
|
||||
count > PLANNER_LIMITS.skirtingPieces ||
|
||||
run.to - run.from < minimumSpan - EPS ||
|
||||
firstAllowance(run.left) - Math.min(run.left.back, run.left.front) > target - minimum + EPS ||
|
||||
Math.max(run.right.back, run.right.front) - lastAllowance(run.right) > target - minimum + EPS
|
||||
|
|
@ -226,7 +228,7 @@ function splitRun(
|
|||
target: number,
|
||||
settings: SkirtingSettings,
|
||||
balanced: boolean,
|
||||
): SkirtingPiece[] | null {
|
||||
): UnpackedPiece[] | null {
|
||||
const count = splitCount(run, target, settings.minLength);
|
||||
if (!count) return null;
|
||||
if (count === 1) return [makePiece(run, run.from, run.to, true, true)];
|
||||
|
|
@ -291,8 +293,8 @@ function splitWithOffcuts(
|
|||
runs: Run[],
|
||||
target: number,
|
||||
settings: SkirtingSettings,
|
||||
): SkirtingPiece[] | null {
|
||||
const pieces: SkirtingPiece[] = [];
|
||||
): UnpackedPiece[] | null {
|
||||
const pieces: UnpackedPiece[] = [];
|
||||
const offcuts: number[] = [];
|
||||
for (const run of runs) {
|
||||
if (!splitCount(run, target, settings.minLength)) return null;
|
||||
|
|
@ -322,7 +324,7 @@ function splitWithOffcuts(
|
|||
bFits = fits(full, b);
|
||||
return aFits !== bFits ? Number(bFits) - Number(aFits) : aFits ? a - b : b - a;
|
||||
});
|
||||
let next: { piece: SkirtingPiece; to: number } | null = null;
|
||||
let next: { piece: UnpackedPiece; to: number } | null = null;
|
||||
let available = target;
|
||||
for (let index = 0; index < offcuts.length; index++) {
|
||||
next = take(offcuts[index]);
|
||||
|
|
@ -332,7 +334,7 @@ function splitWithOffcuts(
|
|||
}
|
||||
}
|
||||
next ??= take(target);
|
||||
if (!next || pieces.length >= 10000) return null;
|
||||
if (!next || pieces.length >= PLANNER_LIMITS.skirtingPieces) return null;
|
||||
pieces.push(next.piece);
|
||||
const tail = available - consumedLength(next.piece, available, settings.kerf);
|
||||
if (tail >= settings.minLength - EPS) offcuts.push(tail);
|
||||
|
|
@ -349,14 +351,14 @@ interface Packed {
|
|||
waste: number;
|
||||
}
|
||||
|
||||
/** Best-fit decreasing across all rooms, using one purchased board length. */
|
||||
/** Greedy decreasing packing, optionally reserving exact three-piece combinations. */
|
||||
function pack(
|
||||
source: SkirtingPiece[],
|
||||
source: UnpackedPiece[],
|
||||
stockLength: number,
|
||||
kerf: number,
|
||||
exactTails = false,
|
||||
): Packed {
|
||||
const pieces = source.map((piece) => ({ ...piece }));
|
||||
const pieces: SkirtingPiece[] = source.map((piece) => ({ ...piece, blankLength: 0 }));
|
||||
let pending = [...pieces].sort(
|
||||
(a, b) => b.length - a.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
||||
);
|
||||
|
|
@ -425,16 +427,16 @@ export function planSkirting(
|
|||
if (
|
||||
!Number.isFinite(settings.stockLength) ||
|
||||
settings.stockLength < 0.1 ||
|
||||
settings.stockLength > 1e6 ||
|
||||
settings.stockLength > PROJECT_LIMITS.maxMeasurement ||
|
||||
!Number.isFinite(settings.thickness) ||
|
||||
settings.thickness < 0 ||
|
||||
settings.thickness > 10000 ||
|
||||
settings.thickness > PROJECT_LIMITS.maxSkirtingThickness ||
|
||||
!Number.isFinite(settings.minLength) ||
|
||||
settings.minLength < 0.1 ||
|
||||
settings.minLength > 1e6 ||
|
||||
settings.minLength > PROJECT_LIMITS.maxMeasurement ||
|
||||
!Number.isFinite(settings.kerf) ||
|
||||
settings.kerf < 0 ||
|
||||
settings.kerf > 10000
|
||||
settings.kerf > PROJECT_LIMITS.maxSkirtingKerf
|
||||
) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
|
|
@ -445,7 +447,7 @@ export function planSkirting(
|
|||
}
|
||||
const stockLength = settings.stockLength;
|
||||
let runs: Run[] = [];
|
||||
const fallbackSplits = new Map<Run, SkirtingPiece[]>();
|
||||
const fallbackSplits = new Map<Run, UnpackedPiece[]>();
|
||||
rooms.forEach((room, roomIndex) => {
|
||||
const geometry = buildGeometry(room, tolerance);
|
||||
if (!geometry.valid) {
|
||||
|
|
@ -533,7 +535,7 @@ export function planSkirting(
|
|||
|
||||
const candidates: Packed[] = [];
|
||||
const prefixes = new Map(runs.map((run) => [run.roomId, run.prefix]));
|
||||
const addCandidate = (source: SkirtingPiece[]) => {
|
||||
const addCandidate = (source: UnpackedPiece[]) => {
|
||||
// Raw spans can fit while their displayed half-millimetre blanks do not.
|
||||
// Reject this split without losing other feasible joint arrangements.
|
||||
if (
|
||||
|
|
@ -587,7 +589,7 @@ export function planSkirting(
|
|||
issues.push({
|
||||
severity: 'info',
|
||||
message:
|
||||
'Deterministic stock packing shares offcuts across rooms. It compares full-stock, balanced and offcut-led joins, then prefers less waste and fewer cuts; it is a heuristic, not a guaranteed global optimum. Cut square blanks to the longest-point lengths, then prepare their mitres and cope marked ends. Each cope counts one additional shaping operation; profile-specific relief passes are not estimated. Crosscut kerf is allowed between blanks; bevel kerfs stay inside the blanks. Waste is unused stock length including crosscut kerf.',
|
||||
'Deterministic stock packing shares offcuts across rooms. It compares full-stock, balanced and offcut-led joins, prioritising fewer cuts and then less waste. Exact combinations can save a trimming pass. This is a heuristic, not a guaranteed global optimum. Cut square blanks to the longest-point lengths, then prepare their mitres and cope marked ends. Each cope counts one additional shaping operation; profile-specific relief passes are not estimated. Crosscut kerf is allowed between blanks; bevel kerfs stay inside the blanks. Waste is unused stock length including crosscut kerf.',
|
||||
});
|
||||
const plan = { ...best, stockCount: best.stocks.length, issues };
|
||||
const cutNumbers = new Map<string, number>();
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export interface FlooringSettings {
|
|||
gap: number;
|
||||
pattern: 'brick' | 'random';
|
||||
/** Random flooring can prioritise fewer saw passes or less visual repetition. */
|
||||
optimisation?: 'cuts' | 'random';
|
||||
optimisation: 'cuts' | 'random';
|
||||
minOffset: number;
|
||||
maxOffset: number;
|
||||
minLength: number;
|
||||
|
|
@ -40,7 +40,7 @@ export interface SkirtingSettings {
|
|||
kerf: number;
|
||||
}
|
||||
export interface Project {
|
||||
version: 1;
|
||||
version: 2;
|
||||
name: string;
|
||||
rooms: Room[];
|
||||
flooring: FlooringSettings;
|
||||
|
|
@ -50,11 +50,13 @@ export interface Project {
|
|||
completedBoards: string[];
|
||||
}
|
||||
export type PlannerSection = 'measurements' | 'materials' | 'cuts';
|
||||
export type PlanMaterial = 'flooring' | 'skirting';
|
||||
export interface ViewState {
|
||||
roomId: string;
|
||||
selectedPiece: string | null;
|
||||
search: string;
|
||||
collapsedSections: PlannerSection[];
|
||||
material: PlanMaterial;
|
||||
}
|
||||
export interface UrlState {
|
||||
project: Project;
|
||||
|
|
@ -99,8 +101,8 @@ export interface FlooringPiece {
|
|||
length: number;
|
||||
width: number;
|
||||
/** Preparation dimensions reserved from stock, including exact-fit remnants. */
|
||||
blankLength?: number;
|
||||
blankWidth?: number;
|
||||
blankLength: number;
|
||||
blankWidth: number;
|
||||
stockId: string;
|
||||
reused: boolean;
|
||||
cut: boolean;
|
||||
|
|
@ -108,9 +110,9 @@ export interface FlooringPiece {
|
|||
/** Cutting template, relative to the bounding-box origin in board coordinates. */
|
||||
template: Point[];
|
||||
/** Ends that must retain factory joints; ripEdge names the board-axis long edge(s) to trim. */
|
||||
factoryLeft?: boolean;
|
||||
factoryRight?: boolean;
|
||||
ripEdge?: 'top' | 'bottom' | 'both' | null;
|
||||
factoryLeft: boolean;
|
||||
factoryRight: boolean;
|
||||
ripEdge: 'top' | 'bottom' | 'both' | null;
|
||||
}
|
||||
export interface FlooringRow {
|
||||
roomId: string;
|
||||
|
|
@ -145,7 +147,7 @@ export interface SkirtingPiece {
|
|||
end: Point;
|
||||
length: number;
|
||||
/** Preparation length reserved from stock; a whole fractional length stays exact. */
|
||||
blankLength?: number;
|
||||
blankLength: number;
|
||||
backLength: number;
|
||||
leftMitre: number;
|
||||
rightMitre: number;
|
||||
|
|
@ -153,17 +155,17 @@ export interface SkirtingPiece {
|
|||
rightType: 'square' | 'external' | 'internal' | 'cope';
|
||||
stockId: string;
|
||||
/** Plan section: x follows installation direction, back y=0, front y=thickness. */
|
||||
template?: Point[];
|
||||
template: [Point, Point, Point, Point];
|
||||
/** Full-thickness installed footprint after coping, separate from the cutting blank. */
|
||||
installedTemplate?: Point[];
|
||||
installedTemplate: [Point, Point, Point, Point];
|
||||
/** 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;
|
||||
leftOverlap: number;
|
||||
rightOverlap: number;
|
||||
/** Positive means the front cut point is farther right than the back point. */
|
||||
leftBevel?: number;
|
||||
rightBevel?: number;
|
||||
leftBevel: number;
|
||||
rightBevel: number;
|
||||
}
|
||||
export interface SkirtingStock {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import LZString from 'lz-string';
|
||||
import { defaultState } from './defaults';
|
||||
import { PERSISTENCE_LIMITS, PROJECT_LIMITS } from './limits';
|
||||
import type {
|
||||
Door,
|
||||
FlooringSettings,
|
||||
|
|
@ -10,21 +10,6 @@ import type {
|
|||
Wall,
|
||||
} from './types';
|
||||
|
||||
/** Resource limits apply to links as well as edits made inside the app. */
|
||||
export const URL_LIMITS = {
|
||||
queryLength: 100_000,
|
||||
hashLength: 100_000,
|
||||
jsonLength: 1_000_000,
|
||||
rooms: 20,
|
||||
walls: 100,
|
||||
doors: 30,
|
||||
legacyStockLengths: 30,
|
||||
identifierLength: 120,
|
||||
nameLength: 200,
|
||||
completedBoards: 12_000,
|
||||
searchLength: 200,
|
||||
} as const;
|
||||
|
||||
const SECTIONS = ['measurements', 'materials', 'cuts'] as const;
|
||||
|
||||
function object(value: unknown, field: string): Record<string, unknown> {
|
||||
|
|
@ -46,7 +31,7 @@ function number(value: unknown, field: string): number {
|
|||
function string(
|
||||
value: unknown,
|
||||
field: string,
|
||||
maxLength: number = URL_LIMITS.identifierLength,
|
||||
maxLength: number = PERSISTENCE_LIMITS.identifierLength,
|
||||
): string {
|
||||
if (typeof value !== 'string' || value.length > maxLength) {
|
||||
throw new Error(`${field} must be text of at most ${maxLength} characters.`);
|
||||
|
|
@ -89,22 +74,20 @@ function uniqueValues<T extends string>(items: T[], field: string): T[] {
|
|||
}
|
||||
|
||||
function completedBoards(value: unknown): string[] {
|
||||
const entries = array(
|
||||
value === undefined ? [] : value,
|
||||
'Completed boards',
|
||||
URL_LIMITS.completedBoards,
|
||||
).map((value) => {
|
||||
const entries = array(value, 'Completed boards', PERSISTENCE_LIMITS.completedBoards).map(
|
||||
(value) => {
|
||||
const key = string(value, 'Completed board');
|
||||
if (!/^(?:floor:B|skirting:S)[1-9]\d*$/.test(key))
|
||||
throw new Error('Completed boards must use floor:B1 or skirting:S1 identifiers.');
|
||||
return key;
|
||||
});
|
||||
},
|
||||
);
|
||||
return uniqueValues(entries, 'Completed boards');
|
||||
}
|
||||
|
||||
function collapsedSections(value: unknown): PlannerSection[] {
|
||||
return uniqueValues(
|
||||
array(value === undefined ? [] : value, 'Collapsed sections', SECTIONS.length).map((value) =>
|
||||
array(value, 'Collapsed sections', SECTIONS.length).map((value) =>
|
||||
choice(value, SECTIONS, 'Collapsed section'),
|
||||
),
|
||||
'Collapsed sections',
|
||||
|
|
@ -126,7 +109,10 @@ function wall(value: unknown): Wall {
|
|||
id: identifier(data.id, 'Wall identifier'),
|
||||
length: number(data.length, 'Wall length'),
|
||||
turn: number(data.turn, 'Wall turn'),
|
||||
doors: uniqueIds(array(data.doors, 'Wall doors', URL_LIMITS.doors).map(door), 'Wall doors'),
|
||||
doors: uniqueIds(
|
||||
array(data.doors, 'Wall doors', PROJECT_LIMITS.doorsPerWall).map(door),
|
||||
'Wall doors',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -134,8 +120,11 @@ function room(value: unknown): Room {
|
|||
const data = object(value, 'Room');
|
||||
return {
|
||||
id: identifier(data.id, 'Room identifier'),
|
||||
name: string(data.name, 'Room name', URL_LIMITS.nameLength),
|
||||
walls: uniqueIds(array(data.walls, 'Room walls', URL_LIMITS.walls).map(wall), 'Room walls'),
|
||||
name: string(data.name, 'Room name', PROJECT_LIMITS.nameLength),
|
||||
walls: uniqueIds(
|
||||
array(data.walls, 'Room walls', PROJECT_LIMITS.wallsPerRoom).map(wall),
|
||||
'Room walls',
|
||||
),
|
||||
direction: choice(data.direction, [0, 90], 'Flooring direction'),
|
||||
};
|
||||
}
|
||||
|
|
@ -147,11 +136,7 @@ function flooring(value: unknown): FlooringSettings {
|
|||
width: number(data.width, 'Board width'),
|
||||
gap: number(data.gap, 'Expansion gap'),
|
||||
pattern: choice(data.pattern, ['brick', 'random'], 'Flooring pattern'),
|
||||
optimisation: choice(
|
||||
data.optimisation === undefined ? 'cuts' : data.optimisation,
|
||||
['cuts', 'random'] as const,
|
||||
'Flooring optimisation',
|
||||
),
|
||||
optimisation: choice(data.optimisation, ['cuts', 'random'] as const, 'Flooring optimisation'),
|
||||
minOffset: number(data.minOffset, 'Minimum offset'),
|
||||
maxOffset: number(data.maxOffset, 'Maximum offset'),
|
||||
minLength: number(data.minLength, 'Minimum board length'),
|
||||
|
|
@ -160,28 +145,13 @@ function flooring(value: unknown): FlooringSettings {
|
|||
};
|
||||
}
|
||||
|
||||
function skirting(value: unknown): { settings: SkirtingSettings; legacyCalculations: boolean } {
|
||||
function skirting(value: unknown): SkirtingSettings {
|
||||
const data = object(value, 'Skirting settings');
|
||||
// Earlier links allowed several purchased lengths. The first remains the chosen size.
|
||||
const legacyLengths =
|
||||
data.stockLength === undefined
|
||||
? array(data.stockLengths, 'Skirting stock lengths', URL_LIMITS.legacyStockLengths).map(
|
||||
(value) => number(value, 'Skirting stock length'),
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
settings: {
|
||||
stockLength:
|
||||
data.stockLength === undefined
|
||||
? (legacyLengths[0] ?? 0)
|
||||
: number(data.stockLength, 'Skirting board length'),
|
||||
stockLength: number(data.stockLength, 'Skirting board length'),
|
||||
thickness: number(data.thickness, 'Skirting thickness'),
|
||||
minLength: number(data.minLength, 'Minimum skirting length'),
|
||||
kerf: number(data.kerf, 'Skirting saw kerf'),
|
||||
},
|
||||
// This format predates coping blanks and rounded stock allocation for both
|
||||
// materials. Even a single stock size can now assign different cuts to an ID.
|
||||
legacyCalculations: data.stockLength === undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -189,42 +159,35 @@ function skirting(value: unknown): { settings: SkirtingSettings; legacyCalculati
|
|||
function parseState(value: unknown): UrlState {
|
||||
const data = object(value, 'Plan');
|
||||
const project = object(data.project, 'Project');
|
||||
if (project.version !== 1) {
|
||||
if (project.version !== 2) {
|
||||
throw new Error(
|
||||
'This project version is unsupported. Open it in the app version that created it.',
|
||||
);
|
||||
}
|
||||
const rooms = uniqueIds(
|
||||
array(project.rooms, 'Project rooms', URL_LIMITS.rooms).map(room),
|
||||
array(project.rooms, 'Project rooms', PROJECT_LIMITS.rooms).map(room),
|
||||
'Project rooms',
|
||||
);
|
||||
if (rooms.length === 0) throw new Error('A project needs at least one room.');
|
||||
const view = object(data.view, 'View settings');
|
||||
const roomId = string(view.roomId, 'Selected room');
|
||||
const skirt = skirting(project.skirting);
|
||||
const completed = completedBoards(project.completedBoards);
|
||||
const previousOptimiser =
|
||||
object(project.flooring, 'Flooring settings').optimisation === undefined;
|
||||
return {
|
||||
project: {
|
||||
version: 1,
|
||||
name: string(project.name, 'Project name', URL_LIMITS.nameLength),
|
||||
version: 2,
|
||||
name: string(project.name, 'Project name', PROJECT_LIMITS.nameLength),
|
||||
rooms,
|
||||
flooring: flooring(project.flooring),
|
||||
skirting: skirt.settings,
|
||||
skirting: skirting(project.skirting),
|
||||
tolerance: number(project.tolerance, 'Measurement tolerance'),
|
||||
completedBoards: skirt.legacyCalculations || previousOptimiser ? [] : completed,
|
||||
completedBoards: completedBoards(project.completedBoards),
|
||||
},
|
||||
view: {
|
||||
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
|
||||
selectedPiece:
|
||||
view.selectedPiece === null ? null : string(view.selectedPiece, 'Selected piece'),
|
||||
search: string(
|
||||
view.search === undefined ? '' : view.search,
|
||||
'Cut search',
|
||||
URL_LIMITS.searchLength,
|
||||
),
|
||||
search: string(view.search, 'Cut search', PERSISTENCE_LIMITS.searchLength),
|
||||
collapsedSections: collapsedSections(view.collapsedSections),
|
||||
material: choice(view.material, ['flooring', 'skirting'], 'Plan view'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -262,17 +225,18 @@ export function encodeState(state: UrlState): string {
|
|||
});
|
||||
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 > URL_LIMITS.queryLength)
|
||||
if (query.length > PERSISTENCE_LIMITS.queryLength)
|
||||
throw new Error('This plan link is too large. Split it into smaller projects.');
|
||||
return query;
|
||||
}
|
||||
|
||||
function parseQuery(query: string): UrlState {
|
||||
if (query.length > URL_LIMITS.queryLength)
|
||||
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);
|
||||
|
|
@ -315,7 +279,7 @@ function parseQuery(query: string): UrlState {
|
|||
throw new Error(`${prefix} indices must be consecutive, starting at 1.`);
|
||||
return ordered;
|
||||
};
|
||||
const roomIndices = indices('room', URL_LIMITS.rooms);
|
||||
const roomIndices = indices('room', PROJECT_LIMITS.rooms);
|
||||
const rooms = roomIndices.length
|
||||
? roomIndices.map((roomIndex) => {
|
||||
const prefix = `room.${roomIndex}`;
|
||||
|
|
@ -324,14 +288,14 @@ function parseQuery(query: string): UrlState {
|
|||
id,
|
||||
name: text(`${prefix}.name`, `Room ${roomIndex}`),
|
||||
direction: numeric(`${prefix}.direction`, 0),
|
||||
walls: indices(`${prefix}.wall`, URL_LIMITS.walls).map((wallIndex) => {
|
||||
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`, URL_LIMITS.doors).map((doorIndex) => {
|
||||
doors: indices(`${wallPrefix}.door`, PROJECT_LIMITS.doorsPerWall).map((doorIndex) => {
|
||||
const doorPrefix = `${wallPrefix}.door.${doorIndex}`;
|
||||
return {
|
||||
id: text(`${doorPrefix}.id`, `${wallId}-door-${doorIndex}`),
|
||||
|
|
@ -344,24 +308,12 @@ function parseQuery(query: string): UrlState {
|
|||
};
|
||||
})
|
||||
: defaults.project.rooms;
|
||||
const stockValues = params.getAll('skirting.stock');
|
||||
if (stockValues.length > URL_LIMITS.legacyStockLengths)
|
||||
throw new Error(
|
||||
`Skirting stock lengths supports at most ${URL_LIMITS.legacyStockLengths} entries.`,
|
||||
);
|
||||
const stock = params.has('skirting.length')
|
||||
? { stockLength: numeric('skirting.length') }
|
||||
: {
|
||||
stockLengths: !stockValues.length
|
||||
? [defaults.project.skirting.stockLength]
|
||||
: stockValues.length === 1 && stockValues[0] === ''
|
||||
? []
|
||||
: stockValues.map((value) => measurement(value, 'skirting.stock')),
|
||||
};
|
||||
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', 1),
|
||||
version: numeric('version', defaults.project.version),
|
||||
name: text('name', defaults.project.name),
|
||||
tolerance: numeric('tolerance', defaults.project.tolerance),
|
||||
completedBoards: params.getAll('done'),
|
||||
|
|
@ -369,15 +321,13 @@ function parseQuery(query: string): UrlState {
|
|||
flooring: Object.fromEntries(
|
||||
Object.entries(floor).map(([key, value]) => [
|
||||
key,
|
||||
key === 'optimisation' && !params.has('flooring.optimisation')
|
||||
? undefined
|
||||
: typeof value === 'number'
|
||||
typeof value === 'number'
|
||||
? numeric(`flooring.${key}`, value)
|
||||
: text(`flooring.${key}`, value),
|
||||
]),
|
||||
),
|
||||
skirting: {
|
||||
...stock,
|
||||
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),
|
||||
|
|
@ -388,42 +338,17 @@ function parseQuery(query: string): UrlState {
|
|||
selectedPiece: params.has('selectedCut') ? text('selectedCut') : null,
|
||||
search: text('search', ''),
|
||||
collapsedSections: params.getAll('collapsed'),
|
||||
material: text('view', 'flooring'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Read existing shared links so the hook can replace them with the readable query format. */
|
||||
function parseLegacy(hash: string): UrlState {
|
||||
if (hash.length > URL_LIMITS.hashLength)
|
||||
throw new Error('The link is too large. Use a smaller project link.');
|
||||
if (!hash.startsWith('#plan='))
|
||||
throw new Error('Use a complete plan query or an existing #plan= link.');
|
||||
const compressed = decodeURIComponent(hash.slice(6));
|
||||
if (!compressed || !/^[A-Za-z0-9+$-]+$/.test(compressed))
|
||||
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
|
||||
const json = LZString.decompressFromEncodedURIComponent(compressed);
|
||||
if (!json)
|
||||
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
|
||||
if (json.length > URL_LIMITS.jsonLength)
|
||||
throw new Error('The project in this link is too large. Use a smaller project link.');
|
||||
if (LZString.compressToEncodedURIComponent(json) !== compressed)
|
||||
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(json);
|
||||
} catch {
|
||||
throw new Error('The project data is damaged. Copy the complete plan link again.');
|
||||
}
|
||||
return parseState(value);
|
||||
}
|
||||
|
||||
/** Accept a readable query or a legacy fragment; never throw for an untrusted link. */
|
||||
/** Accept a readable query and never throw for an untrusted link. */
|
||||
export function decodeState(link: string): { state: UrlState; error: string | null } {
|
||||
if (link === '' || link === '#' || link === '?') return { state: defaultState(), error: null };
|
||||
try {
|
||||
if (!link.startsWith('?') && !link.startsWith('#'))
|
||||
throw new Error('Use the complete plan query, starting with ?.');
|
||||
return { state: link.startsWith('?') ? parseQuery(link) : parseLegacy(link), error: null };
|
||||
if (!link.startsWith('?')) throw new Error('Use the complete plan query, starting with ?.');
|
||||
return { state: parseQuery(link), error: null };
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof URIError
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ export const PROJECT_STORAGE_KEY = 'floor-planner.project';
|
|||
|
||||
const readLocation = () => {
|
||||
// The landing-page CTA opens the last project; it is not a project override.
|
||||
const link =
|
||||
window.location.search === '?planner' ? '' : window.location.search || window.location.hash;
|
||||
if (link && link !== '?' && link !== '#') return decodeState(link);
|
||||
const link = window.location.search === '?planner' ? '' : window.location.search;
|
||||
if (link && link !== '?') return decodeState(link);
|
||||
try {
|
||||
const saved = decodeState(window.localStorage.getItem(PROJECT_STORAGE_KEY) ?? '');
|
||||
if (!saved.error) return saved;
|
||||
|
|
@ -36,8 +35,7 @@ export function useProject() {
|
|||
const next = readLocation();
|
||||
if (!next.error) {
|
||||
try {
|
||||
// Canonicalize editable queries and migrate existing fragment links
|
||||
// without creating an extra entry in browser history.
|
||||
// Canonicalize editable queries without creating an extra history entry.
|
||||
const query = encodeState(next.state);
|
||||
if (query !== window.location.search || window.location.hash)
|
||||
window.history.replaceState(null, '', queryUrl(query));
|
||||
|
|
@ -48,11 +46,9 @@ export function useProject() {
|
|||
}
|
||||
setLoaded(next);
|
||||
};
|
||||
window.addEventListener('hashchange', restore);
|
||||
window.addEventListener('popstate', restore);
|
||||
restore();
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', restore);
|
||||
window.removeEventListener('popstate', restore);
|
||||
};
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { nextId } from './defaults';
|
||||
import { PROJECT_LIMITS } from './limits';
|
||||
import type { Room } from './types';
|
||||
|
||||
/** Inserting a segment never renumbers walls or moves their attached doors. */
|
||||
export function insertWallAfter(room: Room, wallId: string): Room {
|
||||
const index = room.walls.findIndex((wall) => wall.id === wallId);
|
||||
if (index < 0 || room.walls.length >= 100) return room;
|
||||
if (index < 0 || room.walls.length >= PROJECT_LIMITS.wallsPerRoom) return room;
|
||||
const wall = {
|
||||
id: nextId(
|
||||
`${room.id}-w`,
|
||||
|
|
|
|||
300
src/styles.css
300
src/styles.css
|
|
@ -398,7 +398,7 @@ summary span {
|
|||
.wall-head,
|
||||
.wall-row {
|
||||
display: grid;
|
||||
grid-template-columns: 25px minmax(0, 1fr) minmax(0, 0.8fr) 40px;
|
||||
grid-template-columns: 32px minmax(0, 1fr) minmax(0, 0.8fr);
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
|
@ -408,9 +408,36 @@ summary span {
|
|||
}
|
||||
.wall-row {
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 7px;
|
||||
border-bottom: 1px solid #dde2e9;
|
||||
}
|
||||
.wall-head > span:last-child {
|
||||
display: none;
|
||||
}
|
||||
.wall-actions {
|
||||
grid-column: 2 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
.wall-actions .icon-button {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
.wall-drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
.wall-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.wall-row.drag-over {
|
||||
border-top: 3px solid var(--blue);
|
||||
background: #e9f0fc;
|
||||
}
|
||||
.wall-row input {
|
||||
padding: 8px 6px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.wall-code {
|
||||
font: 12px var(--mono);
|
||||
|
|
@ -626,16 +653,6 @@ summary span {
|
|||
stroke-width: 2px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.pending {
|
||||
color: #a13e00;
|
||||
}
|
||||
.drawing-legend > span {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
|
|
@ -770,30 +787,25 @@ summary span {
|
|||
margin-top: 2px;
|
||||
}
|
||||
#cut-plan {
|
||||
margin-top: 35px;
|
||||
margin: 0;
|
||||
scroll-margin-top: 60px;
|
||||
}
|
||||
.cut-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 0;
|
||||
border-top: 3px solid var(--ink);
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.cut-list-header h2 {
|
||||
font-size: 27px;
|
||||
}
|
||||
.cut-list-header .button {
|
||||
border-color: var(--ink);
|
||||
display: none;
|
||||
}
|
||||
.cut-list-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0 28px;
|
||||
}
|
||||
.cut-list-header {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
.material-cuts {
|
||||
min-width: 0;
|
||||
|
|
@ -833,17 +845,6 @@ summary span {
|
|||
padding: 18px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@media (min-width: 1600px) {
|
||||
.app-header {
|
||||
padding-left: max(32px, calc((100vw - 1656px) / 2));
|
||||
|
|
@ -861,13 +862,6 @@ summary span {
|
|||
align-self: start;
|
||||
margin-top: 0;
|
||||
}
|
||||
.cut-list-card {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 22px;
|
||||
}
|
||||
.cut-list-header {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 699px) {
|
||||
.app-header {
|
||||
|
|
@ -957,12 +951,6 @@ summary span {
|
|||
.wall-head {
|
||||
font-size: 11px;
|
||||
}
|
||||
.wall-row {
|
||||
grid-template-columns: 29px minmax(0, 1fr) minmax(0, 0.8fr) 44px;
|
||||
}
|
||||
.wall-row .icon-button {
|
||||
width: 44px;
|
||||
}
|
||||
.text-button,
|
||||
summary {
|
||||
font-size: 13px;
|
||||
|
|
@ -980,9 +968,6 @@ summary span {
|
|||
.tooltip-content {
|
||||
right: 0;
|
||||
}
|
||||
.badge {
|
||||
font-size: 9px;
|
||||
}
|
||||
.purchase-summary h2 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
|
@ -1001,26 +986,6 @@ summary span {
|
|||
.waste-stat {
|
||||
font-size: 14px;
|
||||
}
|
||||
#cut-plan {
|
||||
margin-top: 28px;
|
||||
}
|
||||
.cut-list-header {
|
||||
padding-top: 18px;
|
||||
}
|
||||
.cut-list-header h2 {
|
||||
font-size: 25px;
|
||||
}
|
||||
.cut-list-header .helper {
|
||||
font-size: 11px;
|
||||
}
|
||||
.cut-list-header .button {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cut-list-header .button svg {
|
||||
display: none;
|
||||
}
|
||||
.material-cuts > h3 {
|
||||
font-size: 17px;
|
||||
padding: 14px;
|
||||
|
|
@ -1049,10 +1014,14 @@ summary span {
|
|||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
width: 76px;
|
||||
min-width: 76px;
|
||||
padding: 6px;
|
||||
background: #f0f5ff;
|
||||
border-color: #1647ab;
|
||||
color: #1647ab;
|
||||
}
|
||||
.cut-thumbnail {
|
||||
display: block;
|
||||
|
|
@ -1071,7 +1040,9 @@ summary span {
|
|||
height: 100%;
|
||||
}
|
||||
.cut-preview-code {
|
||||
font: 700 12px var(--mono);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.skirting-end-grid {
|
||||
display: grid;
|
||||
|
|
@ -1135,16 +1106,19 @@ summary span {
|
|||
}
|
||||
.cut-dialog-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
padding: 16px 20px 22px;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.exact-preview,
|
||||
.skirting-full-preview {
|
||||
margin: 0;
|
||||
}
|
||||
.exact-preview figcaption {
|
||||
font: 12px var(--mono);
|
||||
font:
|
||||
13px Arial,
|
||||
sans-serif;
|
||||
color: #4b5c73;
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
|
@ -1242,20 +1216,11 @@ summary span {
|
|||
.cut-location {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.cut-preview-button {
|
||||
flex-shrink: 0;
|
||||
background: #f0f5ff;
|
||||
border-color: #1647ab;
|
||||
color: #1647ab;
|
||||
}
|
||||
.skirting-preview-button {
|
||||
background: #fff5eb;
|
||||
border-color: #b74900;
|
||||
color: #983e03;
|
||||
}
|
||||
.cut-preview-code {
|
||||
font-family: inherit;
|
||||
}
|
||||
.cut-preview-code > span {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
|
@ -1299,20 +1264,10 @@ summary span {
|
|||
sans-serif;
|
||||
fill: #17263f;
|
||||
}
|
||||
.cut-dialog-body {
|
||||
font-size: 14px;
|
||||
gap: 14px;
|
||||
}
|
||||
.cut-origin {
|
||||
font-size: 14px;
|
||||
color: #4b5c73;
|
||||
}
|
||||
.exact-preview figcaption {
|
||||
font:
|
||||
13px Arial,
|
||||
sans-serif;
|
||||
color: #4b5c73;
|
||||
}
|
||||
.cut-dialog-body .helper {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
|
@ -1463,6 +1418,86 @@ summary span {
|
|||
.planning-panels {
|
||||
min-width: 0;
|
||||
}
|
||||
.setup-intro {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.setup-intro h2 {
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.setup-intro p {
|
||||
color: #4b5c73;
|
||||
font-size: 14px;
|
||||
max-width: 48ch;
|
||||
}
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.step-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
border: 1px solid var(--ink);
|
||||
border-radius: 50%;
|
||||
font: 13px var(--mono);
|
||||
}
|
||||
.panel-description {
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
.shape-heading {
|
||||
margin-top: 18px;
|
||||
}
|
||||
.shape-heading + .template-options {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.shape-help {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 18px;
|
||||
background: #eef2f9;
|
||||
border-left: 3px solid var(--blue);
|
||||
}
|
||||
.material-help {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.advanced-settings {
|
||||
border-block: 1px solid var(--line);
|
||||
margin: 16px 0;
|
||||
}
|
||||
.advanced-settings[open] {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.plan-view-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 0 12px;
|
||||
}
|
||||
.plan-view-toolbar > span {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.plan-view-switch {
|
||||
flex: 1;
|
||||
}
|
||||
.plan-view-switch button {
|
||||
min-height: 44px;
|
||||
gap: 8px;
|
||||
}
|
||||
.plan-view-switch button.active .material-key {
|
||||
border: 1px solid white;
|
||||
}
|
||||
.plan-view-switch button.active:has(.skirt-key) {
|
||||
background: var(--orange);
|
||||
}
|
||||
.planner-panel {
|
||||
border-top: 2px solid var(--ink);
|
||||
margin-bottom: 20px;
|
||||
|
|
@ -1482,6 +1517,7 @@ summary span {
|
|||
font-weight: 700;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
scroll-margin-top: 16px;
|
||||
}
|
||||
.panel-toggle > span:last-child {
|
||||
font-size: 25px;
|
||||
|
|
@ -1500,25 +1536,6 @@ summary span {
|
|||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.planner-panel .cut-list-card {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
.planner-panel .cut-list-header {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
}
|
||||
.planner-panel .cut-list-header h2 {
|
||||
display: none;
|
||||
}
|
||||
.planner-panel .cut-list-header .helper {
|
||||
font-size: 12px;
|
||||
}
|
||||
.planner-panel #cut-plan {
|
||||
margin: 0;
|
||||
}
|
||||
.planner-panel .purchase-summary {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
|
@ -1530,10 +1547,10 @@ summary span {
|
|||
background: #fffdf7;
|
||||
}
|
||||
.drawing-column .drawing-viewport {
|
||||
height: clamp(180px, calc(100dvh - 260px), 780px);
|
||||
height: clamp(180px, calc(100dvh - 316px), 780px);
|
||||
}
|
||||
.drawing-column:has(.plan-search-status) .drawing-viewport {
|
||||
height: clamp(160px, calc(100dvh - 335px), 720px);
|
||||
height: clamp(160px, calc(100dvh - 391px), 720px);
|
||||
}
|
||||
.drawing-column .room-svg {
|
||||
width: 100%;
|
||||
|
|
@ -1610,6 +1627,17 @@ summary span {
|
|||
color: #345c55;
|
||||
}
|
||||
@media (max-width: 899px) {
|
||||
.plan-view-toolbar {
|
||||
padding-block: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
.plan-view-switch button {
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
.setup-intro {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.drawing-header {
|
||||
padding: 8px 10px;
|
||||
gap: 6px;
|
||||
|
|
@ -1654,9 +1682,10 @@ summary span {
|
|||
.panel-toggle {
|
||||
font-size: 18px;
|
||||
min-height: 50px;
|
||||
scroll-margin-top: 390px;
|
||||
}
|
||||
.planner-panel {
|
||||
scroll-margin-top: 330px;
|
||||
scroll-margin-top: 390px;
|
||||
}
|
||||
.board-cuts {
|
||||
padding-inline: 12px;
|
||||
|
|
@ -1668,7 +1697,7 @@ summary span {
|
|||
select,
|
||||
button,
|
||||
summary {
|
||||
scroll-margin-top: 350px;
|
||||
scroll-margin-top: 410px;
|
||||
scroll-margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1677,41 +1706,6 @@ summary span {
|
|||
position: static;
|
||||
}
|
||||
}
|
||||
.wall-head,
|
||||
.wall-row {
|
||||
grid-template-columns: 32px minmax(0, 1fr) minmax(0, 0.8fr);
|
||||
}
|
||||
.wall-head > span:last-child {
|
||||
display: none;
|
||||
}
|
||||
.wall-row {
|
||||
padding-bottom: 7px;
|
||||
border-bottom: 1px solid #dde2e9;
|
||||
}
|
||||
.wall-actions {
|
||||
grid-column: 2 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
.wall-actions .icon-button {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
.wall-drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
.wall-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.wall-row.drag-over {
|
||||
border-top: 3px solid var(--blue);
|
||||
background: #e9f0fc;
|
||||
}
|
||||
.wall-row input {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cut-search {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
|
|
|
|||
|
|
@ -9,19 +9,30 @@ import type { FlooringPiece, FlooringPlan, SkirtingPiece, SkirtingPlan } from '.
|
|||
const project = defaultProject();
|
||||
project.rooms.push(rectangle('room-2', 'Office'));
|
||||
function floorPiece(id: string, stockId: string, roomId = 'room-1', cut = true): FlooringPiece {
|
||||
const length = cut ? 400 : 1200;
|
||||
return {
|
||||
id,
|
||||
stockId,
|
||||
roomId,
|
||||
cut,
|
||||
length: cut ? 400 : 1200,
|
||||
length,
|
||||
width: 190,
|
||||
blankLength: length,
|
||||
blankWidth: 190,
|
||||
row: 1,
|
||||
column: 1,
|
||||
polygon: [],
|
||||
template: [],
|
||||
template: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: length, y: 0 },
|
||||
{ x: length, y: 190 },
|
||||
{ x: 0, y: 190 },
|
||||
],
|
||||
shaped: false,
|
||||
reused: false,
|
||||
factoryLeft: false,
|
||||
factoryRight: false,
|
||||
ripEdge: null,
|
||||
};
|
||||
}
|
||||
const flooring: FlooringPlan = {
|
||||
|
|
@ -46,11 +57,18 @@ const flooring: FlooringPlan = {
|
|||
issues: [],
|
||||
};
|
||||
function skirtPiece(id: string, stockId: string, roomId = 'room-1', length = 900): SkirtingPiece {
|
||||
const template: SkirtingPiece['template'] = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: length, y: 0 },
|
||||
{ x: length, y: 18 },
|
||||
{ x: 0, y: 18 },
|
||||
];
|
||||
return {
|
||||
id,
|
||||
stockId,
|
||||
roomId,
|
||||
length,
|
||||
blankLength: length,
|
||||
backLength: length,
|
||||
wallId: 'w1',
|
||||
wallIndex: 0,
|
||||
|
|
@ -60,6 +78,13 @@ function skirtPiece(id: string, stockId: string, roomId = 'room-1', length = 900
|
|||
rightMitre: 0,
|
||||
leftType: 'square',
|
||||
rightType: 'square',
|
||||
template,
|
||||
installedTemplate: template,
|
||||
visibleLength: length,
|
||||
leftOverlap: 0,
|
||||
rightOverlap: 0,
|
||||
leftBevel: 0,
|
||||
rightBevel: 0,
|
||||
};
|
||||
}
|
||||
const skirting: SkirtingPlan = {
|
||||
|
|
|
|||
|
|
@ -11,19 +11,31 @@ import { planSkirting } from '../src/lib/skirting';
|
|||
import type { FlooringPiece, FlooringPlan } from '../src/lib/types';
|
||||
|
||||
function piece(id: string, stockId: string, changes: Partial<FlooringPiece> = {}): FlooringPiece {
|
||||
const length = changes.length ?? 600;
|
||||
const width = changes.width ?? 190;
|
||||
return {
|
||||
id,
|
||||
roomId: 'room-1',
|
||||
row: 1,
|
||||
column: 1,
|
||||
polygon: [],
|
||||
length: 600,
|
||||
width: 190,
|
||||
length,
|
||||
width,
|
||||
blankLength: length,
|
||||
blankWidth: width,
|
||||
stockId,
|
||||
reused: false,
|
||||
cut: true,
|
||||
shaped: false,
|
||||
template: [],
|
||||
template: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: length, y: 0 },
|
||||
{ x: length, y: width },
|
||||
{ x: 0, y: width },
|
||||
],
|
||||
factoryLeft: false,
|
||||
factoryRight: false,
|
||||
ripEdge: null,
|
||||
...changes,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { CutDialog } from '../src/components/CutDialog';
|
|||
import { planSkirting } from '../src/lib/skirting';
|
||||
import { skirtingEndLabel } from '../src/lib/cut-list';
|
||||
import { defaultProject } from '../src/lib/defaults';
|
||||
import { dimensionedOutline, flooringTemplate, skirtingEndBoxes } from '../src/lib/cut-preview';
|
||||
import { dimensionedOutline, skirtingEndBoxes } from '../src/lib/cut-preview';
|
||||
import { cutMm, degree } from '../src/lib/format';
|
||||
import type { FlooringPiece, Point, SkirtingPiece } from '../src/lib/types';
|
||||
|
||||
|
|
@ -18,13 +18,23 @@ const floor: FlooringPiece = {
|
|||
polygon: [],
|
||||
length: 600.001,
|
||||
width: 190,
|
||||
blankLength: 600.5,
|
||||
blankWidth: 190,
|
||||
stockId: 'B1',
|
||||
reused: false,
|
||||
cut: true,
|
||||
shaped: false,
|
||||
template: [],
|
||||
template: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 600.001, y: 0 },
|
||||
{ x: 600.001, y: 190 },
|
||||
{ x: 0, y: 190 },
|
||||
],
|
||||
factoryLeft: false,
|
||||
factoryRight: false,
|
||||
ripEdge: null,
|
||||
};
|
||||
const template: Point[] = [
|
||||
const template: SkirtingPiece['template'] = [
|
||||
{ x: 18, y: 0 },
|
||||
{ x: 1182, y: 0 },
|
||||
{ x: 1200, y: 18 },
|
||||
|
|
@ -38,6 +48,7 @@ const skirt: SkirtingPiece = {
|
|||
start: { x: 0, y: 0 },
|
||||
end: { x: 1164, y: 0 },
|
||||
length: 1200,
|
||||
blankLength: 1200,
|
||||
backLength: 1164,
|
||||
leftMitre: 45,
|
||||
rightMitre: 45,
|
||||
|
|
@ -45,11 +56,17 @@ const skirt: SkirtingPiece = {
|
|||
rightType: 'external',
|
||||
stockId: 'S1',
|
||||
template,
|
||||
installedTemplate: template,
|
||||
visibleLength: 1200,
|
||||
leftOverlap: 0,
|
||||
rightOverlap: 0,
|
||||
leftBevel: -45,
|
||||
rightBevel: 45,
|
||||
};
|
||||
|
||||
describe('cut shape previews', () => {
|
||||
it('keeps precise geometry while rounding displayed cuts up to half millimetres', () => {
|
||||
const points = flooringTemplate(floor);
|
||||
const points = floor.template;
|
||||
expect(points).toEqual([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 600.001, y: 0 },
|
||||
|
|
@ -73,6 +90,12 @@ describe('cut shape previews', () => {
|
|||
width: 190.1,
|
||||
blankLength: 597.2,
|
||||
blankWidth: 190.2,
|
||||
template: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 597.2, y: 0 },
|
||||
{ x: 597.2, y: 190.1 },
|
||||
{ x: 0, y: 190.1 },
|
||||
],
|
||||
reused: true,
|
||||
};
|
||||
const markup = renderToStaticMarkup(createElement(FlooringPreview, { piece }));
|
||||
|
|
@ -289,8 +312,8 @@ describe('cut shape previews', () => {
|
|||
expect(skirtingEndLabel({ ...piece, rightType: 'internal', rightMitre: 30 }, 'right')).toBe(
|
||||
'Receive cope · wall-fit 30°',
|
||||
);
|
||||
const prepared = piece.template!;
|
||||
const installed = piece.installedTemplate!;
|
||||
const prepared = piece.template;
|
||||
const installed = piece.installedTemplate;
|
||||
expect(prepared[0].x).toBeLessThan(installed[0].x);
|
||||
expect(prepared[3].x).toBeCloseTo(installed[3].x);
|
||||
const compact = renderToStaticMarkup(
|
||||
|
|
|
|||
4
tests/fixtures/angled-room-f8.json
vendored
4
tests/fixtures/angled-room-f8.json
vendored
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"project": {
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"name": "My flooring project",
|
||||
"rooms": [
|
||||
{
|
||||
|
|
@ -106,6 +106,7 @@
|
|||
"width": 190,
|
||||
"gap": 10,
|
||||
"pattern": "random",
|
||||
"optimisation": "cuts",
|
||||
"minOffset": 0.1,
|
||||
"maxOffset": 1000,
|
||||
"minLength": 0.1,
|
||||
|
|
@ -123,6 +124,7 @@
|
|||
},
|
||||
"view": {
|
||||
"roomId": "room-2",
|
||||
"material": "flooring",
|
||||
"selectedPiece": "F8",
|
||||
"search": "F8",
|
||||
"collapsedSections": []
|
||||
|
|
|
|||
11
tests/fixtures/angled-room.json
vendored
11
tests/fixtures/angled-room.json
vendored
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"project": {
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"name": "My flooring project",
|
||||
"rooms": [
|
||||
{
|
||||
|
|
@ -94,6 +94,7 @@
|
|||
"width": 190,
|
||||
"gap": 10,
|
||||
"pattern": "random",
|
||||
"optimisation": "cuts",
|
||||
"minOffset": 0.1,
|
||||
"maxOffset": 1000,
|
||||
"minLength": 0.1,
|
||||
|
|
@ -106,10 +107,14 @@
|
|||
"minLength": 200,
|
||||
"kerf": 3
|
||||
},
|
||||
"tolerance": 50
|
||||
"tolerance": 50,
|
||||
"completedBoards": []
|
||||
},
|
||||
"view": {
|
||||
"roomId": "room-2",
|
||||
"selectedPiece": null
|
||||
"material": "flooring",
|
||||
"selectedPiece": null,
|
||||
"search": "",
|
||||
"collapsedSections": []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const settings: FlooringSettings = {
|
|||
width: 200,
|
||||
gap: 10,
|
||||
pattern: 'brick',
|
||||
optimisation: 'cuts',
|
||||
minOffset: 300,
|
||||
maxOffset: 600,
|
||||
minLength: 200,
|
||||
|
|
@ -68,6 +69,18 @@ function expectStockFits(plan: FlooringPlan, s: FlooringSettings) {
|
|||
expect(used + Math.max(0, pieces.length - 1) * s.kerf).toBeLessThanOrEqual(s.length + 0.02);
|
||||
expect(stock.remaining).toBeGreaterThanOrEqual(0);
|
||||
expect(used + stock.remaining).toBeLessThanOrEqual(s.length + 0.02);
|
||||
// Crosscuts cannot create factory joints: each purchased board supplies one of each end.
|
||||
for (const end of ['factoryLeft', 'factoryRight'] as const) {
|
||||
expect(
|
||||
pieces.filter((piece) => piece[end]).length,
|
||||
`${stock.id} allocates its ${end} more than once`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
for (const piece of pieces.filter((piece) => piece.factoryLeft && piece.factoryRight)) {
|
||||
expect(piece.length).toBeCloseTo(s.length, 3);
|
||||
expect(piece.reused).toBe(false);
|
||||
expect(pieces).toHaveLength(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
function expectOffsets(plan: FlooringPlan, s: FlooringSettings) {
|
||||
|
|
@ -137,19 +150,82 @@ describe('laminate planning', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('does not reuse a cut end where an interior board needs two factory ends', () => {
|
||||
const plan = planFlooring([room()], settings, 1);
|
||||
describe.each([
|
||||
['brick', 'cuts'],
|
||||
['random', 'cuts'],
|
||||
['random', 'random'],
|
||||
] as const)('%s pattern, %s optimisation: factory short ends', (pattern, optimisation) => {
|
||||
const s = { ...settings, pattern, optimisation };
|
||||
|
||||
it.each([0, 90] as const)(
|
||||
'never packs three row ends onto one board, with boards running at %i degrees',
|
||||
(direction) => {
|
||||
const r = direction === 0 ? room(3920, 1620) : room(1620, 3920);
|
||||
r.direction = direction;
|
||||
const plan = planFlooring([r], s, 1);
|
||||
expect(errors(plan)).toEqual([]);
|
||||
expect(plan.rows).toHaveLength(8);
|
||||
expect(plan.reusedCount).toBeGreaterThan(0);
|
||||
for (const row of plan.rows) {
|
||||
const position = (polygon: Point[]) =>
|
||||
Math.min(...polygon.map((point) => (direction === 0 ? point.x : point.y)));
|
||||
const pieces = row.pieceIds
|
||||
.map((id) => plan.pieces.find((piece) => piece.id === id)!)
|
||||
.sort((a, b) => a.column - b.column);
|
||||
for (const interior of pieces.slice(1, -1)) {
|
||||
expect(interior.length).toBe(settings.length);
|
||||
expect(interior.reused).toBe(false);
|
||||
expect(interior.factoryLeft).toBe(true);
|
||||
expect(interior.factoryRight).toBe(true);
|
||||
}
|
||||
.sort((a, b) => position(a.polygon) - position(b.polygon));
|
||||
expect(pieces.length).toBeGreaterThan(2);
|
||||
// Derive the required joints from installed positions, independently of allocation flags.
|
||||
pieces.forEach((piece, index) => {
|
||||
expect(piece.factoryLeft, `${piece.id}: left joint`).toBe(index > 0);
|
||||
expect(piece.factoryRight, `${piece.id}: right joint`).toBe(index < pieces.length - 1);
|
||||
});
|
||||
}
|
||||
const shortestEnds = plan.pieces
|
||||
.filter((piece) => piece.factoryLeft !== piece.factoryRight)
|
||||
.map((piece) => piece.length)
|
||||
.sort((a, b) => a - b)
|
||||
.slice(0, 3);
|
||||
expect(shortestEnds).toHaveLength(3);
|
||||
// Three ends fit by length, including kerf, but cannot share only two factory joints.
|
||||
expect(shortestEnds.reduce((sum, length) => sum + length, 0) + 2 * s.kerf).toBeLessThan(
|
||||
s.length,
|
||||
);
|
||||
for (const stock of plan.stocks) expect(stock.pieceIds.length).toBeLessThanOrEqual(2);
|
||||
expectStockFits(plan, s);
|
||||
expectCoverage(plan, r, s.gap);
|
||||
},
|
||||
);
|
||||
|
||||
it('allows three pieces when the middle cut spans wall to wall and the outer cuts keep opposite ends', () => {
|
||||
const first = room(1620, 220, 'first');
|
||||
const middle = room(220, 414, 'middle');
|
||||
middle.direction = 90;
|
||||
const last = room(1620, 220, 'last');
|
||||
const rooms = [first, middle, last];
|
||||
const plan = planFlooring(rooms, s, 1);
|
||||
expect(errors(plan)).toEqual([]);
|
||||
const triples = plan.stocks.filter((stock) => stock.pieceIds.length === 3);
|
||||
expect(triples).toHaveLength(1);
|
||||
const pieces = triples[0].pieceIds.map((id) => plan.pieces.find((piece) => piece.id === id)!);
|
||||
expect(
|
||||
pieces.map((piece) => ({
|
||||
room: piece.roomId,
|
||||
length: piece.length,
|
||||
left: piece.factoryLeft,
|
||||
right: piece.factoryRight,
|
||||
})),
|
||||
).toEqual([
|
||||
{ room: 'first', length: 400, left: true, right: false },
|
||||
{ room: 'middle', length: 394, left: false, right: false },
|
||||
{ room: 'last', length: 400, left: false, right: true },
|
||||
]);
|
||||
expect(plan.pieces.filter((piece) => piece.roomId === middle.id)).toEqual([pieces[1]]);
|
||||
expect(pieces.map((piece) => piece.reused)).toEqual([false, true, true]);
|
||||
expect(plan.boardCount).toBe(3);
|
||||
expect(plan.cuts).toBe(2);
|
||||
expect(triples[0].remaining).toBe(0);
|
||||
expectStockFits(plan, s);
|
||||
for (const r of rooms) expectCoverage(plan, r, s.gap);
|
||||
});
|
||||
});
|
||||
|
||||
it('produces exact concave fireplace templates and keeps the gap at its external corners', () => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import baseline from '../benchmarks/baseline.json';
|
||||
import { planFlooringOptions } from '../src/lib/flooring';
|
||||
import { planSkirting } from '../src/lib/skirting';
|
||||
import { defaultProject, rectangle } from '../src/lib/defaults';
|
||||
import { buildGeometry, insetPolygon, signedArea } from '../src/lib/geometry';
|
||||
import type { FlooringPlan, Project } from '../src/lib/types';
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ function validateFlooring(plan: FlooringPlan, project: Project) {
|
|||
stock.pieceIds.forEach((id, index) => {
|
||||
const piece = byId.get(id)!;
|
||||
expect(piece.stockId).toBe(stock.id);
|
||||
expect(piece.blankLength!).toBeLessThanOrEqual(remaining + 0.01);
|
||||
expect(piece.blankLength).toBeLessThanOrEqual(remaining + 0.01);
|
||||
expect(!piece.factoryLeft || left).toBe(true);
|
||||
expect(!piece.factoryRight || right).toBe(true);
|
||||
expect(piece.reused).toBe(index > 0);
|
||||
|
|
@ -38,7 +39,7 @@ function validateFlooring(plan: FlooringPlan, project: Project) {
|
|||
crosscuts += Number(cut);
|
||||
expect(piece.cut).toBe(cut || piece.shaped || !!piece.ripEdge);
|
||||
if (cut) {
|
||||
remaining = Math.round(Math.max(0, remaining - piece.blankLength! - s.kerf) * 1000) / 1000;
|
||||
remaining = Math.round(Math.max(0, remaining - piece.blankLength - s.kerf) * 1000) / 1000;
|
||||
if (piece.factoryRight) right = false;
|
||||
else left = false;
|
||||
} else {
|
||||
|
|
@ -136,9 +137,21 @@ describe('measured optimiser quality', () => {
|
|||
totals.pattern += flooringMetrics(options.random, c.project.flooring).patternPenalty;
|
||||
totals.skirtingCuts += skirt.cuts;
|
||||
}
|
||||
expect(totals.cuts).toBeLessThanOrEqual(1840); // Original: 2177.
|
||||
expect(totals.randomCuts).toBeLessThanOrEqual(2130);
|
||||
expect(totals.pattern).toBeLessThanOrEqual(129); // Original: 265.
|
||||
expect(totals.cuts).toBeLessThanOrEqual(1741); // Original: 2177.
|
||||
expect(totals.randomCuts).toBeLessThanOrEqual(1994);
|
||||
expect(totals.pattern).toBeLessThanOrEqual(113); // Original: 265.
|
||||
expect(totals.skirtingCuts).toBeLessThanOrEqual(1436); // Original: 1447.
|
||||
});
|
||||
|
||||
it('packs reserved offcuts before applying the 2000-board cap on a 500-row floor', () => {
|
||||
const project = defaultProject();
|
||||
project.flooring.pattern = 'brick';
|
||||
project.rooms = [rectangle('limit', 'Limit', 4520, 95020)];
|
||||
const plan = planFlooringOptions(project.rooms, project.flooring, 1).cuts;
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
expect(plan.rows).toHaveLength(500);
|
||||
expect(plan.boardCount).toBe(2000);
|
||||
expect(plan.cuts).toBe(750);
|
||||
expect(plan.coveredArea).toBe(4500 * 95000);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ function expectCompleteSkirting(room: Room) {
|
|||
const plan = planSkirting([room], settings, 1);
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
const polygons = plan.pieces.map((piece) =>
|
||||
skirtingPolygon(piece, settings.thickness, signedArea(geometry.vertices)),
|
||||
skirtingPolygon(piece, signedArea(geometry.vertices)),
|
||||
);
|
||||
const inner = insetPolygon(geometry.vertices, settings.thickness);
|
||||
// Exact area of the mitred perimeter band; unlike clipping, this does not
|
||||
|
|
@ -57,12 +57,12 @@ function expectCompleteSkirting(room: Room) {
|
|||
return sum + Math.hypot(next.x - point.x, next.y - point.y) / 1000;
|
||||
}, 0);
|
||||
const piece = plan.pieces[i];
|
||||
const installed = piece.installedTemplate!;
|
||||
const installed = piece.installedTemplate;
|
||||
expect(Math.hypot(polygon[0].x - piece.start.x, polygon[0].y - piece.start.y)).toBeCloseTo(
|
||||
Math.abs(installed[0].x - piece.template![0].x),
|
||||
Math.abs(installed[0].x - piece.template[0].x),
|
||||
);
|
||||
expect(Math.hypot(polygon[1].x - piece.end.x, polygon[1].y - piece.end.y)).toBeCloseTo(
|
||||
Math.abs(installed[1].x - piece.template![1].x),
|
||||
Math.abs(installed[1].x - piece.template[1].x),
|
||||
);
|
||||
expect(
|
||||
Math.abs(overlap(polygon, geometry.vertices) - Math.abs(signedArea(polygon))),
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ describe('skirting cutting plan', () => {
|
|||
const pieces = plan.pieces.filter((piece) => piece.wallIndex === wall);
|
||||
expect(pieces[0].leftType).toBe(wall === 0 ? 'internal' : 'cope');
|
||||
expect(pieces.at(-1)?.rightType).toBe(wall === 3 ? 'cope' : 'internal');
|
||||
expect(pieces.every((piece) => piece.visibleLength! >= settings.minLength - 1e-6)).toBe(true);
|
||||
expect(pieces.reduce((sum, piece) => sum + piece.visibleLength!, 0)).toBeCloseTo(
|
||||
expect(pieces.every((piece) => piece.visibleLength >= settings.minLength - 1e-6)).toBe(true);
|
||||
expect(pieces.reduce((sum, piece) => sum + piece.visibleLength, 0)).toBeCloseTo(
|
||||
[4000, 3000, 4000, 3000][wall] - 36,
|
||||
);
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ describe('skirting cutting plan', () => {
|
|||
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 reversePlan = planSkirting([reversed], { ...settings, stockLength: 6000 }, 5);
|
||||
const reverseFront = reversePlan.pieces.find((piece) => piece.wallIndex === 4)!;
|
||||
|
|
@ -265,7 +265,7 @@ describe('skirting cutting plan', () => {
|
|||
for (const piece of plan.pieces) {
|
||||
expect(piece.length).toBeLessThanOrEqual(340 + 1e-6);
|
||||
expect(piece.backLength).toBeGreaterThanOrEqual(300 - 1e-6);
|
||||
expect(piece.template![2].x - piece.template![3].x).toBeGreaterThanOrEqual(300 - 1e-6);
|
||||
expect(piece.template[2].x - piece.template[3].x).toBeGreaterThanOrEqual(300 - 1e-6);
|
||||
expect(piece.visibleLength).toBeGreaterThanOrEqual(300 - 1e-6);
|
||||
}
|
||||
for (let wallIndex = 0; wallIndex < 3; wallIndex++) {
|
||||
|
|
@ -297,7 +297,7 @@ describe('skirting cutting plan', () => {
|
|||
expect(piece.rightMitre).toBeCloseTo(piece.wallIndex === 2 ? 60 : 30);
|
||||
expect(piece.leftType).toBe(piece.wallIndex === 0 ? 'internal' : 'cope');
|
||||
expect(piece.visibleLength).toBeCloseTo(3000 - 36 * Math.tan(Math.PI / 3));
|
||||
expect(piece.installedTemplate![1].x - piece.installedTemplate![0].x).toBeGreaterThan(
|
||||
expect(piece.installedTemplate[1].x - piece.installedTemplate[0].x).toBeGreaterThan(
|
||||
settings.minLength,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,81 +1,61 @@
|
|||
import LZString from 'lz-string';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defaultState, fireplace, rectangle } from '../src/lib/defaults';
|
||||
import { decodeState, encodeState, URL_LIMITS } from '../src/lib/url-state';
|
||||
import { PERSISTENCE_LIMITS, PROJECT_LIMITS } from '../src/lib/limits';
|
||||
import { decodeState, encodeState } from '../src/lib/url-state';
|
||||
|
||||
function rawLink(value: unknown): string {
|
||||
return `#plan=${LZString.compressToEncodedURIComponent(JSON.stringify(value))}`;
|
||||
}
|
||||
|
||||
function expectInvalid(hash: string, message?: RegExp) {
|
||||
const result = decodeState(hash);
|
||||
function expectInvalid(link: string, message?: RegExp) {
|
||||
const result = decodeState(link);
|
||||
expect(result.state).toEqual(defaultState());
|
||||
expect(result.error).toMatch(message ?? /Could not load this plan/);
|
||||
}
|
||||
|
||||
describe('URL state', () => {
|
||||
it('starts with independent defaults when no link is provided', () => {
|
||||
for (const hash of ['', '#', '?']) {
|
||||
const first = decodeState(hash);
|
||||
for (const link of ['', '#', '?']) {
|
||||
const first = decodeState(link);
|
||||
expect(first).toEqual({ state: defaultState(), error: null });
|
||||
first.state.project.rooms[0].name = 'Edited';
|
||||
expect(decodeState(hash).state.project.rooms[0].name).toBe('Living room');
|
||||
expect(decodeState(link).state.project.rooms[0].name).toBe('Living room');
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips all default inputs exactly and produces the same link every time', () => {
|
||||
it('round-trips current inputs exactly and produces a stable readable link', () => {
|
||||
const state = defaultState();
|
||||
const hash = encodeState(state);
|
||||
expect(hash).toMatch(/^\?version=1&name=/);
|
||||
const params = new URLSearchParams(hash);
|
||||
const query = encodeState(state);
|
||||
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.has('skirting.stock')).toBe(false);
|
||||
expect(params.get('room.1.wall.1.length')).toBe('4800');
|
||||
expect(params.get('room.1.wall.4.door.1.width')).toBe('826');
|
||||
expect(hash).not.toContain('#plan=');
|
||||
expect(decodeState(hash)).toEqual({ state, error: null });
|
||||
expect(encodeState(decodeState(hash).state)).toBe(hash);
|
||||
expect(encodeState(structuredClone(state))).toBe(hash);
|
||||
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, multiple rooms, doors, materials and view selections', () => {
|
||||
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[1].direction = 90;
|
||||
state.project.rooms[0].walls[0].doors = [{ id: 'wide-door', offset: 123.75, width: 1500 }];
|
||||
state.project.flooring.pattern = 'random';
|
||||
state.project.flooring.optimisation = 'random';
|
||||
state.project.skirting.stockLength = 3600;
|
||||
state.view = { ...state.view, roomId: 'room-2', selectedPiece: 'A12' };
|
||||
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
|
||||
state.view = {
|
||||
roomId: 'room-2',
|
||||
selectedPiece: 'A12',
|
||||
search: 'A12',
|
||||
collapsedSections: ['materials'],
|
||||
material: 'skirting',
|
||||
};
|
||||
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it('loads legacy step, layer and zoom links into the same workspace', () => {
|
||||
const state = defaultState();
|
||||
const old = {
|
||||
...state,
|
||||
view: {
|
||||
roomId: state.view.roomId,
|
||||
selectedPiece: 'A4',
|
||||
step: 'plan',
|
||||
layer: 'skirting',
|
||||
zoom: 2,
|
||||
},
|
||||
};
|
||||
const decoded = decodeState(rawLink(old));
|
||||
expect(decoded.error).toBeNull();
|
||||
expect(decoded.state.project).toEqual(state.project);
|
||||
expect(decoded.state.view).toEqual({ ...state.view, selectedPiece: 'A4' });
|
||||
expect(Object.keys(decoded.state.view).sort()).toEqual([
|
||||
'collapsedSections',
|
||||
'roomId',
|
||||
'search',
|
||||
'selectedPiece',
|
||||
]);
|
||||
});
|
||||
|
||||
it('canonicalizes property order and discards derived or unknown fields', () => {
|
||||
it('canonicalizes property order and discards unknown derived fields', () => {
|
||||
const state = defaultState();
|
||||
const reordered = {
|
||||
view: { ...state.view, derived: 6 },
|
||||
|
|
@ -83,10 +63,13 @@ describe('URL state', () => {
|
|||
cache: [],
|
||||
};
|
||||
expect(encodeState(reordered)).toBe(encodeState(state));
|
||||
expect(decodeState(rawLink(reordered))).toEqual({ state, error: null });
|
||||
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 but impossible measurements for domain-level feedback', () => {
|
||||
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;
|
||||
|
|
@ -98,7 +81,7 @@ describe('URL state', () => {
|
|||
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it('allows an unfinished room and invalid board length without inventing inputs', () => {
|
||||
it('allows unfinished current projects without inventing inputs', () => {
|
||||
const state = defaultState();
|
||||
state.project.rooms[0].walls = [];
|
||||
state.project.skirting.stockLength = 0;
|
||||
|
|
@ -106,151 +89,61 @@ describe('URL state', () => {
|
|||
});
|
||||
|
||||
it('falls back to the first room for a stale selected room', () => {
|
||||
const state = defaultState();
|
||||
state.view.roomId = 'deleted-room';
|
||||
const result = decodeState(rawLink(state));
|
||||
const params = new URLSearchParams(encodeState(defaultState()));
|
||||
params.set('selectedRoom', 'deleted-room');
|
||||
const result = decodeState(`?${params}`);
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.state.view.roomId).toBe(state.project.rooms[0].id);
|
||||
expect(result.state.view.roomId).toBe(result.state.project.rooms[0].id);
|
||||
});
|
||||
|
||||
it('accepts URL-escaped legacy compression characters', () => {
|
||||
const hash = rawLink(defaultState());
|
||||
expect(decodeState(`#plan=${encodeURIComponent(hash.slice(6))}`).state).toEqual(defaultState());
|
||||
it.each(['#plan=old', '#other=value', 'version=2'])('rejects non-query links %s', (link) => {
|
||||
expectInvalid(link, /starting with \?/);
|
||||
});
|
||||
|
||||
it.each(['#other=value', '#plan=', '#plan=garbage', '#plan=%', '#plan=%20', '#plan=abc&more=1'])(
|
||||
'rejects malformed link %s with an actionable error',
|
||||
(hash) => expectInvalid(hash),
|
||||
);
|
||||
|
||||
it('rejects truncated and appended legacy payloads even when the decoder ignores trailing data', () => {
|
||||
const hash = rawLink(defaultState());
|
||||
expectInvalid(hash.slice(0, -4));
|
||||
expectInvalid(`${hash}AAAA`);
|
||||
it.each(['?version=1', '?version=3'])('rejects unsupported schema link %s', (link) => {
|
||||
expectInvalid(link, /version is unsupported/);
|
||||
});
|
||||
|
||||
it('rejects a valid compression payload containing invalid JSON', () => {
|
||||
expectInvalid(
|
||||
`#plan=${LZString.compressToEncodedURIComponent('{broken')}`,
|
||||
/project data is damaged/,
|
||||
);
|
||||
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 unsupported schema versions', () => {
|
||||
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();
|
||||
expectInvalid(
|
||||
rawLink({ ...state, project: { ...state.project, version: 2 } }),
|
||||
/version is unsupported/,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([null, [], {}, { project: null }, { project: { version: 1 } }])(
|
||||
'rejects missing project data',
|
||||
(value) => {
|
||||
expectInvalid(rawLink(value));
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, '1200', true, {}, []])('rejects incorrectly typed measurements', (length) => {
|
||||
const state = defaultState();
|
||||
expectInvalid(
|
||||
rawLink({
|
||||
...state,
|
||||
project: { ...state.project, flooring: { ...state.project.flooring, length } },
|
||||
}),
|
||||
/finite number/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects nonfinite numbers both when reading and writing', () => {
|
||||
const state = defaultState();
|
||||
const json = JSON.stringify(state).replace('"length":4800', '"length":1e999');
|
||||
expectInvalid(`#plan=${LZString.compressToEncodedURIComponent(json)}`, /finite number/);
|
||||
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/);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['selectedPiece', false],
|
||||
['selectedPiece', {}],
|
||||
['selectedPiece', 12],
|
||||
['roomId', null],
|
||||
])('rejects invalid view field %s', (field, value) => {
|
||||
const state = defaultState();
|
||||
expectInvalid(rawLink({ ...state, view: { ...state.view, [field]: value } }));
|
||||
});
|
||||
it.each(['?flooring.pattern=diagonal', '?flooring.optimisation=quick', '?room.1.direction=45'])(
|
||||
'rejects an invalid current enum in %s',
|
||||
(query) => expectInvalid(query),
|
||||
);
|
||||
|
||||
it('rejects invalid flooring enums', () => {
|
||||
const state = defaultState();
|
||||
expectInvalid(
|
||||
rawLink({
|
||||
...state,
|
||||
project: { ...state.project, flooring: { ...state.project.flooring, pattern: 'diagonal' } },
|
||||
}),
|
||||
);
|
||||
expectInvalid(
|
||||
rawLink({
|
||||
...state,
|
||||
project: { ...state.project, rooms: [{ ...state.project.rooms[0], direction: 45 }] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects too many rooms, walls, doors or legacy stock lengths', () => {
|
||||
const rooms = defaultState();
|
||||
rooms.project.rooms = Array.from({ length: URL_LIMITS.rooms + 1 }, (_, i) =>
|
||||
rectangle(`room-${i}`),
|
||||
);
|
||||
expectInvalid(rawLink(rooms), /at most 20/);
|
||||
const walls = defaultState();
|
||||
walls.project.rooms[0].walls = Array.from({ length: URL_LIMITS.walls + 1 }, (_, i) => ({
|
||||
id: `wall-${i}`,
|
||||
length: 1,
|
||||
turn: 90,
|
||||
doors: [],
|
||||
}));
|
||||
expectInvalid(rawLink(walls), /at most 100/);
|
||||
const doors = defaultState();
|
||||
doors.project.rooms[0].walls[0].doors = Array.from(
|
||||
{ length: URL_LIMITS.doors + 1 },
|
||||
(_, i) => ({ id: `door-${i}`, offset: i * 10, width: 5 }),
|
||||
);
|
||||
expectInvalid(rawLink(doors), /at most 30/);
|
||||
const stock = defaultState();
|
||||
expectInvalid(
|
||||
rawLink({
|
||||
...stock,
|
||||
project: {
|
||||
...stock.project,
|
||||
skirting: {
|
||||
...stock.project.skirting,
|
||||
stockLength: undefined,
|
||||
stockLengths: Array(URL_LIMITS.legacyStockLengths + 1).fill(2400),
|
||||
},
|
||||
},
|
||||
}),
|
||||
/at most 30/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects no rooms, duplicate identifiers and oversized strings', () => {
|
||||
const empty = defaultState();
|
||||
empty.project.rooms = [];
|
||||
expectInvalid(rawLink(empty), /at least one room/);
|
||||
const duplicate = defaultState();
|
||||
duplicate.project.rooms.push(rectangle());
|
||||
expectInvalid(rawLink(duplicate), /duplicate identifiers/);
|
||||
const duplicateWall = defaultState();
|
||||
duplicateWall.project.rooms[0].walls.push(duplicateWall.project.rooms[0].walls[0]);
|
||||
expectInvalid(rawLink(duplicateWall), /duplicate identifiers/);
|
||||
const largeName = defaultState();
|
||||
largeName.project.name = 'a'.repeat(URL_LIMITS.nameLength + 1);
|
||||
expectInvalid(rawLink(largeName), /at most 200 characters/);
|
||||
});
|
||||
|
||||
it('uses individually editable fields and canonicalizes query parameter order', () => {
|
||||
it('uses individually editable fields and canonical parameter order', () => {
|
||||
const state = defaultState();
|
||||
const params = new URLSearchParams(encodeState(state));
|
||||
params.set('flooring.length', '1400');
|
||||
|
|
@ -270,7 +163,7 @@ describe('URL state', () => {
|
|||
expect(encodeState(decodeState(`?${reversed}`).state)).toBe(encodeState(result.state));
|
||||
});
|
||||
|
||||
it('accepts a small hand-written query and uses defaults for omitted material fields', () => {
|
||||
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;
|
||||
|
|
@ -279,7 +172,7 @@ describe('URL state', () => {
|
|||
expect(result).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it('can describe an unfinished room without storing array counts or generated plans', () => {
|
||||
it('can describe an unfinished room without array counts or generated plans', () => {
|
||||
const result = decodeState(
|
||||
'?room.1.name=Office&room.1.wall.1.length=4000&room.1.wall.1.turn=-90',
|
||||
);
|
||||
|
|
@ -297,336 +190,76 @@ describe('URL state', () => {
|
|||
expect(new URLSearchParams(encoded).has('room.1.wall.2.length')).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves a zero board length and empty room while omitting an unselected cut', () => {
|
||||
const state = defaultState();
|
||||
state.project.skirting.stockLength = 0;
|
||||
state.project.rooms[0].walls = [];
|
||||
const query = encodeState(state);
|
||||
expect(query).toContain('skirting.length=0');
|
||||
expect(query).not.toContain('selectedCut=');
|
||||
expect(decodeState(query)).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'?flooring.length=NaN',
|
||||
'?flooring.length=Infinity',
|
||||
'?flooring.length=1e999',
|
||||
'?flooring.length=',
|
||||
'?flooring.length=true',
|
||||
'?flooring.length=0x10',
|
||||
'?flooring.pattern=diagonal',
|
||||
'?room.1.direction=45',
|
||||
'?version=2',
|
||||
'?room.1.wall.1.turn=90',
|
||||
'?room.1.wall.1.length=4000&room.1.wall.1.door.1.offset=300',
|
||||
'?skirting.stock=&skirting.stock=2400',
|
||||
'?name=%',
|
||||
'?name=%C0%AF',
|
||||
])('rejects an invalid editable query %s', (query) => expectInvalid(query));
|
||||
|
||||
it('rejects duplicate single-value fields and migrates repeated legacy stock lengths', () => {
|
||||
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/);
|
||||
const result = decodeState('?skirting.stock=2400&skirting.stock=3600');
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.state.project.skirting.stockLength).toBe(2400);
|
||||
expectInvalid('?skirting.length=2400&skirting.length=3600', /only once/);
|
||||
expectInvalid('?skirting.stock=2400', /retired skirting stock-list/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ lengths: [3600, 2400], chosen: 3600, completed: [] },
|
||||
{ lengths: [2400, 3600], chosen: 2400, completed: [] },
|
||||
{ lengths: [3600], chosen: 3600, completed: [] },
|
||||
{ lengths: [3600, 3600], chosen: 3600, completed: [] },
|
||||
{ lengths: [], chosen: 0, completed: [] },
|
||||
])(
|
||||
'migrates old stock sizes $lengths consistently in query and compressed links',
|
||||
({ lengths, chosen, completed }) => {
|
||||
const state = defaultState();
|
||||
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
|
||||
const old = {
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
skirting: { ...state.project.skirting, stockLength: undefined, stockLengths: lengths },
|
||||
},
|
||||
};
|
||||
const query = new URLSearchParams();
|
||||
for (const length of lengths.length ? lengths : [''])
|
||||
query.append('skirting.stock', String(length));
|
||||
for (const board of state.project.completedBoards) query.append('done', board);
|
||||
state.project.skirting.stockLength = chosen;
|
||||
state.project.completedBoards = completed;
|
||||
for (const link of [rawLink(old), `?${query}`]) {
|
||||
const result = decodeState(link);
|
||||
expect(result).toEqual({ state, error: null });
|
||||
const canonical = encodeState(result.state);
|
||||
expect(canonical).toContain(`skirting.length=${chosen}`);
|
||||
expect(canonical).not.toContain('skirting.stock');
|
||||
expect(decodeState(canonical)).toEqual(result);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['?done=skirting:S4', '?skirting.stock=2400&done=skirting:S4'])(
|
||||
'clears completion from earlier calculations in %s and preserves new progress after migration',
|
||||
(link) => {
|
||||
const migrated = decodeState(link);
|
||||
expect(migrated.error).toBeNull();
|
||||
expect(migrated.state.project.completedBoards).toEqual([]);
|
||||
migrated.state.project.completedBoards = ['skirting:S4'];
|
||||
expect(decodeState(encodeState(migrated.state))).toEqual(migrated);
|
||||
},
|
||||
);
|
||||
|
||||
it('prefers the current length field over legacy stock lists without clearing progress', () => {
|
||||
const state = defaultState();
|
||||
state.project.skirting.stockLength = 4800;
|
||||
state.project.completedBoards = ['skirting:S6'];
|
||||
const legacy = {
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
skirting: { ...state.project.skirting, stockLengths: [3600, 2400] },
|
||||
},
|
||||
};
|
||||
expect(decodeState(rawLink(legacy))).toEqual({ state, error: null });
|
||||
expect(
|
||||
decodeState(
|
||||
'?flooring.optimisation=cuts&skirting.length=4800&skirting.stock=3600&skirting.stock=2400&done=skirting:S6',
|
||||
),
|
||||
).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it.each([null, '2400', true, {}, [], NaN, Infinity])(
|
||||
'rejects invalid skirting board length %j',
|
||||
(stockLength) => {
|
||||
const state = defaultState();
|
||||
expectInvalid(
|
||||
rawLink({
|
||||
...state,
|
||||
project: { ...state.project, skirting: { ...state.project.skirting, stockLength } },
|
||||
}),
|
||||
/finite number/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['', 'NaN', 'Infinity', 'true', '0x10'])(
|
||||
'rejects invalid editable skirting length %s',
|
||||
(length) => {
|
||||
expectInvalid(`?skirting.length=${length}`, /finite number/);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects sparse, zero, malformed and excessive room, wall and door indices', () => {
|
||||
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.21.id=excess', /at most 20/);
|
||||
expectInvalid('?room.1.wall.101.length=4000', /at most 100/);
|
||||
expectInvalid('?room.1.wall.1.length=4000&room.1.wall.1.door.31.width=500', /at most 30/);
|
||||
expectInvalid(`?${Array(31).fill('skirting.stock=2400').join('&')}`, /at most 30/);
|
||||
expectInvalid('?room.1.id=same&room.2.id=same', /duplicate identifiers/);
|
||||
});
|
||||
|
||||
it('discards unrecognized query fields instead of storing derived state', () => {
|
||||
const decoded = decodeState('?flooring.length=1400&boardCount=999&cache=old&room.8.cache=old');
|
||||
expect(decoded.error).toBeNull();
|
||||
const canonical = encodeState(decoded.state);
|
||||
expect(canonical).toContain('flooring.length=1400');
|
||||
expect(canonical).not.toMatch(/boardCount|cache/);
|
||||
expect(decoded.state.project.rooms).toEqual(defaultState().project.rooms);
|
||||
});
|
||||
|
||||
it('converts a legacy shared plan to readable fields without losing inputs or selections', () => {
|
||||
const state = defaultState();
|
||||
state.project.rooms.push(fireplace('second', 'Fireplace room'));
|
||||
state.project.skirting.stockLength = 3600;
|
||||
state.view = { ...state.view, roomId: 'second', selectedPiece: 'B2' };
|
||||
const legacy = decodeState(rawLink(state));
|
||||
expect(legacy.error).toBeNull();
|
||||
const query = encodeState(legacy.state);
|
||||
expect(query).toContain('room.2.wall.4.turn=-90');
|
||||
expect(query).toContain('selectedRoom=second&selectedCut=B2');
|
||||
expect(decodeState(query)).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it('stores progress and view preferences as optional readable fields', () => {
|
||||
const state = defaultState();
|
||||
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
|
||||
state.view.search = 'F12';
|
||||
state.view.collapsedSections = ['materials', 'measurements'];
|
||||
const query = encodeState(state);
|
||||
expect(query).toContain('done=floor:B4&done=skirting:S6');
|
||||
expect(query).toContain('search=F12&collapsed=materials&collapsed=measurements');
|
||||
expect(decodeState(query)).toEqual({ state, error: null });
|
||||
const empty = new URLSearchParams(encodeState(defaultState()));
|
||||
for (const key of ['done', 'search', 'collapsed']) expect(empty.has(key)).toBe(false);
|
||||
});
|
||||
|
||||
it('canonicalizes completion and collapse sets without mutating their inputs', () => {
|
||||
const state = defaultState();
|
||||
state.project.completedBoards = ['skirting:S6', 'floor:B4'];
|
||||
state.view.collapsedSections = ['measurements', 'cuts', 'materials'];
|
||||
const before = structuredClone(state);
|
||||
const query = encodeState(state);
|
||||
const loaded = decodeState(query);
|
||||
expect(loaded.error).toBeNull();
|
||||
expect(loaded.state.project.completedBoards).toEqual(['floor:B4', 'skirting:S6']);
|
||||
expect(loaded.state.view.collapsedSections).toEqual(['cuts', 'materials', 'measurements']);
|
||||
expect(state).toEqual(before);
|
||||
state.project.completedBoards.reverse();
|
||||
state.view.collapsedSections.reverse();
|
||||
expect(encodeState(state)).toBe(query);
|
||||
expect(encodeState(loaded.state)).toBe(query);
|
||||
});
|
||||
|
||||
it('loads old query and fragment links with empty progress and view preferences', () => {
|
||||
const state = defaultState();
|
||||
const old = {
|
||||
project: { ...state.project, completedBoards: undefined },
|
||||
view: { roomId: state.view.roomId, selectedPiece: null },
|
||||
};
|
||||
expect(decodeState(rawLink(old))).toEqual({ state, error: null });
|
||||
expect(decodeState('?flooring.length=1200').state).toEqual(state);
|
||||
const first = defaultState();
|
||||
first.project.completedBoards.push('floor:B4');
|
||||
first.view.collapsedSections.push('materials');
|
||||
expect(defaultState().project.completedBoards).toEqual([]);
|
||||
expect(defaultState().view.collapsedSections).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves Unicode and spaces in the search input independently of selected cuts', () => {
|
||||
const state = defaultState();
|
||||
state.view.search = ' Café 北 F12 / A2 ';
|
||||
state.view.selectedPiece = 'F12';
|
||||
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
||||
expectInvalid('?search=F1&search=F2', /only once/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'',
|
||||
'B4',
|
||||
'floor:S4',
|
||||
'skirting:B4',
|
||||
'floor:B0',
|
||||
'floor:B04',
|
||||
'floor:B-1',
|
||||
'floor:B1.5',
|
||||
])('rejects invalid completed-board key %s', (key) => {
|
||||
expectInvalid(`?done=${encodeURIComponent(key)}`, /floor:B1 or skirting:S1/);
|
||||
});
|
||||
|
||||
it('rejects duplicate completion and collapse entries rather than counting them twice', () => {
|
||||
expectInvalid('?done=floor:B4&done=floor:B4', /duplicate entries/);
|
||||
expectInvalid('?collapsed=materials&collapsed=materials', /duplicate entries/);
|
||||
const state = defaultState();
|
||||
state.project.completedBoards = ['floor:B4', 'floor:B4'];
|
||||
expect(() => encodeState(state)).toThrow(/duplicate entries/);
|
||||
const distinct = decodeState(
|
||||
'?flooring.optimisation=cuts&skirting.length=2400&done=floor:B1&done=skirting:S1',
|
||||
);
|
||||
expect(distinct.error).toBeNull();
|
||||
expect(distinct.state.project.completedBoards).toEqual(['floor:B1', 'skirting:S1']);
|
||||
});
|
||||
|
||||
it('bounds the search, progress list and collapsed sections', () => {
|
||||
expectInvalid(`?search=${'a'.repeat(URL_LIMITS.searchLength + 1)}`, /at most 200 characters/);
|
||||
expectInvalid('?collapsed=outline', /unsupported value/);
|
||||
expectInvalid(`?room.${PROJECT_LIMITS.rooms + 1}.id=excess`, /at most 20/);
|
||||
expectInvalid(`?room.1.wall.${PROJECT_LIMITS.wallsPerRoom + 1}.length=4000`, /at most 100/);
|
||||
expectInvalid(
|
||||
'?collapsed=cuts&collapsed=materials&collapsed=measurements&collapsed=cuts',
|
||||
/at most 3/,
|
||||
`?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: URL_LIMITS.completedBoards + 1 },
|
||||
{ length: PERSISTENCE_LIMITS.completedBoards + 1 },
|
||||
(_, index) => `floor:B${index + 1}`,
|
||||
);
|
||||
expect(() => encodeState(state)).toThrow(/at most 12000/);
|
||||
expectInvalid(rawLink(state), /at most 12000/);
|
||||
expectInvalid(
|
||||
`?done=floor:B${'1'.repeat(URL_LIMITS.identifierLength)}`,
|
||||
/at most 120 characters/,
|
||||
`?search=${'a'.repeat(PERSISTENCE_LIMITS.searchLength + 1)}`,
|
||||
/at most 200 characters/,
|
||||
);
|
||||
expectInvalid(`?name=${'x'.repeat(PERSISTENCE_LIMITS.queryLength)}`, /link is too large/);
|
||||
});
|
||||
|
||||
it.each([null, true, 'floor:B4', [false], [{}]])(
|
||||
'rejects incorrectly typed completed-board sets %j',
|
||||
(completedBoards) => {
|
||||
const state = defaultState();
|
||||
expectInvalid(rawLink({ ...state, project: { ...state.project, completedBoards } }));
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, 42, [], {}])('rejects incorrectly typed search input %j', (search) => {
|
||||
const state = defaultState();
|
||||
expectInvalid(rawLink({ ...state, view: { ...state.view, search } }));
|
||||
});
|
||||
|
||||
it.each([null, true, 'materials', [42], ['outline']])(
|
||||
'rejects incorrectly typed or unsupported collapsed sections %j',
|
||||
(collapsedSections) => {
|
||||
const state = defaultState();
|
||||
expectInvalid(rawLink({ ...state, view: { ...state.view, collapsedSections } }));
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects oversized query strings before parsing fields', () => {
|
||||
expectInvalid(`?name=${'x'.repeat(URL_LIMITS.queryLength)}`, /link is too large/);
|
||||
});
|
||||
|
||||
it.each(['cuts', 'random'] as const)(
|
||||
'saves the %s optimisation and current completion marks',
|
||||
(optimisation) => {
|
||||
const state = defaultState();
|
||||
state.project.flooring.optimisation = optimisation;
|
||||
state.project.completedBoards = ['floor:B4'];
|
||||
const query = encodeState(state);
|
||||
expect(query).toContain(`flooring.optimisation=${optimisation}`);
|
||||
expect(decodeState(query)).toEqual({ state, error: null });
|
||||
},
|
||||
);
|
||||
|
||||
it('migrates the previous optimiser without carrying completion onto a different allocation', () => {
|
||||
const state = defaultState();
|
||||
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
|
||||
const query = new URLSearchParams(encodeState(state));
|
||||
query.delete('flooring.optimisation');
|
||||
const old = {
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
flooring: { ...state.project.flooring, optimisation: undefined },
|
||||
},
|
||||
};
|
||||
for (const link of [`?${query}`, rawLink(old)]) {
|
||||
const migrated = decodeState(link);
|
||||
expect(migrated.error).toBeNull();
|
||||
expect(migrated.state.project.flooring.optimisation).toBe('cuts');
|
||||
expect(migrated.state.project.completedBoards).toEqual([]);
|
||||
migrated.state.project.completedBoards = ['floor:B1'];
|
||||
expect(decodeState(encodeState(migrated.state))).toEqual(migrated);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([null, 42, true, {}, 'waste'])('rejects unsupported optimisation %j', (optimisation) => {
|
||||
const state = defaultState();
|
||||
expectInvalid(
|
||||
rawLink({
|
||||
...state,
|
||||
project: { ...state.project, flooring: { ...state.project.flooring, optimisation } },
|
||||
}),
|
||||
/Flooring optimisation/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects oversized compressed and expanded payloads', () => {
|
||||
expectInvalid(`#plan=${'a'.repeat(URL_LIMITS.hashLength)}`, /link is too large/);
|
||||
expectInvalid(
|
||||
rawLink('a'.repeat(URL_LIMITS.jsonLength + 1)),
|
||||
/project in this link is too large/,
|
||||
);
|
||||
it('rejects damaged percent encoding', () => {
|
||||
expectInvalid('?name=%', /invalid URL encoding/);
|
||||
expectInvalid('?name=%C0%AF', /invalid URL encoding/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue