1b5ad112cc
Split FLOOR_WARN_PX=800 / FLOOR_BLOCK_PX=300. Warn-band still encodes and includes the photo (Photos-ready + batchApply ok+warn). Block excludes siblings and clean cal. CAPTURE-105 / CAPTURE.md / PHOTOS.md updated.
139 lines
4.9 KiB
TypeScript
139 lines
4.9 KiB
TypeScript
import type { BBox, Eighths, Measures, Widths } from '../capture/capture.types';
|
||
import { maskSpanAlong } from '../geometry/rect';
|
||
|
||
// Display / direction: corners[3] → corners[0] is the long side.
|
||
export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } {
|
||
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 } {
|
||
const { start: a, end: b } = axisEndpoints(bbox);
|
||
const ex = b[0] - a[0];
|
||
const ey = b[1] - a[1];
|
||
const elen = Math.hypot(ex, ey) || 1;
|
||
const ux = ex / elen, uy = ey / elen;
|
||
const cx = (bbox.corners[0][0] + bbox.corners[1][0] + bbox.corners[2][0] + bbox.corners[3][0]) / 4;
|
||
const cy = (bbox.corners[0][1] + bbox.corners[1][1] + bbox.corners[2][1] + bbox.corners[3][1]) / 4;
|
||
const cProj = cx * ux + cy * uy;
|
||
let minP = Infinity, maxP = -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 < minP) minP = p;
|
||
if (p > maxP) maxP = p;
|
||
}
|
||
}
|
||
if (maxP < minP) return { start: a, end: b, span: 0, ux, uy };
|
||
return {
|
||
start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)],
|
||
end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)],
|
||
span: maxP - minP,
|
||
ux,
|
||
uy,
|
||
};
|
||
}
|
||
|
||
// Interior stations only (6, 12, … ≤ L−6). Tips dominate min/max on tapered
|
||
// boards, so the first and last 6 in are excluded. Each sample is a line
|
||
// probe perpendicular to the axis; width = max run of slab px across it.
|
||
function sampleWidths(
|
||
bitset: Uint8Array, w: number, h: number,
|
||
start: [number, number], ux: number, uy: number,
|
||
scale: number, lengthIn: number,
|
||
): Eighths[] {
|
||
const px = -uy, py = ux;
|
||
const stepPx = 6 * scale;
|
||
const lastPx = lengthIn > 12 ? Math.floor((lengthIn - 6) / 6) * 6 * scale : 0;
|
||
const out: Eighths[] = [];
|
||
const half = Math.max(2, Math.round((w + h) / 4));
|
||
for (let t = stepPx; t <= lastPx; t += stepPx) {
|
||
const ox = start[0] + ux * t;
|
||
const oy = start[1] + uy * t;
|
||
let minP = Infinity, maxP = -Infinity;
|
||
for (let p = -half; p <= half; p++) {
|
||
const x = Math.round(ox + px * p);
|
||
const y = Math.round(oy + py * p);
|
||
if (x < 0 || y < 0 || x >= w || y >= h) continue;
|
||
if (bitset[y * w + x]) {
|
||
if (p < minP) minP = p;
|
||
if (p > maxP) maxP = p;
|
||
}
|
||
}
|
||
if (maxP < minP) continue;
|
||
out.push(Math.max(0, Math.round(((maxP - minP + 1) / scale) * 8)));
|
||
}
|
||
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'> & { sourceBelowBlock: false; sourceBelowWarn: false } {
|
||
const lengthIn = lengthEighths / 8;
|
||
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);
|
||
const max = Math.max(...every6In);
|
||
const avg = Math.round(every6In.reduce((a, b) => a + b, 0) / every6In.length);
|
||
widths = { min, max, avg, every6In };
|
||
} else {
|
||
widths = { min: 0, max: 0, avg: 0, every6In: [] };
|
||
}
|
||
const sqft = Math.round((slabPx / (scale * scale) / 144) * 100) / 100;
|
||
const bdft = Math.round(sqft * (thicknessEighths / 8) * 100) / 100;
|
||
return {
|
||
lengthEighths,
|
||
thicknessEighths,
|
||
scale,
|
||
widths,
|
||
sqft,
|
||
bdft,
|
||
sourceBelowBlock: false,
|
||
sourceBelowWarn: false,
|
||
};
|
||
}
|