Files
SlabUploader/frontend/src/geometry/rect.ts
T
grokbot_ty 52240083b3 feat(capture): shared crop_fill + tape geometry for #99
- One crop_fill (default 0.9) replaces duplicated FILL in inventory/overlay
- Thin localStorage settings store; crop % only moves long-axis end insets
- Pure tapeGeometry: fixed stage line, stage↔image map, tick span → px
- measure() prefers tape tick span for scale; maskSpanAlong demoted
- rotateCalibration bakes cal tilt into inventory + overlay only
- Fix stale 80%-fill comment on slabBounds
2026-09-25 17:44:07 -07:00

133 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}