fix(capture): rotate length-axis rectangle and scale from mask span

Length axis keeps the approved mask, replaces Flip 180 with a ± angle
slider, and sets scale from wood span along the approved direction rather
than bbox.longSidePx. Widths still sample every 6 in perpendicular to that
axis.
This commit is contained in:
2026-09-14 16:37:00 +00:00
parent b781ffeb66
commit b328871549
9 changed files with 201 additions and 45 deletions
+51
View File
@@ -0,0 +1,51 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { minAreaRect, orientedRect, maskSpanAlong } from './rect.ts';
import { measure } from '../measure/measure.ts';
// 100×20 horizontal slab
const W = 100, H = 20;
const bitset = new Uint8Array(W * H);
for (let x = 0; x < W; x++) for (let y = 0; y < H; y++) bitset[y * W + x] = 1;
test('maskSpanAlong of a 100×20 slab: X ≈ 99–100, Y ≈ 19–20', () => {
const sx = maskSpanAlong(bitset, W, H, 1, 0);
const sy = maskSpanAlong(bitset, W, H, 0, 1);
assert.ok(sx >= 99 && sx <= 100, `spanX ${sx}`);
assert.ok(sy >= 19 && sy <= 20, `spanY ${sy}`);
});
test('orientedRect changes angleDeg and preserves longSidePx', () => {
const prev = minAreaRect(bitset, W, H);
assert.ok(prev, 'minAreaRect returned a box');
const rotated = orientedRect(prev, 45);
assert.ok(Math.abs(rotated.angleDeg - 45) < 1e-9);
assert.equal(rotated.longSidePx, prev.longSidePx);
// The long side (angleDeg 0 → +x) must be preserved by the rotation.
const longOf = (c) => Math.hypot(c[3][0] - c[0][0], c[3][1] - c[0][1]);
assert.ok(Math.abs(longOf(rotated.corners) - longOf(prev.corners)) < 1e-9);
assert.ok(Math.abs(longOf(prev.corners) - prev.longSidePx) < 1e-9);
// angle normalization to [0, 180)
assert.ok(orientedRect(prev, 190).angleDeg >= 0 && orientedRect(prev, 190).angleDeg < 180);
});
test('measure scale uses mask span along the axis, not longSidePx', () => {
// Thin slab (20 long, 100 wide): minAreaRect's long side (angleDeg 90) is
// the 100-px dimension along +y. Rotate the axis to angleDeg 0 (+x), which
// crosses only the 20-px slab width — span 19, not longSidePx 99.
const TW = 20, TH = 100;
const thin = new Uint8Array(TW * TH).fill(1);
const thinBox = minAreaRect(thin, TW, TH);
assert.ok(thinBox && thinBox.longSidePx >= 99, `longSide ${thinBox?.longSidePx}`);
const rotated = orientedRect(thinBox, 0);
const lengthEighths = 24; // 3 in
const m = measure(thin, TW, TH, rotated, lengthEighths, 8, TW * TH);
// Scale = span along the axis / length. Span ≈ 19 → scale ≈ 6.3 px/in,
// far from the old longSidePx/3 ≈ 33.
const ex = rotated.corners[0][0] - rotated.corners[3][0];
const ey = rotated.corners[0][1] - rotated.corners[3][1];
const elen = Math.hypot(ex, ey);
const span = maskSpanAlong(thin, TW, TH, ex / elen, ey / elen);
assert.ok(Math.abs(m.scale - span / (lengthEighths / 8)) < 1e-9);
assert.ok(m.scale < 10, `scale ${m.scale} should be span-based, not ${thinBox.longSidePx / 3}`);
});