fix(photos): preserve tapered tips and dark live edge (#73 #74)

Lower the axial body threshold while retaining fringe rejection, minimize length-only tip inset, and make sheet sensitivity curves testable and conservative at zero.
This commit is contained in:
2026-09-16 15:18:06 +00:00
parent 037a4b870a
commit 03d3fcf460
5 changed files with 281 additions and 36 deletions
+17 -8
View File
@@ -82,7 +82,13 @@ export interface OverlayPlanInput {
}
/**
* Axial tip stations from across-width profile (#71).
* 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.
*/
@@ -121,8 +127,8 @@ function widthAwareAxialRange(
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);
// 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;
@@ -282,7 +288,10 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
// 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);
// 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[] = [];
@@ -300,8 +309,8 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
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];
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];
@@ -337,8 +346,8 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
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 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);