// 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; } }