2 Commits

Author SHA1 Message Date
grokbot_ty 9bc35f7edd fix(photos): overlay UAT nits + 90% crop fill (#67,#68)
Length dim already mask tip-to-tip (locked by test). Width labels move
axis-left off the wood via labelAt; font steps down to max(22, scale*1.0).
Inventory/overlay crop FILL 0.8→0.9; CAPTURE/PHOTOS docs match.
2026-09-15 21:46:58 +00:00
grokbot_ty 15895cae0c test(photos): overlay UAT nits + 90% fill expect (#67,#68)
Lock length dim to mask tip-to-tip (not oversized bbox), require width
labelAt on axis-left off the wood, smaller font contract, and 90% crop
fill extent. Red before green.
2026-09-15 21:46:27 +00:00
6 changed files with 127 additions and 14 deletions
+4 -4
View File
@@ -34,7 +34,7 @@ Otherwise the app opens Add photo.
4. App draws the minimum-area bounding rectangle of the mask and the length axis on the long side of that rectangle. User rotates the axis if it does not follow the slab length, then approves the axis. 4. App draws the minimum-area bounding rectangle of the mask and the length axis on the long side of that rectangle. User rotates the axis if it does not follow the slab length, then approves the axis.
5. User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate. 5. User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate.
6. App sets `scale = longest_axis_px / L` (mask/wood span along the approved direction ÷ user `L`; rectangle is direction only). 6. App sets `scale = longest_axis_px / L` (mask/wood span along the approved direction ÷ user `L`; rectangle is direction only).
7. App computes widths, sqft, and bdft from the mask. App crops the masked slab into a 3:4 portrait frame: slab centered, 80% fill at the widest and tallest points, background transparent. App writes that frame as an inventory PNG (`image/png`, 8-bit RGBA). 7. App computes widths, sqft, and bdft from the mask. App crops the masked slab into a 3:4 portrait frame: slab centered, 90% fill at the widest and tallest points, background transparent. App writes that frame as an inventory PNG (`image/png`, 8-bit RGBA).
8. User sees the transparent PNG and the numbers. Milestone 1 stops here. 8. User sees the transparent PNG and the numbers. Milestone 1 stops here.
Calculate does not create a WooCommerce product. Calculate does not create a WooCommerce product.
@@ -97,7 +97,7 @@ User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), th
- Widths: min, max, avg from samples taken every 6 in along the approved length axis, perpendicular to it, after dropping the first and last 6 in of the span (bulk widths, not tips). If `L ≤ 12 in` there are no interior stations and all three are 0. - Widths: min, max, avg from samples taken every 6 in along the approved length axis, perpendicular to it, after dropping the first and last 6 in of the span (bulk widths, not tips). If `L ≤ 12 in` there are no interior stations and all three are 0.
- `sqft = (mask_pixels / scale²) / 144` (2 decimals) - `sqft = (mask_pixels / scale²) / 144` (2 decimals)
- `bdft = sqft × thickness_in` (2 decimals) - `bdft = sqft × thickness_in` (2 decimals)
- Crop: 3:4 portrait, slab centered, 80% fill at the widest and tallest points, transparent background - Crop: 3:4 portrait, slab centered, 90% fill at the widest and tallest points, transparent background
- File: inventory PNG, `image/png`, 8-bit RGBA, alpha kept - File: inventory PNG, `image/png`, 8-bit RGBA, alpha kept
User sees the transparent PNG and those numbers. Stop. Calculate does not create a WooCommerce product. User sees the transparent PNG and those numbers. Stop. Calculate does not create a WooCommerce product.
@@ -108,7 +108,7 @@ On Calculate, the app builds the inventory image from the approved mask:
1. Keep slab pixels from the source photo. Removed pixels are transparent. 1. Keep slab pixels from the source photo. Removed pixels are transparent.
2. Place the slab in a 3:4 portrait frame, centered. 2. Place the slab in a 3:4 portrait frame, centered.
3. Scale so the widest and tallest points of the slab fill 80% of the frame. Extra margin where the slab is narrower. 3. Scale so the widest and tallest points of the slab fill 90% of the frame. Extra margin where the slab is narrower.
4. After that crop, shorter side ≥ 800px when the source can supply it. Otherwise warn and require a new photo. No upscale from an undersized source. 4. After that crop, shorter side ≥ 800px when the source can supply it. Otherwise warn and require a new photo. No upscale from an undersized source.
5. Encode as PNG with alpha. 5. Encode as PNG with alpha.
@@ -133,7 +133,7 @@ The first inventory photo is the measurement reference. Later inventory photos u
| Format | PNG, 8-bit RGBA (`image/png`) | | Format | PNG, 8-bit RGBA (`image/png`) |
| Alpha | Kept through encode and upload | | Alpha | Kept through encode and upload |
| Aspect | 3:4 portrait | | Aspect | 3:4 portrait |
| Layout | Slab centered, 80% fill at the widest and tallest points | | Layout | Slab centered, 90% fill at the widest and tallest points |
| Size | After crop, shorter side ≥ 800px when the source can supply it | | Size | After crop, shorter side ≥ 800px when the source can supply it |
Source below that floor: warn and require a new photo. No upscale from an undersized source. Source below that floor: warn and require a new photo. No upscale from an undersized source.
+1 -1
View File
@@ -14,7 +14,7 @@ Phone capture of one slab → a lined-up set of Woo-compatible inventory PNGs, p
1. User adds a batch of one or more photos of the same slab (Setup rules from Milestone 1: green or black sheet, top-down reference expectations still apply to the calibration choice). 1. User adds a batch of one or more photos of the same slab (Setup rules from Milestone 1: green or black sheet, top-down reference expectations still apply to the calibration choice).
2. User selects which photo in the batch is the calibration photo. 2. User selects which photo in the batch is the calibration photo.
3. App runs the Milestone 1 workflow on that calibration photo only: Mask → Length axis → Measure (length `L`, thickness, Calculate). 3. App runs the Milestone 1 workflow on that calibration photo only: Mask → Length axis → Measure (length `L`, thickness, Calculate).
4. After Calculate, the app applies the approved mask sheet and sensitivity, and the same crop rules (3:4 portrait, 80% fill, transparent PNG, shorter-side floor), to every other photo in the batch. 4. After Calculate, the app applies the approved mask sheet and sensitivity, and the same crop rules (3:4 portrait, 90% fill, transparent PNG, shorter-side floor), to every other photo in the batch.
5. App adds a second copy of the calibration inventory PNG with dimension overlays burned in (see Dimension overlay). 5. App adds a second copy of the calibration inventory PNG with dimension overlays burned in (see Dimension overlay).
6. User sees Photos ready: inventory PNGs lined up in this order — clean calibration PNG, then the dimension-overlay copy of the calibration PNG, then the other batch photos in capture order — plus the Milestone 1 calibration details (widths, sqft, bdft, scale) presented as in Measure. 6. User sees Photos ready: inventory PNGs lined up in this order — clean calibration PNG, then the dimension-overlay copy of the calibration PNG, then the other batch photos in capture order — plus the Milestone 1 calibration details (widths, sqft, bdft, scale) presented as in Measure.
+3 -3
View File
@@ -81,7 +81,7 @@ test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not
assert.equal(out.width, 800); assert.equal(out.width, 800);
}); });
test('80% fill: limiting dimension maps to 0.8 × frame side', () => { test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => {
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 }; const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1); const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
const out = composeInventoryFrame( const out = composeInventoryFrame(
@@ -103,8 +103,8 @@ test('80% fill: limiting dimension maps to 0.8 × frame side', () => {
const xs = [minx, maxx], ys = [miny, maxy]; const xs = [minx, maxx], ys = [miny, maxy];
const wExtent = Math.max(...xs) - Math.min(...xs) + 1; const wExtent = Math.max(...xs) - Math.min(...xs) + 1;
const hExtent = Math.max(...ys) - Math.min(...ys) + 1; const hExtent = Math.max(...ys) - Math.min(...ys) + 1;
assert.ok(Math.abs(wExtent - 640) <= 2, `width extent ${wExtent} ≈ 640 (0.8×800)`); assert.ok(Math.abs(wExtent - 720) <= 2, `width extent ${wExtent} ≈ 720 (0.9×800)`);
assert.ok(Math.abs(hExtent - 576) <= 2, `height extent ${hExtent} ≈ 576 (1800×0.32)`); assert.ok(Math.abs(hExtent - 648) <= 2, `height extent ${hExtent} ≈ 648 (1800×0.36)`);
}); });
test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => { test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => {
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Mask, Source } from '../capture/capture.types'; import type { Mask, Source } from '../capture/capture.types';
const FLOOR = 800; // shorter side, px const FLOOR = 800; // shorter side, px
const FILL = 0.8; // 80% fill at the widest and tallest points of the slab const FILL = 0.9; // 90% fill at the widest and tallest points of the slab
const ASPECT_W = 3; const ASPECT_W = 3;
const ASPECT_H = 4; const ASPECT_H = 4;
+108 -1
View File
@@ -136,7 +136,7 @@ test('buildOverlayPlan: length line on axis-right outside slab; widths at 12in s
// Color contract for draw consumers // Color contract for draw consumers
assert.equal(plan.stroke, '#000000'); assert.equal(plan.stroke, '#000000');
assert.ok(plan.fontPx >= 24, `fontPx ${plan.fontPx} large enough to read`); assert.ok(plan.fontPx >= 18, `fontPx ${plan.fontPx} large enough to read`);
}); });
test('buildOverlayPlan: stations measured from EXIF bottom, not tip-inclusive M1 every-6 (#63)', () => { test('buildOverlayPlan: stations measured from EXIF bottom, not tip-inclusive M1 every-6 (#63)', () => {
@@ -185,3 +185,110 @@ test('buildOverlayPlan: dishonest upright-photo axes rejected — uses approved
const ly = Math.abs(plan.length.a[1] - plan.length.b[1]); const ly = Math.abs(plan.length.a[1] - plan.length.b[1]);
assert.ok(lx > ly * 2, `length graphic follows approved +x axis (dx=${lx}, dy=${ly})`); assert.ok(lx > ly * 2, `length graphic follows approved +x axis (dx=${lx}, dy=${ly})`);
}); });
// --- Ty UAT nits (#67) ---
test('buildOverlayPlan: length endpoints snap to mask tips, not oversized bbox corners (#67)', () => {
// Wood occupies x=100..299 only; synthetic bbox long side extends past tips (x=50..350).
const W = 400, H = 160;
const bitset = new Uint8Array(W * H);
for (let y = 40; y < 100; y++) for (let x = 100; x < 300; x++) bitset[y * W + x] = 1;
const bbox = {
corners: [
[350, 100],
[350, 40],
[50, 40],
[50, 100],
] as [number, number][],
longSidePx: 300,
angleDeg: 0,
};
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox,
scale: 10,
lengthIn: 20,
lengthLabel: '20"',
mapToInventory: (x, y) => [x, y],
});
// Length line sits on axis-right; project a/b onto the length axis through the
// wood centroid — endpoints must match mask span ends, not bbox corners.
const oriented = pickBottomTowardTop([100, 70], [299, 70]);
const strip = (p: [number, number]): [number, number] => {
const cx = 199.5, cy = 70;
const along = (p[0] - cx) * oriented.ux + (p[1] - cy) * oriented.uy;
return [cx + oriented.ux * along, cy + oriented.uy * along];
};
const xs = [strip(plan.length.a)[0], strip(plan.length.b)[0]].sort((a, b) => a - b);
// Mask tips ≈ 100 and 299 (inclusive pixels); bbox corners at 50 and 350
assert.ok(Math.abs(xs[0] - 100) <= 2, `mask min tip ${xs[0]} ≈ 100, not bbox 50`);
assert.ok(Math.abs(xs[1] - 299) <= 2, `mask max tip ${xs[1]} ≈ 299, not bbox 350`);
assert.ok(Math.abs(xs[0] - 50) > 20, 'must not use bbox corner at 50');
assert.ok(Math.abs(xs[1] - 350) > 20, 'must not use bbox corner at 350');
});
test('buildOverlayPlan: width labels sit axis-left outside the slab (#67)', () => {
// Uniform strip: length +x, width along y. Axis-right of bottom→top is −y (up).
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;
const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox: box,
scale: 10,
lengthIn: 48,
lengthLabel: '48"',
mapToInventory: (x, y) => [x, y],
});
assert.ok(plan.widths.length > 0);
for (const w of plan.widths) {
assert.ok(w.labelAt, 'width mark must expose labelAt');
// left/right tips still on wood edges
assert.ok(Math.abs(w.left[1] - 149) <= 2 || Math.abs(w.left[1] - 50) <= 2);
assert.ok(Math.abs(w.right[1] - 149) <= 2 || Math.abs(w.right[1] - 50) <= 2);
// labelAt on axis-left = opposite of axis-right from center
// For this geometry, axis-right is toward smaller y; axis-left toward larger y
// (outside past the bottom edge y≈149).
const cy = w.center[1];
assert.ok(
w.labelAt[1] > Math.max(w.left[1], w.right[1]) + 4,
`width labelAt y=${w.labelAt[1]} must sit past slab edge (left of travel), not on wood center y=${cy}`,
);
assert.ok(
Math.abs(w.labelAt[0] - w.center[0]) < 3,
'width label stays on the station cross-section (same x as center)',
);
}
// Length stays axis-right (outside opposite side)
const lenY = (plan.length.a[1] + plan.length.b[1]) / 2;
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)', () => {
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;
const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
const scale = 10;
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox: box,
scale,
lengthIn: 48,
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)));
});
+10 -4
View File
@@ -45,6 +45,8 @@ export interface OverlayWidthMark {
center: [number, number]; center: [number, number];
left: [number, number]; left: [number, number];
right: [number, number]; right: [number, number];
/** Axis-left of the slab, outside the wood edge (opposite of length dim). */
labelAt: [number, number];
label: string; label: string;
} }
@@ -159,6 +161,7 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
const { bottom, top, ux, uy } = oriented; const { bottom, top, ux, uy } = oriented;
const [rx, ry] = axisRightUnit(ux, uy); const [rx, ry] = axisRightUnit(ux, uy);
const pad = Math.max(16, scale * 0.75);
const stations = overlayWidthStations(lengthIn); const stations = overlayWidthStations(lengthIn);
const widths: OverlayWidthMark[] = []; const widths: OverlayWidthMark[] = [];
for (const stationIn of stations) { for (const stationIn of stations) {
@@ -170,12 +173,16 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
// left = opposite of axis-right; right = axis-right tip on slab edge // 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 leftSrc: [number, number] = [ox + rx * extent.minP, oy + ry * extent.minP];
const rightSrc: [number, number] = [ox + rx * extent.maxP, oy + ry * extent.maxP]; const rightSrc: [number, number] = [ox + rx * extent.maxP, oy + ry * extent.maxP];
// Width number sits axis-left outside the slab (past left tip).
const labelOff = extent.minP - pad - scale * 0.4;
const labelSrcW: [number, number] = [ox + rx * labelOff, oy + ry * labelOff];
const widthIn = (extent.maxP - extent.minP + 1) / scale; const widthIn = (extent.maxP - extent.minP + 1) / scale;
widths.push({ widths.push({
stationIn, stationIn,
center: mapToInventory(ox, oy), center: mapToInventory(ox, oy),
left: mapToInventory(leftSrc[0], leftSrc[1]), left: mapToInventory(leftSrc[0], leftSrc[1]),
right: mapToInventory(rightSrc[0], rightSrc[1]), right: mapToInventory(rightSrc[0], rightSrc[1]),
labelAt: mapToInventory(labelSrcW[0], labelSrcW[1]),
label: fmtInches(widthIn), label: fmtInches(widthIn),
}); });
} }
@@ -190,7 +197,6 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry); const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
if (extent && extent.maxP > maxRight) maxRight = extent.maxP; if (extent && extent.maxP > maxRight) maxRight = extent.maxP;
} }
const pad = Math.max(16, scale * 0.75);
const outward = maxRight + pad; const outward = maxRight + pad;
const aSrc: [number, number] = [bottom[0] + rx * outward, bottom[1] + ry * outward]; 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 bSrc: [number, number] = [top[0] + rx * outward, top[1] + ry * outward];
@@ -198,7 +204,7 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
// Nudge label further outward a bit // Nudge label further outward a bit
const labelSrc: [number, number] = [midSrc[0] + rx * (scale * 0.4), midSrc[1] + ry * (scale * 0.4)]; const labelSrc: [number, number] = [midSrc[0] + rx * (scale * 0.4), midSrc[1] + ry * (scale * 0.4)];
const fontPx = Math.max(28, Math.round(scale * 1.2)); const fontPx = Math.max(22, Math.round(scale * 1.0));
return { return {
stroke: '#000000', stroke: '#000000',
@@ -263,7 +269,7 @@ export function drawOverlayPlan(
// Widths // Widths
for (const w of plan.widths) { for (const w of plan.widths) {
arrow(w.left, w.right); arrow(w.left, w.right);
ctx.fillText(w.label, w.center[0], w.center[1]); ctx.fillText(w.label, w.labelAt[0], w.labelAt[1]);
} }
ctx.restore(); ctx.restore();
@@ -282,7 +288,7 @@ export function inventorySourceMap(
outW: number, outW: number,
outH: number, outH: number,
): InventoryMap { ): InventoryMap {
const FILL = 0.8; const FILL = 0.9;
const x0 = Math.max(0, Math.round(slabBounds.x)); const x0 = Math.max(0, Math.round(slabBounds.x));
const y0 = Math.max(0, Math.round(slabBounds.y)); const y0 = Math.max(0, Math.round(slabBounds.y));
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w))); const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));