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 && (
)}