import type { ABox, BBox } from '../capture/capture.types'; export function slabBounds(bitset: Uint8Array, w: number, h: number): ABox { let minX = w, minY = h, maxX = -1, maxY = -1; for (let y = 0; y < h; y++) { const row = y * w; for (let x = 0; x < w; x++) { if (bitset[row + x]) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } } } if (maxX < 0) return { x: 0, y: 0, w: 0, h: 0 }; return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 }; } // Convex hull (Andrew's monotone chain). Points are [x, y]. export function convexHull(pts: [number, number][]): [number, number][] { const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1]); if (p.length < 3) return p; const cross = (o: [number, number], a: [number, number], b: [number, number]) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); const lower: [number, number][] = []; for (const pt of p) { while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop(); lower.push(pt); } const upper: [number, number][] = []; for (let i = p.length - 1; i >= 0; i--) { const pt = p[i]; while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop(); upper.push(pt); } lower.pop(); upper.pop(); return lower.concat(upper); } // Rotating calipers: true minimum-area bounding rectangle of the mask. export function minAreaRect(bitset: Uint8Array, w: number, h: number): BBox | null { const pts: [number, number][] = []; for (let y = 0; y < h; y++) { const row = y * w; for (let x = 0; x < w; x++) if (bitset[row + x]) pts.push([x, y]); } if (pts.length < 2) return null; const hull = convexHull(pts); let best = { area: Infinity, cx: 0, cy: 0, ux: 1, uy: 0, hw: 0, hh: 0 }; const n = hull.length; for (let i = 0; i < n; i++) { const j = (i + 1) % n; const ex = hull[j][0] - hull[i][0]; const ey = hull[j][1] - hull[i][1]; const len = Math.hypot(ex, ey); if (len === 0) continue; const ux = ex / len, uy = ey / len; const vx = -uy, vy = ux; let minU = Infinity, maxU = -Infinity, minV = Infinity, maxV = -Infinity; for (let k = 0; k < n; k++) { const dx = hull[k][0] - hull[i][0]; const dy = hull[k][1] - hull[i][1]; const u = dx * ux + dy * uy; const v = dx * vx + dy * vy; if (u < minU) minU = u; if (u > maxU) maxU = u; if (v < minV) minV = v; if (v > maxV) maxV = v; } const rw = maxU - minU; const rh = maxV - minV; const area = rw * rh; if (area < best.area) { best = { area, cx: hull[i][0] + (minU + maxU) / 2 * ux + (minV + maxV) / 2 * vx, cy: hull[i][1] + (minU + maxU) / 2 * uy + (minV + maxV) / 2 * vy, ux, uy, hw: rw / 2, hh: rh / 2 }; } } // long-side direction: the edge with the larger extent const lu = best.hw >= best.hh ? best.ux : -best.uy; const lv = best.hw >= best.hh ? best.uy : best.ux; const su = best.hw >= best.hh ? -best.uy : best.ux; const sv = best.hw >= best.hh ? -best.ux : -best.uy; const halfL = best.hw >= best.hh ? best.hw : best.hh; const halfS = best.hw >= best.hh ? best.hh : best.hw; const corners: [number, number][] = []; for (const [a, b] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) { corners.push([best.cx + a * halfL * lu + b * halfS * su, best.cy + a * halfL * lv + b * halfS * sv]); } const angleDeg = ((Math.atan2(lv, lu) * 180) / Math.PI + 180) % 180; return { corners, longSidePx: halfL * 2, angleDeg }; } // Rotate the long side to a new angle, keeping center and half-extents. // Corners are rebuilt in minAreaRect order so corners[3] → corners[0] stays the // long side (the axis arrow direction). export function orientedRect(prev: BBox, angleDeg: number): BBox { const a = ((angleDeg % 180) + 180) % 180; const halfL = prev.longSidePx / 2; const halfS = Math.hypot(prev.corners[0][0] - prev.corners[1][0], prev.corners[0][1] - prev.corners[1][1]) / 2; // corners[i] is a corner point: average of all four = center const cx = (prev.corners[0][0] + prev.corners[1][0] + prev.corners[2][0] + prev.corners[3][0]) / 4; const cy = (prev.corners[0][1] + prev.corners[1][1] + prev.corners[2][1] + prev.corners[3][1]) / 4; const r = (a * Math.PI) / 180; const lu = Math.cos(r), lv = Math.sin(r); const su = -lv, sv = lu; const corners: [number, number][] = []; for (const [aSign, bSign] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) { corners.push([cx + aSign * halfL * lu + bSign * halfS * su, cy + aSign * halfL * lv + bSign * halfS * sv]); } return { corners, longSidePx: prev.longSidePx, angleDeg: a }; } // Project every slab pixel onto the unit vector (ux, uy); return max − min // extent in px. 0 when the mask is empty. // #99: demoted to an optional sanity check — declared L comes from tape tick // distance in image space, not this projected guess. export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number { let min = Infinity, max = -Infinity; for (let y = 0; y < h; y++) { const row = y * w; for (let x = 0; x < w; x++) { if (!bitset[row + x]) continue; const p = x * ux + y * uy; if (p < min) min = p; if (p > max) max = p; } } return max < min ? 0 : max - min; }