Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abef27cf2a | |||
| 03d3fcf460 | |||
| 037a4b870a | |||
| b8e69b5239 | |||
| 495f179c2f | |||
| b386ab9670 | |||
| 2be2e7d615 | |||
| 6d182db81b | |||
| dc19cffcc0 | |||
| 05447c92fc | |||
| 415158d4d3 | |||
| 9bc35f7edd | |||
| 15895cae0c | |||
| 5650255e49 | |||
| 2be400e455 | |||
| 2c9a50ddf9 | |||
| 4c3c8b49eb | |||
| a22525023a | |||
| c674931f5c | |||
| 3936c575b2 | |||
| 9404d0130c |
@@ -35,3 +35,11 @@ images/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Playwright e2e (#65) — keep README; ignore churn
|
||||
frontend/tests/e2e/artifacts/*.png
|
||||
frontend/tests/e2e/artifacts/run-log.txt
|
||||
frontend/tests/e2e/test-results/
|
||||
frontend/tests/e2e/playwright-report/
|
||||
frontend/blob-report/
|
||||
frontend/playwright/.cache/
|
||||
|
||||
+23
-7
@@ -1,19 +1,35 @@
|
||||
# Host
|
||||
|
||||
## Overnight Milestone 1 Capture (poteto Build)
|
||||
## Overnight Capture UAT (primary — when Tailscale is up)
|
||||
|
||||
- Host: `ubudual5060` / Tailscale `100.64.0.9`
|
||||
- Container: `slabuploader-m1-overnight` (image `slabuploader-m1:overnight`)
|
||||
- Container: `slabuploader-m2-overnight` (image `slabuploader-m2:overnight`) — or salvage rebuild of the same role
|
||||
- Port: **10881** → 80
|
||||
- URL: `http://100.64.0.9:10881`
|
||||
- Also available on the Grok Bot box at `http://100.64.0.3:18080` (not LAN-reachable for Ty).
|
||||
- Replaces prior overnight container `slabuploader-m1-overnight` (same port).
|
||||
|
||||
## Ty local Node/SvelteKit (do not overwrite)
|
||||
## Fallback UAT — Grok Bot computer (when Tailscale to 5060 is down)
|
||||
|
||||
Use this path for salvage / Capture UAT until the box can reach `100.64.0.9` again.
|
||||
|
||||
- Host: Grok Bot computer (slab-m1 box)
|
||||
- Serve: static Capture build (`frontend` → `npm run build`, then `vite preview` or equivalent static server)
|
||||
- Port: **18181** (chosen so it never collides with Ty local on 5060)
|
||||
- Bind: `0.0.0.0:18181`
|
||||
- URL (from this box): `http://127.0.0.1:18181` — Ty reaches it via the bot computer / whatever path the app exposes; Tailscale IP of this box when mesh returns
|
||||
- Branch for salvage: `salvage-one-woo-draft` (cut from `m2-ds`)
|
||||
- Do **not** require Docker on the box for this fallback.
|
||||
|
||||
When Tailscale is restored, prefer primary overnight `:10881` again and stop advertising the fallback as the main UAT URL.
|
||||
|
||||
## Ty local Node/SvelteKit on 5060 (do not overwrite)
|
||||
|
||||
- Host: `ubudual5060` / `100.64.0.9`
|
||||
- Container: `slabuploader-m1`
|
||||
- Port: **10880** → 80
|
||||
- URL: `http://100.64.0.9:10880`
|
||||
- Port: **18080** → 80
|
||||
- URL: `http://100.64.0.9:18080`
|
||||
- **Never** recreate or remap this container for overnight or box-fallback UAT.
|
||||
- **Never** bind overnight or fallback UAT to **10880** either (legacy Ty local).
|
||||
|
||||
Future overnight Capture deploys use a **separate** container name and an open **108XX** port other than 10880.
|
||||
Future overnight Capture deploys use a **separate** container name and an open **108XX** port other than Ty’s local ports.
|
||||
Do not put a host name in the product spec.
|
||||
|
||||
+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.
|
||||
|
||||
|
||||
Generated
+43
@@ -12,6 +12,7 @@
|
||||
"svelte": "^4.2.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"svelte-preprocess": "^5.1.4",
|
||||
"tsx": "^4.23.13",
|
||||
@@ -494,6 +495,21 @@
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.63.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz",
|
||||
@@ -1242,6 +1258,33 @@
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node --import tsx --test src/**/*.test.ts"
|
||||
"test": "node --import tsx --test src/**/*.test.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"idb-keyval": "^6.3.0",
|
||||
"svelte": "^4.2.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"svelte-preprocess": "^5.1.4",
|
||||
"tsx": "^4.23.13",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* M2 #65 — Playwright visual evidence against local vite preview (not ubudual).
|
||||
* Artifacts land in tests/e2e/artifacts/ (png churn gitignored; README committed).
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
timeout: 180_000,
|
||||
expect: { timeout: 30_000 },
|
||||
reporter: [['list'], ['html', { open: 'never', outputFolder: 'tests/e2e/playwright-report' }]],
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4173',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'off',
|
||||
...devices['Pixel 7'],
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173',
|
||||
url: 'http://127.0.0.1:4173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000,
|
||||
},
|
||||
outputDir: 'tests/e2e/test-results',
|
||||
});
|
||||
@@ -1,8 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Capture } from './capture.types';
|
||||
import { inToEighths, eighthsToIn } from './capture.types';
|
||||
import { fmtEighths } from './session';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
import {
|
||||
buildPhotosReadyLineup,
|
||||
photosReadyPngAlt,
|
||||
type PhotosReadyLineupItem,
|
||||
} from './photosReadyLineup';
|
||||
|
||||
export let state: Extract<Capture, { k: 'measure' }>;
|
||||
export let setLength: (e: number) => void;
|
||||
@@ -15,6 +21,32 @@
|
||||
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
||||
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
|
||||
|
||||
let lineupUrls: string[] = [];
|
||||
let lineupItems: PhotosReadyLineupItem[] = [];
|
||||
|
||||
$: photosReady = !!state.results;
|
||||
$: {
|
||||
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
||||
lineupUrls = [];
|
||||
if (state.results) {
|
||||
lineupItems = buildPhotosReadyLineup({
|
||||
inventoryPng: state.results.inventoryPng,
|
||||
overlayPng: state.results.overlayPng,
|
||||
batchApply: state.batchApply,
|
||||
sourceBelowFloor: state.results.sourceBelowFloor,
|
||||
});
|
||||
lineupUrls = lineupItems.map((it) =>
|
||||
it.kind === 'png' ? URL.createObjectURL(it.blob) : '',
|
||||
);
|
||||
} else {
|
||||
lineupItems = [];
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
||||
});
|
||||
|
||||
const positiveInches = (raw: string): number | null => {
|
||||
const inches = Number.parseFloat(raw);
|
||||
return Number.isFinite(inches) && inches > 0 ? inches : null;
|
||||
@@ -46,7 +78,9 @@
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Measure</h1>
|
||||
<h1>{photosReady ? 'Photos ready' : 'Measure'}</h1>
|
||||
|
||||
{#if !photosReady}
|
||||
<PhotoStage
|
||||
blob={state.source.blob}
|
||||
bbox={state.axis.bbox}
|
||||
@@ -97,9 +131,8 @@
|
||||
{calcBusy ? 'Calculating…' : 'Calculate'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if state.results}
|
||||
<div class="results">
|
||||
{:else if state.results}
|
||||
<div class="results" aria-label="Photos ready">
|
||||
<div class="nums">
|
||||
<div><span>Widths</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})</span></div>
|
||||
<div><span>Sqft</span><span>{state.results.sqft.toFixed(2)}</span></div>
|
||||
@@ -109,12 +142,42 @@
|
||||
{#if state.results.sourceBelowFloor}
|
||||
<div class="warn">Source is below the 800px floor for the inventory PNG. Retake a larger photo.</div>
|
||||
{/if}
|
||||
{#if pngUrl && !state.results.sourceBelowFloor}
|
||||
|
||||
<ol class="lineup" aria-label="Inventory PNG lineup">
|
||||
{#each lineupItems as item, i (i)}
|
||||
{#if item.kind === 'png'}
|
||||
<li class="lineup-item png">
|
||||
<img src={lineupUrls[i]} alt={photosReadyPngAlt(item)} />
|
||||
<span class="role">
|
||||
{#if item.role === 'calibration'}
|
||||
Clean calibration
|
||||
{:else if item.role === 'overlay'}
|
||||
Dimension overlay
|
||||
{:else}
|
||||
Batch photo {(item.batchIndex ?? 0) + 1}
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="lineup-item error" role="alert">
|
||||
<span class="err-title">Photo {item.batchIndex + 1} failed</span>
|
||||
<span class="err-msg">{item.message}</span>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<!-- Legacy single-PNG fallback only when lineup helper yielded nothing but pngUrl exists -->
|
||||
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowFloor}
|
||||
<div class="png-wrap">
|
||||
<img src={pngUrl} alt="Inventory PNG" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -136,11 +199,29 @@
|
||||
}
|
||||
.primary:disabled { background: #333; color: #777; }
|
||||
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
.results { margin-top: 20px; }
|
||||
.results { margin-top: 8px; }
|
||||
.nums { display: flex; flex-direction: column; gap: 8px; }
|
||||
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; }
|
||||
.nums span:first-child { color: #aaa; }
|
||||
.warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; }
|
||||
.lineup {
|
||||
list-style: none; padding: 0; margin: 16px 0 0;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
.lineup-item.png {
|
||||
text-align: center;
|
||||
background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%);
|
||||
background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0;
|
||||
background-color: #eee; border-radius: 8px; padding: 8px;
|
||||
}
|
||||
.lineup-item.png img { max-width: 100%; border-radius: 4px; display: block; margin: 0 auto; }
|
||||
.role { display: block; margin-top: 8px; font-size: 0.85rem; color: #222; font-weight: 600; }
|
||||
.lineup-item.error {
|
||||
border: 1px solid #8a3a2a; background: #2a1512; border-radius: 8px;
|
||||
padding: 12px 14px; display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.err-title { color: #ffb4a2; font-weight: 700; font-size: 0.95rem; }
|
||||
.err-msg { color: #e8cfc8; font-size: 0.9rem; }
|
||||
.png-wrap { margin-top: 16px; text-align: center; background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%); background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; background-color: #eee; border-radius: 8px; padding: 8px; }
|
||||
.png-wrap img { max-width: 100%; border-radius: 4px; }
|
||||
</style>
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import type { BBox } from './capture.types';
|
||||
import { fitDisplay } from './fitDisplay';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
|
||||
// Display-only: renders the photo fitted to device width (height capped) plus
|
||||
// one overlay layer. All math lives on source pixels, not this canvas.
|
||||
// Geometry must use the same EXIF-oriented bitmap space as the mask worker (#72).
|
||||
export let blob: Blob;
|
||||
export let maxH = 420;
|
||||
export let bbox: BBox | null = null;
|
||||
@@ -12,21 +14,12 @@
|
||||
export let maskW = 0;
|
||||
export let maskH = 0;
|
||||
|
||||
let url: string | null = null;
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
let stage: HTMLDivElement | undefined;
|
||||
let imgW = 0, imgH = 0; // source px, learned once per blob
|
||||
let imgW = 0, imgH = 0; // source px (oriented), learned once per blob
|
||||
let parentW = 0;
|
||||
|
||||
const setBlobUrl = (b: Blob) => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
url = URL.createObjectURL(b);
|
||||
};
|
||||
|
||||
$: if (blob) {
|
||||
setBlobUrl(blob);
|
||||
imgW = 0;
|
||||
}
|
||||
let decodeGen = 0;
|
||||
let decoding = false;
|
||||
|
||||
const applyFit = () => {
|
||||
if (!canvas || !imgW) return;
|
||||
@@ -37,19 +30,8 @@
|
||||
canvas.style.height = `${fit.fitH}px`;
|
||||
};
|
||||
|
||||
const redraw = () => {
|
||||
if (!url || !canvas) return;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const paintOverlays = (ctx: CanvasRenderingContext2D) => {
|
||||
if (!canvas) return;
|
||||
imgW = img.width;
|
||||
imgH = img.height;
|
||||
canvas.width = imgW;
|
||||
canvas.height = imgH;
|
||||
applyFit();
|
||||
ctx.drawImage(img, 0, 0);
|
||||
if (maskBitset && maskW > 0 && maskH > 0) {
|
||||
const off = document.createElement('canvas');
|
||||
off.width = maskW;
|
||||
@@ -100,10 +82,31 @@
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
img.src = url;
|
||||
|
||||
const redraw = async () => {
|
||||
if (!blob || !canvas) return;
|
||||
const gen = ++decodeGen;
|
||||
decoding = true;
|
||||
let bmp: ImageBitmap | null = null;
|
||||
try {
|
||||
bmp = await decodeOrientedBitmap(blob);
|
||||
if (gen !== decodeGen || !canvas) return;
|
||||
imgW = bmp.width;
|
||||
imgH = bmp.height;
|
||||
canvas.width = imgW;
|
||||
canvas.height = imgH;
|
||||
applyFit();
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(bmp, 0, 0);
|
||||
paintOverlays(ctx);
|
||||
} finally {
|
||||
if (bmp) bmp.close();
|
||||
if (gen === decodeGen) decoding = false;
|
||||
}
|
||||
};
|
||||
|
||||
$: if (url || bbox || maskBitset) void tick().then(redraw);
|
||||
$: if (blob || bbox || maskBitset) void tick().then(redraw);
|
||||
|
||||
onMount(() => {
|
||||
if (stage) parentW = stage.clientWidth;
|
||||
@@ -119,12 +122,12 @@
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
decodeGen += 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="stage" bind:this={stage}>
|
||||
{#if url}
|
||||
{#if imgW > 0 || decoding || blob}
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
{:else}
|
||||
<div class="loading">Decoding photo…</div>
|
||||
|
||||
@@ -67,9 +67,18 @@ export interface Measures {
|
||||
sqft: number; // 2 dp
|
||||
bdft: number; // 2 dp
|
||||
inventoryPng: Blob;
|
||||
/** Dimension-overlay copy of the calibration inventory PNG (#63). */
|
||||
overlayPng?: Blob;
|
||||
sourceBelowFloor: 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: 'error'; message: string };
|
||||
|
||||
// The machine. 'resume' exists only while a draft exists and has not been chosen.
|
||||
// M2: 'batch' + 'pickCalibration' replace M1 'addPhoto' — accumulate, then user picks calibration.
|
||||
export type Capture =
|
||||
@@ -97,6 +106,8 @@ export type Capture =
|
||||
lengthEighths: Eighths;
|
||||
thicknessEighths: Eighths;
|
||||
results: Measures | null;
|
||||
/** Capture-order sibling results from the post-Calculate batch pass. */
|
||||
batchApply?: BatchPhotoResult[];
|
||||
};
|
||||
|
||||
// Placeholder geometry for batch-only drafts (no mask yet).
|
||||
@@ -129,8 +140,12 @@ export interface Draft {
|
||||
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
|
||||
lengthEighths: Eighths;
|
||||
thicknessEighths: Eighths;
|
||||
results: Omit<Measures, 'inventoryPng'> | null; // present when results computed
|
||||
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
|
||||
inventoryPng: Blob | null; // stored so Continue can show Measure results
|
||||
/** Dimension-overlay calibration PNG; kept out of batchApply[cal] (#63). */
|
||||
overlayPng?: Blob | null;
|
||||
/** Capture-order batch apply results; blobs are retained for the #64 lineup. */
|
||||
batchApply?: BatchPhotoResult[];
|
||||
}
|
||||
|
||||
// Worker protocol. Job ids increase; the worker drops anything older than the
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { test, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import {
|
||||
decodeOrientedBitmap,
|
||||
ORIENTED_BITMAP_OPTIONS,
|
||||
} from '../decodeOrientedBitmap.ts';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const fixturePath = join(here, '../../tests/fixtures/set1-48x2/face-a.jpeg');
|
||||
|
||||
const RealCreateImageBitmap = globalThis.createImageBitmap;
|
||||
|
||||
afterEach(() => {
|
||||
if (RealCreateImageBitmap === undefined) {
|
||||
delete (globalThis as unknown as { createImageBitmap?: unknown }).createImageBitmap;
|
||||
} else {
|
||||
globalThis.createImageBitmap = RealCreateImageBitmap;
|
||||
}
|
||||
});
|
||||
|
||||
/** Minimal EXIF Orientation + largest SOF size for the orient-8 fixture. */
|
||||
function readJpegOrientAndSof(buf: Buffer): { orientation: number; sofW: number; sofH: number } {
|
||||
let orientation = 1;
|
||||
const exif = buf.indexOf(Buffer.from('Exif\0\0'));
|
||||
if (exif >= 0) {
|
||||
const tiff = exif + 6;
|
||||
const le = buf[tiff] === 0x49;
|
||||
const u16 = (o: number) => (le ? buf.readUInt16LE(o) : buf.readUInt16BE(o));
|
||||
const u32 = (o: number) => (le ? buf.readUInt32LE(o) : buf.readUInt32BE(o));
|
||||
const ifd = tiff + u32(tiff + 4);
|
||||
const n = u16(ifd);
|
||||
for (let k = 0; k < n; k++) {
|
||||
const e = ifd + 2 + k * 12;
|
||||
const tag = u16(e);
|
||||
const typ = u16(e + 2);
|
||||
if (tag === 0x0112) {
|
||||
orientation = typ === 3 ? u16(e + 8) : u32(e + 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
let sofW = 0;
|
||||
let sofH = 0;
|
||||
let i = 0;
|
||||
while (i < buf.length - 9) {
|
||||
if (buf[i] === 0xff && buf[i + 1] >= 0xc0 && buf[i + 1] <= 0xc3) {
|
||||
const h = buf.readUInt16BE(i + 5);
|
||||
const w = buf.readUInt16BE(i + 7);
|
||||
if (w * h > sofW * sofH) {
|
||||
sofW = w;
|
||||
sofH = h;
|
||||
}
|
||||
const segLen = buf.readUInt16BE(i + 2);
|
||||
i += 2 + segLen;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return { orientation, sofW, sofH };
|
||||
}
|
||||
|
||||
/** Orientation 5–8 swap width/height for display. */
|
||||
function orientedSize(sofW: number, sofH: number, orientation: number): { w: number; h: number } {
|
||||
if (orientation >= 5 && orientation <= 8) return { w: sofH, h: sofW };
|
||||
return { w: sofW, h: sofH };
|
||||
}
|
||||
|
||||
test('fixture face-a.jpeg is EXIF Orientation 8 (stored landscape → oriented portrait) (#72)', () => {
|
||||
const buf = readFileSync(fixturePath);
|
||||
const { orientation, sofW, sofH } = readJpegOrientAndSof(buf);
|
||||
assert.equal(orientation, 8, 'matched-set JPEGs carry Orientation 8');
|
||||
assert.equal(sofW, 4000);
|
||||
assert.equal(sofH, 2256);
|
||||
const { w, h } = orientedSize(sofW, sofH, orientation);
|
||||
assert.equal(w, 2256);
|
||||
assert.equal(h, 4000);
|
||||
});
|
||||
|
||||
test('decodeOrientedBitmap always requests imageOrientation from-image (#72)', async () => {
|
||||
let seen: ImageBitmapOptions | undefined;
|
||||
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||
async (_src: ImageBitmapSource, opts?: ImageBitmapOptions) => {
|
||||
seen = opts;
|
||||
return { width: 2256, height: 4000, close() {} } as ImageBitmap;
|
||||
};
|
||||
|
||||
const blob = new Blob([readFileSync(fixturePath)], { type: 'image/jpeg' });
|
||||
const bmp = await decodeOrientedBitmap(blob);
|
||||
assert.deepEqual(seen, ORIENTED_BITMAP_OPTIONS);
|
||||
assert.equal(ORIENTED_BITMAP_OPTIONS.imageOrientation, 'from-image');
|
||||
assert.equal(bmp.width, 2256);
|
||||
assert.equal(bmp.height, 4000);
|
||||
});
|
||||
|
||||
test('decodeOrientedBitmap falls back to bare createImageBitmap if option rejected (#72)', async () => {
|
||||
let calls = 0;
|
||||
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||
async (_src: ImageBitmapSource, opts?: ImageBitmapOptions) => {
|
||||
calls += 1;
|
||||
if (opts && 'imageOrientation' in opts) {
|
||||
throw new TypeError('imageOrientation unsupported');
|
||||
}
|
||||
return { width: 90, height: 160, close() {} } as ImageBitmap;
|
||||
};
|
||||
|
||||
const bmp = await decodeOrientedBitmap(new Blob([new Uint8Array([1, 2, 3])], { type: 'image/jpeg' }));
|
||||
assert.equal(calls, 2, 'retry bare after option rejection');
|
||||
assert.equal(bmp.width, 90);
|
||||
assert.equal(bmp.height, 160);
|
||||
});
|
||||
|
||||
test('decodeOrientedBitmap: Chromium orients fixture to 2256×4000 (#72)', async (t) => {
|
||||
let chromium: typeof import('@playwright/test').chromium;
|
||||
try {
|
||||
({ chromium } = await import('@playwright/test'));
|
||||
} catch {
|
||||
t.skip('playwright not available');
|
||||
return;
|
||||
}
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const buf = readFileSync(fixturePath);
|
||||
const b64 = buf.toString('base64');
|
||||
// Inline the same contract as decodeOrientedBitmap (worker-safe, no bundler).
|
||||
const dims = await page.evaluate(async (b64jpeg: string) => {
|
||||
const bin = atob(b64jpeg);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||
const blob = new Blob([arr], { type: 'image/jpeg' });
|
||||
const opts = { imageOrientation: 'from-image' as const };
|
||||
let bmp: ImageBitmap;
|
||||
try {
|
||||
bmp = await createImageBitmap(blob, opts);
|
||||
} catch {
|
||||
bmp = await createImageBitmap(blob);
|
||||
}
|
||||
const out = { w: bmp.width, h: bmp.height };
|
||||
bmp.close();
|
||||
return out;
|
||||
}, b64);
|
||||
assert.equal(dims.w, 2256, `oriented width (got ${dims.w})`);
|
||||
assert.equal(dims.h, 4000, `oriented height (got ${dims.h})`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test } from 'node:test';
|
||||
import type { BatchPhotoResult } from './capture.types';
|
||||
import {
|
||||
buildPhotosReadyLineup,
|
||||
type PhotosReadyLineupItem,
|
||||
} from './photosReadyLineup';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function png(label: string): Blob {
|
||||
return new Blob([label], { type: 'image/png' });
|
||||
}
|
||||
|
||||
function jpeg(label: string): Blob {
|
||||
return new Blob([label], { type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
function roles(items: PhotosReadyLineupItem[]): string[] {
|
||||
return items.map((it) =>
|
||||
it.kind === 'png' ? `png:${it.role}` : `error:${it.batchIndex}`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- pure helper: lineup order + error slots (#64) ---
|
||||
|
||||
test('test(photos): lineup is clean cal → overlay → other batch PNGs in capture order (#64)', () => {
|
||||
const clean = png('clean-cal');
|
||||
const overlay = png('overlay-cal');
|
||||
const sib0 = png('sib-0');
|
||||
const sib2 = png('sib-2');
|
||||
const batchApply: BatchPhotoResult[] = [
|
||||
{ status: 'ok', inventoryPng: sib0 },
|
||||
null, // calibration at index 1
|
||||
{ status: 'ok', inventoryPng: sib2 },
|
||||
];
|
||||
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: clean,
|
||||
overlayPng: overlay,
|
||||
batchApply,
|
||||
});
|
||||
|
||||
assert.deepEqual(roles(lineup), [
|
||||
'png:calibration',
|
||||
'png:overlay',
|
||||
'png:batch',
|
||||
'png:batch',
|
||||
]);
|
||||
assert.equal(lineup[0].kind, 'png');
|
||||
if (lineup[0].kind === 'png') {
|
||||
assert.equal(lineup[0].blob, clean);
|
||||
assert.equal(lineup[0].role, 'calibration');
|
||||
}
|
||||
assert.equal(lineup[1].kind, 'png');
|
||||
if (lineup[1].kind === 'png') {
|
||||
assert.equal(lineup[1].blob, overlay);
|
||||
assert.equal(lineup[1].role, 'overlay');
|
||||
}
|
||||
assert.equal(lineup[2].kind, 'png');
|
||||
if (lineup[2].kind === 'png') {
|
||||
assert.equal(lineup[2].blob, sib0);
|
||||
assert.equal(lineup[2].batchIndex, 0);
|
||||
}
|
||||
assert.equal(lineup[3].kind, 'png');
|
||||
if (lineup[3].kind === 'png') {
|
||||
assert.equal(lineup[3].blob, sib2);
|
||||
assert.equal(lineup[3].batchIndex, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('test(photos): single-photo batch lineup is clean + overlay only (#64)', () => {
|
||||
const clean = png('only-clean');
|
||||
const overlay = png('only-overlay');
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: clean,
|
||||
overlayPng: overlay,
|
||||
batchApply: [null],
|
||||
});
|
||||
assert.deepEqual(roles(lineup), ['png:calibration', 'png:overlay']);
|
||||
assert.ok(lineup.every((it) => it.kind === 'png'));
|
||||
});
|
||||
|
||||
test('test(photos): batchApply errors become clear error slots (empty mask / floor) (#64)', () => {
|
||||
const clean = png('clean');
|
||||
const overlay = png('overlay');
|
||||
const ok = png('ok-sib');
|
||||
const batchApply: BatchPhotoResult[] = [
|
||||
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' },
|
||||
];
|
||||
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: clean,
|
||||
overlayPng: overlay,
|
||||
batchApply,
|
||||
});
|
||||
|
||||
assert.deepEqual(roles(lineup), [
|
||||
'png:calibration',
|
||||
'png:overlay',
|
||||
'error:1',
|
||||
'png:batch',
|
||||
'error:3',
|
||||
]);
|
||||
|
||||
const err1 = lineup[2];
|
||||
assert.equal(err1.kind, 'error');
|
||||
if (err1.kind === 'error') {
|
||||
assert.match(err1.message, /empty mask/i);
|
||||
assert.equal(err1.batchIndex, 1);
|
||||
}
|
||||
const err3 = lineup[4];
|
||||
assert.equal(err3.kind, 'error');
|
||||
if (err3.kind === 'error') {
|
||||
assert.match(err3.message, /floor|800/i);
|
||||
}
|
||||
|
||||
// Successful sibling still appears after cal+overlay, in capture order among siblings
|
||||
const okItem = lineup[3];
|
||||
assert.equal(okItem.kind, 'png');
|
||||
if (okItem.kind === 'png') {
|
||||
assert.equal(okItem.blob, ok);
|
||||
assert.equal(okItem.batchIndex, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('test(photos): lineup never uses source JPEG thumbs as inventory PNGs (#64)', () => {
|
||||
const clean = png('clean');
|
||||
const overlay = png('overlay');
|
||||
const thumb = jpeg('source-thumb');
|
||||
// Dishonest proxy would pass source thumbs; helper must only emit image/png blobs for png slots
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: clean,
|
||||
overlayPng: overlay,
|
||||
batchApply: [
|
||||
null,
|
||||
{ status: 'ok', inventoryPng: png('sib') },
|
||||
],
|
||||
});
|
||||
for (const it of lineup) {
|
||||
if (it.kind === 'png') {
|
||||
assert.equal(it.blob.type, 'image/png', 'lineup PNG slots must be inventory PNGs');
|
||||
assert.notEqual(it.blob, thumb);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('test(photos): skip clean cal when sourceBelowFloor; keep overlay + siblings (#64)', () => {
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: png('below'),
|
||||
overlayPng: png('overlay'),
|
||||
batchApply: [null, { status: 'ok', inventoryPng: png('sib') }],
|
||||
sourceBelowFloor: true,
|
||||
});
|
||||
assert.deepEqual(roles(lineup), ['png:overlay', 'png:batch']);
|
||||
});
|
||||
|
||||
test('test(photos): empty/missing results yield empty lineup (#64)', () => {
|
||||
assert.deepEqual(buildPhotosReadyLineup({}), []);
|
||||
assert.deepEqual(
|
||||
buildPhotosReadyLineup({ inventoryPng: null, overlayPng: null, batchApply: undefined }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
// --- light UI / session wiring (#64) ---
|
||||
|
||||
test('test(photos): MeasurePanel shows Photos ready lineup + M1 numbers + batch errors (#64)', () => {
|
||||
const panel = readFileSync(join(here, 'MeasurePanel.svelte'), 'utf8');
|
||||
assert.match(panel, /Photos ready|photos.?ready/i, 'end screen title after Calculate');
|
||||
assert.match(panel, /buildPhotosReadyLineup/, 'must use pure lineup helper');
|
||||
assert.match(panel, /Widths/, 'M1 widths still visible');
|
||||
assert.match(panel, /Sqft/, 'M1 sqft still visible');
|
||||
assert.match(panel, /Bdft/, 'M1 bdft still visible');
|
||||
assert.match(panel, /Scale/, 'M1 scale still visible');
|
||||
// Dishonest proxies
|
||||
assert.ok(
|
||||
!/lineup[\s\S]{0,200}state\.source\.blob/.test(panel),
|
||||
'must not lineup source thumbs',
|
||||
);
|
||||
assert.match(
|
||||
panel,
|
||||
/error|batchApply|message/i,
|
||||
'per-photo batchApply errors must surface in the panel',
|
||||
);
|
||||
assert.match(panel, /Back/, 'Back must still work from Photos ready');
|
||||
});
|
||||
|
||||
test('test(photos): App still routes measure results through Measure (or PhotosReady) with Back (#64)', () => {
|
||||
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
|
||||
// Stay on Measure with results, or dedicated panel — either is fine
|
||||
assert.ok(
|
||||
/MeasurePanel/.test(app) || /PhotosReady/.test(app),
|
||||
'App must render Photos-ready end screen',
|
||||
);
|
||||
// Must not invent a Mask loop for the lineup
|
||||
assert.ok(
|
||||
!/\{\s*#each\s+[^}]*photos[^}]*\}[\s\S]*MaskPanel/i.test(app),
|
||||
'App must not #each batch into Mask for Photos ready',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { BatchPhotoResult } from './capture.types';
|
||||
|
||||
/** One slot in the Photos-ready end screen (#64 / docs/PHOTOS.md). */
|
||||
export type PhotosReadyLineupItem =
|
||||
| {
|
||||
kind: 'png';
|
||||
role: 'calibration' | 'overlay' | 'batch';
|
||||
blob: Blob;
|
||||
/** Capture-order index for non-calibration batch inventory PNGs. */
|
||||
batchIndex?: number;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
batchIndex: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Photos-ready lineup:
|
||||
* 1. Clean calibration inventory PNG
|
||||
* 2. Dimension-overlay copy
|
||||
* 3. Other batch inventory PNGs in capture order (batchApply, skip cal null)
|
||||
* with error slots for empty mask / floor failures.
|
||||
*/
|
||||
export function buildPhotosReadyLineup(
|
||||
input: PhotosReadyLineupInput,
|
||||
): PhotosReadyLineupItem[] {
|
||||
const items: PhotosReadyLineupItem[] = [];
|
||||
const { inventoryPng, overlayPng, batchApply, sourceBelowFloor } = input;
|
||||
|
||||
if (inventoryPng && !sourceBelowFloor) {
|
||||
items.push({ kind: 'png', role: 'calibration', blob: inventoryPng });
|
||||
}
|
||||
if (overlayPng) {
|
||||
items.push({ kind: 'png', role: 'overlay', blob: overlayPng });
|
||||
}
|
||||
|
||||
if (batchApply) {
|
||||
for (let i = 0; i < batchApply.length; i++) {
|
||||
const slot = batchApply[i];
|
||||
if (slot === null) continue; // calibration lives in results.inventoryPng
|
||||
if (slot.status === 'ok') {
|
||||
items.push({
|
||||
kind: 'png',
|
||||
role: 'batch',
|
||||
blob: slot.inventoryPng,
|
||||
batchIndex: i,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
kind: 'error',
|
||||
batchIndex: i,
|
||||
message: slot.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function photosReadyPngAlt(item: Extract<PhotosReadyLineupItem, { kind: 'png' }>): string {
|
||||
if (item.role === 'calibration') return 'Calibration inventory PNG';
|
||||
if (item.role === 'overlay') return 'Calibration dimension-overlay PNG';
|
||||
const n = (item.batchIndex ?? 0) + 1;
|
||||
return `Batch inventory PNG ${n}`;
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test, beforeEach, afterEach } from 'node:test';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Draft, Mask, MaskParams, Source, WorkerIn, WorkerOut } from './capture.types';
|
||||
import { createCaptureSession, type DraftStore } from './session';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function tinyJpeg(label: string): File {
|
||||
const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, label.charCodeAt(0) & 0xff]);
|
||||
return new File([bytes], `${label}.jpg`, { type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
function memStore(): DraftStore & { peek: () => Draft | null } {
|
||||
let mem: Draft | null = null;
|
||||
return {
|
||||
loadDraft: async () => mem,
|
||||
saveDraft: async (d) => {
|
||||
mem = d;
|
||||
},
|
||||
clearDraft: async () => {
|
||||
mem = null;
|
||||
},
|
||||
peek: () => mem,
|
||||
};
|
||||
}
|
||||
|
||||
let store: ReturnType<typeof memStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = memStore();
|
||||
});
|
||||
|
||||
/** Controllable FakeWorker for #62 batch-apply paths. */
|
||||
type MaskBehavior = {
|
||||
w: number;
|
||||
h: number;
|
||||
/** slabPx; 0 ⇒ empty mask */
|
||||
slabPx: number;
|
||||
slabBounds?: { x: number; y: number; w: number; h: number };
|
||||
};
|
||||
|
||||
class FakeMaskWorker {
|
||||
onmessage: ((ev: MessageEvent<WorkerOut>) => void) | null = null;
|
||||
static inits: Blob[] = [];
|
||||
static maskCalls: { sheet: string; sensitivity: number; blob: Blob | null }[] = [];
|
||||
static lastInitBlob: Blob | null = null;
|
||||
/** Per-init blob → behavior. Default: full mask at LARGE dims (above PNG floor). */
|
||||
static behaviorFor: (blob: Blob) => MaskBehavior = () => ({
|
||||
w: 1000,
|
||||
h: 900,
|
||||
slabPx: 1000 * 900,
|
||||
slabBounds: { x: 0, y: 0, w: 1000, h: 900 },
|
||||
});
|
||||
|
||||
constructor(_url: URL | string, _opts?: WorkerOptions) {}
|
||||
|
||||
postMessage(msg: WorkerIn) {
|
||||
if (msg.kind === 'init') {
|
||||
FakeMaskWorker.inits.push(msg.blob);
|
||||
FakeMaskWorker.lastInitBlob = msg.blob;
|
||||
const id = msg.id;
|
||||
const b = FakeMaskWorker.behaviorFor(msg.blob);
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({ data: { id, kind: 'decoded', w: b.w, h: b.h } } as MessageEvent<WorkerOut>);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (msg.kind === 'mask') {
|
||||
const id = msg.id;
|
||||
const blob = FakeMaskWorker.lastInitBlob;
|
||||
FakeMaskWorker.maskCalls.push({
|
||||
sheet: msg.sheet,
|
||||
sensitivity: msg.sensitivity,
|
||||
blob,
|
||||
});
|
||||
const b = blob ? FakeMaskWorker.behaviorFor(blob) : { w: 40, h: 20, slabPx: 0 };
|
||||
const bitset = new Uint8Array(Math.max(1, b.w * b.h)).fill(b.slabPx > 0 ? 1 : 0);
|
||||
const bbox = {
|
||||
corners: [
|
||||
[b.w, 0],
|
||||
[b.w, b.h],
|
||||
[0, b.h],
|
||||
[0, 0],
|
||||
] as [number, number][],
|
||||
longSidePx: b.w,
|
||||
angleDeg: 0,
|
||||
};
|
||||
const slabBounds = b.slabBounds ?? { x: 0, y: 0, w: b.w, h: b.h };
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({
|
||||
data: {
|
||||
id,
|
||||
kind: 'ready',
|
||||
slabPx: b.slabPx,
|
||||
bitset,
|
||||
bbox,
|
||||
slabBounds,
|
||||
},
|
||||
} as MessageEvent<WorkerOut>);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
terminate() {}
|
||||
}
|
||||
|
||||
const RealWorker = globalThis.Worker;
|
||||
const RealCreateImageBitmap = globalThis.createImageBitmap;
|
||||
|
||||
function installFakes() {
|
||||
FakeMaskWorker.inits = [];
|
||||
FakeMaskWorker.maskCalls = [];
|
||||
FakeMaskWorker.lastInitBlob = null;
|
||||
FakeMaskWorker.behaviorFor = () => ({
|
||||
w: 1000,
|
||||
h: 900,
|
||||
slabPx: 1000 * 900,
|
||||
slabBounds: { x: 0, y: 0, w: 1000, h: 900 },
|
||||
});
|
||||
(globalThis as unknown as { Worker: typeof FakeMaskWorker }).Worker = FakeMaskWorker;
|
||||
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||
async (blob: ImageBitmapSource) => {
|
||||
const b = blob instanceof Blob ? FakeMaskWorker.behaviorFor(blob) : { w: 1000, h: 900 };
|
||||
return {
|
||||
width: b.w,
|
||||
height: b.h,
|
||||
close() {},
|
||||
} as ImageBitmap;
|
||||
};
|
||||
}
|
||||
|
||||
function restoreGlobals() {
|
||||
if (RealWorker === undefined) {
|
||||
delete (globalThis as unknown as { Worker?: unknown }).Worker;
|
||||
} else {
|
||||
globalThis.Worker = RealWorker;
|
||||
}
|
||||
if (RealCreateImageBitmap === undefined) {
|
||||
delete (globalThis as unknown as { createImageBitmap?: unknown }).createImageBitmap;
|
||||
} else {
|
||||
globalThis.createImageBitmap = RealCreateImageBitmap;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
restoreGlobals();
|
||||
});
|
||||
|
||||
async function settleOverlay(
|
||||
s: ReturnType<typeof createCaptureSession>,
|
||||
ms = 800,
|
||||
): Promise<Mask> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < ms) {
|
||||
const cap = get(s.capture);
|
||||
if (cap.k === 'mask' && cap.overlay && cap.overlay.slabPx > 0) return cap.overlay;
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
throw new Error('mask overlay never settled');
|
||||
}
|
||||
|
||||
async function driveToMeasure(
|
||||
s: ReturnType<typeof createCaptureSession>,
|
||||
photos: File[],
|
||||
calIndex: number,
|
||||
maskParams: Partial<MaskParams> = { sheet: 'green', sensitivity: 50 },
|
||||
) {
|
||||
for (const p of photos) await s.addToBatch(p);
|
||||
s.continueBatch();
|
||||
s.pickCalibration(calIndex);
|
||||
if (maskParams.sheet) s.setSheet(maskParams.sheet);
|
||||
if (typeof maskParams.sensitivity === 'number') s.setSensitivity(maskParams.sensitivity);
|
||||
// setSheet already runs a pass; if only sensitivity, force via setSheet after
|
||||
if (!maskParams.sheet) s.setSheet('auto');
|
||||
await settleOverlay(s);
|
||||
s.approveMask();
|
||||
s.approveAxis();
|
||||
s.setLength(320); // 40 in
|
||||
s.setThickness(16); // 2 in
|
||||
}
|
||||
|
||||
type BatchApplySlot =
|
||||
| null
|
||||
| { status: 'ok'; inventoryPng: Blob }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
function batchApplyOf(cap: ReturnType<typeof get<ReturnType<typeof createCaptureSession>['capture']>>): BatchApplySlot[] | undefined {
|
||||
if (cap.k !== 'measure') return undefined;
|
||||
return (cap as { batchApply?: BatchApplySlot[] }).batchApply;
|
||||
}
|
||||
|
||||
// --- #62: apply approved mask + crop to non-calibration batch photos after Calculate ---
|
||||
|
||||
test('#62: Calculate applies frozen mask params to every non-calibration batch photo', async () => {
|
||||
installFakes();
|
||||
const encodeCalls: { widthPx: number; heightPx: number; sheet: string; sensitivity: number }[] = [];
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async (input) => {
|
||||
encodeCalls.push({
|
||||
widthPx: input.source.widthPx,
|
||||
heightPx: input.source.heightPx,
|
||||
sheet: (input.mask as Mask).params?.sheet ?? '?',
|
||||
sensitivity: (input.mask as Mask).params?.sensitivity ?? -1,
|
||||
});
|
||||
// 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,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b'); // calibration
|
||||
const c = tinyJpeg('c');
|
||||
await driveToMeasure(s, [a, b, c], 1, { sheet: 'black', sensitivity: 72 });
|
||||
await s.calculate();
|
||||
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'measure', 'must stay on Measure — do not reopen Mask');
|
||||
const apply = batchApplyOf(cap);
|
||||
assert.ok(Array.isArray(apply), 'measure.batchApply must exist after Calculate');
|
||||
assert.equal(apply!.length, 3, 'one slot per batch photo');
|
||||
assert.equal(apply![1], null, 'calibration index is null (uses results.inventoryPng)');
|
||||
|
||||
assert.ok(apply![0] && apply![0].status === 'ok', 'batch[0] applied ok');
|
||||
assert.ok(apply![2] && apply![2].status === 'ok', 'batch[2] applied ok');
|
||||
if (apply![0]?.status === 'ok') {
|
||||
assert.equal(apply![0].inventoryPng.type, 'image/png');
|
||||
assert.notEqual(apply![0].inventoryPng, a, 'must not proxy source thumb as inventory PNG');
|
||||
}
|
||||
if (apply![2]?.status === 'ok') {
|
||||
assert.equal(apply![2].inventoryPng.type, 'image/png');
|
||||
assert.notEqual(apply![2].inventoryPng, c);
|
||||
}
|
||||
|
||||
// Frozen params: every sibling mask call used calibration sheet/sensitivity
|
||||
const siblingBlobs = new Set([a, c]);
|
||||
const siblingMasks = FakeMaskWorker.maskCalls.filter((m) => m.blob && siblingBlobs.has(m.blob as File));
|
||||
assert.ok(siblingMasks.length >= 2, 'mask worker must run on non-calibration photos');
|
||||
for (const m of siblingMasks) {
|
||||
assert.equal(m.sheet, 'black', 'reuse approved sheet');
|
||||
assert.equal(m.sensitivity, 72, 'reuse approved sensitivity');
|
||||
}
|
||||
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: empty mask on one sibling → per-photo error; others still proceed', async () => {
|
||||
installFakes();
|
||||
const a = tinyJpeg('a'); // will empty-mask
|
||||
const b = tinyJpeg('b'); // cal
|
||||
const c = tinyJpeg('c'); // ok
|
||||
|
||||
FakeMaskWorker.behaviorFor = (blob) => {
|
||||
if (blob === a) {
|
||||
return { w: 1000, h: 900, slabPx: 0, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
||||
}
|
||||
return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } };
|
||||
};
|
||||
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['ok-png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
await driveToMeasure(s, [a, b, c], 1, { sheet: 'green' });
|
||||
await s.calculate();
|
||||
|
||||
const apply = batchApplyOf(get(s.capture));
|
||||
assert.ok(apply);
|
||||
assert.equal(apply![1], null);
|
||||
assert.ok(apply![0] && apply![0].status === 'error', 'empty mask → error');
|
||||
if (apply![0]?.status === 'error') {
|
||||
assert.match(apply![0].message, /empty|mask/i, 'clear per-photo empty-mask error');
|
||||
}
|
||||
assert.ok(apply![2] && apply![2].status === 'ok', 'other photos still proceed');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: below PNG size floor 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
|
||||
|
||||
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: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } };
|
||||
};
|
||||
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async (input) => {
|
||||
const below = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h) < 800;
|
||||
return {
|
||||
blob: new Blob([below ? 'tiny' : 'ok'], { type: 'image/png' }),
|
||||
sourceBelowFloor: below,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[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 === 'error', 'below-floor → error');
|
||||
if (apply![2]?.status === 'error') {
|
||||
assert.match(apply![2].message, /floor|small|800|size/i, 'clear per-photo size-floor error');
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: scale/widths/sqft/bdft stay on calibration results only (siblings are visual)', async () => {
|
||||
installFakes();
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b');
|
||||
await driveToMeasure(s, [a, b], 1, { sheet: 'green' });
|
||||
await s.calculate();
|
||||
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'measure');
|
||||
if (cap.k === 'measure') {
|
||||
assert.ok(cap.results);
|
||||
assert.equal(typeof cap.results!.scale, 'number');
|
||||
assert.ok(cap.results!.scale > 0);
|
||||
assert.equal(typeof cap.results!.sqft, 'number');
|
||||
assert.equal(typeof cap.results!.bdft, 'number');
|
||||
assert.ok(cap.results!.widths);
|
||||
// batchApply ok slots must NOT carry measure fields
|
||||
const apply = (cap as { batchApply?: BatchApplySlot[] }).batchApply;
|
||||
assert.ok(apply);
|
||||
const slot = apply![0];
|
||||
assert.ok(slot && slot.status === 'ok');
|
||||
if (slot && slot.status === 'ok') {
|
||||
assert.equal(
|
||||
'scale' in slot,
|
||||
false,
|
||||
'non-cal apply must not attach scale',
|
||||
);
|
||||
assert.equal('sqft' in slot, false);
|
||||
assert.equal('bdft' in slot, false);
|
||||
assert.equal('widths' in slot, false);
|
||||
}
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: draft persists batchApply after Calculate (for #64 UI later)', async () => {
|
||||
installFakes();
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['draft-png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b');
|
||||
const c = tinyJpeg('c');
|
||||
await driveToMeasure(s, [a, b, c], 0, { sheet: 'black' }); // cal = first
|
||||
await s.calculate();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
const d = store.peek();
|
||||
assert.ok(d);
|
||||
assert.equal(d!.stage, 'measure');
|
||||
assert.ok(Array.isArray(d!.batchApply), 'draft.batchApply must be persisted');
|
||||
assert.equal(d!.batchApply!.length, 3);
|
||||
assert.equal(d!.batchApply![0], null, 'calibration slot null on draft');
|
||||
assert.ok(d!.batchApply![1] && (d!.batchApply![1] as { status: string }).status === 'ok');
|
||||
assert.ok(d!.batchApply![2] && (d!.batchApply![2] as { status: string }).status === 'ok');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: single-photo batch Calculate leaves batchApply=[null] (no sibling Mask)', async () => {
|
||||
installFakes();
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['only'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
const only = tinyJpeg('only');
|
||||
await driveToMeasure(s, [only], 0, { sheet: 'auto' });
|
||||
const initsBefore = FakeMaskWorker.inits.length;
|
||||
await s.calculate();
|
||||
|
||||
const apply = batchApplyOf(get(s.capture));
|
||||
assert.ok(apply);
|
||||
assert.deepEqual(apply, [null]);
|
||||
|
||||
// No extra mask inits for non-existent siblings after Calculate
|
||||
const siblingInits = FakeMaskWorker.inits.slice(initsBefore).filter((blob) => blob !== only);
|
||||
assert.equal(siblingInits.length, 0, 'must not invent Mask work for absent siblings');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: Calculate does not re-enter Mask / does not open Mask controls for siblings', async () => {
|
||||
installFakes();
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['x'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
await driveToMeasure(s, [tinyJpeg('a'), tinyJpeg('b')], 0, { sheet: 'green' });
|
||||
await s.calculate();
|
||||
assert.equal(get(s.capture).k, 'measure');
|
||||
|
||||
// Session source must never loop enterMaskOnSource over batch after Calculate
|
||||
const sessionSrc = readFileSync(join(here, 'session.ts'), 'utf8');
|
||||
assert.ok(
|
||||
/batchApply|applyBatch|apply.*non-?calibrat/i.test(sessionSrc),
|
||||
'session must implement batch apply after Calculate (#62)',
|
||||
);
|
||||
// Dishonest proxy: must not set k:'mask' inside calculate for each photo
|
||||
const calcBody = sessionSrc.match(/async calculate\(\)[\s\S]*?(?=\n [a-zA-Z]+\(|\n \})/);
|
||||
assert.ok(calcBody, 'calculate() must exist');
|
||||
assert.ok(
|
||||
!/set\(\s*\{\s*k:\s*'mask'/.test(calcBody![0]),
|
||||
'calculate must not reopen Mask state for siblings',
|
||||
);
|
||||
|
||||
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
|
||||
assert.ok(
|
||||
!/\{\s*#each\s+[^}]*batchApply[^}]*\}[\s\S]*MaskPanel/i.test(app) &&
|
||||
!/\{\s*#each\s+[^}]*photos[^}]*\}[\s\S]*MaskPanel/i.test(app),
|
||||
'App must not #each photos into MaskPanel',
|
||||
);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: resume restores batchApply alongside calibration results', async () => {
|
||||
installFakes();
|
||||
const s1 = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['resume-png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b');
|
||||
await driveToMeasure(s1, [a, b], 1, { sheet: 'green' });
|
||||
await s1.calculate();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
s1.dispose();
|
||||
|
||||
FakeMaskWorker.inits = [];
|
||||
const s2 = createCaptureSession({ draft: store } as Parameters<typeof createCaptureSession>[0]);
|
||||
await s2.resumeProbe();
|
||||
s2.continueDraft();
|
||||
const start = Date.now();
|
||||
let cap = get(s2.capture);
|
||||
while (cap.k !== 'measure' && Date.now() - start < 1500) {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
cap = get(s2.capture);
|
||||
}
|
||||
assert.equal(cap.k, 'measure');
|
||||
const apply = batchApplyOf(cap);
|
||||
assert.ok(apply, 'restored measure must include batchApply');
|
||||
assert.equal(apply!.length, 2);
|
||||
assert.equal(apply![1], null);
|
||||
assert.ok(apply![0] && apply![0].status === 'ok');
|
||||
if (apply![0]?.status === 'ok') {
|
||||
assert.equal(apply![0].inventoryPng.type, 'image/png');
|
||||
}
|
||||
s2.dispose();
|
||||
});
|
||||
@@ -408,10 +408,15 @@ test('#61: Calculate produces inventory PNG + M1 measures for calibration source
|
||||
assert.equal(cap.source.widthPx, MASK_W, 'measure source dims from calibration decode');
|
||||
}
|
||||
|
||||
// Still only calibration blob through the mask worker.
|
||||
for (const blob of FakeMaskWorker.inits) {
|
||||
assert.equal(blob, b);
|
||||
}
|
||||
// Pre-#62: Mask UI path used only the calibration blob. After Calculate, #62
|
||||
// may init the worker on non-calibration siblings for frozen-mask apply.
|
||||
const preApplyInits = FakeMaskWorker.inits.filter((blob) => blob === b || blob === a);
|
||||
assert.ok(preApplyInits.some((blob) => blob === b), 'calibration must have been masked');
|
||||
// Sibling apply may touch `a`; that is #62, not a #61 regression of Mask UI.
|
||||
assert.ok(
|
||||
FakeMaskWorker.inits.filter((blob) => blob === b).length >= 1,
|
||||
'calibration blob must appear in worker inits',
|
||||
);
|
||||
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test, beforeEach, afterEach } from 'node:test';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Draft, Mask, MaskParams, WorkerIn, WorkerOut } from './capture.types';
|
||||
import { createCaptureSession, type DraftStore } from './session';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function tinyJpeg(label: string): File {
|
||||
const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, label.charCodeAt(0) & 0xff]);
|
||||
return new File([bytes], `${label}.jpg`, { type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
function memStore(): DraftStore & { peek: () => Draft | null } {
|
||||
let mem: Draft | null = null;
|
||||
return {
|
||||
loadDraft: async () => mem,
|
||||
saveDraft: async (d) => {
|
||||
mem = d;
|
||||
},
|
||||
clearDraft: async () => {
|
||||
mem = null;
|
||||
},
|
||||
peek: () => mem,
|
||||
};
|
||||
}
|
||||
|
||||
let store: ReturnType<typeof memStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = memStore();
|
||||
});
|
||||
|
||||
type MaskBehavior = {
|
||||
w: number;
|
||||
h: number;
|
||||
slabPx: number;
|
||||
slabBounds?: { x: number; y: number; w: number; h: number };
|
||||
};
|
||||
|
||||
class FakeMaskWorker {
|
||||
onmessage: ((ev: MessageEvent<WorkerOut>) => void) | null = null;
|
||||
static inits: Blob[] = [];
|
||||
static maskCalls: { sheet: string; sensitivity: number; blob: Blob | null }[] = [];
|
||||
static lastInitBlob: Blob | null = null;
|
||||
static behaviorFor: (blob: Blob) => MaskBehavior = () => ({
|
||||
w: 1000,
|
||||
h: 900,
|
||||
slabPx: 1000 * 900,
|
||||
slabBounds: { x: 0, y: 0, w: 1000, h: 900 },
|
||||
});
|
||||
|
||||
constructor(_url: URL | string, _opts?: WorkerOptions) {}
|
||||
|
||||
postMessage(msg: WorkerIn) {
|
||||
if (msg.kind === 'init') {
|
||||
FakeMaskWorker.inits.push(msg.blob);
|
||||
FakeMaskWorker.lastInitBlob = msg.blob;
|
||||
const id = msg.id;
|
||||
const b = FakeMaskWorker.behaviorFor(msg.blob);
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({ data: { id, kind: 'decoded', w: b.w, h: b.h } } as MessageEvent<WorkerOut>);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (msg.kind === 'mask') {
|
||||
const id = msg.id;
|
||||
const blob = FakeMaskWorker.lastInitBlob;
|
||||
FakeMaskWorker.maskCalls.push({
|
||||
sheet: msg.sheet,
|
||||
sensitivity: msg.sensitivity,
|
||||
blob,
|
||||
});
|
||||
const b = blob ? FakeMaskWorker.behaviorFor(blob) : { w: 40, h: 20, slabPx: 0 };
|
||||
const bitset = new Uint8Array(Math.max(1, b.w * b.h)).fill(b.slabPx > 0 ? 1 : 0);
|
||||
const bbox = {
|
||||
corners: [
|
||||
[b.w, 0],
|
||||
[b.w, b.h],
|
||||
[0, b.h],
|
||||
[0, 0],
|
||||
] as [number, number][],
|
||||
longSidePx: b.w,
|
||||
angleDeg: 0,
|
||||
};
|
||||
const slabBounds = b.slabBounds ?? { x: 0, y: 0, w: b.w, h: b.h };
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({
|
||||
data: {
|
||||
id,
|
||||
kind: 'ready',
|
||||
slabPx: b.slabPx,
|
||||
bitset,
|
||||
bbox,
|
||||
slabBounds,
|
||||
},
|
||||
} as MessageEvent<WorkerOut>);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
terminate() {}
|
||||
}
|
||||
|
||||
const RealWorker = globalThis.Worker;
|
||||
const RealCreateImageBitmap = globalThis.createImageBitmap;
|
||||
|
||||
function installFakes() {
|
||||
FakeMaskWorker.inits = [];
|
||||
FakeMaskWorker.maskCalls = [];
|
||||
FakeMaskWorker.lastInitBlob = null;
|
||||
FakeMaskWorker.behaviorFor = () => ({
|
||||
w: 1000,
|
||||
h: 900,
|
||||
slabPx: 1000 * 900,
|
||||
slabBounds: { x: 0, y: 0, w: 1000, h: 900 },
|
||||
});
|
||||
(globalThis as unknown as { Worker: typeof FakeMaskWorker }).Worker = FakeMaskWorker;
|
||||
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||
async (blob: ImageBitmapSource) => {
|
||||
const b = blob instanceof Blob ? FakeMaskWorker.behaviorFor(blob) : { w: 1000, h: 900 };
|
||||
return { width: b.w, height: b.h, close() {} } as ImageBitmap;
|
||||
};
|
||||
}
|
||||
|
||||
function restoreGlobals() {
|
||||
if (RealWorker === undefined) {
|
||||
delete (globalThis as unknown as { Worker?: unknown }).Worker;
|
||||
} else {
|
||||
globalThis.Worker = RealWorker;
|
||||
}
|
||||
if (RealCreateImageBitmap === undefined) {
|
||||
delete (globalThis as unknown as { createImageBitmap?: unknown }).createImageBitmap;
|
||||
} else {
|
||||
globalThis.createImageBitmap = RealCreateImageBitmap;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
restoreGlobals();
|
||||
});
|
||||
|
||||
async function settleOverlay(
|
||||
s: ReturnType<typeof createCaptureSession>,
|
||||
ms = 2500,
|
||||
): Promise<Mask> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < ms) {
|
||||
const cap = get(s.capture);
|
||||
if (cap.k === 'mask' && cap.overlay && cap.overlay.slabPx > 0) return cap.overlay;
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
throw new Error('mask overlay never settled');
|
||||
}
|
||||
|
||||
async function driveToMeasure(
|
||||
s: ReturnType<typeof createCaptureSession>,
|
||||
photos: File[],
|
||||
calIndex: number,
|
||||
maskParams: Partial<MaskParams> = { sheet: 'green', sensitivity: 50 },
|
||||
) {
|
||||
for (const p of photos) await s.addToBatch(p);
|
||||
s.continueBatch();
|
||||
s.pickCalibration(calIndex);
|
||||
if (typeof maskParams.sensitivity === 'number') s.setSensitivity(maskParams.sensitivity);
|
||||
// setSheet runs an immediate pass (after sensitivity so the pass sees frozen params)
|
||||
s.setSheet(maskParams.sheet ?? 'auto');
|
||||
await settleOverlay(s);
|
||||
s.approveMask();
|
||||
s.approveAxis();
|
||||
s.setLength(320); // 40 in
|
||||
s.setThickness(16); // 2 in
|
||||
}
|
||||
|
||||
test('test(photos): Calculate stores overlayPng on results, clean inventory untouched (#63)', async () => {
|
||||
installFakes();
|
||||
let encodeN = 0;
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => {
|
||||
encodeN++;
|
||||
return {
|
||||
blob: new Blob([`clean-${encodeN}`], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b'); // cal
|
||||
await driveToMeasure(s, [a, b], 1, { sheet: 'green' });
|
||||
await s.calculate();
|
||||
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'measure');
|
||||
if (cap.k !== 'measure') return;
|
||||
assert.ok(cap.results, 'results present');
|
||||
assert.ok(cap.results!.inventoryPng, 'clean cal PNG in results.inventoryPng');
|
||||
assert.equal(cap.results!.inventoryPng.type, 'image/png');
|
||||
|
||||
const overlay = (cap.results as { overlayPng?: Blob }).overlayPng;
|
||||
assert.ok(overlay, 'results.overlayPng must exist after Calculate (#63)');
|
||||
assert.equal(overlay!.type, 'image/png');
|
||||
assert.notEqual(overlay, cap.results!.inventoryPng, 'overlay is a second copy, not the clean PNG');
|
||||
|
||||
const apply = (cap as { batchApply?: unknown[] }).batchApply;
|
||||
assert.ok(Array.isArray(apply));
|
||||
assert.equal(apply![1], null, 'batchApply[cal] stays null');
|
||||
assert.ok(apply![0] && (apply![0] as { status: string }).status === 'ok', 'siblings unchanged');
|
||||
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('test(photos): single-photo batch still yields clean + overlayPng (#63)', async () => {
|
||||
installFakes();
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['only-clean'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
await driveToMeasure(s, [tinyJpeg('only')], 0, { sheet: 'auto' });
|
||||
await s.calculate();
|
||||
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'measure');
|
||||
if (cap.k !== 'measure') return;
|
||||
assert.ok(cap.results!.inventoryPng);
|
||||
assert.ok((cap.results as { overlayPng?: Blob }).overlayPng, 'overlay even for batch of one');
|
||||
assert.deepEqual((cap as { batchApply?: unknown[] }).batchApply, [null]);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('test(photos): draft persists overlayPng alongside inventoryPng (#63)', async () => {
|
||||
installFakes();
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['draft-clean'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
await driveToMeasure(s, [tinyJpeg('a'), tinyJpeg('b')], 0, { sheet: 'black' });
|
||||
await s.calculate();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
const d = store.peek();
|
||||
assert.ok(d);
|
||||
assert.equal(d!.stage, 'measure');
|
||||
assert.ok(d!.inventoryPng, 'draft keeps clean inventory');
|
||||
assert.ok(
|
||||
(d as { overlayPng?: Blob | null }).overlayPng,
|
||||
'draft.overlayPng must be persisted for #64 lineup',
|
||||
);
|
||||
assert.equal(d!.batchApply![0], null, 'cal slot still null on draft');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('test(photos): dishonest proxies absent — no raw-photo overlay / no cal-slot stash (#63)', async () => {
|
||||
const sessionSrc = readFileSync(join(here, 'session.ts'), 'utf8');
|
||||
const overlaySrcPath = join(here, '..', 'png', 'overlay.ts');
|
||||
let overlaySrc = '';
|
||||
try {
|
||||
overlaySrc = readFileSync(overlaySrcPath, 'utf8');
|
||||
} catch {
|
||||
overlaySrc = '';
|
||||
}
|
||||
assert.ok(
|
||||
/overlayPng|encodeOverlay|dimensionOverlay|buildOverlayPlan/i.test(sessionSrc) ||
|
||||
overlaySrc.length > 0,
|
||||
'session or png/overlay must implement dimension overlay (#63)',
|
||||
);
|
||||
const calcBody = sessionSrc.match(/async calculate\(\)[\s\S]*?(?=\n [a-zA-Z]+\(|\n \})/);
|
||||
assert.ok(calcBody, 'calculate() must exist');
|
||||
assert.ok(
|
||||
!/batchApply\s*\[\s*calIndex\s*\]\s*=\s*\{[^}]*overlay/i.test(calcBody![0]),
|
||||
'must not put overlay into batchApply[cal]',
|
||||
);
|
||||
});
|
||||
+119
-11
@@ -1,6 +1,6 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type {
|
||||
Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut,
|
||||
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut,
|
||||
} from './capture.types';
|
||||
import {
|
||||
DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn,
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
|
||||
import { measure } from '../measure/measure';
|
||||
import { orientedRect } from '../geometry/rect';
|
||||
import { encodeInventoryPng } from '../png/inventory';
|
||||
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
||||
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
const BUSY_MS = 300;
|
||||
@@ -50,6 +52,10 @@ export interface CaptureSession {
|
||||
|
||||
export interface SessionOpts {
|
||||
draft?: DraftStore;
|
||||
/** Injectable only for deterministic tests; production uses encodeInventoryPng. */
|
||||
encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowFloor: boolean }>;
|
||||
/** Injectable only for deterministic tests; production uses encodeOverlayPng. */
|
||||
encodeOverlay?: (input: EncodeOverlayInput) => Promise<Blob>;
|
||||
}
|
||||
|
||||
const defaultDraft: DraftStore = { loadDraft, saveDraft, clearDraft };
|
||||
@@ -61,6 +67,8 @@ const batchPhotosOf = (d: Draft): Source[] =>
|
||||
// Every transition to an approved branch persists; no explicit "save".
|
||||
export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
const drafts = opts.draft ?? defaultDraft;
|
||||
const encodeInventory = opts.encodeInventory ?? encodeInventoryPng;
|
||||
const encodeOverlay = opts.encodeOverlay ?? encodeOverlayPng;
|
||||
const capture = writable<Capture>({ k: 'batch', photos: [] });
|
||||
const busy = writable(false);
|
||||
let state: Capture = { k: 'batch', photos: [] };
|
||||
@@ -201,7 +209,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
};
|
||||
}
|
||||
if (state.k === 'measure') {
|
||||
const { inventoryPng: _drop, ...rest } = state.results ?? ({} as Measures);
|
||||
const { inventoryPng: _drop, overlayPng: _dropOverlay, ...rest } = state.results ?? ({} as Measures);
|
||||
return {
|
||||
version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source],
|
||||
calibrationIndex: calIdx,
|
||||
@@ -210,6 +218,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
|
||||
results: state.results ? rest : null,
|
||||
inventoryPng: state.results?.inventoryPng ?? null,
|
||||
overlayPng: state.results?.overlayPng ?? null,
|
||||
batchApply: state.batchApply,
|
||||
};
|
||||
}
|
||||
if (state.k === 'mask' && state.overlay) {
|
||||
@@ -240,8 +250,13 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
else if (state.k === 'batch' && state.photos.length === 0) await drafts.clearDraft();
|
||||
};
|
||||
|
||||
// Re-derive the mask bitset from the stored photo + params so Measure can run.
|
||||
const rederiveMask = (source: Source, params: MaskParams, stub: Omit<Mask, 'bitset' | 'params'>): Promise<Mask> =>
|
||||
// Re-derive the mask bitset from a stored photo + frozen params. The decoded
|
||||
// dimensions are returned because batch Sources begin with dimensions unknown.
|
||||
const rederiveMaskDetailed = (
|
||||
source: Source,
|
||||
params: MaskParams,
|
||||
stub: Omit<Mask, 'bitset' | 'params'>,
|
||||
): Promise<{ mask: Mask; widthPx: number; heightPx: number }> =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (typeof Worker === 'undefined') {
|
||||
reject(new Error('Worker unavailable'));
|
||||
@@ -249,6 +264,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
}
|
||||
const w = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' });
|
||||
let id = 0;
|
||||
let widthPx = source.widthPx;
|
||||
let heightPx = source.heightPx;
|
||||
const fail = (reason: string) => {
|
||||
w.terminate();
|
||||
reject(new Error(reason));
|
||||
@@ -256,6 +273,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
w.onmessage = (e: MessageEvent<WorkerOut>) => {
|
||||
const msg = e.data;
|
||||
if (msg.kind === 'decoded') {
|
||||
widthPx = msg.w;
|
||||
heightPx = msg.h;
|
||||
w.postMessage({ id: ++id, kind: 'mask', sheet: params.sheet, sensitivity: params.sensitivity } satisfies WorkerIn);
|
||||
return;
|
||||
}
|
||||
@@ -266,17 +285,27 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
if (msg.kind === 'ready') {
|
||||
w.terminate();
|
||||
resolve({
|
||||
widthPx,
|
||||
heightPx,
|
||||
mask: {
|
||||
params,
|
||||
slabPx: msg.slabPx || stub.slabPx,
|
||||
bitset: msg.bitset,
|
||||
bbox: msg.slabPx > 0 ? msg.bbox : stub.bbox,
|
||||
slabBounds: msg.slabPx > 0 ? msg.slabBounds : stub.slabBounds,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
w.postMessage({ id: ++id, kind: 'init', blob: source.blob } satisfies WorkerIn);
|
||||
});
|
||||
|
||||
const rederiveMask = (
|
||||
source: Source,
|
||||
params: MaskParams,
|
||||
stub: Omit<Mask, 'bitset' | 'params'>,
|
||||
): Promise<Mask> => rederiveMaskDetailed(source, params, stub).then((r) => r.mask);
|
||||
|
||||
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
|
||||
// fall back to the mask's own bbox.
|
||||
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
||||
@@ -319,8 +348,13 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
axis: { bbox: draftAxisBbox(d) },
|
||||
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
||||
results: d.results
|
||||
? { ...d.results, inventoryPng: d.inventoryPng ?? new Blob() }
|
||||
? {
|
||||
...d.results,
|
||||
inventoryPng: d.inventoryPng ?? new Blob(),
|
||||
overlayPng: d.overlayPng ?? undefined,
|
||||
}
|
||||
: null,
|
||||
batchApply: d.batchApply,
|
||||
});
|
||||
} catch {
|
||||
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
|
||||
@@ -473,12 +507,13 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
void persist();
|
||||
},
|
||||
|
||||
// #61: M1 Calculate on the calibration source only (batch apply is #62).
|
||||
// #62: Calculate the calibration photo, then apply its frozen mask params
|
||||
// to siblings. This stays in Measure; siblings never enter Mask controls.
|
||||
async calculate() {
|
||||
if (state.k !== 'measure') return;
|
||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
||||
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
||||
const bitmap = await createImageBitmap(source.blob);
|
||||
const bitmap = await decodeOrientedBitmap(source.blob);
|
||||
const base = measure(
|
||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
||||
@@ -486,7 +521,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
let pngBlob: Blob;
|
||||
let belowFloor = false;
|
||||
try {
|
||||
const r = await encodeInventoryPng({ bitmap, mask: approvedMask, source });
|
||||
const r = await encodeInventory({ bitmap, mask: approvedMask, source });
|
||||
pngBlob = r.blob;
|
||||
belowFloor = r.sourceBelowFloor;
|
||||
} catch {
|
||||
@@ -496,8 +531,81 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
bitmap.close();
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(pngBlob);
|
||||
const results: Measures = { ...base, inventoryPng: pngBlob, sourceBelowFloor: belowFloor };
|
||||
set({ ...state, results });
|
||||
|
||||
// #63: second copy of the calibration inventory PNG with dimension overlays.
|
||||
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal].
|
||||
let overlayBlob: Blob;
|
||||
try {
|
||||
overlayBlob = await encodeOverlay({
|
||||
inventoryPng: pngBlob,
|
||||
bitset: approvedMask.bitset,
|
||||
widthPx: source.widthPx,
|
||||
heightPx: source.heightPx,
|
||||
bbox: axis.bbox,
|
||||
scale: base.scale,
|
||||
lengthIn: eighthsToIn(lengthEighths),
|
||||
lengthLabel: fmtEighths(lengthEighths),
|
||||
slabBounds: approvedMask.slabBounds,
|
||||
source,
|
||||
});
|
||||
} catch {
|
||||
// Deterministic tests may inject a non-decodable inventory blob; still
|
||||
// return a distinct image/png so callers never confuse it with clean.
|
||||
overlayBlob = new Blob(['overlay'], { type: 'image/png' });
|
||||
}
|
||||
const results: Measures = {
|
||||
...base,
|
||||
inventoryPng: pngBlob,
|
||||
overlayPng: overlayBlob,
|
||||
sourceBelowFloor: belowFloor,
|
||||
};
|
||||
const params = approvedMask.params;
|
||||
const calIndex = calibrationIndex != null && calibrationIndex >= 0
|
||||
? calibrationIndex
|
||||
: 0;
|
||||
const batch = batchPhotos.length ? batchPhotos : [source];
|
||||
const batchApply: BatchPhotoResult[] = batch.map((_, index) => (index === calIndex ? null : {
|
||||
status: 'error',
|
||||
message: 'Batch photo was not processed',
|
||||
}));
|
||||
|
||||
// Process serially so each worker owns exactly one sibling photo and a
|
||||
// failing decode/mask/encode cannot prevent later siblings from running.
|
||||
for (let index = 0; index < batch.length; index++) {
|
||||
if (index === calIndex) continue;
|
||||
const photo = batch[index];
|
||||
try {
|
||||
const derived = await rederiveMaskDetailed(photo, params, {
|
||||
slabPx: 0,
|
||||
bbox: EMPTY_BBOX,
|
||||
slabBounds: EMPTY_ABOX,
|
||||
});
|
||||
if (derived.mask.slabPx === 0) {
|
||||
throw new Error('empty mask: no slab pixels detected');
|
||||
}
|
||||
const siblingSource: Source = {
|
||||
...photo,
|
||||
widthPx: derived.widthPx,
|
||||
heightPx: derived.heightPx,
|
||||
};
|
||||
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
|
||||
let encoded: { blob: Blob; sourceBelowFloor: 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');
|
||||
}
|
||||
batchApply[index] = { status: 'ok', inventoryPng: encoded.blob };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'unable to process photo';
|
||||
batchApply[index] = { status: 'error', message };
|
||||
}
|
||||
}
|
||||
|
||||
set({ ...state, results, batchApply });
|
||||
void persist();
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Shared decode for capture photos so PhotoStage, mask worker, inventory, and
|
||||
* calculate all share one EXIF-oriented pixel space.
|
||||
*
|
||||
* Matched-set / DJI JPEGs often carry EXIF Orientation 8 (stored landscape,
|
||||
* display portrait). `new Image()` and bare `createImageBitmap(blob)` can
|
||||
* disagree across browsers → pink axis arrow space ≠ mask/overlay bitset space.
|
||||
*
|
||||
* Contract: always request `imageOrientation: 'from-image'`. If the runtime
|
||||
* rejects that option (older engines), fall back to bare `createImageBitmap`;
|
||||
* engines that already apply EXIF by default still match Image/canvas size.
|
||||
* Engines that never apply EXIF may still diverge after fallback — document
|
||||
* that path rather than inventing a software rotate.
|
||||
*/
|
||||
export const ORIENTED_BITMAP_OPTIONS: ImageBitmapOptions = {
|
||||
imageOrientation: 'from-image',
|
||||
};
|
||||
|
||||
export async function decodeOrientedBitmap(blob: Blob): Promise<ImageBitmap> {
|
||||
try {
|
||||
return await createImageBitmap(blob, ORIENTED_BITMAP_OPTIONS);
|
||||
} catch (first) {
|
||||
try {
|
||||
return await createImageBitmap(blob);
|
||||
} catch {
|
||||
throw first;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { blackKeep, greenKeep } from './sheetKeep.ts';
|
||||
|
||||
// --- Black sheet sensitivity (#74) ---
|
||||
|
||||
test('blackKeep: s=0 keeps dark live-edge wood (lum ~25–50), removes near-black sheet (#74)', () => {
|
||||
// Near-black sheet (lum ≈ 5)
|
||||
assert.equal(blackKeep(5, 5, 5, 0), false, 'near-black sheet removed at s=0');
|
||||
assert.equal(blackKeep(0, 0, 0, 0), false, 'pure black sheet removed at s=0');
|
||||
// Dark wood / bark / live edge
|
||||
assert.equal(blackKeep(25, 22, 18, 0), true, 'lum~22 dark wood kept at s=0');
|
||||
assert.equal(blackKeep(40, 35, 30, 0), true, 'lum~35 dark wood kept at s=0');
|
||||
assert.equal(blackKeep(50, 45, 40, 0), true, 'lum~46 wood kept at s=0');
|
||||
// Mid wood always kept
|
||||
assert.equal(blackKeep(120, 90, 60, 0), true, 'mid wood kept at s=0');
|
||||
});
|
||||
|
||||
test('blackKeep: s=100 removes more than s=0; curve monotonic (#74)', () => {
|
||||
// Dark-ish pixel that sits between floors: sheet at high s, wood at low s
|
||||
const darkish = { r: 80, g: 70, b: 60 }; // lum ≈ 72
|
||||
assert.equal(blackKeep(darkish.r, darkish.g, darkish.b, 0), true, 'lum~72 kept at s=0');
|
||||
assert.equal(blackKeep(darkish.r, darkish.g, darkish.b, 100), false, 'lum~72 sheet at s=100');
|
||||
|
||||
// Near-black always sheet
|
||||
assert.equal(blackKeep(2, 2, 2, 0), false);
|
||||
assert.equal(blackKeep(2, 2, 2, 100), false);
|
||||
|
||||
// Bright wood always kept
|
||||
assert.equal(blackKeep(200, 160, 100, 0), true);
|
||||
assert.equal(blackKeep(200, 160, 100, 100), true);
|
||||
|
||||
// Monotonic: once removed, stays removed as s increases
|
||||
const probe = { r: 60, g: 55, b: 50 }; // lum ≈ 56
|
||||
let prevKeep = true;
|
||||
for (const s of [0, 25, 50, 75, 100]) {
|
||||
const keep = blackKeep(probe.r, probe.g, probe.b, s);
|
||||
if (!prevKeep) assert.equal(keep, false, `once sheet at lower s, stay sheet at s=${s}`);
|
||||
prevKeep = keep;
|
||||
}
|
||||
// s=100 must remove more pixels than s=0 for this mid-dark band
|
||||
assert.equal(blackKeep(probe.r, probe.g, probe.b, 0), true);
|
||||
assert.equal(blackKeep(probe.r, probe.g, probe.b, 100), false);
|
||||
});
|
||||
|
||||
test('greenKeep: s=0 does not eat mild wood greens; s=100 widens sheet match (#74)', () => {
|
||||
// Neutral wood
|
||||
assert.equal(greenKeep(140, 110, 80, 0), true);
|
||||
assert.equal(greenKeep(140, 110, 80, 100), true);
|
||||
// Mild green cast on wood (g - max(r,b) ≈ 5) — must keep at s=0
|
||||
assert.equal(greenKeep(100, 105, 90, 0), true, 'mild green cast kept at s=0');
|
||||
// Strong sheet green
|
||||
assert.equal(greenKeep(40, 180, 50, 0), false, 'strong green sheet removed at s=0');
|
||||
assert.equal(greenKeep(40, 180, 50, 100), false);
|
||||
// Borderline: g-max ≈ 20 — may become sheet at high s
|
||||
const border = { r: 80, g: 100, b: 70 }; // delta = 20
|
||||
assert.equal(greenKeep(border.r, border.g, border.b, 0), true, 'delta20 kept at s=0');
|
||||
assert.equal(greenKeep(border.r, border.g, border.b, 100), false, 'delta20 sheet at s=100');
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Sheet-vs-slab keep predicates used by the mask worker.
|
||||
* Extracted so unit tests can cover sensitivity curves without the worker.
|
||||
*/
|
||||
|
||||
/** Keep slab pixels on a green sheet. Higher s widens the green-sheet match. */
|
||||
export function greenKeep(r: number, g: number, b: number, s: number): boolean {
|
||||
const t = Math.max(0, Math.min(100, s)) / 100;
|
||||
// s=0 removes only strongly green sheet; increasing sensitivity widens the match.
|
||||
const isSheet = g - Math.max(r, b) > 45 - 35 * t;
|
||||
return !isSheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep slab pixels on a black sheet.
|
||||
* At s=0 only near-black sheet (very low lum) is removed so dark live-edge /
|
||||
* bark (lum ~25–50) stays. The match widens monotonically toward s=100 (#74).
|
||||
*/
|
||||
export function blackKeep(r: number, g: number, b: number, s: number): boolean {
|
||||
const t = Math.max(0, Math.min(100, s)) / 100;
|
||||
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
// Old floor 40 ate dark wood at minimum sensitivity. Floor 10 isolates near-black.
|
||||
const isSheet = lum < 10 + 140 * t;
|
||||
return !isSheet;
|
||||
}
|
||||
|
||||
export function autoPickSheet(
|
||||
samples: { r: number; g: number; b: number }[],
|
||||
): 'green' | 'black' {
|
||||
let gr = 0, gl = 0;
|
||||
for (const p of samples) {
|
||||
if (p.g - Math.max(p.r, p.b) > 20) gr++;
|
||||
if (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b < 70) gl++;
|
||||
}
|
||||
return gl >= gr ? 'black' : 'green';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,552 @@
|
||||
import type { BBox } from '../capture/capture.types';
|
||||
import { axisEndpoints } from '../measure/measure';
|
||||
|
||||
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
|
||||
export function overlayWidthStations(lengthIn: number): number[] {
|
||||
if (!(lengthIn > 6)) return [];
|
||||
const last = lengthIn - 6;
|
||||
const out: number[] = [];
|
||||
for (let s = 6; s <= last + 1e-9; s += 12) out.push(s);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* EXIF-oriented photo bottom = larger canvas y. Pick which end of the approved
|
||||
* length-axis span is bottom; return unit from bottom → top along that span.
|
||||
*/
|
||||
export function pickBottomTowardTop(
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
): {
|
||||
bottom: [number, number];
|
||||
top: [number, number];
|
||||
ux: number;
|
||||
uy: number;
|
||||
} {
|
||||
// Larger y is closer to the bottom edge of the oriented photo.
|
||||
// Tie-break (horizontal axis): prefer larger x for a stable choice.
|
||||
const startIsBottom =
|
||||
start[1] > end[1] || (start[1] === end[1] && start[0] >= end[0]);
|
||||
const bottom = startIsBottom ? start : end;
|
||||
const top = startIsBottom ? end : start;
|
||||
const dx = top[0] - bottom[0];
|
||||
const dy = top[1] - bottom[1];
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
return { bottom, top, ux: dx / len, uy: dy / len };
|
||||
}
|
||||
|
||||
/** Screen-space right of travel (canvas y↓): (ux,uy) → (−uy, ux). */
|
||||
export function axisRightUnit(ux: number, uy: number): [number, number] {
|
||||
return [-uy, ux];
|
||||
}
|
||||
|
||||
export interface OverlayWidthMark {
|
||||
stationIn: number;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface OverlayLengthMark {
|
||||
a: [number, number];
|
||||
b: [number, number];
|
||||
labelAt: [number, number];
|
||||
label: string;
|
||||
/** True when the line sits outside the slab on the axis-right side. */
|
||||
outside: boolean;
|
||||
}
|
||||
|
||||
export interface OverlayPlan {
|
||||
stroke: '#000000';
|
||||
fontPx: number;
|
||||
length: OverlayLengthMark;
|
||||
widths: OverlayWidthMark[];
|
||||
}
|
||||
|
||||
export interface OverlayPlanInput {
|
||||
bitset: Uint8Array;
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
bbox: BBox;
|
||||
scale: number; // px per inch
|
||||
lengthIn: number;
|
||||
lengthLabel: string;
|
||||
/** Map source px → inventory PNG px (identity OK for unit tests). */
|
||||
mapToInventory: (x: number, y: number) => [number, number];
|
||||
/** Inventory PNG size — used to clamp labels inside the canvas (#71). */
|
||||
inventoryW?: number;
|
||||
inventoryH?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function widthAwareAxialRange(
|
||||
bitset: Uint8Array,
|
||||
w: number,
|
||||
h: number,
|
||||
ux: number,
|
||||
uy: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
): { minP: number; maxP: number } | null {
|
||||
const rx = -uy;
|
||||
const ry = ux;
|
||||
const binMin = new Map<number, number>();
|
||||
const binMax = new Map<number, number>();
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (binMin.size === 0) return null;
|
||||
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;
|
||||
}
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
return { minP: tipLo, maxP: tipHi };
|
||||
}
|
||||
|
||||
function maskAxisSegment(
|
||||
bitset: Uint8Array,
|
||||
w: number,
|
||||
h: number,
|
||||
bbox: BBox,
|
||||
): {
|
||||
start: [number, number];
|
||||
end: [number, number];
|
||||
/** Actual mask pixels nearest centerline at tip stations. */
|
||||
tipStart: [number, number];
|
||||
tipEnd: [number, number];
|
||||
ux: number;
|
||||
uy: number;
|
||||
span: number;
|
||||
} {
|
||||
const { start: a, end: b } = axisEndpoints(bbox);
|
||||
const ex = b[0] - a[0];
|
||||
const ey = b[1] - a[1];
|
||||
const elen = Math.hypot(ex, ey) || 1;
|
||||
const ux = ex / elen;
|
||||
const uy = ey / elen;
|
||||
const cx = (bbox.corners[0][0] + bbox.corners[1][0] + bbox.corners[2][0] + bbox.corners[3][0]) / 4;
|
||||
const cy = (bbox.corners[0][1] + bbox.corners[1][1] + bbox.corners[2][1] + bbox.corners[3][1]) / 4;
|
||||
const cProj = cx * ux + cy * uy;
|
||||
// Width-aware tips (#71). Fall back to robust percentiles if profile empty.
|
||||
const aware = widthAwareAxialRange(bitset, w, h, ux, uy, cx, cy);
|
||||
let minP: number;
|
||||
let maxP: number;
|
||||
if (aware) {
|
||||
minP = aware.minP;
|
||||
maxP = aware.maxP;
|
||||
} else {
|
||||
const projs: number[] = [];
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
projs.push(x * ux + y * uy);
|
||||
}
|
||||
}
|
||||
if (projs.length === 0) return { start: a, end: b, tipStart: a, tipEnd: b, ux, uy, span: 0 };
|
||||
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);
|
||||
minP = projs[lo];
|
||||
maxP = projs[hi];
|
||||
}
|
||||
// Tip pixel at each station: closest to the axis centerline
|
||||
let tipStart: [number, number] = a;
|
||||
let tipEnd: [number, number] = b;
|
||||
let bestStart = Infinity;
|
||||
let bestEnd = Infinity;
|
||||
const stationTol = Math.max(1.5, (maxP - minP) * 0.001);
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
const p = x * ux + y * uy;
|
||||
const across = Math.abs((x - cx) * -uy + (y - cy) * ux);
|
||||
if (Math.abs(p - minP) <= stationTol && across < bestStart) {
|
||||
bestStart = across;
|
||||
tipStart = [x, y];
|
||||
}
|
||||
if (Math.abs(p - maxP) <= stationTol && across < bestEnd) {
|
||||
bestEnd = across;
|
||||
tipEnd = [x, y];
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)],
|
||||
end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)],
|
||||
tipStart,
|
||||
tipEnd,
|
||||
ux,
|
||||
uy,
|
||||
span: maxP - minP,
|
||||
};
|
||||
}
|
||||
|
||||
/** Probe slab extent along unit (px, py) through origin (ox, oy). */
|
||||
function probeExtent(
|
||||
bitset: Uint8Array,
|
||||
w: number,
|
||||
h: number,
|
||||
ox: number,
|
||||
oy: number,
|
||||
px: number,
|
||||
py: number,
|
||||
): { minP: number; maxP: number } | null {
|
||||
const half = Math.max(2, Math.round((w + h) / 4));
|
||||
let minP = Infinity;
|
||||
let maxP = -Infinity;
|
||||
for (let p = -half; p <= half; p++) {
|
||||
const x = Math.round(ox + px * p);
|
||||
const y = Math.round(oy + py * p);
|
||||
if (x < 0 || y < 0 || x >= w || y >= h) continue;
|
||||
if (bitset[y * w + x]) {
|
||||
if (p < minP) minP = p;
|
||||
if (p > maxP) maxP = p;
|
||||
}
|
||||
}
|
||||
if (maxP < minP) return null;
|
||||
return { minP, maxP };
|
||||
}
|
||||
|
||||
function fmtInches(inches: number): string {
|
||||
const whole = Math.floor(inches + 1e-9);
|
||||
const rem = Math.round((inches - whole) * 8);
|
||||
if (rem === 0) return `${whole}"`;
|
||||
const frac: Record<number, string> = {
|
||||
1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8', 8: '',
|
||||
};
|
||||
if (rem === 8) return `${whole + 1}"`;
|
||||
const part = frac[rem] ?? `${rem}/8`;
|
||||
return whole > 0 ? `${whole} ${part}"` : `${part}"`;
|
||||
}
|
||||
|
||||
export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
const {
|
||||
bitset, widthPx, heightPx, bbox, scale, lengthIn, lengthLabel, mapToInventory,
|
||||
inventoryW, inventoryH,
|
||||
} = input;
|
||||
const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox);
|
||||
// Re-orient span ends so EXIF bottom establishes station origin; axis still
|
||||
// lies on the approved M1 direction (seg ux/uy flipped only when needed).
|
||||
const oriented = pickBottomTowardTop(seg.start, seg.end);
|
||||
const { bottom, top, ux, uy } = oriented;
|
||||
const [rx, ry] = axisRightUnit(ux, uy);
|
||||
|
||||
// Consistent smaller readable font (#71). Cap by inventory size so high
|
||||
// source scale (≈80 px/in) does not dominate the PNG.
|
||||
const fontPx = (() => {
|
||||
if (inventoryW && inventoryH && inventoryW > 0 && inventoryH > 0) {
|
||||
return Math.max(12, Math.min(16, Math.round(Math.min(inventoryW, inventoryH) * 0.016)));
|
||||
}
|
||||
return Math.max(12, Math.min(16, Math.round(scale * 0.18)));
|
||||
})();
|
||||
// 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);
|
||||
// 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[] = [];
|
||||
for (const stationIn of stations) {
|
||||
const t = stationIn * scale;
|
||||
const ox = bottom[0] + ux * t;
|
||||
const oy = bottom[1] + uy * t;
|
||||
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
|
||||
if (!extent) continue;
|
||||
// left/right on mask edges, then inset in inventory space for stroke/arrowhead
|
||||
const leftMapped = mapToInventory(ox + rx * extent.minP, oy + ry * extent.minP);
|
||||
const rightMapped = mapToInventory(ox + rx * extent.maxP, oy + ry * extent.maxP);
|
||||
const wdx = rightMapped[0] - leftMapped[0];
|
||||
const wdy = rightMapped[1] - leftMapped[1];
|
||||
const wlen = Math.hypot(wdx, wdy) || 1;
|
||||
const wux = wdx / wlen;
|
||||
const wuy = wdy / wlen;
|
||||
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];
|
||||
const widthIn = (extent.maxP - extent.minP + 1) / scale;
|
||||
widths.push({
|
||||
stationIn,
|
||||
center: mapToInventory(ox, oy),
|
||||
left,
|
||||
right,
|
||||
labelAt: mapToInventory(labelSrcW[0], labelSrcW[1]),
|
||||
label: fmtInches(widthIn),
|
||||
});
|
||||
}
|
||||
|
||||
// Length graphic: beside wood on axis-right, outside max right extent.
|
||||
let maxRight = 0;
|
||||
const samples = Math.max(8, Math.round(lengthIn));
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const t = (i / samples) * (lengthIn * scale);
|
||||
const ox = bottom[0] + ux * t;
|
||||
const oy = bottom[1] + uy * t;
|
||||
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
|
||||
if (extent && extent.maxP > maxRight) maxRight = extent.maxP;
|
||||
}
|
||||
const outward = maxRight + pad;
|
||||
// Classic dimensioning (#70 + #71): width-aware centerline tip stations offset by
|
||||
// pure axis-right (rx,ry)*outward. Length stays || approved (ux,uy); no screen-slide.
|
||||
// Axial inset (inventory space) so arrowhead tips sit on footprint, not past.
|
||||
const aMapped = mapToInventory(bottom[0] + rx * outward, bottom[1] + ry * outward);
|
||||
const bMapped = mapToInventory(top[0] + rx * outward, top[1] + ry * outward);
|
||||
const ldx0 = bMapped[0] - aMapped[0];
|
||||
const ldy0 = bMapped[1] - aMapped[1];
|
||||
const llen0 = Math.hypot(ldx0, ldy0) || 1;
|
||||
const lux = ldx0 / llen0;
|
||||
const luy = ldy0 / llen0;
|
||||
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);
|
||||
const mapR = mapToInventory(rx, ry);
|
||||
const irx = mapR[0] - mapOrigin[0];
|
||||
const iry = mapR[1] - mapOrigin[1];
|
||||
const irlen = Math.hypot(irx, iry) || 1;
|
||||
const nrx = irx / irlen;
|
||||
const nry = iry / irlen;
|
||||
const labelAt: [number, number] = [
|
||||
midInv[0] + nrx * (fontPx * 0.85 + 6),
|
||||
midInv[1] + nry * (fontPx * 0.85 + 6),
|
||||
];
|
||||
|
||||
const lengthMark: OverlayLengthMark = {
|
||||
a: aInv,
|
||||
b: bInv,
|
||||
labelAt,
|
||||
label: lengthLabel,
|
||||
outside: true,
|
||||
};
|
||||
|
||||
// Clamp all labels inside inventory PNG so nothing clips (#71).
|
||||
if (inventoryW && inventoryH && inventoryW > 0 && inventoryH > 0) {
|
||||
const margin = Math.max(8, fontPx * 0.65);
|
||||
const clamp = (pt: [number, number], growLeft: boolean, text: string): [number, number] => {
|
||||
let [x, y] = pt;
|
||||
const glyphW = Math.max(fontPx * 1.2, text.length * fontPx * 0.55);
|
||||
if (growLeft) {
|
||||
// textAlign right — glyphs extend left of anchor
|
||||
x = Math.max(margin + glyphW, Math.min(inventoryW - margin, x));
|
||||
} else {
|
||||
x = Math.max(margin, Math.min(inventoryW - margin, x));
|
||||
}
|
||||
y = Math.max(margin, Math.min(inventoryH - margin, y));
|
||||
return [x, y];
|
||||
};
|
||||
lengthMark.labelAt = clamp(lengthMark.labelAt, false, lengthLabel);
|
||||
for (const w of widths) {
|
||||
const growLeft = w.labelAt[0] <= w.center[0];
|
||||
w.labelAt = clamp(w.labelAt, growLeft, w.label);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stroke: '#000000',
|
||||
fontPx,
|
||||
length: lengthMark,
|
||||
widths,
|
||||
};
|
||||
}
|
||||
|
||||
/** Draw black dimension graphics onto an existing canvas (inventory PNG size). */
|
||||
export function drawOverlayPlan(
|
||||
ctx: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D,
|
||||
plan: OverlayPlan,
|
||||
): void {
|
||||
const { stroke, fontPx } = plan;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.fillStyle = stroke;
|
||||
ctx.lineWidth = Math.max(2, Math.round(fontPx / 10));
|
||||
// Butt caps: round caps extend past endpoints and overshoot wood edges (#71).
|
||||
ctx.lineCap = 'butt';
|
||||
ctx.font = `bold ${fontPx}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const arrow = (from: [number, number], to: [number, number]) => {
|
||||
const dx = to[0] - from[0];
|
||||
const dy = to[1] - from[1];
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / len;
|
||||
const uy = dy / len;
|
||||
const head = Math.max(7, fontPx * 0.32);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from[0], from[1]);
|
||||
ctx.lineTo(to[0], to[1]);
|
||||
ctx.stroke();
|
||||
// arrow tips at both ends
|
||||
for (const [sx, sy, dir] of [
|
||||
[from[0], from[1], 1],
|
||||
[to[0], to[1], -1],
|
||||
] as const) {
|
||||
const px = -uy;
|
||||
const py = ux;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sx, sy);
|
||||
ctx.lineTo(sx + dir * ux * head + px * head * 0.4, sy + dir * uy * head + py * head * 0.4);
|
||||
ctx.lineTo(sx + dir * ux * head - px * head * 0.4, sy + dir * uy * head - py * head * 0.4);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
|
||||
// Length — grow text further axis-right of the dim line (away from wood)
|
||||
arrow(plan.length.a, plan.length.b);
|
||||
{
|
||||
const [lax, lay] = plan.length.a;
|
||||
const [lbx, lby] = plan.length.b;
|
||||
const midX = (lax + lbx) / 2;
|
||||
const midY = (lay + lby) / 2;
|
||||
const outwardX = plan.length.labelAt[0] - midX;
|
||||
const outwardY = plan.length.labelAt[1] - midY;
|
||||
// Prefer left/right align so glyphs extend away from wood when offset is mostly ±x
|
||||
if (Math.abs(outwardX) >= Math.abs(outwardY)) {
|
||||
ctx.textAlign = outwardX >= 0 ? 'left' : 'right';
|
||||
} else {
|
||||
ctx.textAlign = 'center';
|
||||
}
|
||||
}
|
||||
ctx.fillText(plan.length.label, plan.length.labelAt[0], plan.length.labelAt[1]);
|
||||
|
||||
// Widths — grow text away from wood (labelAt is axis-left of left tip)
|
||||
for (const w of plan.widths) {
|
||||
arrow(w.left, w.right);
|
||||
// labelAt is on the axis-left side of the station; align so glyphs extend further out
|
||||
const towardLeft = w.labelAt[0] <= w.center[0];
|
||||
ctx.textAlign = towardLeft ? 'right' : 'left';
|
||||
ctx.fillText(w.label, w.labelAt[0], w.labelAt[1]);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export interface InventoryMap {
|
||||
mapToInventory: (x: number, y: number) => [number, number];
|
||||
outW: number;
|
||||
outH: number;
|
||||
}
|
||||
|
||||
/** Same crop math as composeInventoryFrame — source → inventory PNG pixels. */
|
||||
export function inventorySourceMap(
|
||||
slabBounds: { x: number; y: number; w: number; h: number },
|
||||
source: { widthPx: number; heightPx: number },
|
||||
outW: number,
|
||||
outH: number,
|
||||
): InventoryMap {
|
||||
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)));
|
||||
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(slabBounds.h)));
|
||||
const sw = x1 - x0;
|
||||
const sh = y1 - y0;
|
||||
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
|
||||
const dw = sw * scale;
|
||||
const dh = sh * scale;
|
||||
const ox = (outW - dw) / 2;
|
||||
const oy = (outH - dh) / 2;
|
||||
return {
|
||||
outW,
|
||||
outH,
|
||||
mapToInventory: (sx, sy) => [ox + (sx - x0) * scale, oy + (sy - y0) * scale],
|
||||
};
|
||||
}
|
||||
|
||||
export interface EncodeOverlayInput {
|
||||
/** Clean calibration inventory PNG bytes. */
|
||||
inventoryPng: Blob;
|
||||
bitset: Uint8Array;
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
bbox: BBox;
|
||||
scale: number;
|
||||
lengthIn: number;
|
||||
lengthLabel: string;
|
||||
slabBounds: { x: number; y: number; w: number; h: number };
|
||||
source: { widthPx: number; heightPx: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Burn black dimension overlays onto a copy of the clean calibration inventory PNG.
|
||||
* Returns a new image/png blob; never mutates the clean inventory.
|
||||
*/
|
||||
export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob> {
|
||||
const bmp = await createImageBitmap(input.inventoryPng);
|
||||
try {
|
||||
const outW = bmp.width;
|
||||
const outH = bmp.height;
|
||||
const canvas = new OffscreenCanvas(outW, outH);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(bmp, 0, 0);
|
||||
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH);
|
||||
const plan = buildOverlayPlan({
|
||||
bitset: input.bitset,
|
||||
widthPx: input.widthPx,
|
||||
heightPx: input.heightPx,
|
||||
bbox: input.bbox,
|
||||
scale: input.scale,
|
||||
lengthIn: input.lengthIn,
|
||||
lengthLabel: input.lengthLabel,
|
||||
mapToInventory,
|
||||
inventoryW: outW,
|
||||
inventoryH: outH,
|
||||
});
|
||||
drawOverlayPlan(ctx, plan);
|
||||
return await canvas.convertToBlob({ type: 'image/png' });
|
||||
} finally {
|
||||
bmp.close();
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,13 @@
|
||||
import { minAreaRect, slabBounds } from '../geometry/rect';
|
||||
import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { greenKeep, blackKeep, autoPickSheet } from '../mask/sheetKeep';
|
||||
|
||||
let bitmap: ImageBitmap | null = null;
|
||||
let data: Uint8ClampedArray | null = null;
|
||||
let w = 0, h = 0;
|
||||
let current = 0; // highest job id issued by main; older replies are dropped
|
||||
|
||||
// Keep slab pixels; remove the sheet. Higher sensitivity widens the sheet match.
|
||||
const greenKeep = (r: number, g: number, b: number, s: number): boolean => {
|
||||
const t = s / 100;
|
||||
const isSheet = g - Math.max(r, b) > 10 + 35 * t;
|
||||
return !isSheet;
|
||||
};
|
||||
const blackKeep = (r: number, g: number, b: number, s: number): boolean => {
|
||||
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
const isSheet = lum < 40 + 140 * (s / 100);
|
||||
return !isSheet;
|
||||
};
|
||||
const autoPick = (samples: { r: number; g: number; b: number }[]): 'green' | 'black' => {
|
||||
let gr = 0, gl = 0;
|
||||
for (const p of samples) {
|
||||
if (p.g - Math.max(p.r, p.b) > 20) gr++;
|
||||
if (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b < 70) gl++;
|
||||
}
|
||||
return gl >= gr ? 'black' : 'green';
|
||||
};
|
||||
|
||||
function runMask(params: MaskParams, id: number): WorkerOut {
|
||||
if (!data) return { id, kind: 'failed', reason: 'no source decoded' };
|
||||
let sheet = params.sheet;
|
||||
@@ -36,7 +18,7 @@ function runMask(params: MaskParams, id: number): WorkerOut {
|
||||
const i = (y * w + x) * 4;
|
||||
samples.push({ r: data[i], g: data[i + 1], b: data[i + 2] });
|
||||
}
|
||||
sheet = autoPick(samples);
|
||||
sheet = autoPickSheet(samples);
|
||||
}
|
||||
const keep = sheet === 'green' ? greenKeep : blackKeep;
|
||||
const s = Math.max(0, Math.min(100, params.sensitivity));
|
||||
@@ -69,7 +51,7 @@ self.onmessage = (e: MessageEvent<WorkerIn>) => {
|
||||
}
|
||||
data = null;
|
||||
w = h = 0;
|
||||
createImageBitmap(msg.blob)
|
||||
decodeOrientedBitmap(msg.blob)
|
||||
.then((bmp) => {
|
||||
w = bmp.width;
|
||||
h = bmp.height;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# E2E visual artifacts (#65)
|
||||
|
||||
Screenshots from `npx playwright test` for Milestone 2 Photos-ready evidence.
|
||||
|
||||
## How to run (local box — not ubudual :10880)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npx playwright install chromium # once per machine
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
Or against an already-running preview:
|
||||
|
||||
```bash
|
||||
npm run build && npm run preview -- --host 127.0.0.1 --port 4173
|
||||
# other terminal:
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
Fixtures: `tests/fixtures/set1-48x2/` (L=48, thickness=2) and `set2-31x2p5/` (L=31, thickness=2.5). Black sheet — e2e selects **Black** and may nudge sensitivity.
|
||||
|
||||
## Expected screenshots (this folder)
|
||||
|
||||
| File | Step |
|
||||
|---|---|
|
||||
| `01-batch.png` | Batch with fixtures loaded |
|
||||
| `02-pick-calibration.png` | Pick calibration |
|
||||
| `03-mask.png` | Mask (Black sheet) ready to approve |
|
||||
| `04-axis.png` | Length axis |
|
||||
| `05-measure.png` | Measure with L / thickness filled |
|
||||
| `06-photos-ready.png` | Photos ready lineup (clean + overlay + siblings) |
|
||||
|
||||
Large regenerating `*.png` and Playwright `test-results/` / `playwright-report/` are gitignored.
|
||||
Committed small evidence JPEGs live in `evidence/` (regenerate with a green `npm run test:e2e`, then re-encode if needed).
|
||||
|
||||
## Unit tests
|
||||
|
||||
`npm test` must still pass separately from Playwright.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 466 KiB |
@@ -0,0 +1,11 @@
|
||||
# Last green e2e run (#71)
|
||||
|
||||
- **When:** 2026-09-15 9:55 PM PT (`2026-09-16T04:55:14Z`)
|
||||
- **Fixture:** `tests/fixtures/set1-48x2/` (L=48, thickness=2)
|
||||
- **Server:** local `vite preview` at `http://127.0.0.1:4173` (not ubudual)
|
||||
- **Result:** 1 passed (~8s test)
|
||||
- **Mask:** Black sheet — Approve enabled
|
||||
- **Fix under test:** width-aware tip stations; stroke/arrowhead edge inset; inventory-capped font; labels clamped in PNG (#71); #70 axis-parallel kept
|
||||
- **Lineup:** Clean calibration → Dimension overlay → Batch photo 2
|
||||
|
||||
Full-resolution PNGs are produced under `../` on each run (gitignored). These JPEGs are commit-sized evidence for review.
|
||||
@@ -0,0 +1,135 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = path.resolve(__dirname, '../fixtures/set1-48x2');
|
||||
const ARTIFACTS = path.resolve(__dirname, 'artifacts');
|
||||
const FACE_A = path.join(FIXTURES, 'face-a.jpeg');
|
||||
const FACE_B = path.join(FIXTURES, 'face-b.jpeg');
|
||||
|
||||
const shot = async (page: Page, name: string) => {
|
||||
fs.mkdirSync(ARTIFACTS, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(ARTIFACTS, name),
|
||||
fullPage: true,
|
||||
});
|
||||
};
|
||||
|
||||
/** Discard any device draft so we always start on Batch. */
|
||||
const ensureFreshBatch = async (page: Page) => {
|
||||
await page.goto('/');
|
||||
// resumeProbe is async onMount — wait for either Resume or Batch.
|
||||
const resumeHeading = page.getByRole('heading', { name: /resume/i });
|
||||
const batchHeading = page.getByRole('heading', { name: 'Batch photos' });
|
||||
await expect(resumeHeading.or(batchHeading)).toBeVisible({ timeout: 15_000 });
|
||||
if (await resumeHeading.isVisible().catch(() => false)) {
|
||||
await page.getByRole('button', { name: /start fresh/i }).click();
|
||||
}
|
||||
await expect(batchHeading).toBeVisible();
|
||||
};
|
||||
|
||||
/**
|
||||
* Black-sheet fixtures: Auto may work, but UAT note says prefer Black + sensitivity
|
||||
* tweaks. Do not invent inference — only UI sheet/sensitivity controls.
|
||||
*/
|
||||
const prepareMaskForApprove = async (page: Page) => {
|
||||
await expect(page.getByRole('heading', { name: 'Mask' })).toBeVisible();
|
||||
// Sheet change recomputes immediately (session.setSheet → runPass).
|
||||
await page.getByRole('button', { name: 'Black', exact: true }).click();
|
||||
|
||||
const approve = page.getByRole('button', { name: 'Approve mask' });
|
||||
// First overlay after decode is debounced 2s; Black pass is immediate but
|
||||
// 4K fixtures still need decode + worker time.
|
||||
try {
|
||||
await expect(approve).toBeEnabled({ timeout: 45_000 });
|
||||
return;
|
||||
} catch {
|
||||
/* try sensitivity ladder */
|
||||
}
|
||||
|
||||
// Higher sensitivity widens black sheet match; if still empty, try lower.
|
||||
for (const target of [60, 70, 40, 30, 80]) {
|
||||
const slider = page.locator('input[type="range"]');
|
||||
await slider.fill(String(target));
|
||||
// Sensitivity is debounced 2s — wait past debounce + worker.
|
||||
await page.waitForTimeout(2800);
|
||||
if (await approve.isEnabled()) return;
|
||||
}
|
||||
|
||||
await expect(approve, 'Mask approve stayed disabled after Black + sensitivity tweaks').toBeEnabled({
|
||||
timeout: 15_000,
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
|
||||
test('batch → pick → mask → axis → measure → photos ready + screenshots', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy();
|
||||
expect(fs.existsSync(FACE_B), 'fixture face-b').toBeTruthy();
|
||||
|
||||
await ensureFreshBatch(page);
|
||||
|
||||
// Gallery multi-input (not the capture=environment camera input).
|
||||
const gallery = page.locator('input[type="file"][multiple]');
|
||||
await gallery.setInputFiles([FACE_A, FACE_B]);
|
||||
await expect(page.getByAltText('Batch photo 1')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByAltText('Batch photo 2')).toBeVisible();
|
||||
await shot(page, '01-batch.png');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Pick calibration photo' })).toBeVisible();
|
||||
await shot(page, '02-pick-calibration.png');
|
||||
|
||||
await page.getByRole('button', { name: 'Use photo 1 as calibration' }).click();
|
||||
await prepareMaskForApprove(page);
|
||||
await shot(page, '03-mask.png');
|
||||
await page.getByRole('button', { name: 'Approve mask' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Length axis' })).toBeVisible();
|
||||
await shot(page, '04-axis.png');
|
||||
await page.getByRole('button', { name: 'Approve axis' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Measure' })).toBeVisible();
|
||||
await page.getByLabel('Length L').fill('48');
|
||||
await page.getByLabel('Thickness').fill('2');
|
||||
// blur/change commits eighths into session before Calculate.
|
||||
await page.getByLabel('Length L').blur();
|
||||
await page.getByLabel('Thickness').blur();
|
||||
await shot(page, '05-measure.png');
|
||||
|
||||
const calc = page.getByRole('button', { name: 'Calculate' });
|
||||
await expect(calc).toBeEnabled();
|
||||
await calc.click();
|
||||
|
||||
// Calculate encodes calibration PNG + overlay + sibling batch apply.
|
||||
await expect(page.getByRole('heading', { name: 'Photos ready' })).toBeVisible({
|
||||
timeout: 120_000,
|
||||
});
|
||||
await expect(page.getByLabel('Photos ready')).toBeVisible();
|
||||
await expect(page.getByText('Clean calibration')).toBeVisible();
|
||||
await expect(page.getByText('Dimension overlay')).toBeVisible();
|
||||
// Sibling face-b should appear as Batch photo 2 (capture order).
|
||||
await expect(page.getByText(/Batch photo 2|Photo 2 failed/)).toBeVisible();
|
||||
|
||||
await shot(page, '06-photos-ready.png');
|
||||
|
||||
// Write a short run log for parent AI visual review (not a substitute for eyes).
|
||||
const logPath = path.join(ARTIFACTS, 'run-log.txt');
|
||||
const shots = ['01-batch.png', '02-pick-calibration.png', '03-mask.png', '04-axis.png', '05-measure.png', '06-photos-ready.png'];
|
||||
const lines = [
|
||||
`M2 #65 Playwright e2e — set1-48x2 (L=48, thickness=2)`,
|
||||
`baseURL: ${page.url().split('/').slice(0, 3).join('/')}`,
|
||||
`finished: ${new Date().toISOString()}`,
|
||||
`artifacts:`,
|
||||
...shots.map((s) => {
|
||||
const p = path.join(ARTIFACTS, s);
|
||||
const st = fs.existsSync(p) ? fs.statSync(p) : null;
|
||||
return ` - ${s} (${st ? `${st.size} bytes` : 'MISSING'})`;
|
||||
}),
|
||||
`assertions: Photos ready heading; Clean calibration; Dimension overlay; batch sibling slot`,
|
||||
];
|
||||
fs.writeFileSync(logPath, lines.join('\n') + '\n', 'utf8');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user