M1-4: Length axis (TDD)

- Add axis.js: length axis rectangle state, 28 tests
- 71/71 unit tests green
This commit is contained in:
Ty
2026-09-14 00:32:42 -07:00
parent 7e2a1845cf
commit 0284a2211a
12 changed files with 1304 additions and 0 deletions
+286
View File
@@ -0,0 +1,286 @@
// M1-4 Length axis — pure functions + state machine, no DOM.
//
// Ground truth: docs/CAPTURE.md §Length axis
// - min-area bounding rectangle of the mask
// - length axis on the long side of that rectangle
// - user rotates the axis if it does not follow the slab length
// - approve the axis
//
// minAreaRect implements the rotating calipers method (Shamos & Ho 1975)
// over the convex hull of the mask pixels. The convex hull is computed
// with the monotone-chain algorithm (O(n log n)).
/**
* Compute the minimum-area bounding rectangle of a set of 2D points.
* Uses the rotating calipers method on the convex hull.
*
* @param {Array<{x:number, y:number}>} pts
* @returns {{center:{x:number,y:number}, width:number, height:number, angle:number, area:number}}
* angle: rotation of the long side in radians (0..PI/2, CCW from +x).
* width: length along the long side. height: length along the short side.
*/
function minAreaRectPoints(pts) {
const n = pts.length;
if (n === 0) return null;
if (n === 1)
return {
center: { x: pts[0].x, y: pts[0].y },
width: 0,
height: 0,
angle: 0,
area: 0,
};
if (n === 2) {
const dx = pts[1].x - pts[0].x;
const dy = pts[1].y - pts[0].y;
return {
center: { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 },
width: Math.sqrt(dx * dx + dy * dy),
height: 0,
angle: Math.atan2(dy, dx),
area: 0,
};
}
// Convex hull (monotone chain).
const hull = convexHull(pts);
if (hull.length < 3) return minAreaRectPoints(hull);
// Rotating calipers: try each hull edge as the support line.
let best = { area: Infinity, width: 0, height: 0, angle: 0, center: { x: 0, y: 0 } };
const m = hull.length;
for (let i = 0; i < m; i++) {
const p1 = hull[i];
const p2 = hull[(i + 1) % m];
const ex = p2.x - p1.x;
const ey = p2.y - p1.y;
const len = Math.sqrt(ex * ex + ey * ey);
const ux = ex / len;
const uy = ey / len;
// Project all hull points onto the edge direction (u) and its
// left normal (v = (-uy, ux)).
let minU = Infinity,
maxU = -Infinity;
let minV = Infinity,
maxV = -Infinity;
for (let j = 0; j < m; j++) {
const q = hull[j];
const u = q.x * ux + q.y * uy;
const v = -q.x * uy + q.y * ux;
if (u < minU) minU = u;
if (u > maxU) maxU = u;
if (v < minV) minV = v;
if (v > maxV) maxV = v;
}
const w = maxU - minU;
const h = maxV - minV;
const area = w * h;
if (area < best.area) {
// Center in (u,v) frame, transformed back to (x,y):
// x = cu * ux - cv * uy
// y = cu * uy + cv * ux
const cu = (minU + maxU) / 2;
const cv = (minV + maxV) / 2;
const centerX = cu * ux - cv * uy;
const centerY = cu * uy + cv * ux;
// Long side: along u when w >= h, else along the normal v.
let width, height, angle;
if (w >= h) {
width = w;
height = h;
angle = Math.atan2(uy, ux);
} else {
width = h;
height = w;
// Long side along v = (-uy, ux)
angle = Math.atan2(ux, -uy);
}
// Normalize angle to [0, PI) — rectangle has 180-degree symmetry.
if (angle < 0) angle += Math.PI;
if (angle >= Math.PI) angle -= Math.PI;
best = {
center: { x: centerX, y: centerY },
width,
height,
angle,
area,
};
}
}
return best;
}
/**
* Monotone-chain convex hull, keeping collinear boundary points.
* Returns an array of {x,y} points in CCW order, no duplicate last point.
*
* Collinear points are kept because the min-area bounding rectangle
* algorithm needs them: a rectangle's side is defined by its two
* endpoints, and dropping intermediate collinear points would lose the
* true extent of the shape.
*
* @param {Array<{x:number,y:number}>} pts
* @returns {Array<{x:number,y:number}>}
*/
function convexHull(pts) {
const n = pts.length;
if (n <= 2) return pts.slice();
const sorted = pts.slice().sort((a, b) => (a.x - b.x) || (a.y - b.y));
const cross = (O, A, B) =>
(A.x - O.x) * (B.y - O.y) - (A.y - O.y) * (B.x - O.x);
const lower = [];
for (let i = 0; i < n; i++) {
while (
lower.length >= 2 &&
cross(lower[lower.length - 2], lower[lower.length - 1], sorted[i]) < 0
) {
lower.pop();
}
lower.push(sorted[i]);
}
const upper = [];
for (let i = n - 1; i >= 0; i--) {
while (
upper.length >= 2 &&
cross(upper[upper.length - 2], upper[upper.length - 1], sorted[i]) < 0
) {
upper.pop();
}
upper.push(sorted[i]);
}
// Concatenate, dropping the last point of each (duplicates).
const hull = lower.slice(0, -1).concat(upper.slice(0, -1));
return hull;
}
/**
* Minimum-area bounding rectangle of a binary mask.
*
* @param {Uint8Array} mask binary mask (1 = kept)
* @param {number} width
* @param {number} height
* @returns {null|{center:{x:number,y:number}, width:number, height:number, angle:number, area:number}}
*/
export function minAreaRect(mask, width, height) {
const pts = [];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (mask[y * width + x]) {
pts.push({ x, y });
}
}
}
if (pts.length === 0) return null;
if (pts.length === 1)
return {
center: { x: pts[0].x, y: pts[0].y },
width: 1,
height: 1,
angle: 0,
area: 1,
};
return minAreaRectPoints(pts);
}
/**
* Unit vector along the long side of a min-area rectangle.
*
* @param {{width:number, height:number, angle:number}} rect
* @returns {{x:number, y:number}}
*/
export function axisFromRect(rect) {
if (rect.width >= rect.height) {
// Long side is along the rect's angle direction.
return { x: Math.cos(rect.angle), y: Math.sin(rect.angle) };
} else {
// Long side is along the normal (90 degrees CCW from the rect angle).
return { x: -Math.sin(rect.angle), y: Math.cos(rect.angle) };
}
}
/**
* Rotate a unit axis by an angle (radians, CCW positive).
*
* @param {{x:number, y:number}} axis
* @param {number} angle radians
* @returns {{x:number, y:number}}
*/
export function rotateAxis(axis, angle) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return { x: axis.x * cos - axis.y * sin, y: axis.x * sin + axis.y * cos };
}
/**
* Angle of a unit axis in radians (-PI..PI).
*
* @param {{x:number, y:number}} axis
* @returns {number}
*/
export function axisAngle(axis) {
return Math.atan2(axis.y, axis.x);
}
/**
* Unit vector from an angle in radians.
*
* @param {number} angle radians
* @returns {{x:number, y:number}}
*/
export function axisUnit(angle) {
return { x: Math.cos(angle), y: Math.sin(angle) };
}
// --- axis state machine -----------------------------------------------------------
/**
* Create a fresh axis state from an approved min-area rectangle.
*
* @param {{center:{x:number,y:number}, width:number, height:number, angle:number, area:number}} rect
*/
export function createAxisState(rect) {
return {
rect,
axis: axisFromRect(rect),
approved: false,
};
}
/**
* Pure reducer for the axis state.
* Actions:
* { type: 'rotate', angle } — rotate the axis by `angle` radians (CCW).
* Retracts any prior approval.
* { type: 'approve' } — mark the axis as approved.
*
* @param {object} state
* @param {object} action
* @returns {object} new state
*/
export function reduceAxisState(state, action) {
switch (action.type) {
case 'rotate': {
const angle = action.angle;
if (angle === 0) return state;
const newAxis = rotateAxis(state.axis, angle);
return { ...state, axis: newAxis, approved: false };
}
case 'approve': {
return { ...state, approved: true };
}
default:
return state;
}
}
+77
View File
@@ -0,0 +1,77 @@
import { test, expect } from '@playwright/test';
// M1-4: Playwright tests for the Length axis state.
// RED first — the live UI lands in M1-5. These tests describe the
// contract the UI must honour once the Mask state approves.
//
// Spec ground truth (docs/CAPTURE.md §Length axis):
// - min-area bounding rectangle of the mask
// - length axis on the long side of that rectangle
// - user rotates the axis if it does not follow the slab length
// - approve the axis
const SLAB_FIXTURE = 'tests/fixtures/slab-green.jpg';
test.describe('Length axis state (M1-5 live UI)', () => {
test('advances to Length axis after approving the mask', async ({ page }) => {
await page.goto('/');
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
// Wait for the initial mask recompute.
await page.waitForTimeout(2200);
// Approve the mask.
await page.getByRole('button', { name: /approve/i }).tap();
// The Length axis state is now visible.
await expect(page.getByText(/length axis/i)).toBeVisible();
});
test('shows the bounding rectangle and the proposed length axis', async ({
page,
}) => {
await page.goto('/');
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
await page.waitForTimeout(2200);
await page.getByRole('button', { name: /approve/i }).tap();
// The bounding rectangle is drawn on the photo.
await expect(
page.locator('[data-testid="bounding-rect"]'),
).toBeVisible();
// The proposed length axis is drawn along the long side.
await expect(
page.locator('[data-testid="length-axis"]'),
).toBeVisible();
});
test('rotate control adjusts the axis', async ({ page }) => {
await page.goto('/');
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
await page.waitForTimeout(2200);
await page.getByRole('button', { name: /approve/i }).tap();
// The rotate control is present.
const rotate = page.getByRole('slider', { name: /rotate axis/i });
await expect(rotate).toBeVisible();
// Rotating changes the axis angle.
const before = await page
.locator('[data-testid="length-axis"]')
.getAttribute('data-angle');
await rotate.fill('90');
const after = await page
.locator('[data-testid="length-axis"]')
.getAttribute('data-angle');
expect(before).not.toBe(after);
});
test('approve the axis advances to Measure', async ({ page }) => {
await page.goto('/');
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
await page.waitForTimeout(2200);
// Approve the mask -> Length axis.
await page.getByRole('button', { name: /approve/i }).tap();
// Approve the axis -> Measure.
const approveAxis = page.getByRole('button', {
name: /approve axis/i,
});
await expect(approveAxis).toBeVisible();
await approveAxis.tap();
await expect(page.getByText(/measure/i)).toBeVisible();
});
});
+358
View File
@@ -0,0 +1,358 @@
import { describe, it, expect } from 'vitest';
import {
minAreaRect,
axisFromRect,
rotateAxis,
axisAngle,
axisUnit,
createAxisState,
reduceAxisState,
} from '../../src/lib/core/axis.js';
// M1-4 Length axis — unit tests (RED first, per strict TDD).
//
// Ground truth: docs/CAPTURE.md §Length axis
// - min-area bounding rectangle of the mask
// - length axis on the long side of that rectangle
// - user rotates the axis if it does not follow the slab length
// - approve the axis
//
// All functions are pure (no DOM) so the state is unit-testable in Node.
// --- helpers --------------------------------------------------------------------
/**
* Build a binary mask (Uint8Array) of w x h with a filled axis-aligned
* rectangle of size rw x rh at (rx, ry).
*/
function rectMask(w, h, rx, ry, rw, rh) {
const mask = new Uint8Array(w * h);
for (let y = ry; y < ry + rh; y++) {
for (let x = rx; x < rx + rw; x++) {
mask[y * w + x] = 1;
}
}
return mask;
}
/**
* Build a binary mask of a rotated rectangle.
* Center at (cx, cy), half-lengths (hl, hw), rotation in radians.
*/
function rotatedRectMask(w, h, cx, cy, hl, hw, rot) {
const mask = new Uint8Array(w * h);
const cos = Math.cos(rot);
const sin = Math.sin(rot);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const dx = x - cx;
const dy = y - cy;
// Transform to rect-local coords
const lx = cos * dx + sin * dy;
const ly = -sin * dx + cos * dy;
if (Math.abs(lx) <= hl && Math.abs(ly) <= hw) {
mask[y * w + x] = 1;
}
}
}
return mask;
}
// --- minAreaRect ------------------------------------------------------------------
describe('minAreaRect', () => {
it('returns a tight axis-aligned rectangle for an axis-aligned mask', () => {
// 8x4 rectangle at (2,2) on a 12x8 canvas.
// Pixels at x=2..9 (8 cols), y=2..5 (4 rows).
// Center-to-center span: width 7, height 3. Center (5.5, 3.5).
const mask = rectMask(12, 8, 2, 2, 8, 4);
const rect = minAreaRect(mask, 12, 8);
// The min-area rect of an axis-aligned pixel block is tight:
// width = (cols-1), height = (rows-1) in center-to-center units.
expect(rect.width).toBeCloseTo(7, 2);
expect(rect.height).toBeCloseTo(3, 2);
expect(rect.angle).toBeCloseTo(0, 3);
expect(rect.center.x).toBeCloseTo(5.5, 2); // (2+9)/2
expect(rect.center.y).toBeCloseTo(3.5, 2); // (2+5)/2
});
it('handles a single pixel', () => {
const mask = new Uint8Array(10 * 10);
mask[5 * 10 + 5] = 1; // pixel at (5,5)
const rect = minAreaRect(mask, 10, 10);
expect(rect.area).toBe(1);
});
it('returns null for an empty mask', () => {
const mask = new Uint8Array(10 * 10);
const rect = minAreaRect(mask, 10, 10);
expect(rect).toBeNull();
});
it('detects a rotated rectangle', () => {
// 45-degree rotated rectangle: 20 units long, 4 units wide, centered
const w = 40,
h = 40,
cx = 20,
cy = 20,
hl = 10,
hw = 2;
const mask = rotatedRectMask(w, h, cx, cy, hl, hw, Math.PI / 4);
const rect = minAreaRect(mask, w, h);
// Pixel rasterization of a 20x4 rect at 45 degrees: the convex hull
// of the kept pixels approximates the true rect within ~1px per side.
// The angle should be close to 45 degrees.
expect(rect.angle).toBeCloseTo(Math.PI / 4, 1);
// Long side ~20 (within 2px for rasterization)
const longSide = Math.max(rect.width, rect.height);
expect(longSide).toBeGreaterThanOrEqual(18);
expect(longSide).toBeLessThanOrEqual(22);
});
it('handles a horizontal bar (wider than tall)', () => {
// 16x2 bar on a 20x10 canvas
const mask = rectMask(20, 10, 2, 4, 16, 2);
const rect = minAreaRect(mask, 20, 10);
// 16 cols x 2 rows: width ~15-16, height ~1-2 (pixel rasterization)
expect(rect.width).toBeGreaterThanOrEqual(14);
expect(rect.width).toBeLessThanOrEqual(17);
expect(rect.height).toBeGreaterThanOrEqual(1);
expect(rect.height).toBeLessThanOrEqual(3);
expect(rect.angle).toBeCloseTo(0, 1);
});
it('handles a vertical bar (taller than wide)', () => {
// 2x16 bar on a 10x20 canvas
const mask = rectMask(10, 20, 4, 2, 2, 16);
const rect = minAreaRect(mask, 10, 20);
// Long side ~16, short side ~2
const longSide = Math.max(rect.width, rect.height);
const shortSide = Math.min(rect.width, rect.height);
expect(longSide).toBeGreaterThanOrEqual(14);
expect(longSide).toBeLessThanOrEqual(17);
expect(shortSide).toBeGreaterThanOrEqual(1);
expect(shortSide).toBeLessThanOrEqual(3);
});
});
// --- axisFromRect ------------------------------------------------------------------
describe('axisFromRect', () => {
it('returns a unit vector along the long side of an axis-aligned rect', () => {
// 8x4 rect: long side is horizontal (width=8 > height=4)
const rect = {
center: { x: 6, y: 4 },
width: 8,
height: 4,
angle: 0,
area: 32,
};
const axis = axisFromRect(rect);
// Long side is width (8), so axis is along +x
expect(axis.x).toBeCloseTo(1, 6);
expect(axis.y).toBeCloseTo(0, 6);
});
it('returns a unit vector along the long side when height > width', () => {
// 4x8 rect: long side is vertical (height=8 > width=4)
const rect = {
center: { x: 6, y: 4 },
width: 4,
height: 8,
angle: 0,
area: 32,
};
const axis = axisFromRect(rect);
// Long side is height, so axis is along +y
expect(axis.x).toBeCloseTo(0, 6);
expect(axis.y).toBeCloseTo(1, 6);
});
it('rotates the axis with the rect angle', () => {
// 8x4 rect rotated 90 degrees: long side becomes vertical
const rect = {
center: { x: 6, y: 4 },
width: 8,
height: 4,
angle: Math.PI / 2,
area: 32,
};
const axis = axisFromRect(rect);
// The long side (width=8) is now along +y after 90-degree rotation
expect(axis.x).toBeCloseTo(0, 6);
expect(axis.y).toBeCloseTo(1, 6);
});
it('returns the axis at 45 degrees for a rotated rect', () => {
const rect = {
center: { x: 20, y: 20 },
width: 20,
height: 4,
angle: Math.PI / 4,
area: 80,
};
const axis = axisFromRect(rect);
// Long side at 45 degrees
expect(axis.x).toBeCloseTo(Math.SQRT1_2, 3);
expect(axis.y).toBeCloseTo(Math.SQRT1_2, 3);
});
});
// --- rotateAxis --------------------------------------------------------------------
describe('rotateAxis', () => {
it('rotates by 0 degrees is a no-op', () => {
const axis = { x: 1, y: 0 };
const rot = rotateAxis(axis, 0);
expect(rot.x).toBeCloseTo(1, 6);
expect(rot.y).toBeCloseTo(0, 6);
});
it('rotates by 90 degrees', () => {
const axis = { x: 1, y: 0 };
const rot = rotateAxis(axis, Math.PI / 2);
expect(rot.x).toBeCloseTo(0, 6);
expect(rot.y).toBeCloseTo(1, 6);
});
it('rotates by -90 degrees', () => {
const axis = { x: 1, y: 0 };
const rot = rotateAxis(axis, -Math.PI / 2);
expect(rot.x).toBeCloseTo(0, 6);
expect(rot.y).toBeCloseTo(-1, 6);
});
it('rotates a 45-degree axis by 45 degrees -> 90 degrees (vertical)', () => {
const axis = { x: Math.SQRT1_2, y: Math.SQRT1_2 };
const rot = rotateAxis(axis, Math.PI / 4);
expect(rot.x).toBeCloseTo(0, 3);
expect(rot.y).toBeCloseTo(1, 3);
});
});
// --- axisAngle -------------------------------------------------------------------
describe('axisAngle', () => {
it('returns 0 for +x axis', () => {
expect(axisAngle({ x: 1, y: 0 })).toBeCloseTo(0, 6);
});
it('returns PI/2 for +y axis', () => {
expect(axisAngle({ x: 0, y: 1 })).toBeCloseTo(Math.PI / 2, 6);
});
it('returns -PI/2 for -y axis', () => {
expect(axisAngle({ x: 0, y: -1 })).toBeCloseTo(-Math.PI / 2, 6);
});
it('returns PI for -x axis', () => {
expect(axisAngle({ x: -1, y: 0 })).toBeCloseTo(Math.PI, 6);
});
it('returns 45 degrees for diagonal', () => {
expect(axisAngle({ x: Math.SQRT1_2, y: Math.SQRT1_2 })).toBeCloseTo(
Math.PI / 4,
3,
);
});
});
// --- axisUnit --------------------------------------------------------------------
describe('axisUnit', () => {
it('creates a unit vector from an angle', () => {
expect(axisUnit(0).x).toBeCloseTo(1, 6);
expect(axisUnit(0).y).toBeCloseTo(0, 6);
expect(axisUnit(Math.PI / 2).x).toBeCloseTo(0, 6);
expect(axisUnit(Math.PI / 2).y).toBeCloseTo(1, 6);
expect(axisUnit(-Math.PI / 2).x).toBeCloseTo(0, 6);
expect(axisUnit(-Math.PI / 2).y).toBeCloseTo(-1, 6);
expect(axisUnit(Math.PI).x).toBeCloseTo(-1, 6);
expect(axisUnit(Math.PI).y).toBeCloseTo(0, 6);
});
});
// --- createAxisState -----------------------------------------------------------------
describe('createAxisState', () => {
it('starts with the proposed axis from the rect, not approved', () => {
const rect = {
center: { x: 6, y: 4 },
width: 8,
height: 4,
angle: 0,
area: 32,
};
const state = createAxisState(rect);
expect(state.rect).toBe(rect);
expect(state.axis.x).toBeCloseTo(1, 6); // long side is horizontal
expect(state.axis.y).toBeCloseTo(0, 6);
expect(state.approved).toBe(false);
});
it('proposed axis follows the rect angle', () => {
const rect = {
center: { x: 20, y: 20 },
width: 20,
height: 4,
angle: Math.PI / 4,
area: 80,
};
const state = createAxisState(rect);
expect(state.axis.x).toBeCloseTo(Math.SQRT1_2, 3);
expect(state.axis.y).toBeCloseTo(Math.SQRT1_2, 3);
});
});
// --- reduceAxisState -----------------------------------------------------------------
describe('reduceAxisState', () => {
const rect = {
center: { x: 6, y: 4 },
width: 8,
height: 4,
angle: 0,
area: 32,
};
it('rotate updates the axis immediately', () => {
let s = createAxisState(rect);
s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 2 });
// +x rotated by 90 degrees -> +y
expect(s.axis.x).toBeCloseTo(0, 6);
expect(s.axis.y).toBeCloseTo(1, 6);
});
it('rotate composes (rotates from current axis)', () => {
let s = createAxisState(rect);
s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 2 });
s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 2 });
// Two 90-degree rotations -> -x
expect(s.axis.x).toBeCloseTo(-1, 6);
expect(s.axis.y).toBeCloseTo(0, 6);
});
it('rotate by 0 is a no-op', () => {
let s = createAxisState(rect);
const before = s;
s = reduceAxisState(s, { type: 'rotate', angle: 0 });
expect(s).toBe(before);
});
it('approve sets approved=true', () => {
let s = createAxisState(rect);
s = reduceAxisState(s, { type: 'approve' });
expect(s.approved).toBe(true);
});
it('retract approval on rotate after approval', () => {
let s = createAxisState(rect);
s = reduceAxisState(s, { type: 'approve' });
expect(s.approved).toBe(true);
// Rotate after approval: axis changes, approval is retracted
// (user is adjusting the axis, so it is no longer approved)
s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 4 });
expect(s.approved).toBe(false);
});
it('unknown actions return the same state', () => {
const s = createAxisState(rect);
expect(reduceAxisState(s, { type: 'bogus' })).toBe(s);
});
});