diff --git a/README.md b/README.md index 8c39634..77c4441 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,10 @@ npm run check # Build, unit tests and browser tests 1. Start with a rectangle, fireplace recess, or custom outline. Enter wall lengths in order. The first wall runs east; the turn **after** each wall is positive for right and negative for left. For example, a clockwise rectangle has 90° turns. The final corner is derived from the closed outline. Insert a wall after any existing wall, drag its handle to reorder it, or use the up/down buttons on a phone or keyboard. Doors stay attached to their wall. Numeric fields allow empty values and a minus sign while typing; unfinished entries revert to the last valid value when you leave the field. 2. Add door openings with a width and a distance from the named starting corner. Each doorway shows the neighbouring wall names and an arrow in the measurement direction. Include architrave/trim in the width where skirting must be omitted. Doors terminate skirting; laminate ends at the room outline with a transition and expansion gap. Rooms are planned independently. 3. For a custom outline, the closing-wall suggestion gives the required length and turn. Add it, then replace the suggested length with the actual measured length. An outline must close within the chosen tolerance and have no intersections or invalid doors before a cut plan can be produced. -4. Set material dimensions, spacer, kerf and minimum piece sizes alongside the measurements. Kerf means material removed by the blade: a 3 mm cut leaves 600 mm + 397 mm from a 1,000 mm board. Both kerf fields have keyboard and touch tooltips. Skirting stock lengths are purchase options, not a fixed inventory. Materials are shared across rooms; direction is per room. -5. Room measurements, materials and cut plans open and close independently on the same page. The drawing stays pinned on the right while you scroll the panels; on phones a compact drawing stays above them. Flooring and skirting are visible together, with the whole drawing fitting without an internal scrollbar. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Flooring and skirting cuts are grouped by the purchased board they come from, including offcuts reused across rooms. Group headings use the cut codes; internal stock IDs stay out of the interface. Pieces wrap side by side when space allows. Each piece has a small preview that opens dimensions directly on its shape. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Numbered skirting pieces and their labels open cut details; uncut lengths use a neutral fill. Print all room drawings followed by flooring and skirting cut sheets grouped by purchased board. Every cut is included once, with its room and row or wall. -6. Find a 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. +4. Set material dimensions, spacer, kerf and minimum piece sizes alongside the measurements. Kerf means material removed by the blade: a 3 mm cut leaves 600 mm + 397 mm from a 1,000 mm board. Both kerf fields have keyboard and touch tooltips. Set one purchased skirting board length for the whole project. Materials are shared across rooms; direction is per room. +5. Room measurements, materials and cut plans open and close independently on the same page. The drawing stays pinned on the right while you scroll the panels; on phones a compact drawing stays above them. Flooring and skirting are visible together, with the whole drawing fitting without an internal scrollbar. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Cut codes sit above the wall and skirting drawing; short pieces use nearby labels with pointers so their names remain visible. The compact drawing header shows only the room, area and material legend. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Flooring and skirting cuts are grouped by the purchased board they come from, including offcuts reused across rooms. Group headings use the cut codes; internal stock IDs stay out of the interface. Pieces wrap side by side when space allows. Each piece has a small preview that opens dimensions directly on its shape. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Numbered skirting pieces and their labels open cut details; uncut lengths use a neutral fill. Print all room drawings followed by flooring and skirting cut sheets grouped by purchased board. Every cut is included once, with its room and row or wall. +6. Find a cut by its code, or search by room name. Searching several cut codes together, such as `F27 + F28`, finds their pieces together. **Show on plan** highlights the source board’s pieces or an individual cut, with room buttons when a source is reused across rooms. A visible **Search active** banner stays above the pinned drawing; **Show all pieces** clears the search there. Source boards have **Mark done** checkboxes; completion marks appear on their list entries, installed pieces and printed sheets. Room search results also let you locate and mark uncut boards without adding cut labels to them. Measurements, wall order, flooring direction or material changes clear completion because the allocation changes; renaming rooms keeps it. +7. Copy the link to save or share the complete project. Measurements, materials and view choices live in readable query parameters such as `flooring.length=1200`, `skirting.length=2400`, `room.1.wall.1.length=4800` and `selectedCut=F1`. Older stock-list links retain their first length; their completion marks are cleared because coping blanks and rounded stock allocation have changed, including for single-size links. Progress uses repeated `done=floor:B4` or `done=skirting:S6` parameters; `search=F1` and repeated `collapsed=materials` parameters preserve the search and closed panels. You can edit values directly in the URL. Older compressed fragment links still load and migrate automatically; obsolete step, layer and zoom choices are ignored. Reloading or opening that link reproduces the plan. Browser back/forward restores room changes. There is no local storage, account, API, database or analytics. Fonts are system fonts; the app makes no external font requests. ## Calculation rules and practical limits @@ -35,9 +35,9 @@ npm run check # Build, unit tests and browser tests - 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. - Offcut reuse preserves the needed left/right factory ends. Only full-width end remnants are available for reuse; ripped side strips and shaped scraps are conservatively waste. Crosscuts include kerf. The stock count is actual allocated boards, not an area-only estimate. Waste includes kerf and unused material, including still-usable stock remainders. -- Skirting internal corners use butt joints: the previous wall runs through and the next is shortened by thickness, adjusted for non-square corners. Coping is excluded. External corners use equal mitres. Minimum sizes apply after corner allowances and door subtraction. An unavoidable undersized run is an error. -- Skirting cut lengths are the longest-point envelope. Cut square blanks first, then bevel their ends as specified; this keeps reusable stock remainders square. Back lengths and diagram positions locate the wall-facing edge. The stock allocation includes crosscut kerf and counts the additional bevel passes. -- Flooring uses a bounded beam search, minimizing purchased boards first. Random layouts then prefer less visual repetition before fewer saw passes; brick layouts prioritize fewer saw passes. This can add cuts to break a staircase without increasing the selected board count. Skirting compares full-stock, balanced and offcut-aware splits and packs them across offered lengths, preferring less waste, then fewer cuts. Available offcuts can move the next wall’s joints. Minimum lengths are reserved on both front and back edges of angled pieces. These are deterministic, auditable heuristics rather than guarantees of a global minimum. Room order is an input and can affect shared-offcut allocation. +- Skirting internal corners use coped joints. The blank keeps the wall-corner long point instead of subtracting the receiving board’s thickness; the cope preparation is half the measured turn. Earlier walls receive adjoining later walls, so installation order has no cycle. At square corners, a 4,000 mm wall keeps a 4,000 mm blank; with 18 mm skirting at both corners its exposed front length is 3,964 mm. Non-square receiving ends have a wall-fitting bevel. External corners use equal mitres. Minimum sizes check the exposed face and installed body after corner and door allowances. Preparation previews and dashed body edges guide marking; the actual moulding profile must be scribed and back-cut, since thickness alone does not define it. This follows [Gary Katz’s coping and long-point measurement guidance](https://www.finehomebuilding.com/pdf/021174063.pdf); the general-angle allowances are derived from intersecting wall faces. +- Skirting cut lengths are the longest-point envelope. Cut square blanks first, then prepare the mitres and cope the indicated internal ends; this keeps reusable stock remainders square. Back lengths and diagram positions locate the wall-facing edge. The stock allocation includes crosscut kerf and counts additional bevel and coping operations. +- Flooring uses a bounded beam search, minimizing purchased boards first. Random layouts then prefer less visual repetition before fewer saw passes; brick layouts prioritize fewer saw passes. This can add cuts to break a staircase without increasing the selected board count. Skirting compares full-stock, balanced and offcut-aware splits and packs them into the single purchased length, preferring less waste, then fewer cuts. Available offcuts can move the next wall’s joints. Minimum lengths are reserved on the exposed face and full-thickness body of angled and coped pieces. These are deterministic, auditable heuristics rather than guarantees of a global minimum. Room order is an input and can affect shared-offcut allocation. - Board counts exclude an extra allowance for damage or mistakes. Spacer, minimum size and staggering defaults are editable starting values; follow the exact product's installation instructions. For example, [Quick-Step's installation guidance](https://www.quick-step.co.uk/en-gb/laminate/installation) provides product-specific manuals, and its [room-transition guidance](https://www.quick-step.co.uk/en-gb/frequently-asked-questions/laminate/preparation-and-installation/do-i-really-need-expansion-gaps-between-every-room) explains separate expansion joints. - Links are bounded to 20 rooms, 100 walls per room and 30 doors per wall. Flooring calculations are bounded to 2,000 purchased boards and 500 rows per room. Large plans may take longer; very large projects should be split into separate links. Long links may exceed the limits of a messaging service. @@ -51,6 +51,7 @@ npm run check # Build, unit tests and browser tests | `src/lib/flooring.ts` | Row search, clipping, factory-end-aware stock allocation | | `src/lib/skirting.ts` | Corner allowances, run splitting and stock allocation | | `src/lib/url-state.ts` | Readable query schema, legacy links and input validation | +| `src/lib/piece-labels.ts` | Collision-free cut-code placement and pointers for small pieces | | `src/lib/board-tracking.ts` | Exact board/cut matching and room-name search | | `src/lib/wall-editing.ts` | Wall insertion and reordering with stable IDs | | `src/lib/use-project.ts` | URL and browser-history synchronization | diff --git a/e2e/planner.spec.ts b/e2e/planner.spec.ts index 62872c9..e7dcfa3 100644 --- a/e2e/planner.spec.ts +++ b/e2e/planner.spec.ts @@ -3,7 +3,7 @@ import type { Locator, Page } from '@playwright/test'; import LZString from 'lz-string'; import { readFile } from 'node:fs/promises'; import { decodeState, encodeState } from '../src/lib/url-state'; -import { defaultState, rectangle } from '../src/lib/defaults'; +import { defaultState, fireplace, rectangle } from '../src/lib/defaults'; import { planFlooring } from '../src/lib/flooring'; import { planSkirting } from '../src/lib/skirting'; import { flooringCuts, skirtingCuts, uncutFlooring } from '../src/lib/cut-list'; @@ -49,6 +49,25 @@ function sharedRoomFixture() { return fixture; } +function shortFireplaceFixture() { + const fixture = defaultState(); + fixture.project.rooms = [fireplace('room-2', 'Room 2')]; + fixture.project.flooring = { + length: 1200, + width: 190, + pattern: 'random', + minOffset: 100, + maxOffset: 1000, + minLength: 10, + minWidth: 0.1, + gap: 10, + kerf: 3, + }; + fixture.project.skirting = { stockLength: 2400, thickness: 27, minLength: 200, kerf: 3 }; + 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 ( @@ -252,15 +271,25 @@ test.afterEach(async ({ page }) => { test('measurements, materials, drawing and both cut lists share one page', async ({ page }) => { await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800'); await expect(page.getByLabel('Room length', { exact: true })).toHaveValue('3600'); - for (const label of ['Board length', 'Board width', 'Stock length 1', 'Skirting thickness']) { + for (const label of [ + 'Board length', + 'Board width', + 'Skirting board length', + 'Skirting thickness', + ]) { await expect(page.getByLabel(label, { exact: true })).toBeVisible(); } await expect(page.getByRole('heading', { name: 'Materials', exact: true })).toBeVisible(); await expect(page.getByRole('heading', { name: /^Flooring cuts/ })).toBeVisible(); await expect(page.getByRole('heading', { name: /^Skirting cuts/ })).toBeVisible(); - await expect(preview(page).getByText('Outline closed', { exact: true })).toBeVisible(); + expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0); await expect(page.locator('.layer-switch, .step-nav')).toHaveCount(0); await expect(page.getByRole('button', { name: 'Export CSV' })).toHaveCount(0); + await expect(preview(page).getByText('Outline closed', { exact: true })).toHaveCount(0); + await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveCount(1); + await expect( + page.getByRole('button', { name: /Add stock length|Remove stock length/ }), + ).toHaveCount(0); await noVisibleStockIds(page); await expect(page.locator('.app-header').getByText('mm', { exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: /^Zoom (in|out)$/ })).toHaveCount(0); @@ -406,7 +435,7 @@ test('custom walls close using a calculated wall that can be measured again', as await page.getByLabel('Wall 3 length', { exact: true }).fill('4000'); await expect(page.getByText('3,000 mm to close')).toBeVisible(); await page.getByRole('button', { name: 'Add closing wall' }).click(); - await expect(preview(page).getByText('Outline closed', { exact: true })).toBeVisible(); + expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0); await openWalls(page); await expect(page.getByLabel('Wall 4 length', { exact: true })).toHaveValue('3000'); await page.getByLabel('Wall 4 length', { exact: true }).fill('3050'); @@ -489,6 +518,70 @@ test('fireplace thumbnails open true-scale mitres and shaped floor cuts in dialo await expect(page.locator('.selected-cut')).toHaveCount(0); }); +test('internal skirting corners retain coping blanks and expose the installed minimum', async ({ + page, +}) => { + const fixture = defaultState(); + fixture.project.rooms = [rectangle('room-1', 'Coped room', 2400, 2400)]; + fixture.project.rooms[0].walls.forEach((wall) => { + wall.doors = []; + }); + fixture.project.skirting = { stockLength: 2400, thickness: 18, minLength: 200, kerf: 3 }; + await page.goto(`/${encodeState(fixture)}`); + const { skirt } = plans(page); + expect(skirt.stockCount).toBe(4); + expect(skirt.cuts).toBe(8); + expect(skirt.pieces).toHaveLength(4); + expect( + skirt.pieces.every( + (piece) => + Math.abs(piece.length - 2400) < 0.001 && Math.abs(piece.visibleLength! - 2364) < 0.001, + ), + ).toBe(true); + const receiver = skirt.pieces.find((piece) => piece.wallIndex === 0)!; + expect([receiver.leftType, receiver.rightType]).toEqual(['internal', 'internal']); + expect(skirtingCuts(skirt).map((piece) => piece.id)).toEqual(['A1', 'A2', 'A3']); + await expect(skirtCodes(page)).toHaveCount(3); + await expect(page.locator('.skirting-cuts .cut-summary')).toContainText( + 'Buy 4 lengths · 1 installed uncut', + ); + const piece = skirt.pieces.find((piece) => piece.id === 'A1')!; + const entry = skirtEntries(page).filter({ + has: page.getByRole('button', { name: 'Preview skirting cut A1', exact: true }), + }); + await expect(entry.locator('.cut-size')).toContainText('2,400 mm'); + await expect(entry).toContainText('Left: Cope · prepare 45°'); + await expect(entry).toContainText('Right: Receive cope · square'); + await entry.getByRole('button', { name: 'Preview skirting cut A1', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Skirting cut A1', exact: true }); + await expect(dialog).toContainText('preparation before coping'); + await expect(dialog).toContainText('mark your moulding’s exposed profile'); + await expect(dialog).toContainText('Exposed front length: 2,364 mm.'); + const end = dialog.getByRole('img', { name: 'Left cope preparation close-up A1', exact: true }); + await expect(end).toBeVisible(); + const body = end.locator('.skirting-cope-body'); + await expect(body).toHaveAttribute('stroke-dasharray', '3 2'); + const geometry = await end.evaluate((element) => { + const prep = element.querySelector('.skirting-cut-edge') as SVGLineElement; + const body = element.querySelector('.skirting-cope-body') as SVGLineElement; + return { + preparationX: prep.x2.baseVal.value - prep.x1.baseVal.value, + preparationY: prep.y2.baseVal.value - prep.y1.baseVal.value, + installedX: body.x2.baseVal.value - body.x1.baseVal.value, + }; + }); + expect(Math.abs(geometry.preparationX)).toBeCloseTo(18, 3); + expect(Math.abs(geometry.preparationY)).toBeCloseTo(18, 3); + expect(geometry.installedX).toBeCloseTo(0, 3); + expect(piece.leftOverlap).toBeCloseTo(18, 3); + await page.keyboard.press('Escape'); + const floorCount = await floorEntries(page).count(); + await page.getByLabel('Minimum piece', { exact: true }).fill('2365'); + await expect(skirtEntries(page)).toHaveCount(0); + await expect(floorEntries(page)).toHaveCount(floorCount); + await expect(page.locator('.issue.error').first()).toBeVisible(); +}); + test('door positions and material lengths update skirting immediately', async ({ page }) => { const before = await page.locator('.skirting-cuts').innerText(); await page.getByRole('button', { name: 'Add a doorway' }).click(); @@ -509,7 +602,7 @@ test('door positions and material lengths update skirting immediately', async ({ 'Door on W1: 1,200 mm from W4 corner, then 900 mm opening toward W2 corner', ); expect(await page.locator('.skirting-cuts').innerText()).not.toBe(before); - await page.getByLabel('Stock length 1', { exact: true }).fill('3600'); + await page.getByLabel('Skirting board length', { exact: true }).fill('3600'); await page.getByLabel('Skirting thickness', { exact: true }).fill('22'); const { skirt } = plans(page); await expect(skirtCodes(page)).toHaveCount(skirtingCuts(skirt).length); @@ -523,14 +616,41 @@ test('door positions and material lengths update skirting immediately', async ({ test('invalid skirting settings keep flooring results usable', async ({ page }) => { const floorCount = await floorCodes(page).count(); - await page.getByLabel('Stock length 1', { exact: true }).fill('100'); + await page.getByLabel('Skirting board length', { exact: true }).fill('100'); await expect(skirtEntries(page)).toHaveCount(0); await expect(floorCodes(page)).toHaveCount(floorCount); await expect(page.locator('.issue.error').first()).toBeVisible(); - await page.getByLabel('Stock length 1', { exact: true }).fill('2400'); + await page.getByLabel('Skirting board length', { exact: true }).fill('2400'); expect(await skirtEntries(page).count()).toBeGreaterThan(0); }); +test('fractional stock keeps the planner usable and displays the blanks that actually fit', async ({ + page, +}) => { + const fixture = defaultState(); + fixture.project.rooms = [rectangle('room-1', 'Fractional room', 2400.1, 2400.1)]; + fixture.project.rooms[0].walls.forEach((wall) => { + wall.doors = []; + }); + fixture.project.skirting = { stockLength: 1200.2, thickness: 18, minLength: 300, kerf: 3 }; + await page.goto(`/${encodeState(fixture)}`); + await expect(preview(page)).toBeVisible(); + await expect(skirtCodes(page)).toHaveCount(7); + const code = skirtingCuts(plans(page).skirt).find( + (piece) => Math.abs(piece.length - 1200.2) < 1e-6, + )!.id; + const entry = skirtEntries(page).filter({ + has: page.getByRole('button', { name: `Preview skirting cut ${code}`, exact: true }), + }); + await expect(entry.locator('.cut-size')).toContainText('1,200.2 mm'); + await entry.getByRole('button', { name: `Preview skirting cut ${code}`, exact: true }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog.locator('.skirting-full-preview figcaption')).toContainText( + '1,200.2 × 18 mm', + ); + expect(await dialog.locator('.dimension-label').allTextContents()).not.toContain('1,200.5'); +}); + test('pattern, direction and spacers persist without steps, layers or stored results', async ({ page, }) => { @@ -676,7 +796,8 @@ test('clicking the skirting code itself opens its cutting dialog', async ({ page const id = await code.evaluate((element) => element.closest('[data-piece-id]')!.getAttribute('data-piece-id'), ); - await code.locator(target).click(); + // The text receives centre clicks; use the exposed corner to test the backing rectangle. + await code.locator(target).click(target === 'rect' ? { position: { x: 1, y: 1 } } : {}); await expect( page.getByRole('dialog', { name: `Skirting cut ${id}`, exact: true }), ).toBeVisible(); @@ -724,6 +845,35 @@ test('readable query fields, legacy links and browser history preserve the plan' expect(state(page).project.rooms).toHaveLength(2); }); +test('legacy skirting sizes migrate to one editable length and reset changed allocations', async ({ + page, +}) => { + const initial = defaultState(); + const legacy = { + ...initial, + project: { + ...initial.project, + completedBoards: ['skirting:S1'], + skirting: { ...initial.project.skirting, stockLength: undefined, stockLengths: [3600, 2400] }, + }, + }; + await page.goto(`/#plan=${LZString.compressToEncodedURIComponent(JSON.stringify(legacy))}`); + await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('3600'); + expect(state(page).project.skirting.stockLength).toBe(3600); + expect(state(page).project.completedBoards).toEqual([]); + const params = new URL(page.url()).searchParams; + expect(params.get('skirting.length')).toBe('3600'); + expect(params.has('skirting.stock')).toBe(false); + await page.getByLabel('Skirting board length', { exact: true }).fill('3000'); + await page.reload(); + await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('3000'); + await page.goto('/?skirting.stock=2400&skirting.stock=2400&done=skirting:S1'); + await expect(page.getByLabel('Skirting board length', { exact: true })).toHaveValue('2400'); + expect(state(page).project.completedBoards).toEqual([]); + expect(new URL(page.url()).searchParams.get('skirting.length')).toBe('2400'); + expect(new URL(page.url()).searchParams.has('skirting.stock')).toBe(false); +}); + test('printing includes every room and every cut once, grouped by its source board', async ({ page, }) => { @@ -832,7 +982,7 @@ test('drawing cuts still open a usable preview when the cut plan is collapsed', }) => { 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(); + await preview(page).locator('.floor-code[data-piece-id="F1"] text').click(); const dialog = page.getByRole('dialog', { name: 'Flooring cut F1', exact: true }); await expect(dialog).toBeVisible(); await expect(dialog.locator('.cut-dimensions')).toBeVisible(); @@ -864,7 +1014,7 @@ test('negative angles can be backspaced and retyped without saving unfinished dr 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(); + expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0); const restored = page.url(); await turn.fill('-'); expect(page.url()).toBe(restored); @@ -947,6 +1097,103 @@ test('the supplied angled room keeps W12 lines and labels inside the drawing', a } }); +test('short fireplace cuts retain positive shapes and readable labels above the walls', async ({ + page, + isMobile, +}) => { + await page.setViewportSize({ width: isMobile ? 320 : 1280, height: 844 }); + await page.goto(`/${encodeState(shortFireplaceFixture())}`); + const { floor } = plans(page); + const cuts = flooringCuts(floor); + expect(cuts.some((piece) => piece.length < 100)).toBe(true); + for (const piece of floor.pieces) { + expect(piece.length).toBeGreaterThan(0); + expect(piece.width).toBeGreaterThan(0); + const twiceArea = piece.polygon.reduce((sum, point, i) => { + const next = piece.polygon[(i + 1) % piece.polygon.length]; + return sum + point.x * next.y - next.x * point.y; + }, 0); + expect(Math.abs(twiceArea)).toBeGreaterThan(0.001); + } + const labels = preview(page).locator('.floor-code'); + await expect(labels).toHaveCount(cuts.length); + const failures = await preview(page) + .locator('svg.room-svg') + .evaluate((element) => { + const svg = element as SVGSVGElement; + const box = svg.viewBox.baseVal; + const inverse = svg.getScreenCTM()!.inverse(); + const geometry = [...svg.querySelectorAll('.wall-drawing, .skirt-piece')]; + const failures: string[] = []; + const rectangles: { id: string | null; rect: DOMRect }[] = []; + for (const label of svg.querySelectorAll('.floor-code')) { + const id = label.getAttribute('data-piece-id'); + const rect = label.querySelector('rect') as SVGRectElement; + const text = label.querySelector('text') as SVGTextElement; + const rectBox = rect.getBoundingClientRect(), + textBox = text.getBoundingClientRect(); + if ( + textBox.left < rectBox.left - 0.5 || + textBox.right > rectBox.right + 0.5 || + textBox.top < rectBox.top - 0.5 || + textBox.bottom > rectBox.bottom + 0.5 + ) + failures.push(`${id}: text exceeds its backing rectangle`); + rectangles.push({ id, rect: rectBox }); + const bounds = rect.getBBox(); + const transform = inverse.multiply(rect.getScreenCTM()!); + const corners = [ + [bounds.x, bounds.y], + [bounds.x + bounds.width, bounds.y], + [bounds.x + bounds.width, bounds.y + bounds.height], + [bounds.x, bounds.y + bounds.height], + ].map(([x, y]) => new DOMPoint(x, y).matrixTransform(transform)); + if ( + corners.some( + (point) => + point.x < box.x - 0.01 || + point.y < box.y - 0.01 || + point.x > box.x + box.width + 0.01 || + point.y > box.y + box.height + 0.01, + ) + ) + failures.push(`${id}: label extends outside the SVG viewBox`); + if ( + !geometry.every((shape) => + Boolean(shape.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING), + ) + ) + failures.push(`${id}: wall or skirting paints above label`); + } + rectangles.forEach((first, i) => + rectangles.slice(i + 1).forEach((second) => { + if ( + Math.min(first.rect.right, second.rect.right) - + Math.max(first.rect.left, second.rect.left) > + 0.5 && + Math.min(first.rect.bottom, second.rect.bottom) - + Math.max(first.rect.top, second.rect.top) > + 0.5 + ) + failures.push(`${first.id} and ${second.id}: label rectangles overlap`); + }), + ); + return failures; + }); + expect(failures).toEqual([]); + expect(await labels.locator('.piece-label-leader').count()).toBeGreaterThan(0); + const short = cuts.find((piece) => piece.length < 100)!; + await preview(page).locator(`.floor-code[data-piece-id="${short.id}"] text`).click(); + await expect( + page.getByRole('dialog', { name: `Flooring cut ${short.id}`, exact: true }), + ).toBeVisible(); + await page.keyboard.press('Escape'); + const header = (await preview(page).locator('.drawing-header').boundingBox())!; + const legend = (await preview(page).locator('.drawing-legend').boundingBox())!; + expect(legend.y + legend.height - header.y).toBeLessThanOrEqual(isMobile ? 70 : 60); + await noHorizontalOverflow(page); +}); + test('the supplied F8 room aligns a row seam with its fireplace corner and keeps exact templates', async ({ page, }) => { @@ -1108,6 +1355,32 @@ test('completed source boards and locate actions follow shared offcuts across ro expect(new URL(page.url()).searchParams.getAll('done')).toEqual([]); }); +test('the pinned plan makes active search obvious and can restore every piece', async ({ + page, +}) => { + const allCuts = await floorEntries(page).count(); + await page.getByLabel('Find a cut or room', { exact: true }).fill('F1'); + const banner = page.locator('.drawing-column .plan-search-status'); + await expect(banner).toContainText('Search active'); + await expect(banner).toContainText('F1'); + await expect(banner.getByRole('button', { name: 'Show all pieces', exact: true })).toBeVisible(); + expect(await highlightedIds(page, 'flooring')).toEqual(['F1']); + await panel(page, 'cuts').locator('.panel-toggle').click(); + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); + const bounds = (await banner.boundingBox())!; + expect(bounds.y).toBeGreaterThanOrEqual(0); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(page.viewportSize()!.height); + await banner.getByRole('button', { name: 'Show all pieces', exact: true }).click(); + await expect(banner).toHaveCount(0); + expect(state(page).view.search).toBe(''); + expect(await highlightedIds(page)).toEqual([]); + await expect(preview(page).locator('.search-muted')).toHaveCount(0); + await panel(page, 'cuts').locator('.panel-toggle').click(); + await expect(page.getByLabel('Find a cut or room', { exact: true })).toHaveValue(''); + await expect(floorEntries(page)).toHaveCount(allCuts); + await noHorizontalOverflow(page); +}); + test('cuts from one board share the available width and wrap on a narrow phone', async ({ page, isMobile, diff --git a/src/App.tsx b/src/App.tsx index 004eb8f..fec1194 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef } from 'react'; import type { ReactNode } from 'react'; -import { Copy, Plus, Printer } from 'lucide-react'; +import { Copy, Plus, Printer, Search, X } from 'lucide-react'; import { useProject } from './lib/use-project'; import { rectangle, nextId } from './lib/defaults'; import { buildGeometry } from './lib/geometry'; @@ -314,14 +314,8 @@ export default function App() { {skirtErrors.length ? '—' : skirting.stockCount} skirting lengths
- {[...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 + {skirting.stockCount} × {mm(project.skirting.stockLength)} mm ·{' '} + {wholeSkirting} used whole
@@ -353,24 +347,26 @@ export default function App() {