diff --git a/e2e/floorplan.spec.ts b/e2e/floorplan.spec.ts index 90d0125..fb9b1c3 100644 --- a/e2e/floorplan.spec.ts +++ b/e2e/floorplan.spec.ts @@ -720,6 +720,69 @@ test.describe('Arrow key movement', () => { }); }); +// ─── Measure tool ───────────────────────────────────────────────────────────── + +test.describe('Measure tool', () => { + test.beforeEach(({ page }) => setup(page)); + + test('activates with toolbar button and M key', async ({ page }) => { + await page.getByTestId('tool-measure').click(); + await expect(page.getByTestId('tool-measure')).toHaveAttribute('aria-pressed', 'true'); + + await page.getByTestId('tool-select').click(); + await page.keyboard.press('m'); + await expect(page.getByTestId('tool-measure')).toHaveAttribute('aria-pressed', 'true'); + }); + + test('places start and end points without crashing', async ({ page }) => { + const { cx, cy } = await canvasCenter(page); + await page.getByTestId('tool-measure').click(); + + // First click: start point (body appears) + await page.mouse.click(cx, cy); + // Second click: end point (tape extends) + await page.mouse.click(cx + 200, cy); + + // App still functional — toolbar is visible + await expect(page.getByTestId('tool-measure')).toBeVisible(); + }); + + test('Escape cancels an in-progress measurement', async ({ page }) => { + const { cx, cy } = await canvasCenter(page); + await page.getByTestId('tool-measure').click(); + + await page.mouse.click(cx, cy); + await page.keyboard.press('Escape'); + + // After Escape, a fresh click starts a new measurement without error + await page.mouse.click(cx + 50, cy + 50); + await expect(page.getByTestId('tool-measure')).toBeVisible(); + }); + + test('Escape after completed measurement clears it', async ({ page }) => { + const { cx, cy } = await canvasCenter(page); + await page.getByTestId('tool-measure').click(); + + await page.mouse.click(cx, cy); + await page.mouse.click(cx + 200, cy); + await page.keyboard.press('Escape'); + + // App still functional + await expect(page.getByTestId('tool-measure')).toBeVisible(); + }); + + test('does not mutate the floor plan', async ({ page }) => { + const { cx, cy } = await canvasCenter(page); + await page.getByTestId('tool-measure').click(); + + await page.mouse.click(cx, cy); + await page.mouse.click(cx + 200, cy); + + const elements = await getActivePlanElements(page); + expect(elements).toHaveLength(0); + }); +}); + // ─── Keyboard shortcuts in canvas ───────────────────────────────────────────── test.describe('Canvas keyboard shortcuts', () => { diff --git a/package-lock.json b/package-lock.json index 6ffe5bd..d0f62a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2921,9 +2921,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2941,9 +2938,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2961,9 +2955,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2981,9 +2972,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3001,9 +2989,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3021,9 +3006,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7361,9 +7343,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7385,9 +7364,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7409,9 +7385,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7433,9 +7406,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/components/Canvas/MeasureOverlay.tsx b/src/components/Canvas/MeasureOverlay.tsx index 8452ae7..0cf8ed4 100644 --- a/src/components/Canvas/MeasureOverlay.tsx +++ b/src/components/Canvas/MeasureOverlay.tsx @@ -1,4 +1,4 @@ -import { Circle, Group, Line, Text } from 'react-konva'; +import { Group, Line, Rect, Text } from 'react-konva'; import { distance, formatFeet } from '../../utils/geometry'; import type { Point } from '../../types'; @@ -9,36 +9,189 @@ type Props = { worldToBase: (pt: Point) => { x: number; y: number }; }; +const TAPE_YELLOW = '#f5c518'; +const TAPE_EDGE = '#c4961a'; +const BODY_OUTLINE = '#5a4000'; + +/** Small tape measure body icon centered at origin, tape slot facing +x. */ +function TapeBody({ + bw, + bh, + br, + sw, + zoom, +}: { + bw: number; + bh: number; + br: number; + sw: number; + zoom: number; +}) { + return ( + <> + {/* Main yellow housing */} + + {/* Dark grip strip on right side */} + + {/* Tape slot (dark slot the tape exits from on the right) */} + + + ); +} + export function MeasureOverlay({ start, end, zoom, worldToBase }: Props) { const a = worldToBase(start); + // Constant screen-space sizes + const BW = 20 / zoom; // body width + const BH = 14 / zoom; // body height + const BR = 3 / zoom; // body corner radius + const TW = 5 / zoom; // tape strip width + const HOOK_HALF = 6 / zoom; // half-height of the end hook + + // No end yet: show tape measure body only (no direction, face right) if (!end) { - return ; + return ( + + + + ); } const b = worldToBase(end); const dist = distance(start, end); + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.hypot(dx, dy); + + if (len < 1e-6) return null; + + const ux = dx / len; + const uy = dy / len; + const perp = { x: -uy, y: ux }; + const angleDeg = Math.atan2(dy, dx) * (180 / Math.PI); const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + // Foot tick marks along the tape + const ticks: Array<{ x: number; y: number; major: boolean }> = []; + const wholeFeet = Math.floor(dist); + for (let f = 1; f <= wholeFeet; f++) { + const t = f / dist; + ticks.push({ x: a.x + t * dx, y: a.y + t * dy, major: true }); + } + // Half-foot ticks for spans over 2 ft + if (dist > 2) { + const halfSteps = Math.floor(dist * 2); + for (let f = 1; f <= halfSteps; f++) { + if (f % 2 === 0) continue; // skip whole-foot positions + const t = (f * 0.5) / dist; + ticks.push({ x: a.x + t * dx, y: a.y + t * dy, major: false }); + } + } + return ( + {/* ── Tape strip ── */} + + {/* Top edge */} + {/* Bottom edge */} + - - + + {/* ── Tick marks ── */} + {ticks.map((tick, i) => { + const h = tick.major ? 3.5 / zoom : 2 / zoom; + return ( + + ); + })} + + {/* ── Tape measure body at start, rotated toward end ── */} + + + + + {/* ── End hook (metal tang perpendicular to tape) ── */} + + {/* Hook plate */} + + {/* Small lip at bottom of hook */} + + + + {/* ── Distance label ── */} {dist > 0.05 && ( )}