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 changes the rect angle (not just a separate axis vector)', () => { let s = createAxisState(rect); const originalAngle = s.rect.angle; s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 4 }); // The rect itself must have rotated expect(s.rect.angle).toBeCloseTo(originalAngle + Math.PI / 4, 6); // And the axis is re-derived from the rotated rect expect(s.axis).toEqual(axisFromRect(s.rect)); }); it('rotate by 90 deg: rect angle changes, axis is perpendicular to original', () => { let s = createAxisState(rect); s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 2 }); expect(s.rect.angle).toBeCloseTo(Math.PI / 2, 6); // Original axis was +x (1,0). After 90 deg rect rotation, axis is +y (0,1) 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); }); });