From 1b5ad112ccc310874c018d7759f94e3dcdaf4a15 Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Sat, 26 Sep 2026 09:11:34 -0700 Subject: [PATCH] =?UTF-8?q?fix(capture):=20warn=20300=E2=80=93799px=20PNG?= =?UTF-8?q?=20floor;=20hard-block=20only=20below=20300?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split FLOOR_WARN_PX=800 / FLOOR_BLOCK_PX=300. Warn-band still encodes and includes the photo (Photos-ready + batchApply ok+warn). Block excludes siblings and clean cal. CAPTURE-105 / CAPTURE.md / PHOTOS.md updated. --- docs/REQUIREMENTS.md | 2 +- docs/capabilities/CAPTURE.md | 6 +- docs/capabilities/PHOTOS.md | 2 +- frontend/src/App.svelte | 6 +- frontend/src/capture/MeasurePanel.svelte | 19 ++++- frontend/src/capture/capture.types.ts | 7 +- .../src/capture/photosReadyLineup.test.ts | 29 ++++++- frontend/src/capture/photosReadyLineup.ts | 37 +++++++-- .../src/capture/session.batchApply.test.ts | 76 +++++++++++++++---- frontend/src/capture/session.overlay.test.ts | 9 ++- frontend/src/capture/session.ts | 30 ++++++-- frontend/src/create/flow.ts | 3 +- frontend/src/measure/measure.ts | 5 +- frontend/src/png/crop.ts | 12 ++- frontend/src/png/inventory.test.ts | 44 +++++++++-- frontend/src/png/inventory.ts | 25 ++++-- frontend/tests/e2e/helpers/draft.ts | 3 +- 17 files changed, 246 insertions(+), 69 deletions(-) diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 4f962bb..96e60e9 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -35,7 +35,7 @@ Sources reconciled 2026-09-18 from the current branch docs plus the `pre-simplif | CAPTURE-90 | bdft equals sqft times thickness_in, rounded half-up to 2 decimals, with no divide-by-12; 1 sqft at 1 inch equals 1 bdft. | shipped | frontend/src/measure/measure.test.ts | | CAPTURE-95 | The inventory PNG is 8-bit RGBA with alpha preserved through encode and upload. | shipped | frontend/src/png/inventory.test.ts | | CAPTURE-100 | Crop is 3:4 portrait, slab centered, 90 percent fill at the widest and tallest extremes, on a transparent background. | shipped | frontend/src/png/inventory.test.ts | -| CAPTURE-105 | After crop the shorter side is at least 800 px when the source allows; otherwise warn and require a new photo, and never upscale. | shipped | frontend/src/png/inventory.test.ts | +| CAPTURE-105 | After crop the shorter side targets 800 px when the source allows; warn (still encode/include) when 300–799 px; hard-block below 300 px; never upscale. | shipped | frontend/src/png/inventory.test.ts | | CAPTURE-110 | A non-calibration photo that yields an empty mask or fails the size floor is excluded with a per-photo error while the rest proceed. | shipped | frontend/src/capture/session.batchApply.test.ts | | CAPTURE-115 | Scale, widths, sqft, and bdft stay tied to the calibration photo; non-calibration photos are visual inventory only. | shipped | frontend/src/capture/session.batchApply.test.ts | | CAPTURE-120 | The dimension overlay is black and oriented to the approved length axis, with the length graphic beside the wood on the right in axis space. | shipped | frontend/src/png/overlay.test.ts | diff --git a/docs/capabilities/CAPTURE.md b/docs/capabilities/CAPTURE.md index 309e223..02257c7 100644 --- a/docs/capabilities/CAPTURE.md +++ b/docs/capabilities/CAPTURE.md @@ -123,7 +123,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 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 targets ≥ 800px when the source can supply it. If the slab bbox shorter side is 300–799 px, warn but still encode and include the photo. Below 300 px is a hard block (exclude / retake). No upscale from an undersized source. 5. Encode as PNG with alpha. ## Device draft @@ -147,6 +147,6 @@ The calibration photo is the measurement reference (`CAPTURE-25`, `CAPTURE-115`) | Alpha | Kept through encode and upload | | Aspect | 3:4 portrait | | 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 targets ≥ 800px when the source can supply it | -Source below that floor: warn and require a new photo. No upscale from an undersized source. +Shorter side 300–799 px: warn but still encode/include. Below 300 px: hard block (exclude / retake). No upscale from an undersized source. diff --git a/docs/capabilities/PHOTOS.md b/docs/capabilities/PHOTOS.md index 640ca93..0f5e7f4 100644 --- a/docs/capabilities/PHOTOS.md +++ b/docs/capabilities/PHOTOS.md @@ -48,7 +48,7 @@ For each non-calibration photo: - Reuse the approved Mask sheet (Auto / Green / Black) and sensitivity from the calibration photo. Do not re-open Mask controls for that photo in this milestone. - Run background removal with those params; compose inventory PNG with the same crop rules as Milestone 1 Calculate. -- If a photo yields an empty mask or fails the PNG size floor, keep it out of the ready set and show a clear per-photo error; other photos still proceed. +- If a photo yields an empty mask or fails the PNG size block floor (<300 px shorter side), keep it out of the ready set and show a clear per-photo error; other photos still proceed. A warn-band size (300–799 px) still includes the photo with a clear per-photo warn. Scale, widths, sqft, and bdft stay tied to the calibration photo and user `L` / thickness. Non-calibration photos are visual inventory only. diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 6159256..87fbf9c 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -77,12 +77,14 @@ widthMinIn: eighthsToIn(r.widths.min), widthMaxIn: eighthsToIn(r.widths.max), widthAvgIn: eighthsToIn(r.widths.avg), - sourceBelowFloor: r.sourceBelowFloor, + sourceBelowBlock: r.sourceBelowBlock, + sourceBelowWarn: r.sourceBelowWarn, lineup: buildPhotosReadyLineup({ inventoryPng: r.inventoryPng, overlayPng: r.overlayPng, batchApply: m.batchApply, - sourceBelowFloor: r.sourceBelowFloor, + sourceBelowBlock: r.sourceBelowBlock, + sourceBelowWarn: r.sourceBelowWarn, }), }; saving = true; diff --git a/frontend/src/capture/MeasurePanel.svelte b/frontend/src/capture/MeasurePanel.svelte index b293a44..353afe4 100644 --- a/frontend/src/capture/MeasurePanel.svelte +++ b/frontend/src/capture/MeasurePanel.svelte @@ -88,7 +88,8 @@ inventoryPng: state.results.inventoryPng, overlayPng: state.results.overlayPng, batchApply: state.batchApply, - sourceBelowFloor: state.results.sourceBelowFloor, + sourceBelowBlock: state.results.sourceBelowBlock, + sourceBelowWarn: state.results.sourceBelowWarn, }); lineupUrls = lineupItems.map((it) => it.kind === 'png' ? URL.createObjectURL(it.blob) : '', @@ -347,8 +348,14 @@
Scale{state.results.scale.toFixed(2)} px/in
Species{speciesOpts.find((o) => o.id === state.speciesId)?.name ?? (state.speciesId != null ? `#${state.speciesId}` : '—')}
- {#if state.results.sourceBelowFloor} -
Source is below the 800px floor for the inventory PNG. Retake a larger photo.
+ {#if state.results.sourceBelowBlock} + + {:else if state.results.sourceBelowWarn} +
+ Source shorter side is under 800px (300–799). Photo included; consider a closer/larger shot for best quality. +
{/if}
    @@ -365,6 +372,9 @@ Batch photo {(item.batchIndex ?? 0) + 1} {/if} + {#if item.warn} + {item.warn} + {/if} {:else}
- {#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowFloor} + {#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowBlock}
Inventory PNG
@@ -437,6 +447,7 @@ .results .nums { font-size: 1rem; } .results .nums div { font-size: 1rem; } .warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; } + .item-warn { display: block; margin-top: 6px; color: #c07020; font-size: 0.8rem; font-weight: 600; } .lineup { list-style: none; padding: 0; margin: 16px 0 0; display: flex; flex-direction: column; gap: 14px; diff --git a/frontend/src/capture/capture.types.ts b/frontend/src/capture/capture.types.ts index 498fe9c..9eecc13 100644 --- a/frontend/src/capture/capture.types.ts +++ b/frontend/src/capture/capture.types.ts @@ -82,14 +82,17 @@ export interface Measures { inventoryPng: Blob; /** Dimension-overlay copy of the calibration inventory PNG (#63). */ overlayPng?: Blob; - sourceBelowFloor: boolean; + /** Shorter side of slab bbox < 300 px — hard fail / exclude clean cal. */ + sourceBelowBlock: boolean; + /** Shorter side in 300–799 px — encode/include with warn. */ + sourceBelowWarn: boolean; } // Result of applying the calibration mask to one batch sibling. The // calibration slot is null because its inventory PNG lives in Measures. export type BatchPhotoResult = | null - | { status: 'ok'; inventoryPng: Blob } + | { status: 'ok'; inventoryPng: Blob; /** Soft size-floor concern; photo still included. */ warn?: string } | { status: 'error'; message: string }; // The machine. 'resume' exists only while a draft exists and has not been chosen. diff --git a/frontend/src/capture/photosReadyLineup.test.ts b/frontend/src/capture/photosReadyLineup.test.ts index 42d9c5c..bfd072c 100644 --- a/frontend/src/capture/photosReadyLineup.test.ts +++ b/frontend/src/capture/photosReadyLineup.test.ts @@ -92,7 +92,7 @@ test('test(photos): batchApply errors become clear error slots (empty mask / flo null, { status: 'error', message: 'empty mask: no slab pixels detected' }, { status: 'ok', inventoryPng: ok }, - { status: 'error', message: 'PNG size floor: shorter side is below 800 px' }, + { status: 'error', message: 'PNG size floor: shorter side is below 300 px' }, ]; const lineup = buildPhotosReadyLineup({ @@ -118,7 +118,7 @@ test('test(photos): batchApply errors become clear error slots (empty mask / flo const err3 = lineup[4]; assert.equal(err3.kind, 'error'); if (err3.kind === 'error') { - assert.match(err3.message, /floor|800/i); + assert.match(err3.message, /floor|300/i); } // Successful sibling still appears after cal+overlay, in capture order among siblings @@ -151,16 +151,37 @@ test('test(photos): lineup never uses source JPEG thumbs as inventory PNGs (#64) } }); -test('test(photos): skip clean cal when sourceBelowFloor; keep overlay + siblings (#64)', () => { +test('test(photos): skip clean cal when sourceBelowBlock; keep overlay + siblings (#64)', () => { const lineup = buildPhotosReadyLineup({ inventoryPng: png('below'), overlayPng: png('overlay'), batchApply: [null, { status: 'ok', inventoryPng: png('sib') }], - sourceBelowFloor: true, + sourceBelowBlock: true, }); assert.deepEqual(roles(lineup), ['png:overlay', 'png:batch']); }); +test('test(photos): warn band keeps clean cal + attaches warn; siblings with warn stay png (#64)', () => { + const lineup = buildPhotosReadyLineup({ + inventoryPng: png('warn-cal'), + overlayPng: png('overlay'), + batchApply: [ + null, + { status: 'ok', inventoryPng: png('sib'), warn: 'PNG size warn: shorter side is below 800 px (300–799); photo included' }, + ], + sourceBelowWarn: true, + }); + assert.deepEqual(roles(lineup), ['png:calibration', 'png:overlay', 'png:batch']); + assert.equal(lineup[0].kind, 'png'); + if (lineup[0].kind === 'png') { + assert.match(lineup[0].warn ?? '', /warn|800|300/i); + } + assert.equal(lineup[2].kind, 'png'); + if (lineup[2].kind === 'png') { + assert.match(lineup[2].warn ?? '', /warn|800/i); + } +}); + test('test(photos): empty/missing results yield empty lineup (#64)', () => { assert.deepEqual(buildPhotosReadyLineup({}), []); assert.deepEqual( diff --git a/frontend/src/capture/photosReadyLineup.ts b/frontend/src/capture/photosReadyLineup.ts index c3dfb5d..f7a4bd9 100644 --- a/frontend/src/capture/photosReadyLineup.ts +++ b/frontend/src/capture/photosReadyLineup.ts @@ -8,6 +8,8 @@ export type PhotosReadyLineupItem = blob: Blob; /** Capture-order index for non-calibration batch inventory PNGs. */ batchIndex?: number; + /** Soft size-floor warn (300–799); photo still shown. */ + warn?: string; } | { kind: 'error'; @@ -19,25 +21,45 @@ export interface PhotosReadyLineupInput { inventoryPng?: Blob | null; overlayPng?: Blob | null; batchApply?: BatchPhotoResult[] | null; - /** When true, omit the clean calibration PNG (M1 floor warn path). */ - sourceBelowFloor?: boolean; + /** When true, omit the clean calibration PNG (hard block < 300 px). */ + sourceBelowBlock?: boolean; + /** Soft warn for calibration (300–799); clean cal still included. */ + sourceBelowWarn?: boolean; + /** Calibration warn message when sourceBelowWarn. */ + calibrationWarn?: string; } /** * Build the Photos-ready lineup: - * 1. Clean calibration inventory PNG + * 1. Clean calibration inventory PNG (skipped only on hard block) * 2. Dimension-overlay copy * 3. Other batch inventory PNGs in capture order (batchApply, skip cal null) - * with error slots for empty mask / floor failures. + * with error slots for empty mask / block-floor failures; warn stays on png. */ export function buildPhotosReadyLineup( input: PhotosReadyLineupInput, ): PhotosReadyLineupItem[] { const items: PhotosReadyLineupItem[] = []; - const { inventoryPng, overlayPng, batchApply, sourceBelowFloor } = input; + const { + inventoryPng, + overlayPng, + batchApply, + sourceBelowBlock, + sourceBelowWarn, + calibrationWarn, + } = input; - if (inventoryPng && !sourceBelowFloor) { - items.push({ kind: 'png', role: 'calibration', blob: inventoryPng }); + if (inventoryPng && !sourceBelowBlock) { + const warn = sourceBelowWarn + ? (calibrationWarn + ?? 'PNG size warn: shorter side is below 800 px (300–799); photo included') + : undefined; + items.push({ + kind: 'png', + role: 'calibration', + blob: inventoryPng, + ...(warn ? { warn } : {}), + }); } if (overlayPng) { items.push({ kind: 'png', role: 'overlay', blob: overlayPng }); @@ -53,6 +75,7 @@ export function buildPhotosReadyLineup( role: 'batch', blob: slot.inventoryPng, batchIndex: i, + ...(slot.warn ? { warn: slot.warn } : {}), }); } else { items.push({ diff --git a/frontend/src/capture/session.batchApply.test.ts b/frontend/src/capture/session.batchApply.test.ts index 131cbe8..351f833 100644 --- a/frontend/src/capture/session.batchApply.test.ts +++ b/frontend/src/capture/session.batchApply.test.ts @@ -211,7 +211,8 @@ test('#62: Calculate applies frozen mask params to every non-calibration batch p // Distinctive PNG blob so tests cannot confuse with source JPEG thumbs return { blob: new Blob([`png-${input.source.widthPx}x${input.source.heightPx}`], { type: 'image/png' }), - sourceBelowFloor: false, + sourceBelowBlock: false, + sourceBelowWarn: false, }; }, } as Parameters[0]); @@ -269,7 +270,8 @@ test('#62: empty mask on one sibling → per-photo error; others still proceed', draft: store, encodeInventory: async () => ({ blob: new Blob(['ok-png'], { type: 'image/png' }), - sourceBelowFloor: false, + sourceBelowBlock: false, + sourceBelowWarn: false, }), } as Parameters[0]); @@ -287,15 +289,15 @@ test('#62: empty mask on one sibling → per-photo error; others still proceed', s.dispose(); }); -test('#62: below PNG size floor on one sibling → per-photo error; others proceed', async () => { +test('#62: below block floor (<300) on one sibling → per-photo error; others proceed', async () => { installFakes(); const a = tinyJpeg('a'); // ok const b = tinyJpeg('b'); // cal - const c = tinyJpeg('c'); // below floor + const c = tinyJpeg('c'); // below block floor FakeMaskWorker.behaviorFor = (blob) => { if (blob === c) { - return { w: 400, h: 300, slabPx: 400 * 300, slabBounds: { x: 0, y: 0, w: 400, h: 300 } }; + return { w: 250, h: 200, slabPx: 250 * 200, slabBounds: { x: 0, y: 0, w: 250, h: 200 } }; } return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } }; }; @@ -303,10 +305,11 @@ test('#62: below PNG size floor on one sibling → per-photo error; others proce const s = createCaptureSession({ draft: store, encodeInventory: async (input) => { - const below = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h) < 800; + const short = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h); return { - blob: new Blob([below ? 'tiny' : 'ok'], { type: 'image/png' }), - sourceBelowFloor: below, + blob: new Blob([short < 300 ? 'tiny' : 'ok'], { type: 'image/png' }), + sourceBelowBlock: short < 300, + sourceBelowWarn: short >= 300 && short < 800, }; }, } as Parameters[0]); @@ -317,9 +320,47 @@ test('#62: below PNG size floor on one sibling → per-photo error; others proce const apply = batchApplyOf(get(s.capture)); assert.ok(apply); assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok'); - assert.ok(apply![2] && apply![2].status === 'error', 'below-floor → error'); + assert.ok(apply![2] && apply![2].status === 'error', 'below-block → error'); if (apply![2]?.status === 'error') { - assert.match(apply![2].message, /floor|small|800|size/i, 'clear per-photo size-floor error'); + assert.match(apply![2].message, /floor|300|size/i, 'clear per-photo size-floor error'); + } + s.dispose(); +}); + +test('#62: warn band (300–799) on sibling → ok with warn; photo included', async () => { + installFakes(); + const a = tinyJpeg('a'); // ok + const b = tinyJpeg('b'); // cal + const c = tinyJpeg('c'); // warn band + + FakeMaskWorker.behaviorFor = (blob) => { + if (blob === c) { + return { w: 500, h: 400, slabPx: 500 * 400, slabBounds: { x: 0, y: 0, w: 500, h: 400 } }; + } + return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } }; + }; + + const s = createCaptureSession({ + draft: store, + encodeInventory: async (input) => { + const short = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h); + return { + blob: new Blob([short < 800 ? 'warn' : 'ok'], { type: 'image/png' }), + sourceBelowBlock: short < 300, + sourceBelowWarn: short >= 300 && short < 800, + }; + }, + } as Parameters[0]); + + await driveToMeasure(s, [a, b, c], 1, { sheet: 'auto' }); + await s.calculate(); + + const apply = batchApplyOf(get(s.capture)); + assert.ok(apply); + assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok'); + assert.ok(apply![2] && apply![2].status === 'ok', 'warn-band → still ok'); + if (apply![2]?.status === 'ok') { + assert.match(apply![2].warn ?? '', /warn|800|300/i, 'warn message attached'); } s.dispose(); }); @@ -330,7 +371,8 @@ test('#62: scale/widths/sqft/bdft stay on calibration results only (siblings are draft: store, encodeInventory: async () => ({ blob: new Blob(['png'], { type: 'image/png' }), - sourceBelowFloor: false, + sourceBelowBlock: false, + sourceBelowWarn: false, }), } as Parameters[0]); @@ -373,7 +415,8 @@ test('#62: draft persists batchApply after Calculate (for #64 UI later)', async draft: store, encodeInventory: async () => ({ blob: new Blob(['draft-png'], { type: 'image/png' }), - sourceBelowFloor: false, + sourceBelowBlock: false, + sourceBelowWarn: false, }), } as Parameters[0]); @@ -401,7 +444,8 @@ test('#62: single-photo batch Calculate leaves batchApply=[null] (no sibling Mas draft: store, encodeInventory: async () => ({ blob: new Blob(['only'], { type: 'image/png' }), - sourceBelowFloor: false, + sourceBelowBlock: false, + sourceBelowWarn: false, }), } as Parameters[0]); @@ -426,7 +470,8 @@ test('#62: Calculate does not re-enter Mask / does not open Mask controls for si draft: store, encodeInventory: async () => ({ blob: new Blob(['x'], { type: 'image/png' }), - sourceBelowFloor: false, + sourceBelowBlock: false, + sourceBelowWarn: false, }), } as Parameters[0]); @@ -463,7 +508,8 @@ test('#62: resume restores batchApply alongside calibration results', async () = draft: store, encodeInventory: async () => ({ blob: new Blob(['resume-png'], { type: 'image/png' }), - sourceBelowFloor: false, + sourceBelowBlock: false, + sourceBelowWarn: false, }), } as Parameters[0]); diff --git a/frontend/src/capture/session.overlay.test.ts b/frontend/src/capture/session.overlay.test.ts index c43d1ce..5f3ff51 100644 --- a/frontend/src/capture/session.overlay.test.ts +++ b/frontend/src/capture/session.overlay.test.ts @@ -185,7 +185,8 @@ test('test(photos): Calculate stores overlayPng on results, clean inventory unto encodeN++; return { blob: new Blob([`clean-${encodeN}`], { type: 'image/png' }), - sourceBelowFloor: false, + sourceBelowBlock: false, + sourceBelowWarn: false, }; }, } as Parameters[0]); @@ -221,7 +222,8 @@ test('test(photos): single-photo batch still yields clean + overlayPng (#63)', a draft: store, encodeInventory: async () => ({ blob: new Blob(['only-clean'], { type: 'image/png' }), - sourceBelowFloor: false, + sourceBelowBlock: false, + sourceBelowWarn: false, }), } as Parameters[0]); @@ -243,7 +245,8 @@ test('test(photos): draft persists overlayPng alongside inventoryPng (#63)', asy draft: store, encodeInventory: async () => ({ blob: new Blob(['draft-clean'], { type: 'image/png' }), - sourceBelowFloor: false, + sourceBelowBlock: false, + sourceBelowWarn: false, }), } as Parameters[0]); diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts index 766578e..1794330 100644 --- a/frontend/src/capture/session.ts +++ b/frontend/src/capture/session.ts @@ -62,7 +62,7 @@ export interface CaptureSession { export interface SessionOpts { draft?: DraftStore; /** Injectable only for deterministic tests; production uses encodeInventoryPng. */ - encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowFloor: boolean }>; + encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean }>; /** Injectable only for deterministic tests; production uses encodeOverlayPng. */ encodeOverlay?: (input: EncodeOverlayInput) => Promise; } @@ -414,6 +414,12 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { ...d.results, inventoryPng: d.inventoryPng ?? new Blob(), overlayPng: d.overlayPng ?? undefined, + // Migrate legacy sourceBelowFloor → warn; missing flags default false. + sourceBelowBlock: (d.results as { sourceBelowBlock?: boolean }).sourceBelowBlock ?? false, + sourceBelowWarn: + (d.results as { sourceBelowWarn?: boolean }).sourceBelowWarn + ?? (d.results as { sourceBelowFloor?: boolean }).sourceBelowFloor + ?? false, } : null, batchApply: d.batchApply, @@ -632,7 +638,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { tapeAxis, ); let pngBlob: Blob; - let belowFloor = false; + let belowBlock = false; + let belowWarn = false; // #99: bake photo tilt into cal inventory + overlay only; siblings untouched. let calBitmap = bitmap; let calMask = approvedMask; @@ -650,7 +657,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { try { const r = await encodeInventory({ bitmap: calBitmap, mask: calMask, source: calSource }); pngBlob = r.blob; - belowFloor = r.sourceBelowFloor; + belowBlock = r.sourceBelowBlock; + belowWarn = r.sourceBelowWarn; } catch { // CAPTURE.md: inventory is image/png even when encode is unavailable. pngBlob = new Blob([], { type: 'image/png' }); @@ -704,7 +712,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { ...base, inventoryPng: pngBlob, overlayPng: overlayBlob, - sourceBelowFloor: belowFloor, + sourceBelowBlock: belowBlock, + sourceBelowWarn: belowWarn, }; const params = approvedMask.params; const calIndex = calibrationIndex != null && calibrationIndex >= 0 @@ -736,16 +745,21 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { heightPx: derived.heightPx, }; const siblingBitmap = await decodeOrientedBitmap(photo.blob); - let encoded: { blob: Blob; sourceBelowFloor: boolean }; + let encoded: { blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean }; try { encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource }); } finally { siblingBitmap.close(); } - if (encoded.sourceBelowFloor) { - throw new Error('PNG size floor: shorter side is below 800 px'); + if (encoded.sourceBelowBlock) { + throw new Error('PNG size floor: shorter side is below 300 px'); } - batchApply[index] = { status: 'ok', inventoryPng: encoded.blob }; + const warn = encoded.sourceBelowWarn + ? 'PNG size warn: shorter side is below 800 px (300–799); photo included' + : undefined; + batchApply[index] = warn + ? { status: 'ok', inventoryPng: encoded.blob, warn } + : { status: 'ok', inventoryPng: encoded.blob }; } catch (error) { const message = error instanceof Error ? error.message : 'unable to process photo'; batchApply[index] = { status: 'error', message }; diff --git a/frontend/src/create/flow.ts b/frontend/src/create/flow.ts index 33651bd..51d236e 100644 --- a/frontend/src/create/flow.ts +++ b/frontend/src/create/flow.ts @@ -18,7 +18,8 @@ export interface CalibratedCapture { widthMinIn: number; widthMaxIn: number; widthAvgIn: number; - sourceBelowFloor: boolean; + sourceBelowBlock: boolean; + sourceBelowWarn: boolean; lineup: PhotosReadyLineupItem[]; } diff --git a/frontend/src/measure/measure.ts b/frontend/src/measure/measure.ts index 4f8284b..aecebb1 100644 --- a/frontend/src/measure/measure.ts +++ b/frontend/src/measure/measure.ts @@ -92,7 +92,7 @@ export function measure( lengthEighths: Eighths, thicknessEighths: Eighths, slabPx: number, tape?: TapeAxis | null, -): Omit & { sourceBelowFloor: false } { +): Omit & { sourceBelowBlock: false; sourceBelowWarn: false } { const lengthIn = lengthEighths / 8; let start: [number, number]; let ux: number; @@ -132,6 +132,7 @@ export function measure( widths, sqft, bdft, - sourceBelowFloor: false, + sourceBelowBlock: false, + sourceBelowWarn: false, }; } diff --git a/frontend/src/png/crop.ts b/frontend/src/png/crop.ts index 8a7a890..b1c1d0a 100644 --- a/frontend/src/png/crop.ts +++ b/frontend/src/png/crop.ts @@ -2,12 +2,22 @@ * Shared crop constants for inventory PNG + tape-line overlay (#99). * Aspect is fixed 3:4; crop_fill only moves the long-axis end insets. * Non-retro: changing fill affects future encodes only, not stored PNGs. + * + * Size floors (shorter side of slab bbox, source px): + * - FLOOR_WARN_PX (800): target frame shorter side when source allows; + * 300–799 → warn but still encode / include. + * - FLOOR_BLOCK_PX (300): hard fail / exclude below this. */ export const DEFAULT_CROP_FILL = 0.9; export const ASPECT_W = 3; export const ASPECT_H = 4; -export const FLOOR_PX = 800; +/** Target inventory frame shorter side when source can supply it. */ +export const FLOOR_WARN_PX = 800; +/** Hard-block threshold: shorter side below this fails / excludes the photo. */ +export const FLOOR_BLOCK_PX = 300; +/** @deprecated Prefer FLOOR_WARN_PX; kept as alias for the target frame size. */ +export const FLOOR_PX = FLOOR_WARN_PX; /** Fraction of the long axis inset from each end: (1 - fill) / 2. */ export function cropMarginFraction(fill: number = DEFAULT_CROP_FILL): number { diff --git a/frontend/src/png/inventory.test.ts b/frontend/src/png/inventory.test.ts index 669c042..0f77213 100644 --- a/frontend/src/png/inventory.test.ts +++ b/frontend/src/png/inventory.test.ts @@ -30,7 +30,8 @@ test('alpha contract: removed pixels transparent, slab pixels keep RGB at alpha const source: Pick = { widthPx: W, heightPx: H }; const out = composeInventoryFrame(src, maskFor(bitset, { x: 1, y: 1, w: 4, h: 4 }), source); - assert.equal(out.sourceBelowFloor, true); + assert.equal(out.sourceBelowBlock, true, '4px slab is below block floor'); + assert.equal(out.sourceBelowWarn, false); assert.ok(out.width < 800, 'undersized source stays small'); let opaque = 0; @@ -66,10 +67,11 @@ test('3:4 frame at the floor: 2000×1800 slab → 800×1067', () => { ); assert.equal(out.width, 800); assert.equal(out.height, 1067); - assert.equal(out.sourceBelowFloor, false); + assert.equal(out.sourceBelowBlock, false); + assert.equal(out.sourceBelowWarn, false); }); -test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not belowFloor', () => { +test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, no warn/block', () => { const source: Pick = { widthPx: 1200, heightPx: 1000 }; const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1); const out = composeInventoryFrame( @@ -77,10 +79,24 @@ test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not maskFor(bitset, { x: 0, y: 0, w: 1200, h: 1000 }), source, ); - assert.equal(out.sourceBelowFloor, false); + assert.equal(out.sourceBelowBlock, false); + assert.equal(out.sourceBelowWarn, false); assert.equal(out.width, 800); }); +test('warn band: shorter side 500 (300–799) → sourceBelowWarn, encode allowed, no upscale past source', () => { + const source: Pick = { widthPx: 600, heightPx: 500 }; + const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1); + const out = composeInventoryFrame( + makePhoto(source.widthPx, source.heightPx), + maskFor(bitset, { x: 0, y: 0, w: 600, h: 500 }), + source, + ); + assert.equal(out.sourceBelowWarn, true); + assert.equal(out.sourceBelowBlock, false); + assert.ok(out.width <= 800, `outW ${out.width} must not exceed warn floor`); +}); + test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => { const source: Pick = { widthPx: 2200, heightPx: 2000 }; const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1); @@ -107,7 +123,7 @@ test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => { 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('belowWarn (was belowFloor): 900×700 → warn not block, outW capped at warn floor', () => { const source: Pick = { widthPx: 900, heightPx: 700 }; const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1); const out = composeInventoryFrame( @@ -115,6 +131,20 @@ test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upsc maskFor(bitset, { x: 0, y: 0, w: 900, h: 700 }), source, ); - assert.equal(out.sourceBelowFloor, true); - assert.ok(out.width <= 800, `outW ${out.width} must not exceed the floor`); + assert.equal(out.sourceBelowWarn, true); + assert.equal(out.sourceBelowBlock, false); + assert.ok(out.width <= 800, `outW ${out.width} must not exceed the warn floor`); +}); + +test('belowBlock: shorter side 200 → sourceBelowBlock, no upscale', () => { + const source: Pick = { widthPx: 250, heightPx: 200 }; + const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1); + const out = composeInventoryFrame( + makePhoto(source.widthPx, source.heightPx), + maskFor(bitset, { x: 0, y: 0, w: 250, h: 200 }), + source, + ); + assert.equal(out.sourceBelowBlock, true); + assert.equal(out.sourceBelowWarn, false); + assert.ok(out.width <= 800); }); diff --git a/frontend/src/png/inventory.ts b/frontend/src/png/inventory.ts index 37fe911..174a957 100644 --- a/frontend/src/png/inventory.ts +++ b/frontend/src/png/inventory.ts @@ -1,5 +1,5 @@ import type { Mask, Source } from '../capture/capture.types'; -import { ASPECT_H, ASPECT_W, FLOOR_PX, DEFAULT_CROP_FILL } from './crop'; +import { ASPECT_H, ASPECT_W, FLOOR_WARN_PX, FLOOR_BLOCK_PX, DEFAULT_CROP_FILL } from './crop'; import { getCropFill } from '../settings/cropFill'; type MaskPick = Pick; @@ -17,7 +17,10 @@ export interface Frame { width: number; height: number; rgba: Uint8Array; - sourceBelowFloor: boolean; + /** Shorter side of slab bbox is below FLOOR_BLOCK_PX (hard fail). */ + sourceBelowBlock: boolean; + /** Shorter side is in [FLOOR_BLOCK_PX, FLOOR_WARN_PX) — encode/include with warn. */ + sourceBelowWarn: boolean; } export function composeInventoryFrame( @@ -34,8 +37,12 @@ export function composeInventoryFrame( const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h))); const sw = x1 - x0; const sh = y1 - y0; - const belowFloor = Math.min(sw, sh) < FLOOR_PX; - const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR_PX)) : FLOOR_PX; + const shortSide = Math.min(sw, sh); + const sourceBelowBlock = shortSide < FLOOR_BLOCK_PX; + const sourceBelowWarn = !sourceBelowBlock && shortSide < FLOOR_WARN_PX; + // Undersized vs warn floor: frame from available source (no upscale to 800). + const undersized = shortSide < FLOOR_WARN_PX; + const outW = undersized ? Math.max(1, Math.min(sw, FLOOR_WARN_PX)) : FLOOR_WARN_PX; const outH = Math.round((outW * ASPECT_H) / ASPECT_W); const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1)); const dw = sw * scale; @@ -60,12 +67,12 @@ export function composeInventoryFrame( rgba[di + 3] = 255; } } - return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor }; + return { width: outW, height: outH, rgba, sourceBelowBlock, sourceBelowWarn }; } export async function encodeInventoryPng( input: InventoryInput, -): Promise<{ blob: Blob; sourceBelowFloor: boolean }> { +): Promise<{ blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean }> { const { bitmap, mask, source } = input; const fill = input.cropFill ?? getCropFill(); const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx); @@ -82,5 +89,9 @@ export async function encodeInventoryPng( const ctx = out.getContext('2d')!; ctx.putImageData(new ImageData(new Uint8ClampedArray(frame.rgba), frame.width, frame.height), 0, 0); const blob = await out.convertToBlob({ type: 'image/png' }); - return { blob, sourceBelowFloor: frame.sourceBelowFloor }; + return { + blob, + sourceBelowBlock: frame.sourceBelowBlock, + sourceBelowWarn: frame.sourceBelowWarn, + }; } diff --git a/frontend/tests/e2e/helpers/draft.ts b/frontend/tests/e2e/helpers/draft.ts index 492d585..8fb96b3 100644 --- a/frontend/tests/e2e/helpers/draft.ts +++ b/frontend/tests/e2e/helpers/draft.ts @@ -73,7 +73,8 @@ export async function seedMeasureDraft( }, sqft: payload.sqft, bdft: payload.bdft, - sourceBelowFloor: false, + sourceBelowBlock: false, + sourceBelowWarn: false, }, inventoryPng: png, overlayPng: null,