Compare commits
2 Commits
5650255e49
...
9bc35f7edd
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bc35f7edd | |||
| 15895cae0c |
+4
-4
@@ -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.
|
||||
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).
|
||||
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.
|
||||
|
||||
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.
|
||||
- `sqft = (mask_pixels / scale²) / 144` (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
|
||||
|
||||
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.
|
||||
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.
|
||||
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`) |
|
||||
| Alpha | Kept through encode and upload |
|
||||
| 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 |
|
||||
|
||||
Source below that floor: warn and require a new photo. No upscale from an undersized source.
|
||||
|
||||
+1
-1
@@ -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).
|
||||
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).
|
||||
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).
|
||||
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.
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not
|
||||
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 bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
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 wExtent = Math.max(...xs) - Math.min(...xs) + 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(hExtent - 576) <= 2, `height extent ${hExtent} ≈ 576 (1800×0.32)`);
|
||||
assert.ok(Math.abs(wExtent - 720) <= 2, `width extent ${wExtent} ≈ 720 (0.9×800)`);
|
||||
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)', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
|
||||
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_H = 4;
|
||||
|
||||
|
||||
@@ -136,7 +136,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 >= 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)', () => {
|
||||
@@ -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]);
|
||||
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)));
|
||||
});
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface OverlayWidthMark {
|
||||
center: [number, number];
|
||||
left: [number, number];
|
||||
right: [number, number];
|
||||
/** Axis-left of the slab, outside the wood edge (opposite of length dim). */
|
||||
labelAt: [number, number];
|
||||
label: string;
|
||||
}
|
||||
|
||||
@@ -159,6 +161,7 @@ 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);
|
||||
const stations = overlayWidthStations(lengthIn);
|
||||
const widths: OverlayWidthMark[] = [];
|
||||
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
|
||||
const leftSrc: [number, number] = [ox + rx * extent.minP, oy + ry * extent.minP];
|
||||
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;
|
||||
widths.push({
|
||||
stationIn,
|
||||
center: mapToInventory(ox, oy),
|
||||
left: mapToInventory(leftSrc[0], leftSrc[1]),
|
||||
right: mapToInventory(rightSrc[0], rightSrc[1]),
|
||||
labelAt: mapToInventory(labelSrcW[0], labelSrcW[1]),
|
||||
label: fmtInches(widthIn),
|
||||
});
|
||||
}
|
||||
@@ -190,7 +197,6 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
|
||||
if (extent && extent.maxP > maxRight) maxRight = extent.maxP;
|
||||
}
|
||||
const pad = Math.max(16, scale * 0.75);
|
||||
const outward = maxRight + pad;
|
||||
const aSrc: [number, number] = [bottom[0] + rx * outward, bottom[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
|
||||
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 {
|
||||
stroke: '#000000',
|
||||
@@ -263,7 +269,7 @@ export function drawOverlayPlan(
|
||||
// Widths
|
||||
for (const w of plan.widths) {
|
||||
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();
|
||||
@@ -282,7 +288,7 @@ export function inventorySourceMap(
|
||||
outW: number,
|
||||
outH: number,
|
||||
): InventoryMap {
|
||||
const FILL = 0.8;
|
||||
const FILL = 0.9;
|
||||
const x0 = Math.max(0, Math.round(slabBounds.x));
|
||||
const y0 = Math.max(0, Math.round(slabBounds.y));
|
||||
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
|
||||
|
||||
Reference in New Issue
Block a user