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
This commit is contained in:
grokbot_ty
2026-09-25 17:41:29 -07:00
parent d20e3d9077
commit 52240083b3
12 changed files with 486 additions and 16 deletions
+18
View File
@@ -0,0 +1,18 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { minAreaRect, orientedRect } from '../geometry/rect.ts';
import { measure } from './measure.ts';
test('#99: tape span drives scale, not maskSpanAlong', () => {
const W = 480, H = 100;
const bitset = new Uint8Array(W * H).fill(1);
const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
// Declared L = 48"; tape span = 240 px → 5 px/in (not the full 480 mask span).
const m = measure(bitset, W, H, box, 384, 8, W * H, {
start: [100, 50],
ux: 1,
uy: 0,
spanPx: 240,
});
assert.ok(Math.abs(m.scale - 5) < 1e-9, `scale ${m.scale}`);
});
+36 -3
View File
@@ -6,8 +6,19 @@ export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [numb
return { start: bbox.corners[3], end: bbox.corners[0] };
}
export interface TapeAxis {
/** Image-space tape start (tick A). */
start: [number, number];
/** Image-space unit direction tick A → tick B. */
ux: number;
uy: number;
/** Image-space distance between ticks (owner-declared tape length in px). */
spanPx: number;
}
// Wood end→end along the approved direction: project the mask onto the axis
// unit vector and return the segment on the bbox center line covering that span.
// Used only as a fallback / sanity path when no tape ticks are present (#99).
function maskAxisSegment(
bitset: Uint8Array, w: number, h: number, bbox: BBox,
): { start: [number, number]; end: [number, number]; span: number; ux: number; uy: number } {
@@ -71,16 +82,38 @@ function sampleWidths(
return out;
}
/**
* @param tape When provided (#99), scale = tape.spanPx / L and width stations
* walk from the tick-A endpoint. maskSpanAlong is not used for scale.
*/
export function measure(
bitset: Uint8Array, w: number, h: number,
bbox: BBox,
lengthEighths: Eighths, thicknessEighths: Eighths,
slabPx: number,
tape?: TapeAxis | null,
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
const lengthIn = lengthEighths / 8;
const seg = maskAxisSegment(bitset, w, h, bbox);
const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
let start: [number, number];
let ux: number;
let uy: number;
let scale: number;
if (tape && tape.spanPx > 0 && lengthIn > 0) {
start = tape.start;
ux = tape.ux;
uy = tape.uy;
scale = tape.spanPx / lengthIn;
// Optional sanity: maskSpanAlong remains available for diagnostics only.
void maskSpanAlong;
} else {
const seg = maskAxisSegment(bitset, w, h, bbox);
// Legacy fallback when no tape ticks — keep prior behaviour for old drafts.
scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
start = seg.start;
ux = seg.ux;
uy = seg.uy;
}
const every6In = sampleWidths(bitset, w, h, start, ux, uy, scale, lengthIn);
let widths: Widths;
if (every6In.length > 0) {
const min = Math.min(...every6In);