fix(photos): width-aware tips, stroke inset, smaller in-bounds labels (#71)
Root cause: 0.2%/99.8% axial percentiles still included dense fringe past the visible body; round line caps and scale-tied font overshot edges and clipped width labels. Use across-width tip stations, inset length/width ends for stroke/arrowhead, butt caps, inventory-capped font, axis-right length label, clamp labels to PNG.
This commit is contained in:
@@ -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)})`);
|
||||
}
|
||||
@@ -794,7 +838,7 @@ test('buildOverlayPlan: width endpoints on/inside wood edges with stroke inset (
|
||||
`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}`);
|
||||
assert.ok(planSpan >= fullSpan - 20, `inset must be modest, got plan=${planSpan} full=${fullSpan}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user