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.
288 lines
7.6 KiB
JavaScript
288 lines
7.6 KiB
JavaScript
// 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;
|
|
// Rotate the RECTANGLE, not a separate axis vector. The rect's angle
|
|
// changes, and the axis is re-derived from the rotated rect.
|
|
const newRect = { ...state.rect, angle: state.rect.angle + angle };
|
|
return { ...state, rect: newRect, axis: axisFromRect(newRect), approved: false };
|
|
}
|
|
case 'approve': {
|
|
return { ...state, approved: true };
|
|
}
|
|
default:
|
|
return state;
|
|
}
|
|
} |