Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8e69b5239 | |||
| 495f179c2f | |||
| b386ab9670 |
@@ -138,7 +138,7 @@ test('buildOverlayPlan: length line on axis-right outside slab; widths at 12in s
|
||||
|
||||
// Color contract for draw consumers
|
||||
assert.equal(plan.stroke, '#000000');
|
||||
assert.ok(plan.fontPx >= 18, `fontPx ${plan.fontPx} large enough to read`);
|
||||
assert.ok(plan.fontPx >= 12, `fontPx ${plan.fontPx} large enough to read`);
|
||||
});
|
||||
|
||||
test('buildOverlayPlan: stations measured from EXIF bottom, not tip-inclusive M1 every-6 (#63)', () => {
|
||||
@@ -272,7 +272,7 @@ test('buildOverlayPlan: width labels sit axis-left outside the slab (#67)', () =
|
||||
assert.ok(lenY < 50 - 4, `length dim stays axis-right outside top edge, got y=${lenY}`);
|
||||
});
|
||||
|
||||
test('buildOverlayPlan: font one step smaller than prior 28 / scale*1.2 (#67)', () => {
|
||||
test('buildOverlayPlan: font smaller than prior 28/scale*1.2 and 22/scale*1.0 (#67,#71)', () => {
|
||||
const W = 480, H = 200;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let y = 50; y < 150; y++) for (let x = 0; x < W; x++) bitset[y * W + x] = 1;
|
||||
@@ -288,11 +288,13 @@ test('buildOverlayPlan: font one step smaller than prior 28 / scale*1.2 (#67)',
|
||||
lengthLabel: '48"',
|
||||
mapToInventory: (x, y) => [x, y],
|
||||
});
|
||||
const oldFont = Math.max(28, Math.round(scale * 1.2));
|
||||
assert.ok(plan.fontPx < oldFont, `fontPx ${plan.fontPx} must be < prior ${oldFont}`);
|
||||
assert.ok(plan.fontPx >= 18, `fontPx ${plan.fontPx} still legible`);
|
||||
// Concrete contract: max(22, round(scale*1.0))
|
||||
assert.equal(plan.fontPx, Math.max(22, Math.round(scale * 1.0)));
|
||||
const oldFont67 = Math.max(28, Math.round(scale * 1.2));
|
||||
const oldFont71 = Math.max(22, Math.round(scale * 1.0));
|
||||
assert.ok(plan.fontPx < oldFont67, `fontPx ${plan.fontPx} must be < prior ${oldFont67}`);
|
||||
assert.ok(plan.fontPx < oldFont71, `fontPx ${plan.fontPx} must be < prior ${oldFont71}`);
|
||||
assert.ok(plan.fontPx >= 12, `fontPx ${plan.fontPx} still legible`);
|
||||
// Concrete contract (#71): capped by scale when no inventory size
|
||||
assert.equal(plan.fontPx, Math.max(12, Math.min(16, Math.round(scale * 0.18))));
|
||||
});
|
||||
|
||||
|
||||
@@ -371,28 +373,47 @@ test('buildOverlayPlan: length axial span = robust tip stations; no AABB oversho
|
||||
}
|
||||
}
|
||||
|
||||
// Robust tip stations along approved bbox axis (0.2% / 99.8%)
|
||||
// Width-aware tip stations along approved bbox axis (#71; was 0.2%/99.8%)
|
||||
const { start: aa, end: bb } = axisEndpoints(bbox);
|
||||
const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1;
|
||||
const ux = ex / elen, uy = ey / elen;
|
||||
const cx = bbox.corners.reduce((s, c) => s + c[0], 0) / 4;
|
||||
const cy = bbox.corners.reduce((s, c) => s + c[1], 0) / 4;
|
||||
const cProj = cx * ux + cy * uy;
|
||||
const projs: number[] = [];
|
||||
const rx = -uy, ry = ux;
|
||||
const binMin = new Map<number, number>();
|
||||
const binMax = new Map<number, number>();
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!bitset[y * W + x]) continue;
|
||||
projs.push(x * ux + y * uy);
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
const tipMin = projs[lo];
|
||||
const tipMax = projs[hi];
|
||||
// Centerline tip positions (same as maskAxisSegment start/end)
|
||||
const tipStart: [number, number] = [cx + ux * (tipMin - cProj), cy + uy * (tipMin - cProj)];
|
||||
const tipEnd: [number, number] = [cx + ux * (tipMax - cProj), cy + uy * (tipMax - cProj)];
|
||||
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;
|
||||
}
|
||||
const thresh = Math.max(4, maxWidth * 0.32);
|
||||
widths.sort((a, b) => a.bin - b.bin);
|
||||
let tipLo = widths[0].bin, 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; }
|
||||
}
|
||||
const tipStart: [number, number] = [cx + ux * (tipLo - cProj), cy + uy * (tipLo - cProj)];
|
||||
const tipEnd: [number, number] = [cx + ux * (tipHi - cProj), cy + uy * (tipHi - cProj)];
|
||||
const oriented = pickBottomTowardTop(tipStart, tipEnd);
|
||||
|
||||
// Inventory map is isotropic scale+translate → directions preserved; axial span scales.
|
||||
@@ -400,13 +421,13 @@ test('buildOverlayPlan: length axial span = robust tip stations; no AABB oversho
|
||||
const mapTop = inv.mapToInventory(oriented.top[0], oriented.top[1]);
|
||||
const tipAxial = Math.hypot(mapTop[0] - mapBot[0], mapTop[1] - mapBot[1]);
|
||||
const lenAxial = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]);
|
||||
// Axial edge-inset (#71) shortens span slightly vs tip stations
|
||||
assert.ok(
|
||||
Math.abs(lenAxial - tipAxial) <= 2,
|
||||
`length span ${lenAxial.toFixed(1)} must equal centerline tip span ${tipAxial.toFixed(1)} (no speck overshoot)`,
|
||||
lenAxial <= tipAxial + 2 && lenAxial >= tipAxial - 24,
|
||||
`length span ${lenAxial.toFixed(1)} must match width-aware tip span ${tipAxial.toFixed(1)} (allow inset)`,
|
||||
);
|
||||
|
||||
// Axial stations of length ends = robust tip stations (pure axis-right offset does not change along-u).
|
||||
// Inventory map is isotropic scale+translate, so axis direction is preserved.
|
||||
// Axial stations of length ends = tip stations (pure axis-right offset does not change along-u).
|
||||
const mapOrigin = inv.mapToInventory(0, 0);
|
||||
const mapU = inv.mapToInventory(oriented.ux, oriented.uy);
|
||||
const iux = mapU[0] - mapOrigin[0];
|
||||
@@ -417,8 +438,8 @@ test('buildOverlayPlan: length axial span = robust tip stations; no AABB oversho
|
||||
(a, b) => a - b,
|
||||
);
|
||||
const tipProjs = [mapBot[0] * nux + mapBot[1] * nuy, mapTop[0] * nux + mapTop[1] * nuy].sort((a, b) => a - b);
|
||||
assert.ok(Math.abs(lenProjs[0] - tipProjs[0]) <= 2, `length min station ${lenProjs[0]} ≈ tip ${tipProjs[0]}`);
|
||||
assert.ok(Math.abs(lenProjs[1] - tipProjs[1]) <= 2, `length max station ${lenProjs[1]} ≈ tip ${tipProjs[1]}`);
|
||||
assert.ok(Math.abs(lenProjs[0] - tipProjs[0]) <= 14, `length min station ${lenProjs[0]} ≈ tip ${tipProjs[0]} (inset)`);
|
||||
assert.ok(Math.abs(lenProjs[1] - tipProjs[1]) <= 14, `length max station ${lenProjs[1]} ≈ tip ${tipProjs[1]} (inset)`);
|
||||
|
||||
// Length stays axis-right of wood (clearance to the right in inventory x for this fixture)
|
||||
const lenMidX = (plan.length.a[0] + plan.length.b[0]) / 2;
|
||||
@@ -542,18 +563,40 @@ test('buildOverlayPlan: length a→b || approved (ux,uy); width left→right ||
|
||||
const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1;
|
||||
const uxB = ex / elen, uyB = ey / elen;
|
||||
const cProj = cx * uxB + cy * uyB;
|
||||
const projs: number[] = [];
|
||||
const rxB = -uyB, ryB = uxB;
|
||||
const binMin = new Map<number, number>();
|
||||
const binMax = new Map<number, number>();
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!bitset[y * W + x]) continue;
|
||||
projs.push(x * uxB + y * uyB);
|
||||
const along = x * uxB + y * uyB;
|
||||
const across = (x - cx) * rxB + (y - cy) * ryB;
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
const tipStart: [number, number] = [cx + uxB * (projs[lo] - cProj), cy + uyB * (projs[lo] - cProj)];
|
||||
const tipEnd: [number, number] = [cx + uxB * (projs[hi] - cProj), cy + uyB * (projs[hi] - cProj)];
|
||||
let maxW = 0;
|
||||
const wbins: { bin: number; width: number }[] = [];
|
||||
for (const [bin, minA] of binMin) {
|
||||
const maxA = binMax.get(bin)!;
|
||||
const width = maxA - minA + 1;
|
||||
wbins.push({ bin, width });
|
||||
if (width > maxW) maxW = width;
|
||||
}
|
||||
const thresh = Math.max(4, maxW * 0.32);
|
||||
wbins.sort((a, b) => a.bin - b.bin);
|
||||
let tipLo = wbins[0].bin, tipHi = wbins[wbins.length - 1].bin;
|
||||
for (const { bin, width } of wbins) {
|
||||
if (width >= thresh) { tipLo = bin; break; }
|
||||
}
|
||||
for (let i = wbins.length - 1; i >= 0; i--) {
|
||||
if (wbins[i].width >= thresh) { tipHi = wbins[i].bin; break; }
|
||||
}
|
||||
const tipStart: [number, number] = [cx + uxB * (tipLo - cProj), cy + uyB * (tipLo - cProj)];
|
||||
const tipEnd: [number, number] = [cx + uxB * (tipHi - cProj), cy + uyB * (tipHi - cProj)];
|
||||
const oriented = pickBottomTowardTop(tipStart, tipEnd);
|
||||
const { ux, uy, bottom, top } = oriented;
|
||||
const [rx, ry] = axisRightUnit(ux, uy);
|
||||
@@ -577,9 +620,10 @@ test('buildOverlayPlan: length a→b || approved (ux,uy); width left→right ||
|
||||
const oy = endPt[1] - tip[1];
|
||||
const along = ox * ux + oy * uy;
|
||||
const across = ox * rx + oy * ry;
|
||||
// Pure axis-right plus small axial inset toward center (#71 edgeInset)
|
||||
assert.ok(
|
||||
Math.abs(along) <= 1.5,
|
||||
`length end offset must be pure axis-right (along=${along.toFixed(2)} must be ~0; tip=(${tip[0].toFixed(1)},${tip[1].toFixed(1)}))`,
|
||||
Math.abs(along) <= 4,
|
||||
`length end offset must be axis-right (+tiny axial inset) (along=${along.toFixed(2)}; tip=(${tip[0].toFixed(1)},${tip[1].toFixed(1)}))`,
|
||||
);
|
||||
assert.ok(across > 8, `length end must sit axis-right of centerline tip (across=${across.toFixed(1)})`);
|
||||
}
|
||||
@@ -599,3 +643,280 @@ test('buildOverlayPlan: length a→b || approved (ux,uy); width left→right ||
|
||||
assert.ok(Math.abs(perp) < 0.02, `width must be ⊥ length axis (dot=${perp.toFixed(4)})`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Dim tips on footprint; labels in-bounds; smaller font (#71) ---
|
||||
|
||||
/**
|
||||
* Solid body + dense fringe whiskers past the visual tips.
|
||||
* 0.2%/99.8% axial percentiles still land in the fringe (overshoot);
|
||||
* width-aware tip finding must snap to the solid body ends.
|
||||
*/
|
||||
function fringeTipFixture() {
|
||||
const W = 280, H = 560;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
// Solid body: x=90..190, y=120..440
|
||||
for (let y = 120; y <= 440; y++) {
|
||||
for (let x = 90; x <= 190; x++) bitset[y * W + x] = 1;
|
||||
}
|
||||
// Dense fringe whiskers (narrower than body) past top/bottom — denser than 0.2%
|
||||
for (let y = 95; y < 120; y++) {
|
||||
for (let x = 130; x <= 150; x++) bitset[y * W + x] = 1;
|
||||
}
|
||||
for (let y = 441; y <= 465; y++) {
|
||||
for (let x = 130; x <= 150; x++) bitset[y * W + x] = 1;
|
||||
}
|
||||
// Approved axis ~vertical (slight tilt)
|
||||
const ang = (8 * Math.PI) / 180;
|
||||
const ux0 = Math.sin(ang);
|
||||
const uy0 = Math.cos(ang);
|
||||
const rx0 = -uy0;
|
||||
const ry0 = ux0;
|
||||
const cx = 140, cy = 280, halfL = 200, halfW = 70;
|
||||
const corners: [number, number][] = [
|
||||
[cx + ux0 * halfL + rx0 * halfW, cy + uy0 * halfL + ry0 * halfW],
|
||||
[cx + ux0 * halfL - rx0 * halfW, cy + uy0 * halfL - ry0 * halfW],
|
||||
[cx - ux0 * halfL - rx0 * halfW, cy - uy0 * halfL - ry0 * halfW],
|
||||
[cx - ux0 * halfL + rx0 * halfW, cy - uy0 * halfL + ry0 * halfW],
|
||||
];
|
||||
const bbox = { corners, longSidePx: halfL * 2, angleDeg: 90 - 8 };
|
||||
return { W, H, bitset, bbox, solidMinY: 120, solidMaxY: 440, ux0, uy0, cx, cy };
|
||||
}
|
||||
|
||||
test('buildOverlayPlan: length span ≤ solid-body axial extent; fringe whiskers excluded (#71)', () => {
|
||||
const { W, H, bitset, bbox, solidMinY, solidMaxY, ux0, uy0, cx, cy } = fringeTipFixture();
|
||||
const scale = 10;
|
||||
const wood = maskWoodAabb(bitset, W, H);
|
||||
const slabBounds = {
|
||||
x: wood.minX - 10,
|
||||
y: wood.minY - 10,
|
||||
w: wood.maxX - wood.minX + 20,
|
||||
h: wood.maxY - wood.minY + 20,
|
||||
};
|
||||
const outW = 900, outH = 1200;
|
||||
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, outW, outH);
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox,
|
||||
scale,
|
||||
lengthIn: 32,
|
||||
lengthLabel: '32"',
|
||||
mapToInventory: inv.mapToInventory,
|
||||
inventoryW: outW,
|
||||
inventoryH: outH,
|
||||
});
|
||||
|
||||
// Solid-body axial projections (exclude fringe by y gate on this fixture)
|
||||
const solidProjs: number[] = [];
|
||||
for (let y = solidMinY; y <= solidMaxY; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!bitset[y * W + x]) continue;
|
||||
solidProjs.push(x * ux0 + y * uy0);
|
||||
}
|
||||
}
|
||||
solidProjs.sort((a, b) => a - b);
|
||||
const solidMin = solidProjs[0];
|
||||
const solidMax = solidProjs[solidProjs.length - 1];
|
||||
const cProj = cx * ux0 + cy * uy0;
|
||||
const solidBot: [number, number] = [cx + ux0 * (solidMin - cProj), cy + uy0 * (solidMin - cProj)];
|
||||
const solidTop: [number, number] = [cx + ux0 * (solidMax - cProj), cy + uy0 * (solidMax - cProj)];
|
||||
const oriented = pickBottomTowardTop(solidBot, solidTop);
|
||||
const mapBot = inv.mapToInventory(oriented.bottom[0], oriented.bottom[1]);
|
||||
const mapTop = inv.mapToInventory(oriented.top[0], oriented.top[1]);
|
||||
const solidAxial = Math.hypot(mapTop[0] - mapBot[0], mapTop[1] - mapBot[1]);
|
||||
const lenAxial = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]);
|
||||
|
||||
// Length must not exceed solid body (percentile fringe would overshoot by ≫2px)
|
||||
assert.ok(
|
||||
lenAxial <= solidAxial + 2,
|
||||
`length span ${lenAxial.toFixed(1)} must be ≤ solid-body span ${solidAxial.toFixed(1)} (fringe excluded)`,
|
||||
);
|
||||
|
||||
// Prove 0.2%/99.8% still overshoots solid body — so this fixture is meaningful
|
||||
const allProjs: number[] = [];
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!bitset[y * W + x]) continue;
|
||||
allProjs.push(x * ux0 + y * uy0);
|
||||
}
|
||||
}
|
||||
allProjs.sort((a, b) => a - b);
|
||||
const lo = Math.min(Math.floor(allProjs.length * 0.002), allProjs.length - 1);
|
||||
const hi = Math.max(Math.floor(allProjs.length * 0.998), lo);
|
||||
const pctSpan = allProjs[hi] - allProjs[lo];
|
||||
const solidSpan = solidMax - solidMin;
|
||||
assert.ok(
|
||||
pctSpan > solidSpan + 5,
|
||||
`fixture sanity: percentile span ${pctSpan.toFixed(1)} must overshoot solid ${solidSpan.toFixed(1)}`,
|
||||
);
|
||||
|
||||
// Still || approved axis (keep #70)
|
||||
const ldx = plan.length.b[0] - plan.length.a[0];
|
||||
const ldy = plan.length.b[1] - plan.length.a[1];
|
||||
const llen = Math.hypot(ldx, ldy) || 1;
|
||||
const mapOrigin = inv.mapToInventory(0, 0);
|
||||
const mapU = inv.mapToInventory(oriented.ux, oriented.uy);
|
||||
const iux = mapU[0] - mapOrigin[0];
|
||||
const iuy = mapU[1] - mapOrigin[1];
|
||||
const ilen = Math.hypot(iux, iuy) || 1;
|
||||
const lengthDot = (ldx / llen) * (iux / ilen) + (ldy / llen) * (iuy / ilen);
|
||||
assert.ok(lengthDot > 0.999, `length must stay || axis (dot=${lengthDot.toFixed(6)})`);
|
||||
});
|
||||
|
||||
test('buildOverlayPlan: width endpoints on/inside wood edges with stroke inset (#71)', () => {
|
||||
const { W, H, bitset, bbox } = fringeTipFixture();
|
||||
const scale = 10;
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox,
|
||||
scale,
|
||||
lengthIn: 32,
|
||||
lengthLabel: '32"',
|
||||
mapToInventory: (x, y) => [x, y],
|
||||
inventoryW: W,
|
||||
inventoryH: H,
|
||||
});
|
||||
assert.ok(plan.widths.length > 0, 'expected width stations');
|
||||
|
||||
const wood = maskWoodAabb(bitset, W, H);
|
||||
// Rebuild oriented axis to re-probe full mask extent at each station
|
||||
const { start: aa, end: bb } = axisEndpoints(bbox);
|
||||
const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1;
|
||||
const uxB = ex / elen, uyB = ey / elen;
|
||||
const cx = bbox.corners.reduce((s, c) => s + c[0], 0) / 4;
|
||||
const cy = bbox.corners.reduce((s, c) => s + c[1], 0) / 4;
|
||||
const cProj = cx * uxB + cy * uyB;
|
||||
// Width-aware tips from the same solid body y-range as fringeTipFixture
|
||||
const solidProjs: number[] = [];
|
||||
for (let y = 120; y <= 440; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!bitset[y * W + x]) continue;
|
||||
solidProjs.push(x * uxB + y * uyB);
|
||||
}
|
||||
}
|
||||
solidProjs.sort((a, b) => a - b);
|
||||
const tipStart: [number, number] = [cx + uxB * (solidProjs[0] - cProj), cy + uyB * (solidProjs[0] - cProj)];
|
||||
const tipEnd: [number, number] = [cx + uxB * (solidProjs[solidProjs.length - 1] - cProj), cy + uyB * (solidProjs[solidProjs.length - 1] - cProj)];
|
||||
const oriented = pickBottomTowardTop(tipStart, tipEnd);
|
||||
const { ux, uy, bottom } = oriented;
|
||||
const [rx, ry] = axisRightUnit(ux, uy);
|
||||
|
||||
for (const w of plan.widths) {
|
||||
assert.ok(
|
||||
w.left[0] >= wood.minX - 1 && w.left[0] <= wood.maxX + 1 &&
|
||||
w.left[1] >= wood.minY - 1 && w.left[1] <= wood.maxY + 1,
|
||||
`width left must be on/inside wood AABB`,
|
||||
);
|
||||
assert.ok(
|
||||
w.right[0] >= wood.minX - 1 && w.right[0] <= wood.maxX + 1 &&
|
||||
w.right[1] >= wood.minY - 1 && w.right[1] <= wood.maxY + 1,
|
||||
`width right must be on/inside wood AABB`,
|
||||
);
|
||||
// Full mask probe at this station (no inset)
|
||||
const t = w.stationIn * scale;
|
||||
const ox = bottom[0] + ux * t;
|
||||
const oy = bottom[1] + uy * t;
|
||||
const half = Math.max(2, Math.round((W + H) / 4));
|
||||
let minP = Infinity, maxP = -Infinity;
|
||||
for (let p = -half; p <= half; p++) {
|
||||
const x = Math.round(ox + rx * p);
|
||||
const y = Math.round(oy + ry * 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;
|
||||
}
|
||||
}
|
||||
const fullSpan = maxP - minP;
|
||||
const planSpan = Math.hypot(w.right[0] - w.left[0], w.right[1] - w.left[1]);
|
||||
// Inset ≥ ~0.5px each end so stroke/arrowhead visual tips do not past the edge
|
||||
assert.ok(
|
||||
planSpan <= fullSpan - 0.9,
|
||||
`width span ${planSpan.toFixed(2)} must be inset from full mask ${fullSpan} (stroke/arrowhead)`,
|
||||
);
|
||||
// Tips still near the edge (not collapsed)
|
||||
assert.ok(planSpan >= fullSpan - 20, `inset must be modest, got plan=${planSpan} full=${fullSpan}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildOverlayPlan: labels inside inventory PNG; length off wood; smaller font (#71)', () => {
|
||||
const { W, H, bitset, bbox } = fringeTipFixture();
|
||||
const scale = 10;
|
||||
const wood = maskWoodAabb(bitset, W, H);
|
||||
// Tight crop so axis-left width labels would clip without clamping
|
||||
const slabBounds = {
|
||||
x: wood.minX - 4,
|
||||
y: wood.minY - 4,
|
||||
w: wood.maxX - wood.minX + 8,
|
||||
h: wood.maxY - wood.minY + 8,
|
||||
};
|
||||
const outW = 600, outH = 800;
|
||||
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, outW, outH);
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox,
|
||||
scale,
|
||||
lengthIn: 32,
|
||||
lengthLabel: '32"',
|
||||
mapToInventory: inv.mapToInventory,
|
||||
inventoryW: outW,
|
||||
inventoryH: outH,
|
||||
});
|
||||
|
||||
// Font: one consistent smaller size than prior max(22, scale*1.0)
|
||||
const priorFont = Math.max(22, Math.round(scale * 1.0));
|
||||
assert.ok(plan.fontPx < priorFont, `fontPx ${plan.fontPx} must be < prior ${priorFont}`);
|
||||
assert.ok(plan.fontPx >= 12, `fontPx ${plan.fontPx} still readable`);
|
||||
|
||||
// Wood AABB in inventory
|
||||
let mMinX = Infinity, mMaxX = -Infinity, mMinY = Infinity, mMaxY = -Infinity;
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!bitset[y * W + x]) continue;
|
||||
const [mx, my] = inv.mapToInventory(x, y);
|
||||
if (mx < mMinX) mMinX = mx;
|
||||
if (mx > mMaxX) mMaxX = mx;
|
||||
if (my < mMinY) mMinY = my;
|
||||
if (my > mMaxY) mMaxY = my;
|
||||
}
|
||||
}
|
||||
|
||||
// Length label axis-right off wood
|
||||
const lenLabel = plan.length.labelAt;
|
||||
assert.ok(
|
||||
lenLabel[0] > mMaxX + 2 || lenLabel[1] < mMinY - 2 || lenLabel[1] > mMaxY + 2,
|
||||
`length label (${lenLabel[0].toFixed(0)},${lenLabel[1].toFixed(0)}) must be off wood AABB`,
|
||||
);
|
||||
// Glyph margin: keep anchors inside canvas
|
||||
const margin = Math.max(8, plan.fontPx * 0.6);
|
||||
assert.ok(lenLabel[0] >= margin && lenLabel[0] <= outW - margin, `length label x in canvas`);
|
||||
assert.ok(lenLabel[1] >= margin && lenLabel[1] <= outH - margin, `length label y in canvas`);
|
||||
|
||||
for (const w of plan.widths) {
|
||||
// Width labels axis-left off wood
|
||||
assert.ok(
|
||||
w.labelAt[0] < mMinX - 1 || w.labelAt[1] < mMinY - 1 || w.labelAt[1] > mMaxY + 1,
|
||||
`width label ${w.label} must be off wood`,
|
||||
);
|
||||
// Clamped inside inventory PNG (anchor + estimated glyph extent toward axis-left)
|
||||
const glyphPad = plan.fontPx * Math.max(2, w.label.length * 0.55);
|
||||
assert.ok(
|
||||
w.labelAt[0] >= margin && w.labelAt[0] <= outW - margin,
|
||||
`width label x=${w.labelAt[0].toFixed(0)} must be inside canvas [0,${outW}]`,
|
||||
);
|
||||
assert.ok(
|
||||
w.labelAt[1] >= margin && w.labelAt[1] <= outH - margin,
|
||||
`width label y=${w.labelAt[1].toFixed(0)} must be inside canvas [0,${outH}]`,
|
||||
);
|
||||
// textAlign right grows left — left extent of glyphs must stay ≥ 0
|
||||
assert.ok(
|
||||
w.labelAt[0] - glyphPad >= -1,
|
||||
`width label glyphs must not clip left edge (anchor=${w.labelAt[0].toFixed(0)}, pad=${glyphPad.toFixed(0)})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -76,6 +76,69 @@ export interface OverlayPlanInput {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Axial tip stations from across-width profile (#71).
|
||||
* 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 narrow fringe; keep natural mild tapers.
|
||||
const thresh = Math.max(4, maxWidth * 0.32);
|
||||
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(
|
||||
@@ -86,7 +149,7 @@ function maskAxisSegment(
|
||||
): {
|
||||
start: [number, number];
|
||||
end: [number, number];
|
||||
/** Actual mask pixels achieving min/max axial projection (wood tips). */
|
||||
/** Actual mask pixels nearest centerline at tip stations. */
|
||||
tipStart: [number, number];
|
||||
tipEnd: [number, number];
|
||||
ux: number;
|
||||
@@ -102,24 +165,30 @@ function maskAxisSegment(
|
||||
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;
|
||||
// Collect axial projections; absolute min/max tip pixels can be mask specks
|
||||
// past the visible wood footprint (Ty UAT #69). Use robust percentiles and
|
||||
// pick tip pixels near the centerline at those stations.
|
||||
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);
|
||||
// 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];
|
||||
}
|
||||
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);
|
||||
const minP = projs[lo];
|
||||
const maxP = projs[hi];
|
||||
// Tip pixel at each robust station: closest to the axis centerline
|
||||
// Tip pixel at each station: closest to the axis centerline
|
||||
let tipStart: [number, number] = a;
|
||||
let tipEnd: [number, number] = b;
|
||||
let bestStart = Infinity;
|
||||
@@ -130,7 +199,7 @@ function maskAxisSegment(
|
||||
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); // |(p - c) cross u| via r
|
||||
const across = Math.abs((x - cx) * -uy + (y - cy) * ux);
|
||||
if (Math.abs(p - minP) <= stationTol && across < bestStart) {
|
||||
bestStart = across;
|
||||
tipStart = [x, y];
|
||||
@@ -191,7 +260,10 @@ function fmtInches(inches: number): string {
|
||||
}
|
||||
|
||||
export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
const { bitset, widthPx, heightPx, bbox, scale, lengthIn, lengthLabel, mapToInventory } = input;
|
||||
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).
|
||||
@@ -199,7 +271,19 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
const { bottom, top, ux, uy } = oriented;
|
||||
const [rx, ry] = axisRightUnit(ux, uy);
|
||||
|
||||
const pad = Math.max(16, scale * 0.75);
|
||||
// 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);
|
||||
const edgeInset = Math.max(lineW / 2 + 0.5, arrowHead * 0.15);
|
||||
const pad = Math.max(10, Math.min(28, fontPx * 1.1));
|
||||
const stations = overlayWidthStations(lengthIn);
|
||||
const widths: OverlayWidthMark[] = [];
|
||||
for (const stationIn of stations) {
|
||||
@@ -208,19 +292,25 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
const oy = bottom[1] + uy * t;
|
||||
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
|
||||
if (!extent) continue;
|
||||
// left = opposite of axis-right; right = axis-right tip on slab edge
|
||||
const leftSrc: [number, number] = [ox + rx * extent.minP, oy + ry * extent.minP];
|
||||
const rightSrc: [number, number] = [ox + rx * extent.maxP, oy + ry * extent.maxP];
|
||||
// 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 * edgeInset, leftMapped[1] + wuy * edgeInset];
|
||||
const right: [number, number] = [rightMapped[0] - wux * edgeInset, rightMapped[1] - wuy * edgeInset];
|
||||
// Width number sits axis-left outside the slab (past left tip).
|
||||
// Extra clearance so glyph box (textAlign grows away from wood) stays off footprint.
|
||||
const labelOff = extent.minP - pad - scale * 0.4 - Math.max(22, Math.round(scale * 1.0)) * 0.35;
|
||||
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: mapToInventory(leftSrc[0], leftSrc[1]),
|
||||
right: mapToInventory(rightSrc[0], rightSrc[1]),
|
||||
left,
|
||||
right,
|
||||
labelAt: mapToInventory(labelSrcW[0], labelSrcW[1]),
|
||||
label: fmtInches(widthIn),
|
||||
});
|
||||
@@ -237,28 +327,66 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
if (extent && extent.maxP > maxRight) maxRight = extent.maxP;
|
||||
}
|
||||
const outward = maxRight + pad;
|
||||
// Classic dimensioning (#70): centerline tip stations (robust 0.2%/99.8% axial
|
||||
// percentiles from maskAxisSegment — keeps #69 speck immunity) offset by pure
|
||||
// axis-right (rx,ry)*outward. Length stays || approved (ux,uy); no screen-only
|
||||
// tip translation (that made a→b follow tip-to-tip ≠ axis on irregular wood).
|
||||
const aSrc: [number, number] = [bottom[0] + rx * outward, bottom[1] + ry * outward];
|
||||
const bSrc: [number, number] = [top[0] + rx * outward, top[1] + ry * outward];
|
||||
const midSrc: [number, number] = [(aSrc[0] + bSrc[0]) / 2, (aSrc[1] + bSrc[1]) / 2];
|
||||
// Nudge label further outward a bit (axis-right of dim line)
|
||||
const labelSrc: [number, number] = [midSrc[0] + rx * (scale * 0.4), midSrc[1] + ry * (scale * 0.4)];
|
||||
// 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 * edgeInset, aMapped[1] + luy * edgeInset];
|
||||
const bInv: [number, number] = [bMapped[0] - lux * edgeInset, bMapped[1] - luy * edgeInset];
|
||||
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 fontPx = Math.max(22, Math.round(scale * 1.0));
|
||||
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: {
|
||||
a: mapToInventory(aSrc[0], aSrc[1]),
|
||||
b: mapToInventory(bSrc[0], bSrc[1]),
|
||||
labelAt: mapToInventory(labelSrc[0], labelSrc[1]),
|
||||
label: lengthLabel,
|
||||
outside: true,
|
||||
},
|
||||
length: lengthMark,
|
||||
widths,
|
||||
};
|
||||
}
|
||||
@@ -272,8 +400,9 @@ export function drawOverlayPlan(
|
||||
ctx.save();
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.fillStyle = stroke;
|
||||
ctx.lineWidth = Math.max(3, Math.round(fontPx / 10));
|
||||
ctx.lineCap = 'round';
|
||||
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';
|
||||
@@ -284,7 +413,7 @@ export function drawOverlayPlan(
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / len;
|
||||
const uy = dy / len;
|
||||
const head = Math.max(10, fontPx * 0.35);
|
||||
const head = Math.max(7, fontPx * 0.32);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from[0], from[1]);
|
||||
ctx.lineTo(to[0], to[1]);
|
||||
@@ -305,9 +434,22 @@ export function drawOverlayPlan(
|
||||
}
|
||||
};
|
||||
|
||||
// Length
|
||||
// Length — grow text further axis-right of the dim line (away from wood)
|
||||
arrow(plan.length.a, plan.length.b);
|
||||
ctx.textAlign = 'center';
|
||||
{
|
||||
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)
|
||||
@@ -390,6 +532,8 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
|
||||
lengthIn: input.lengthIn,
|
||||
lengthLabel: input.lengthLabel,
|
||||
mapToInventory,
|
||||
inventoryW: outW,
|
||||
inventoryH: outH,
|
||||
});
|
||||
drawOverlayPlan(ctx, plan);
|
||||
return await canvas.convertToBlob({ type: 'image/png' });
|
||||
|
||||
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 466 KiB |
@@ -1,11 +1,11 @@
|
||||
# Last green e2e run (#70)
|
||||
# Last green e2e run (#71)
|
||||
|
||||
- **When:** 2026-09-15 9:36 PM PT (`2026-09-16T04:36:47Z`)
|
||||
- **When:** 2026-09-15 9:55 PM PT (`2026-09-16T04:55:14Z`)
|
||||
- **Fixture:** `tests/fixtures/set1-48x2/` (L=48, thickness=2)
|
||||
- **Server:** local `vite preview` at `http://127.0.0.1:4173` (not ubudual)
|
||||
- **Result:** 1 passed (~8s test)
|
||||
- **Mask:** Black sheet — Approve enabled
|
||||
- **Fix under test:** length dim || approved axis; widths ⊥ axis (#70); #69 robust tip stations kept
|
||||
- **Fix under test:** width-aware tip stations; stroke/arrowhead edge inset; inventory-capped font; labels clamped in PNG (#71); #70 axis-parallel kept
|
||||
- **Lineup:** Clean calibration → Dimension overlay → Batch photo 2
|
||||
|
||||
Full-resolution PNGs are produced under `../` on each run (gitignored). These JPEGs are commit-sized evidence for review.
|
||||
|
||||