test(photos): overlay tips on footprint + labels in-bounds (#71)
Failing UAT proofs: length span must exclude fringe whiskers past solid body; width endpoints inset for stroke/arrowhead; smaller font; labels clamped inside inventory PNG.
This commit is contained in:
@@ -599,3 +599,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 - 8, `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)})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user