f00c4a7460
reduceAxisState 'rotate' now changes state.rect.angle and re-derives state.axis via axisFromRect(newRect). The rectangle rotates as a whole; the axis for Measure is derived from the rotated rect's long side. 2 new regression tests: rect.angle changes on rotate, axis is re-derived from rotated rect. 109/109 tests green.
378 lines
12 KiB
JavaScript
378 lines
12 KiB
JavaScript
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);
|
|
});
|
|
});
|