..
This commit is contained in:
parent
9689bfcc18
commit
62a385e4c9
33 changed files with 2649 additions and 809 deletions
42
README.md
42
README.md
|
|
@ -10,7 +10,7 @@ npm run dev
|
||||||
Use a supported Node.js release (22.12+ or 24+). Open the local URL printed by Vite.
|
Use a supported Node.js release (22.12+ or 24+). Open the local URL printed by Vite.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm test # Pure geometry, planning, URL and CSV tests
|
npm test # Pure geometry, planning, URL and rendering tests
|
||||||
npx playwright install chromium
|
npx playwright install chromium
|
||||||
npm run test:e2e # Desktop Chromium and mobile-sized Chromium
|
npm run test:e2e # Desktop Chromium and mobile-sized Chromium
|
||||||
npm run build # Strict TypeScript check and production build
|
npm run build # Strict TypeScript check and production build
|
||||||
|
|
@ -19,18 +19,19 @@ npm run check # Build, unit tests and browser tests
|
||||||
|
|
||||||
## Planning workflow
|
## Planning workflow
|
||||||
|
|
||||||
1. Start with a rectangle, fireplace recess, or custom outline. Enter wall lengths in order. The first wall runs east; the turn **after** each wall is positive for right and negative for left. For example, a clockwise rectangle has 90° turns. The final corner is derived from the closed outline.
|
1. Start with a rectangle, fireplace recess, or custom outline. Enter wall lengths in order. The first wall runs east; the turn **after** each wall is positive for right and negative for left. For example, a clockwise rectangle has 90° turns. The final corner is derived from the closed outline. Insert a wall after any existing wall, drag its handle to reorder it, or use the up/down buttons on a phone or keyboard. Doors stay attached to their wall. Numeric fields allow empty values and a minus sign while typing; unfinished entries revert to the last valid value when you leave the field.
|
||||||
2. Add door openings with a width and a distance from the named starting corner. Each doorway shows the neighbouring wall names and an arrow in the measurement direction. Include architrave/trim in the width where skirting must be omitted. Doors terminate skirting; laminate ends at the room outline with a transition and expansion gap. Rooms are planned independently.
|
2. Add door openings with a width and a distance from the named starting corner. Each doorway shows the neighbouring wall names and an arrow in the measurement direction. Include architrave/trim in the width where skirting must be omitted. Doors terminate skirting; laminate ends at the room outline with a transition and expansion gap. Rooms are planned independently.
|
||||||
3. For a custom outline, the closing-wall suggestion gives the required length and turn. Add it, then replace the suggested length with the actual measured length. An outline must close within the chosen tolerance and have no intersections or invalid doors before a cut plan can be produced.
|
3. For a custom outline, the closing-wall suggestion gives the required length and turn. Add it, then replace the suggested length with the actual measured length. An outline must close within the chosen tolerance and have no intersections or invalid doors before a cut plan can be produced.
|
||||||
4. Set material dimensions, spacer, kerf and minimum piece sizes alongside the measurements. Kerf means material removed by the blade: a 3 mm cut leaves 600 mm + 397 mm from a 1,000 mm board. Both kerf fields have keyboard and touch tooltips. Skirting stock lengths are purchase options, not a fixed inventory. Materials are shared across rooms; direction is per room.
|
4. Set material dimensions, spacer, kerf and minimum piece sizes alongside the measurements. Kerf means material removed by the blade: a 3 mm cut leaves 600 mm + 397 mm from a 1,000 mm board. Both kerf fields have keyboard and touch tooltips. Skirting stock lengths are purchase options, not a fixed inventory. Materials are shared across rooms; direction is per room.
|
||||||
5. The drawing and cut plan update on the same page. Flooring and skirting are visible together, with the whole drawing fitting the page without an internal scrollbar. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Flooring and skirting cuts are grouped by the purchased board they come from, including offcuts reused across rooms. 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. Export CSV in the same board order, or print all room drawings followed by flooring and skirting cut sheets grouped by purchased board. Every cut is included once, with its room and row or wall.
|
5. Room measurements, materials and cut plans open and close independently on the same page. The drawing stays pinned on the right while you scroll the panels; on phones a compact drawing stays above them. 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. 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. Copy the link to save or share the complete project. Measurements, materials, selected room and selected piece live in readable query parameters such as `flooring.length=1200`, `room.1.wall.1.length=4800` and `selectedCut=F1`. 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.
|
6. Find a board or cut by its exact code, or search by room name. **Show on plan** highlights the source board’s pieces or an individual cut, with room buttons when a source is reused across rooms. Source boards have **Mark done** checkboxes; completion marks appear on their list entries, installed pieces and printed sheets. A full uncut board can also be found and marked by its source code. 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`, `room.1.wall.1.length=4800` and `selectedCut=F1`. Progress uses repeated `done=floor:B4` or `done=skirting:S6` parameters; `search=F1` and repeated `collapsed=materials` parameters preserve the search and closed panels. You can edit values directly in the URL. 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.
|
||||||
|
|
||||||
## Calculation rules and practical limits
|
## Calculation rules and practical limits
|
||||||
|
|
||||||
- Geometry follows measured vectors, checks final-wall length/direction, detects crossings and validates opening bounds and overlaps. A small accepted closure residual snaps only the final wall to the origin, with a visible warning. A mismatch can indicate measurement error or an irregular wall; lengths and corner angles alone cannot identify or reconstruct a bow. Remeasure and represent known changes of direction as additional segments.
|
- Geometry follows measured vectors, checks final-wall length/direction, detects crossings and validates opening bounds and overlaps. A small accepted closure residual snaps only the final wall to the origin, with a visible warning. A mismatch can indicate measurement error or an irregular wall; lengths and corner angles alone cannot identify or reconstruct a bow. Remeasure and represent known changes of direction as additional segments.
|
||||||
- Polygon offsets and clipping use `clipper-lib` at 0.001 mm internal precision. List dimensions are rounded to 0.1 mm; cutting diagrams retain up to 0.001 mm. The supplied cut templates contain board-relative polygon vertices and edge lengths for angled and notched pieces.
|
- Polygon offsets and clipping use `clipper-lib` at 0.001 mm internal precision. Displayed cut dimensions round up to the next 0.5 mm; angles display to 0.1°. New cutting blanks reserve that rounded length plus kerf so the displayed cuts fit their source board. Finished polygon coordinates retain their original precision. The supplied cut templates contain board-relative polygon vertices and edge lengths for angled and notched pieces.
|
||||||
- Laminate is inset from every wall, including fireplace recesses. Interior rows retain full factory width. When the final row would be narrower than the minimum, the first and last rows share the width equally. A bounded search also compares other feasible edge-row arrangements.
|
- Laminate is inset from every wall, including fireplace recesses. Interior rows retain full factory width. When the final row would be narrower than the minimum, the first and last rows share the width equally. A bounded search also compares other feasible edge-row arrangements, including row seams aligned with inward corners to avoid unnecessary notches and tapered tips.
|
||||||
- Brick uses a half-board stagger. Random is deterministic and discourages staircase runs, near-equal successive steps and repeating joint alignments, including steps that wrap across a board length. Pattern preferences never invalidate a physically valid plan when tight offset limits force repetition. The offset is the nearest end-joint distance on the adjacent row's grid, not a distance from a wall. Half a board is the largest possible nearest-joint offset. Settings that cannot be met produce an error instead of a partial layout.
|
- Brick uses a half-board stagger. Random is deterministic and discourages staircase runs, near-equal successive steps and repeating joint alignments, including steps that wrap across a board length. Pattern preferences never invalidate a physically valid plan when tight offset limits force repetition. The offset is the nearest end-joint distance on the adjacent row's grid, not a distance from a wall. Half a board is the largest possible nearest-joint offset. Settings that cannot be met produce an error instead of a partial layout.
|
||||||
- Minimum laminate sizes apply to each clipped piece's bounding length/width. Irregular shapes can still have narrow tips or necks; their exact templates are available in the cut popups. Product-specific click systems and assembly sequences are not simulated.
|
- Minimum laminate sizes apply to each clipped piece's bounding length/width. Irregular shapes can still have narrow tips or necks; their exact templates are available in the cut popups. Product-specific click systems and assembly sequences are not simulated.
|
||||||
- Offcut reuse preserves the needed left/right factory ends. Only full-width end remnants are available for reuse; ripped side strips and shaped scraps are conservatively waste. Crosscuts include kerf. The stock count is actual allocated boards, not an area-only estimate. Waste includes kerf and unused material, including still-usable stock remainders.
|
- Offcut reuse preserves the needed left/right factory ends. Only full-width end remnants are available for reuse; ripped side strips and shaped scraps are conservatively waste. Crosscuts include kerf. The stock count is actual allocated boards, not an area-only estimate. Waste includes kerf and unused material, including still-usable stock remainders.
|
||||||
|
|
@ -42,18 +43,19 @@ npm run check # Build, unit tests and browser tests
|
||||||
|
|
||||||
## Code map
|
## Code map
|
||||||
|
|
||||||
| File | Responsibility |
|
| File | Responsibility |
|
||||||
| ------------------------ | ---------------------------------------------------------------- |
|
| --------------------------- | ---------------------------------------------------------------- |
|
||||||
| `src/lib/types.ts` | Inputs and derived result contracts |
|
| `src/lib/types.ts` | Inputs and derived result contracts |
|
||||||
| `src/lib/defaults.ts` | Starter measurements and deterministic IDs |
|
| `src/lib/defaults.ts` | Starter measurements and deterministic IDs |
|
||||||
| `src/lib/geometry.ts` | Vector tracing, closure, validation and polygon offsets |
|
| `src/lib/geometry.ts` | Vector tracing, closure, validation and polygon offsets |
|
||||||
| `src/lib/flooring.ts` | Row search, clipping, factory-end-aware stock allocation |
|
| `src/lib/flooring.ts` | Row search, clipping, factory-end-aware stock allocation |
|
||||||
| `src/lib/skirting.ts` | Corner allowances, run splitting and stock allocation |
|
| `src/lib/skirting.ts` | Corner allowances, run splitting and stock allocation |
|
||||||
| `src/lib/url-state.ts` | Readable query schema, legacy links and input validation |
|
| `src/lib/url-state.ts` | Readable query schema, legacy links and input validation |
|
||||||
| `src/lib/use-project.ts` | URL and browser-history synchronization |
|
| `src/lib/board-tracking.ts` | Exact board/cut matching and room-name search |
|
||||||
| `src/lib/export.ts` | CSV generation, including formula-injection protection |
|
| `src/lib/wall-editing.ts` | Wall insertion and reordering with stable IDs |
|
||||||
| `src/components/` | Editors, dimensioned SVGs, board groups and printable cut sheets |
|
| `src/lib/use-project.ts` | URL and browser-history synchronization |
|
||||||
| `tests/` | Pure calculation and state tests |
|
| `src/components/` | Editors, dimensioned SVGs, board groups and printable cut sheets |
|
||||||
| `e2e/` | Actual browser workflows at desktop and mobile sizes |
|
| `tests/` | Pure calculation and state tests |
|
||||||
|
| `e2e/` | Actual browser workflows at desktop and mobile sizes |
|
||||||
|
|
||||||
Only the inputs and view selections are persisted. Geometry, plans, quantities, warning messages and drawings are derived. There are no timestamps or random-number calls in the planners. `npm run build` also checks unused variables and strict TypeScript types.
|
Only the inputs, completion marks and view selections are persisted. Incomplete numeric drafts exist only while editing a field. Geometry, plans, quantities, warning messages and drawings are derived. There are no timestamps or random-number calls in the planners. `npm run build` also checks unused variables and strict TypeScript types.
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@ import { defaultState, rectangle } from '../src/lib/defaults';
|
||||||
import { planFlooring } from '../src/lib/flooring';
|
import { planFlooring } from '../src/lib/flooring';
|
||||||
import { planSkirting } from '../src/lib/skirting';
|
import { planSkirting } from '../src/lib/skirting';
|
||||||
import { flooringCuts, skirtingCuts, uncutFlooring } from '../src/lib/cut-list';
|
import { flooringCuts, skirtingCuts, uncutFlooring } from '../src/lib/cut-list';
|
||||||
import { mm } from '../src/lib/format';
|
import { cutMm } from '../src/lib/format';
|
||||||
|
|
||||||
const errors = new WeakMap<Page, string[]>();
|
const errors = new WeakMap<Page, string[]>();
|
||||||
|
const panel = (page: Page, section: string) =>
|
||||||
|
page.locator(`.planner-panel[data-section="${section}"]`);
|
||||||
const preview = (page: Page) => page.getByRole('region', { name: 'Floor plan preview' });
|
const preview = (page: Page) => page.getByRole('region', { name: 'Floor plan preview' });
|
||||||
const floorEntries = (page: Page) => page.locator('.flooring-cuts .board-cut');
|
const floorEntries = (page: Page) => page.locator('.flooring-cuts .board-cut');
|
||||||
const skirtEntries = (page: Page) => page.locator('.skirting-cuts .board-cut');
|
const skirtEntries = (page: Page) => page.locator('.skirting-cuts .board-cut');
|
||||||
|
|
@ -30,6 +32,78 @@ const plans = (page: Page) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function sharedRoomFixture() {
|
||||||
|
const fixture = defaultState();
|
||||||
|
fixture.project.flooring.width = 200;
|
||||||
|
fixture.project.rooms = [
|
||||||
|
rectangle('room-1', 'Living room', 1820, 1220),
|
||||||
|
rectangle('room-2', 'Study', 1220, 1820),
|
||||||
|
];
|
||||||
|
fixture.project.rooms.forEach((room) =>
|
||||||
|
room.walls.forEach((wall) => {
|
||||||
|
wall.doors = [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fixture.project.rooms[1].direction = 90;
|
||||||
|
fixture.view.roomId = 'room-2';
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function highlightedIds(page: Page, material?: 'flooring' | 'skirting') {
|
||||||
|
const selector = `${material ? `[data-material="${material}"]` : '[data-piece-id]'}[data-highlighted="true"]`;
|
||||||
|
return (
|
||||||
|
await preview(page)
|
||||||
|
.locator(selector)
|
||||||
|
.evaluateAll((elements) => elements.map((element) => element.getAttribute('data-piece-id')))
|
||||||
|
).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wallDimensionsInsideSvg(page: Page, wallNumber: number) {
|
||||||
|
const wall = preview(page)
|
||||||
|
.locator('.wall-drawing')
|
||||||
|
.filter({ hasText: new RegExp(`W${wallNumber} ·`) });
|
||||||
|
await expect(wall.locator('.wall-label text')).toBeVisible();
|
||||||
|
const result = await wall.evaluate((element) => {
|
||||||
|
const svg = element.ownerSVGElement!;
|
||||||
|
const box = svg.viewBox.baseVal;
|
||||||
|
const inverse = svg.getScreenCTM()!.inverse();
|
||||||
|
const rootPoint = (element: SVGGraphicsElement, x: number, y: number) =>
|
||||||
|
new DOMPoint(x, y).matrixTransform(inverse.multiply(element.getScreenCTM()!));
|
||||||
|
const lines = [...element.querySelectorAll(':scope > line')].map(
|
||||||
|
(line) => line as SVGLineElement,
|
||||||
|
);
|
||||||
|
const endpoints = lines.flatMap((line) => [
|
||||||
|
rootPoint(line, line.x1.baseVal.value, line.y1.baseVal.value),
|
||||||
|
rootPoint(line, line.x2.baseVal.value, line.y2.baseVal.value),
|
||||||
|
]);
|
||||||
|
const label = element.querySelector('.wall-label') as SVGGElement;
|
||||||
|
const bounds = label.getBBox();
|
||||||
|
const corners = [
|
||||||
|
rootPoint(label, bounds.x, bounds.y),
|
||||||
|
rootPoint(label, bounds.x + bounds.width, bounds.y),
|
||||||
|
rootPoint(label, bounds.x + bounds.width, bounds.y + bounds.height),
|
||||||
|
rootPoint(label, bounds.x, bounds.y + bounds.height),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
lineCount: lines.length,
|
||||||
|
outside: [...endpoints, ...corners]
|
||||||
|
.filter(
|
||||||
|
(point) =>
|
||||||
|
point.x < box.x - 0.01 ||
|
||||||
|
point.x > box.x + box.width + 0.01 ||
|
||||||
|
point.y < box.y - 0.01 ||
|
||||||
|
point.y > box.y + box.height + 0.01,
|
||||||
|
)
|
||||||
|
.map((point) => ({ x: point.x, y: point.y })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(result.lineCount).toBe(4);
|
||||||
|
expect(
|
||||||
|
result.outside,
|
||||||
|
`W${wallNumber} lines, ticks and label must fit inside the SVG viewBox`,
|
||||||
|
).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
async function stockGroups(groups: Locator) {
|
async function stockGroups(groups: Locator) {
|
||||||
return groups.evaluateAll((elements) =>
|
return groups.evaluateAll((elements) =>
|
||||||
elements.map((element) => ({
|
elements.map((element) => ({
|
||||||
|
|
@ -73,6 +147,31 @@ async function measuredEdges(preview: Locator) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectHalfMillimetreCuts(dimensions: { shown: number; actual: number }[]) {
|
||||||
|
for (const { shown, actual } of dimensions) {
|
||||||
|
expect(shown, 'A displayed cut must never round down').toBeGreaterThanOrEqual(actual - 1e-7);
|
||||||
|
expect(shown, 'Round up by less than half a millimetre').toBeLessThan(actual + 0.5 + 1e-7);
|
||||||
|
expect(shown * 2).toBeCloseTo(Math.round(shown * 2), 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function noVisibleStockIds(page: Page) {
|
||||||
|
expect(await page.locator('body').innerText()).not.toMatch(
|
||||||
|
/(?:Floorboard B|Skirting(?: board)? S)\d+|(?:floor:B|skirting:S)\d+/i,
|
||||||
|
);
|
||||||
|
const labels = await page
|
||||||
|
.locator('[aria-label]')
|
||||||
|
.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => element.getAttribute('aria-label')).join(' '),
|
||||||
|
);
|
||||||
|
expect(labels).not.toMatch(
|
||||||
|
/(?:Floorboard B|Skirting(?: board)? S)\d+|(?:floor:B|skirting:S)\d+/i,
|
||||||
|
);
|
||||||
|
expect(await page.getByLabel('Find a cut or room', { exact: true }).inputValue()).not.toMatch(
|
||||||
|
/(?:floor:B|skirting:S)\d+/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function openWalls(page: Page) {
|
async function openWalls(page: Page) {
|
||||||
if (
|
if (
|
||||||
!(await page
|
!(await page
|
||||||
|
|
@ -161,6 +260,8 @@ test('measurements, materials, drawing and both cut lists share one page', async
|
||||||
await expect(page.getByRole('heading', { name: /^Skirting cuts/ })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /^Skirting cuts/ })).toBeVisible();
|
||||||
await expect(preview(page).getByText('Outline closed', { exact: true })).toBeVisible();
|
await expect(preview(page).getByText('Outline closed', { exact: true })).toBeVisible();
|
||||||
await expect(page.locator('.layer-switch, .step-nav')).toHaveCount(0);
|
await expect(page.locator('.layer-switch, .step-nav')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'Export CSV' })).toHaveCount(0);
|
||||||
|
await noVisibleStockIds(page);
|
||||||
await expect(page.locator('.app-header').getByText('mm', { exact: true })).toHaveCount(0);
|
await expect(page.locator('.app-header').getByText('mm', { exact: true })).toHaveCount(0);
|
||||||
await expect(page.getByRole('button', { name: /^Zoom (in|out)$/ })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: /^Zoom (in|out)$/ })).toHaveCount(0);
|
||||||
await noDrawingScroll(page);
|
await noDrawingScroll(page);
|
||||||
|
|
@ -207,7 +308,7 @@ test('source-board groups contain every numbered cut once and leave full boards
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.locator('.cut-size'),
|
.locator('.cut-size'),
|
||||||
).toHaveText(`${mm(piece.length)} × ${mm(piece.width)} mm`);
|
).toHaveText(`${cutMm(piece.length)} × ${cutMm(piece.width)} mm`);
|
||||||
}
|
}
|
||||||
await expect(page.locator('.material-cuts table')).toHaveCount(0);
|
await expect(page.locator('.material-cuts table')).toHaveCount(0);
|
||||||
await expect(preview(page).locator('.floor-piece')).toHaveCount(floor.pieces.length);
|
await expect(preview(page).locator('.floor-piece')).toHaveCount(floor.pieces.length);
|
||||||
|
|
@ -255,11 +356,10 @@ test('source-board groups contain every numbered cut once and leave full boards
|
||||||
await expect(shape).toBeVisible();
|
await expect(shape).toBeVisible();
|
||||||
const dimensions = await measuredEdges(shape);
|
const dimensions = await measuredEdges(shape);
|
||||||
expect(dimensions).toHaveLength(2);
|
expect(dimensions).toHaveLength(2);
|
||||||
for (const dimension of dimensions) expect(dimension.shown).toBeCloseTo(dimension.actual, 3);
|
expectHalfMillimetreCuts(dimensions);
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||||
await expect(thumbnail).toBeFocused();
|
await expect(thumbnail).toBeFocused();
|
||||||
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('inconsistent final measurements block cuts and small residuals show the expected length', async ({
|
test('inconsistent final measurements block cuts and small residuals show the expected length', async ({
|
||||||
|
|
@ -270,11 +370,9 @@ test('inconsistent final measurements block cuts and small residuals show the ex
|
||||||
await expect(page.getByText(/The outline is open by 100\.0 mm/).first()).toBeVisible();
|
await expect(page.getByText(/The outline is open by 100\.0 mm/).first()).toBeVisible();
|
||||||
await expect(floorEntries(page)).toHaveCount(0);
|
await expect(floorEntries(page)).toHaveCount(0);
|
||||||
await expect(skirtEntries(page)).toHaveCount(0);
|
await expect(skirtEntries(page)).toHaveCount(0);
|
||||||
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
|
|
||||||
await page.getByLabel('Wall 4 length', { exact: true }).fill('3605');
|
await page.getByLabel('Wall 4 length', { exact: true }).fill('3605');
|
||||||
await expect(page.getByText(/non-straight wall/)).toBeVisible();
|
await expect(page.getByText(/non-straight wall/)).toBeVisible();
|
||||||
await expect(page.getByText('Final wall: 3,600 mm expected · 5 mm difference')).toBeVisible();
|
await expect(page.getByText('Final wall: 3,600 mm expected · 5 mm difference')).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
|
|
||||||
expect(await floorEntries(page).count()).toBeGreaterThan(0);
|
expect(await floorEntries(page).count()).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -313,7 +411,6 @@ test('custom walls close using a calculated wall that can be measured again', as
|
||||||
await expect(page.getByLabel('Wall 4 length', { exact: true })).toHaveValue('3000');
|
await expect(page.getByLabel('Wall 4 length', { exact: true })).toHaveValue('3000');
|
||||||
await page.getByLabel('Wall 4 length', { exact: true }).fill('3050');
|
await page.getByLabel('Wall 4 length', { exact: true }).fill('3050');
|
||||||
await expect(page.getByText(/3,000 mm expected; length differs by 50 mm/)).toBeVisible();
|
await expect(page.getByText(/3,000 mm expected; length differs by 50 mm/)).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialogs', async ({
|
test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialogs', async ({
|
||||||
|
|
@ -343,8 +440,8 @@ test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialo
|
||||||
const lengths = await dialog.locator('.skirting-full-preview .dimension-label').allTextContents();
|
const lengths = await dialog.locator('.skirting-full-preview .dimension-label').allTextContents();
|
||||||
expect(lengths.map((text) => Number(text.replaceAll(',', '')))).toEqual(
|
expect(lengths.map((text) => Number(text.replaceAll(',', '')))).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
Number(mitred.length.toFixed(3)),
|
Number(cutMm(mitred.length).replaceAll(',', '')),
|
||||||
Number(mitred.backLength.toFixed(3)),
|
Number(cutMm(mitred.backLength).replaceAll(',', '')),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const end = Math.abs(mitred.leftMitre) === 45 ? 'left' : 'right';
|
const end = Math.abs(mitred.leftMitre) === 45 ? 'left' : 'right';
|
||||||
|
|
@ -383,7 +480,7 @@ test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialo
|
||||||
await expect(shapedPreview).toBeVisible();
|
await expect(shapedPreview).toBeVisible();
|
||||||
const dimensions = await measuredEdges(shapedPreview);
|
const dimensions = await measuredEdges(shapedPreview);
|
||||||
expect(dimensions).toHaveLength(shaped.template.length);
|
expect(dimensions).toHaveLength(shaped.template.length);
|
||||||
for (const dimension of dimensions) expect(dimension.shown).toBeCloseTo(dimension.actual, 3);
|
expectHalfMillimetreCuts(dimensions);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('dialog').locator('.template-coordinates, .vertex-label, .cut-measures'),
|
page.getByRole('dialog').locator('.template-coordinates, .vertex-label, .cut-measures'),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
|
|
@ -411,7 +508,6 @@ test('door positions and material lengths update skirting immediately', async ({
|
||||||
'aria-label',
|
'aria-label',
|
||||||
'Door on W1: 1,200 mm from W4 corner, then 900 mm opening toward W2 corner',
|
'Door on W1: 1,200 mm from W4 corner, then 900 mm opening toward W2 corner',
|
||||||
);
|
);
|
||||||
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
|
|
||||||
expect(await page.locator('.skirting-cuts').innerText()).not.toBe(before);
|
expect(await page.locator('.skirting-cuts').innerText()).not.toBe(before);
|
||||||
await page.getByLabel('Stock length 1', { exact: true }).fill('3600');
|
await page.getByLabel('Stock length 1', { exact: true }).fill('3600');
|
||||||
await page.getByLabel('Skirting thickness', { exact: true }).fill('22');
|
await page.getByLabel('Skirting thickness', { exact: true }).fill('22');
|
||||||
|
|
@ -420,11 +516,9 @@ test('door positions and material lengths update skirting immediately', async ({
|
||||||
await expect(page.locator('.skirting-cuts .cut-summary')).toContainText(
|
await expect(page.locator('.skirting-cuts .cut-summary')).toContainText(
|
||||||
`Buy ${skirt.stockCount} lengths`,
|
`Buy ${skirt.stockCount} lengths`,
|
||||||
);
|
);
|
||||||
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
|
|
||||||
await movedDoor.getByLabel('Door width', { exact: true }).fill('6000');
|
await movedDoor.getByLabel('Door width', { exact: true }).fill('6000');
|
||||||
await expect(page.getByText(/must fit within the wall/).first()).toBeVisible();
|
await expect(page.getByText(/must fit within the wall/).first()).toBeVisible();
|
||||||
await expect(skirtEntries(page)).toHaveCount(0);
|
await expect(skirtEntries(page)).toHaveCount(0);
|
||||||
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('invalid skirting settings keep flooring results usable', async ({ page }) => {
|
test('invalid skirting settings keep flooring results usable', async ({ page }) => {
|
||||||
|
|
@ -433,10 +527,8 @@ test('invalid skirting settings keep flooring results usable', async ({ page })
|
||||||
await expect(skirtEntries(page)).toHaveCount(0);
|
await expect(skirtEntries(page)).toHaveCount(0);
|
||||||
await expect(floorCodes(page)).toHaveCount(floorCount);
|
await expect(floorCodes(page)).toHaveCount(floorCount);
|
||||||
await expect(page.locator('.issue.error').first()).toBeVisible();
|
await expect(page.locator('.issue.error').first()).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
|
|
||||||
await page.getByLabel('Stock length 1', { exact: true }).fill('2400');
|
await page.getByLabel('Stock length 1', { exact: true }).fill('2400');
|
||||||
expect(await skirtEntries(page).count()).toBeGreaterThan(0);
|
expect(await skirtEntries(page).count()).toBeGreaterThan(0);
|
||||||
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeEnabled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pattern, direction and spacers persist without steps, layers or stored results', async ({
|
test('pattern, direction and spacers persist without steps, layers or stored results', async ({
|
||||||
|
|
@ -448,7 +540,12 @@ test('pattern, direction and spacers persist without steps, layers or stored res
|
||||||
await page.getByLabel('Wall spacer', { exact: true }).fill('12');
|
await page.getByLabel('Wall spacer', { exact: true }).fill('12');
|
||||||
expect(state(page).project.flooring).toMatchObject({ pattern: 'random', gap: 12 });
|
expect(state(page).project.flooring).toMatchObject({ pattern: 'random', gap: 12 });
|
||||||
expect(state(page).project.rooms[0].direction).toBe(90);
|
expect(state(page).project.rooms[0].direction).toBe(90);
|
||||||
expect(Object.keys(state(page).view).sort()).toEqual(['roomId', 'selectedPiece']);
|
expect(Object.keys(state(page).view).sort()).toEqual([
|
||||||
|
'collapsedSections',
|
||||||
|
'roomId',
|
||||||
|
'search',
|
||||||
|
'selectedPiece',
|
||||||
|
]);
|
||||||
expect(Object.keys(state(page)).sort()).toEqual(['project', 'view']);
|
expect(Object.keys(state(page)).sort()).toEqual(['project', 'view']);
|
||||||
const table = await page.locator('.flooring-cuts').innerText();
|
const table = await page.locator('.flooring-cuts').innerText();
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
@ -499,12 +596,12 @@ test('multiple rooms retain independent measurements with combined cut lists', a
|
||||||
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Living room');
|
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Living room');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shared URLs reopen the selected preview and CSV contains every cut once', async ({
|
test('shared URLs reopen the selected preview and copying the link preserves the plan', async ({
|
||||||
page,
|
page,
|
||||||
context,
|
context,
|
||||||
}) => {
|
}) => {
|
||||||
await page.getByLabel('Room name', { exact: true }).fill('Café 北');
|
await page.getByLabel('Room name', { exact: true }).fill('Café 北');
|
||||||
const { floor, skirt } = plans(page);
|
const { floor } = plans(page);
|
||||||
const pieceCode = flooringCuts(floor)[0].id;
|
const pieceCode = flooringCuts(floor)[0].id;
|
||||||
await page
|
await page
|
||||||
.getByRole('button', { name: `Preview flooring cut ${pieceCode}`, exact: true })
|
.getByRole('button', { name: `Preview flooring cut ${pieceCode}`, exact: true })
|
||||||
|
|
@ -530,22 +627,6 @@ test('shared URLs reopen the selected preview and CSV contains every cut once',
|
||||||
await shared.close();
|
await shared.close();
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
expect(state(page).view.selectedPiece).toBeNull();
|
expect(state(page).view.selectedPiece).toBeNull();
|
||||||
const downloadPromise = page.waitForEvent('download');
|
|
||||||
await page.getByRole('button', { name: 'Export CSV' }).click();
|
|
||||||
const download = await downloadPromise;
|
|
||||||
expect(download.suggestedFilename()).toBe('flooring-cut-plan.csv');
|
|
||||||
const csv = await readFile((await download.path())!, 'utf8');
|
|
||||||
expect(csv).toContain('"Material","Room","Piece","Stock"');
|
|
||||||
expect(csv).toContain(`"Flooring","Café 北","${pieceCode}"`);
|
|
||||||
expect(csv.split('\r\n').filter((line) => line.startsWith('"Flooring",'))).toHaveLength(
|
|
||||||
flooringCuts(floor).length,
|
|
||||||
);
|
|
||||||
expect(csv.split('\r\n').filter((line) => line.startsWith('"Skirting",'))).toHaveLength(
|
|
||||||
skirtingCuts(skirt).length,
|
|
||||||
);
|
|
||||||
for (const piece of floor.pieces.filter((piece) => !piece.cut && !piece.reused))
|
|
||||||
expect(csv).not.toContain(`"${piece.id}"`);
|
|
||||||
expect(csv).not.toContain('bounded heuristic');
|
|
||||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||||
const closedUrl = page.url();
|
const closedUrl = page.url();
|
||||||
await page.getByRole('button', { name: 'Copy link', exact: true }).click();
|
await page.getByRole('button', { name: 'Copy link', exact: true }).click();
|
||||||
|
|
@ -646,19 +727,7 @@ test('readable query fields, legacy links and browser history preserve the plan'
|
||||||
test('printing includes every room and every cut once, grouped by its source board', async ({
|
test('printing includes every room and every cut once, grouped by its source board', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const fixture = defaultState();
|
const fixture = sharedRoomFixture();
|
||||||
fixture.project.flooring.width = 200;
|
|
||||||
fixture.project.rooms = [
|
|
||||||
rectangle('room-1', 'Living room', 1820, 1220),
|
|
||||||
rectangle('room-2', 'Study', 1220, 1820),
|
|
||||||
];
|
|
||||||
fixture.project.rooms.forEach((room) =>
|
|
||||||
room.walls.forEach((wall) => {
|
|
||||||
wall.doors = [];
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
fixture.project.rooms[1].direction = 90;
|
|
||||||
fixture.view.roomId = 'room-2';
|
|
||||||
await page.goto(`/${encodeState(fixture)}`);
|
await page.goto(`/${encodeState(fixture)}`);
|
||||||
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study');
|
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study');
|
||||||
const { floor, skirt, project } = plans(page);
|
const { floor, skirt, project } = plans(page);
|
||||||
|
|
@ -708,12 +777,392 @@ test('printing includes every room and every cut once, grouped by its source boa
|
||||||
expect(await cut.locator('.dimension-label').count()).toBeGreaterThanOrEqual(2);
|
expect(await cut.locator('.dimension-label').count()).toBeGreaterThanOrEqual(2);
|
||||||
await expect(cut.locator('svg').first()).toBeVisible();
|
await expect(cut.locator('svg').first()).toBeVisible();
|
||||||
}
|
}
|
||||||
expect(await printed.innerText()).not.toMatch(/\b(?:FU|SU)\d+\b/);
|
expect(await printed.innerText()).not.toMatch(
|
||||||
|
/\b(?:FU|SU)\d+\b|(?:Floorboard B|Skirting(?: board)? S)\d+/i,
|
||||||
|
);
|
||||||
await page.emulateMedia({ media: 'screen' });
|
await page.emulateMedia({ media: 'screen' });
|
||||||
await expect(printed).toBeHidden();
|
await expect(printed).toBeHidden();
|
||||||
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study');
|
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('panels collapse independently and the room stays pinned while reading cuts', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
for (const section of ['measurements', 'materials', 'cuts']) {
|
||||||
|
await expect(panel(page, section).locator('.panel-toggle')).toHaveAttribute(
|
||||||
|
'aria-expanded',
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
await panel(page, section).locator('.panel-toggle').click();
|
||||||
|
await expect(page.locator(`#${section}-content`)).toBeHidden();
|
||||||
|
}
|
||||||
|
expect(state(page).view.collapsedSections).toEqual(['cuts', 'materials', 'measurements']);
|
||||||
|
expect(new URL(page.url()).searchParams.getAll('collapsed')).toEqual([
|
||||||
|
'cuts',
|
||||||
|
'materials',
|
||||||
|
'measurements',
|
||||||
|
]);
|
||||||
|
await page.reload();
|
||||||
|
for (const section of ['measurements', 'materials', 'cuts']) {
|
||||||
|
await expect(panel(page, section).locator('.panel-toggle')).toHaveAttribute(
|
||||||
|
'aria-expanded',
|
||||||
|
'false',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await panel(page, 'cuts').locator('.panel-toggle').click();
|
||||||
|
await expect(page.getByLabel('Find a cut or room', { exact: true })).toBeVisible();
|
||||||
|
await skirtEntries(page).last().scrollIntoViewIfNeeded();
|
||||||
|
const scroll = await page.evaluate(() => window.scrollY);
|
||||||
|
expect(scroll).toBeGreaterThan(500);
|
||||||
|
const drawing = await page.locator('.drawing-column').boundingBox();
|
||||||
|
const viewport = page.viewportSize()!;
|
||||||
|
expect(drawing!.y).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(drawing!.y).toBeLessThanOrEqual(20);
|
||||||
|
expect(drawing!.y + drawing!.height).toBeLessThanOrEqual(viewport.height + 1);
|
||||||
|
if (viewport.width >= 900) {
|
||||||
|
const panels = await page.locator('.planning-panels').boundingBox();
|
||||||
|
expect(drawing!.x).toBeGreaterThan(panels!.x + panels!.width);
|
||||||
|
}
|
||||||
|
await noDrawingScroll(page);
|
||||||
|
await noHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drawing cuts still open a usable preview when the cut plan is collapsed', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await panel(page, 'cuts').locator('.panel-toggle').click();
|
||||||
|
await expect(page.locator('#cuts-content')).toBeHidden();
|
||||||
|
await preview(page).getByRole('button', { name: 'Select flooring cut F1', exact: true }).click();
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'Flooring cut F1', exact: true });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog.locator('.cut-dimensions')).toBeVisible();
|
||||||
|
expect(state(page).view.selectedPiece).toBe('F1');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
await expect(page.locator('#cuts-content')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('negative angles can be backspaced and retyped without saving unfinished drafts', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.getByRole('button', { name: 'With fireplace' }).click();
|
||||||
|
const turn = page.getByLabel('Wall 4 turn', { exact: true });
|
||||||
|
await expect(turn).toHaveValue('-90');
|
||||||
|
await expect(turn).toHaveAttribute('type', 'text');
|
||||||
|
await turn.focus();
|
||||||
|
await turn.press('End');
|
||||||
|
await turn.press('Backspace');
|
||||||
|
await expect(turn).toHaveValue('-9');
|
||||||
|
expect(state(page).project.rooms[0].walls[3].turn).toBe(-9);
|
||||||
|
const committed = page.url();
|
||||||
|
await turn.press('Backspace');
|
||||||
|
await expect(turn).toHaveValue('-');
|
||||||
|
expect(page.url()).toBe(committed);
|
||||||
|
await turn.press('Backspace');
|
||||||
|
await expect(turn).toHaveValue('');
|
||||||
|
expect(page.url()).toBe(committed);
|
||||||
|
await turn.pressSequentially('-90');
|
||||||
|
await expect(turn).toHaveValue('-90');
|
||||||
|
expect(state(page).project.rooms[0].walls[3].turn).toBe(-90);
|
||||||
|
await expect(preview(page).getByText('Outline closed', { exact: true })).toBeVisible();
|
||||||
|
const restored = page.url();
|
||||||
|
await turn.fill('-');
|
||||||
|
expect(page.url()).toBe(restored);
|
||||||
|
await turn.press('Enter');
|
||||||
|
await expect(turn).toHaveValue('-90');
|
||||||
|
expect(page.url()).toBe(restored);
|
||||||
|
const length = page.getByLabel('Wall 4 length', { exact: true });
|
||||||
|
await length.fill('');
|
||||||
|
expect(page.url()).toBe(restored);
|
||||||
|
await length.press('Tab');
|
||||||
|
await expect(length).toHaveValue('450');
|
||||||
|
expect(decodeState(new URL(page.url()).search).error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inserting and reordering walls keeps stable wall IDs and attached doors', async ({
|
||||||
|
page,
|
||||||
|
isMobile,
|
||||||
|
}) => {
|
||||||
|
await openWalls(page);
|
||||||
|
const originals = state(page).project.rooms[0].walls;
|
||||||
|
const originalIds = originals.map((wall) => wall.id);
|
||||||
|
const doorWall = originals.find((wall) => wall.doors.length)!;
|
||||||
|
await page.getByRole('button', { name: 'Insert wall after W2', exact: true }).click();
|
||||||
|
let walls = state(page).project.rooms[0].walls;
|
||||||
|
expect(walls).toHaveLength(5);
|
||||||
|
const inserted = walls[2];
|
||||||
|
expect(originalIds).not.toContain(inserted.id);
|
||||||
|
expect(inserted).toMatchObject({ length: 1000, turn: 90, doors: [] });
|
||||||
|
expect(walls.map((wall) => wall.id)).toEqual([
|
||||||
|
originalIds[0],
|
||||||
|
originalIds[1],
|
||||||
|
inserted.id,
|
||||||
|
originalIds[2],
|
||||||
|
originalIds[3],
|
||||||
|
]);
|
||||||
|
await page.getByRole('button', { name: 'Move wall 5 up', exact: true }).click();
|
||||||
|
expect(state(page).project.rooms[0].walls[3]).toEqual(doorWall);
|
||||||
|
await page.getByRole('button', { name: 'Move wall 4 down', exact: true }).click();
|
||||||
|
expect(state(page).project.rooms[0].walls[4]).toEqual(doorWall);
|
||||||
|
if (!isMobile) {
|
||||||
|
const source = page.locator(`.wall-row[data-wall-id="${doorWall.id}"] .wall-drag-handle`);
|
||||||
|
const target = page.locator(`.wall-row[data-wall-id="${originalIds[0]}"]`);
|
||||||
|
await source.dragTo(target);
|
||||||
|
expect(state(page).project.rooms[0].walls[0]).toEqual(doorWall);
|
||||||
|
}
|
||||||
|
walls = state(page).project.rooms[0].walls;
|
||||||
|
for (const original of originals)
|
||||||
|
expect(walls.find((wall) => wall.id === original.id)).toEqual(original);
|
||||||
|
expect(new Set(walls.map((wall) => wall.id)).size).toBe(5);
|
||||||
|
const order = walls.map((wall) => wall.id);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('.wall-row')).toHaveCount(5);
|
||||||
|
expect(
|
||||||
|
await page
|
||||||
|
.locator('.wall-row')
|
||||||
|
.evaluateAll((rows) => rows.map((row) => row.getAttribute('data-wall-id'))),
|
||||||
|
).toEqual(order);
|
||||||
|
expect(state(page).project.rooms[0].walls.find((wall) => wall.id === doorWall.id)!.doors).toEqual(
|
||||||
|
doorWall.doors,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the supplied angled room keeps W12 lines and labels inside the drawing', async ({
|
||||||
|
page,
|
||||||
|
isMobile,
|
||||||
|
}) => {
|
||||||
|
const angledRoom = await readFile(
|
||||||
|
new URL('../tests/fixtures/angled-room.json', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const fixture = decodeState(`#plan=${LZString.compressToEncodedURIComponent(angledRoom)}`);
|
||||||
|
expect(fixture.error).toBeNull();
|
||||||
|
await page.goto(`/${encodeState(fixture.state)}`);
|
||||||
|
await expect(preview(page).locator('.wall-drawing')).toHaveCount(12);
|
||||||
|
for (const width of isMobile ? [320, 390] : [1280]) {
|
||||||
|
await page.setViewportSize({ width, height: 844 });
|
||||||
|
await wallDimensionsInsideSvg(page, 12);
|
||||||
|
await noDrawingScroll(page);
|
||||||
|
await noHorizontalOverflow(page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the supplied F8 room aligns a row seam with its fireplace corner and keeps exact templates', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const json = await readFile(
|
||||||
|
new URL('../tests/fixtures/angled-room-f8.json', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const fixture = decodeState(`#plan=${LZString.compressToEncodedURIComponent(json)}`);
|
||||||
|
expect(fixture.error).toBeNull();
|
||||||
|
fixture.state.view.selectedPiece = null;
|
||||||
|
fixture.state.view.search = '';
|
||||||
|
await page.goto(`/${encodeState(fixture.state)}`);
|
||||||
|
const { floor, skirt } = plans(page);
|
||||||
|
const corner = { x: 7607.513, y: 2160.34 };
|
||||||
|
const atCorner = floor.pieces.filter((piece) =>
|
||||||
|
piece.polygon.some((point) => Math.hypot(point.x - corner.x, point.y - corner.y) < 0.01),
|
||||||
|
);
|
||||||
|
expect(atCorner.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(new Set(atCorner.map((piece) => piece.row)).size).toBe(2);
|
||||||
|
for (const piece of atCorner) {
|
||||||
|
// Flooring runs vertically here, so a row seam has a constant world x coordinate.
|
||||||
|
const xs = piece.polygon.map((point) => point.x);
|
||||||
|
expect(
|
||||||
|
Math.min(Math.abs(corner.x - Math.min(...xs)), Math.abs(corner.x - Math.max(...xs))),
|
||||||
|
).toBeLessThan(0.01);
|
||||||
|
const polygon = preview(page).locator(`.floor-piece[data-piece-id="${piece.id}"] > polygon`);
|
||||||
|
const drawn = await polygon.evaluate((element) =>
|
||||||
|
[...(element as SVGPolygonElement).points].map((point) => ({ x: point.x, y: point.y })),
|
||||||
|
);
|
||||||
|
expect(drawn).toHaveLength(piece.polygon.length);
|
||||||
|
drawn.forEach((point, i) => {
|
||||||
|
expect(point.x).toBeCloseTo(piece.polygon[i].x, 2);
|
||||||
|
expect(point.y).toBeCloseTo(piece.polygon[i].y, 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const cut = atCorner.find((piece) => piece.cut)!;
|
||||||
|
await page.getByRole('button', { name: `Preview flooring cut ${cut.id}`, exact: true }).click();
|
||||||
|
const dialog = page.getByRole('dialog', { name: `Flooring cut ${cut.id}`, exact: true });
|
||||||
|
const shape = dialog.getByRole('img', { name: `Flooring shape ${cut.id}`, exact: true });
|
||||||
|
await expect(shape).toBeVisible();
|
||||||
|
const dimensions = await measuredEdges(shape);
|
||||||
|
expect(
|
||||||
|
dimensions.some(({ actual }) => Math.abs(actual * 2 - Math.round(actual * 2)) > 0.001),
|
||||||
|
).toBe(true);
|
||||||
|
expectHalfMillimetreCuts(dimensions);
|
||||||
|
expect(state(page).project.rooms[0].walls).toEqual(fixture.state.project.rooms[0].walls);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
const skirtCut = skirtingCuts(skirt).find(
|
||||||
|
(piece) =>
|
||||||
|
Math.abs(piece.length * 2 - Math.round(piece.length * 2)) > 0.001 ||
|
||||||
|
Math.abs(piece.backLength * 2 - Math.round(piece.backLength * 2)) > 0.001,
|
||||||
|
)!;
|
||||||
|
expect(skirtCut).toBeDefined();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: `Preview skirting cut ${skirtCut.id}`, exact: true })
|
||||||
|
.click();
|
||||||
|
const skirtDialog = page.getByRole('dialog', {
|
||||||
|
name: `Skirting cut ${skirtCut.id}`,
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
const lengths = await skirtDialog
|
||||||
|
.locator('.skirting-full-preview .dimension-label')
|
||||||
|
.allTextContents();
|
||||||
|
expect(lengths.map((text) => Number(text.replaceAll(',', '')))).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
Number(cutMm(skirtCut.length).replaceAll(',', '')),
|
||||||
|
Number(cutMm(skirtCut.backLength).replaceAll(',', '')),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await noVisibleStockIds(page);
|
||||||
|
await noHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completed source boards and locate actions follow shared offcuts across rooms', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const fixture = sharedRoomFixture();
|
||||||
|
await page.goto(`/${encodeState(fixture)}`);
|
||||||
|
const { floor, skirt } = plans(page);
|
||||||
|
const shared = floor.stocks.find(
|
||||||
|
(stock) =>
|
||||||
|
new Set(stock.pieceIds.map((id) => floor.pieces.find((piece) => piece.id === id)!.roomId))
|
||||||
|
.size > 1,
|
||||||
|
)!;
|
||||||
|
expect(shared).toBeDefined();
|
||||||
|
const sourcePieces = floor.pieces.filter((piece) => piece.stockId === shared.id);
|
||||||
|
const key = `floor:${shared.id}`;
|
||||||
|
const group = page.locator(`.stock-cut-group[data-board-key="${key}"]`);
|
||||||
|
const groupCodes = await group
|
||||||
|
.locator('.board-cut')
|
||||||
|
.evaluateAll((cuts) => cuts.map((cut) => cut.getAttribute('data-cut-id')).join(' + '));
|
||||||
|
const done = group.getByRole('checkbox', {
|
||||||
|
name: `Mark cuts ${groupCodes} done`,
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await done.check();
|
||||||
|
await expect(group).toHaveClass(/is-done/);
|
||||||
|
expect(state(page).project.completedBoards).toEqual([key]);
|
||||||
|
expect(new URL(page.url()).searchParams.getAll('done')).toEqual([key]);
|
||||||
|
await group.getByRole('button', { name: `Show cuts ${groupCodes} on plan`, exact: true }).click();
|
||||||
|
expect(state(page).view.search).toBe(key);
|
||||||
|
const search = page.getByLabel('Find a cut or room', { exact: true });
|
||||||
|
await expect(search).toHaveValue(groupCodes);
|
||||||
|
await noVisibleStockIds(page);
|
||||||
|
await search.fill('');
|
||||||
|
await search.fill(groupCodes);
|
||||||
|
expect(state(page).view.search).toBe(groupCodes);
|
||||||
|
await noVisibleStockIds(page);
|
||||||
|
expect(await highlightedIds(page, 'flooring')).toEqual(
|
||||||
|
sourcePieces
|
||||||
|
.filter((piece) => piece.roomId === 'room-2')
|
||||||
|
.map((piece) => piece.id)
|
||||||
|
.sort(),
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.locator('.matching-rooms')
|
||||||
|
.getByRole('button', { name: 'Living room', exact: true })
|
||||||
|
.click();
|
||||||
|
expect(state(page).view.roomId).toBe('room-1');
|
||||||
|
expect(await highlightedIds(page, 'flooring')).toEqual(
|
||||||
|
sourcePieces
|
||||||
|
.filter((piece) => piece.roomId === 'room-1')
|
||||||
|
.map((piece) => piece.id)
|
||||||
|
.sort(),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await preview(page)
|
||||||
|
.locator('.floor-piece[data-highlighted="true"]')
|
||||||
|
.evaluateAll((pieces) => pieces.every((piece) => piece.getAttribute('data-done') === 'true')),
|
||||||
|
).toBe(true);
|
||||||
|
const piece = sourcePieces.find((piece) => piece.roomId === 'room-2')!;
|
||||||
|
await page.getByRole('button', { name: `Show cut ${piece.id} on plan`, exact: true }).click();
|
||||||
|
expect(state(page).view.roomId).toBe('room-2');
|
||||||
|
expect(state(page).view.search).toBe(piece.id);
|
||||||
|
expect(await highlightedIds(page)).toEqual([piece.id]);
|
||||||
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||||
|
await expect(group.locator('.board-cut')).toHaveCount(sourcePieces.length);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByLabel('Find a cut or room', { exact: true })).toHaveValue(piece.id);
|
||||||
|
await expect(done).toBeChecked();
|
||||||
|
expect(await highlightedIds(page)).toEqual([piece.id]);
|
||||||
|
await page.getByLabel('Room name', { exact: true }).fill('Office');
|
||||||
|
expect(state(page).project.completedBoards).toEqual([key]);
|
||||||
|
await page.locator('.room-tab').first().click();
|
||||||
|
expect(state(page).project.completedBoards).toEqual([key]);
|
||||||
|
await page.getByLabel('Find a cut or room', { exact: true }).fill('');
|
||||||
|
const skirtPiece = skirtingCuts(skirt).find((piece) => piece.roomId === 'room-1')!;
|
||||||
|
const skirtKey = `skirting:${skirtPiece.stockId}`;
|
||||||
|
await page
|
||||||
|
.locator(`.stock-cut-group[data-board-key="${skirtKey}"]`)
|
||||||
|
.getByRole('checkbox')
|
||||||
|
.check();
|
||||||
|
expect(state(page).project.completedBoards).toEqual([key, skirtKey]);
|
||||||
|
expect(await preview(page).locator('.skirt-piece[data-done="true"]').count()).toBeGreaterThan(0);
|
||||||
|
await page.getByLabel('Board width', { exact: true }).fill('201');
|
||||||
|
expect(state(page).project.completedBoards).toEqual([]);
|
||||||
|
await expect(preview(page).locator('[data-done="true"]')).toHaveCount(0);
|
||||||
|
expect(new URL(page.url()).searchParams.getAll('done')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cuts from one board share the available width and wrap on a narrow phone', async ({
|
||||||
|
page,
|
||||||
|
isMobile,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: isMobile ? 320 : 1600, height: 900 });
|
||||||
|
await page.goto(`/${encodeState(sharedRoomFixture())}`);
|
||||||
|
const group = page
|
||||||
|
.locator('.flooring-cuts .stock-cut-group')
|
||||||
|
.filter({ has: page.locator('.board-cut:nth-child(2)') })
|
||||||
|
.first();
|
||||||
|
const pieces = group.locator('.board-cut');
|
||||||
|
expect(await pieces.count()).toBeGreaterThan(1);
|
||||||
|
const first = (await pieces.nth(0).boundingBox())!;
|
||||||
|
const second = (await pieces.nth(1).boundingBox())!;
|
||||||
|
if (isMobile) {
|
||||||
|
expect(second.y).toBeGreaterThanOrEqual(first.y + first.height);
|
||||||
|
expect(second.x).toBeCloseTo(first.x, 1);
|
||||||
|
} else {
|
||||||
|
expect(second.y).toBeCloseTo(first.y, 1);
|
||||||
|
expect(second.x).toBeGreaterThanOrEqual(first.x + first.width);
|
||||||
|
}
|
||||||
|
await noHorizontalOverflow(page);
|
||||||
|
await noVisibleStockIds(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search can locate and complete a whole board without inventing a cut code', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const { floor } = plans(page);
|
||||||
|
const whole = floor.pieces.find((piece) => !piece.cut && !piece.reused)!;
|
||||||
|
expect(whole).toBeDefined();
|
||||||
|
const search = page.getByLabel('Find a cut or room', { exact: true });
|
||||||
|
await search.fill(`floor:${whole.stockId}`);
|
||||||
|
const group = page.locator(`.stock-cut-group[data-board-key="floor:${whole.stockId}"]`);
|
||||||
|
await expect(group).toContainText('Installed uncut');
|
||||||
|
await group.getByRole('button', { name: 'Show uncut floorboard on plan', exact: true }).click();
|
||||||
|
await expect(search).toHaveValue('Uncut floorboard');
|
||||||
|
await noVisibleStockIds(page);
|
||||||
|
await expect(group.locator('.board-cut')).toHaveCount(0);
|
||||||
|
await expect(group.getByRole('button', { name: /^Preview/ })).toHaveCount(0);
|
||||||
|
expect(await highlightedIds(page)).toEqual([whole.id]);
|
||||||
|
await expect(preview(page).locator(`.floor-piece[data-piece-id="${whole.id}"] text`)).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await group.getByRole('checkbox', { name: 'Mark uncut floorboard done', exact: true }).check();
|
||||||
|
await expect(preview(page).locator(`.floor-piece[data-piece-id="${whole.id}"]`)).toHaveAttribute(
|
||||||
|
'data-done',
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
await search.fill('nothing matches this room');
|
||||||
|
await expect(page.locator('.search-results')).toHaveText('No matching boards or cuts.');
|
||||||
|
expect(await highlightedIds(page)).toEqual([]);
|
||||||
|
expect(state(page).project.completedBoards).toEqual([`floor:${whole.stockId}`]);
|
||||||
|
});
|
||||||
|
|
||||||
for (const width of [320, 390]) {
|
for (const width of [320, 390]) {
|
||||||
test(`phone layout works at ${width} px with readable inputs and touch targets`, async ({
|
test(`phone layout works at ${width} px with readable inputs and touch targets`, async ({
|
||||||
page,
|
page,
|
||||||
|
|
@ -727,7 +1176,11 @@ for (const width of [320, 390]) {
|
||||||
.locator('button, input, select, summary')
|
.locator('button, input, select, summary')
|
||||||
.evaluateAll((elements) =>
|
.evaluateAll((elements) =>
|
||||||
elements.flatMap((element) => {
|
elements.flatMap((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const target =
|
||||||
|
element instanceof HTMLInputElement && element.type === 'checkbox'
|
||||||
|
? (element.closest('label') ?? element)
|
||||||
|
: element;
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
return rect.width > 0 && rect.height > 0 && (rect.width < 39.5 || rect.height < 39.5)
|
return rect.width > 0 && rect.height > 0 && (rect.width < 39.5 || rect.height < 39.5)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|
@ -743,7 +1196,7 @@ for (const width of [320, 390]) {
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const smallInputs = await page
|
const smallInputs = await page
|
||||||
.locator('input, select')
|
.locator('input:not([type="checkbox"]), select')
|
||||||
.evaluateAll((elements) =>
|
.evaluateAll((elements) =>
|
||||||
elements
|
elements
|
||||||
.filter(
|
.filter(
|
||||||
|
|
|
||||||
347
src/App.tsx
347
src/App.tsx
|
|
@ -1,21 +1,64 @@
|
||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import { Copy, Plus, Printer, ArrowDown } from 'lucide-react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { Copy, Plus, Printer } from 'lucide-react';
|
||||||
import { useProject } from './lib/use-project';
|
import { useProject } from './lib/use-project';
|
||||||
import { rectangle, nextId } from './lib/defaults';
|
import { rectangle, nextId } from './lib/defaults';
|
||||||
import { buildGeometry } from './lib/geometry';
|
import { buildGeometry } from './lib/geometry';
|
||||||
import { planFlooring } from './lib/flooring';
|
import { planFlooring } from './lib/flooring';
|
||||||
import { planSkirting } from './lib/skirting';
|
import { planSkirting } from './lib/skirting';
|
||||||
import { area, mm } from './lib/format';
|
import { area, mm } from './lib/format';
|
||||||
import { isSkirtingCut } from './lib/cut-list';
|
import { flooringCuts, isSkirtingCut, skirtingCuts } from './lib/cut-list';
|
||||||
import type { Project, Room } from './lib/types';
|
import type { PlannerSection, Project, Room } from './lib/types';
|
||||||
|
import { boardSearchLabel, findBoardMatches } from './lib/board-tracking';
|
||||||
import { OutlineEditor } from './components/OutlineEditor';
|
import { OutlineEditor } from './components/OutlineEditor';
|
||||||
import { MaterialsEditor } from './components/MaterialsEditor';
|
import { MaterialsEditor } from './components/MaterialsEditor';
|
||||||
import { FloorPlan } from './components/FloorPlan';
|
import { FloorPlan } from './components/FloorPlan';
|
||||||
import { CutList } from './components/CutList';
|
import { CutList } from './components/CutList';
|
||||||
|
import { CutDialog } from './components/CutDialog';
|
||||||
import { PrintPlan } from './components/PrintPlan';
|
import { PrintPlan } from './components/PrintPlan';
|
||||||
import { Issues } from './components/Fields';
|
import { Issues } from './components/Fields';
|
||||||
|
|
||||||
const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ block: 'start' });
|
function PlannerPanel({
|
||||||
|
section,
|
||||||
|
title,
|
||||||
|
open,
|
||||||
|
toggle,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
section: PlannerSection;
|
||||||
|
title: string;
|
||||||
|
open: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className={`planner-panel ${open ? 'open' : ''}`} data-section={section}>
|
||||||
|
<h2 className="panel-toggle-heading">
|
||||||
|
<button
|
||||||
|
className="panel-toggle"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={`${section}-content`}
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
<span>{title}</span>
|
||||||
|
<span aria-hidden="true">{open ? '−' : '+'}</span>
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id={`${section}-content`} hidden={!open} className="panel-content">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completion refers to this exact allocation; names and view preferences do not change it.
|
||||||
|
const allocationInputs = (project: Project) =>
|
||||||
|
JSON.stringify({
|
||||||
|
rooms: project.rooms.map(({ id, walls, direction }) => ({ id, walls, direction })),
|
||||||
|
flooring: project.flooring,
|
||||||
|
skirting: project.skirting,
|
||||||
|
tolerance: project.tolerance,
|
||||||
|
});
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { state, error, update } = useProject();
|
const { state, error, update } = useProject();
|
||||||
|
|
@ -50,7 +93,50 @@ export default function App() {
|
||||||
const wholeSkirting = skirting.pieces.filter((p) => !isSkirtingCut(p, skirting)).length;
|
const wholeSkirting = skirting.pieces.filter((p) => !isSkirtingCut(p, skirting)).length;
|
||||||
|
|
||||||
const changeProject = (fn: (p: Project) => Project) =>
|
const changeProject = (fn: (p: Project) => Project) =>
|
||||||
update((s) => ({ ...s, project: fn(s.project), view: { ...s.view, selectedPiece: null } }));
|
update((s) => {
|
||||||
|
const project = fn(s.project);
|
||||||
|
const changed = allocationInputs(project) !== allocationInputs(s.project);
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
project: changed ? { ...project, completedBoards: [] } : project,
|
||||||
|
view: { ...s.view, selectedPiece: null, search: changed ? '' : s.view.search },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const matches = useMemo(
|
||||||
|
() => findBoardMatches(project, flooring, skirting, view.search),
|
||||||
|
[project, flooring, skirting, view.search],
|
||||||
|
);
|
||||||
|
const searchLabel = boardSearchLabel(project, flooring, skirting, view.search);
|
||||||
|
const setSearch = (search: string) =>
|
||||||
|
update((s) => {
|
||||||
|
const found = findBoardMatches(s.project, flooring, skirting, search);
|
||||||
|
const roomId = found.roomIds.includes(s.view.roomId)
|
||||||
|
? s.view.roomId
|
||||||
|
: (found.roomIds[0] ?? s.view.roomId);
|
||||||
|
return { ...s, view: { ...s.view, search, roomId, selectedPiece: null } };
|
||||||
|
});
|
||||||
|
const togglePanel = (section: PlannerSection) =>
|
||||||
|
update((s) => ({
|
||||||
|
...s,
|
||||||
|
view: {
|
||||||
|
...s.view,
|
||||||
|
collapsedSections: s.view.collapsedSections.includes(section)
|
||||||
|
? s.view.collapsedSections.filter((item) => item !== section)
|
||||||
|
: [...s.view.collapsedSections, section],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const toggleBoard = (key: string) =>
|
||||||
|
update((s) => ({
|
||||||
|
...s,
|
||||||
|
project: {
|
||||||
|
...s.project,
|
||||||
|
completedBoards: s.project.completedBoards.includes(key)
|
||||||
|
? s.project.completedBoards.filter((item) => item !== key)
|
||||||
|
: [...s.project.completedBoards, key],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const locatePiece = (id: string, roomId: string) =>
|
||||||
|
update((s) => ({ ...s, view: { ...s.view, search: id, roomId, selectedPiece: null } }));
|
||||||
const changeRoom = (fn: (r: Room) => Room) =>
|
const changeRoom = (fn: (r: Room) => Room) =>
|
||||||
changeProject((p) => ({ ...p, rooms: p.rooms.map((r) => (r.id === room.id ? fn(r) : r)) }));
|
changeProject((p) => ({ ...p, rooms: p.rooms.map((r) => (r.id === room.id ? fn(r) : r)) }));
|
||||||
const select = (id: string | null, roomId?: string) =>
|
const select = (id: string | null, roomId?: string) =>
|
||||||
|
|
@ -68,9 +154,18 @@ export default function App() {
|
||||||
...s,
|
...s,
|
||||||
project: {
|
project: {
|
||||||
...s.project,
|
...s.project,
|
||||||
|
completedBoards: [],
|
||||||
rooms: [...s.project.rooms, rectangle(id, `Room ${s.project.rooms.length + 1}`)],
|
rooms: [...s.project.rooms, rectangle(id, `Room ${s.project.rooms.length + 1}`)],
|
||||||
},
|
},
|
||||||
view: { ...s.view, roomId: id, selectedPiece: null },
|
view: {
|
||||||
|
...s.view,
|
||||||
|
roomId: id,
|
||||||
|
selectedPiece: null,
|
||||||
|
search: '',
|
||||||
|
collapsedSections: s.view.collapsedSections.filter(
|
||||||
|
(section) => section !== 'measurements',
|
||||||
|
),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}, true);
|
}, true);
|
||||||
const removeRoom = () =>
|
const removeRoom = () =>
|
||||||
|
|
@ -78,8 +173,8 @@ export default function App() {
|
||||||
const rooms = s.project.rooms.filter((r) => r.id !== room.id);
|
const rooms = s.project.rooms.filter((r) => r.id !== room.id);
|
||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
project: { ...s.project, rooms },
|
project: { ...s.project, rooms, completedBoards: [] },
|
||||||
view: { ...s.view, roomId: rooms[0].id, selectedPiece: null },
|
view: { ...s.view, roomId: rooms[0].id, selectedPiece: null, search: '' },
|
||||||
};
|
};
|
||||||
}, true);
|
}, true);
|
||||||
const copyLink = async () => {
|
const copyLink = async () => {
|
||||||
|
|
@ -148,51 +243,116 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
<span className="project-area">{area(totalArea)} m² total</span>
|
<span className="project-area">{area(totalArea)} m² total</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="page-jumps" aria-label="Jump to section">
|
|
||||||
<button onClick={() => jump('measurements')}>
|
|
||||||
Room <ArrowDown size={13} />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => jump('materials')}>
|
|
||||||
Materials <ArrowDown size={13} />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => jump('drawing')}>
|
|
||||||
Drawing <ArrowDown size={13} />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => jump('cut-plan')}>
|
|
||||||
Cuts <ArrowDown size={13} />
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
<div className="workspace">
|
<div className="workspace">
|
||||||
<div className="input-columns">
|
<div className="planning-panels">
|
||||||
<section
|
<PlannerPanel
|
||||||
className="editor-panel room-editor"
|
section="measurements"
|
||||||
id="measurements"
|
title="Room measurements"
|
||||||
aria-label="Room measurements"
|
open={!view.collapsedSections.includes('measurements')}
|
||||||
|
toggle={() => togglePanel('measurements')}
|
||||||
>
|
>
|
||||||
<OutlineEditor
|
<section
|
||||||
room={room}
|
className="editor-panel room-editor"
|
||||||
geometry={geometry}
|
id="measurements"
|
||||||
tolerance={project.tolerance}
|
aria-label="Room measurements"
|
||||||
changeRoom={changeRoom}
|
>
|
||||||
changeProject={changeProject}
|
<OutlineEditor
|
||||||
remove={removeRoom}
|
key={room.id}
|
||||||
canRemove={project.rooms.length > 1}
|
room={room}
|
||||||
/>
|
geometry={geometry}
|
||||||
</section>
|
tolerance={project.tolerance}
|
||||||
<section
|
changeRoom={changeRoom}
|
||||||
className="editor-panel materials-editor"
|
changeProject={changeProject}
|
||||||
id="materials"
|
remove={removeRoom}
|
||||||
aria-label="Materials"
|
canRemove={project.rooms.length > 1}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<Issues issues={roomErrors} />
|
||||||
|
</PlannerPanel>
|
||||||
|
<PlannerPanel
|
||||||
|
section="materials"
|
||||||
|
title="Materials"
|
||||||
|
open={!view.collapsedSections.includes('materials')}
|
||||||
|
toggle={() => togglePanel('materials')}
|
||||||
>
|
>
|
||||||
<MaterialsEditor
|
<section
|
||||||
project={project}
|
className="editor-panel materials-editor"
|
||||||
room={room}
|
id="materials"
|
||||||
changeRoom={changeRoom}
|
aria-label="Materials"
|
||||||
changeProject={changeProject}
|
>
|
||||||
/>
|
<MaterialsEditor
|
||||||
</section>
|
project={project}
|
||||||
|
room={room}
|
||||||
|
changeRoom={changeRoom}
|
||||||
|
changeProject={changeProject}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section className="purchase-summary" aria-label="Materials to buy">
|
||||||
|
<h2>
|
||||||
|
Materials to buy{' '}
|
||||||
|
<small>{project.rooms.length > 1 ? 'All rooms' : room.name}</small>
|
||||||
|
</h2>
|
||||||
|
<div className="purchase-item flooring">
|
||||||
|
<span className="material-key floor-key" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{floorErrors.length ? '—' : flooring.boardCount} <span>floorboards</span>
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm ·{' '}
|
||||||
|
{wholeBoards} used whole
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="waste-stat">
|
||||||
|
{floorErrors.length ? '—' : mm(flooring.wastePercent)}%<small>waste</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="purchase-item skirting">
|
||||||
|
<span className="material-key skirt-key" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{skirtErrors.length ? '—' : skirting.stockCount} <span>skirting lengths</span>
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
{[...new Set(skirting.stocks.map((s) => s.length))]
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map(
|
||||||
|
(l) =>
|
||||||
|
`${skirting.stocks.filter((s) => s.length === l).length} × ${mm(l)} mm`,
|
||||||
|
)
|
||||||
|
.join(' + ') || 'Check settings'}{' '}
|
||||||
|
· {wholeSkirting} used whole
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="waste-stat">
|
||||||
|
{skirtErrors.length ? '—' : mm(skirting.waste / 1000)} m<small>waste</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</PlannerPanel>
|
||||||
|
<PlannerPanel
|
||||||
|
section="cuts"
|
||||||
|
title="Cut plan"
|
||||||
|
open={!view.collapsedSections.includes('cuts')}
|
||||||
|
toggle={() => togglePanel('cuts')}
|
||||||
|
>
|
||||||
|
<div id="cut-plan">
|
||||||
|
<CutList
|
||||||
|
project={project}
|
||||||
|
flooring={flooring}
|
||||||
|
skirting={skirting}
|
||||||
|
select={select}
|
||||||
|
completedBoards={project.completedBoards}
|
||||||
|
toggleBoard={toggleBoard}
|
||||||
|
search={view.search}
|
||||||
|
setSearch={setSearch}
|
||||||
|
locateBoard={setSearch}
|
||||||
|
locatePiece={locatePiece}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PlannerPanel>
|
||||||
</div>
|
</div>
|
||||||
<div className="drawing-column" id="drawing">
|
<aside className="drawing-column" id="drawing" aria-label="Room drawing">
|
||||||
<FloorPlan
|
<FloorPlan
|
||||||
room={room}
|
room={room}
|
||||||
geometry={geometry}
|
geometry={geometry}
|
||||||
|
|
@ -200,61 +360,48 @@ export default function App() {
|
||||||
skirting={skirting}
|
skirting={skirting}
|
||||||
selectedPiece={view.selectedPiece}
|
selectedPiece={view.selectedPiece}
|
||||||
select={(id) => select(id)}
|
select={(id) => select(id)}
|
||||||
|
completedBoards={project.completedBoards}
|
||||||
|
highlightedPieces={matches.pieceIds}
|
||||||
|
searchActive={!!view.search.trim()}
|
||||||
/>
|
/>
|
||||||
<section className="purchase-summary" aria-label="Materials to buy">
|
{view.search.trim() && (
|
||||||
<h2>
|
<div className="plan-search-status" role="status">
|
||||||
Materials to buy <small>{project.rooms.length > 1 ? 'All rooms' : room.name}</small>
|
<p>
|
||||||
</h2>
|
{matches.pieceIds.length
|
||||||
<div className="purchase-item flooring">
|
? `${matches.pieceIds.length} piece${matches.pieceIds.length === 1 ? '' : 's'} found for “${searchLabel}”`
|
||||||
<span className="material-key floor-key" aria-hidden="true" />
|
: `No boards or cuts match “${searchLabel}”`}
|
||||||
<div>
|
</p>
|
||||||
<strong>
|
{matches.roomIds.length > 1 && (
|
||||||
{floorErrors.length ? '—' : flooring.boardCount} <span>floorboards</span>
|
<div className="matching-rooms">
|
||||||
</strong>
|
{project.rooms
|
||||||
<p>
|
.filter((r) => matches.roomIds.includes(r.id))
|
||||||
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm · {wholeBoards}{' '}
|
.map((r) => (
|
||||||
used whole
|
<button
|
||||||
</p>
|
key={r.id}
|
||||||
</div>
|
aria-pressed={r.id === room.id}
|
||||||
<span className="waste-stat">
|
onClick={() =>
|
||||||
{floorErrors.length ? '—' : mm(flooring.wastePercent)}%<small>waste</small>
|
update((s) => ({
|
||||||
</span>
|
...s,
|
||||||
|
view: { ...s.view, roomId: r.id, selectedPiece: null },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{r.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="purchase-item skirting">
|
)}
|
||||||
<span className="material-key skirt-key" aria-hidden="true" />
|
</aside>
|
||||||
<div>
|
|
||||||
<strong>
|
|
||||||
{skirtErrors.length ? '—' : skirting.stockCount} <span>skirting lengths</span>
|
|
||||||
</strong>
|
|
||||||
<p>
|
|
||||||
{[...new Set(skirting.stocks.map((s) => s.length))]
|
|
||||||
.sort((a, b) => a - b)
|
|
||||||
.map(
|
|
||||||
(l) =>
|
|
||||||
`${skirting.stocks.filter((s) => s.length === l).length} × ${mm(l)} mm`,
|
|
||||||
)
|
|
||||||
.join(' + ') || 'Check settings'}{' '}
|
|
||||||
· {wholeSkirting} used whole
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="waste-stat">
|
|
||||||
{skirtErrors.length ? '—' : mm(skirting.waste / 1000)} m<small>waste</small>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<Issues issues={roomErrors} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="cut-plan">
|
|
||||||
<CutList
|
|
||||||
project={project}
|
|
||||||
flooring={flooring}
|
|
||||||
skirting={skirting}
|
|
||||||
selectedPiece={view.selectedPiece}
|
|
||||||
select={select}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<CutDialog
|
||||||
|
floor={flooringCuts(flooring).find((piece) => piece.id === view.selectedPiece)}
|
||||||
|
skirt={skirtingCuts(skirting).find((piece) => piece.id === view.selectedPiece)}
|
||||||
|
project={project}
|
||||||
|
close={() => select(null)}
|
||||||
|
/>
|
||||||
<PrintPlan
|
<PrintPlan
|
||||||
project={project}
|
project={project}
|
||||||
geometries={geometries}
|
geometries={geometries}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { Download } from 'lucide-react';
|
import { MapPin, X } from 'lucide-react';
|
||||||
import type { FlooringPlan, Project, SkirtingPlan } from '../lib/types';
|
import type { FlooringPlan, Project, SkirtingPlan } from '../lib/types';
|
||||||
import { degree, mm } from '../lib/format';
|
import { cutMm, degree, mm } from '../lib/format';
|
||||||
import { downloadCsv } from '../lib/export';
|
|
||||||
import {
|
import {
|
||||||
flooringCuts,
|
flooringCuts,
|
||||||
groupFlooringCuts,
|
groupFlooringCuts,
|
||||||
|
|
@ -10,26 +9,99 @@ import {
|
||||||
uncutFlooring,
|
uncutFlooring,
|
||||||
uncutSkirting,
|
uncutSkirting,
|
||||||
} from '../lib/cut-list';
|
} from '../lib/cut-list';
|
||||||
|
import { boardSearchLabel, findBoardMatches } from '../lib/board-tracking';
|
||||||
import { FlooringPreview, SkirtingPreview } from './CutPreview';
|
import { FlooringPreview, SkirtingPreview } from './CutPreview';
|
||||||
import { CutDialog } from './CutDialog';
|
|
||||||
|
function BoardHeader({
|
||||||
|
label,
|
||||||
|
size,
|
||||||
|
done,
|
||||||
|
toggle,
|
||||||
|
locate,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
size: string;
|
||||||
|
done: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
locate: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className="board-group-header">
|
||||||
|
<h4>
|
||||||
|
{label.charAt(0).toUpperCase() + label.slice(1)}
|
||||||
|
<span>{size}</span>
|
||||||
|
</h4>
|
||||||
|
<div className="board-group-actions">
|
||||||
|
<label className="board-done-control">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`Mark ${label} done`}
|
||||||
|
checked={done}
|
||||||
|
onChange={toggle}
|
||||||
|
/>
|
||||||
|
<span>{done ? 'Done' : 'Mark done'}</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="button board-locate"
|
||||||
|
aria-label={`Show ${label} on plan`}
|
||||||
|
onClick={locate}
|
||||||
|
>
|
||||||
|
<MapPin size={15} aria-hidden="true" />
|
||||||
|
Show on plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
flooring: FlooringPlan;
|
flooring: FlooringPlan;
|
||||||
skirting: SkirtingPlan;
|
skirting: SkirtingPlan;
|
||||||
selectedPiece: string | null;
|
|
||||||
select: (id: string | null, roomId?: string) => void;
|
select: (id: string | null, roomId?: string) => void;
|
||||||
|
completedBoards: string[];
|
||||||
|
toggleBoard: (key: string) => void;
|
||||||
|
search: string;
|
||||||
|
setSearch: (query: string) => void;
|
||||||
|
locateBoard: (key: string) => void;
|
||||||
|
locatePiece: (id: string, roomId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CutList({ project, flooring, skirting, selectedPiece, select }: Props) {
|
export function CutList({
|
||||||
|
project,
|
||||||
|
flooring,
|
||||||
|
skirting,
|
||||||
|
select,
|
||||||
|
completedBoards,
|
||||||
|
toggleBoard,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
locateBoard,
|
||||||
|
locatePiece,
|
||||||
|
}: Props) {
|
||||||
const floorCuts = flooringCuts(flooring),
|
const floorCuts = flooringCuts(flooring),
|
||||||
skirtCuts = skirtingCuts(skirting);
|
skirtCuts = skirtingCuts(skirting);
|
||||||
const wholeFloor = uncutFlooring(flooring, project.flooring);
|
const wholeFloor = uncutFlooring(flooring, project.flooring),
|
||||||
const wholeSkirt = uncutSkirting(skirting);
|
wholeSkirt = uncutSkirting(skirting);
|
||||||
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
|
const floorGroups = new Map(
|
||||||
const errors = [...flooring.issues, ...skirting.issues].some(
|
groupFlooringCuts(flooring).map((group) => [group[0].stockId, group]),
|
||||||
(issue) => issue.severity === 'error',
|
|
||||||
);
|
);
|
||||||
|
const skirtGroups = new Map(
|
||||||
|
groupSkirtingCuts(skirting).map((group) => [group[0].stockId, group]),
|
||||||
|
);
|
||||||
|
const searching = search.trim().length > 0;
|
||||||
|
const matches = findBoardMatches(project, flooring, skirting, search);
|
||||||
|
const matchedBoards = new Set(matches.boardKeys),
|
||||||
|
matchedPieces = new Set(matches.pieceIds);
|
||||||
|
const completed = new Set(completedBoards);
|
||||||
|
// Keep every cut from a matching source together, even when only one piece or room matched.
|
||||||
|
const floorStocks = flooring.stocks.filter((stock) =>
|
||||||
|
searching ? matchedBoards.has(`floor:${stock.id}`) : floorGroups.has(stock.id),
|
||||||
|
);
|
||||||
|
const skirtStocks = skirting.stocks.filter((stock) =>
|
||||||
|
searching ? matchedBoards.has(`skirting:${stock.id}`) : skirtGroups.has(stock.id),
|
||||||
|
);
|
||||||
|
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
|
||||||
return (
|
return (
|
||||||
<section className="cut-list-card" aria-labelledby="cut-plan-title">
|
<section className="cut-list-card" aria-labelledby="cut-plan-title">
|
||||||
<div className="cut-list-header">
|
<div className="cut-list-header">
|
||||||
|
|
@ -39,15 +111,39 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
|
||||||
Each group comes from one board. Select a piece for its cutting diagram.
|
Each group comes from one board. Select a piece for its cutting diagram.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
className="button"
|
|
||||||
onClick={() => downloadCsv(project, flooring, skirting)}
|
|
||||||
disabled={errors || (!flooring.pieces.length && !skirting.pieces.length)}
|
|
||||||
>
|
|
||||||
<Download size={16} />
|
|
||||||
Export CSV
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="cut-search">
|
||||||
|
<label htmlFor="board-search">Find a cut or room</label>
|
||||||
|
<div className="cut-search-control">
|
||||||
|
<input
|
||||||
|
id="board-search"
|
||||||
|
type="search"
|
||||||
|
value={boardSearchLabel(project, flooring, skirting, search)}
|
||||||
|
maxLength={200}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="F12, A2, or a room name"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{search.length > 0 && (
|
||||||
|
<button
|
||||||
|
className="button clear-search"
|
||||||
|
aria-label="Clear search"
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
>
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{searching && (
|
||||||
|
<p className="search-results" role="status">
|
||||||
|
{matches.boardKeys.length
|
||||||
|
? `${matches.boardKeys.length} matching board${matches.boardKeys.length === 1 ? '' : 's'}`
|
||||||
|
: 'No matching boards or cuts.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="material-cuts flooring-cuts" aria-labelledby="flooring-cuts-title">
|
<section className="material-cuts flooring-cuts" aria-labelledby="flooring-cuts-title">
|
||||||
<h3 id="flooring-cuts-title">
|
<h3 id="flooring-cuts-title">
|
||||||
Flooring cuts <span className="count-badge">{floorCuts.length}</span>
|
Flooring cuts <span className="count-badge">{floorCuts.length}</span>
|
||||||
|
|
@ -57,61 +153,98 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
|
||||||
{wholeFloor.readyOffcuts > 0 &&
|
{wholeFloor.readyOffcuts > 0 &&
|
||||||
` · ${wholeFloor.readyOffcuts} offcuts need no further cuts`}
|
` · ${wholeFloor.readyOffcuts} offcuts need no further cuts`}
|
||||||
</p>
|
</p>
|
||||||
{groupFlooringCuts(flooring).map((group) => (
|
{floorStocks.map((stock) => {
|
||||||
<section
|
const key = `floor:${stock.id}`,
|
||||||
className="stock-cut-group"
|
done = completed.has(key),
|
||||||
data-stock-id={group[0].stockId}
|
group = floorGroups.get(stock.id) ?? [],
|
||||||
key={group[0].stockId}
|
label = group.length
|
||||||
aria-label={`Floorboard ${group[0].stockId}`}
|
? `cuts ${group.map((piece) => piece.id).join(' + ')}`
|
||||||
>
|
: 'uncut floorboard';
|
||||||
<h4>
|
return (
|
||||||
Floorboard {group[0].stockId}
|
<section
|
||||||
<span>
|
className={`stock-cut-group ${done ? 'is-done' : ''}`}
|
||||||
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm
|
data-stock-id={stock.id}
|
||||||
</span>
|
data-board-key={key}
|
||||||
</h4>
|
key={key}
|
||||||
<ul className="board-cuts">
|
aria-label={group.length ? `Flooring ${label}` : 'Uncut floorboard'}
|
||||||
{group.map((piece) => (
|
>
|
||||||
<li className="board-cut" key={piece.id} data-cut-id={piece.id}>
|
<BoardHeader
|
||||||
<button
|
label={label}
|
||||||
className="cut-preview-button flooring-preview-button"
|
size={`${mm(project.flooring.length)} × ${mm(project.flooring.width)} mm`}
|
||||||
aria-label={`Preview flooring cut ${piece.id}`}
|
done={done}
|
||||||
aria-haspopup="dialog"
|
toggle={() => toggleBoard(key)}
|
||||||
onClick={() => select(piece.id, piece.roomId)}
|
locate={() => locateBoard(key)}
|
||||||
>
|
/>
|
||||||
<span className="cut-thumbnail" aria-hidden="true">
|
{group.length ? (
|
||||||
<FlooringPreview piece={piece} compact />
|
<ul className="board-cuts">
|
||||||
</span>
|
{group.map((piece) => (
|
||||||
<span className="cut-preview-code">
|
<li
|
||||||
{piece.id} <span aria-hidden="true">↗</span>
|
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(piece.id) ? 'search-match' : ''}`}
|
||||||
</span>
|
key={piece.id}
|
||||||
</button>
|
data-cut-id={piece.id}
|
||||||
<div>
|
>
|
||||||
<p className="cut-size">
|
<button
|
||||||
{mm(piece.length)} × {mm(piece.width)} mm
|
className="cut-preview-button flooring-preview-button"
|
||||||
</p>
|
aria-label={`Preview flooring cut ${piece.id}`}
|
||||||
<p className="cut-location">
|
aria-haspopup="dialog"
|
||||||
{roomName(piece.roomId)} · Row {piece.row}
|
onClick={() => select(piece.id, piece.roomId)}
|
||||||
</p>
|
>
|
||||||
{piece.reused && (
|
<span className="cut-thumbnail" aria-hidden="true">
|
||||||
<p className="cut-note">
|
<FlooringPreview piece={piece} compact />
|
||||||
{piece.cut ? 'Cut from the remaining offcut' : 'Use the offcut as it is'}
|
</span>
|
||||||
</p>
|
<span className="cut-preview-code">
|
||||||
)}
|
{piece.id} <span aria-hidden="true">↗</span>
|
||||||
</div>
|
</span>
|
||||||
</li>
|
</button>
|
||||||
))}
|
<div className="board-cut-info">
|
||||||
</ul>
|
<p className="cut-size">
|
||||||
</section>
|
{cutMm(piece.length)} × {cutMm(piece.width)} mm
|
||||||
))}
|
</p>
|
||||||
{!floorCuts.length && (
|
<p className="cut-location">
|
||||||
|
{roomName(piece.roomId)} · Row {piece.row}
|
||||||
|
</p>
|
||||||
|
{piece.reused && (
|
||||||
|
<p className="cut-note">
|
||||||
|
{piece.cut
|
||||||
|
? 'Cut from the remaining offcut'
|
||||||
|
: 'Use the offcut as it is'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="button cut-locate"
|
||||||
|
aria-label={`Show cut ${piece.id} on plan`}
|
||||||
|
onClick={() => locatePiece(piece.id, piece.roomId)}
|
||||||
|
>
|
||||||
|
<MapPin size={14} aria-hidden="true" />
|
||||||
|
Show on plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="uncut-board-note">
|
||||||
|
Installed uncut ·{' '}
|
||||||
|
{flooring.pieces
|
||||||
|
.filter((piece) => piece.stockId === stock.id)
|
||||||
|
.map((piece) => roomName(piece.roomId))
|
||||||
|
.join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!floorStocks.length && (
|
||||||
<p className="empty-state">
|
<p className="empty-state">
|
||||||
{flooring.pieces.length
|
{searching
|
||||||
? 'No flooring cuts needed.'
|
? 'No matching floorboards.'
|
||||||
: 'Complete the outline and flooring measurements to calculate cuts.'}
|
: flooring.pieces.length
|
||||||
|
? 'No flooring cuts needed.'
|
||||||
|
: 'Complete the outline and flooring measurements to calculate cuts.'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="material-cuts skirting-cuts" aria-labelledby="skirting-cuts-title">
|
<section className="material-cuts skirting-cuts" aria-labelledby="skirting-cuts-title">
|
||||||
<h3 id="skirting-cuts-title">
|
<h3 id="skirting-cuts-title">
|
||||||
Skirting cuts <span className="count-badge">{skirtCuts.length}</span>
|
Skirting cuts <span className="count-badge">{skirtCuts.length}</span>
|
||||||
|
|
@ -120,69 +253,97 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
|
||||||
Buy {skirting.stockCount} lengths ·{' '}
|
Buy {skirting.stockCount} lengths ·{' '}
|
||||||
{wholeSkirt.reduce((sum, group) => sum + group.count, 0)} installed uncut
|
{wholeSkirt.reduce((sum, group) => sum + group.count, 0)} installed uncut
|
||||||
</p>
|
</p>
|
||||||
{groupSkirtingCuts(skirting).map((group) => (
|
{skirtStocks.map((stock) => {
|
||||||
<section
|
const key = `skirting:${stock.id}`,
|
||||||
className="stock-cut-group"
|
done = completed.has(key),
|
||||||
data-stock-id={group[0].stockId}
|
group = skirtGroups.get(stock.id) ?? [],
|
||||||
key={group[0].stockId}
|
label = group.length
|
||||||
aria-label={`Skirting board ${group[0].stockId}`}
|
? `cuts ${group.map((piece) => piece.id).join(' + ')}`
|
||||||
>
|
: 'uncut skirting board';
|
||||||
<h4>
|
return (
|
||||||
Skirting board {group[0].stockId}
|
<section
|
||||||
<span>
|
className={`stock-cut-group ${done ? 'is-done' : ''}`}
|
||||||
{mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
|
data-stock-id={stock.id}
|
||||||
</span>
|
data-board-key={key}
|
||||||
</h4>
|
key={key}
|
||||||
<ul className="board-cuts">
|
aria-label={group.length ? `Skirting ${label}` : 'Uncut skirting board'}
|
||||||
{group.map((piece) => (
|
>
|
||||||
<li className="board-cut" key={piece.id} data-cut-id={piece.id}>
|
<BoardHeader
|
||||||
<button
|
label={label}
|
||||||
className="cut-preview-button skirting-preview-button"
|
size={`${mm(stock.length)} mm`}
|
||||||
aria-label={`Preview skirting cut ${piece.id}`}
|
done={done}
|
||||||
aria-haspopup="dialog"
|
toggle={() => toggleBoard(key)}
|
||||||
onClick={() => select(piece.id, piece.roomId)}
|
locate={() => locateBoard(key)}
|
||||||
>
|
/>
|
||||||
<span className="cut-thumbnail" aria-hidden="true">
|
{group.length ? (
|
||||||
<SkirtingPreview
|
<ul className="board-cuts">
|
||||||
piece={piece}
|
{group.map((piece) => (
|
||||||
thickness={project.skirting.thickness}
|
<li
|
||||||
compact
|
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(piece.id) ? 'search-match' : ''}`}
|
||||||
/>
|
key={piece.id}
|
||||||
</span>
|
data-cut-id={piece.id}
|
||||||
<span className="cut-preview-code">
|
>
|
||||||
{piece.id} <span aria-hidden="true">↗</span>
|
<button
|
||||||
</span>
|
className="cut-preview-button skirting-preview-button"
|
||||||
</button>
|
aria-label={`Preview skirting cut ${piece.id}`}
|
||||||
<div>
|
aria-haspopup="dialog"
|
||||||
<p className="cut-size">
|
onClick={() => select(piece.id, piece.roomId)}
|
||||||
{mm(piece.length)} mm <span className="cut-note">long point</span>
|
>
|
||||||
</p>
|
<span className="cut-thumbnail" aria-hidden="true">
|
||||||
<p className="cut-location">
|
<SkirtingPreview
|
||||||
{roomName(piece.roomId)} · Wall {piece.wallIndex + 1}
|
piece={piece}
|
||||||
</p>
|
thickness={project.skirting.thickness}
|
||||||
<p className="cut-note">
|
compact
|
||||||
Left {degree(piece.leftMitre)} · Right {degree(piece.rightMitre)}
|
/>
|
||||||
</p>
|
</span>
|
||||||
</div>
|
<span className="cut-preview-code">
|
||||||
</li>
|
{piece.id} <span aria-hidden="true">↗</span>
|
||||||
))}
|
</span>
|
||||||
</ul>
|
</button>
|
||||||
</section>
|
<div className="board-cut-info">
|
||||||
))}
|
<p className="cut-size">
|
||||||
{!skirtCuts.length && (
|
{cutMm(piece.length)} mm <span className="cut-note">long point</span>
|
||||||
|
</p>
|
||||||
|
<p className="cut-location">
|
||||||
|
{roomName(piece.roomId)} · Wall {piece.wallIndex + 1}
|
||||||
|
</p>
|
||||||
|
<p className="cut-note">
|
||||||
|
Left {degree(piece.leftMitre)} · Right {degree(piece.rightMitre)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="button cut-locate"
|
||||||
|
aria-label={`Show cut ${piece.id} on plan`}
|
||||||
|
onClick={() => locatePiece(piece.id, piece.roomId)}
|
||||||
|
>
|
||||||
|
<MapPin size={14} aria-hidden="true" />
|
||||||
|
Show on plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="uncut-board-note">
|
||||||
|
Installed uncut ·{' '}
|
||||||
|
{skirting.pieces
|
||||||
|
.filter((piece) => piece.stockId === stock.id)
|
||||||
|
.map((piece) => roomName(piece.roomId))
|
||||||
|
.join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!skirtStocks.length && (
|
||||||
<p className="empty-state">
|
<p className="empty-state">
|
||||||
{skirting.pieces.length
|
{searching
|
||||||
? 'No skirting cuts needed.'
|
? 'No matching skirting boards.'
|
||||||
: 'Complete the outline and skirting measurements to calculate cuts.'}
|
: skirting.pieces.length
|
||||||
|
? 'No skirting cuts needed.'
|
||||||
|
: 'Complete the outline and skirting measurements to calculate cuts.'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<CutDialog
|
|
||||||
floor={floorCuts.find((piece) => piece.id === selectedPiece)}
|
|
||||||
skirt={skirtCuts.find((piece) => piece.id === selectedPiece)}
|
|
||||||
project={project}
|
|
||||||
close={() => select(null)}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
import type { FlooringPiece, SkirtingPiece } from '../lib/types';
|
import type { FlooringPiece, SkirtingPiece } from '../lib/types';
|
||||||
|
import { cutMm, degree } from '../lib/format';
|
||||||
import {
|
import {
|
||||||
exactMm,
|
|
||||||
dimensionedOutline,
|
dimensionedOutline,
|
||||||
flooringTemplate,
|
flooringTemplate,
|
||||||
fullPreviewBox,
|
fullPreviewBox,
|
||||||
|
|
@ -93,7 +93,7 @@ function Dimensions({ dimensions, font }: { dimensions: EdgeDimension[]; font: n
|
||||||
{dimension.text}
|
{dimension.text}
|
||||||
</tspan>{' '}
|
</tspan>{' '}
|
||||||
<tspan x={dimension.label.x} dy={font * 1.2}>
|
<tspan x={dimension.label.x} dy={font * 1.2}>
|
||||||
{exactMm(dimension.angle)}°
|
{degree(dimension.angle)}
|
||||||
</tspan>
|
</tspan>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -137,7 +137,7 @@ export function FlooringPreview({
|
||||||
</svg>
|
</svg>
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<figcaption>
|
<figcaption>
|
||||||
{exactMm(piece.length)} × {exactMm(piece.width)} mm
|
{cutMm(piece.length)} × {cutMm(piece.width)} mm
|
||||||
{dimensions?.dimensions.some((dimension) => dimension.angle !== undefined) &&
|
{dimensions?.dimensions.some((dimension) => dimension.angle !== undefined) &&
|
||||||
' · angles from horizontal'}
|
' · angles from horizontal'}
|
||||||
</figcaption>
|
</figcaption>
|
||||||
|
|
@ -198,7 +198,7 @@ export function SkirtingPreview({
|
||||||
<Dimensions dimensions={dimensions!.dimensions} font={dimensions!.font} />
|
<Dimensions dimensions={dimensions!.dimensions} font={dimensions!.font} />
|
||||||
</svg>
|
</svg>
|
||||||
<figcaption>
|
<figcaption>
|
||||||
{exactMm(piece.length)} × {exactMm(thickness)} mm · wall side above
|
{cutMm(piece.length)} × {cutMm(thickness)} mm · wall side above
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
)}
|
)}
|
||||||
|
|
@ -242,7 +242,7 @@ export function SkirtingPreview({
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<figcaption>
|
<figcaption>
|
||||||
{index === 0 ? 'Left' : 'Right'} ·{' '}
|
{index === 0 ? 'Left' : 'Right'} ·{' '}
|
||||||
{exactMm(Math.abs(index === 0 ? piece.leftMitre : piece.rightMitre))}°{' '}
|
{degree(index === 0 ? piece.leftMitre : piece.rightMitre)}{' '}
|
||||||
{index === 0 ? piece.leftType : piece.rightType}
|
{index === 0 ? piece.leftType : piece.rightType}
|
||||||
</figcaption>
|
</figcaption>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,61 @@
|
||||||
import { useId, useState } from 'react';
|
import { useEffect, useId, useRef, useState } from 'react';
|
||||||
|
import type { InputHTMLAttributes } from 'react';
|
||||||
import type { Issue } from '../lib/types';
|
import type { Issue } from '../lib/types';
|
||||||
import { AlertCircle, Info, Check } from 'lucide-react';
|
import { AlertCircle, Info, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
const numberFromDraft = (text: string): number | null => {
|
||||||
|
if (!/^-?(?:\d+(?:\.\d*)?|\.\d+)$/.test(text)) return null;
|
||||||
|
const number = Number(text);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Only unfinished typing is local; every complete numeric value is committed immediately. */
|
||||||
|
export function NumericInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
onKeyDown,
|
||||||
|
...props
|
||||||
|
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> & {
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(String(value));
|
||||||
|
const emitted = useRef(value);
|
||||||
|
useEffect(() => {
|
||||||
|
if (value !== emitted.current) setDraft(String(value));
|
||||||
|
emitted.current = value;
|
||||||
|
}, [value]);
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
type="text"
|
||||||
|
inputMode={props.inputMode ?? (Number(props.min) < 0 ? 'text' : 'decimal')}
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => {
|
||||||
|
const text = event.target.value;
|
||||||
|
setDraft(text);
|
||||||
|
const number = numberFromDraft(text);
|
||||||
|
if (number !== null && number !== value) {
|
||||||
|
emitted.current = number;
|
||||||
|
onChange(number);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(event) => {
|
||||||
|
setDraft(String(value));
|
||||||
|
onBlur?.(event);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
onKeyDown?.(event);
|
||||||
|
if (!event.defaultPrevented && (event.key === 'Enter' || event.key === 'Escape')) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function NumberField({
|
export function NumberField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
|
@ -54,16 +109,14 @@ export function NumberField({
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="number-input">
|
<span className="number-input">
|
||||||
<input
|
<NumericInput
|
||||||
id={id}
|
id={id}
|
||||||
type="number"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={value}
|
value={value}
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
aria-describedby={hint ? `${id}-hint` : undefined}
|
aria-describedby={hint ? `${id}-hint` : undefined}
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
<span>{unit}</span>
|
<span>{unit}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
import type { FlooringPlan, Geometry, Point, Room, SkirtingPlan, WallEdge } from '../lib/types';
|
import type { FlooringPlan, Geometry, Point, Room, SkirtingPlan, WallEdge } from '../lib/types';
|
||||||
import { area, mm } from '../lib/format';
|
import { area, cutMm, mm } from '../lib/format';
|
||||||
import { signedArea } from '../lib/geometry';
|
import { signedArea } from '../lib/geometry';
|
||||||
import { isSkirtingCut } from '../lib/cut-list';
|
import { isSkirtingCut } from '../lib/cut-list';
|
||||||
import { skirtingPolygon } from '../lib/skirting-polygon';
|
import { skirtingPolygon } from '../lib/skirting-polygon';
|
||||||
import { dimensionLabels } from '../lib/dimension-labels';
|
import { dimensionLabels, wallDimensionBox } from '../lib/dimension-labels';
|
||||||
import type { DimensionLabel, LabelBox } from '../lib/dimension-labels';
|
import type { DimensionLabel, LabelBox } from '../lib/dimension-labels';
|
||||||
import { ClosedBadge } from './Fields';
|
import { ClosedBadge } from './Fields';
|
||||||
|
|
||||||
|
|
@ -113,6 +113,9 @@ interface Props {
|
||||||
selectedPiece: string | null;
|
selectedPiece: string | null;
|
||||||
select?: (id: string) => void;
|
select?: (id: string) => void;
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
|
completedBoards?: string[];
|
||||||
|
highlightedPieces?: string[];
|
||||||
|
searchActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FloorPlan({
|
export function FloorPlan({
|
||||||
|
|
@ -123,6 +126,9 @@ export function FloorPlan({
|
||||||
selectedPiece,
|
selectedPiece,
|
||||||
select,
|
select,
|
||||||
interactive = true,
|
interactive = true,
|
||||||
|
completedBoards = [],
|
||||||
|
highlightedPieces = [],
|
||||||
|
searchActive = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const hatch = useId().replaceAll(':', '');
|
const hatch = useId().replaceAll(':', '');
|
||||||
const allPoints = geometry.edges.flatMap((edge) => [edge.start, edge.end]);
|
const allPoints = geometry.edges.flatMap((edge) => [edge.start, edge.end]);
|
||||||
|
|
@ -145,14 +151,17 @@ export function FloorPlan({
|
||||||
const y = center.y - ((orientation * (piece.end.x - piece.start.x)) / length) * span * 0.032;
|
const y = center.y - ((orientation * (piece.end.x - piece.start.x)) / length) * span * 0.032;
|
||||||
skirtLabels.set(piece.id, { x, y });
|
skirtLabels.set(piece.id, { x, y });
|
||||||
reserved.push({
|
reserved.push({
|
||||||
minX: x - font * 1.2,
|
minX: x - font * 1.4,
|
||||||
maxX: x + font * 1.2,
|
maxX: x + font * 1.4,
|
||||||
minY: y - font * 0.7,
|
minY: y - font * 0.7,
|
||||||
maxY: y + font * 0.7,
|
maxY: y + font * 0.7,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const labels = dimensionLabels(geometry.edges, span, orientation, reserved);
|
const labels = dimensionLabels(geometry.edges, span, orientation, reserved);
|
||||||
const boxes = [...reserved, ...labels.map((label) => label.box)];
|
const boxes = [
|
||||||
|
...reserved,
|
||||||
|
...labels.map((label, i) => wallDimensionBox(geometry.edges[i], label, span)),
|
||||||
|
];
|
||||||
const left = Math.min(minX, ...boxes.map((b) => b.minX)) - span * 0.035;
|
const left = Math.min(minX, ...boxes.map((b) => b.minX)) - span * 0.035;
|
||||||
const top = Math.min(minY, ...boxes.map((b) => b.minY)) - span * 0.035;
|
const top = Math.min(minY, ...boxes.map((b) => b.minY)) - span * 0.035;
|
||||||
const width = Math.max(maxX, ...boxes.map((b) => b.maxX)) + span * 0.035 - left;
|
const width = Math.max(maxX, ...boxes.map((b) => b.maxX)) + span * 0.035 - left;
|
||||||
|
|
@ -161,7 +170,10 @@ export function FloorPlan({
|
||||||
<section className="drawing-card" aria-label="Floor plan preview">
|
<section className="drawing-card" aria-label="Floor plan preview">
|
||||||
<div className="drawing-header">
|
<div className="drawing-header">
|
||||||
<h2>
|
<h2>
|
||||||
Floor plan <small>{area(geometry.area)} m²</small>
|
Floor plan{' '}
|
||||||
|
<small>
|
||||||
|
{room.name} · {area(geometry.area)} m²
|
||||||
|
</small>
|
||||||
</h2>
|
</h2>
|
||||||
<ClosedBadge closed={geometry.closed && geometry.valid} />
|
<ClosedBadge closed={geometry.closed && geometry.valid} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -212,6 +224,8 @@ export function FloorPlan({
|
||||||
{geometry.closed && <polygon points={points(geometry.vertices)} fill="#fff" />}
|
{geometry.closed && <polygon points={points(geometry.vertices)} fill="#fff" />}
|
||||||
{floorPieces.map((piece) => {
|
{floorPieces.map((piece) => {
|
||||||
const cut = piece.cut || piece.reused;
|
const cut = piece.cut || piece.reused;
|
||||||
|
const done = completedBoards.includes(`floor:${piece.stockId}`);
|
||||||
|
const highlighted = highlightedPieces.includes(piece.id);
|
||||||
const x =
|
const x =
|
||||||
(Math.min(...piece.polygon.map((p) => p.x)) +
|
(Math.min(...piece.polygon.map((p) => p.x)) +
|
||||||
Math.max(...piece.polygon.map((p) => p.x))) /
|
Math.max(...piece.polygon.map((p) => p.x))) /
|
||||||
|
|
@ -223,8 +237,11 @@ export function FloorPlan({
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
key={piece.id}
|
key={piece.id}
|
||||||
className={`floor-piece ${cut ? 'svg-cut floor-cut' : 'uncut'}`}
|
className={`floor-piece ${cut ? 'svg-cut floor-cut' : 'uncut'} ${done ? 'is-done' : ''} ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
|
||||||
data-material="flooring"
|
data-material="flooring"
|
||||||
|
data-board-key={`floor:${piece.stockId}`}
|
||||||
|
data-done={done}
|
||||||
|
data-highlighted={highlighted}
|
||||||
data-piece-id={piece.id}
|
data-piece-id={piece.id}
|
||||||
role={cut && interactive ? 'button' : undefined}
|
role={cut && interactive ? 'button' : undefined}
|
||||||
tabIndex={cut && interactive ? 0 : undefined}
|
tabIndex={cut && interactive ? 0 : undefined}
|
||||||
|
|
@ -246,11 +263,30 @@ export function FloorPlan({
|
||||||
<polygon
|
<polygon
|
||||||
points={points(piece.polygon)}
|
points={points(piece.polygon)}
|
||||||
fill={
|
fill={
|
||||||
cut ? (selectedPiece === piece.id ? '#ceddf9' : `url(#${hatch})`) : '#f5f6f8'
|
done
|
||||||
|
? '#dbe9e7'
|
||||||
|
: cut
|
||||||
|
? selectedPiece === piece.id
|
||||||
|
? '#ceddf9'
|
||||||
|
: `url(#${hatch})`
|
||||||
|
: '#f5f6f8'
|
||||||
}
|
}
|
||||||
stroke={cut ? '#1647ab' : '#9baabd'}
|
stroke={cut ? '#1647ab' : '#9baabd'}
|
||||||
strokeWidth={span * (selectedPiece === piece.id ? 0.003 : 0.0012)}
|
strokeWidth={span * (selectedPiece === piece.id ? 0.003 : 0.0012)}
|
||||||
/>
|
/>
|
||||||
|
{done && !cut && (
|
||||||
|
<text
|
||||||
|
className="completion-mark"
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fontSize={font}
|
||||||
|
fill="#20594f"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
{cut && (
|
{cut && (
|
||||||
<>
|
<>
|
||||||
<g
|
<g
|
||||||
|
|
@ -258,11 +294,11 @@ export function FloorPlan({
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
>
|
>
|
||||||
<rect
|
<rect
|
||||||
x={-font * 1.1}
|
x={-font * 1.3}
|
||||||
y={-font * 0.55}
|
y={-font * 0.55}
|
||||||
width={font * 2.2}
|
width={font * 2.6}
|
||||||
height={font * 1.1}
|
height={font * 1.1}
|
||||||
fill="#1647ab"
|
fill={done ? '#27655d' : '#1647ab'}
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
|
|
@ -272,10 +308,11 @@ export function FloorPlan({
|
||||||
fontWeight="600"
|
fontWeight="600"
|
||||||
>
|
>
|
||||||
{piece.id}
|
{piece.id}
|
||||||
|
{done ? ' ✓' : ''}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
<title>
|
<title>
|
||||||
{piece.id}: {mm(piece.length)} × {mm(piece.width)} mm
|
{piece.id}: {cutMm(piece.length)} × {cutMm(piece.width)} mm
|
||||||
</title>
|
</title>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -287,6 +324,8 @@ export function FloorPlan({
|
||||||
))}
|
))}
|
||||||
{skirtPieces.map((piece) => {
|
{skirtPieces.map((piece) => {
|
||||||
const cut = isSkirtingCut(piece, skirting);
|
const cut = isSkirtingCut(piece, skirting);
|
||||||
|
const done = completedBoards.includes(`skirting:${piece.stockId}`);
|
||||||
|
const highlighted = highlightedPieces.includes(piece.id);
|
||||||
const label = skirtLabels.get(piece.id);
|
const label = skirtLabels.get(piece.id);
|
||||||
// Each template uses its true wall-facing and room-facing edges, including mitres.
|
// Each template uses its true wall-facing and room-facing edges, including mitres.
|
||||||
const thickness = piece.template ? Math.max(...piece.template.map((p) => p.y)) : 0;
|
const thickness = piece.template ? Math.max(...piece.template.map((p) => p.y)) : 0;
|
||||||
|
|
@ -294,8 +333,11 @@ export function FloorPlan({
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
key={piece.id}
|
key={piece.id}
|
||||||
className={`skirt-piece ${cut ? 'svg-cut skirt-cut' : 'uncut'}`}
|
className={`skirt-piece ${cut ? 'svg-cut skirt-cut' : 'uncut'} ${done ? 'is-done' : ''} ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
|
||||||
data-material="skirting"
|
data-material="skirting"
|
||||||
|
data-board-key={`skirting:${piece.stockId}`}
|
||||||
|
data-done={done}
|
||||||
|
data-highlighted={highlighted}
|
||||||
data-piece-id={piece.id}
|
data-piece-id={piece.id}
|
||||||
role={cut && interactive ? 'button' : undefined}
|
role={cut && interactive ? 'button' : undefined}
|
||||||
tabIndex={cut && interactive ? 0 : undefined}
|
tabIndex={cut && interactive ? 0 : undefined}
|
||||||
|
|
@ -316,7 +358,15 @@ export function FloorPlan({
|
||||||
>
|
>
|
||||||
<polygon
|
<polygon
|
||||||
points={points(polygon)}
|
points={points(polygon)}
|
||||||
fill={cut ? (selectedPiece === piece.id ? '#17263f' : '#b74900') : '#d8d5cd'}
|
fill={
|
||||||
|
done
|
||||||
|
? '#27655d'
|
||||||
|
: cut
|
||||||
|
? selectedPiece === piece.id
|
||||||
|
? '#17263f'
|
||||||
|
: '#b74900'
|
||||||
|
: '#d8d5cd'
|
||||||
|
}
|
||||||
stroke={cut ? '#8d3800' : '#79756b'}
|
stroke={cut ? '#8d3800' : '#79756b'}
|
||||||
strokeWidth={span * 0.001}
|
strokeWidth={span * 0.001}
|
||||||
/>
|
/>
|
||||||
|
|
@ -330,14 +380,27 @@ export function FloorPlan({
|
||||||
strokeWidth={span * 0.025}
|
strokeWidth={span * 0.025}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{done && !cut && (
|
||||||
|
<text
|
||||||
|
className="completion-mark"
|
||||||
|
x={(piece.start.x + piece.end.x) / 2}
|
||||||
|
y={(piece.start.y + piece.end.y) / 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fontSize={font}
|
||||||
|
fill="#20594f"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
{label && (
|
{label && (
|
||||||
<g className="skirting-code" transform={`translate(${label.x} ${label.y})`}>
|
<g className="skirting-code" transform={`translate(${label.x} ${label.y})`}>
|
||||||
<rect
|
<rect
|
||||||
x={-font * 1.1}
|
x={-font * 1.3}
|
||||||
y={-font * 0.58}
|
y={-font * 0.58}
|
||||||
width={font * 2.2}
|
width={font * 2.6}
|
||||||
height={font * 1.16}
|
height={font * 1.16}
|
||||||
fill="#b74900"
|
fill={done ? '#27655d' : '#b74900'}
|
||||||
stroke="#8d3800"
|
stroke="#8d3800"
|
||||||
strokeWidth={span * 0.0012}
|
strokeWidth={span * 0.0012}
|
||||||
/>
|
/>
|
||||||
|
|
@ -349,12 +412,13 @@ export function FloorPlan({
|
||||||
fontWeight="700"
|
fontWeight="700"
|
||||||
>
|
>
|
||||||
{piece.id}
|
{piece.id}
|
||||||
|
{done ? ' ✓' : ''}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
)}
|
)}
|
||||||
{cut && (
|
{cut && (
|
||||||
<title>
|
<title>
|
||||||
{piece.id}: {mm(piece.length)} mm · {mm(piece.leftMitre)}° /{' '}
|
{piece.id}: {cutMm(piece.length)} mm · {mm(piece.leftMitre)}° /{' '}
|
||||||
{mm(piece.rightMitre)}°
|
{mm(piece.rightMitre)}°
|
||||||
</title>
|
</title>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
import { Plus, Trash2, DoorOpen, Check, MoveUpRight } from 'lucide-react';
|
import { Fragment, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
DoorOpen,
|
||||||
|
Check,
|
||||||
|
MoveUpRight,
|
||||||
|
GripVertical,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
} from 'lucide-react';
|
||||||
import type { Geometry, Project, Room, Wall } from '../lib/types';
|
import type { Geometry, Project, Room, Wall } from '../lib/types';
|
||||||
import { fireplace, rectangle, nextId } from '../lib/defaults';
|
import { fireplace, rectangle, nextId } from '../lib/defaults';
|
||||||
import { normalizeAngle } from '../lib/geometry';
|
import { normalizeAngle } from '../lib/geometry';
|
||||||
import { mm } from '../lib/format';
|
import { mm } from '../lib/format';
|
||||||
import { NumberField, Issues } from './Fields';
|
import { NumberField, NumericInput, Issues } from './Fields';
|
||||||
import { DoorPosition } from './DoorPosition';
|
import { DoorPosition } from './DoorPosition';
|
||||||
|
import { insertWallAfter, moveWall } from '../lib/wall-editing';
|
||||||
interface Props {
|
interface Props {
|
||||||
room: Room;
|
room: Room;
|
||||||
geometry: Geometry;
|
geometry: Geometry;
|
||||||
|
|
@ -23,6 +34,8 @@ export function OutlineEditor({
|
||||||
remove,
|
remove,
|
||||||
canRemove,
|
canRemove,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const draggedWall = useRef<string | null>(null);
|
||||||
|
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||||
const changeWall = (id: string, fn: (wall: Wall) => Wall) =>
|
const changeWall = (id: string, fn: (wall: Wall) => Wall) =>
|
||||||
changeRoom((r) => ({ ...r, walls: r.walls.map((w) => (w.id === id ? fn(w) : w)) }));
|
changeRoom((r) => ({ ...r, walls: r.walls.map((w) => (w.id === id ? fn(w) : w)) }));
|
||||||
const isRectangle =
|
const isRectangle =
|
||||||
|
|
@ -85,7 +98,7 @@ export function OutlineEditor({
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<Fragment key={room.id}>
|
||||||
<div className="editor-heading">
|
<div className="editor-heading">
|
||||||
<h2>Room measurements</h2>
|
<h2>Room measurements</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -168,23 +181,39 @@ export function OutlineEditor({
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
{room.walls.map((wall, i) => (
|
{room.walls.map((wall, i) => (
|
||||||
<div className="wall-row" key={wall.id}>
|
<div
|
||||||
|
className={`wall-row${dropTarget === wall.id ? ' drag-over' : ''}`}
|
||||||
|
key={wall.id}
|
||||||
|
data-wall-id={wall.id}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
if (!draggedWall.current || draggedWall.current === wall.id) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
setDropTarget(wall.id);
|
||||||
|
}}
|
||||||
|
onDragLeave={(event) => {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
|
||||||
|
setDropTarget(null);
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
const id = draggedWall.current;
|
||||||
|
if (!id) return;
|
||||||
|
event.preventDefault();
|
||||||
|
changeRoom((r) => moveWall(r, id, i));
|
||||||
|
draggedWall.current = null;
|
||||||
|
setDropTarget(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span className="wall-code">W{i + 1}</span>
|
<span className="wall-code">W{i + 1}</span>
|
||||||
<input
|
<NumericInput
|
||||||
aria-label={`Wall ${i + 1} length`}
|
aria-label={`Wall ${i + 1} length`}
|
||||||
type="number"
|
|
||||||
inputMode="decimal"
|
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
value={wall.length}
|
value={wall.length}
|
||||||
onChange={(e) =>
|
onChange={(length) => changeWall(wall.id, (w) => ({ ...w, length }))}
|
||||||
changeWall(wall.id, (w) => ({ ...w, length: Number(e.target.value) }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<input
|
<NumericInput
|
||||||
aria-label={`Wall ${i + 1} turn`}
|
aria-label={`Wall ${i + 1} turn`}
|
||||||
type="number"
|
|
||||||
inputMode="decimal"
|
|
||||||
min={-179}
|
min={-179}
|
||||||
max={179}
|
max={179}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
|
|
@ -199,20 +228,64 @@ export function OutlineEditor({
|
||||||
: 'Turn after this wall; positive turns right.'
|
: 'Turn after this wall; positive turns right.'
|
||||||
}
|
}
|
||||||
disabled={geometry.closed && i === room.walls.length - 1}
|
disabled={geometry.closed && i === room.walls.length - 1}
|
||||||
onChange={(e) =>
|
onChange={(turn) => changeWall(wall.id, (w) => ({ ...w, turn }))}
|
||||||
changeWall(wall.id, (w) => ({ ...w, turn: Number(e.target.value) }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<div className="wall-actions">
|
||||||
className="icon-button"
|
<button
|
||||||
aria-label={`Remove wall ${i + 1}`}
|
className="icon-button wall-drag-handle"
|
||||||
disabled={room.walls.length === 1}
|
draggable
|
||||||
onClick={() =>
|
aria-label={`Drag wall ${i + 1} to reorder`}
|
||||||
changeRoom((r) => ({ ...r, walls: r.walls.filter((w) => w.id !== wall.id) }))
|
title="Drag to reorder"
|
||||||
}
|
onDragStart={(event) => {
|
||||||
>
|
draggedWall.current = wall.id;
|
||||||
<Trash2 size={14} />
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
</button>
|
event.dataTransfer.setData('text/plain', wall.id);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => {
|
||||||
|
draggedWall.current = null;
|
||||||
|
setDropTarget(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
aria-label={`Move wall ${i + 1} up`}
|
||||||
|
title="Move wall up"
|
||||||
|
disabled={i === 0}
|
||||||
|
onClick={() => changeRoom((r) => moveWall(r, wall.id, i - 1))}
|
||||||
|
>
|
||||||
|
<ArrowUp size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
aria-label={`Move wall ${i + 1} down`}
|
||||||
|
title="Move wall down"
|
||||||
|
disabled={i === room.walls.length - 1}
|
||||||
|
onClick={() => changeRoom((r) => moveWall(r, wall.id, i + 1))}
|
||||||
|
>
|
||||||
|
<ArrowDown size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
aria-label={`Insert wall after W${i + 1}`}
|
||||||
|
title={`Insert wall after W${i + 1}`}
|
||||||
|
disabled={room.walls.length >= 100}
|
||||||
|
onClick={() => changeRoom((r) => insertWallAfter(r, wall.id))}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
aria-label={`Remove wall ${i + 1}`}
|
||||||
|
disabled={room.walls.length === 1}
|
||||||
|
onClick={() =>
|
||||||
|
changeRoom((r) => ({ ...r, walls: r.walls.filter((w) => w.id !== wall.id) }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -364,6 +437,6 @@ export function OutlineEditor({
|
||||||
Remove this room
|
Remove this room
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ export function PrintPlan({
|
||||||
Buy {flooring.boardCount} floorboards and {skirting.stockCount} skirting lengths. Install{' '}
|
Buy {flooring.boardCount} floorboards and {skirting.stockCount} skirting lengths. Install{' '}
|
||||||
{wholeFloor} floorboards and {wholeSkirt} skirting lengths uncut.
|
{wholeFloor} floorboards and {wholeSkirt} skirting lengths uncut.
|
||||||
</p>
|
</p>
|
||||||
|
<p>Cut each group from one board.</p>
|
||||||
</header>
|
</header>
|
||||||
{project.rooms.map((room, index) => (
|
{project.rooms.map((room, index) => (
|
||||||
<section className="print-room" key={room.id} data-room-id={room.id}>
|
<section className="print-room" key={room.id} data-room-id={room.id}>
|
||||||
|
|
@ -51,15 +52,23 @@ export function PrintPlan({
|
||||||
skirting={skirting}
|
skirting={skirting}
|
||||||
selectedPiece={null}
|
selectedPiece={null}
|
||||||
interactive={false}
|
interactive={false}
|
||||||
|
completedBoards={project.completedBoards}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
<section className="print-material print-flooring" aria-label="Printed flooring cuts">
|
<section className="print-material print-flooring" aria-label="Printed flooring cuts">
|
||||||
<h2>Flooring cuts</h2>
|
<h2>Flooring cuts</h2>
|
||||||
{groupFlooringCuts(flooring).map((group) => (
|
{groupFlooringCuts(flooring).map((group) => (
|
||||||
<section className="print-stock" data-stock-id={group[0].stockId} key={group[0].stockId}>
|
<section
|
||||||
|
className={`print-stock ${project.completedBoards.includes(`floor:${group[0].stockId}`) ? 'is-done' : ''}`}
|
||||||
|
data-stock-id={group[0].stockId}
|
||||||
|
key={group[0].stockId}
|
||||||
|
>
|
||||||
<h3>
|
<h3>
|
||||||
Floorboard {group[0].stockId}{' '}
|
Cuts {group.map((piece) => piece.id).join(' + ')}
|
||||||
|
{project.completedBoards.includes(`floor:${group[0].stockId}`)
|
||||||
|
? ' · Done ✓'
|
||||||
|
: ''}{' '}
|
||||||
<span>
|
<span>
|
||||||
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm
|
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -85,9 +94,16 @@ export function PrintPlan({
|
||||||
<section className="print-material print-skirting" aria-label="Printed skirting cuts">
|
<section className="print-material print-skirting" aria-label="Printed skirting cuts">
|
||||||
<h2>Skirting cuts</h2>
|
<h2>Skirting cuts</h2>
|
||||||
{groupSkirtingCuts(skirting).map((group) => (
|
{groupSkirtingCuts(skirting).map((group) => (
|
||||||
<section className="print-stock" data-stock-id={group[0].stockId} key={group[0].stockId}>
|
<section
|
||||||
|
className={`print-stock ${project.completedBoards.includes(`skirting:${group[0].stockId}`) ? 'is-done' : ''}`}
|
||||||
|
data-stock-id={group[0].stockId}
|
||||||
|
key={group[0].stockId}
|
||||||
|
>
|
||||||
<h3>
|
<h3>
|
||||||
Skirting board {group[0].stockId}{' '}
|
Cuts {group.map((piece) => piece.id).join(' + ')}
|
||||||
|
{project.completedBoards.includes(`skirting:${group[0].stockId}`)
|
||||||
|
? ' · Done ✓'
|
||||||
|
: ''}{' '}
|
||||||
<span>
|
<span>
|
||||||
{mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
|
{mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
73
src/lib/board-tracking.ts
Normal file
73
src/lib/board-tracking.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import type { FlooringPlan, Project, SkirtingPlan } from './types';
|
||||||
|
import { flooringCuts, skirtingCuts } from './cut-list';
|
||||||
|
|
||||||
|
export interface BoardMatches {
|
||||||
|
pieceIds: string[];
|
||||||
|
roomIds: string[];
|
||||||
|
boardKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Codes match exactly; ordinary text searches room names. Qualified keys identify source boards. */
|
||||||
|
export function findBoardMatches(
|
||||||
|
project: Project,
|
||||||
|
flooring: FlooringPlan,
|
||||||
|
skirting: SkirtingPlan,
|
||||||
|
query: string,
|
||||||
|
): BoardMatches {
|
||||||
|
const text = query.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
if (!text) return { pieceIds: [], roomIds: [], boardKeys: [] };
|
||||||
|
const qualified = /^(floor|skirting)\s*:\s*([a-z]+\d+)$/.exec(text);
|
||||||
|
const codes = text.split(/\s*[+,]\s*/);
|
||||||
|
const isCode = codes.every((code) => /^[a-z]+\d+$/.test(code));
|
||||||
|
const matchingRooms = new Set(
|
||||||
|
project.rooms
|
||||||
|
.filter((room) => room.name.toLowerCase().replace(/\s+/g, ' ').includes(text))
|
||||||
|
.map((room) => room.id),
|
||||||
|
);
|
||||||
|
const pieceIds: string[] = [];
|
||||||
|
const roomIds = new Set<string>();
|
||||||
|
const boardKeys = new Set<string>();
|
||||||
|
const materials = [
|
||||||
|
{ key: 'floor', pieces: flooring.pieces },
|
||||||
|
{ key: 'skirting', pieces: skirting.pieces },
|
||||||
|
];
|
||||||
|
for (const material of materials) {
|
||||||
|
for (const piece of material.pieces) {
|
||||||
|
const matches = qualified
|
||||||
|
? material.key === qualified[1] && piece.stockId.toLowerCase() === qualified[2]
|
||||||
|
: isCode
|
||||||
|
? codes.includes(piece.id.toLowerCase()) ||
|
||||||
|
(codes.length === 1 && codes.includes(piece.stockId.toLowerCase()))
|
||||||
|
: matchingRooms.has(piece.roomId);
|
||||||
|
if (!matches) continue;
|
||||||
|
pieceIds.push(piece.id);
|
||||||
|
roomIds.add(piece.roomId);
|
||||||
|
boardKeys.add(`${material.key}:${piece.stockId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pieceIds,
|
||||||
|
roomIds: project.rooms.filter((room) => roomIds.has(room.id)).map((room) => room.id),
|
||||||
|
boardKeys: [...boardKeys],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Source IDs are bookkeeping; show the cut codes when locating a whole source board. */
|
||||||
|
export function boardSearchLabel(
|
||||||
|
project: Project,
|
||||||
|
flooring: FlooringPlan,
|
||||||
|
skirting: SkirtingPlan,
|
||||||
|
query: string,
|
||||||
|
): string {
|
||||||
|
const qualified = /^(floor|skirting)\s*:\s*([a-z]+\d+)$/i.exec(query.trim());
|
||||||
|
if (!qualified) return query;
|
||||||
|
const matches = new Set(findBoardMatches(project, flooring, skirting, query).pieceIds);
|
||||||
|
const codes = [...flooringCuts(flooring), ...skirtingCuts(skirting)]
|
||||||
|
.filter((piece) => matches.has(piece.id))
|
||||||
|
.map((piece) => piece.id);
|
||||||
|
return codes.length
|
||||||
|
? codes.join(' + ')
|
||||||
|
: qualified[1].toLowerCase() === 'floor'
|
||||||
|
? 'Uncut floorboard'
|
||||||
|
: 'Uncut skirting board';
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { FlooringPiece, Point, SkirtingPiece } from './types';
|
import type { FlooringPiece, Point, SkirtingPiece } from './types';
|
||||||
|
import { cutMm, degree } from './format';
|
||||||
|
|
||||||
export interface PreviewBox {
|
export interface PreviewBox {
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -6,8 +7,6 @@ export interface PreviewBox {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
export const exactMm = (value: number) =>
|
|
||||||
new Intl.NumberFormat('en-GB', { maximumFractionDigits: 3 }).format(value);
|
|
||||||
export const viewBox = (box: PreviewBox) => `${box.x} ${box.y} ${box.width} ${box.height}`;
|
export const viewBox = (box: PreviewBox) => `${box.x} ${box.y} ${box.width} ${box.height}`;
|
||||||
|
|
||||||
export interface EdgeDimension {
|
export interface EdgeDimension {
|
||||||
|
|
@ -73,12 +72,12 @@ export function dimensionedOutline(points: Point[], edges?: number[]) {
|
||||||
};
|
};
|
||||||
const normal = { x: orientation * direction.y, y: -orientation * direction.x };
|
const normal = { x: orientation * direction.y, y: -orientation * direction.x };
|
||||||
const center = { x: (edge.start.x + edge.end.x) / 2, y: (edge.start.y + edge.end.y) / 2 };
|
const center = { x: (edge.start.x + edge.end.x) / 2, y: (edge.start.y + edge.end.y) / 2 };
|
||||||
const text = exactMm(edge.length);
|
const text = cutMm(edge.length);
|
||||||
const angle =
|
const angle =
|
||||||
Math.abs(direction.x) > 0.000001 && Math.abs(direction.y) > 0.000001
|
Math.abs(direction.x) > 0.000001 && Math.abs(direction.y) > 0.000001
|
||||||
? (Math.atan2(Math.abs(direction.y), Math.abs(direction.x)) * 180) / Math.PI
|
? (Math.atan2(Math.abs(direction.y), Math.abs(direction.x)) * 180) / Math.PI
|
||||||
: undefined;
|
: undefined;
|
||||||
const angleText = angle === undefined ? '' : `${exactMm(angle)}°`;
|
const angleText = angle === undefined ? '' : degree(angle);
|
||||||
const width = Math.max(font * 1.2, Math.max(text.length, angleText.length) * font * 0.64),
|
const width = Math.max(font * 1.2, Math.max(text.length, angleText.length) * font * 0.64),
|
||||||
height = font * (angle === undefined ? 1.3 : 2.5);
|
height = font * (angle === undefined ? 1.3 : 2.5);
|
||||||
const outside = Math.max(
|
const outside = Math.max(
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export function defaultProject(): Project {
|
||||||
name: 'My flooring project',
|
name: 'My flooring project',
|
||||||
rooms: [rectangle()],
|
rooms: [rectangle()],
|
||||||
tolerance: 10,
|
tolerance: 10,
|
||||||
|
completedBoards: [],
|
||||||
flooring: {
|
flooring: {
|
||||||
length: 1200,
|
length: 1200,
|
||||||
width: 190,
|
width: 190,
|
||||||
|
|
@ -54,7 +55,7 @@ export function defaultProject(): Project {
|
||||||
export function defaultState(): UrlState {
|
export function defaultState(): UrlState {
|
||||||
return {
|
return {
|
||||||
project: defaultProject(),
|
project: defaultProject(),
|
||||||
view: { roomId: 'room-1', selectedPiece: null },
|
view: { roomId: 'room-1', selectedPiece: null, search: '', collapsedSections: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export function nextId(prefix: string, existing: string[]): string {
|
export function nextId(prefix: string, existing: string[]): string {
|
||||||
|
|
|
||||||
|
|
@ -56,3 +56,19 @@ export function dimensionLabels(
|
||||||
return label;
|
return label;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The drawing must include extension-line endpoints as well as the text itself. */
|
||||||
|
export function wallDimensionBox(edge: WallEdge, label: DimensionLabel, span: number): LabelBox {
|
||||||
|
const ends = [edge.start, edge.end].flatMap((point) =>
|
||||||
|
[label.offset - span * 0.012, label.offset + span * 0.012].map((offset) => ({
|
||||||
|
x: point.x + label.normal.x * offset,
|
||||||
|
y: point.y + label.normal.y * offset,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
minX: Math.min(label.box.minX, ...ends.map((point) => point.x)),
|
||||||
|
maxX: Math.max(label.box.maxX, ...ends.map((point) => point.x)),
|
||||||
|
minY: Math.min(label.box.minY, ...ends.map((point) => point.y)),
|
||||||
|
maxY: Math.max(label.box.maxY, ...ends.map((point) => point.y)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
import type { FlooringPlan, Project, SkirtingPlan } from './types';
|
|
||||||
import { flooringInstructions } from './cut-instructions';
|
|
||||||
import {
|
|
||||||
groupFlooringCuts,
|
|
||||||
groupSkirtingCuts,
|
|
||||||
uncutFlooring,
|
|
||||||
uncutSkirting,
|
|
||||||
readyOffcutInstructions,
|
|
||||||
} from './cut-list';
|
|
||||||
|
|
||||||
const cell = (value: string | number) => {
|
|
||||||
const str = String(value);
|
|
||||||
return `"${(/^[=+@\-]/.test(str) ? "'" : '') + str.replaceAll('"', '""')}"`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function cutListCsv(
|
|
||||||
project: Project,
|
|
||||||
flooring: FlooringPlan,
|
|
||||||
skirting: SkirtingPlan,
|
|
||||||
): string {
|
|
||||||
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
|
|
||||||
const floorCuts = groupFlooringCuts(flooring).flat(),
|
|
||||||
skirtCuts = groupSkirtingCuts(skirting).flat();
|
|
||||||
const wholeFloor = uncutFlooring(flooring, project.flooring);
|
|
||||||
const rows: (string | number)[][] = [
|
|
||||||
[
|
|
||||||
'Material',
|
|
||||||
'Room',
|
|
||||||
'Piece',
|
|
||||||
'Stock',
|
|
||||||
'Length mm',
|
|
||||||
'Width mm',
|
|
||||||
'Left mitre deg',
|
|
||||||
'Right mitre deg',
|
|
||||||
'Quantity',
|
|
||||||
'Notes',
|
|
||||||
'Template x:y mm',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'Flooring total',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
project.flooring.length,
|
|
||||||
project.flooring.width,
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
flooring.boardCount,
|
|
||||||
`Boards to buy; ${flooring.pieces.length} installed pieces; ${flooring.cuts} saw cuts`,
|
|
||||||
'',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'Flooring uncut boards',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
project.flooring.length,
|
|
||||||
project.flooring.width,
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
wholeFloor.wholeBoards,
|
|
||||||
'Full boards laid without cuts',
|
|
||||||
'',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'Skirting total',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
project.skirting.thickness,
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
skirting.stockCount,
|
|
||||||
`Lengths to buy; ${skirting.pieces.length} installed pieces; ${skirting.cuts} saw cuts`,
|
|
||||||
'',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
if (wholeFloor.readyOffcuts)
|
|
||||||
rows.push([
|
|
||||||
'Flooring ready offcuts',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
wholeFloor.readyOffcuts,
|
|
||||||
'Already sized; no further cuts',
|
|
||||||
'',
|
|
||||||
]);
|
|
||||||
for (const group of uncutSkirting(skirting)) {
|
|
||||||
rows.push([
|
|
||||||
'Skirting uncut lengths',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
group.length,
|
|
||||||
project.skirting.thickness,
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
group.count,
|
|
||||||
'Full stock lengths installed without cuts',
|
|
||||||
'',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
for (const piece of floorCuts) {
|
|
||||||
rows.push([
|
|
||||||
'Flooring',
|
|
||||||
roomName(piece.roomId),
|
|
||||||
piece.id,
|
|
||||||
piece.stockId,
|
|
||||||
piece.length,
|
|
||||||
piece.width,
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
1,
|
|
||||||
`${piece.reused ? 'Reused offcut; ' : ''}${piece.cut ? `${piece.shaped ? 'Shaped cut' : 'Straight cut'}; ${flooringInstructions(piece)}` : readyOffcutInstructions(piece)}`,
|
|
||||||
piece.template.map((point) => `${point.x.toFixed(1)}:${point.y.toFixed(1)}`).join(' | '),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
for (const piece of skirtCuts) {
|
|
||||||
rows.push([
|
|
||||||
'Skirting',
|
|
||||||
roomName(piece.roomId),
|
|
||||||
piece.id,
|
|
||||||
piece.stockId,
|
|
||||||
piece.length,
|
|
||||||
project.skirting.thickness,
|
|
||||||
piece.leftMitre,
|
|
||||||
piece.rightMitre,
|
|
||||||
1,
|
|
||||||
`Back length ${piece.backLength.toFixed(1)} mm; left ${piece.leftType}; right ${piece.rightType}; signed front-to-back bevels ${piece.leftBevel ?? piece.leftMitre} / ${piece.rightBevel ?? piece.rightMitre} deg; template back y=0, front y=thickness`,
|
|
||||||
piece.template?.map((point) => `${point.x.toFixed(1)}:${point.y.toFixed(1)}`).join(' | ') ??
|
|
||||||
'',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
return '\uFEFF' + rows.map((row) => row.map(cell).join(',')).join('\r\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function downloadCsv(project: Project, flooring: FlooringPlan, skirting: SkirtingPlan) {
|
|
||||||
const url = URL.createObjectURL(
|
|
||||||
new Blob([cutListCsv(project, flooring, skirting)], { type: 'text/csv;charset=utf-8;' }),
|
|
||||||
);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = 'flooring-cut-plan.csv';
|
|
||||||
link.click();
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
||||||
}
|
|
||||||
|
|
@ -112,7 +112,7 @@ function intersect(paths: Point[][], rectangle: Bounds): Point[][] {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only perimeter rows may be ripped: interior rows must retain both factory long edges. */
|
/** Only perimeter rows may be ripped: interior rows must retain both factory long edges. */
|
||||||
function rowWidthOptions(height: number, s: FlooringSettings): number[][] {
|
function rowWidthOptions(height: number, s: FlooringSettings, cornerOffsets: number[] = []): number[][] {
|
||||||
const count = Math.ceil((height - EPSILON) / s.width);
|
const count = Math.ceil((height - EPSILON) / s.width);
|
||||||
if (count <= 0 || count > 500) return [];
|
if (count <= 0 || count > 500) return [];
|
||||||
if (count === 1) return height + EPSILON >= s.minWidth ? [[height]] : [];
|
if (count === 1) return height + EPSILON >= s.minWidth ? [[height]] : [];
|
||||||
|
|
@ -139,9 +139,23 @@ function rowWidthOptions(height: number, s: FlooringSettings): number[][] {
|
||||||
add(last);
|
add(last);
|
||||||
add(edgeTotal / 2);
|
add(edgeTotal / 2);
|
||||||
}
|
}
|
||||||
|
// Place a long-edge seam on an inward corner when both perimeter rows still fit.
|
||||||
|
// This avoids thin toes made solely by a row crossing just beyond that corner.
|
||||||
|
for (const offset of cornerOffsets.slice(0, 8)) {
|
||||||
|
const first = phaseOf(offset, s.width);
|
||||||
|
add(close(first, 0) ? s.width : first);
|
||||||
|
}
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inwardCorners(paths: Point[][]): Point[] {
|
||||||
|
return paths.flatMap(path => path.filter((point, index) => {
|
||||||
|
const previous = path[(index + path.length - 1) % path.length];
|
||||||
|
const next = path[(index + 1) % path.length];
|
||||||
|
return (point.x - previous.x) * (next.y - point.y) - (point.y - previous.y) * (next.x - point.x) < -EPSILON;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function hasVerticalEdge(path: Point[], x: number): boolean {
|
function hasVerticalEdge(path: Point[], x: number): boolean {
|
||||||
return path.some(
|
return path.some(
|
||||||
(p, i) =>
|
(p, i) =>
|
||||||
|
|
@ -497,7 +511,8 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const roomBounds = bounds(paths.flat());
|
const roomBounds = bounds(paths.flat());
|
||||||
const widthOptions = rowWidthOptions(roomBounds.maxY - roomBounds.minY, s);
|
const widthOptions = rowWidthOptions(roomBounds.maxY - roomBounds.minY, s,
|
||||||
|
inwardCorners(paths).map(point => point.y - roomBounds.minY));
|
||||||
const estimatedBoards = paths.reduce((sum, path) => sum + area(path), 0) / (s.length * s.width);
|
const estimatedBoards = paths.reduce((sum, path) => sum + area(path), 0) / (s.length * s.width);
|
||||||
if (estimatedBoards > MAX_BOARDS || !widthOptions.length) {
|
if (estimatedBoards > MAX_BOARDS || !widthOptions.length) {
|
||||||
issues.push({
|
issues.push({
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ export const mm = (value: number) =>
|
||||||
Number.isFinite(value)
|
Number.isFinite(value)
|
||||||
? new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1 }).format(value)
|
? new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1 }).format(value)
|
||||||
: '—';
|
: '—';
|
||||||
|
/** Round cutting marks up without letting floating-point noise add a half millimetre. */
|
||||||
|
export const cutLength = (value: number) => Math.ceil(value * 2 - 1e-9) / 2 + 0;
|
||||||
|
export const cutMm = (value: number) => mm(cutLength(value));
|
||||||
export const area = (value: number) =>
|
export const area = (value: number) =>
|
||||||
new Intl.NumberFormat('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
|
new Intl.NumberFormat('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
|
||||||
value / 1e6,
|
value / 1e6,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { buildGeometry, normalizeAngle, signedArea } from './geometry';
|
import { buildGeometry, normalizeAngle, signedArea } from './geometry';
|
||||||
import { isSkirtingCut } from './cut-list';
|
import { isSkirtingCut } from './cut-list';
|
||||||
|
import { cutLength } from './format';
|
||||||
import type {
|
import type {
|
||||||
Issue,
|
Issue,
|
||||||
Point,
|
Point,
|
||||||
|
|
@ -86,14 +87,19 @@ function endCuts(piece: SkirtingPiece) {
|
||||||
return Number(Math.abs(piece.leftMitre) > EPS) + Number(Math.abs(piece.rightMitre) > EPS);
|
return Number(Math.abs(piece.leftMitre) > EPS) + Number(Math.abs(piece.rightMitre) > EPS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function blankLength(piece: SkirtingPiece, available: number) {
|
||||||
|
return Math.abs(available - piece.length) <= EPS ? available : cutLength(piece.length);
|
||||||
|
}
|
||||||
|
|
||||||
function requiredCuts(piece: SkirtingPiece, available: number) {
|
function requiredCuts(piece: SkirtingPiece, available: number) {
|
||||||
return endCuts(piece) + Number(Math.abs(available - piece.length) > EPS);
|
return endCuts(piece) + Number(Math.abs(available - blankLength(piece, available)) > EPS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function consumedLength(piece: SkirtingPiece, available: number, kerf: number) {
|
function consumedLength(piece: SkirtingPiece, available: number, kerf: number) {
|
||||||
// Cut a square blank, then bevel within its longest-point envelope. This leaves
|
// Cut a square blank, then bevel within its longest-point envelope. This leaves
|
||||||
// a square reusable offcut. Bevel kerfs remove material inside the blank.
|
// a square reusable offcut. Bevel kerfs remove material inside the blank.
|
||||||
return Math.min(available, piece.length + (Math.abs(available - piece.length) <= EPS ? 0 : kerf));
|
const length = blankLength(piece, available);
|
||||||
|
return Math.min(available, length + (Math.abs(available - length) <= EPS ? 0 : kerf));
|
||||||
}
|
}
|
||||||
|
|
||||||
function makePiece(
|
function makePiece(
|
||||||
|
|
@ -141,7 +147,7 @@ function shortestEdge(piece: SkirtingPiece) {
|
||||||
|
|
||||||
function fits(piece: SkirtingPiece, available: number) {
|
function fits(piece: SkirtingPiece, available: number) {
|
||||||
// The final saw pass may extend beyond the stock's tail.
|
// The final saw pass may extend beyond the stock's tail.
|
||||||
return piece.length <= available + EPS;
|
return blankLength(piece, available) <= available + EPS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zero means that no number of pieces can satisfy the end allowances. */
|
/** Zero means that no number of pieces can satisfy the end allowances. */
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,15 @@ export interface Project {
|
||||||
flooring: FlooringSettings;
|
flooring: FlooringSettings;
|
||||||
skirting: SkirtingSettings;
|
skirting: SkirtingSettings;
|
||||||
tolerance: number;
|
tolerance: number;
|
||||||
|
/** Explicit progress marks for purchased boards, e.g. floor:B4 or skirting:S6. */
|
||||||
|
completedBoards: string[];
|
||||||
}
|
}
|
||||||
|
export type PlannerSection = 'measurements' | 'materials' | 'cuts';
|
||||||
export interface ViewState {
|
export interface ViewState {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
selectedPiece: string | null;
|
selectedPiece: string | null;
|
||||||
|
search: string;
|
||||||
|
collapsedSections: PlannerSection[];
|
||||||
}
|
}
|
||||||
export interface UrlState {
|
export interface UrlState {
|
||||||
project: Project;
|
project: Project;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import LZString from 'lz-string';
|
import LZString from 'lz-string';
|
||||||
import { defaultState } from './defaults';
|
import { defaultState } from './defaults';
|
||||||
import type { Door, FlooringSettings, Room, SkirtingSettings, UrlState, Wall } from './types';
|
import type {
|
||||||
|
Door,
|
||||||
|
FlooringSettings,
|
||||||
|
PlannerSection,
|
||||||
|
Room,
|
||||||
|
SkirtingSettings,
|
||||||
|
UrlState,
|
||||||
|
Wall,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
/** Resource limits apply to links as well as edits made inside the app. */
|
/** Resource limits apply to links as well as edits made inside the app. */
|
||||||
export const URL_LIMITS = {
|
export const URL_LIMITS = {
|
||||||
|
|
@ -13,8 +21,12 @@ export const URL_LIMITS = {
|
||||||
stockLengths: 30,
|
stockLengths: 30,
|
||||||
identifierLength: 120,
|
identifierLength: 120,
|
||||||
nameLength: 200,
|
nameLength: 200,
|
||||||
|
completedBoards: 12_000,
|
||||||
|
searchLength: 200,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const SECTIONS = ['measurements', 'materials', 'cuts'] as const;
|
||||||
|
|
||||||
function object(value: unknown, field: string): Record<string, unknown> {
|
function object(value: unknown, field: string): Record<string, unknown> {
|
||||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||||
throw new Error(`${field} must be an object.`);
|
throw new Error(`${field} must be an object.`);
|
||||||
|
|
@ -71,6 +83,34 @@ function uniqueIds<T extends { id: string }>(items: T[], field: string): T[] {
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniqueValues<T extends string>(items: T[], field: string): T[] {
|
||||||
|
if (new Set(items).size !== items.length) throw new Error(`${field} contains duplicate entries.`);
|
||||||
|
return items.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function completedBoards(value: unknown): string[] {
|
||||||
|
const entries = array(
|
||||||
|
value === undefined ? [] : value,
|
||||||
|
'Completed boards',
|
||||||
|
URL_LIMITS.completedBoards,
|
||||||
|
).map((value) => {
|
||||||
|
const key = string(value, 'Completed board');
|
||||||
|
if (!/^(?:floor:B|skirting:S)[1-9]\d*$/.test(key))
|
||||||
|
throw new Error('Completed boards must use floor:B1 or skirting:S1 identifiers.');
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
return uniqueValues(entries, 'Completed boards');
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapsedSections(value: unknown): PlannerSection[] {
|
||||||
|
return uniqueValues(
|
||||||
|
array(value === undefined ? [] : value, 'Collapsed sections', SECTIONS.length).map((value) =>
|
||||||
|
choice(value, SECTIONS, 'Collapsed section'),
|
||||||
|
),
|
||||||
|
'Collapsed sections',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function door(value: unknown): Door {
|
function door(value: unknown): Door {
|
||||||
const data = object(value, 'Door');
|
const data = object(value, 'Door');
|
||||||
return {
|
return {
|
||||||
|
|
@ -151,11 +191,18 @@ function parseState(value: unknown): UrlState {
|
||||||
flooring: flooring(project.flooring),
|
flooring: flooring(project.flooring),
|
||||||
skirting: skirting(project.skirting),
|
skirting: skirting(project.skirting),
|
||||||
tolerance: number(project.tolerance, 'Measurement tolerance'),
|
tolerance: number(project.tolerance, 'Measurement tolerance'),
|
||||||
|
completedBoards: completedBoards(project.completedBoards),
|
||||||
},
|
},
|
||||||
view: {
|
view: {
|
||||||
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
|
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
|
||||||
selectedPiece:
|
selectedPiece:
|
||||||
view.selectedPiece === null ? null : string(view.selectedPiece, 'Selected piece'),
|
view.selectedPiece === null ? null : string(view.selectedPiece, 'Selected piece'),
|
||||||
|
search: string(
|
||||||
|
view.search === undefined ? '' : view.search,
|
||||||
|
'Cut search',
|
||||||
|
URL_LIMITS.searchLength,
|
||||||
|
),
|
||||||
|
collapsedSections: collapsedSections(view.collapsedSections),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -193,9 +240,12 @@ export function encodeState(state: UrlState): string {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
for (const board of project.completedBoards) put('done', board);
|
||||||
put('selectedRoom', view.roomId);
|
put('selectedRoom', view.roomId);
|
||||||
if (view.selectedPiece !== null) put('selectedCut', view.selectedPiece);
|
if (view.selectedPiece !== null) put('selectedCut', view.selectedPiece);
|
||||||
const query = `?${params}`;
|
if (view.search) put('search', view.search);
|
||||||
|
for (const section of view.collapsedSections) put('collapsed', section);
|
||||||
|
const query = `?${params}`.replaceAll('%3A', ':');
|
||||||
if (query.length > URL_LIMITS.queryLength)
|
if (query.length > URL_LIMITS.queryLength)
|
||||||
throw new Error('This plan link is too large. Split it into smaller projects.');
|
throw new Error('This plan link is too large. Split it into smaller projects.');
|
||||||
return query;
|
return query;
|
||||||
|
|
@ -289,6 +339,7 @@ function parseQuery(query: string): UrlState {
|
||||||
version: numeric('version', 1),
|
version: numeric('version', 1),
|
||||||
name: text('name', defaults.project.name),
|
name: text('name', defaults.project.name),
|
||||||
tolerance: numeric('tolerance', defaults.project.tolerance),
|
tolerance: numeric('tolerance', defaults.project.tolerance),
|
||||||
|
completedBoards: params.getAll('done'),
|
||||||
rooms,
|
rooms,
|
||||||
flooring: Object.fromEntries(
|
flooring: Object.fromEntries(
|
||||||
Object.entries(floor).map(([key, value]) => [
|
Object.entries(floor).map(([key, value]) => [
|
||||||
|
|
@ -308,6 +359,8 @@ function parseQuery(query: string): UrlState {
|
||||||
view: {
|
view: {
|
||||||
roomId: text('selectedRoom', rooms[0].id),
|
roomId: text('selectedRoom', rooms[0].id),
|
||||||
selectedPiece: params.has('selectedCut') ? text('selectedCut') : null,
|
selectedPiece: params.has('selectedCut') ? text('selectedCut') : null,
|
||||||
|
search: text('search', ''),
|
||||||
|
collapsedSections: params.getAll('collapsed'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,15 @@ export function useProject() {
|
||||||
window.history[milestone ? 'pushState' : 'replaceState'](null, '', queryUrl(query));
|
window.history[milestone ? 'pushState' : 'replaceState'](null, '', queryUrl(query));
|
||||||
const canonical = decodeState(query).state;
|
const canonical = decodeState(query).state;
|
||||||
if (next.project === current.state.project) canonical.project = current.state.project;
|
if (next.project === current.state.project) canonical.project = current.state.project;
|
||||||
|
else {
|
||||||
|
// Progress changes should not rerun geometry and stock allocation.
|
||||||
|
if (next.project.rooms === current.state.project.rooms)
|
||||||
|
canonical.project.rooms = current.state.project.rooms;
|
||||||
|
if (next.project.flooring === current.state.project.flooring)
|
||||||
|
canonical.project.flooring = current.state.project.flooring;
|
||||||
|
if (next.project.skirting === current.state.project.skirting)
|
||||||
|
canonical.project.skirting = current.state.project.skirting;
|
||||||
|
}
|
||||||
return { state: canonical, error: null };
|
return { state: canonical, error: null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
33
src/lib/wall-editing.ts
Normal file
33
src/lib/wall-editing.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { nextId } from './defaults';
|
||||||
|
import type { Room } from './types';
|
||||||
|
|
||||||
|
/** Inserting a segment never renumbers walls or moves their attached doors. */
|
||||||
|
export function insertWallAfter(room: Room, wallId: string): Room {
|
||||||
|
const index = room.walls.findIndex((wall) => wall.id === wallId);
|
||||||
|
if (index < 0 || room.walls.length >= 100) return room;
|
||||||
|
const wall = {
|
||||||
|
id: nextId(
|
||||||
|
`${room.id}-w`,
|
||||||
|
room.walls.map((wall) => wall.id),
|
||||||
|
),
|
||||||
|
length: 1000,
|
||||||
|
turn: 90,
|
||||||
|
doors: [],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...room,
|
||||||
|
walls: [...room.walls.slice(0, index + 1), wall, ...room.walls.slice(index + 1)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The destination is the wall's final zero-based position, after removing it from the list. */
|
||||||
|
export function moveWall(room: Room, wallId: string, destination: number): Room {
|
||||||
|
const index = room.walls.findIndex((wall) => wall.id === wallId);
|
||||||
|
if (index < 0 || !Number.isFinite(destination)) return room;
|
||||||
|
const target = Math.max(0, Math.min(room.walls.length - 1, Math.trunc(destination)));
|
||||||
|
if (target === index) return room;
|
||||||
|
const walls = [...room.walls];
|
||||||
|
const [wall] = walls.splice(index, 1);
|
||||||
|
walls.splice(target, 0, wall);
|
||||||
|
return { ...room, walls };
|
||||||
|
}
|
||||||
451
src/styles.css
451
src/styles.css
|
|
@ -204,29 +204,10 @@ small {
|
||||||
font: 12px var(--mono);
|
font: 12px var(--mono);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.page-jumps {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.workspace {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(570px, 1fr) minmax(450px, 1fr);
|
|
||||||
align-items: start;
|
|
||||||
gap: 30px;
|
|
||||||
}
|
|
||||||
.input-columns {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
||||||
gap: 25px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.editor-panel {
|
.editor-panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
scroll-margin-top: 65px;
|
scroll-margin-top: 65px;
|
||||||
}
|
}
|
||||||
.materials-editor {
|
|
||||||
border-left: 1px solid var(--line);
|
|
||||||
padding-left: 25px;
|
|
||||||
}
|
|
||||||
.editor-heading {
|
.editor-heading {
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
|
|
@ -591,11 +572,6 @@ summary span {
|
||||||
.stock-input + .text-button {
|
.stock-input + .text-button {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
.drawing-column {
|
|
||||||
min-width: 0;
|
|
||||||
position: static;
|
|
||||||
scroll-margin-top: 65px;
|
|
||||||
}
|
|
||||||
.drawing-card {
|
.drawing-card {
|
||||||
border: 1px solid var(--ink);
|
border: 1px solid var(--ink);
|
||||||
background: white;
|
background: white;
|
||||||
|
|
@ -846,10 +822,6 @@ summary span {
|
||||||
padding-left: max(32px, calc((100vw - 1656px) / 2));
|
padding-left: max(32px, calc((100vw - 1656px) / 2));
|
||||||
padding-right: max(32px, calc((100vw - 1656px) / 2));
|
padding-right: max(32px, calc((100vw - 1656px) / 2));
|
||||||
}
|
}
|
||||||
.workspace {
|
|
||||||
grid-template-columns: minmax(680px, 1fr) minmax(0, 1fr);
|
|
||||||
gap: 35px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@media (max-width: 1199px) {
|
@media (max-width: 1199px) {
|
||||||
.app-main {
|
.app-main {
|
||||||
|
|
@ -858,29 +830,10 @@ summary span {
|
||||||
.app-header {
|
.app-header {
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
}
|
}
|
||||||
.workspace {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 28px;
|
|
||||||
}
|
|
||||||
.input-columns {
|
|
||||||
gap: 30px;
|
|
||||||
}
|
|
||||||
.materials-editor {
|
|
||||||
padding-left: 30px;
|
|
||||||
}
|
|
||||||
.drawing-column {
|
|
||||||
position: static;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
|
|
||||||
gap: 25px;
|
|
||||||
}
|
|
||||||
.purchase-summary {
|
.purchase-summary {
|
||||||
align-self: start;
|
align-self: start;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
.drawing-column > .issues {
|
|
||||||
grid-column: 1/-1;
|
|
||||||
}
|
|
||||||
.cut-list-card {
|
.cut-list-card {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 22px;
|
gap: 22px;
|
||||||
|
|
@ -947,41 +900,6 @@ summary span {
|
||||||
padding: 9px;
|
padding: 9px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.page-jumps {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 0;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 5;
|
|
||||||
background: #fffdf7;
|
|
||||||
padding: 6px 0;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
border-bottom: 1px solid var(--ink);
|
|
||||||
}
|
|
||||||
.page-jumps button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 12px;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.page-jumps button + button {
|
|
||||||
border-left: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
.workspace,
|
|
||||||
.input-columns,
|
|
||||||
.drawing-column {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
gap: 28px;
|
|
||||||
}
|
|
||||||
.materials-editor {
|
|
||||||
border-left: 0;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
.editor-heading {
|
.editor-heading {
|
||||||
min-height: 58px;
|
min-height: 58px;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
|
|
@ -1248,6 +1166,7 @@ summary span {
|
||||||
}
|
}
|
||||||
.stock-cut-group h4 {
|
.stock-cut-group h4 {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
@ -1262,18 +1181,39 @@ summary span {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
.board-cuts {
|
.board-cuts {
|
||||||
padding: 0 16px;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
.board-cut {
|
.board-cut {
|
||||||
|
flex: 1 1 170px;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 16px;
|
align-items: flex-start;
|
||||||
padding: 12px 0;
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e0e4e9;
|
||||||
}
|
}
|
||||||
.board-cut + .board-cut {
|
.board-cut:only-child {
|
||||||
border-top: 1px solid #e0e4e9;
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
@media (max-width: 699px) {
|
||||||
|
.board-cuts {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.board-cut + .board-cut {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.board-cut {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.cut-size {
|
.cut-size {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
@ -1386,9 +1326,13 @@ summary span {
|
||||||
color: #17263f;
|
color: #17263f;
|
||||||
}
|
}
|
||||||
.app-header,
|
.app-header,
|
||||||
.app-main {
|
.app-main,
|
||||||
|
.cut-dialog {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
.cut-dialog::backdrop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.print-plan {
|
.print-plan {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
@ -1504,3 +1448,332 @@ summary span {
|
||||||
-webkit-print-color-adjust: exact;
|
-webkit-print-color-adjust: exact;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(370px, 0.85fr) minmax(0, 1.15fr);
|
||||||
|
gap: 28px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.planning-panels {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.planner-panel {
|
||||||
|
border-top: 2px solid var(--ink);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.panel-toggle-heading {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.panel-toggle {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 0;
|
||||||
|
border: 0;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.panel-toggle > span:last-child {
|
||||||
|
font-size: 25px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.panel-content {
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
.panel-content[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.planner-panel .editor-heading {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.planner-panel .materials-editor {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.planner-panel .cut-list-card {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.planner-panel .cut-list-header {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
align-items: start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.planner-panel .cut-list-header h2 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.planner-panel .cut-list-header .helper {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.planner-panel #cut-plan {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.planner-panel .purchase-summary {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.drawing-column {
|
||||||
|
position: sticky;
|
||||||
|
top: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
align-self: start;
|
||||||
|
background: #fffdf7;
|
||||||
|
}
|
||||||
|
.drawing-column .drawing-viewport {
|
||||||
|
height: clamp(180px, calc(100dvh - 330px), 780px);
|
||||||
|
}
|
||||||
|
.drawing-column:has(.plan-search-status) .drawing-viewport {
|
||||||
|
height: clamp(160px, calc(100dvh - 390px), 720px);
|
||||||
|
}
|
||||||
|
.drawing-column .room-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.drawing-column .drawing-header h2 small {
|
||||||
|
display: block;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
.plan-search-status {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--ink);
|
||||||
|
border-top: 0;
|
||||||
|
background: white;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.matching-rooms {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
.matching-rooms button {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.matching-rooms button[aria-pressed='true'] {
|
||||||
|
background: var(--ink);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.search-muted {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
.is-highlighted > polygon {
|
||||||
|
fill: #ffe69b;
|
||||||
|
stroke: #17263f;
|
||||||
|
stroke-width: 3px;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
.is-highlighted.is-done > polygon {
|
||||||
|
fill: #c4e6df;
|
||||||
|
}
|
||||||
|
.is-done .cut-preview-code {
|
||||||
|
color: #20594f;
|
||||||
|
}
|
||||||
|
.is-done .cut-preview-button {
|
||||||
|
background: #eaf4f1;
|
||||||
|
border-color: #27655d;
|
||||||
|
}
|
||||||
|
.is-done .cut-location {
|
||||||
|
color: #345c55;
|
||||||
|
}
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.app-main {
|
||||||
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
.workspace {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.planning-panels {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.drawing-column {
|
||||||
|
order: -1;
|
||||||
|
width: 100%;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.drawing-column .drawing-viewport,
|
||||||
|
.drawing-column:has(.plan-search-status) .drawing-viewport {
|
||||||
|
height: clamp(150px, 25dvh, 240px);
|
||||||
|
}
|
||||||
|
.drawing-column .drawing-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.drawing-column .drawing-header h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.drawing-column .drawing-header h2 small {
|
||||||
|
display: inline;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.drawing-column .drawing-legend {
|
||||||
|
padding: 0 12px 7px;
|
||||||
|
font-size: 10px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.drawing-column .drawing-help {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.plan-search-status {
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.panel-toggle {
|
||||||
|
font-size: 18px;
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
.planner-panel {
|
||||||
|
scroll-margin-top: 330px;
|
||||||
|
}
|
||||||
|
.board-cuts {
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
.board-cut {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
button,
|
||||||
|
summary {
|
||||||
|
scroll-margin-top: 350px;
|
||||||
|
scroll-margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
.drawing-column {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.wall-head,
|
||||||
|
.wall-row {
|
||||||
|
grid-template-columns: 32px minmax(0, 1fr) minmax(0, 0.8fr);
|
||||||
|
}
|
||||||
|
.wall-head > span:last-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.wall-row {
|
||||||
|
padding-bottom: 7px;
|
||||||
|
border-bottom: 1px solid #dde2e9;
|
||||||
|
}
|
||||||
|
.wall-actions {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.wall-actions .icon-button {
|
||||||
|
width: 40px;
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.wall-drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.wall-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
.wall-row.drag-over {
|
||||||
|
border-top: 3px solid var(--blue);
|
||||||
|
background: #e9f0fc;
|
||||||
|
}
|
||||||
|
.wall-row input {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.cut-search {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.cut-search label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.cut-search .search-results {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4b5c73;
|
||||||
|
}
|
||||||
|
.board-group-header {
|
||||||
|
background: #f1f3f6;
|
||||||
|
}
|
||||||
|
.board-group-header h4 {
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
.board-group-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 12px 8px;
|
||||||
|
}
|
||||||
|
.board-done-control {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 44px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.board-done-control input {
|
||||||
|
width: 22px;
|
||||||
|
min-width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: #27655d;
|
||||||
|
}
|
||||||
|
.board-locate {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
.cut-locate {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
.board-cut-info {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.board-cut.search-match {
|
||||||
|
box-shadow: inset 3px 0 #17263f;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
.stock-cut-group.is-done .board-group-header {
|
||||||
|
background: #e4efeb;
|
||||||
|
}
|
||||||
|
.stock-cut-group.is-done h4 {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.uncut-board-note {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cut-search-control {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.cut-search-control input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.clear-search {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
.completion-mark {
|
||||||
|
pointer-events: none;
|
||||||
|
paint-order: stroke;
|
||||||
|
stroke: white;
|
||||||
|
stroke-width: 2px;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
|
||||||
200
tests/board-tracking.test.ts
Normal file
200
tests/board-tracking.test.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { defaultProject, rectangle } from '../src/lib/defaults';
|
||||||
|
import { boardSearchLabel, findBoardMatches } from '../src/lib/board-tracking';
|
||||||
|
import { CutList } from '../src/components/CutList';
|
||||||
|
import type { FlooringPiece, FlooringPlan, SkirtingPiece, SkirtingPlan } from '../src/lib/types';
|
||||||
|
|
||||||
|
const project = defaultProject();
|
||||||
|
project.rooms.push(rectangle('room-2', 'Office'));
|
||||||
|
function floorPiece(id: string, stockId: string, roomId = 'room-1', cut = true): FlooringPiece {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
stockId,
|
||||||
|
roomId,
|
||||||
|
cut,
|
||||||
|
length: cut ? 400 : 1200,
|
||||||
|
width: 190,
|
||||||
|
row: 1,
|
||||||
|
column: 1,
|
||||||
|
polygon: [],
|
||||||
|
template: [],
|
||||||
|
shaped: false,
|
||||||
|
reused: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const flooring: FlooringPlan = {
|
||||||
|
pieces: [
|
||||||
|
floorPiece('F1', 'B1'),
|
||||||
|
floorPiece('F12', 'B1', 'room-2'),
|
||||||
|
floorPiece('F10', 'B10'),
|
||||||
|
floorPiece('FU1', 'B3', 'room-2', false),
|
||||||
|
],
|
||||||
|
rows: [],
|
||||||
|
stocks: [
|
||||||
|
{ id: 'B1', pieceIds: ['F1', 'F12'], remaining: 394 },
|
||||||
|
{ id: 'B10', pieceIds: ['F10'], remaining: 797 },
|
||||||
|
{ id: 'B3', pieceIds: ['FU1'], remaining: 0 },
|
||||||
|
],
|
||||||
|
boardCount: 3,
|
||||||
|
cuts: 3,
|
||||||
|
wasteArea: 0,
|
||||||
|
wastePercent: 0,
|
||||||
|
reusedCount: 0,
|
||||||
|
coveredArea: 0,
|
||||||
|
issues: [],
|
||||||
|
};
|
||||||
|
function skirtPiece(id: string, stockId: string, roomId = 'room-1', length = 900): SkirtingPiece {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
stockId,
|
||||||
|
roomId,
|
||||||
|
length,
|
||||||
|
backLength: length,
|
||||||
|
wallId: 'w1',
|
||||||
|
wallIndex: 0,
|
||||||
|
start: { x: 0, y: 0 },
|
||||||
|
end: { x: length, y: 0 },
|
||||||
|
leftMitre: 0,
|
||||||
|
rightMitre: 0,
|
||||||
|
leftType: 'square',
|
||||||
|
rightType: 'square',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const skirting: SkirtingPlan = {
|
||||||
|
pieces: [
|
||||||
|
skirtPiece('A2', 'S6'),
|
||||||
|
skirtPiece('B1', 'S6', 'room-2', 1497),
|
||||||
|
skirtPiece('B10', 'S60'),
|
||||||
|
skirtPiece('SU1', 'S7', 'room-2', 2400),
|
||||||
|
],
|
||||||
|
stocks: [
|
||||||
|
{ id: 'S6', length: 2400, used: 2397, waste: 3, pieceIds: ['A2', 'B1'] },
|
||||||
|
{ id: 'S60', length: 2400, used: 900, waste: 1500, pieceIds: ['B10'] },
|
||||||
|
{ id: 'S7', length: 2400, used: 2400, waste: 0, pieceIds: ['SU1'] },
|
||||||
|
],
|
||||||
|
stockCount: 3,
|
||||||
|
cuts: 3,
|
||||||
|
waste: 1503,
|
||||||
|
issues: [],
|
||||||
|
};
|
||||||
|
const find = (query: string) => findBoardMatches(project, flooring, skirting, query);
|
||||||
|
const noop = () => {};
|
||||||
|
function list(search = '', completedBoards: string[] = []) {
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
createElement(CutList, {
|
||||||
|
project,
|
||||||
|
flooring,
|
||||||
|
skirting,
|
||||||
|
select: noop,
|
||||||
|
search,
|
||||||
|
setSearch: noop,
|
||||||
|
completedBoards,
|
||||||
|
toggleBoard: noop,
|
||||||
|
locateBoard: noop,
|
||||||
|
locatePiece: noop,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('board tracking search', () => {
|
||||||
|
it('matches cut and stock codes exactly instead of confusing F1 with F10/F12 or B1 with B10', () => {
|
||||||
|
expect(find(' F1 ')).toEqual({
|
||||||
|
pieceIds: ['F1'],
|
||||||
|
roomIds: ['room-1'],
|
||||||
|
boardKeys: ['floor:B1'],
|
||||||
|
});
|
||||||
|
expect(find('b1')).toEqual({
|
||||||
|
pieceIds: ['F1', 'F12', 'B1'],
|
||||||
|
roomIds: ['room-1', 'room-2'],
|
||||||
|
boardKeys: ['floor:B1', 'skirting:S6'],
|
||||||
|
});
|
||||||
|
expect(find('S6').pieceIds).toEqual(['A2', 'B1']);
|
||||||
|
expect(find('B10').pieceIds).toEqual(['F10', 'B10']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses material-qualified source keys for unambiguous board location and includes all its rooms', () => {
|
||||||
|
expect(find(' FLOOR:B1 ')).toEqual({
|
||||||
|
pieceIds: ['F1', 'F12'],
|
||||||
|
roomIds: ['room-1', 'room-2'],
|
||||||
|
boardKeys: ['floor:B1'],
|
||||||
|
});
|
||||||
|
expect(find('skirting:S6')).toEqual({
|
||||||
|
pieceIds: ['A2', 'B1'],
|
||||||
|
roomIds: ['room-1', 'room-2'],
|
||||||
|
boardKeys: ['skirting:S6'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds full uncut boards as well as cuts, and searches room names without case sensitivity', () => {
|
||||||
|
expect(find('b3')).toEqual({ pieceIds: ['FU1'], roomIds: ['room-2'], boardKeys: ['floor:B3'] });
|
||||||
|
expect(find('s7')).toEqual({
|
||||||
|
pieceIds: ['SU1'],
|
||||||
|
roomIds: ['room-2'],
|
||||||
|
boardKeys: ['skirting:S7'],
|
||||||
|
});
|
||||||
|
expect(find(' OfFiCe ')).toEqual({
|
||||||
|
pieceIds: ['F12', 'FU1', 'B1', 'SU1'],
|
||||||
|
roomIds: ['room-2'],
|
||||||
|
boardKeys: ['floor:B1', 'floor:B3', 'skirting:S6', 'skirting:S7'],
|
||||||
|
});
|
||||||
|
expect(find('living').roomIds).toEqual(['room-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty matches for empty or unknown queries and never mutates the plans', () => {
|
||||||
|
const before = structuredClone({ project, flooring, skirting });
|
||||||
|
expect(find(' ')).toEqual({ pieceIds: [], roomIds: [], boardKeys: [] });
|
||||||
|
expect(find('missing')).toEqual({ pieceIds: [], roomIds: [], boardKeys: [] });
|
||||||
|
expect(find('B1')).toEqual(find('B1'));
|
||||||
|
expect({ project, flooring, skirting }).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps all cuts from a matching source together and applies completion to the whole group', () => {
|
||||||
|
const markup = list('F1', ['floor:B1']);
|
||||||
|
expect(markup).toContain('data-board-key="floor:B1"');
|
||||||
|
expect(markup).not.toContain('data-board-key="floor:B10"');
|
||||||
|
expect(markup).not.toContain('data-board-key="skirting:S6"');
|
||||||
|
expect(markup).toContain('Preview flooring cut F1');
|
||||||
|
expect(markup).toContain('Preview flooring cut F12');
|
||||||
|
expect(markup.match(/class="board-cut is-done/g)).toHaveLength(2);
|
||||||
|
expect(markup).toContain('aria-label="Mark cuts F1 + F12 done" checked=""');
|
||||||
|
expect(markup).toContain('Show cuts F1 + F12 on plan');
|
||||||
|
expect(markup).toContain('Show cut F12 on plan');
|
||||||
|
expect(markup).toContain('<span>Done</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names board groups and actions using their cuts while keeping bookkeeping IDs internal', () => {
|
||||||
|
const markup = list();
|
||||||
|
expect(markup).toContain('aria-label="Flooring cuts F1 + F12"');
|
||||||
|
expect(markup).toContain('aria-label="Skirting cuts A2 + B1"');
|
||||||
|
expect(markup).toContain('Mark cuts A2 + B1 done');
|
||||||
|
expect(markup).toContain('data-board-key="skirting:S6"');
|
||||||
|
expect(markup).toContain('Find a cut or room');
|
||||||
|
expect(markup).not.toMatch(/(?:Floorboard B\d|Skirting board S\d|Export CSV)/i);
|
||||||
|
expect(markup).not.toMatch(/aria-label="[^"]*(?:floor:B\d|skirting:S\d)/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows cut names when a located board is stored as a qualified source key', () => {
|
||||||
|
expect(boardSearchLabel(project, flooring, skirting, 'floor:B1')).toBe('F1 + F12');
|
||||||
|
expect(boardSearchLabel(project, flooring, skirting, 'skirting:S6')).toBe('A2 + B1');
|
||||||
|
expect(boardSearchLabel(project, flooring, skirting, 'Office')).toBe('Office');
|
||||||
|
expect(list('floor:B1')).toContain('value="F1 + F12"');
|
||||||
|
expect(list('skirting:S6')).toContain('value="A2 + B1"');
|
||||||
|
expect(find('F1 + F12')).toEqual(find('floor:B1'));
|
||||||
|
// B1 here is a skirting cut, not the hidden flooring stock with the same internal code.
|
||||||
|
expect(find('A2 + B1')).toEqual(find('skirting:S6'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes an interactive full-board result only when searched, without inventing a cut preview', () => {
|
||||||
|
expect(list()).not.toContain('data-board-key="floor:B3"');
|
||||||
|
const markup = list('floor:B3');
|
||||||
|
expect(markup).toContain('data-board-key="floor:B3"');
|
||||||
|
expect(markup).toContain('Mark uncut floorboard done');
|
||||||
|
expect(markup).toContain('Show uncut floorboard on plan');
|
||||||
|
expect(markup).toContain('Installed uncut');
|
||||||
|
expect(markup).toContain('value="Uncut floorboard"');
|
||||||
|
expect(markup).not.toContain('Preview flooring cut FU1');
|
||||||
|
expect(list('skirting:S7')).toContain('value="Uncut skirting board"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,12 +4,8 @@ import { describe, expect, it } from 'vitest';
|
||||||
import { FlooringPreview, SkirtingPreview } from '../src/components/CutPreview';
|
import { FlooringPreview, SkirtingPreview } from '../src/components/CutPreview';
|
||||||
import { CutDialog } from '../src/components/CutDialog';
|
import { CutDialog } from '../src/components/CutDialog';
|
||||||
import { defaultProject } from '../src/lib/defaults';
|
import { defaultProject } from '../src/lib/defaults';
|
||||||
import {
|
import { dimensionedOutline, flooringTemplate, skirtingEndBoxes } from '../src/lib/cut-preview';
|
||||||
dimensionedOutline,
|
import { cutMm, degree } from '../src/lib/format';
|
||||||
exactMm,
|
|
||||||
flooringTemplate,
|
|
||||||
skirtingEndBoxes,
|
|
||||||
} from '../src/lib/cut-preview';
|
|
||||||
import type { FlooringPiece, Point, SkirtingPiece } from '../src/lib/types';
|
import type { FlooringPiece, Point, SkirtingPiece } from '../src/lib/types';
|
||||||
|
|
||||||
const floor: FlooringPiece = {
|
const floor: FlooringPiece = {
|
||||||
|
|
@ -50,7 +46,7 @@ const skirt: SkirtingPiece = {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('cut shape previews', () => {
|
describe('cut shape previews', () => {
|
||||||
it('draws rectangular floor cuts too, keeping their precise measured dimensions', () => {
|
it('keeps precise geometry while rounding displayed cuts up to half millimetres', () => {
|
||||||
const points = flooringTemplate(floor);
|
const points = flooringTemplate(floor);
|
||||||
expect(points).toEqual([
|
expect(points).toEqual([
|
||||||
{ x: 0, y: 0 },
|
{ x: 0, y: 0 },
|
||||||
|
|
@ -61,11 +57,11 @@ describe('cut shape previews', () => {
|
||||||
const markup = renderToStaticMarkup(createElement(FlooringPreview, { piece: floor }));
|
const markup = renderToStaticMarkup(createElement(FlooringPreview, { piece: floor }));
|
||||||
expect(markup).toContain('aria-label="Flooring shape F1"');
|
expect(markup).toContain('aria-label="Flooring shape F1"');
|
||||||
expect(markup).toContain('preserveAspectRatio="xMidYMid meet"');
|
expect(markup).toContain('preserveAspectRatio="xMidYMid meet"');
|
||||||
expect(markup).toContain('600.001 × 190 mm');
|
expect(markup).toContain('600.5 × 190 mm');
|
||||||
expect(markup).toContain('>600.001</text>');
|
expect(markup).toContain('>600.5</text>');
|
||||||
expect(markup).toContain('>190</text>');
|
expect(markup).toContain('>190</text>');
|
||||||
expect(markup.match(/class="dimension-label"/g)).toHaveLength(2);
|
expect(markup.match(/class="dimension-label"/g)).toHaveLength(2);
|
||||||
expect(exactMm(600.001)).toBe('600.001');
|
expect(cutMm(600.001)).toBe('600.5');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps skirting endpoints in original coordinates and gives both ends equal square windows', () => {
|
it('keeps skirting endpoints in original coordinates and gives both ends equal square windows', () => {
|
||||||
|
|
@ -166,9 +162,9 @@ describe('cut shape previews', () => {
|
||||||
expect(preview.dimensions[1].text).toBe('200');
|
expect(preview.dimensions[1].text).toBe('200');
|
||||||
const angle = preview.dimensions[1].angle!;
|
const angle = preview.dimensions[1].angle!;
|
||||||
expect(angle).toBeCloseTo(53.130102, 6);
|
expect(angle).toBeCloseTo(53.130102, 6);
|
||||||
const markedAngle = (Number(exactMm(angle)) * Math.PI) / 180;
|
const markedAngle = (Number(degree(angle).replace('°', '')) * Math.PI) / 180;
|
||||||
expect(200 * Math.cos(markedAngle)).toBeCloseTo(120, 2);
|
expect(Math.abs(200 * Math.cos(markedAngle) - 120)).toBeLessThan(0.5);
|
||||||
expect(200 * Math.sin(markedAngle)).toBeCloseTo(160, 2);
|
expect(Math.abs(200 * Math.sin(markedAngle) - 160)).toBeLessThan(0.5);
|
||||||
expect(preview.dimensions[1].start).toEqual({ x: 800, y: 0 });
|
expect(preview.dimensions[1].start).toEqual({ x: 800, y: 0 });
|
||||||
expect(preview.dimensions[1].end).toEqual({ x: 920, y: 160 });
|
expect(preview.dimensions[1].end).toEqual({ x: 920, y: 160 });
|
||||||
const reversed = dimensionedOutline([...points].reverse());
|
const reversed = dimensionedOutline([...points].reverse());
|
||||||
|
|
@ -181,7 +177,7 @@ describe('cut shape previews', () => {
|
||||||
piece: { ...floor, length: 920, width: 160, shaped: true, template: points },
|
piece: { ...floor, length: 920, width: 160, shaped: true, template: points },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(markup).toContain('>53.13°</tspan>');
|
expect(markup).toContain('>53.1°</tspan>');
|
||||||
expect(markup).toContain('angles from horizontal');
|
expect(markup).toContain('angles from horizontal');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { fireplace } from '../src/lib/defaults';
|
import { fireplace } from '../src/lib/defaults';
|
||||||
import { buildGeometry } from '../src/lib/geometry';
|
import { buildGeometry } from '../src/lib/geometry';
|
||||||
import { dimensionLabels } from '../src/lib/dimension-labels';
|
import { dimensionLabels, wallDimensionBox } from '../src/lib/dimension-labels';
|
||||||
|
import fixture from './fixtures/angled-room.json';
|
||||||
|
import type { Room } from '../src/lib/types';
|
||||||
describe('wall dimensions', () => {
|
describe('wall dimensions', () => {
|
||||||
it('keeps fireplace recess measurements apart without changing measured lengths', () => {
|
it('keeps fireplace recess measurements apart without changing measured lengths', () => {
|
||||||
const geometry = buildGeometry(fireplace('room-1', 'Fireplace'), 10);
|
const geometry = buildGeometry(fireplace('room-1', 'Fireplace'), 10);
|
||||||
|
|
@ -19,3 +21,32 @@ describe('wall dimensions', () => {
|
||||||
expect(dimensionLabels(geometry.edges, 4800, 1)).toEqual(labels);
|
expect(dimensionLabels(geometry.edges, 4800, 1)).toEqual(labels);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression for the supplied 12-wall plan: W12's outward line crossed the old viewport.
|
||||||
|
it('includes the complete diagonal W12 dimension, not only its midpoint label', () => {
|
||||||
|
const room = fixture.project.rooms[0] as Room;
|
||||||
|
const geometry = buildGeometry(room, fixture.project.tolerance);
|
||||||
|
const xs = geometry.vertices.map((p) => p.x),
|
||||||
|
ys = geometry.vertices.map((p) => p.y);
|
||||||
|
const span = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
|
||||||
|
const labels = dimensionLabels(geometry.edges, span, 1);
|
||||||
|
const edge = geometry.edges[11],
|
||||||
|
label = labels[11];
|
||||||
|
const box = wallDimensionBox(edge, label, span);
|
||||||
|
expect(label.text).toBe('W12 · 6,097');
|
||||||
|
const end = {
|
||||||
|
x: edge.end.x + label.normal.x * label.offset,
|
||||||
|
y: edge.end.y + label.normal.y * label.offset,
|
||||||
|
};
|
||||||
|
expect(end.x).toBeLessThan(Math.min(...xs));
|
||||||
|
expect(box.minX).toBeLessThan(end.x);
|
||||||
|
expect(box.maxX).toBeGreaterThan(label.box.maxX - 1e-6);
|
||||||
|
for (const point of [edge.start, edge.end]) {
|
||||||
|
const x = point.x + label.normal.x * label.offset,
|
||||||
|
y = point.y + label.normal.y * label.offset;
|
||||||
|
expect(x).toBeGreaterThanOrEqual(box.minX);
|
||||||
|
expect(x).toBeLessThanOrEqual(box.maxX);
|
||||||
|
expect(y).toBeGreaterThanOrEqual(box.minY);
|
||||||
|
expect(y).toBeLessThanOrEqual(box.maxY);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { defaultProject } from '../src/lib/defaults';
|
|
||||||
import { cutListCsv } from '../src/lib/export';
|
|
||||||
import { flooringCuts, isSkirtingCut, uncutFlooring, uncutSkirting } from '../src/lib/cut-list';
|
|
||||||
import type { FlooringPlan, SkirtingPlan } from '../src/lib/types';
|
|
||||||
|
|
||||||
const floor: FlooringPlan = {
|
|
||||||
pieces: [
|
|
||||||
{
|
|
||||||
id: 'F1',
|
|
||||||
roomId: 'room-1',
|
|
||||||
row: 1,
|
|
||||||
column: 1,
|
|
||||||
polygon: [],
|
|
||||||
length: 450,
|
|
||||||
width: 190,
|
|
||||||
stockId: 'B1',
|
|
||||||
reused: true,
|
|
||||||
cut: true,
|
|
||||||
shaped: true,
|
|
||||||
template: [
|
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{ x: 450, y: 0 },
|
|
||||||
{ x: 450, y: 190 },
|
|
||||||
{ x: 0, y: 100 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
rows: [],
|
|
||||||
stocks: [{ id: 'B1', pieceIds: ['F1'], remaining: 747 }],
|
|
||||||
boardCount: 1,
|
|
||||||
cuts: 2,
|
|
||||||
wasteArea: 0,
|
|
||||||
wastePercent: 0,
|
|
||||||
reusedCount: 1,
|
|
||||||
coveredArea: 65250,
|
|
||||||
issues: [{ severity: 'warning', message: 'Check the shaped edge.' }],
|
|
||||||
};
|
|
||||||
const skirt: SkirtingPlan = {
|
|
||||||
pieces: [
|
|
||||||
{
|
|
||||||
id: 'A1',
|
|
||||||
roomId: 'room-1',
|
|
||||||
wallId: 'room-1-w1',
|
|
||||||
wallIndex: 0,
|
|
||||||
start: { x: 0, y: 0 },
|
|
||||||
end: { x: 1500, y: 0 },
|
|
||||||
length: 1518,
|
|
||||||
backLength: 1500,
|
|
||||||
leftMitre: 0,
|
|
||||||
rightMitre: 45,
|
|
||||||
leftType: 'square',
|
|
||||||
rightType: 'external',
|
|
||||||
stockId: 'S1',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
stocks: [{ id: 'S1', length: 2400, used: 1521, waste: 879, pieceIds: ['A1'] }],
|
|
||||||
stockCount: 1,
|
|
||||||
cuts: 1,
|
|
||||||
waste: 879,
|
|
||||||
issues: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('cut list CSV', () => {
|
|
||||||
it('exports measured cuts, shape coordinates, mitres, board references and purchase counts', () => {
|
|
||||||
const csv = cutListCsv(defaultProject(), floor, skirt);
|
|
||||||
expect(csv.startsWith('\uFEFF"Material"')).toBe(true);
|
|
||||||
expect(csv).toContain('"Flooring","Living room","F1","B1","450","190"');
|
|
||||||
expect(csv).toContain('Reused offcut; Shaped cut');
|
|
||||||
expect(csv).toContain('0.0:0.0 | 450.0:0.0 | 450.0:190.0 | 0.0:100.0');
|
|
||||||
expect(csv).toContain('"Skirting","Living room","A1","S1","1518","18","0","45"');
|
|
||||||
expect(csv).toContain('Back length 1500.0 mm; left square; right external');
|
|
||||||
expect(csv).toContain('Boards to buy; 1 installed pieces; 2 saw cuts');
|
|
||||||
expect(csv).not.toContain('Check the shaped edge.');
|
|
||||||
expect(csv.split('\r\n')).toHaveLength(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('counts uncut full boards and lengths without exporting their codes or source allocations', () => {
|
|
||||||
const project = defaultProject();
|
|
||||||
const wholeFloor = {
|
|
||||||
...floor.pieces[0],
|
|
||||||
id: 'FULL-F',
|
|
||||||
stockId: 'FULL-B',
|
|
||||||
length: 1200,
|
|
||||||
width: 190,
|
|
||||||
cut: false,
|
|
||||||
shaped: false,
|
|
||||||
reused: false,
|
|
||||||
};
|
|
||||||
const readyOffcut = {
|
|
||||||
...floor.pieces[0],
|
|
||||||
id: 'READY-F',
|
|
||||||
length: 747,
|
|
||||||
cut: false,
|
|
||||||
shaped: false,
|
|
||||||
};
|
|
||||||
const floorPlan: FlooringPlan = {
|
|
||||||
...floor,
|
|
||||||
boardCount: 2,
|
|
||||||
pieces: [...floor.pieces, wholeFloor, readyOffcut],
|
|
||||||
stocks: [
|
|
||||||
{ ...floor.stocks[0], pieceIds: ['F1', 'READY-F'], remaining: 0 },
|
|
||||||
{ id: 'FULL-B', pieceIds: ['FULL-F'], remaining: 0 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const wholeSkirt = {
|
|
||||||
...skirt.pieces[0],
|
|
||||||
id: 'FULL-A',
|
|
||||||
stockId: 'FULL-S',
|
|
||||||
length: 2400,
|
|
||||||
backLength: 2400,
|
|
||||||
leftMitre: 0,
|
|
||||||
rightMitre: 0,
|
|
||||||
leftType: 'square' as const,
|
|
||||||
rightType: 'square' as const,
|
|
||||||
};
|
|
||||||
const skirtPlan: SkirtingPlan = {
|
|
||||||
...skirt,
|
|
||||||
stockCount: 2,
|
|
||||||
pieces: [...skirt.pieces, wholeSkirt],
|
|
||||||
stocks: [
|
|
||||||
...skirt.stocks,
|
|
||||||
{ id: 'FULL-S', length: 2400, used: 2400, waste: 0, pieceIds: ['FULL-A'] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const csv = cutListCsv(project, floorPlan, skirtPlan);
|
|
||||||
for (const code of ['FULL-F', 'FULL-B', 'FULL-A', 'FULL-S']) expect(csv).not.toContain(code);
|
|
||||||
expect(csv).toContain('"Flooring uncut boards","","","","1200","190","","","1"');
|
|
||||||
expect(csv).toContain('"Flooring ready offcuts","","","","","","","","1"');
|
|
||||||
expect(csv).toContain('"Skirting uncut lengths","","","","2400","18","","","1"');
|
|
||||||
expect(csv).toContain('"Flooring total","","","","1200","190","","","2"');
|
|
||||||
expect(csv).toContain('"Skirting total","","","","","18","","","2"');
|
|
||||||
expect(csv.split('\r\n').filter((row) => row.startsWith('"Flooring",'))).toHaveLength(2);
|
|
||||||
expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting",'))).toHaveLength(1);
|
|
||||||
expect(csv.split('\r\n').filter((row) => row.startsWith('"Flooring stock",'))).toHaveLength(0);
|
|
||||||
expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting stock",'))).toHaveLength(0);
|
|
||||||
expect(flooringCuts(floorPlan)).toEqual([...floor.pieces, readyOffcut]);
|
|
||||||
expect(csv).toContain('"Flooring","Living room","READY-F","B1","747","190"');
|
|
||||||
expect(csv).toContain('Reused offcut; Use offcut; no new cut');
|
|
||||||
expect(csv).not.toContain('"Flooring stock"');
|
|
||||||
expect(uncutFlooring(floorPlan, project.flooring)).toEqual({ wholeBoards: 1, readyOffcuts: 1 });
|
|
||||||
expect(uncutSkirting(skirtPlan)).toEqual([{ length: 2400, count: 1 }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps full-length mitred pieces and shortened square skirting in the cut list', () => {
|
|
||||||
const mitred = { ...skirt.pieces[0], length: 2400 };
|
|
||||||
const shortened = { ...skirt.pieces[0], leftMitre: 0, rightMitre: 0 };
|
|
||||||
const uncut = { ...shortened, length: 2400 };
|
|
||||||
expect(isSkirtingCut(mitred, skirt)).toBe(true);
|
|
||||||
expect(isSkirtingCut(shortened, skirt)).toBe(true);
|
|
||||||
expect(isSkirtingCut(uncut, skirt)).toBe(false);
|
|
||||||
expect(isSkirtingCut({ ...uncut, stockId: 'missing' }, skirt)).toBe(true);
|
|
||||||
const csv = cutListCsv(defaultProject(), floor, { ...skirt, pieces: [mitred] });
|
|
||||||
expect(csv).toContain('"Skirting","Living room","A1","S1","2400","18","0","45"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('quotes commas, double quotes, newlines and Unicode room names', () => {
|
|
||||||
const project = defaultProject();
|
|
||||||
project.rooms[0].name = 'Café 北, "main"\nroom';
|
|
||||||
const csv = cutListCsv(project, floor, skirt);
|
|
||||||
expect(csv).toContain('"Café 北, ""main""\nroom"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['=SUM(1,1)', '+cmd', '-formula', '@formula'])(
|
|
||||||
'neutralizes spreadsheet formulas in user text %s',
|
|
||||||
(name) => {
|
|
||||||
const project = defaultProject();
|
|
||||||
project.rooms[0].name = name;
|
|
||||||
expect(cutListCsv(project, floor, skirt)).toContain(`"'${name}"`);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('produces identical output for identical input without mutating the plans', () => {
|
|
||||||
const project = defaultProject();
|
|
||||||
const before = structuredClone({ project, floor, skirt });
|
|
||||||
expect(cutListCsv(project, floor, skirt)).toBe(cutListCsv(project, floor, skirt));
|
|
||||||
expect({ project, floor, skirt }).toEqual(before);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
132
tests/fixtures/angled-room-f8.json
vendored
Normal file
132
tests/fixtures/angled-room-f8.json
vendored
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
{
|
||||||
|
"project": {
|
||||||
|
"version": 1,
|
||||||
|
"name": "My flooring project",
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "room-2",
|
||||||
|
"name": "Room 2",
|
||||||
|
"walls": [
|
||||||
|
{
|
||||||
|
"id": "room-2-w1",
|
||||||
|
"length": 4800,
|
||||||
|
"turn": 10,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w2",
|
||||||
|
"length": 3600,
|
||||||
|
"turn": 90,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w3",
|
||||||
|
"length": 1650,
|
||||||
|
"turn": 90,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w4",
|
||||||
|
"length": 450,
|
||||||
|
"turn": -90,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w5",
|
||||||
|
"length": 1500,
|
||||||
|
"turn": -90,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w6",
|
||||||
|
"length": 450,
|
||||||
|
"turn": 90,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w8",
|
||||||
|
"length": 3600,
|
||||||
|
"turn": 90,
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "room-2-door-1",
|
||||||
|
"offset": 500,
|
||||||
|
"width": 826
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w7",
|
||||||
|
"length": 1650,
|
||||||
|
"turn": 90,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w-3",
|
||||||
|
"length": 1000,
|
||||||
|
"turn": -72.12516087806887,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w-1",
|
||||||
|
"length": 1000,
|
||||||
|
"turn": 93.2,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w-2",
|
||||||
|
"length": 1000,
|
||||||
|
"turn": -91.7,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w-4",
|
||||||
|
"length": 6097,
|
||||||
|
"turn": 59.23323117818302,
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "room-2-door-2",
|
||||||
|
"offset": 500,
|
||||||
|
"width": 826
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w-5",
|
||||||
|
"length": 1687,
|
||||||
|
"turn": 90,
|
||||||
|
"doors": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"direction": 90
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"flooring": {
|
||||||
|
"length": 1200,
|
||||||
|
"width": 190,
|
||||||
|
"gap": 10,
|
||||||
|
"pattern": "random",
|
||||||
|
"minOffset": 0.1,
|
||||||
|
"maxOffset": 1000,
|
||||||
|
"minLength": 0.1,
|
||||||
|
"minWidth": 0.1,
|
||||||
|
"kerf": 3
|
||||||
|
},
|
||||||
|
"skirting": {
|
||||||
|
"stockLengths": [
|
||||||
|
2400
|
||||||
|
],
|
||||||
|
"thickness": 27,
|
||||||
|
"minLength": 200,
|
||||||
|
"kerf": 3
|
||||||
|
},
|
||||||
|
"tolerance": 50,
|
||||||
|
"completedBoards": []
|
||||||
|
},
|
||||||
|
"view": {
|
||||||
|
"roomId": "room-2",
|
||||||
|
"selectedPiece": "F8",
|
||||||
|
"search": "F8",
|
||||||
|
"collapsedSections": []
|
||||||
|
}
|
||||||
|
}
|
||||||
115
tests/fixtures/angled-room.json
vendored
Normal file
115
tests/fixtures/angled-room.json
vendored
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
{
|
||||||
|
"project": {
|
||||||
|
"version": 1,
|
||||||
|
"name": "My flooring project",
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "room-2",
|
||||||
|
"name": "Room 2",
|
||||||
|
"walls": [
|
||||||
|
{
|
||||||
|
"id": "room-2-w1",
|
||||||
|
"length": 4800,
|
||||||
|
"turn": 10,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w2",
|
||||||
|
"length": 3600,
|
||||||
|
"turn": 90,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w3",
|
||||||
|
"length": 1650,
|
||||||
|
"turn": 90,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w4",
|
||||||
|
"length": 450,
|
||||||
|
"turn": -90,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w5",
|
||||||
|
"length": 1500,
|
||||||
|
"turn": -90,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w6",
|
||||||
|
"length": 450,
|
||||||
|
"turn": 90,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w7",
|
||||||
|
"length": 1650,
|
||||||
|
"turn": 90,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w8",
|
||||||
|
"length": 3600,
|
||||||
|
"turn": 90,
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "room-2-door-1",
|
||||||
|
"offset": 500,
|
||||||
|
"width": 826
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w-1",
|
||||||
|
"length": 1000,
|
||||||
|
"turn": 93.2,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w-2",
|
||||||
|
"length": 1000,
|
||||||
|
"turn": -91.7,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w-3",
|
||||||
|
"length": 1000,
|
||||||
|
"turn": -72.12516087806887,
|
||||||
|
"doors": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room-2-w-4",
|
||||||
|
"length": 6097,
|
||||||
|
"turn": 90,
|
||||||
|
"doors": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"direction": 90
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"flooring": {
|
||||||
|
"length": 1200,
|
||||||
|
"width": 190,
|
||||||
|
"gap": 10,
|
||||||
|
"pattern": "random",
|
||||||
|
"minOffset": 0.1,
|
||||||
|
"maxOffset": 1000,
|
||||||
|
"minLength": 0.1,
|
||||||
|
"minWidth": 0.1,
|
||||||
|
"kerf": 3
|
||||||
|
},
|
||||||
|
"skirting": {
|
||||||
|
"stockLengths": [2400],
|
||||||
|
"thickness": 27,
|
||||||
|
"minLength": 200,
|
||||||
|
"kerf": 3
|
||||||
|
},
|
||||||
|
"tolerance": 50
|
||||||
|
},
|
||||||
|
"view": {
|
||||||
|
"roomId": "room-2",
|
||||||
|
"selectedPiece": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
|
import f8Fixture from './fixtures/angled-room-f8.json';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import ClipperLib from 'clipper-lib';
|
import ClipperLib from 'clipper-lib';
|
||||||
import { planFlooring } from '../src/lib/flooring';
|
import { planFlooring } from '../src/lib/flooring';
|
||||||
import { buildGeometry, insetPolygon, signedArea } from '../src/lib/geometry';
|
import { buildGeometry, insetPolygon, signedArea } from '../src/lib/geometry';
|
||||||
import { fireplace, rectangle } from '../src/lib/defaults';
|
import { fireplace, rectangle } from '../src/lib/defaults';
|
||||||
import { flooringCuts } from '../src/lib/cut-list';
|
import { flooringCuts } from '../src/lib/cut-list';
|
||||||
import type { FlooringPlan, FlooringSettings, Point, Room } from '../src/lib/types';
|
import type { FlooringPlan, FlooringSettings, Point, Project, Room } from '../src/lib/types';
|
||||||
|
|
||||||
const settings: FlooringSettings = {
|
const settings: FlooringSettings = {
|
||||||
length: 1200,
|
length: 1200,
|
||||||
|
|
@ -367,3 +368,5 @@ describe('laminate planning', () => {
|
||||||
expect(errors(planFlooring([valid], { ...settings, width: 0 }, 1))[0].roomId).toBeUndefined();
|
expect(errors(planFlooring([valid], { ...settings, width: 0 }, 1))[0].roomId).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('temporary F8 audit',()=>{const project=f8Fixture.project as Project;const plan=planFlooring(project.rooms,project.flooring,project.tolerance);const atCorner=plan.pieces.filter(p=>p.polygon.some(v=>Math.hypot(v.x-7607.513,v.y-2160.340)<0.01));throw new Error(JSON.stringify({atCorner,first:plan.rows[0],last:plan.rows.at(-1),boards:plan.boardCount,cuts:plan.cuts,area:plan.coveredArea,insetArea:insetPolygon(buildGeometry(project.rooms[0],project.tolerance).vertices,10).reduce((a,p)=>a+Math.abs(signedArea(p)),0)}));});
|
||||||
|
|
|
||||||
18
tests/format.test.ts
Normal file
18
tests/format.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { cutMm } from '../src/lib/format';
|
||||||
|
|
||||||
|
describe('cut dimensions', () => {
|
||||||
|
it.each([
|
||||||
|
[0, '0'],
|
||||||
|
[89.423, '89.5'],
|
||||||
|
[177.163, '177.5'],
|
||||||
|
[488.648, '489'],
|
||||||
|
[545.948, '546'],
|
||||||
|
[600.5, '600.5'],
|
||||||
|
[600.50000000001, '600.5'],
|
||||||
|
[1200, '1,200'],
|
||||||
|
[NaN, '—'],
|
||||||
|
])('rounds %s upward to a useful half-millimetre mark', (length, shown) => {
|
||||||
|
expect(cutMm(length)).toBe(shown);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { planSkirting } from '../src/lib/skirting';
|
import { planSkirting } from '../src/lib/skirting';
|
||||||
import { skirtingCuts } from '../src/lib/cut-list';
|
import { skirtingCuts } from '../src/lib/cut-list';
|
||||||
|
import { cutLength } from '../src/lib/format';
|
||||||
import type { Room, SkirtingSettings } from '../src/lib/types';
|
import type { Room, SkirtingSettings } from '../src/lib/types';
|
||||||
|
|
||||||
const settings: SkirtingSettings = { stockLengths: [2400], thickness: 18, minLength: 300, kerf: 3 };
|
const settings: SkirtingSettings = { stockLengths: [2400], thickness: 18, minLength: 300, kerf: 3 };
|
||||||
|
|
@ -146,6 +147,22 @@ describe('skirting cutting plan', () => {
|
||||||
expect(plan.cuts).toBe(4);
|
expect(plan.cuts).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reserves the displayed half-millimetre blanks so rounded cuts fit their source', () => {
|
||||||
|
const plan = planSkirting(
|
||||||
|
[rectangle(1198.4, 1198.6)],
|
||||||
|
{ ...settings, thickness: 0, minLength: 1100 },
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||||
|
expect(plan.stockCount).toBe(3);
|
||||||
|
for (const stock of plan.stocks) {
|
||||||
|
const pieces = stock.pieceIds.map((id) => plan.pieces.find((piece) => piece.id === id)!);
|
||||||
|
const marked = pieces.reduce((sum, piece) => sum + cutLength(piece.length), 0);
|
||||||
|
expect(marked + (pieces.length - 1) * settings.kerf).toBeLessThanOrEqual(stock.length);
|
||||||
|
}
|
||||||
|
expect(plan.pieces.reduce((sum, piece) => sum + piece.length, 0)).toBeCloseTo(4794);
|
||||||
|
});
|
||||||
|
|
||||||
it('looks ahead when choosing from different stock lengths', () => {
|
it('looks ahead when choosing from different stock lengths', () => {
|
||||||
const plan = planSkirting(
|
const plan = planSkirting(
|
||||||
[rectangle(1200, 1200)],
|
[rectangle(1200, 1200)],
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ describe('URL state', () => {
|
||||||
state.project.rooms[0].walls[0].doors = [{ id: 'wide-door', offset: 123.75, width: 1500 }];
|
state.project.rooms[0].walls[0].doors = [{ id: 'wide-door', offset: 123.75, width: 1500 }];
|
||||||
state.project.flooring.pattern = 'random';
|
state.project.flooring.pattern = 'random';
|
||||||
state.project.skirting.stockLengths = [2400, 3600, 4800];
|
state.project.skirting.stockLengths = [2400, 3600, 4800];
|
||||||
state.view = { roomId: 'room-2', selectedPiece: 'A12' };
|
state.view = { ...state.view, roomId: 'room-2', selectedPiece: 'A12' };
|
||||||
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -64,8 +64,13 @@ describe('URL state', () => {
|
||||||
const decoded = decodeState(rawLink(old));
|
const decoded = decodeState(rawLink(old));
|
||||||
expect(decoded.error).toBeNull();
|
expect(decoded.error).toBeNull();
|
||||||
expect(decoded.state.project).toEqual(state.project);
|
expect(decoded.state.project).toEqual(state.project);
|
||||||
expect(decoded.state.view).toEqual({ roomId: state.view.roomId, selectedPiece: 'A4' });
|
expect(decoded.state.view).toEqual({ ...state.view, selectedPiece: 'A4' });
|
||||||
expect(Object.keys(decoded.state.view).sort()).toEqual(['roomId', 'selectedPiece']);
|
expect(Object.keys(decoded.state.view).sort()).toEqual([
|
||||||
|
'collapsedSections',
|
||||||
|
'roomId',
|
||||||
|
'search',
|
||||||
|
'selectedPiece',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('canonicalizes property order and discards derived or unknown fields', () => {
|
it('canonicalizes property order and discards derived or unknown fields', () => {
|
||||||
|
|
@ -341,7 +346,7 @@ describe('URL state', () => {
|
||||||
const state = defaultState();
|
const state = defaultState();
|
||||||
state.project.rooms.push(fireplace('second', 'Fireplace room'));
|
state.project.rooms.push(fireplace('second', 'Fireplace room'));
|
||||||
state.project.skirting.stockLengths = [2400, 3600];
|
state.project.skirting.stockLengths = [2400, 3600];
|
||||||
state.view = { roomId: 'second', selectedPiece: 'B2' };
|
state.view = { ...state.view, roomId: 'second', selectedPiece: 'B2' };
|
||||||
const legacy = decodeState(rawLink(state));
|
const legacy = decodeState(rawLink(state));
|
||||||
expect(legacy.error).toBeNull();
|
expect(legacy.error).toBeNull();
|
||||||
const query = encodeState(legacy.state);
|
const query = encodeState(legacy.state);
|
||||||
|
|
@ -350,6 +355,124 @@ describe('URL state', () => {
|
||||||
expect(decodeState(query)).toEqual({ state, error: null });
|
expect(decodeState(query)).toEqual({ state, error: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores progress and view preferences as optional readable fields', () => {
|
||||||
|
const state = defaultState();
|
||||||
|
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
|
||||||
|
state.view.search = 'F12';
|
||||||
|
state.view.collapsedSections = ['materials', 'measurements'];
|
||||||
|
const query = encodeState(state);
|
||||||
|
expect(query).toContain('done=floor:B4&done=skirting:S6');
|
||||||
|
expect(query).toContain('search=F12&collapsed=materials&collapsed=measurements');
|
||||||
|
expect(decodeState(query)).toEqual({ state, error: null });
|
||||||
|
const empty = new URLSearchParams(encodeState(defaultState()));
|
||||||
|
for (const key of ['done', 'search', 'collapsed']) expect(empty.has(key)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canonicalizes completion and collapse sets without mutating their inputs', () => {
|
||||||
|
const state = defaultState();
|
||||||
|
state.project.completedBoards = ['skirting:S6', 'floor:B4'];
|
||||||
|
state.view.collapsedSections = ['measurements', 'cuts', 'materials'];
|
||||||
|
const before = structuredClone(state);
|
||||||
|
const query = encodeState(state);
|
||||||
|
const loaded = decodeState(query);
|
||||||
|
expect(loaded.error).toBeNull();
|
||||||
|
expect(loaded.state.project.completedBoards).toEqual(['floor:B4', 'skirting:S6']);
|
||||||
|
expect(loaded.state.view.collapsedSections).toEqual(['cuts', 'materials', 'measurements']);
|
||||||
|
expect(state).toEqual(before);
|
||||||
|
state.project.completedBoards.reverse();
|
||||||
|
state.view.collapsedSections.reverse();
|
||||||
|
expect(encodeState(state)).toBe(query);
|
||||||
|
expect(encodeState(loaded.state)).toBe(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads old query and fragment links with empty progress and view preferences', () => {
|
||||||
|
const state = defaultState();
|
||||||
|
const old = {
|
||||||
|
project: { ...state.project, completedBoards: undefined },
|
||||||
|
view: { roomId: state.view.roomId, selectedPiece: null },
|
||||||
|
};
|
||||||
|
expect(decodeState(rawLink(old))).toEqual({ state, error: null });
|
||||||
|
expect(decodeState('?flooring.length=1200').state).toEqual(state);
|
||||||
|
const first = defaultState();
|
||||||
|
first.project.completedBoards.push('floor:B4');
|
||||||
|
first.view.collapsedSections.push('materials');
|
||||||
|
expect(defaultState().project.completedBoards).toEqual([]);
|
||||||
|
expect(defaultState().view.collapsedSections).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves Unicode and spaces in the search input independently of selected cuts', () => {
|
||||||
|
const state = defaultState();
|
||||||
|
state.view.search = ' Café 北 F12 / A2 ';
|
||||||
|
state.view.selectedPiece = 'F12';
|
||||||
|
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
||||||
|
expectInvalid('?search=F1&search=F2', /only once/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'',
|
||||||
|
'B4',
|
||||||
|
'floor:S4',
|
||||||
|
'skirting:B4',
|
||||||
|
'floor:B0',
|
||||||
|
'floor:B04',
|
||||||
|
'floor:B-1',
|
||||||
|
'floor:B1.5',
|
||||||
|
])('rejects invalid completed-board key %s', (key) => {
|
||||||
|
expectInvalid(`?done=${encodeURIComponent(key)}`, /floor:B1 or skirting:S1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate completion and collapse entries rather than counting them twice', () => {
|
||||||
|
expectInvalid('?done=floor:B4&done=floor:B4', /duplicate entries/);
|
||||||
|
expectInvalid('?collapsed=materials&collapsed=materials', /duplicate entries/);
|
||||||
|
const state = defaultState();
|
||||||
|
state.project.completedBoards = ['floor:B4', 'floor:B4'];
|
||||||
|
expect(() => encodeState(state)).toThrow(/duplicate entries/);
|
||||||
|
const distinct = decodeState('?done=floor:B1&done=skirting:S1');
|
||||||
|
expect(distinct.error).toBeNull();
|
||||||
|
expect(distinct.state.project.completedBoards).toEqual(['floor:B1', 'skirting:S1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds the search, progress list and collapsed sections', () => {
|
||||||
|
expectInvalid(`?search=${'a'.repeat(URL_LIMITS.searchLength + 1)}`, /at most 200 characters/);
|
||||||
|
expectInvalid('?collapsed=outline', /unsupported value/);
|
||||||
|
expectInvalid(
|
||||||
|
'?collapsed=cuts&collapsed=materials&collapsed=measurements&collapsed=cuts',
|
||||||
|
/at most 3/,
|
||||||
|
);
|
||||||
|
const state = defaultState();
|
||||||
|
state.project.completedBoards = Array.from(
|
||||||
|
{ length: URL_LIMITS.completedBoards + 1 },
|
||||||
|
(_, index) => `floor:B${index + 1}`,
|
||||||
|
);
|
||||||
|
expect(() => encodeState(state)).toThrow(/at most 12000/);
|
||||||
|
expectInvalid(rawLink(state), /at most 12000/);
|
||||||
|
expectInvalid(
|
||||||
|
`?done=floor:B${'1'.repeat(URL_LIMITS.identifierLength)}`,
|
||||||
|
/at most 120 characters/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([null, true, 'floor:B4', [false], [{}]])(
|
||||||
|
'rejects incorrectly typed completed-board sets %j',
|
||||||
|
(completedBoards) => {
|
||||||
|
const state = defaultState();
|
||||||
|
expectInvalid(rawLink({ ...state, project: { ...state.project, completedBoards } }));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([null, 42, [], {}])('rejects incorrectly typed search input %j', (search) => {
|
||||||
|
const state = defaultState();
|
||||||
|
expectInvalid(rawLink({ ...state, view: { ...state.view, search } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([null, true, 'materials', [42], ['outline']])(
|
||||||
|
'rejects incorrectly typed or unsupported collapsed sections %j',
|
||||||
|
(collapsedSections) => {
|
||||||
|
const state = defaultState();
|
||||||
|
expectInvalid(rawLink({ ...state, view: { ...state.view, collapsedSections } }));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('rejects oversized query strings before parsing fields', () => {
|
it('rejects oversized query strings before parsing fields', () => {
|
||||||
expectInvalid(`?name=${'x'.repeat(URL_LIMITS.queryLength)}`, /link is too large/);
|
expectInvalid(`?name=${'x'.repeat(URL_LIMITS.queryLength)}`, /link is too large/);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
80
tests/wall-editing.test.ts
Normal file
80
tests/wall-editing.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { rectangle } from '../src/lib/defaults';
|
||||||
|
import { insertWallAfter, moveWall } from '../src/lib/wall-editing';
|
||||||
|
|
||||||
|
const ids = (room: ReturnType<typeof rectangle>) => room.walls.map((wall) => wall.id);
|
||||||
|
|
||||||
|
describe('inserting walls', () => {
|
||||||
|
it.each([0, 1, 3])(
|
||||||
|
'inserts after wall index %i without changing existing measurements or doors',
|
||||||
|
(index) => {
|
||||||
|
const room = rectangle();
|
||||||
|
const original = structuredClone(room);
|
||||||
|
const inserted = insertWallAfter(room, room.walls[index].id);
|
||||||
|
expect(inserted.walls).toHaveLength(room.walls.length + 1);
|
||||||
|
expect(inserted.walls[index + 1]).toMatchObject({ length: 1000, turn: 90, doors: [] });
|
||||||
|
expect(new Set(ids(inserted)).size).toBe(inserted.walls.length);
|
||||||
|
expect(inserted.walls.filter((wall) => room.walls.includes(wall))).toEqual(room.walls);
|
||||||
|
for (const wall of room.walls)
|
||||||
|
expect(inserted.walls.find((candidate) => candidate.id === wall.id)).toBe(wall);
|
||||||
|
expect(room).toEqual(original);
|
||||||
|
expect(inserted.id).toBe(room.id);
|
||||||
|
expect(inserted.name).toBe(room.name);
|
||||||
|
expect(inserted.direction).toBe(room.direction);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('assigns deterministic, unique IDs when repeatedly inserting in the same position', () => {
|
||||||
|
const room = rectangle();
|
||||||
|
const first = insertWallAfter(room, room.walls[0].id);
|
||||||
|
const second = insertWallAfter(first, room.walls[0].id);
|
||||||
|
expect(insertWallAfter(room, room.walls[0].id)).toEqual(first);
|
||||||
|
expect(new Set(ids(second)).size).toBe(6);
|
||||||
|
expect(second.walls[2]).toBe(first.walls[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an unknown wall and the wall-count limit without creating replacement state', () => {
|
||||||
|
const room = rectangle();
|
||||||
|
expect(insertWallAfter(room, 'missing')).toBe(room);
|
||||||
|
const full = {
|
||||||
|
...room,
|
||||||
|
walls: Array.from({ length: 100 }, (_, i) => ({ ...room.walls[0], id: `wall-${i}` })),
|
||||||
|
};
|
||||||
|
expect(insertWallAfter(full, 'wall-3')).toBe(full);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reordering walls', () => {
|
||||||
|
it.each([
|
||||||
|
[0, 3],
|
||||||
|
[3, 0],
|
||||||
|
[1, 2],
|
||||||
|
[2, 1],
|
||||||
|
])(
|
||||||
|
'moves index %i to index %i while preserving all walls and their attached doors',
|
||||||
|
(from, to) => {
|
||||||
|
const room = rectangle();
|
||||||
|
const original = structuredClone(room);
|
||||||
|
const moved = moveWall(room, room.walls[from].id, to);
|
||||||
|
expect(moved.walls[to]).toBe(room.walls[from]);
|
||||||
|
expect([...ids(moved)].sort()).toEqual([...ids(room)].sort());
|
||||||
|
for (const wall of room.walls) {
|
||||||
|
const result = moved.walls.find((candidate) => candidate.id === wall.id)!;
|
||||||
|
expect(result).toBe(wall);
|
||||||
|
expect(result.doors).toBe(wall.doors);
|
||||||
|
}
|
||||||
|
expect(room).toEqual(original);
|
||||||
|
expect(moveWall(moved, room.walls[from].id, from).walls).toEqual(room.walls);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('clamps destinations and treats missing, unchanged or non-finite positions as no-ops', () => {
|
||||||
|
const room = rectangle();
|
||||||
|
expect(moveWall(room, room.walls[0].id, -10)).toBe(room);
|
||||||
|
expect(moveWall(room, room.walls[0].id, 100).walls.at(-1)).toBe(room.walls[0]);
|
||||||
|
expect(moveWall(room, room.walls[3].id, -10).walls[0]).toBe(room.walls[3]);
|
||||||
|
expect(moveWall(room, 'missing', 2)).toBe(room);
|
||||||
|
expect(moveWall(room, room.walls[1].id, 1)).toBe(room);
|
||||||
|
expect(moveWall(room, room.walls[1].id, Number.NaN)).toBe(room);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue