03d3fcf460
Lower the axial body threshold while retaining fringe rejection, minimize length-only tip inset, and make sheet sensitivity curves testable and conservative at zero.
553 lines
19 KiB
TypeScript
553 lines
19 KiB
TypeScript
import type { BBox } from '../capture/capture.types';
|
||
import { axisEndpoints } from '../measure/measure';
|
||
|
||
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
|
||
export function overlayWidthStations(lengthIn: number): number[] {
|
||
if (!(lengthIn > 6)) return [];
|
||
const last = lengthIn - 6;
|
||
const out: number[] = [];
|
||
for (let s = 6; s <= last + 1e-9; s += 12) out.push(s);
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* EXIF-oriented photo bottom = larger canvas y. Pick which end of the approved
|
||
* length-axis span is bottom; return unit from bottom → top along that span.
|
||
*/
|
||
export function pickBottomTowardTop(
|
||
start: [number, number],
|
||
end: [number, number],
|
||
): {
|
||
bottom: [number, number];
|
||
top: [number, number];
|
||
ux: number;
|
||
uy: number;
|
||
} {
|
||
// Larger y is closer to the bottom edge of the oriented photo.
|
||
// Tie-break (horizontal axis): prefer larger x for a stable choice.
|
||
const startIsBottom =
|
||
start[1] > end[1] || (start[1] === end[1] && start[0] >= end[0]);
|
||
const bottom = startIsBottom ? start : end;
|
||
const top = startIsBottom ? end : start;
|
||
const dx = top[0] - bottom[0];
|
||
const dy = top[1] - bottom[1];
|
||
const len = Math.hypot(dx, dy) || 1;
|
||
return { bottom, top, ux: dx / len, uy: dy / len };
|
||
}
|
||
|
||
/** Screen-space right of travel (canvas y↓): (ux,uy) → (−uy, ux). */
|
||
export function axisRightUnit(ux: number, uy: number): [number, number] {
|
||
return [-uy, ux];
|
||
}
|
||
|
||
export interface OverlayWidthMark {
|
||
stationIn: number;
|
||
center: [number, number];
|
||
left: [number, number];
|
||
right: [number, number];
|
||
/** Axis-left of the slab, outside the wood edge (opposite of length dim). */
|
||
labelAt: [number, number];
|
||
label: string;
|
||
}
|
||
|
||
export interface OverlayLengthMark {
|
||
a: [number, number];
|
||
b: [number, number];
|
||
labelAt: [number, number];
|
||
label: string;
|
||
/** True when the line sits outside the slab on the axis-right side. */
|
||
outside: boolean;
|
||
}
|
||
|
||
export interface OverlayPlan {
|
||
stroke: '#000000';
|
||
fontPx: number;
|
||
length: OverlayLengthMark;
|
||
widths: OverlayWidthMark[];
|
||
}
|
||
|
||
export interface OverlayPlanInput {
|
||
bitset: Uint8Array;
|
||
widthPx: number;
|
||
heightPx: number;
|
||
bbox: BBox;
|
||
scale: number; // px per inch
|
||
lengthIn: number;
|
||
lengthLabel: string;
|
||
/** Map source px → inventory PNG px (identity OK for unit tests). */
|
||
mapToInventory: (x: number, y: number) => [number, number];
|
||
/** Inventory PNG size — used to clamp labels inside the canvas (#71). */
|
||
inventoryW?: number;
|
||
inventoryH?: number;
|
||
}
|
||
|
||
/**
|
||
* Min across-width as a fraction of max bin width for an axial bin to count as a tip.
|
||
* #71 used 0.32 (excluded fringe); #73 lowers to ~0.15 so tapered live-edge tips remain.
|
||
*/
|
||
export const BODY_WIDTH_FRAC = 0.15;
|
||
|
||
/**
|
||
* Axial tip stations from across-width profile (#71/#73).
|
||
* Thin fringe whiskers past the visible body survive 0.2%/99.8% percentiles;
|
||
* require each axial bin to have meaningful across-width before it counts as a tip.
|
||
*/
|
||
function widthAwareAxialRange(
|
||
bitset: Uint8Array,
|
||
w: number,
|
||
h: number,
|
||
ux: number,
|
||
uy: number,
|
||
cx: number,
|
||
cy: number,
|
||
): { minP: number; maxP: number } | null {
|
||
const rx = -uy;
|
||
const ry = ux;
|
||
const binMin = new Map<number, number>();
|
||
const binMax = new Map<number, number>();
|
||
for (let y = 0; y < h; y++) {
|
||
const row = y * w;
|
||
for (let x = 0; x < w; x++) {
|
||
if (!bitset[row + x]) continue;
|
||
const along = x * ux + y * uy;
|
||
const across = (x - cx) * rx + (y - cy) * ry;
|
||
const bin = Math.round(along);
|
||
const prevMin = binMin.get(bin);
|
||
if (prevMin === undefined || across < prevMin) binMin.set(bin, across);
|
||
const prevMax = binMax.get(bin);
|
||
if (prevMax === undefined || across > prevMax) binMax.set(bin, across);
|
||
}
|
||
}
|
||
if (binMin.size === 0) return null;
|
||
let maxWidth = 0;
|
||
const widths: { bin: number; width: number }[] = [];
|
||
for (const [bin, minA] of binMin) {
|
||
const maxA = binMax.get(bin)!;
|
||
const width = maxA - minA + 1;
|
||
widths.push({ bin, width });
|
||
if (width > maxWidth) maxWidth = width;
|
||
}
|
||
// Body threshold: exclude ~10% fringe whiskers; keep 15–25% live-edge tapers (#73).
|
||
const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC);
|
||
widths.sort((a, b) => a.bin - b.bin);
|
||
let tipLo = widths[0].bin;
|
||
let tipHi = widths[widths.length - 1].bin;
|
||
for (const { bin, width } of widths) {
|
||
if (width >= thresh) {
|
||
tipLo = bin;
|
||
break;
|
||
}
|
||
}
|
||
for (let i = widths.length - 1; i >= 0; i--) {
|
||
if (widths[i].width >= thresh) {
|
||
tipHi = widths[i].bin;
|
||
break;
|
||
}
|
||
}
|
||
return { minP: tipLo, maxP: tipHi };
|
||
}
|
||
|
||
function maskAxisSegment(
|
||
bitset: Uint8Array,
|
||
w: number,
|
||
h: number,
|
||
bbox: BBox,
|
||
): {
|
||
start: [number, number];
|
||
end: [number, number];
|
||
/** Actual mask pixels nearest centerline at tip stations. */
|
||
tipStart: [number, number];
|
||
tipEnd: [number, number];
|
||
ux: number;
|
||
uy: number;
|
||
span: 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;
|
||
const 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;
|
||
// Width-aware tips (#71). Fall back to robust percentiles if profile empty.
|
||
const aware = widthAwareAxialRange(bitset, w, h, ux, uy, cx, cy);
|
||
let minP: number;
|
||
let maxP: number;
|
||
if (aware) {
|
||
minP = aware.minP;
|
||
maxP = aware.maxP;
|
||
} else {
|
||
const projs: number[] = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const row = y * w;
|
||
for (let x = 0; x < w; x++) {
|
||
if (!bitset[row + x]) continue;
|
||
projs.push(x * ux + y * uy);
|
||
}
|
||
}
|
||
if (projs.length === 0) return { start: a, end: b, tipStart: a, tipEnd: b, ux, uy, span: 0 };
|
||
projs.sort((p, q) => p - q);
|
||
const lo = Math.min(Math.floor(projs.length * 0.002), projs.length - 1);
|
||
const hi = Math.max(Math.floor(projs.length * 0.998), lo);
|
||
minP = projs[lo];
|
||
maxP = projs[hi];
|
||
}
|
||
// Tip pixel at each station: closest to the axis centerline
|
||
let tipStart: [number, number] = a;
|
||
let tipEnd: [number, number] = b;
|
||
let bestStart = Infinity;
|
||
let bestEnd = Infinity;
|
||
const stationTol = Math.max(1.5, (maxP - minP) * 0.001);
|
||
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;
|
||
const across = Math.abs((x - cx) * -uy + (y - cy) * ux);
|
||
if (Math.abs(p - minP) <= stationTol && across < bestStart) {
|
||
bestStart = across;
|
||
tipStart = [x, y];
|
||
}
|
||
if (Math.abs(p - maxP) <= stationTol && across < bestEnd) {
|
||
bestEnd = across;
|
||
tipEnd = [x, y];
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)],
|
||
end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)],
|
||
tipStart,
|
||
tipEnd,
|
||
ux,
|
||
uy,
|
||
span: maxP - minP,
|
||
};
|
||
}
|
||
|
||
/** Probe slab extent along unit (px, py) through origin (ox, oy). */
|
||
function probeExtent(
|
||
bitset: Uint8Array,
|
||
w: number,
|
||
h: number,
|
||
ox: number,
|
||
oy: number,
|
||
px: number,
|
||
py: number,
|
||
): { minP: number; maxP: number } | null {
|
||
const half = Math.max(2, Math.round((w + h) / 4));
|
||
let minP = Infinity;
|
||
let 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) return null;
|
||
return { minP, maxP };
|
||
}
|
||
|
||
function fmtInches(inches: number): string {
|
||
const whole = Math.floor(inches + 1e-9);
|
||
const rem = Math.round((inches - whole) * 8);
|
||
if (rem === 0) return `${whole}"`;
|
||
const frac: Record<number, string> = {
|
||
1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8', 8: '',
|
||
};
|
||
if (rem === 8) return `${whole + 1}"`;
|
||
const part = frac[rem] ?? `${rem}/8`;
|
||
return whole > 0 ? `${whole} ${part}"` : `${part}"`;
|
||
}
|
||
|
||
export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||
const {
|
||
bitset, widthPx, heightPx, bbox, scale, lengthIn, lengthLabel, mapToInventory,
|
||
inventoryW, inventoryH,
|
||
} = input;
|
||
const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox);
|
||
// Re-orient span ends so EXIF bottom establishes station origin; axis still
|
||
// lies on the approved M1 direction (seg ux/uy flipped only when needed).
|
||
const oriented = pickBottomTowardTop(seg.start, seg.end);
|
||
const { bottom, top, ux, uy } = oriented;
|
||
const [rx, ry] = axisRightUnit(ux, uy);
|
||
|
||
// Consistent smaller readable font (#71). Cap by inventory size so high
|
||
// source scale (≈80 px/in) does not dominate the PNG.
|
||
const fontPx = (() => {
|
||
if (inventoryW && inventoryH && inventoryW > 0 && inventoryH > 0) {
|
||
return Math.max(12, Math.min(16, Math.round(Math.min(inventoryW, inventoryH) * 0.016)));
|
||
}
|
||
return Math.max(12, Math.min(16, Math.round(scale * 0.18)));
|
||
})();
|
||
// Inset for stroke + arrowhead so visual tips sit on/inside edges (#71).
|
||
const lineW = Math.max(2, Math.round(fontPx / 10));
|
||
const arrowHead = Math.max(7, fontPx * 0.32);
|
||
// Width tips retain stroke/arrowhead protection. Length endpoints need only
|
||
// half-stroke protection: larger arrowhead-derived inset visibly under-reached tips (#73).
|
||
const widthEdgeInset = Math.max(lineW / 2 + 0.5, arrowHead * 0.15);
|
||
const lengthEdgeInset = Math.max(0.5, lineW / 2);
|
||
const pad = Math.max(10, Math.min(28, fontPx * 1.1));
|
||
const stations = overlayWidthStations(lengthIn);
|
||
const widths: OverlayWidthMark[] = [];
|
||
for (const stationIn of stations) {
|
||
const t = stationIn * scale;
|
||
const ox = bottom[0] + ux * t;
|
||
const oy = bottom[1] + uy * t;
|
||
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
|
||
if (!extent) continue;
|
||
// left/right on mask edges, then inset in inventory space for stroke/arrowhead
|
||
const leftMapped = mapToInventory(ox + rx * extent.minP, oy + ry * extent.minP);
|
||
const rightMapped = mapToInventory(ox + rx * extent.maxP, oy + ry * extent.maxP);
|
||
const wdx = rightMapped[0] - leftMapped[0];
|
||
const wdy = rightMapped[1] - leftMapped[1];
|
||
const wlen = Math.hypot(wdx, wdy) || 1;
|
||
const wux = wdx / wlen;
|
||
const wuy = wdy / wlen;
|
||
const left: [number, number] = [leftMapped[0] + wux * widthEdgeInset, leftMapped[1] + wuy * widthEdgeInset];
|
||
const right: [number, number] = [rightMapped[0] - wux * widthEdgeInset, rightMapped[1] - wuy * widthEdgeInset];
|
||
// Width number sits axis-left outside the slab (past left tip).
|
||
const labelOff = extent.minP - pad - fontPx * 0.85;
|
||
const labelSrcW: [number, number] = [ox + rx * labelOff, oy + ry * labelOff];
|
||
const widthIn = (extent.maxP - extent.minP + 1) / scale;
|
||
widths.push({
|
||
stationIn,
|
||
center: mapToInventory(ox, oy),
|
||
left,
|
||
right,
|
||
labelAt: mapToInventory(labelSrcW[0], labelSrcW[1]),
|
||
label: fmtInches(widthIn),
|
||
});
|
||
}
|
||
|
||
// Length graphic: beside wood on axis-right, outside max right extent.
|
||
let maxRight = 0;
|
||
const samples = Math.max(8, Math.round(lengthIn));
|
||
for (let i = 0; i <= samples; i++) {
|
||
const t = (i / samples) * (lengthIn * scale);
|
||
const ox = bottom[0] + ux * t;
|
||
const oy = bottom[1] + uy * t;
|
||
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
|
||
if (extent && extent.maxP > maxRight) maxRight = extent.maxP;
|
||
}
|
||
const outward = maxRight + pad;
|
||
// Classic dimensioning (#70 + #71): width-aware centerline tip stations offset by
|
||
// pure axis-right (rx,ry)*outward. Length stays || approved (ux,uy); no screen-slide.
|
||
// Axial inset (inventory space) so arrowhead tips sit on footprint, not past.
|
||
const aMapped = mapToInventory(bottom[0] + rx * outward, bottom[1] + ry * outward);
|
||
const bMapped = mapToInventory(top[0] + rx * outward, top[1] + ry * outward);
|
||
const ldx0 = bMapped[0] - aMapped[0];
|
||
const ldy0 = bMapped[1] - aMapped[1];
|
||
const llen0 = Math.hypot(ldx0, ldy0) || 1;
|
||
const lux = ldx0 / llen0;
|
||
const luy = ldy0 / llen0;
|
||
const aInv: [number, number] = [aMapped[0] + lux * lengthEdgeInset, aMapped[1] + luy * lengthEdgeInset];
|
||
const bInv: [number, number] = [bMapped[0] - lux * lengthEdgeInset, bMapped[1] - luy * lengthEdgeInset];
|
||
const midInv: [number, number] = [(aInv[0] + bInv[0]) / 2, (aInv[1] + bInv[1]) / 2];
|
||
// Length label further axis-right of dim line so glyphs stay off the wood (#71).
|
||
const mapOrigin = mapToInventory(0, 0);
|
||
const mapR = mapToInventory(rx, ry);
|
||
const irx = mapR[0] - mapOrigin[0];
|
||
const iry = mapR[1] - mapOrigin[1];
|
||
const irlen = Math.hypot(irx, iry) || 1;
|
||
const nrx = irx / irlen;
|
||
const nry = iry / irlen;
|
||
const labelAt: [number, number] = [
|
||
midInv[0] + nrx * (fontPx * 0.85 + 6),
|
||
midInv[1] + nry * (fontPx * 0.85 + 6),
|
||
];
|
||
|
||
const lengthMark: OverlayLengthMark = {
|
||
a: aInv,
|
||
b: bInv,
|
||
labelAt,
|
||
label: lengthLabel,
|
||
outside: true,
|
||
};
|
||
|
||
// Clamp all labels inside inventory PNG so nothing clips (#71).
|
||
if (inventoryW && inventoryH && inventoryW > 0 && inventoryH > 0) {
|
||
const margin = Math.max(8, fontPx * 0.65);
|
||
const clamp = (pt: [number, number], growLeft: boolean, text: string): [number, number] => {
|
||
let [x, y] = pt;
|
||
const glyphW = Math.max(fontPx * 1.2, text.length * fontPx * 0.55);
|
||
if (growLeft) {
|
||
// textAlign right — glyphs extend left of anchor
|
||
x = Math.max(margin + glyphW, Math.min(inventoryW - margin, x));
|
||
} else {
|
||
x = Math.max(margin, Math.min(inventoryW - margin, x));
|
||
}
|
||
y = Math.max(margin, Math.min(inventoryH - margin, y));
|
||
return [x, y];
|
||
};
|
||
lengthMark.labelAt = clamp(lengthMark.labelAt, false, lengthLabel);
|
||
for (const w of widths) {
|
||
const growLeft = w.labelAt[0] <= w.center[0];
|
||
w.labelAt = clamp(w.labelAt, growLeft, w.label);
|
||
}
|
||
}
|
||
|
||
return {
|
||
stroke: '#000000',
|
||
fontPx,
|
||
length: lengthMark,
|
||
widths,
|
||
};
|
||
}
|
||
|
||
/** Draw black dimension graphics onto an existing canvas (inventory PNG size). */
|
||
export function drawOverlayPlan(
|
||
ctx: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D,
|
||
plan: OverlayPlan,
|
||
): void {
|
||
const { stroke, fontPx } = plan;
|
||
ctx.save();
|
||
ctx.strokeStyle = stroke;
|
||
ctx.fillStyle = stroke;
|
||
ctx.lineWidth = Math.max(2, Math.round(fontPx / 10));
|
||
// Butt caps: round caps extend past endpoints and overshoot wood edges (#71).
|
||
ctx.lineCap = 'butt';
|
||
ctx.font = `bold ${fontPx}px sans-serif`;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
|
||
const arrow = (from: [number, number], to: [number, number]) => {
|
||
const dx = to[0] - from[0];
|
||
const dy = to[1] - from[1];
|
||
const len = Math.hypot(dx, dy) || 1;
|
||
const ux = dx / len;
|
||
const uy = dy / len;
|
||
const head = Math.max(7, fontPx * 0.32);
|
||
ctx.beginPath();
|
||
ctx.moveTo(from[0], from[1]);
|
||
ctx.lineTo(to[0], to[1]);
|
||
ctx.stroke();
|
||
// arrow tips at both ends
|
||
for (const [sx, sy, dir] of [
|
||
[from[0], from[1], 1],
|
||
[to[0], to[1], -1],
|
||
] as const) {
|
||
const px = -uy;
|
||
const py = ux;
|
||
ctx.beginPath();
|
||
ctx.moveTo(sx, sy);
|
||
ctx.lineTo(sx + dir * ux * head + px * head * 0.4, sy + dir * uy * head + py * head * 0.4);
|
||
ctx.lineTo(sx + dir * ux * head - px * head * 0.4, sy + dir * uy * head - py * head * 0.4);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
}
|
||
};
|
||
|
||
// Length — grow text further axis-right of the dim line (away from wood)
|
||
arrow(plan.length.a, plan.length.b);
|
||
{
|
||
const [lax, lay] = plan.length.a;
|
||
const [lbx, lby] = plan.length.b;
|
||
const midX = (lax + lbx) / 2;
|
||
const midY = (lay + lby) / 2;
|
||
const outwardX = plan.length.labelAt[0] - midX;
|
||
const outwardY = plan.length.labelAt[1] - midY;
|
||
// Prefer left/right align so glyphs extend away from wood when offset is mostly ±x
|
||
if (Math.abs(outwardX) >= Math.abs(outwardY)) {
|
||
ctx.textAlign = outwardX >= 0 ? 'left' : 'right';
|
||
} else {
|
||
ctx.textAlign = 'center';
|
||
}
|
||
}
|
||
ctx.fillText(plan.length.label, plan.length.labelAt[0], plan.length.labelAt[1]);
|
||
|
||
// Widths — grow text away from wood (labelAt is axis-left of left tip)
|
||
for (const w of plan.widths) {
|
||
arrow(w.left, w.right);
|
||
// labelAt is on the axis-left side of the station; align so glyphs extend further out
|
||
const towardLeft = w.labelAt[0] <= w.center[0];
|
||
ctx.textAlign = towardLeft ? 'right' : 'left';
|
||
ctx.fillText(w.label, w.labelAt[0], w.labelAt[1]);
|
||
}
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
export interface InventoryMap {
|
||
mapToInventory: (x: number, y: number) => [number, number];
|
||
outW: number;
|
||
outH: number;
|
||
}
|
||
|
||
/** Same crop math as composeInventoryFrame — source → inventory PNG pixels. */
|
||
export function inventorySourceMap(
|
||
slabBounds: { x: number; y: number; w: number; h: number },
|
||
source: { widthPx: number; heightPx: number },
|
||
outW: number,
|
||
outH: number,
|
||
): InventoryMap {
|
||
const FILL = 0.9;
|
||
const x0 = Math.max(0, Math.round(slabBounds.x));
|
||
const y0 = Math.max(0, Math.round(slabBounds.y));
|
||
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
|
||
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(slabBounds.h)));
|
||
const sw = x1 - x0;
|
||
const sh = y1 - y0;
|
||
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
|
||
const dw = sw * scale;
|
||
const dh = sh * scale;
|
||
const ox = (outW - dw) / 2;
|
||
const oy = (outH - dh) / 2;
|
||
return {
|
||
outW,
|
||
outH,
|
||
mapToInventory: (sx, sy) => [ox + (sx - x0) * scale, oy + (sy - y0) * scale],
|
||
};
|
||
}
|
||
|
||
export interface EncodeOverlayInput {
|
||
/** Clean calibration inventory PNG bytes. */
|
||
inventoryPng: Blob;
|
||
bitset: Uint8Array;
|
||
widthPx: number;
|
||
heightPx: number;
|
||
bbox: BBox;
|
||
scale: number;
|
||
lengthIn: number;
|
||
lengthLabel: string;
|
||
slabBounds: { x: number; y: number; w: number; h: number };
|
||
source: { widthPx: number; heightPx: number };
|
||
}
|
||
|
||
/**
|
||
* Burn black dimension overlays onto a copy of the clean calibration inventory PNG.
|
||
* Returns a new image/png blob; never mutates the clean inventory.
|
||
*/
|
||
export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob> {
|
||
const bmp = await createImageBitmap(input.inventoryPng);
|
||
try {
|
||
const outW = bmp.width;
|
||
const outH = bmp.height;
|
||
const canvas = new OffscreenCanvas(outW, outH);
|
||
const ctx = canvas.getContext('2d')!;
|
||
ctx.drawImage(bmp, 0, 0);
|
||
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH);
|
||
const plan = buildOverlayPlan({
|
||
bitset: input.bitset,
|
||
widthPx: input.widthPx,
|
||
heightPx: input.heightPx,
|
||
bbox: input.bbox,
|
||
scale: input.scale,
|
||
lengthIn: input.lengthIn,
|
||
lengthLabel: input.lengthLabel,
|
||
mapToInventory,
|
||
inventoryW: outW,
|
||
inventoryH: outH,
|
||
});
|
||
drawOverlayPlan(ctx, plan);
|
||
return await canvas.convertToBlob({ type: 'image/png' });
|
||
} finally {
|
||
bmp.close();
|
||
}
|
||
}
|