diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..dfd7078 --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -0,0 +1,57 @@ +name: Deploy to Pages + +on: + push: + branches: ['main'] + + pull_request: + branches: ['main'] + + workflow_dispatch: + +concurrency: + group: 'pages' + cancel-in-progress: false + +jobs: + build: + runs-on: docker + + env: + CI: 'true' + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Check formatting + continue-on-error: true + run: npm run format:check + + - name: Test + run: npm test + + - name: Build + run: npm run build + + - name: On main, copy build to host pages mount + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main + with: + source: dist + target: floor-planner + + - name: Copy build to staging pages mount + if: github.event_name == 'pull_request' + uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main + with: + source: dist + target: floor-planner-staging diff --git a/README.md b/README.md index 77c4441..4d34f27 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,12 @@ npm run dev Use a supported Node.js release (22.12+ or 24+). Open the local URL printed by Vite. +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 +static hosting without server-side routing. The planner loads only when opened. + ```sh npm test # Pure geometry, planning, URL and rendering tests npx playwright install chromium @@ -25,7 +31,7 @@ npm run check # Build, unit tests and browser tests 4. Set material dimensions, spacer, kerf and minimum piece sizes alongside the measurements. Kerf means material removed by the blade: a 3 mm cut leaves 600 mm + 397 mm from a 1,000 mm board. Both kerf fields have keyboard and touch tooltips. Set one purchased skirting board length for the whole project. Materials are shared across rooms; direction is per room. 5. Room measurements, materials and cut plans open and close independently on the same page. The drawing stays pinned on the right while you scroll the panels; on phones a compact drawing stays above them. Flooring and skirting are visible together, with the whole drawing fitting without an internal scrollbar. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Cut codes sit above the wall and skirting drawing; short pieces use nearby labels with pointers so their names remain visible. The compact drawing header shows only the room, area and material legend. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Flooring and skirting cuts are grouped by the purchased board they come from, including offcuts reused across rooms. Group headings use the cut codes; internal stock IDs stay out of the interface. Pieces wrap side by side when space allows. Each piece has a small preview that opens dimensions directly on its shape. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Numbered skirting pieces and their labels open cut details; uncut lengths use a neutral fill. Print all room drawings followed by flooring and skirting cut sheets grouped by purchased board. Every cut is included once, with its room and row or wall. 6. Find a cut by its code, or search by room name. Searching several cut codes together, such as `F27 + F28`, finds their pieces together. **Show on plan** highlights the source board’s pieces or an individual cut, with room buttons when a source is reused across rooms. A visible **Search active** banner stays above the pinned drawing; **Show all pieces** clears the search there. Source boards have **Mark done** checkboxes; completion marks appear on their list entries, installed pieces and printed sheets. Room search results also let you locate and mark uncut boards without adding cut labels to them. Measurements, wall order, flooring direction or material changes clear completion because the allocation changes; renaming rooms keeps it. -7. Copy the link to save or share the complete project. Measurements, materials and view choices live in readable query parameters such as `flooring.length=1200`, `skirting.length=2400`, `room.1.wall.1.length=4800` and `selectedCut=F1`. Older stock-list links retain their first length; their completion marks are cleared because coping blanks and rounded stock allocation have changed, including for single-size links. Progress uses repeated `done=floor:B4` or `done=skirting:S6` parameters; `search=F1` and repeated `collapsed=materials` parameters preserve the search and closed panels. You can edit values directly in the URL. Older compressed fragment links still load and migrate automatically; obsolete step, layer and zoom choices are ignored. Reloading or opening that link reproduces the plan. Browser back/forward restores room changes. There is no local storage, account, API, database or analytics. Fonts are system fonts; the app makes no external font requests. +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. ## Calculation rules and practical limits @@ -54,7 +60,7 @@ npm run check # Build, unit tests and browser tests | `src/lib/piece-labels.ts` | Collision-free cut-code placement and pointers for small pieces | | `src/lib/board-tracking.ts` | Exact board/cut matching and room-name search | | `src/lib/wall-editing.ts` | Wall insertion and reordering with stable IDs | -| `src/lib/use-project.ts` | URL and browser-history synchronization | +| `src/lib/use-project.ts` | URL, localStorage and browser-history synchronization | | `src/components/` | Editors, dimensioned SVGs, board groups and printable cut sheets | | `tests/` | Pure calculation and state tests | | `e2e/` | Actual browser workflows at desktop and mobile sizes | diff --git a/benchmarks/baseline.json b/benchmarks/baseline.json new file mode 100644 index 0000000..bd9133f --- /dev/null +++ b/benchmarks/baseline.json @@ -0,0 +1,2018 @@ +[ + { + "name": "rectangle-brick", + "flooring": { + "cuts": 32, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 76, + "waste": 215600, + "errors": 0 + }, + "skirting": { + "cuts": 11, + "pieces": 8, + "boards": 7, + "waste": 826, + "errors": 0 + }, + "floorMs": 5.6, + "skirtMs": 1.4 + }, + { + "name": "fireplace-brick", + "flooring": { + "cuts": 40, + "shapingCuts": 4, + "ripCuts": 4, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 74, + "waste": 443600, + "errors": 0 + }, + "skirting": { + "cuts": 24, + "pieces": 11, + "boards": 8, + "waste": 2254, + "errors": 0 + }, + "floorMs": 6.2, + "skirtMs": 0.3 + }, + { + "name": "rotated-brick", + "flooring": { + "cuts": 60, + "shapingCuts": 0, + "ripCuts": 8, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 78, + "waste": 1355600, + "errors": 0 + }, + "skirting": { + "cuts": 24, + "pieces": 11, + "boards": 8, + "waste": 2254, + "errors": 0 + }, + "floorMs": 2.2, + "skirtMs": 0.2 + }, + { + "name": "rectangle-random", + "flooring": { + "cuts": 38, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 2, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 76, + "waste": 215600, + "errors": 0 + }, + "skirting": { + "cuts": 11, + "pieces": 8, + "boards": 7, + "waste": 826, + "errors": 0 + }, + "floorMs": 43.2, + "skirtMs": 0.3 + }, + { + "name": "fireplace-random", + "flooring": { + "cuts": 44, + "shapingCuts": 4, + "ripCuts": 4, + "patternPenalty": 4, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 74, + "waste": 443600, + "errors": 0 + }, + "skirting": { + "cuts": 24, + "pieces": 11, + "boards": 8, + "waste": 2254, + "errors": 0 + }, + "floorMs": 38.9, + "skirtMs": 0.3 + }, + { + "name": "rotated-random", + "flooring": { + "cuts": 52, + "shapingCuts": 0, + "ripCuts": 7, + "patternPenalty": 2, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 76, + "waste": 899600, + "errors": 0 + }, + "skirting": { + "cuts": 24, + "pieces": 11, + "boards": 8, + "waste": 2254, + "errors": 0 + }, + "floorMs": 16.8, + "skirtMs": 0.4 + }, + { + "name": "rectangle-1", + "flooring": { + "cuts": 4, + "shapingCuts": 0, + "ripCuts": 0, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 5, + "waste": 240000, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 4, + "boards": 2, + "waste": 320, + "errors": 0 + }, + "floorMs": 0.6, + "skirtMs": 0.1 + }, + { + "name": "rectangle-2", + "flooring": { + "cuts": 10, + "shapingCuts": 0, + "ripCuts": 2, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 10, + "waste": 115993, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 4, + "boards": 3, + "waste": 496, + "errors": 0 + }, + "floorMs": 4.5, + "skirtMs": 0.1 + }, + { + "name": "rectangle-3", + "flooring": { + "cuts": 14, + "shapingCuts": 0, + "ripCuts": 3, + "patternPenalty": 2, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 19, + "waste": 396372, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 4, + "waste": 672, + "errors": 0 + }, + "floorMs": 7.4, + "skirtMs": 0.1 + }, + { + "name": "rectangle-4", + "flooring": { + "cuts": 22, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 32, + "waste": 1081137, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 7, + "boards": 5, + "waste": 848, + "errors": 0 + }, + "floorMs": 11.9, + "skirtMs": 0.1 + }, + { + "name": "rectangle-5", + "flooring": { + "cuts": 20, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 1, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 43, + "waste": 730288, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 6, + "waste": 1024, + "errors": 0 + }, + "floorMs": 18.7, + "skirtMs": 0.1 + }, + { + "name": "rectangle-6", + "flooring": { + "cuts": 23, + "shapingCuts": 0, + "ripCuts": 5, + "patternPenalty": 6, + "repeats": 0, + "similarSteps": 1, + "staircases": 0, + "boards": 58, + "waste": 783825, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 10, + "boards": 7, + "waste": 1200, + "errors": 0 + }, + "floorMs": 25.7, + "skirtMs": 0.2 + }, + { + "name": "rectangle-7", + "flooring": { + "cuts": 10, + "shapingCuts": 0, + "ripCuts": 2, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 10, + "waste": 259148, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 4, + "boards": 3, + "waste": 776, + "errors": 0 + }, + "floorMs": 5.9, + "skirtMs": 0.1 + }, + { + "name": "rectangle-8", + "flooring": { + "cuts": 14, + "shapingCuts": 0, + "ripCuts": 3, + "patternPenalty": 1, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 18, + "waste": 354357, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 4, + "waste": 952, + "errors": 0 + }, + "floorMs": 7.8, + "skirtMs": 0.1 + }, + { + "name": "rectangle-9", + "flooring": { + "cuts": 19, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 2, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 30, + "waste": 853952, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 8, + "boards": 5, + "waste": 1128, + "errors": 0 + }, + "floorMs": 13.4, + "skirtMs": 0.2 + }, + { + "name": "rectangle-10", + "flooring": { + "cuts": 19, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 8, + "repeats": 0, + "similarSteps": 2, + "staircases": 0, + "boards": 42, + "waste": 797933, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 6, + "waste": 1304, + "errors": 0 + }, + "floorMs": 13.7, + "skirtMs": 0.1 + }, + { + "name": "rectangle-11", + "flooring": { + "cuts": 21, + "shapingCuts": 0, + "ripCuts": 5, + "patternPenalty": 10, + "repeats": 0, + "similarSteps": 0, + "staircases": 1, + "boards": 57, + "waste": 906300, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 10, + "boards": 7, + "waste": 1480, + "errors": 0 + }, + "floorMs": 20.8, + "skirtMs": 0.3 + }, + { + "name": "rectangle-12", + "flooring": { + "cuts": 9, + "shapingCuts": 0, + "ripCuts": 2, + "patternPenalty": 1, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 9, + "waste": 160953, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 4, + "boards": 3, + "waste": 1056, + "errors": 0 + }, + "floorMs": 4.5, + "skirtMs": 0.1 + }, + { + "name": "rectangle-13", + "flooring": { + "cuts": 12, + "shapingCuts": 0, + "ripCuts": 3, + "patternPenalty": 5, + "repeats": 0, + "similarSteps": 1, + "staircases": 0, + "boards": 17, + "waste": 310992, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 4, + "waste": 1232, + "errors": 0 + }, + "floorMs": 7.2, + "skirtMs": 0.1 + }, + { + "name": "rectangle-14", + "flooring": { + "cuts": 17, + "shapingCuts": 0, + "ripCuts": 3, + "patternPenalty": 2, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 27, + "waste": 385417, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 5, + "waste": 1408, + "errors": 0 + }, + "floorMs": 9.8, + "skirtMs": 0.1 + }, + { + "name": "rectangle-15", + "flooring": { + "cuts": 21, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 3, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 41, + "waste": 864228, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 6, + "waste": 1584, + "errors": 0 + }, + "floorMs": 16.3, + "skirtMs": 0.1 + }, + { + "name": "rectangle-16", + "flooring": { + "cuts": 31, + "shapingCuts": 0, + "ripCuts": 5, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 59, + "waste": 1747425, + "errors": 0 + }, + "skirting": { + "cuts": 13, + "pieces": 10, + "boards": 7, + "waste": 1760, + "errors": 0 + }, + "floorMs": 28.1, + "skirtMs": 0.1 + }, + { + "name": "rectangle-17", + "flooring": { + "cuts": 9, + "shapingCuts": 0, + "ripCuts": 2, + "patternPenalty": 1, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 9, + "waste": 301408, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 4, + "boards": 3, + "waste": 1336, + "errors": 0 + }, + "floorMs": 3, + "skirtMs": 0.1 + }, + { + "name": "rectangle-18", + "flooring": { + "cuts": 15, + "shapingCuts": 0, + "ripCuts": 3, + "patternPenalty": 4, + "repeats": 0, + "similarSteps": 0, + "staircases": 1, + "boards": 17, + "waste": 506277, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 4, + "waste": 1512, + "errors": 0 + }, + "floorMs": 6.5, + "skirtMs": 0.3 + }, + { + "name": "rectangle-19", + "flooring": { + "cuts": 15, + "shapingCuts": 0, + "ripCuts": 3, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 27, + "waste": 635532, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 5, + "waste": 1688, + "errors": 0 + }, + "floorMs": 10.2, + "skirtMs": 0.1 + }, + { + "name": "rectangle-20", + "flooring": { + "cuts": 21, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 1, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 39, + "waste": 689173, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 6, + "waste": 1864, + "errors": 0 + }, + "floorMs": 17.4, + "skirtMs": 0.1 + }, + { + "name": "rectangle-21", + "flooring": { + "cuts": 27, + "shapingCuts": 0, + "ripCuts": 5, + "patternPenalty": 2, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 56, + "waste": 1387200, + "errors": 0 + }, + "skirting": { + "cuts": 13, + "pieces": 10, + "boards": 7, + "waste": 2040, + "errors": 0 + }, + "floorMs": 19, + "skirtMs": 0.1 + }, + { + "name": "rectangle-22", + "flooring": { + "cuts": 10, + "shapingCuts": 0, + "ripCuts": 2, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 9, + "waste": 440513, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 4, + "boards": 3, + "waste": 1616, + "errors": 0 + }, + "floorMs": 3, + "skirtMs": 0.1 + }, + { + "name": "rectangle-23", + "flooring": { + "cuts": 15, + "shapingCuts": 0, + "ripCuts": 3, + "patternPenalty": 2, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 17, + "waste": 700212, + "errors": 0 + }, + "skirting": { + "cuts": 13, + "pieces": 7, + "boards": 4, + "waste": 1792, + "errors": 0 + }, + "floorMs": 7, + "skirtMs": 0.1 + }, + { + "name": "rectangle-24", + "flooring": { + "cuts": 16, + "shapingCuts": 0, + "ripCuts": 3, + "patternPenalty": 1, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 26, + "waste": 644297, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 5, + "waste": 1968, + "errors": 0 + }, + "floorMs": 8.2, + "skirtMs": 0.1 + }, + { + "name": "rectangle-25", + "flooring": { + "cuts": 18, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 5, + "repeats": 0, + "similarSteps": 1, + "staircases": 0, + "boards": 38, + "waste": 752768, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 6, + "waste": 2144, + "errors": 0 + }, + "floorMs": 12.1, + "skirtMs": 0.1 + }, + { + "name": "rectangle-26", + "flooring": { + "cuts": 25, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 52, + "waste": 785625, + "errors": 0 + }, + "skirting": { + "cuts": 14, + "pieces": 8, + "boards": 7, + "waste": 2320, + "errors": 0 + }, + "floorMs": 17.1, + "skirtMs": 0.1 + }, + { + "name": "rectangle-27", + "flooring": { + "cuts": 9, + "shapingCuts": 0, + "ripCuts": 2, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 8, + "waste": 338268, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 4, + "boards": 3, + "waste": 1896, + "errors": 0 + }, + "floorMs": 3.4, + "skirtMs": 0.1 + }, + { + "name": "rectangle-28", + "flooring": { + "cuts": 14, + "shapingCuts": 0, + "ripCuts": 2, + "patternPenalty": 1, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 14, + "waste": 172797, + "errors": 0 + }, + "skirting": { + "cuts": 13, + "pieces": 8, + "boards": 4, + "waste": 2072, + "errors": 0 + }, + "floorMs": 8.9, + "skirtMs": 0.2 + }, + { + "name": "rectangle-29", + "flooring": { + "cuts": 16, + "shapingCuts": 0, + "ripCuts": 3, + "patternPenalty": 2, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 25, + "waste": 651712, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 5, + "waste": 2248, + "errors": 0 + }, + "floorMs": 7, + "skirtMs": 0.1 + }, + { + "name": "rectangle-30", + "flooring": { + "cuts": 21, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 2, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 38, + "waste": 1055013, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 8, + "boards": 5, + "waste": 24, + "errors": 0 + }, + "floorMs": 12.1, + "skirtMs": 0.1 + }, + { + "name": "rectangle-31", + "flooring": { + "cuts": 22, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 51, + "waste": 902700, + "errors": 0 + }, + "skirting": { + "cuts": 14, + "pieces": 8, + "boards": 7, + "waste": 2600, + "errors": 0 + }, + "floorMs": 16.1, + "skirtMs": 0.1 + }, + { + "name": "rectangle-32", + "flooring": { + "cuts": 12, + "shapingCuts": 0, + "ripCuts": 5, + "patternPenalty": 1, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 23, + "waste": 468473, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 8, + "boards": 6, + "waste": 1776, + "errors": 0 + }, + "floorMs": 5.9, + "skirtMs": 0.1 + }, + { + "name": "rectangle-33", + "flooring": { + "cuts": 13, + "shapingCuts": 0, + "ripCuts": 2, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 14, + "waste": 364032, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 4, + "boards": 4, + "waste": 2352, + "errors": 0 + }, + "floorMs": 4.8, + "skirtMs": 0.1 + }, + { + "name": "rectangle-34", + "flooring": { + "cuts": 16, + "shapingCuts": 0, + "ripCuts": 3, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 24, + "waste": 657777, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 4, + "waste": 128, + "errors": 0 + }, + "floorMs": 8.6, + "skirtMs": 0.1 + }, + { + "name": "rectangle-35", + "flooring": { + "cuts": 22, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 4, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 36, + "waste": 875908, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 7, + "boards": 5, + "waste": 304, + "errors": 0 + }, + "floorMs": 11.4, + "skirtMs": 0.1 + }, + { + "name": "rectangle-36", + "flooring": { + "cuts": 22, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 3, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 49, + "waste": 778425, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 9, + "boards": 6, + "waste": 480, + "errors": 0 + }, + "floorMs": 15.7, + "skirtMs": 0.1 + }, + { + "name": "rectangle-37", + "flooring": { + "cuts": 11, + "shapingCuts": 0, + "ripCuts": 5, + "patternPenalty": 5, + "repeats": 0, + "similarSteps": 1, + "staircases": 0, + "boards": 22, + "waste": 382528, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 8, + "boards": 6, + "waste": 2056, + "errors": 0 + }, + "floorMs": 5.5, + "skirtMs": 0.1 + }, + { + "name": "rectangle-38", + "flooring": { + "cuts": 12, + "shapingCuts": 0, + "ripCuts": 2, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 14, + "waste": 553917, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 3, + "waste": 232, + "errors": 0 + }, + "floorMs": 4.7, + "skirtMs": 0.1 + }, + { + "name": "rectangle-39", + "flooring": { + "cuts": 16, + "shapingCuts": 0, + "ripCuts": 3, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 23, + "waste": 662492, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 4, + "waste": 408, + "errors": 0 + }, + "floorMs": 7.7, + "skirtMs": 0.1 + }, + { + "name": "rectangle-40", + "flooring": { + "cuts": 23, + "shapingCuts": 0, + "ripCuts": 3, + "patternPenalty": 1, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 33, + "waste": 455453, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 8, + "boards": 5, + "waste": 584, + "errors": 0 + }, + "floorMs": 11.4, + "skirtMs": 0.2 + }, + { + "name": "rectangle-41", + "flooring": { + "cuts": 23, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 3, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 49, + "waste": 1132800, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 9, + "boards": 6, + "waste": 760, + "errors": 0 + }, + "floorMs": 15.2, + "skirtMs": 0.1 + }, + { + "name": "rectangle-42", + "flooring": { + "cuts": 13, + "shapingCuts": 0, + "ripCuts": 5, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 23, + "waste": 775233, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 8, + "boards": 6, + "waste": 2336, + "errors": 0 + }, + "floorMs": 4.5, + "skirtMs": 0.1 + }, + { + "name": "rectangle-43", + "flooring": { + "cuts": 11, + "shapingCuts": 0, + "ripCuts": 2, + "patternPenalty": 2, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 12, + "waste": 262452, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 3, + "waste": 512, + "errors": 0 + }, + "floorMs": 4.6, + "skirtMs": 0.1 + }, + { + "name": "rectangle-44", + "flooring": { + "cuts": 15, + "shapingCuts": 0, + "ripCuts": 3, + "patternPenalty": 6, + "repeats": 0, + "similarSteps": 0, + "staircases": 1, + "boards": 21, + "waste": 425857, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 4, + "waste": 688, + "errors": 0 + }, + "floorMs": 8.2, + "skirtMs": 0.1 + }, + { + "name": "rectangle-45", + "flooring": { + "cuts": 21, + "shapingCuts": 0, + "ripCuts": 3, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 33, + "waste": 753648, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 5, + "waste": 864, + "errors": 0 + }, + "floorMs": 11.7, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-1", + "flooring": { + "cuts": 10, + "shapingCuts": 0, + "ripCuts": 0, + "patternPenalty": 1, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 9, + "waste": 260000, + "errors": 0 + }, + "skirting": { + "cuts": 24, + "pieces": 8, + "boards": 4, + "waste": 1240, + "errors": 0 + }, + "floorMs": 1.1, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-reversed-1", + "flooring": { + "cuts": 9, + "shapingCuts": 0, + "ripCuts": 0, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 9, + "waste": 260000, + "errors": 0 + }, + "skirting": { + "cuts": 24, + "pieces": 8, + "boards": 4, + "waste": 1240, + "errors": 0 + }, + "floorMs": 0.9, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-2", + "flooring": { + "cuts": 20, + "shapingCuts": 0, + "ripCuts": 7, + "patternPenalty": 2, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 18, + "waste": 803810, + "errors": 0 + }, + "skirting": { + "cuts": 26, + "pieces": 12, + "boards": 5, + "waste": 736, + "errors": 0 + }, + "floorMs": 4.1, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-reversed-2", + "flooring": { + "cuts": 20, + "shapingCuts": 0, + "ripCuts": 7, + "patternPenalty": 1, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 18, + "waste": 803810, + "errors": 0 + }, + "skirting": { + "cuts": 25, + "pieces": 12, + "boards": 5, + "waste": 736, + "errors": 0 + }, + "floorMs": 4.8, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-3", + "flooring": { + "cuts": 30, + "shapingCuts": 0, + "ripCuts": 11, + "patternPenalty": 3, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 28, + "waste": 1140240, + "errors": 0 + }, + "skirting": { + "cuts": 26, + "pieces": 13, + "boards": 6, + "waste": 232, + "errors": 0 + }, + "floorMs": 4.7, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-reversed-3", + "flooring": { + "cuts": 31, + "shapingCuts": 0, + "ripCuts": 11, + "patternPenalty": 3, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 29, + "waste": 1380240, + "errors": 0 + }, + "skirting": { + "cuts": 25, + "pieces": 13, + "boards": 6, + "waste": 232, + "errors": 0 + }, + "floorMs": 3.8, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-4", + "flooring": { + "cuts": 32, + "shapingCuts": 0, + "ripCuts": 9, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 38, + "waste": 1029290, + "errors": 0 + }, + "skirting": { + "cuts": 25, + "pieces": 15, + "boards": 8, + "waste": 2128, + "errors": 0 + }, + "floorMs": 11.4, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-reversed-4", + "flooring": { + "cuts": 31, + "shapingCuts": 0, + "ripCuts": 8, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 39, + "waste": 1269290, + "errors": 0 + }, + "skirting": { + "cuts": 25, + "pieces": 14, + "boards": 8, + "waste": 2128, + "errors": 0 + }, + "floorMs": 10.1, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-5", + "flooring": { + "cuts": 28, + "shapingCuts": 0, + "ripCuts": 6, + "patternPenalty": 2, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 24, + "waste": 1024160, + "errors": 0 + }, + "skirting": { + "cuts": 26, + "pieces": 10, + "boards": 6, + "waste": 1024, + "errors": 0 + }, + "floorMs": 5.8, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-reversed-5", + "flooring": { + "cuts": 27, + "shapingCuts": 0, + "ripCuts": 6, + "patternPenalty": 2, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 24, + "waste": 1024160, + "errors": 0 + }, + "skirting": { + "cuts": 26, + "pieces": 10, + "boards": 6, + "waste": 1024, + "errors": 0 + }, + "floorMs": 5.9, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-6", + "flooring": { + "cuts": 33, + "shapingCuts": 0, + "ripCuts": 9, + "patternPenalty": 2, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 35, + "waste": 1026750, + "errors": 0 + }, + "skirting": { + "cuts": 26, + "pieces": 12, + "boards": 7, + "waste": 520, + "errors": 0 + }, + "floorMs": 7.9, + "skirtMs": 0.2 + }, + { + "name": "two-rooms-reversed-6", + "flooring": { + "cuts": 33, + "shapingCuts": 0, + "ripCuts": 9, + "patternPenalty": 1, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 35, + "waste": 1026750, + "errors": 0 + }, + "skirting": { + "cuts": 25, + "pieces": 13, + "boards": 7, + "waste": 520, + "errors": 0 + }, + "floorMs": 9.1, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-7", + "flooring": { + "cuts": 23, + "shapingCuts": 0, + "ripCuts": 6, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 32, + "waste": 1083560, + "errors": 0 + }, + "skirting": { + "cuts": 24, + "pieces": 12, + "boards": 8, + "waste": 2416, + "errors": 0 + }, + "floorMs": 12, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-reversed-7", + "flooring": { + "cuts": 22, + "shapingCuts": 0, + "ripCuts": 6, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 32, + "waste": 1083560, + "errors": 0 + }, + "skirting": { + "cuts": 24, + "pieces": 12, + "boards": 8, + "waste": 2416, + "errors": 0 + }, + "floorMs": 13.4, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-8", + "flooring": { + "cuts": 29, + "shapingCuts": 0, + "ripCuts": 9, + "patternPenalty": 8, + "repeats": 0, + "similarSteps": 2, + "staircases": 0, + "boards": 35, + "waste": 1316690, + "errors": 0 + }, + "skirting": { + "cuts": 26, + "pieces": 14, + "boards": 7, + "waste": 1312, + "errors": 0 + }, + "floorMs": 8.9, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-reversed-8", + "flooring": { + "cuts": 29, + "shapingCuts": 0, + "ripCuts": 9, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 35, + "waste": 1316690, + "errors": 0 + }, + "skirting": { + "cuts": 24, + "pieces": 13, + "boards": 7, + "waste": 1312, + "errors": 0 + }, + "floorMs": 11.4, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-9", + "flooring": { + "cuts": 22, + "shapingCuts": 0, + "ripCuts": 5, + "patternPenalty": 2, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 21, + "waste": 763840, + "errors": 0 + }, + "skirting": { + "cuts": 25, + "pieces": 11, + "boards": 6, + "waste": 2208, + "errors": 0 + }, + "floorMs": 8.9, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-reversed-9", + "flooring": { + "cuts": 24, + "shapingCuts": 0, + "ripCuts": 5, + "patternPenalty": 1, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 22, + "waste": 1003840, + "errors": 0 + }, + "skirting": { + "cuts": 25, + "pieces": 13, + "boards": 6, + "waste": 2208, + "errors": 0 + }, + "floorMs": 9.8, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-10", + "flooring": { + "cuts": 27, + "shapingCuts": 0, + "ripCuts": 7, + "patternPenalty": 4, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 31, + "waste": 891110, + "errors": 0 + }, + "skirting": { + "cuts": 24, + "pieces": 10, + "boards": 7, + "waste": 1704, + "errors": 0 + }, + "floorMs": 10.6, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-reversed-10", + "flooring": { + "cuts": 27, + "shapingCuts": 0, + "ripCuts": 7, + "patternPenalty": 5, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 31, + "waste": 891110, + "errors": 0 + }, + "skirting": { + "cuts": 24, + "pieces": 10, + "boards": 7, + "waste": 1704, + "errors": 0 + }, + "floorMs": 9.2, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-11", + "flooring": { + "cuts": 28, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 4, + "repeats": 0, + "similarSteps": 0, + "staircases": 1, + "boards": 27, + "waste": 802000, + "errors": 0 + }, + "skirting": { + "cuts": 24, + "pieces": 12, + "boards": 6, + "waste": 600, + "errors": 0 + }, + "floorMs": 14.9, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-reversed-11", + "flooring": { + "cuts": 24, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 27, + "waste": 802000, + "errors": 0 + }, + "skirting": { + "cuts": 24, + "pieces": 13, + "boards": 6, + "waste": 600, + "errors": 0 + }, + "floorMs": 13.4, + "skirtMs": 0.1 + }, + { + "name": "two-rooms-12", + "flooring": { + "cuts": 36, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 1, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 38, + "waste": 717610, + "errors": 0 + }, + "skirting": { + "cuts": 25, + "pieces": 15, + "boards": 7, + "waste": 96, + "errors": 0 + }, + "floorMs": 20, + "skirtMs": 1.4 + }, + { + "name": "two-rooms-reversed-12", + "flooring": { + "cuts": 36, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 1, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 38, + "waste": 717610, + "errors": 0 + }, + "skirting": { + "cuts": 25, + "pieces": 14, + "boards": 8, + "waste": 2496, + "errors": 0 + }, + "floorMs": 19.8, + "skirtMs": 0.1 + }, + { + "name": "balanced-edges", + "flooring": { + "cuts": 23, + "shapingCuts": 0, + "ripCuts": 8, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 39, + "waste": 1240000, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 6, + "waste": 2260, + "errors": 0 + }, + "floorMs": 4.3, + "skirtMs": 0.1 + }, + { + "name": "tight-offsets", + "flooring": { + "cuts": 22, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 1, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 42, + "waste": 776000, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 6, + "waste": 1920, + "errors": 0 + }, + "floorMs": 15.5, + "skirtMs": 0.1 + }, + { + "name": "exact-offset", + "flooring": { + "cuts": 16, + "shapingCuts": 0, + "ripCuts": 4, + "patternPenalty": 38, + "repeats": 10, + "similarSteps": 0, + "staircases": 0, + "boards": 42, + "waste": 776000, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 6, + "boards": 6, + "waste": 1920, + "errors": 0 + }, + "floorMs": 1.7, + "skirtMs": 0.1 + }, + { + "name": "forced-alternation", + "flooring": { + "cuts": 8, + "shapingCuts": 0, + "ripCuts": 0, + "patternPenalty": 62, + "repeats": 6, + "similarSteps": 6, + "staircases": 5, + "boards": 20, + "waste": 960000, + "errors": 0 + }, + "skirting": { + "cuts": 13, + "pieces": 7, + "boards": 4, + "waste": 1520, + "errors": 0 + }, + "floorMs": 0.4, + "skirtMs": 0 + }, + { + "name": "fractional-stock", + "flooring": { + "cuts": 2, + "shapingCuts": 0, + "ripCuts": 0, + "patternPenalty": 0, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 2, + "waste": 1200, + "errors": 0 + }, + "skirting": { + "cuts": 24, + "pieces": 8, + "boards": 2, + "waste": 645.6, + "errors": 0 + }, + "floorMs": 0.6, + "skirtMs": 0.1 + }, + { + "name": "large-rectangle", + "flooring": { + "cuts": 90, + "shapingCuts": 0, + "ripCuts": 24, + "patternPenalty": 11, + "repeats": 1, + "similarSteps": 0, + "staircases": 0, + "boards": 507, + "waste": 3596000, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 20, + "boards": 19, + "waste": 1520, + "errors": 0 + }, + "floorMs": 41.5, + "skirtMs": 0.2 + }, + { + "name": "long-room", + "flooring": { + "cuts": 41, + "shapingCuts": 0, + "ripCuts": 25, + "patternPenalty": 1, + "repeats": 0, + "similarSteps": 0, + "staircases": 0, + "boards": 252, + "waste": 3456000, + "errors": 0 + }, + "skirting": { + "cuts": 12, + "pieces": 30, + "boards": 27, + "waste": 1120, + "errors": 0 + }, + "floorMs": 72, + "skirtMs": 0.2 + }, + { + "name": "angled", + "flooring": { + "cuts": 172, + "shapingCuts": 96, + "ripCuts": 7, + "patternPenalty": 5, + "repeats": 0, + "similarSteps": 0, + "staircases": 1, + "boards": 99, + "waste": 1503224.964, + "errors": 0 + }, + "skirting": { + "cuts": 41, + "pieces": 18, + "boards": 12, + "waste": 2472.949, + "errors": 0 + }, + "floorMs": 329.2, + "skirtMs": 0.4 + }, + { + "name": "angled-f8", + "flooring": { + "cuts": 157, + "shapingCuts": 88, + "ripCuts": 9, + "patternPenalty": 9, + "repeats": 0, + "similarSteps": 0, + "staircases": 1, + "boards": 150, + "waste": 1778728.586, + "errors": 0 + }, + "skirting": { + "cuts": 46, + "pieces": 19, + "boards": 12, + "waste": 1595.838, + "errors": 0 + }, + "floorMs": 225.6, + "skirtMs": 0.3 + } +] diff --git a/benchmarks/cases.ts b/benchmarks/cases.ts new file mode 100644 index 0000000..593d19e --- /dev/null +++ b/benchmarks/cases.ts @@ -0,0 +1,61 @@ +import { defaultProject, fireplace, rectangle } from '../src/lib/defaults'; +import type { Project, Room } from '../src/lib/types'; +import angled from '../tests/fixtures/angled-room.json'; +import angledF8 from '../tests/fixtures/angled-room-f8.json'; + +export interface PlannerCase { + name: string; + project: Project; +} + +const room = (width: number, height: number, id = 'room-1'): Room => { + const result = rectangle(id, id, width, height); + result.walls.forEach((wall) => (wall.doors = [])); + return result; +}; + +export function plannerCases(): PlannerCase[] { + const cases: PlannerCase[] = []; + const add = (name: string, rooms: Room[], changes: Partial = {}) => { + const project = defaultProject(); + project.rooms = rooms; + project.flooring = { ...project.flooring, pattern: 'random', ...changes }; + cases.push({ name, project }); + }; + for (const pattern of ['brick', 'random'] as const) { + add(`rectangle-${pattern}`, [rectangle()], { pattern }); + add(`fireplace-${pattern}`, [fireplace('room-2', 'Fireplace')], { pattern }); + add(`rotated-${pattern}`, [{ ...fireplace('room-2', 'Fireplace'), direction: 90 }], { + pattern, + }); + } + // Fixed arithmetic sequences cover a range of remainders without runtime randomness. + for (let i = 0; i < 45; i++) { + add(`rectangle-${i + 1}`, [room(1620 + ((i * 733) % 3800), 620 + ((i * 379) % 1900))], { + width: 200, + }); + } + for (let i = 0; i < 12; i++) { + const rooms = [ + room(1320 + ((i * 271) % 1900), 620 + ((i * 317) % 1200), 'r0'), + room(1420 + ((i * 653) % 2100), 820 + ((i * 211) % 1200), 'r1'), + ]; + add(`two-rooms-${i + 1}`, rooms, { width: 200 }); + add(`two-rooms-reversed-${i + 1}`, [...rooms].reverse(), { width: 200 }); + } + add('balanced-edges', [room(4020, 2050)], { width: 200 }); + add('tight-offsets', [room(4020, 2220)], { minOffset: 250, maxOffset: 450 }); + add('exact-offset', [room(4020, 2220)], { minOffset: 400, maxOffset: 400 }); + add('forced-alternation', [room(2420, 1620)], { width: 200, minLength: 600 }); + add('fractional-stock', [room(620, 420, 'r0'), room(617.2, 420, 'r1')], { + length: 1200.2, + width: 200, + }); + 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 }, + ); + return cases; +} diff --git a/benchmarks/metrics.ts b/benchmarks/metrics.ts new file mode 100644 index 0000000..5bc6534 --- /dev/null +++ b/benchmarks/metrics.ts @@ -0,0 +1,98 @@ +import type { FlooringPlan, FlooringSettings, SkirtingPlan } from '../src/lib/types'; + +/** Evaluate public plan output. Keep these definitions fixed when comparing search changes. */ +export function flooringMetrics(plan: FlooringPlan, s: FlooringSettings) { + let shapingCuts = 0; + let ripCuts = 0; + let patternPenalty = 0; + let repeats = 0; + let similarSteps = 0; + let staircases = 0; + for (const piece of plan.pieces) { + ripCuts += piece.ripEdge === 'both' ? 2 : Number(!!piece.ripEdge); + if (!piece.shaped) continue; + const close = (a: number, b: number) => Math.abs(a - b) <= 0.01; + shapingCuts += piece.template.filter((p, i, path) => { + const q = path[(i + 1) % path.length]; + return !( + (close(p.x, q.x) && (close(p.x, 0) || close(p.x, piece.length))) || + (close(p.y, q.y) && (close(p.y, 0) || close(p.y, piece.width))) + ); + }).length; + } + const history = new Map(); + const step = (from: number, to: number) => { + const value = (((to - from + s.length / 2) % s.length) + s.length) % s.length; + return ((Math.round(value * 1000) / 1000) % s.length) - s.length / 2; + }; + const similar = Math.max(s.kerf * 2, s.length * 0.03); + for (const row of s.pattern === 'random' ? plan.rows : []) { + const recent = history.get(row.roomId) ?? []; + const phase = row.offset; + if (recent.length >= 2) { + const movement = step(recent.at(-1)!, phase); + const previous = step(recent.at(-2)!, recent.at(-1)!); + patternPenalty += Number(Math.abs(Math.abs(movement) - s.length / 2) <= 0.01); + for (let distance = 2; distance <= recent.length; distance++) { + if (Math.abs(step(recent.at(-distance)!, phase)) <= similar) { + patternPenalty += 5 - distance; + if (distance === 2) repeats++; + } + } + if (movement * previous > 0) { + patternPenalty++; + if (Math.abs(movement - previous) <= similar) { + patternPenalty += 3; + similarSteps++; + } + if (recent.length >= 3 && previous * step(recent.at(-3)!, recent.at(-2)!) > 0) { + patternPenalty += 2; + staircases++; + } + } + } + history.set(row.roomId, [...recent, phase].slice(-4)); + } + return { + cuts: plan.cuts, + shapingCuts, + ripCuts, + patternPenalty, + repeats, + similarSteps, + staircases, + boards: plan.boardCount, + waste: plan.wasteArea, + errors: plan.issues.filter((issue) => issue.severity === 'error').length, + }; +} + +export function skirtingMetrics(plan: SkirtingPlan) { + return { + cuts: plan.cuts, + pieces: plan.pieces.length, + boards: plan.stockCount, + waste: Math.round(plan.waste * 1000) / 1000, + errors: plan.issues.filter((issue) => issue.severity === 'error').length, + }; +} + +export function compareFlooring( + a: ReturnType, + b: typeof a, + random = false, +) { + return ( + a.errors - b.errors || + (random ? a.patternPenalty - b.patternPenalty : 0) || + a.cuts - b.cuts || + a.shapingCuts - b.shapingCuts || + a.ripCuts - b.ripCuts || + a.patternPenalty - b.patternPenalty || + a.boards - b.boards + ); +} + +export function compareSkirting(a: ReturnType, b: typeof a) { + return a.errors - b.errors || a.cuts - b.cuts || a.waste - b.waste; +} diff --git a/e2e/landing.spec.ts b/e2e/landing.spec.ts new file mode 100644 index 0000000..61ddf24 --- /dev/null +++ b/e2e/landing.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; + +test('the home page explains the app and its guides fit the screen', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto('/'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'A great floorstarts witha 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' }).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 page.reload(); + await expect(page.getByRole('heading', { name: /Your first floor/ })).toBeInViewport(); + await page.getByRole('link', { name: 'Back to top' }).click(); + await expect(page.getByRole('heading', { level: 1 })).toBeInViewport(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( + page.viewportSize()!.width, + ); + expect(errors).toEqual([]); +}); + +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('/'); + 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 page.getByLabel('Room name', { exact: true }).fill('My first floor'); + const projectUrl = page.url(); + await page.reload(); + await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('My first floor'); + await page.goBack(); + await expect(page.getByRole('heading', { level: 1 })).toContainText('A great floor'); + await page.goForward(); + await expect(page).toHaveURL(projectUrl); + await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('My first floor'); + }); +} + +test('returning through the home page resumes the saved project', async ({ page }) => { + await page.goto('/?planner'); + await page.getByLabel('Room name', { exact: true }).fill('Weekend project'); + await page.getByLabel('Room width', { exact: true }).fill('4200'); + await page.getByRole('link', { name: 'Floor planner home' }).click(); + await expect(page.getByRole('heading', { level: 1 })).toContainText('A great floor'); + await page.getByRole('link', { name: 'Plan my floor' }).first().click(); + await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Weekend project'); + await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4200'); +}); diff --git a/e2e/optimisation.spec.ts b/e2e/optimisation.spec.ts new file mode 100644 index 0000000..ddcc9e7 --- /dev/null +++ b/e2e/optimisation.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test'; +import { defaultState } from '../src/lib/defaults'; +import { planFlooringOptions } from '../src/lib/flooring'; +import { mm } from '../src/lib/format'; +import { decodeState, encodeState } from '../src/lib/url-state'; + +test('random optimisation choices show actual totals, select the plan and survive reload', async ({ + page, + isMobile, +}) => { + if (isMobile) await page.setViewportSize({ width: 320, height: 844 }); + const state = defaultState(); + state.project.flooring.pattern = 'random'; + const p = state.project; + const options = planFlooringOptions(p.rooms, p.flooring, p.tolerance); + await page.goto(`/${encodeState(state)}`); + const choices = page.getByRole('group', { name: 'Flooring optimisation', exact: true }); + const cuts = choices.getByRole('button', { name: /^Minimise cuts/ }); + const random = choices.getByRole('button', { name: /^Maximise randomness/ }); + await expect(cuts).toHaveAttribute('aria-pressed', 'true'); + await expect(cuts).toContainText( + `${options.cuts.cuts} cuts · ${mm(options.cuts.wastePercent)}% waste`, + ); + await expect(random).toContainText( + `${options.random.cuts} cuts · ${mm(options.random.wastePercent)}% waste`, + ); + expect(options.cuts.cuts).toBeLessThan(options.random.cuts); + const checkbox = page.locator('.stock-cut-group input[type="checkbox"]').first(); + await checkbox.check(); + expect(new URL(page.url()).searchParams.getAll('done')).not.toEqual([]); + await random.click(); + await expect(random).toHaveAttribute('aria-pressed', 'true'); + await expect(cuts).toHaveAttribute('aria-pressed', 'false'); + expect(new URL(page.url()).searchParams.get('flooring.optimisation')).toBe('random'); + expect(new URL(page.url()).searchParams.getAll('done')).toEqual([]); + const drawn = page.getByRole('region', { name: 'Floor plan preview' }).locator('.floor-piece'); + await expect(drawn).toHaveCount(options.random.pieces.length); + const ids = await drawn.evaluateAll((pieces) => + pieces.map((piece) => piece.getAttribute('data-piece-id')), + ); + expect(ids.sort()).toEqual(options.random.pieces.map((piece) => piece.id).sort()); + await page.reload(); + await expect(random).toHaveAttribute('aria-pressed', 'true'); + expect(decodeState(new URL(page.url()).search).state.project.flooring.optimisation).toBe( + 'random', + ); + await cuts.click(); + await expect(cuts).toHaveAttribute('aria-pressed', 'true'); + await expect(drawn).toHaveCount(options.cuts.pieces.length); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( + page.viewportSize()!.width, + ); + await page.getByRole('button', { name: 'Brick', exact: true }).click(); + await expect(choices).toHaveCount(0); +}); + +test('infeasible measurements do not advertise zero-cut or zero-waste options', async ({ + page, +}) => { + const state = defaultState(); + state.project.flooring.pattern = 'random'; + state.project.flooring.minOffset = 700; + await page.goto(`/${encodeState(state)}`); + const choices = page.getByRole('group', { name: 'Flooring optimisation', exact: true }); + await expect(choices.getByText('Check measurements', { exact: true })).toHaveCount(2); + await expect(choices).not.toContainText('0 cuts'); + await expect(choices).not.toContainText('0% waste'); +}); diff --git a/e2e/planner.spec.ts b/e2e/planner.spec.ts index e7dcfa3..b956aed 100644 --- a/e2e/planner.spec.ts +++ b/e2e/planner.spec.ts @@ -3,6 +3,7 @@ import type { Locator, Page } from '@playwright/test'; import LZString from 'lz-string'; import { readFile } from 'node:fs/promises'; import { decodeState, encodeState } from '../src/lib/url-state'; +import { PROJECT_STORAGE_KEY } from '../src/lib/use-project'; import { defaultState, fireplace, rectangle } from '../src/lib/defaults'; import { planFlooring } from '../src/lib/flooring'; import { planSkirting } from '../src/lib/skirting'; @@ -23,6 +24,8 @@ const state = (page: Page) => { const url = new URL(page.url()); return decodeState(url.search || url.hash).state; }; +const savedQuery = (page: Page) => + page.evaluate((key) => localStorage.getItem(key), PROJECT_STORAGE_KEY); const plans = (page: Page) => { const { project } = state(page); return { @@ -37,7 +40,8 @@ function sharedRoomFixture() { fixture.project.flooring.width = 200; fixture.project.rooms = [ rectangle('room-1', 'Living room', 1820, 1220), - rectangle('room-2', 'Study', 1220, 1820), + // Its end pieces complement the first room's offcuts after saw kerf. + rectangle('room-2', 'Study', 1220, 1814), ]; fixture.project.rooms.forEach((room) => room.walls.forEach((wall) => { @@ -259,7 +263,7 @@ async function noWallLabelOverlap(page: Page) { test.beforeEach(async ({ page }) => { errors.set(page, []); page.on('pageerror', (error) => errors.get(page)!.push(error.message)); - await page.goto('/'); + await page.goto('/?planner'); await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible(); await expect(page).toHaveURL(/\?version=1&/); }); @@ -679,9 +683,115 @@ test('pattern, direction and spacers persist without steps, layers or stored res ).toHaveAttribute('aria-pressed', 'true'); await expect(page.locator('.flooring-cuts')).toHaveText(table, { useInnerText: true }); await noHorizontalOverflow(page); - expect(await page.evaluate(() => Object.keys(localStorage))).toEqual([]); + expect(await savedQuery(page)).toBe(new URL(page.url()).search); }); +test('localStorage restores all project and view parameters when reopening without a link', async ({ + page, + context, +}) => { + const fixture = sharedRoomFixture(); + fixture.project.name = 'Café 北 flooring'; + fixture.project.tolerance = 7; + fixture.project.flooring = { + ...fixture.project.flooring, + length: 1300, + width: 180, + gap: 12, + pattern: 'random', + minOffset: 250, + maxOffset: 600, + minLength: 180, + minWidth: 50, + kerf: 2.5, + }; + fixture.project.skirting = { stockLength: 3600, thickness: 20, minLength: 250, kerf: 2 }; + fixture.project.rooms[1].walls[0].doors = [{ id: 'study-door', offset: 300, width: 850 }]; + fixture.project.completedBoards = ['floor:B1', 'skirting:S1']; + fixture.view = { + roomId: 'room-2', + selectedPiece: 'F1', + search: 'Study', + collapsedSections: ['cuts', 'materials'], + }; + const query = encodeState(fixture); + await page.goto(`/${query}`); + await expect(page.getByRole('dialog', { name: 'Flooring cut F1', exact: true })).toBeVisible(); + expect(await savedQuery(page)).toBe(query); + + const reopened = await context.newPage(); + await reopened.goto('/?planner'); + await expect(reopened).toHaveURL(page.url()); + expect(state(reopened)).toEqual(fixture); + await expect( + reopened.getByRole('dialog', { name: 'Flooring cut F1', exact: true }), + ).toBeVisible(); + await reopened.keyboard.press('Escape'); + await expect(reopened.getByLabel('Room name', { exact: true })).toHaveValue('Study'); + await expect(reopened.locator('#materials-content')).toBeHidden(); + await expect(reopened.locator('#cuts-content')).toBeHidden(); + await reopened.getByLabel('Room name', { exact: true }).fill('Saved study'); + fixture.project.rooms[1].name = 'Saved study'; + fixture.view.selectedPiece = null; + expect(await savedQuery(reopened)).toBe(encodeState(fixture)); + await reopened.goto('/?planner'); + await expect(reopened.getByLabel('Room name', { exact: true })).toHaveValue('Saved study'); + expect(state(reopened)).toEqual(fixture); + await reopened.close(); +}); + +test('explicit query parameters override the saved plan and default omitted fields', async ({ + page, +}) => { + await page.getByLabel('Room name', { exact: true }).fill('Saved room'); + await page.getByLabel('Wall spacer', { exact: true }).fill('12'); + await page.goto('/?flooring.length=1400'); + await expect(page.getByLabel('Board length', { exact: true })).toHaveValue('1400'); + const expected = defaultState(); + expected.project.flooring.length = 1400; + expect(state(page)).toEqual(expected); + expect(await savedQuery(page)).toBe(encodeState(expected)); +}); + +for (const saved of ['damaged data', '?flooring.length=broken']) { + test(`invalid localStorage falls back to defaults: ${saved}`, async ({ page }) => { + await page.evaluate( + ([key, value]) => localStorage.setItem(key, value), + [PROJECT_STORAGE_KEY, saved], + ); + await page.goto('/?planner'); + await expect(page).toHaveURL(/\?version=1&/); + await expect(page.locator('.url-error')).toHaveCount(0); + expect(state(page)).toEqual(defaultState()); + expect(await savedQuery(page)).toBe(encodeState(defaultState())); + }); +} + +for (const failure of ['blocked', 'full']) { + test(`URL persistence still works when localStorage is ${failure}`, async ({ page }) => { + await page.addInitScript((failure) => { + if (failure === 'blocked') { + Object.defineProperty(window, 'localStorage', { + get() { + throw new DOMException('Storage blocked', 'SecurityError'); + }, + }); + } else { + Storage.prototype.setItem = () => { + throw new DOMException('Storage full', 'QuotaExceededError'); + }; + } + }, failure); + await page.goto('/?planner'); + await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Living room'); + await page.getByLabel('Room name', { exact: true }).fill('URL still works'); + expect(state(page).project.rooms[0].name).toBe('URL still works'); + await page.reload(); + await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('URL still works'); + await expect(page.locator('.url-error')).toHaveCount(0); + }); +} + test('a narrow remainder is shared between the first and last flooring rows', async ({ page }) => { // 3650 minus two 10 mm spacers leaves 19 full rows and a 20 mm sliver. await page.getByLabel('Room length', { exact: true }).fill('3650'); @@ -780,12 +890,15 @@ test('saw kerf help works with keyboard and touch or mouse', async ({ page, isMo }); test('invalid URLs recover when measurements are edited', async ({ page }) => { + const saved = await savedQuery(page); await page.goto('/?flooring.length=broken'); await expect(page.getByText(/Could not load this plan/)).toBeVisible(); + expect(await savedQuery(page)).toBe(saved); await page.getByLabel('Room name', { exact: true }).fill('Recovered plan'); await expect(page.getByText(/Could not load this plan/)).toHaveCount(0); expect(decodeState(new URL(page.url()).search).error).toBeNull(); expect(new URL(page.url()).hash).toBe(''); + expect(await savedQuery(page)).toBe(new URL(page.url()).search); await page.reload(); await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Recovered plan'); }); @@ -818,6 +931,7 @@ test('readable query fields, legacy links and browser history preserve the plan' 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)); const original = page.url(); await page.getByRole('button', { name: 'Add room', exact: true }).click(); await page.getByLabel('Room name', { exact: true }).fill('Study'); @@ -833,10 +947,12 @@ test('readable query fields, legacy links and browser history preserve the plan' await expect(page).toHaveURL(original); await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Legacy lounge'); expect(state(page).project.rooms).toHaveLength(1); + expect(await savedQuery(page)).toBe(new URL(original).search); await page.goForward(); await expect(page).toHaveURL(twoRooms); await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study'); await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('3333'); + expect(await savedQuery(page)).toBe(new URL(twoRooms).search); params.set('flooring.width', '205'); await page.goto(`/?${params}`); await expect(page.getByLabel('Board width', { exact: true })).toHaveValue('205'); diff --git a/index.html b/index.html index 6413164..c644e93 100644 --- a/index.html +++ b/index.html @@ -4,9 +4,10 @@ + Floor planner diff --git a/package-lock.json b/package-lock.json index 784d6e4..2805792 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "floor-planner", "version": "1.0.0", "dependencies": { + "@plausible-analytics/tracker": "^0.4.6", "clipper-lib": "^6.4.2", "lucide-react": "^0.468.0", "lz-string": "^1.5.0", @@ -819,6 +820,12 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@plausible-analytics/tracker": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.6.tgz", + "integrity": "sha512-nbu6xGgSP6jeYuoVklZBzucUnIpo75tVu3Kr+R0EDycVKaMC/jF5Q/YCaM/8gR4fPZ1IDcXt6FdBJFZ1uZwEPA==", + "license": "MIT" + }, "node_modules/@playwright/test": { "version": "1.63.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", diff --git a/package.json b/package.json index 9f7edc7..966c69e 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,13 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", + "benchmark": "node scripts/benchmark-planner.mjs", "check": "npm run build && npm test && npm run test:e2e", "format": "prettier --write .", "format:check": "prettier --check ." }, "dependencies": { + "@plausible-analytics/tracker": "^0.4.6", "clipper-lib": "^6.4.2", "lucide-react": "^0.468.0", "lz-string": "^1.5.0", diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..d72d5b2 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/scripts/benchmark-planner.mjs b/scripts/benchmark-planner.mjs new file mode 100644 index 0000000..320577d --- /dev/null +++ b/scripts/benchmark-planner.mjs @@ -0,0 +1,88 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { createServer } from 'vite'; + +const server = await createServer({ server: { middlewareMode: true }, appType: 'custom' }); +try { + const [{ planFlooring }, { planSkirting }, { plannerCases }, metrics] = await Promise.all([ + server.ssrLoadModule('/src/lib/flooring.ts'), + server.ssrLoadModule('/src/lib/skirting.ts'), + server.ssrLoadModule('/benchmarks/cases.ts'), + server.ssrLoadModule('/benchmarks/metrics.ts'), + ]); + const cases = plannerCases(); + const random = process.argv.includes('--random'); + if (random) cases.forEach(({ project }) => (project.flooring.optimisation = 'random')); + const results = []; + // Warm up the geometry and planners before taking timings. + planFlooring(cases[0].project.rooms, cases[0].project.flooring, cases[0].project.tolerance); + for (const { name, project: p } of cases) { + const start = performance.now(); + const flooring = planFlooring(p.rooms, p.flooring, p.tolerance); + const floorMs = performance.now() - start; + const skirtStart = performance.now(); + const skirting = planSkirting(p.rooms, p.skirting, p.tolerance); + results.push({ + name, + flooring: metrics.flooringMetrics(flooring, p.flooring), + skirting: metrics.skirtingMetrics(skirting), + floorMs: Math.round(floorMs * 10) / 10, + skirtMs: Math.round((performance.now() - skirtStart) * 10) / 10, + }); + } + const baselinePath = new URL('../benchmarks/baseline.json', import.meta.url); + if (process.argv.includes('--write-baseline')) { + await writeFile(baselinePath, JSON.stringify(results, null, 2) + '\n'); + console.log(`Recorded ${results.length} baseline cases.`); + } else { + const comparison = process.argv + .find((arg) => arg.startsWith('--compare=')) + ?.slice('--compare='.length); + const baseline = JSON.parse(await readFile(comparison ?? baselinePath, 'utf8')); + const sum = (rows, key, metric) => rows.reduce((total, row) => total + row[key][metric], 0); + const summary = []; + for (const kind of ['flooring', 'skirting']) { + const compare = + kind === 'flooring' + ? (a, b) => metrics.compareFlooring(a, b, random) + : metrics.compareSkirting; + const changes = results.map((result) => { + const previous = baseline.find((row) => row.name === result.name); + if (!previous) throw new Error(`No baseline for ${result.name}`); + return { name: result.name, change: compare(result[kind], previous[kind]) }; + }); + summary.push({ + planner: kind, + improved: changes.filter((row) => row.change < 0).length, + tied: changes.filter((row) => row.change === 0).length, + regressed: changes.filter((row) => row.change > 0).length, + 'cuts before': sum(baseline, kind, 'cuts'), + 'cuts after': sum(results, kind, 'cuts'), + 'boards before': sum(baseline, kind, 'boards'), + 'boards after': sum(results, kind, 'boards'), + errors: sum(results, kind, 'errors'), + }); + const regressions = changes.filter((row) => row.change > 0).map((row) => row.name); + if (regressions.length) console.log(`${kind} regressions: ${regressions.join(', ')}`); + } + console.table(summary); + console.table( + ['shapingCuts', 'ripCuts', 'patternPenalty', 'repeats', 'similarSteps', 'staircases'].map( + (metric) => ({ + metric, + before: sum(baseline, 'flooring', metric), + after: sum(results, 'flooring', metric), + }), + ), + ); + } + for (const key of ['floorMs', 'skirtMs']) { + const times = results.map((row) => row[key]).sort((a, b) => a - b); + console.log( + `${key}: total ${times.reduce((a, b) => a + b, 0).toFixed(1)} ms; p95 ${times[Math.floor(times.length * 0.95)]} ms; max ${times.at(-1)} ms`, + ); + } + const output = process.argv.find((arg) => arg.startsWith('--output='))?.slice('--output='.length); + if (output) await writeFile(output, JSON.stringify(results, null, 2) + '\n'); +} finally { + await server.close(); +} diff --git a/src/App.tsx b/src/App.tsx index fec1194..8ee4119 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,9 +2,10 @@ import { useMemo, useRef } from 'react'; import type { ReactNode } from 'react'; import { Copy, Plus, Printer, Search, X } from 'lucide-react'; import { useProject } from './lib/use-project'; +import { trackCopyLink, trackPrint, trackRoomAdded, trackRoomRemoved } from './lib/analytics'; import { rectangle, nextId } from './lib/defaults'; import { buildGeometry } from './lib/geometry'; -import { planFlooring } from './lib/flooring'; +import { planFlooringOptions } from './lib/flooring'; import { planSkirting } from './lib/skirting'; import { area, mm } from './lib/format'; import { flooringCuts, isSkirtingCut, skirtingCuts } from './lib/cut-list'; @@ -70,10 +71,13 @@ export default function App() { [project.rooms, project.tolerance], ); const geometry = geometries[project.rooms.indexOf(room)]; - const flooring = useMemo( - () => planFlooring(project.rooms, project.flooring, project.tolerance), - [project.rooms, project.flooring, project.tolerance], + // Switching between already calculated options only selects a result. + const flooringInputs = JSON.stringify({ ...project.flooring, optimisation: undefined }); + const flooringOptions = useMemo( + () => planFlooringOptions(project.rooms, project.flooring, project.tolerance), + [project.rooms, flooringInputs, project.tolerance], ); + const flooring = flooringOptions[project.flooring.optimisation ?? 'cuts']; const skirting = useMemo( () => planSkirting(project.rooms, project.skirting, project.tolerance), [project.rooms, project.skirting, project.tolerance], @@ -144,7 +148,7 @@ export default function App() { ...s, view: { ...s.view, roomId: roomId ?? s.view.roomId, selectedPiece: id }, })); - const addRoom = () => + const addRoom = () => { update((s) => { const id = nextId( 'room', @@ -168,7 +172,9 @@ export default function App() { }, }; }, true); - const removeRoom = () => + trackRoomAdded(); + }; + const removeRoom = () => { update((s) => { const rooms = s.project.rooms.filter((r) => r.id !== room.id); return { @@ -177,10 +183,13 @@ export default function App() { view: { ...s.view, roomId: rooms[0].id, selectedPiece: null, search: '' }, }; }, true); + trackRoomRemoved(); + }; const copyLink = async () => { try { await navigator.clipboard.writeText(window.location.href); if (copyStatus.current) copyStatus.current.textContent = 'Link copied'; + trackCopyLink(); } catch { if (copyStatus.current) copyStatus.current.textContent = 'Copy the address from your browser.'; @@ -190,10 +199,14 @@ export default function App() { return ( <>
- +
+ {f.pattern === 'random' && ( +
+ Optimise random flooring · all rooms +
+ {(['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; + return ( + + ); + })} +
+
+ )}
Board direction · {room.name}
diff --git a/src/landing.css b/src/landing.css new file mode 100644 index 0000000..656e9dd --- /dev/null +++ b/src/landing.css @@ -0,0 +1,840 @@ +.landing-page { + --landing-muted: #5e665f; + --landing-line: #d9ddd3; + --landing-accent: #ac451d; + background: #fdfcf7; + color: var(--ink); + font-size: 15px; + -webkit-font-smoothing: antialiased; +} +.landing-container { + width: min(1180px, calc(100% - 96px)); + margin-inline: auto; +} +.landing-page a { + color: inherit; +} +.landing-page a:focus-visible { + outline: 3px solid var(--blue); + outline-offset: 5px; +} +.landing-page section[id], +.landing-page main { + scroll-margin-top: 24px; +} +.landing-header { + min-height: 100px; + 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; + 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 { + padding-block: 12px; + font-size: 13px; + text-decoration: none; +} +.landing-nav a:hover, +.landing-footer > a:last-child:hover { + text-decoration: underline; + text-underline-offset: 5px; +} +.landing-page .landing-cta { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 42px; + min-height: 64px; + padding: 18px 25px; + background: var(--landing-accent); + border: 1px solid var(--landing-accent); + color: #fffdf7; + border-radius: 3px; + font-size: 17px; + font-weight: 600; + text-decoration: none; + transition: + background 160ms ease, + transform 160ms ease; +} +.landing-page .landing-cta:hover { + background: #8b3514; + transform: translateY(-2px); +} +.landing-page .landing-cta-small { + min-height: 44px; + padding: 10px 16px; + font-size: 13px; + gap: 18px; +} +.landing-hero { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: center; + 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; + flex-wrap: wrap; + align-items: center; + gap: 15px 22px; + margin-top: 29px; +} +.landing-guide-link { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 44px; + font-size: 12px; + 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; +} +.landing-plan-room { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 22px 24px 10px; + font-size: 12px; + font-weight: 600; +} +.landing-plan-room > span:last-child { + font: 10px var(--mono); + color: #657260; + align-self: center; +} +.landing-plan-paper > svg { + display: block; + width: 100%; + height: auto; +} +.landing-plan-legend { + display: flex; + flex-wrap: wrap; + gap: 20px; + padding: 17px 24px; + border-top: 1px solid #dbe0d5; + color: #526156; + font-size: 10px; +} +.landing-plan-legend span { + display: inline-flex; + align-items: center; + gap: 6px; +} +.landing-plan-legend i { + width: 9px; + height: 9px; + border: 1px solid #b49a72; + background: #ead9bc; +} +.landing-plan-legend .legend-trim { + background: none; + border: 2px solid #b74900; +} +.landing-plan-legend .legend-cut { + background: #c66935; + border-color: #a74617; +} +.landing-benefits { + padding-top: 29px; + 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); + margin-top: 34px; +} +.landing-benefit { + padding-inline: 30px; + border-left: 1px solid var(--landing-line); +} +.landing-benefit:first-child { + padding-left: 0; + border: 0; +} +.landing-benefit:last-child { + padding-right: 0; +} +.landing-benefit-top { + display: flex; + justify-content: space-between; + align-items: center; + 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; + border-block: 1px solid #e0e4d8; +} +.landing-guide-layout { + display: grid; + 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; + margin-top: 22px; + font-size: 14px; +} +.landing-text-link { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 44px; + font-size: 12px; + text-underline-offset: 4px; +} +.landing-guide-intro > .landing-text-link { + margin-top: 16px; +} +.landing-tool-note { + display: flex; + align-items: flex-start; + gap: 13px; + padding-top: 25px; + margin-top: 30px; + border-top: 1px solid #cdd5c5; + color: var(--landing-muted); + font-size: 12px; + line-height: 1.8; +} +.landing-tool-note strong { + display: block; + color: var(--ink); + font-weight: 600; + margin-bottom: 4px; +} +.landing-steps { + list-style: none; + padding: 0; + margin: 0; +} +.landing-steps li { + display: flex; + align-items: flex-start; + gap: 23px; + padding-block: 23px; + border-bottom: 1px solid #cdd5c5; +} +.landing-steps li:first-child { + padding-top: 0; +} +.landing-step-number { + font: 13px var(--mono); + color: var(--landing-accent); + padding-top: 4px; +} +.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; +} +.landing-finishing { + padding-block: 73px 65px; +} +.landing-finishing-heading { + display: grid; + grid-template-columns: 1.1fr 0.9fr; + align-items: end; + 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; + gap: 24px; +} +.landing-trim-card { + border: 1px solid var(--landing-line); + border-radius: 3px; + 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; + border-bottom: 1px solid var(--landing-line); +} +.landing-trim-diagram { + display: block; + width: calc(100% - 48px); + max-width: 430px; + height: auto; + margin: 20px auto; +} +.landing-trim-copy { + margin: 0 24px; + padding: 22px 0 21px; + 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; +} +.landing-trim-choice svg { + margin-top: 2px; + color: #527057; +} +.landing-finishing-note { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 40px; + margin-top: 23px; + font-size: 11px; + color: var(--landing-muted); + line-height: 1.8; +} +.landing-finishing-note p { + max-width: 780px; +} +.landing-finishing-note strong { + color: var(--ink); +} +.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; + color: var(--landing-muted); +} +.landing-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +.landing-skip { + position: absolute; + top: 8px; + left: 8px; + padding: 12px; + background: white; + z-index: 10; + 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; + } + .landing-hero-actions { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + .landing-plan-heading, + .landing-plan-room { + padding-inline: 15px; + } + .landing-guide-layout { + gap: 65px; + } + .landing-finishing-heading { + gap: 35px; + } +} +@media (max-width: 800px) { + .landing-nav { + display: none; + } + .landing-header { + min-height: 82px; + } + .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; + } + .landing-hero-actions { + flex-direction: row; + align-items: center; + gap: 20px; + } + .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; + } + .landing-guide-layout { + grid-template-columns: 1fr; + gap: 38px; + } + .landing-guide-intro { + max-width: 540px; + } + .landing-tool-note { + margin-top: 15px; + padding-top: 20px; + } + .landing-finishing-heading { + grid-template-columns: 1fr; + gap: 20px; + } + .landing-trim-grid { + gap: 16px; + } + .landing-trim-diagram { + width: calc(100% - 16px); + } + .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-container { + width: calc(100% - 40px); + } + .landing-header { + gap: 12px; + } + .landing-brand { + font-size: 19px; + gap: 8px; + } + .landing-brand .square-mark { + transform: scale(0.85); + transform-origin: left center; + 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 { + gap: 8px; + } + .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; + } + .landing-benefits { + padding-bottom: 42px; + } + .landing-benefit-grid { + grid-template-columns: 1fr; + gap: 24px; + margin-top: 30px; + } + .landing-benefit, + .landing-benefit:first-child { + border: 0; + padding: 0 0 24px; + border-bottom: 1px solid var(--landing-line); + } + .landing-benefit:last-child { + padding-bottom: 0; + border: 0; + } + .landing-benefit-top { + margin-bottom: 12px; + } + .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; + } + .landing-finishing { + padding-block: 46px 36px; + } + .landing-trim-grid { + grid-template-columns: 1fr; + gap: 22px; + } + .landing-trim-label { + font-size: 9px; + padding: 15px 20px; + } + .landing-trim-copy { + margin-inline: 20px; + } + .landing-trim-diagram { + width: calc(100% - 32px); + } + .landing-finishing-note { + 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 { + transition: none; + } + .landing-page .landing-cta:hover { + transform: none; + } +} +@media print { + .landing-header, + .landing-footer, + .landing-final-cta, + .landing-hero-actions, + .landing-skip { + display: none; + } + .landing-container { + width: 100%; + } + .landing-plan { + transform: none; + box-shadow: none; + } + .landing-trim-card, + .landing-steps li { + break-inside: avoid; + } +} diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 0000000..c781dd2 --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,52 @@ +import { + init as plausibleInit, + track as plausibleTrack, + type PlausibleEventOptions, +} from '@plausible-analytics/tracker'; + +const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true; +const ANALYTICS_DOMAIN = 'floor-planner.schmelczer.dev'; +const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status'; +const ANALYTICS_LOGGING = import.meta.env.DEV; + +let isInitialized = false; + +const track = (eventName: string, options: PlausibleEventOptions = {}) => { + try { + plausibleTrack(eventName, options); + } catch (error) { + console.warn(`Could not track analytics event "${eventName}".`, error); + } +}; + +export const initAnalytics = () => { + if (isInitialized) { + return; + } + + try { + plausibleInit({ + domain: ANALYTICS_DOMAIN, + endpoint: ANALYTICS_ENDPOINT, + autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS, + logging: ANALYTICS_LOGGING, + // Query strings and legacy fragments contain the saved plan, including room names. + transformRequest: (payload) => ({ + ...payload, + u: payload.u.split(/[?#]/)[0], + r: payload.r?.split(/[?#]/)[0] ?? payload.r, + }), + }); + isInitialized = true; + } catch (error) { + console.warn('Could not initialize analytics.', error); + } +}; + +export const trackRoomAdded = () => track('Add Room'); + +export const trackRoomRemoved = () => track('Remove Room'); + +export const trackCopyLink = () => track('Copy Link'); + +export const trackPrint = () => track('Print'); diff --git a/src/lib/defaults.ts b/src/lib/defaults.ts index 1994f42..54f13c6 100644 --- a/src/lib/defaults.ts +++ b/src/lib/defaults.ts @@ -43,6 +43,7 @@ export function defaultProject(): Project { width: 190, gap: 10, pattern: 'brick', + optimisation: 'cuts', minOffset: 300, maxOffset: 600, minLength: 200, diff --git a/src/lib/flooring.ts b/src/lib/flooring.ts index a7f1dc0..fe9363f 100644 --- a/src/lib/flooring.ts +++ b/src/lib/flooring.ts @@ -16,6 +16,39 @@ const SCALE = 1000; const EPSILON = 0.01; const MAX_BOARDS = 2000; +export type FlooringOptions = Record<'cuts' | 'random', FlooringPlan>; + +/** Compare both searches before presenting their actual cuts and waste to the user. */ +export function planFlooringOptions( + rooms: Room[], + s: FlooringSettings, + tolerance: number, +): FlooringOptions { + const shapes: ShapeCache = new Map(); + 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 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; + const cutWinner = better(cutsSettings); + const randomWinner = better(randomSettings); + const cutPlan = finishFlooring(cutWinner, s); + return { + cuts: cutPlan, + random: randomWinner === cutWinner ? cutPlan : finishFlooring(randomWinner, s), + }; +} + +export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: number): FlooringPlan { + return planFlooringOptions(rooms, s, tolerance)[s.optimisation ?? 'cuts']; +} + interface Bounds { minX: number; maxX: number; @@ -31,6 +64,8 @@ interface Layout { rows: FlooringRow[]; stocks: AvailableStock[]; cuts: number; + shapingCuts: number; + ripCuts: number; reusedCount: number; patternPenalty: number; } @@ -48,6 +83,12 @@ interface RowShape { bounds: Bounds; } +interface Solution { + layout: Layout; + issues: Issue[]; +} +type ShapeCache = Map }>; + const round = (n: number) => Math.round(n * SCALE) / SCALE; const phaseOf = (n: number, length: number) => round(((n % length) + length) % length) % length; const close = (a: number, b: number) => Math.abs(a - b) <= EPSILON; @@ -228,7 +269,7 @@ function allocate( ): { stockId: string; reused: boolean; crosscut: boolean; blankLength: number } { const length = round(piece.bounds.maxX - piece.bounds.minX); let stock: AvailableStock | undefined; - for (const candidate of layout.stocks) { + for (const candidate of piece.needsLeft && piece.needsRight ? [] : layout.stocks) { if ( candidate.remaining < blankLength(length, candidate.remaining) - EPSILON || (piece.needsLeft && !candidate.left) || @@ -249,6 +290,18 @@ function allocate( }; layout.stocks.push(stock); } + const allocation = takeStock(stock, length, piece.needsRight, pieceId, s); + if (allocation.reused) layout.reusedCount++; + return allocation; +} + +function takeStock( + stock: AvailableStock, + length: number, + needsRight: boolean, + pieceId: string, + s: FlooringSettings, +) { const reused = stock.pieceIds.length > 0; const reserved = blankLength(length, stock.remaining); const crosscut = stock.remaining - length > EPSILON; @@ -260,13 +313,88 @@ function allocate( } else { stock.remaining = round(Math.max(0, stock.remaining - reserved - s.kerf)); // Take starters from the right; take finishers from the left. The remainder has one new cut end. - if (piece.needsRight) stock.right = false; + if (needsRight) stock.right = false; else stock.left = false; } - if (reused) layout.reusedCount++; return { stockId: stock.id, reused, crosscut, blankLength: reserved }; } +/** Pair boundary blanks after seeing their future demand, preserving factory ends and cut order. */ +function repack(layout: Layout, s: FlooringSettings): Layout { + const byId = new Map(layout.pieces.map((piece, index) => [piece.id, { piece, index }])); + // Neutral pieces can occupy a board's middle. Keep these more general cutting patterns intact. + const fixed = layout.stocks.filter((stock) => + stock.pieceIds.some((id) => { + const p = byId.get(id)!.piece; + return !p.factoryLeft && !p.factoryRight; + }), + ); + const fixedIds = new Set(fixed.flatMap((stock) => stock.pieceIds)); + const movable = layout.pieces.filter((p) => !fixedIds.has(p.id)); + const left = movable.filter((p) => p.factoryLeft && !p.factoryRight); + const right = movable.filter((p) => p.factoryRight && !p.factoryLeft); + const paired = new Set(); + const groups: string[][] = fixed.map((stock) => [...stock.pieceIds]); + const pairCuts = (a: FlooringPiece, b: FlooringPiece) => { + if (byId.get(a.id)!.index > byId.get(b.id)!.index) [a, b] = [b, a]; + const remaining = round(Math.max(0, s.length - blankLength(a.length, s.length) - s.kerf)); + if (blankLength(b.length, remaining) > remaining + EPSILON) return Infinity; + return Number(!close(a.length, s.length)) + Number(!close(b.length, remaining)); + }; + const pair = (a: FlooringPiece, b: FlooringPiece) => { + paired.add(a.id); + paired.add(b.id); + groups.push([a.id, b.id]); + }; + // An exact complementary tail eliminates a saw pass. Reserve those matches first. + for (const a of left) { + const b = right.find((b) => !paired.has(b.id) && pairCuts(a, b) === 1); + if (b) pair(a, b); + } + // With saw passes fixed, fit large finishers with small starters to reduce purchased stock. + right.sort((a, b) => a.length - b.length); + for (const a of left.sort((a, b) => b.length - a.length)) { + if (paired.has(a.id)) continue; + const b = right.find((b) => !paired.has(b.id) && Number.isFinite(pairCuts(a, b))); + if (b) pair(a, b); + } + for (const p of movable) if (!paired.has(p.id)) groups.push([p.id]); + const index = (id: string) => byId.get(id)!.index; + groups.forEach((ids) => ids.sort((a, b) => index(a) - index(b))); + groups.sort((a, b) => index(a[0]) - index(b[0])); + const result: Layout = { + ...layout, + pieces: [...layout.pieces], + stocks: [], + cuts: layout.shapingCuts + layout.ripCuts, + reusedCount: 0, + }; + for (const ids of groups) { + const stock: AvailableStock = { + id: `B${result.stocks.length + 1}`, + pieceIds: [], + remaining: s.length, + left: true, + right: true, + }; + result.stocks.push(stock); + for (const id of ids) { + const { piece, index } = byId.get(id)!; + const allocation = takeStock(stock, piece.length, !!piece.factoryRight, id, s); + result.cuts += Number(allocation.crosscut); + result.reusedCount += Number(allocation.reused); + result.pieces[index] = { + ...piece, + stockId: stock.id, + reused: allocation.reused, + blankLength: allocation.blankLength, + cut: allocation.crosscut || piece.shaped || !!piece.ripEdge, + }; + } + } + return compareLayouts(result, layout, s) < 0 ? result : layout; +} + function appendRow( base: Layout, cuts: CutPiece[], @@ -280,9 +408,13 @@ function appendRow( pieces: [...base.pieces], rows: [...base.rows], cuts: base.cuts, + shapingCuts: base.shapingCuts, + ripCuts: base.ripCuts, reusedCount: base.reusedCount, patternPenalty: base.patternPenalty + randomPatternPenalty(base.rows, room.id, phase, s), - stocks: base.stocks.map((stock) => ({ ...stock, pieceIds: [...stock.pieceIds] })), + stocks: base.stocks.map((stock) => + stock.remaining > 0 ? { ...stock, pieceIds: [...stock.pieceIds] } : stock, + ), }; const pieceIds: string[] = []; cuts.forEach((piece, column) => { @@ -309,6 +441,8 @@ function appendRow( const ripEdge = ripTop && ripBottom ? 'both' : ripTop ? 'top' : ripBottom ? 'bottom' : null; const passes = Number(allocation.crosscut) + Number(ripTop) + Number(ripBottom) + shapeCuts; layout.cuts += passes; + layout.shapingCuts += shapeCuts; + layout.ripCuts += Number(ripTop) + Number(ripBottom); layout.pieces.push({ id, roomId: room.id, @@ -388,6 +522,104 @@ function randomPatternPenalty( return penalty; } +/** Reorder interchangeable full-width rectangular rows without adding any cutting operations. */ +function improveRowOrder(layout: Layout, rooms: Room[], s: FlooringSettings): Layout { + if (s.pattern !== 'random' || layout.patternPenalty === 0) return layout; + const rows = [...layout.rows]; + const byId = new Map(layout.pieces.map((piece) => [piece.id, piece])); + const moves = new Map(); + let improvement = 0; + for (const room of rooms) { + const positions = rows.flatMap((row, index) => (row.roomId === room.id ? [index] : [])); + const original = positions.map((index) => rows[index]); + const order = [...original]; + const shapes = new Map( + original.map((row) => { + const pieces = row.pieceIds.map((id) => byId.get(id)!); + const b = bounds( + pieces.flatMap((p) => p.polygon.map((point) => boardPoint(point, room.direction))), + ); + const rectangular = + pieces.every((p) => !p.shaped && !p.ripEdge && close(p.width, s.width)) && + close( + pieces.reduce((sum, p) => sum + p.length, 0), + b.maxX - b.minX, + ); + return [row, { y: b.minY, key: rectangular ? `${round(b.minX)}:${round(b.maxX)}` : '' }]; + }), + ); + const scoreAt = (index: number) => + randomPatternPenalty( + order.slice(Math.max(0, index - 4), index), + room.id, + order[index].offset, + s, + ); + let attempts = 0; + for (let pass = 0; pass < 4; pass++) { + let best: { i: number; j: number; delta: number } | undefined; + for (let i = 0; i < order.length; i++) { + const key = shapes.get(order[i])!.key; + if (!key) continue; + for (let j = i + 1; j < order.length; j++) { + if (++attempts > 4096) break; + if (shapes.get(order[j])!.key !== key || close(order[i].offset, order[j].offset)) + continue; + const affected = [ + ...new Set( + [i, j].flatMap((index) => + Array.from({ length: Math.min(5, order.length - index) }, (_, n) => index + n), + ), + ), + ]; + const before = affected.reduce((sum, index) => sum + scoreAt(index), 0); + [order[i], order[j]] = [order[j], order[i]]; + const valid = [i, i + 1, j, j + 1].every( + (index) => + index === 0 || + index >= order.length || + staggerAllowed(order[index - 1].offset, order[index].offset, s), + ); + const delta = valid + ? affected.reduce((sum, index) => sum + scoreAt(index), 0) - before + : 0; + [order[i], order[j]] = [order[j], order[i]]; + if (delta < (best?.delta ?? 0)) best = { i, j, delta }; + } + } + if (!best) break; + [order[best.i], order[best.j]] = [order[best.j], order[best.i]]; + improvement -= best.delta; + } + order.forEach((source, index) => { + const target = original[index]; + if (source === target) return; + rows[positions[index]] = { ...source, index: target.index }; + const shift = worldPoint( + { x: 0, y: shapes.get(target)!.y - shapes.get(source)!.y }, + room.direction, + ); + source.pieceIds.forEach((id) => moves.set(id, { row: target.index, shift })); + }); + } + if (!improvement) return layout; + return { + ...layout, + rows, + patternPenalty: layout.patternPenalty - improvement, + pieces: layout.pieces.map((piece) => { + const move = moves.get(piece.id); + return move + ? { + ...piece, + row: move.row, + polygon: piece.polygon.map((p) => ({ x: p.x + move.shift.x, y: p.y + move.shift.y })), + } + : piece; + }), + }; +} + /** Small fixed hash, never time or Math.random: edits and shared URLs reproduce the same result. */ function fraction(seed: string): number { let hash = 2166136261; @@ -452,11 +684,16 @@ function candidatePhases( return candidates.slice(0, 28); } -function compareLayouts(a: Layout, b: Layout): number { +function compareLayouts(a: Layout, b: Layout, s: FlooringSettings): number { return ( - a.stocks.length - b.stocks.length || - a.patternPenalty - b.patternPenalty || + (s.pattern === 'random' && s.optimisation === 'random' + ? a.patternPenalty - b.patternPenalty + : 0) || a.cuts - b.cuts || + a.shapingCuts - b.shapingCuts || + a.ripCuts - b.ripCuts || + a.patternPenalty - b.patternPenalty || + a.stocks.length - b.stocks.length || b.reusedCount - a.reusedCount || compareText( a.rows.map((r) => r.offset.toFixed(3)).join(','), @@ -495,15 +732,23 @@ function validateSettings(s: FlooringSettings): string | null { * Only full-width end remnants are reused; ripped strips are conservatively waste. * This is a practical cutting plan, not a claim of a global packing optimum. */ -export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: number): FlooringPlan { +function solveFlooring( + rooms: Room[], + s: FlooringSettings, + tolerance: number, + shapes: ShapeCache, +): Solution { let layout: Layout = { pieces: [], rows: [], stocks: [], cuts: 0, + shapingCuts: 0, + ripCuts: 0, reusedCount: 0, patternPenalty: 0, }; + let finalists = [layout]; const issues: Issue[] = []; const invalid = validateSettings(s); if (invalid) issues.push({ severity: 'error', message: invalid }); @@ -553,14 +798,22 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb let y = roomBounds.minY; for (let row = 0; row < widths.length && beam.length; row++) { const end = row === widths.length - 1 ? roomBounds.maxY : round(y + widths[row]); - const rowPaths = intersect(paths, { ...roomBounds, minY: y, maxY: end }); - if (!rowPaths.length) { - y = end; - continue; + const key = `${room.id}:${widths[0]}:${row}`; + let cached = shapes.get(key); + if (!cached) { + const rowPaths = intersect(paths, { ...roomBounds, minY: y, maxY: end }); + if (!rowPaths.length) { + y = end; + continue; + } + cached = { + shape: { paths: rowPaths, start: y, end, bounds: bounds(rowPaths.flat()) }, + cuts: new Map(), + }; + shapes.set(key, cached); } - const shape: RowShape = { paths: rowPaths, start: y, end, bounds: bounds(rowPaths.flat()) }; + const { shape, cuts: cache } = cached; const alternatives: Layout[] = []; - const cache = new Map(); for (const base of beam) { const roomRows = base.rows.filter((r) => r.roomId === room.id); const previousPhase = roomRows.at(-1)?.offset; @@ -586,26 +839,32 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb if (candidate.stocks.length <= MAX_BOARDS) alternatives.push(candidate); } } - alternatives.sort(compareLayouts); - // Keep distinct phases/inventories; identical routes do not earn extra beam slots. - beam = alternatives - .filter( - (candidate, index) => - index === 0 || - candidate.rows.at(-1)?.offset !== alternatives[index - 1].rows.at(-1)?.offset || - candidate.stocks - .map((stock) => `${stock.remaining}:${Number(stock.left)}${Number(stock.right)}`) - .join(',') !== - alternatives[index - 1].stocks - .map((stock) => `${stock.remaining}:${Number(stock.left)}${Number(stock.right)}`) - .join(','), - ) - .slice(0, beamWidth); + alternatives.sort((a, b) => compareLayouts(a, b, s)); + // Histories with different recent joints can have different future pattern costs. + const seen = new Set(); + beam = []; + for (const candidate of alternatives) { + const phases = candidate.rows + .filter((r) => r.roomId === room.id) + .slice(s.pattern === 'random' ? -4 : -1) + .map((r) => r.offset) + .join(','); + const inventory = candidate.stocks + .filter((stock) => stock.remaining >= s.minLength - EPSILON) + .map((stock) => `${stock.remaining}:${Number(stock.left)}${Number(stock.right)}`) + .sort() + .join(','); + const key = `${phases}|${inventory}`; + if (seen.has(key)) continue; + seen.add(key); + beam.push(candidate); + if (beam.length === beamWidth) break; + } y = end; } if (beam.length) complete.push(...beam); } - complete.sort(compareLayouts); + complete.sort((a, b) => compareLayouts(a, b, s)); if (!complete.length) { issues.push({ severity: 'error', @@ -615,6 +874,13 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb continue; } layout = complete[0]; + finalists = complete; + } + for (const candidate of finalists) { + const improved = improveRowOrder(repack(candidate, s), rooms, s); + if (compareLayouts(improved, layout, s) < 0) layout = improved; + } + for (const room of rooms) { if (layout.pieces.some((p) => p.roomId === room.id && p.shaped)) issues.push({ severity: 'warning', @@ -622,6 +888,10 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb message: `${room.name}: angled/notched cuts use the supplied shape templates. Minimum sizes check each piece's overall length and width; inspect narrow tips and joints against your flooring instructions.`, }); } + return { layout, issues }; +} + +function finishFlooring({ layout, issues }: Solution, s: FlooringSettings): FlooringPlan { const coveredArea = layout.pieces.reduce((sum, piece) => sum + area(piece.polygon), 0); const purchasedArea = layout.stocks.length ? layout.stocks.length * s.length * s.width : 0; const wasteArea = Math.max(0, purchasedArea - coveredArea); diff --git a/src/lib/skirting.ts b/src/lib/skirting.ts index 0d4e80b..9e09fa9 100644 --- a/src/lib/skirting.ts +++ b/src/lib/skirting.ts @@ -350,7 +350,12 @@ interface Packed { } /** Best-fit decreasing across all rooms, using one purchased board length. */ -function pack(source: SkirtingPiece[], stockLength: number, kerf: number): Packed { +function pack( + source: SkirtingPiece[], + stockLength: number, + kerf: number, + exactTails = false, +): Packed { const pieces = source.map((piece) => ({ ...piece })); let pending = [...pieces].sort( (a, b) => b.length - a.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), @@ -360,6 +365,25 @@ function pack(source: SkirtingPiece[], stockLength: number, kerf: number): Packe while (pending.length) { let available = stockLength; const assigned: SkirtingPiece[] = []; + if (exactTails && pending.length > 2) { + const head = pending[0]; + const tail = stockLength - consumedLength(head, stockLength, kerf); + const options = pending.slice(1, 129).filter((piece) => fits(piece, tail)); + for (const second of options) { + const remaining = tail - consumedLength(second, tail, kerf); + const third = options.find( + (piece) => piece !== second && Math.abs(piece.length - remaining) <= EPS, + ); + if (!third) continue; + pending = [ + head, + second, + third, + ...pending.filter((piece) => piece !== head && piece !== second && piece !== third), + ]; + break; + } + } for (const piece of pending) { if (!fits(piece, available)) continue; piece.blankLength = blankLength(piece, available); @@ -525,6 +549,7 @@ export function planSkirting( piece.id = `${prefixes.get(piece.roomId)}${count}`; } candidates.push(pack(source, stockLength, settings.kerf)); + candidates.push(pack(source, stockLength, settings.kerf, true)); }; // Compare full-stock, balanced, and offcut-led wall joints. { @@ -540,8 +565,8 @@ export function planSkirting( } candidates.sort( (a, b) => - (Math.abs(a.waste - b.waste) > EPS ? a.waste - b.waste : 0) || a.cuts - b.cuts || + (Math.abs(a.waste - b.waste) > EPS ? a.waste - b.waste : 0) || a.stocks.length - b.stocks.length || a.pieces.length - b.pieces.length, ); diff --git a/src/lib/types.ts b/src/lib/types.ts index 294c24b..69b7116 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -25,6 +25,8 @@ export interface FlooringSettings { width: number; gap: number; pattern: 'brick' | 'random'; + /** Random flooring can prioritise fewer saw passes or less visual repetition. */ + optimisation?: 'cuts' | 'random'; minOffset: number; maxOffset: number; minLength: number; diff --git a/src/lib/url-state.ts b/src/lib/url-state.ts index f53ca57..878b5eb 100644 --- a/src/lib/url-state.ts +++ b/src/lib/url-state.ts @@ -147,6 +147,11 @@ 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', + ), minOffset: number(data.minOffset, 'Minimum offset'), maxOffset: number(data.maxOffset, 'Maximum offset'), minLength: number(data.minLength, 'Minimum board length'), @@ -198,6 +203,8 @@ function parseState(value: unknown): UrlState { 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, @@ -206,7 +213,7 @@ function parseState(value: unknown): UrlState { flooring: flooring(project.flooring), skirting: skirt.settings, tolerance: number(project.tolerance, 'Measurement tolerance'), - completedBoards: skirt.legacyCalculations ? [] : completed, + completedBoards: skirt.legacyCalculations || previousOptimiser ? [] : completed, }, view: { roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id, @@ -362,9 +369,11 @@ function parseQuery(query: string): UrlState { flooring: Object.fromEntries( Object.entries(floor).map(([key, value]) => [ key, - typeof value === 'number' - ? numeric(`flooring.${key}`, value) - : text(`flooring.${key}`, value), + key === 'optimisation' && !params.has('flooring.optimisation') + ? undefined + : typeof value === 'number' + ? numeric(`flooring.${key}`, value) + : text(`flooring.${key}`, value), ]), ), skirting: { diff --git a/src/lib/use-project.ts b/src/lib/use-project.ts index 9ba08fc..0210d83 100644 --- a/src/lib/use-project.ts +++ b/src/lib/use-project.ts @@ -2,10 +2,33 @@ import { useCallback, useEffect, useState } from 'react'; import type { UrlState } from './types'; import { decodeState, encodeState } from './url-state'; -const readLocation = () => decodeState(window.location.search || window.location.hash); +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); + try { + const saved = decodeState(window.localStorage.getItem(PROJECT_STORAGE_KEY) ?? ''); + if (!saved.error) return saved; + } catch { + // Browser privacy settings can make localStorage unavailable. + } + return decodeState(''); +}; + +const saveLocal = (query: string) => { + try { + window.localStorage.setItem(PROJECT_STORAGE_KEY, query); + } catch { + // Keep URL persistence working if storage is blocked or full. + } +}; + const queryUrl = (query: string) => `${window.location.pathname}${query}`; -/** Readable query parameters are the durable source of truth. */ +/** Persist every parameter in the URL and locally; explicit links take precedence. */ export function useProject() { const [loaded, setLoaded] = useState(readLocation); useEffect(() => { @@ -18,6 +41,7 @@ export function useProject() { const query = encodeState(next.state); if (query !== window.location.search || window.location.hash) window.history.replaceState(null, '', queryUrl(query)); + saveLocal(query); } catch (error) { next.error = error instanceof Error ? error.message : 'This plan could not be saved.'; } @@ -39,6 +63,7 @@ export function useProject() { const query = encodeState(next); if (query !== window.location.search || window.location.hash) window.history[milestone ? 'pushState' : 'replaceState'](null, '', queryUrl(query)); + saveLocal(query); const canonical = decodeState(query).state; if (next.project === current.state.project) canonical.project = current.state.project; else { diff --git a/src/main.tsx b/src/main.tsx index cb12cd2..275182d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,4 +1,8 @@ import { createRoot } from 'react-dom/client'; -import App from './App'; +import Site from './Site'; +import { initAnalytics } from './lib/analytics'; import './styles.css'; -createRoot(document.getElementById('root')!).render(); + +initAnalytics(); + +createRoot(document.getElementById('root')!).render(); diff --git a/src/styles.css b/src/styles.css index f377e94..653e7aa 100644 --- a/src/styles.css +++ b/src/styles.css @@ -39,6 +39,7 @@ button:disabled { color: #78818f; background: #f1f1ed; } +a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, @@ -112,6 +113,10 @@ small { align-items: center; gap: 13px; } +.app-title-link { + color: inherit; + text-decoration: none; +} .square-mark { width: 28px; height: 28px; @@ -557,6 +562,29 @@ summary span { color: white; font-weight: 600; } +.optimisation-options { + display: grid; + gap: 6px; +} +.optimisation-options button { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 4px 12px; + padding: 10px; + text-align: left; + font-size: 13px; +} +.optimisation-options button.active { + border-color: var(--blue); + background: var(--blue); + color: white; +} +.optimisation-options button small { + color: inherit; + font-variant-numeric: tabular-nums; +} .drawing-card { border: 1px solid var(--ink); background: white; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tests/flooring.test.ts b/tests/flooring.test.ts index 8f7c840..3abe331 100644 --- a/tests/flooring.test.ts +++ b/tests/flooring.test.ts @@ -223,7 +223,12 @@ describe('laminate planning', () => { ] as const)( 'breaks random staircases and near-brick repeats in a common %s', (_, r, boardBudget, cutBudget) => { - const s = { ...settings, width: 190, pattern: 'random' as const }; + const s = { + ...settings, + width: 190, + pattern: 'random' as const, + optimisation: 'random' as const, + }; const plan = planFlooring([r], s, 1); expect(errors(plan)).toEqual([]); expect(plan).toEqual(planFlooring([r], s, 1)); @@ -296,8 +301,9 @@ describe('laminate planning', () => { }); it('plans each room independently and can reuse compatible end offcuts across directions', () => { - const a = room(1820, 1220, '1'); - const b = room(1220, 1820, '2'); + const a = room(620, 220, '1'); + // Its ends complement the first room's remnants after the 3 mm saw kerf. + const b = room(220, 617, '2'); b.direction = 90; const combined = planFlooring([a, b], settings, 1); const separately = diff --git a/tests/optimisation.test.ts b/tests/optimisation.test.ts new file mode 100644 index 0000000..ee40d2c --- /dev/null +++ b/tests/optimisation.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import { plannerCases } from '../benchmarks/cases'; +import { + compareFlooring, + compareSkirting, + flooringMetrics, + skirtingMetrics, +} from '../benchmarks/metrics'; +import baseline from '../benchmarks/baseline.json'; +import { planFlooringOptions } from '../src/lib/flooring'; +import { planSkirting } from '../src/lib/skirting'; +import { buildGeometry, insetPolygon, signedArea } from '../src/lib/geometry'; +import type { FlooringPlan, Project } from '../src/lib/types'; + +function validateFlooring(plan: FlooringPlan, project: Project) { + const s = project.flooring; + expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]); + const byId = new Map(plan.pieces.map((piece) => [piece.id, piece])); + expect(byId.size).toBe(plan.pieces.length); + const ids = [...byId.keys()].sort(); + expect(plan.stocks.flatMap((stock) => stock.pieceIds).sort()).toEqual(ids); + expect(plan.rows.flatMap((row) => row.pieceIds).sort()).toEqual(ids); + let crosscuts = 0; + let reused = 0; + for (const stock of plan.stocks) { + let remaining = s.length; + let left = true; + let right = true; + 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.factoryLeft || left).toBe(true); + expect(!piece.factoryRight || right).toBe(true); + expect(piece.reused).toBe(index > 0); + reused += Number(piece.reused); + const cut = remaining - piece.length > 0.01; + 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; + if (piece.factoryRight) right = false; + else left = false; + } else { + remaining = 0; + left = right = false; + } + }); + expect(stock.remaining).toBeCloseTo(remaining, 3); + } + const metrics = flooringMetrics(plan, s); + expect(plan.cuts).toBe(crosscuts + metrics.shapingCuts + metrics.ripCuts); + expect(plan.reusedCount).toBe(reused); + for (const room of project.rooms) { + const pieces = plan.pieces.filter((piece) => piece.roomId === room.id); + const paths = insetPolygon(buildGeometry(room, project.tolerance).vertices, s.gap); + const expected = paths.reduce((sum, path) => sum + Math.abs(signedArea(path)), 0); + const actual = pieces.reduce((sum, piece) => sum + Math.abs(signedArea(piece.polygon)), 0); + // Integer clipping rounds intersections to 0.001 mm; bound area drift by boundary length. + const perimeter = paths.reduce( + (sum, path) => + sum + + path.reduce((length, p, i) => { + const q = path[(i + 1) % path.length]; + return length + Math.hypot(p.x - q.x, p.y - q.y); + }, 0), + 0, + ); + expect(Math.abs(actual - expected)).toBeLessThan(Math.max(1, perimeter * 0.001)); + const rows = plan.rows.filter((row) => row.roomId === room.id); + let previousEnd = -Infinity; + rows.forEach((row, index) => { + const ys = row.pieceIds.flatMap((id) => + byId.get(id)!.polygon.map((p) => (room.direction === 0 ? p.y : -p.x)), + ); + expect(Math.min(...ys)).toBeGreaterThanOrEqual(previousEnd - 0.01); + previousEnd = Math.max(...ys); + for (const id of row.pieceIds) { + const piece = byId.get(id)!; + expect(piece.row).toBe(row.index); + expect(piece.roomId).toBe(room.id); + expect(piece.length).toBeGreaterThanOrEqual(s.minLength - 0.01); + expect(piece.width).toBeGreaterThanOrEqual(s.minWidth - 0.01); + } + if (!index) return; + const delta = Math.abs(row.offset - rows[index - 1].offset); + const offset = Math.min(delta, s.length - delta); + expect(offset).toBeGreaterThanOrEqual(s.minOffset - 0.01); + expect(offset).toBeLessThanOrEqual(s.maxOffset + 0.01); + }); + } +} + +describe('measured optimiser quality', () => { + const cases = plannerCases(); + const results = new Map< + string, + { options: ReturnType; skirt: ReturnType } + >(); + const resultFor = ({ name, project }: (typeof cases)[number]) => { + let result = results.get(name); + if (!result) { + result = { + options: planFlooringOptions(project.rooms, project.flooring, project.tolerance), + skirt: skirtingMetrics(planSkirting(project.rooms, project.skirting, project.tolerance)), + }; + results.set(name, result); + } + return result; + }; + it.each(cases)( + '$name preserves coverage, stock, factory ends and priority', + ({ name, project }) => { + const input = JSON.stringify(project); + const { options, skirt } = resultFor({ name, project }); + const cuts = flooringMetrics(options.cuts, project.flooring); + const random = flooringMetrics(options.random, project.flooring); + const before = baseline.find((row) => row.name === name)!; + expect(compareFlooring(cuts, before.flooring)).toBeLessThanOrEqual(0); + expect(compareFlooring(cuts, random)).toBeLessThanOrEqual(0); + expect(compareFlooring(random, cuts, true)).toBeLessThanOrEqual(0); + validateFlooring(options.cuts, project); + if (options.random !== options.cuts) validateFlooring(options.random, project); + expect(compareSkirting(skirt, before.skirting)).toBeLessThanOrEqual(0); + expect(skirt.errors).toBe(0); + expect(JSON.stringify(project)).toBe(input); + }, + ); + + it('retains the measured improvements across the complete fixed corpus', () => { + const totals = { cuts: 0, randomCuts: 0, pattern: 0, skirtingCuts: 0 }; + for (const c of cases) { + const { options, skirt } = resultFor(c); + totals.cuts += options.cuts.cuts; + totals.randomCuts += options.random.cuts; + 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.skirtingCuts).toBeLessThanOrEqual(1436); // Original: 1447. + }); +}); diff --git a/tests/skirting.test.ts b/tests/skirting.test.ts index 5c99f13..1470fc5 100644 --- a/tests/skirting.test.ts +++ b/tests/skirting.test.ts @@ -221,13 +221,13 @@ describe('skirting cutting plan', () => { expect(failures.every((issue) => issue.roomId === invalid.id)).toBe(true); }); - it('lets reused tails set wall joints while counting preparation and coping operations', () => { + it('prefers one fewer saw pass over saving a stock board on a square room', () => { const room = rectangle(4000, 4000); const plan = planSkirting([room], settings, 5); expect(plan).toEqual(planSkirting([room], settings, 5)); - expect(plan.stockCount).toBe(7); // Fixed joints used eight lengths for this simple square. - expect(plan.cuts).toBe(13); - expect(plan.waste).toBeCloseTo(7 * 2400 - 16000); + expect(plan.stockCount).toBe(8); // Seven boards require thirteen preparation/coping passes. + expect(plan.cuts).toBe(12); + expect(plan.waste).toBeCloseTo(8 * 2400 - 16000); for (let wallIndex = 0; wallIndex < 4; wallIndex++) { const pieces = plan.pieces.filter((piece) => piece.wallIndex === wallIndex); expect(pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(4000); diff --git a/tests/url-state.test.ts b/tests/url-state.test.ts index 6e24c60..be2db58 100644 --- a/tests/url-state.test.ts +++ b/tests/url-state.test.ts @@ -393,7 +393,9 @@ describe('URL state', () => { }; expect(decodeState(rawLink(legacy))).toEqual({ state, error: null }); expect( - decodeState('?skirting.length=4800&skirting.stock=3600&skirting.stock=2400&done=skirting:S6'), + decodeState( + '?flooring.optimisation=cuts&skirting.length=4800&skirting.stock=3600&skirting.stock=2400&done=skirting:S6', + ), ).toEqual({ state, error: null }); }); @@ -523,7 +525,9 @@ describe('URL state', () => { const state = defaultState(); state.project.completedBoards = ['floor:B4', 'floor:B4']; expect(() => encodeState(state)).toThrow(/duplicate entries/); - const distinct = decodeState('?skirting.length=2400&done=floor:B1&done=skirting:S1'); + 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']); }); @@ -573,6 +577,51 @@ describe('URL state', () => { 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(