Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abef27cf2a | |||
| 03d3fcf460 | |||
| 037a4b870a | |||
| b8e69b5239 | |||
| 495f179c2f | |||
| b386ab9670 | |||
| 2be2e7d615 | |||
| 6d182db81b | |||
| dc19cffcc0 | |||
| 05447c92fc | |||
| 415158d4d3 | |||
| 9bc35f7edd | |||
| 15895cae0c | |||
| 5650255e49 | |||
| 2be400e455 |
@@ -35,3 +35,11 @@ images/
|
|||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.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
|
# Host
|
||||||
|
|
||||||
## Overnight Milestone 1 Capture (poteto Build)
|
## Overnight Capture UAT (primary — when Tailscale is up)
|
||||||
|
|
||||||
- Host: `ubudual5060` / Tailscale `100.64.0.9`
|
- 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
|
- Port: **10881** → 80
|
||||||
- URL: `http://100.64.0.9:10881`
|
- 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`
|
- Host: `ubudual5060` / `100.64.0.9`
|
||||||
- Container: `slabuploader-m1`
|
- Container: `slabuploader-m1`
|
||||||
- Port: **10880** → 80
|
- Port: **18080** → 80
|
||||||
- URL: `http://100.64.0.9:10880`
|
- 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.
|
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.
|
4. App draws the minimum-area bounding rectangle of the mask and the length axis on the long side of that rectangle. User rotates the axis if it does not follow the slab length, then approves the axis.
|
||||||
5. User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate.
|
5. User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate.
|
||||||
6. App sets `scale = longest_axis_px / L` (mask/wood span along the approved direction ÷ user `L`; rectangle is direction only).
|
6. App sets `scale = longest_axis_px / L` (mask/wood span along the approved direction ÷ user `L`; rectangle is direction only).
|
||||||
7. App computes widths, sqft, and bdft from the mask. App crops the masked slab into a 3:4 portrait frame: slab centered, 80% fill at the widest and tallest points, background transparent. App writes that frame as an inventory PNG (`image/png`, 8-bit RGBA).
|
7. App computes widths, sqft, and bdft from the mask. App crops the masked slab into a 3:4 portrait frame: slab centered, 90% fill at the widest and tallest points, background transparent. App writes that frame as an inventory PNG (`image/png`, 8-bit RGBA).
|
||||||
8. User sees the transparent PNG and the numbers. Milestone 1 stops here.
|
8. User sees the transparent PNG and the numbers. Milestone 1 stops here.
|
||||||
|
|
||||||
Calculate does not create a WooCommerce product.
|
Calculate does not create a WooCommerce product.
|
||||||
@@ -97,7 +97,7 @@ User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), th
|
|||||||
- Widths: min, max, avg from samples taken every 6 in along the approved length axis, perpendicular to it, after dropping the first and last 6 in of the span (bulk widths, not tips). If `L ≤ 12 in` there are no interior stations and all three are 0.
|
- Widths: min, max, avg from samples taken every 6 in along the approved length axis, perpendicular to it, after dropping the first and last 6 in of the span (bulk widths, not tips). If `L ≤ 12 in` there are no interior stations and all three are 0.
|
||||||
- `sqft = (mask_pixels / scale²) / 144` (2 decimals)
|
- `sqft = (mask_pixels / scale²) / 144` (2 decimals)
|
||||||
- `bdft = sqft × thickness_in` (2 decimals)
|
- `bdft = sqft × thickness_in` (2 decimals)
|
||||||
- Crop: 3:4 portrait, slab centered, 80% fill at the widest and tallest points, transparent background
|
- Crop: 3:4 portrait, slab centered, 90% fill at the widest and tallest points, transparent background
|
||||||
- File: inventory PNG, `image/png`, 8-bit RGBA, alpha kept
|
- File: inventory PNG, `image/png`, 8-bit RGBA, alpha kept
|
||||||
|
|
||||||
User sees the transparent PNG and those numbers. Stop. Calculate does not create a WooCommerce product.
|
User sees the transparent PNG and those numbers. Stop. Calculate does not create a WooCommerce product.
|
||||||
@@ -108,7 +108,7 @@ On Calculate, the app builds the inventory image from the approved mask:
|
|||||||
|
|
||||||
1. Keep slab pixels from the source photo. Removed pixels are transparent.
|
1. Keep slab pixels from the source photo. Removed pixels are transparent.
|
||||||
2. Place the slab in a 3:4 portrait frame, centered.
|
2. Place the slab in a 3:4 portrait frame, centered.
|
||||||
3. Scale so the widest and tallest points of the slab fill 80% of the frame. Extra margin where the slab is narrower.
|
3. Scale so the widest and tallest points of the slab fill 90% of the frame. Extra margin where the slab is narrower.
|
||||||
4. After that crop, shorter side ≥ 800px when the source can supply it. Otherwise warn and require a new photo. No upscale from an undersized source.
|
4. After that crop, shorter side ≥ 800px when the source can supply it. Otherwise warn and require a new photo. No upscale from an undersized source.
|
||||||
5. Encode as PNG with alpha.
|
5. Encode as PNG with alpha.
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ The first inventory photo is the measurement reference. Later inventory photos u
|
|||||||
| Format | PNG, 8-bit RGBA (`image/png`) |
|
| Format | PNG, 8-bit RGBA (`image/png`) |
|
||||||
| Alpha | Kept through encode and upload |
|
| Alpha | Kept through encode and upload |
|
||||||
| Aspect | 3:4 portrait |
|
| Aspect | 3:4 portrait |
|
||||||
| Layout | Slab centered, 80% fill at the widest and tallest points |
|
| Layout | Slab centered, 90% fill at the widest and tallest points |
|
||||||
| Size | After crop, shorter side ≥ 800px when the source can supply it |
|
| Size | After crop, shorter side ≥ 800px when the source can supply it |
|
||||||
|
|
||||||
Source below that floor: warn and require a new photo. No upscale from an undersized source.
|
Source below that floor: warn and require a new photo. No upscale from an undersized source.
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ Phone capture of one slab → a lined-up set of Woo-compatible inventory PNGs, p
|
|||||||
1. User adds a batch of one or more photos of the same slab (Setup rules from Milestone 1: green or black sheet, top-down reference expectations still apply to the calibration choice).
|
1. User adds a batch of one or more photos of the same slab (Setup rules from Milestone 1: green or black sheet, top-down reference expectations still apply to the calibration choice).
|
||||||
2. User selects which photo in the batch is the calibration photo.
|
2. User selects which photo in the batch is the calibration photo.
|
||||||
3. App runs the Milestone 1 workflow on that calibration photo only: Mask → Length axis → Measure (length `L`, thickness, Calculate).
|
3. App runs the Milestone 1 workflow on that calibration photo only: Mask → Length axis → Measure (length `L`, thickness, Calculate).
|
||||||
4. After Calculate, the app applies the approved mask sheet and sensitivity, and the same crop rules (3:4 portrait, 80% fill, transparent PNG, shorter-side floor), to every other photo in the batch.
|
4. After Calculate, the app applies the approved mask sheet and sensitivity, and the same crop rules (3:4 portrait, 90% fill, transparent PNG, shorter-side floor), to every other photo in the batch.
|
||||||
5. App adds a second copy of the calibration inventory PNG with dimension overlays burned in (see Dimension overlay).
|
5. App adds a second copy of the calibration inventory PNG with dimension overlays burned in (see Dimension overlay).
|
||||||
6. User sees Photos ready: inventory PNGs lined up in this order — clean calibration PNG, then the dimension-overlay copy of the calibration PNG, then the other batch photos in capture order — plus the Milestone 1 calibration details (widths, sqft, bdft, scale) presented as in Measure.
|
6. User sees Photos ready: inventory PNGs lined up in this order — clean calibration PNG, then the dimension-overlay copy of the calibration PNG, then the other batch photos in capture order — plus the Milestone 1 calibration details (widths, sqft, bdft, scale) presented as in Measure.
|
||||||
|
|
||||||
|
|||||||
Generated
+43
@@ -12,6 +12,7 @@
|
|||||||
"svelte": "^4.2.20"
|
"svelte": "^4.2.20"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||||
"svelte-preprocess": "^5.1.4",
|
"svelte-preprocess": "^5.1.4",
|
||||||
"tsx": "^4.23.13",
|
"tsx": "^4.23.13",
|
||||||
@@ -494,6 +495,21 @@
|
|||||||
"node": "^22.20 || ^24.12 || >=25"
|
"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": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.63.2",
|
"version": "4.63.2",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz",
|
"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==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.28",
|
"version": "8.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||||
|
|||||||
@@ -7,13 +7,16 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"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": {
|
"dependencies": {
|
||||||
"idb-keyval": "^6.3.0",
|
"idb-keyval": "^6.3.0",
|
||||||
"svelte": "^4.2.20"
|
"svelte": "^4.2.20"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||||
"svelte-preprocess": "^5.1.4",
|
"svelte-preprocess": "^5.1.4",
|
||||||
"tsx": "^4.23.13",
|
"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',
|
||||||
|
});
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
import { onDestroy, onMount, tick } from 'svelte';
|
import { onDestroy, onMount, tick } from 'svelte';
|
||||||
import type { BBox } from './capture.types';
|
import type { BBox } from './capture.types';
|
||||||
import { fitDisplay } from './fitDisplay';
|
import { fitDisplay } from './fitDisplay';
|
||||||
|
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||||
|
|
||||||
// Display-only: renders the photo fitted to device width (height capped) plus
|
// Display-only: renders the photo fitted to device width (height capped) plus
|
||||||
// one overlay layer. All math lives on source pixels, not this canvas.
|
// 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 blob: Blob;
|
||||||
export let maxH = 420;
|
export let maxH = 420;
|
||||||
export let bbox: BBox | null = null;
|
export let bbox: BBox | null = null;
|
||||||
@@ -12,21 +14,12 @@
|
|||||||
export let maskW = 0;
|
export let maskW = 0;
|
||||||
export let maskH = 0;
|
export let maskH = 0;
|
||||||
|
|
||||||
let url: string | null = null;
|
|
||||||
let canvas: HTMLCanvasElement | undefined;
|
let canvas: HTMLCanvasElement | undefined;
|
||||||
let stage: HTMLDivElement | 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;
|
let parentW = 0;
|
||||||
|
let decodeGen = 0;
|
||||||
const setBlobUrl = (b: Blob) => {
|
let decoding = false;
|
||||||
if (url) URL.revokeObjectURL(url);
|
|
||||||
url = URL.createObjectURL(b);
|
|
||||||
};
|
|
||||||
|
|
||||||
$: if (blob) {
|
|
||||||
setBlobUrl(blob);
|
|
||||||
imgW = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyFit = () => {
|
const applyFit = () => {
|
||||||
if (!canvas || !imgW) return;
|
if (!canvas || !imgW) return;
|
||||||
@@ -37,73 +30,83 @@
|
|||||||
canvas.style.height = `${fit.fitH}px`;
|
canvas.style.height = `${fit.fitH}px`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const redraw = () => {
|
const paintOverlays = (ctx: CanvasRenderingContext2D) => {
|
||||||
if (!url || !canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext('2d')!;
|
if (maskBitset && maskW > 0 && maskH > 0) {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
const off = document.createElement('canvas');
|
||||||
const img = new Image();
|
off.width = maskW;
|
||||||
img.onload = () => {
|
off.height = maskH;
|
||||||
if (!canvas) return;
|
const octx = off.getContext('2d')!;
|
||||||
imgW = img.width;
|
const d = octx.createImageData(maskW, maskH);
|
||||||
imgH = img.height;
|
const tile = 8;
|
||||||
|
for (let p = 0; p < maskW * maskH; p++) {
|
||||||
|
if (!maskBitset[p]) {
|
||||||
|
const x = p % maskW;
|
||||||
|
const y = (p / maskW) | 0;
|
||||||
|
const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2;
|
||||||
|
d.data[p * 4] = light ? 220 : 180;
|
||||||
|
d.data[p * 4 + 1] = light ? 220 : 180;
|
||||||
|
d.data[p * 4 + 2] = light ? 220 : 180;
|
||||||
|
d.data[p * 4 + 3] = 220;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
octx.putImageData(d, 0, 0);
|
||||||
|
ctx.drawImage(off, 0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
if (bbox && bbox.corners.length === 4) {
|
||||||
|
const c = bbox.corners;
|
||||||
|
ctx.strokeStyle = '#00e5ff';
|
||||||
|
ctx.lineWidth = Math.max(2, canvas.width / 400);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(c[0][0], c[0][1]);
|
||||||
|
for (let i = 1; i < 4; i++) ctx.lineTo(c[i][0], c[i][1]);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.stroke();
|
||||||
|
// The axis reads corners[3] → corners[0], the long side.
|
||||||
|
const a = c[3];
|
||||||
|
const b = c[0];
|
||||||
|
ctx.strokeStyle = '#ff3d71';
|
||||||
|
ctx.lineWidth = Math.max(3, canvas.width / 300);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(a[0], a[1]);
|
||||||
|
ctx.lineTo(b[0], b[1]);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = '#ff3d71';
|
||||||
|
ctx.beginPath();
|
||||||
|
const ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
|
||||||
|
const r = Math.max(10, canvas.width / 60);
|
||||||
|
ctx.moveTo(b[0], b[1]);
|
||||||
|
ctx.lineTo(b[0] - r * Math.cos(ang - 0.4), b[1] - r * Math.sin(ang - 0.4));
|
||||||
|
ctx.lineTo(b[0] - r * Math.cos(ang + 0.4), b[1] - r * Math.sin(ang + 0.4));
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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.width = imgW;
|
||||||
canvas.height = imgH;
|
canvas.height = imgH;
|
||||||
applyFit();
|
applyFit();
|
||||||
ctx.drawImage(img, 0, 0);
|
const ctx = canvas.getContext('2d')!;
|
||||||
if (maskBitset && maskW > 0 && maskH > 0) {
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
const off = document.createElement('canvas');
|
ctx.drawImage(bmp, 0, 0);
|
||||||
off.width = maskW;
|
paintOverlays(ctx);
|
||||||
off.height = maskH;
|
} finally {
|
||||||
const octx = off.getContext('2d')!;
|
if (bmp) bmp.close();
|
||||||
const d = octx.createImageData(maskW, maskH);
|
if (gen === decodeGen) decoding = false;
|
||||||
const tile = 8;
|
}
|
||||||
for (let p = 0; p < maskW * maskH; p++) {
|
|
||||||
if (!maskBitset[p]) {
|
|
||||||
const x = p % maskW;
|
|
||||||
const y = (p / maskW) | 0;
|
|
||||||
const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2;
|
|
||||||
d.data[p * 4] = light ? 220 : 180;
|
|
||||||
d.data[p * 4 + 1] = light ? 220 : 180;
|
|
||||||
d.data[p * 4 + 2] = light ? 220 : 180;
|
|
||||||
d.data[p * 4 + 3] = 220;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
octx.putImageData(d, 0, 0);
|
|
||||||
ctx.drawImage(off, 0, 0, canvas.width, canvas.height);
|
|
||||||
}
|
|
||||||
if (bbox && bbox.corners.length === 4) {
|
|
||||||
const c = bbox.corners;
|
|
||||||
ctx.strokeStyle = '#00e5ff';
|
|
||||||
ctx.lineWidth = Math.max(2, canvas.width / 400);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(c[0][0], c[0][1]);
|
|
||||||
for (let i = 1; i < 4; i++) ctx.lineTo(c[i][0], c[i][1]);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.stroke();
|
|
||||||
// The axis reads corners[3] → corners[0], the long side.
|
|
||||||
const a = c[3];
|
|
||||||
const b = c[0];
|
|
||||||
ctx.strokeStyle = '#ff3d71';
|
|
||||||
ctx.lineWidth = Math.max(3, canvas.width / 300);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(a[0], a[1]);
|
|
||||||
ctx.lineTo(b[0], b[1]);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.fillStyle = '#ff3d71';
|
|
||||||
ctx.beginPath();
|
|
||||||
const ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
|
|
||||||
const r = Math.max(10, canvas.width / 60);
|
|
||||||
ctx.moveTo(b[0], b[1]);
|
|
||||||
ctx.lineTo(b[0] - r * Math.cos(ang - 0.4), b[1] - r * Math.sin(ang - 0.4));
|
|
||||||
ctx.lineTo(b[0] - r * Math.cos(ang + 0.4), b[1] - r * Math.sin(ang + 0.4));
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
img.src = url;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$: if (url || bbox || maskBitset) void tick().then(redraw);
|
$: if (blob || bbox || maskBitset) void tick().then(redraw);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (stage) parentW = stage.clientWidth;
|
if (stage) parentW = stage.clientWidth;
|
||||||
@@ -119,12 +122,12 @@
|
|||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
});
|
});
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (url) URL.revokeObjectURL(url);
|
decodeGen += 1;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="stage" bind:this={stage}>
|
<div class="stage" bind:this={stage}>
|
||||||
{#if url}
|
{#if imgW > 0 || decoding || blob}
|
||||||
<canvas bind:this={canvas}></canvas>
|
<canvas bind:this={canvas}></canvas>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="loading">Decoding photo…</div>
|
<div class="loading">Decoding photo…</div>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import { measure } from '../measure/measure';
|
|||||||
import { orientedRect } from '../geometry/rect';
|
import { orientedRect } from '../geometry/rect';
|
||||||
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
||||||
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
||||||
|
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||||
|
|
||||||
const DEBOUNCE_MS = 2000;
|
const DEBOUNCE_MS = 2000;
|
||||||
const BUSY_MS = 300;
|
const BUSY_MS = 300;
|
||||||
@@ -512,7 +513,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
if (state.k !== 'measure') return;
|
if (state.k !== 'measure') return;
|
||||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
||||||
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
||||||
const bitmap = await createImageBitmap(source.blob);
|
const bitmap = await decodeOrientedBitmap(source.blob);
|
||||||
const base = measure(
|
const base = measure(
|
||||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
approvedMask.bitset, source.widthPx, source.heightPx,
|
||||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
||||||
@@ -587,7 +588,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
widthPx: derived.widthPx,
|
widthPx: derived.widthPx,
|
||||||
heightPx: derived.heightPx,
|
heightPx: derived.heightPx,
|
||||||
};
|
};
|
||||||
const siblingBitmap = await createImageBitmap(photo.blob);
|
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
|
||||||
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
||||||
try {
|
try {
|
||||||
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource });
|
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource });
|
||||||
|
|||||||
@@ -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);
|
assert.equal(out.width, 800);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('80% fill: limiting dimension maps to 0.8 × frame side', () => {
|
test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => {
|
||||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
|
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
|
||||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||||
const out = composeInventoryFrame(
|
const out = composeInventoryFrame(
|
||||||
@@ -103,8 +103,8 @@ test('80% fill: limiting dimension maps to 0.8 × frame side', () => {
|
|||||||
const xs = [minx, maxx], ys = [miny, maxy];
|
const xs = [minx, maxx], ys = [miny, maxy];
|
||||||
const wExtent = Math.max(...xs) - Math.min(...xs) + 1;
|
const wExtent = Math.max(...xs) - Math.min(...xs) + 1;
|
||||||
const hExtent = Math.max(...ys) - Math.min(...ys) + 1;
|
const hExtent = Math.max(...ys) - Math.min(...ys) + 1;
|
||||||
assert.ok(Math.abs(wExtent - 640) <= 2, `width extent ${wExtent} ≈ 640 (0.8×800)`);
|
assert.ok(Math.abs(wExtent - 720) <= 2, `width extent ${wExtent} ≈ 720 (0.9×800)`);
|
||||||
assert.ok(Math.abs(hExtent - 576) <= 2, `height extent ${hExtent} ≈ 576 (1800×0.32)`);
|
assert.ok(Math.abs(hExtent - 648) <= 2, `height extent ${hExtent} ≈ 648 (1800×0.36)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => {
|
test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Mask, Source } from '../capture/capture.types';
|
import type { Mask, Source } from '../capture/capture.types';
|
||||||
|
|
||||||
const FLOOR = 800; // shorter side, px
|
const FLOOR = 800; // shorter side, px
|
||||||
const FILL = 0.8; // 80% fill at the widest and tallest points of the slab
|
const FILL = 0.9; // 90% fill at the widest and tallest points of the slab
|
||||||
const ASPECT_W = 3;
|
const ASPECT_W = 3;
|
||||||
const ASPECT_H = 4;
|
const ASPECT_H = 4;
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import {
|
|||||||
pickBottomTowardTop,
|
pickBottomTowardTop,
|
||||||
axisRightUnit,
|
axisRightUnit,
|
||||||
buildOverlayPlan,
|
buildOverlayPlan,
|
||||||
|
inventorySourceMap,
|
||||||
|
BODY_WIDTH_FRAC,
|
||||||
type OverlayPlan,
|
type OverlayPlan,
|
||||||
} from './overlay.ts';
|
} from './overlay.ts';
|
||||||
import { orientedRect, minAreaRect } from '../geometry/rect.ts';
|
import { orientedRect, minAreaRect } from '../geometry/rect.ts';
|
||||||
|
import { axisEndpoints } from '../measure/measure.ts';
|
||||||
|
|
||||||
// --- Station math (#63) ---
|
// --- Station math (#63) ---
|
||||||
|
|
||||||
@@ -136,7 +139,7 @@ test('buildOverlayPlan: length line on axis-right outside slab; widths at 12in s
|
|||||||
|
|
||||||
// Color contract for draw consumers
|
// Color contract for draw consumers
|
||||||
assert.equal(plan.stroke, '#000000');
|
assert.equal(plan.stroke, '#000000');
|
||||||
assert.ok(plan.fontPx >= 24, `fontPx ${plan.fontPx} large enough to read`);
|
assert.ok(plan.fontPx >= 12, `fontPx ${plan.fontPx} large enough to read`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildOverlayPlan: stations measured from EXIF bottom, not tip-inclusive M1 every-6 (#63)', () => {
|
test('buildOverlayPlan: stations measured from EXIF bottom, not tip-inclusive M1 every-6 (#63)', () => {
|
||||||
@@ -185,3 +188,932 @@ test('buildOverlayPlan: dishonest upright-photo axes rejected — uses approved
|
|||||||
const ly = Math.abs(plan.length.a[1] - plan.length.b[1]);
|
const ly = Math.abs(plan.length.a[1] - plan.length.b[1]);
|
||||||
assert.ok(lx > ly * 2, `length graphic follows approved +x axis (dx=${lx}, dy=${ly})`);
|
assert.ok(lx > ly * 2, `length graphic follows approved +x axis (dx=${lx}, dy=${ly})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Ty UAT nits (#67) ---
|
||||||
|
|
||||||
|
test('buildOverlayPlan: length endpoints snap to mask tips, not oversized bbox corners (#67)', () => {
|
||||||
|
// Wood occupies x=100..299 only; synthetic bbox long side extends past tips (x=50..350).
|
||||||
|
const W = 400, H = 160;
|
||||||
|
const bitset = new Uint8Array(W * H);
|
||||||
|
for (let y = 40; y < 100; y++) for (let x = 100; x < 300; x++) bitset[y * W + x] = 1;
|
||||||
|
const bbox = {
|
||||||
|
corners: [
|
||||||
|
[350, 100],
|
||||||
|
[350, 40],
|
||||||
|
[50, 40],
|
||||||
|
[50, 100],
|
||||||
|
] as [number, number][],
|
||||||
|
longSidePx: 300,
|
||||||
|
angleDeg: 0,
|
||||||
|
};
|
||||||
|
const plan = buildOverlayPlan({
|
||||||
|
bitset,
|
||||||
|
widthPx: W,
|
||||||
|
heightPx: H,
|
||||||
|
bbox,
|
||||||
|
scale: 10,
|
||||||
|
lengthIn: 20,
|
||||||
|
lengthLabel: '20"',
|
||||||
|
mapToInventory: (x, y) => [x, y],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Length line sits on axis-right; project a/b onto the length axis through the
|
||||||
|
// wood centroid — endpoints must match mask span ends, not bbox corners.
|
||||||
|
const oriented = pickBottomTowardTop([100, 70], [299, 70]);
|
||||||
|
const strip = (p: [number, number]): [number, number] => {
|
||||||
|
const cx = 199.5, cy = 70;
|
||||||
|
const along = (p[0] - cx) * oriented.ux + (p[1] - cy) * oriented.uy;
|
||||||
|
return [cx + oriented.ux * along, cy + oriented.uy * along];
|
||||||
|
};
|
||||||
|
const xs = [strip(plan.length.a)[0], strip(plan.length.b)[0]].sort((a, b) => a - b);
|
||||||
|
// Mask tips ≈ 100 and 299 (inclusive pixels); bbox corners at 50 and 350
|
||||||
|
assert.ok(Math.abs(xs[0] - 100) <= 2, `mask min tip ${xs[0]} ≈ 100, not bbox 50`);
|
||||||
|
assert.ok(Math.abs(xs[1] - 299) <= 2, `mask max tip ${xs[1]} ≈ 299, not bbox 350`);
|
||||||
|
assert.ok(Math.abs(xs[0] - 50) > 20, 'must not use bbox corner at 50');
|
||||||
|
assert.ok(Math.abs(xs[1] - 350) > 20, 'must not use bbox corner at 350');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildOverlayPlan: width labels sit axis-left outside the slab (#67)', () => {
|
||||||
|
// Uniform strip: length +x, width along y. Axis-right of bottom→top is −y (up).
|
||||||
|
const W = 480, H = 200;
|
||||||
|
const bitset = new Uint8Array(W * H);
|
||||||
|
for (let y = 50; y < 150; y++) for (let x = 0; x < W; x++) bitset[y * W + x] = 1;
|
||||||
|
const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
|
||||||
|
const plan = buildOverlayPlan({
|
||||||
|
bitset,
|
||||||
|
widthPx: W,
|
||||||
|
heightPx: H,
|
||||||
|
bbox: box,
|
||||||
|
scale: 10,
|
||||||
|
lengthIn: 48,
|
||||||
|
lengthLabel: '48"',
|
||||||
|
mapToInventory: (x, y) => [x, y],
|
||||||
|
});
|
||||||
|
assert.ok(plan.widths.length > 0);
|
||||||
|
for (const w of plan.widths) {
|
||||||
|
assert.ok(w.labelAt, 'width mark must expose labelAt');
|
||||||
|
// left/right tips still on wood edges
|
||||||
|
assert.ok(Math.abs(w.left[1] - 149) <= 2 || Math.abs(w.left[1] - 50) <= 2);
|
||||||
|
assert.ok(Math.abs(w.right[1] - 149) <= 2 || Math.abs(w.right[1] - 50) <= 2);
|
||||||
|
// labelAt on axis-left = opposite of axis-right from center
|
||||||
|
// For this geometry, axis-right is toward smaller y; axis-left toward larger y
|
||||||
|
// (outside past the bottom edge y≈149).
|
||||||
|
const cy = w.center[1];
|
||||||
|
assert.ok(
|
||||||
|
w.labelAt[1] > Math.max(w.left[1], w.right[1]) + 4,
|
||||||
|
`width labelAt y=${w.labelAt[1]} must sit past slab edge (left of travel), not on wood center y=${cy}`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(w.labelAt[0] - w.center[0]) < 3,
|
||||||
|
'width label stays on the station cross-section (same x as center)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Length stays axis-right (outside opposite side)
|
||||||
|
const lenY = (plan.length.a[1] + plan.length.b[1]) / 2;
|
||||||
|
assert.ok(lenY < 50 - 4, `length dim stays axis-right outside top edge, got y=${lenY}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildOverlayPlan: font smaller than prior 28/scale*1.2 and 22/scale*1.0 (#67,#71)', () => {
|
||||||
|
const W = 480, H = 200;
|
||||||
|
const bitset = new Uint8Array(W * H);
|
||||||
|
for (let y = 50; y < 150; y++) for (let x = 0; x < W; x++) bitset[y * W + x] = 1;
|
||||||
|
const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
|
||||||
|
const scale = 10;
|
||||||
|
const plan = buildOverlayPlan({
|
||||||
|
bitset,
|
||||||
|
widthPx: W,
|
||||||
|
heightPx: H,
|
||||||
|
bbox: box,
|
||||||
|
scale,
|
||||||
|
lengthIn: 48,
|
||||||
|
lengthLabel: '48"',
|
||||||
|
mapToInventory: (x, y) => [x, y],
|
||||||
|
});
|
||||||
|
const oldFont67 = Math.max(28, Math.round(scale * 1.2));
|
||||||
|
const oldFont71 = Math.max(22, Math.round(scale * 1.0));
|
||||||
|
assert.ok(plan.fontPx < oldFont67, `fontPx ${plan.fontPx} must be < prior ${oldFont67}`);
|
||||||
|
assert.ok(plan.fontPx < oldFont71, `fontPx ${plan.fontPx} must be < prior ${oldFont71}`);
|
||||||
|
assert.ok(plan.fontPx >= 12, `fontPx ${plan.fontPx} still legible`);
|
||||||
|
// Concrete contract (#71): capped by scale when no inventory size
|
||||||
|
assert.equal(plan.fontPx, Math.max(12, Math.min(16, Math.round(scale * 0.18))));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- Length dim must not overshoot wood footprint (#69) ---
|
||||||
|
|
||||||
|
/** Tilted-axis slab: irregular mask + approved bbox long side ~10° from vertical. */
|
||||||
|
function tiltedSlabFixture() {
|
||||||
|
const W = 300, H = 600;
|
||||||
|
const bitset = new Uint8Array(W * H);
|
||||||
|
for (let y = 50; y <= 550; y++) {
|
||||||
|
const t = (y - 50) / 500;
|
||||||
|
const x0 = Math.round(80 + t * 20);
|
||||||
|
const x1 = Math.round(180 + t * 40);
|
||||||
|
for (let x = x0; x <= x1; x++) bitset[y * W + x] = 1;
|
||||||
|
}
|
||||||
|
const ang = (10 * Math.PI) / 180;
|
||||||
|
const ux0 = Math.sin(ang);
|
||||||
|
const uy0 = Math.cos(ang);
|
||||||
|
const rx0 = -uy0;
|
||||||
|
const ry0 = ux0;
|
||||||
|
const cx = 140, cy = 300, halfL = 280, halfW = 90;
|
||||||
|
const corners: [number, number][] = [
|
||||||
|
[cx + ux0 * halfL + rx0 * halfW, cy + uy0 * halfL + ry0 * halfW],
|
||||||
|
[cx + ux0 * halfL - rx0 * halfW, cy + uy0 * halfL - ry0 * halfW],
|
||||||
|
[cx - ux0 * halfL - rx0 * halfW, cy - uy0 * halfL - ry0 * halfW],
|
||||||
|
[cx - ux0 * halfL + rx0 * halfW, cy - uy0 * halfL + ry0 * halfW],
|
||||||
|
];
|
||||||
|
const bbox = { corners, longSidePx: halfL * 2, angleDeg: 80 };
|
||||||
|
return { W, H, bitset, bbox };
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskWoodAabb(bitset: Uint8Array, W: number, H: number) {
|
||||||
|
let minX = W, maxX = 0, minY = H, maxY = 0;
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
if (!bitset[y * W + x]) continue;
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { minX, maxX, minY, maxY };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('buildOverlayPlan: length axial span = robust tip stations; no AABB overshoot along axis (#69)', () => {
|
||||||
|
const { W, H, bitset, bbox } = tiltedSlabFixture();
|
||||||
|
const scale = 10;
|
||||||
|
const wood = maskWoodAabb(bitset, W, H);
|
||||||
|
const slabBounds = {
|
||||||
|
x: wood.minX - 8,
|
||||||
|
y: wood.minY - 8,
|
||||||
|
w: wood.maxX - wood.minX + 16,
|
||||||
|
h: wood.maxY - wood.minY + 16,
|
||||||
|
};
|
||||||
|
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, 900, 1200);
|
||||||
|
const plan = buildOverlayPlan({
|
||||||
|
bitset,
|
||||||
|
widthPx: W,
|
||||||
|
heightPx: H,
|
||||||
|
bbox,
|
||||||
|
scale,
|
||||||
|
lengthIn: 50,
|
||||||
|
lengthLabel: '50"',
|
||||||
|
mapToInventory: inv.mapToInventory,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wood AABB in inventory space (for axis-right clearance only)
|
||||||
|
let mMinX = Infinity, mMaxX = -Infinity;
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
if (!bitset[y * W + x]) continue;
|
||||||
|
const [mx] = inv.mapToInventory(x, y);
|
||||||
|
if (mx < mMinX) mMinX = mx;
|
||||||
|
if (mx > mMaxX) mMaxX = mx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width-aware tip stations along approved bbox axis (#71; was 0.2%/99.8%)
|
||||||
|
const { start: aa, end: bb } = axisEndpoints(bbox);
|
||||||
|
const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1;
|
||||||
|
const ux = ex / elen, uy = ey / elen;
|
||||||
|
const cx = bbox.corners.reduce((s, c) => s + c[0], 0) / 4;
|
||||||
|
const cy = bbox.corners.reduce((s, c) => s + c[1], 0) / 4;
|
||||||
|
const cProj = cx * ux + cy * uy;
|
||||||
|
const rx = -uy, ry = ux;
|
||||||
|
const binMin = new Map<number, number>();
|
||||||
|
const binMax = new Map<number, number>();
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
if (!bitset[y * W + 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC);
|
||||||
|
widths.sort((a, b) => a.bin - b.bin);
|
||||||
|
let tipLo = widths[0].bin, 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; }
|
||||||
|
}
|
||||||
|
const tipStart: [number, number] = [cx + ux * (tipLo - cProj), cy + uy * (tipLo - cProj)];
|
||||||
|
const tipEnd: [number, number] = [cx + ux * (tipHi - cProj), cy + uy * (tipHi - cProj)];
|
||||||
|
const oriented = pickBottomTowardTop(tipStart, tipEnd);
|
||||||
|
|
||||||
|
// Inventory map is isotropic scale+translate → directions preserved; axial span scales.
|
||||||
|
const mapBot = inv.mapToInventory(oriented.bottom[0], oriented.bottom[1]);
|
||||||
|
const mapTop = inv.mapToInventory(oriented.top[0], oriented.top[1]);
|
||||||
|
const tipAxial = Math.hypot(mapTop[0] - mapBot[0], mapTop[1] - mapBot[1]);
|
||||||
|
const lenAxial = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]);
|
||||||
|
// Axial edge-inset (#71) shortens span slightly vs tip stations
|
||||||
|
assert.ok(
|
||||||
|
lenAxial <= tipAxial + 2 && lenAxial >= tipAxial - 8,
|
||||||
|
`length span ${lenAxial.toFixed(1)} must match width-aware tip span ${tipAxial.toFixed(1)} (allow small axial inset only; #73)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Axial stations of length ends = tip stations (pure axis-right offset does not change along-u).
|
||||||
|
const mapOrigin = inv.mapToInventory(0, 0);
|
||||||
|
const mapU = inv.mapToInventory(oriented.ux, oriented.uy);
|
||||||
|
const iux = mapU[0] - mapOrigin[0];
|
||||||
|
const iuy = mapU[1] - mapOrigin[1];
|
||||||
|
const ilen = Math.hypot(iux, iuy) || 1;
|
||||||
|
const nux = iux / ilen, nuy = iuy / ilen;
|
||||||
|
const lenProjs = [plan.length.a[0] * nux + plan.length.a[1] * nuy, plan.length.b[0] * nux + plan.length.b[1] * nuy].sort(
|
||||||
|
(a, b) => a - b,
|
||||||
|
);
|
||||||
|
const tipProjs = [mapBot[0] * nux + mapBot[1] * nuy, mapTop[0] * nux + mapTop[1] * nuy].sort((a, b) => a - b);
|
||||||
|
assert.ok(Math.abs(lenProjs[0] - tipProjs[0]) <= 14, `length min station ${lenProjs[0]} ≈ tip ${tipProjs[0]} (inset)`);
|
||||||
|
assert.ok(Math.abs(lenProjs[1] - tipProjs[1]) <= 14, `length max station ${lenProjs[1]} ≈ tip ${tipProjs[1]} (inset)`);
|
||||||
|
|
||||||
|
// Length stays axis-right of wood (clearance to the right in inventory x for this fixture)
|
||||||
|
const lenMidX = (plan.length.a[0] + plan.length.b[0]) / 2;
|
||||||
|
assert.ok(lenMidX > mMaxX + 4, `length dim must sit outside wood on axis-right (x=${lenMidX} vs maxX=${mMaxX})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildOverlayPlan: width labels fully left of wood AABB after inventory map (#69)', () => {
|
||||||
|
const { W, H, bitset, bbox } = tiltedSlabFixture();
|
||||||
|
const scale = 10;
|
||||||
|
const wood = maskWoodAabb(bitset, W, H);
|
||||||
|
const slabBounds = {
|
||||||
|
x: wood.minX - 8,
|
||||||
|
y: wood.minY - 8,
|
||||||
|
w: wood.maxX - wood.minX + 16,
|
||||||
|
h: wood.maxY - wood.minY + 16,
|
||||||
|
};
|
||||||
|
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, 900, 1200);
|
||||||
|
const plan = buildOverlayPlan({
|
||||||
|
bitset,
|
||||||
|
widthPx: W,
|
||||||
|
heightPx: H,
|
||||||
|
bbox,
|
||||||
|
scale,
|
||||||
|
lengthIn: 50,
|
||||||
|
lengthLabel: '50"',
|
||||||
|
mapToInventory: inv.mapToInventory,
|
||||||
|
});
|
||||||
|
assert.ok(plan.widths.length > 0, 'expected width stations');
|
||||||
|
|
||||||
|
let mMinX = Infinity, mMaxX = -Infinity, mMinY = Infinity, mMaxY = -Infinity;
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
if (!bitset[y * W + x]) continue;
|
||||||
|
const [mx, my] = inv.mapToInventory(x, y);
|
||||||
|
if (mx < mMinX) mMinX = mx;
|
||||||
|
if (mx > mMaxX) mMaxX = mx;
|
||||||
|
if (my < mMinY) mMinY = my;
|
||||||
|
if (my > mMaxY) mMaxY = my;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const w of plan.widths) {
|
||||||
|
// Label anchor fully off the wood footprint (axis-left), with margin for glyph half-width
|
||||||
|
const margin = plan.fontPx * 0.55; // textAlign will grow away from wood; still keep anchor clear
|
||||||
|
const onWood =
|
||||||
|
w.labelAt[0] >= mMinX - 1 &&
|
||||||
|
w.labelAt[0] <= mMaxX + 1 &&
|
||||||
|
w.labelAt[1] >= mMinY - 1 &&
|
||||||
|
w.labelAt[1] <= mMaxY + 1;
|
||||||
|
assert.ok(
|
||||||
|
!onWood,
|
||||||
|
`width labelAt (${w.labelAt[0].toFixed(0)},${w.labelAt[1].toFixed(0)}) must be outside wood AABB`,
|
||||||
|
);
|
||||||
|
// Axis-left = past the left tip (smaller across); for this nearly-vertical slab, left of wood in x
|
||||||
|
assert.ok(
|
||||||
|
w.labelAt[0] < mMinX - 2,
|
||||||
|
`width label ${w.label} x=${w.labelAt[0].toFixed(0)} must be left of wood minX=${mMinX.toFixed(0)} (margin~${margin.toFixed(0)})`,
|
||||||
|
);
|
||||||
|
// Width arrows still touch wood edges (left/right tips inside/on AABB)
|
||||||
|
assert.ok(w.left[0] >= mMinX - 3 && w.left[0] <= mMaxX + 3);
|
||||||
|
assert.ok(w.right[0] >= mMinX - 3 && w.right[0] <= mMaxX + 3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Length/width dims must follow approved axis, not screen tip hack (#70) ---
|
||||||
|
|
||||||
|
// --- Length/width dims must follow approved axis, not screen tip hack (#70) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ~25° approved axis. Irregular tips sit far off the centerline on opposite
|
||||||
|
* sides so tip-to-tip ≠ (ux,uy). #69 screen-hack follows tip pixels and fails
|
||||||
|
* the parallel-axis / pure axis-right offset asserts; classic offset passes.
|
||||||
|
*/
|
||||||
|
function tiltedAxis25Fixture() {
|
||||||
|
const W = 420, H = 520;
|
||||||
|
const ang = (25 * Math.PI) / 180;
|
||||||
|
const ux0 = Math.sin(ang);
|
||||||
|
const uy0 = Math.cos(ang);
|
||||||
|
const rx0 = -uy0;
|
||||||
|
const ry0 = ux0;
|
||||||
|
const cx = 210, cy = 260, halfL = 160, halfW = 50;
|
||||||
|
const bitset = new Uint8Array(W * H);
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
const along = (x - cx) * ux0 + (y - cy) * uy0;
|
||||||
|
const across = (x - cx) * rx0 + (y - cy) * ry0;
|
||||||
|
const t = (along + halfL) / (2 * halfL);
|
||||||
|
// Strong opposite bias at the tips (±0.85*halfW) so tip pixels diverge from axis
|
||||||
|
const centerAcross = (t - 0.5) * 2 * halfW * 0.85;
|
||||||
|
const halfAt = halfW * 0.35;
|
||||||
|
if (Math.abs(along) <= halfL && Math.abs(across - centerAcross) <= halfAt) {
|
||||||
|
bitset[y * W + x] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const corners: [number, number][] = [
|
||||||
|
[cx + ux0 * halfL + rx0 * halfW, cy + uy0 * halfL + ry0 * halfW],
|
||||||
|
[cx + ux0 * halfL - rx0 * halfW, cy + uy0 * halfL - ry0 * halfW],
|
||||||
|
[cx - ux0 * halfL - rx0 * halfW, cy - uy0 * halfL - ry0 * halfW],
|
||||||
|
[cx - ux0 * halfL + rx0 * halfW, cy - uy0 * halfL + ry0 * halfW],
|
||||||
|
];
|
||||||
|
const bbox = { corners, longSidePx: halfL * 2, angleDeg: 90 - 25 };
|
||||||
|
return { W, H, bitset, bbox, ux0, uy0, rx0, ry0, halfL, cx, cy };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
test('buildOverlayPlan: angleDeg=90 vertical long side → width left→right within 2° of horizontal (#72)', () => {
|
||||||
|
// Upright slab: approved length axis is vertical (angleDeg 90). Width marks must
|
||||||
|
// run left→right ≈ horizontal (atan2 ≈ 0 or ±π), not slanted with the photo.
|
||||||
|
const W = 200, H = 400;
|
||||||
|
const bitset = new Uint8Array(W * H);
|
||||||
|
for (let y = 40; y <= 360; y++) {
|
||||||
|
for (let x = 60; x <= 140; x++) bitset[y * W + x] = 1;
|
||||||
|
}
|
||||||
|
const prev = minAreaRect(bitset, W, H)!;
|
||||||
|
const bbox = orientedRect(prev, 90);
|
||||||
|
assert.equal(bbox.angleDeg, 90);
|
||||||
|
const scale = 10; // 320px span ≈ 32"
|
||||||
|
const plan = buildOverlayPlan({
|
||||||
|
bitset,
|
||||||
|
widthPx: W,
|
||||||
|
heightPx: H,
|
||||||
|
bbox,
|
||||||
|
scale,
|
||||||
|
lengthIn: 32,
|
||||||
|
lengthLabel: '32"',
|
||||||
|
mapToInventory: (x, y) => [x, y],
|
||||||
|
});
|
||||||
|
assert.ok(plan.widths.length > 0, 'expected width stations');
|
||||||
|
for (const w of plan.widths) {
|
||||||
|
const ang = Math.atan2(w.right[1] - w.left[1], w.right[0] - w.left[0]);
|
||||||
|
// Fold to [-90, 90] so left→right and right→left both count as horizontal.
|
||||||
|
let deg = (ang * 180) / Math.PI;
|
||||||
|
if (deg > 90) deg -= 180;
|
||||||
|
if (deg < -90) deg += 180;
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(deg) <= 2,
|
||||||
|
`width station ${w.stationIn} left→right must be within 2° of horizontal (atan2 deg=${deg.toFixed(2)}; left=${w.left} right=${w.right})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildOverlayPlan: length a→b || approved (ux,uy); width left→right || (rx,ry) (#70)', () => {
|
||||||
|
const { W, H, bitset, bbox, cx, cy } = tiltedAxis25Fixture();
|
||||||
|
const scale = 10;
|
||||||
|
const plan = buildOverlayPlan({
|
||||||
|
bitset,
|
||||||
|
widthPx: W,
|
||||||
|
heightPx: H,
|
||||||
|
bbox,
|
||||||
|
scale,
|
||||||
|
lengthIn: 32,
|
||||||
|
lengthLabel: '32"',
|
||||||
|
mapToInventory: (x, y) => [x, y],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { start: aa, end: bb } = axisEndpoints(bbox);
|
||||||
|
const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1;
|
||||||
|
const uxB = ex / elen, uyB = ey / elen;
|
||||||
|
const cProj = cx * uxB + cy * uyB;
|
||||||
|
const rxB = -uyB, ryB = uxB;
|
||||||
|
const binMin = new Map<number, number>();
|
||||||
|
const binMax = new Map<number, number>();
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
if (!bitset[y * W + x]) continue;
|
||||||
|
const along = x * uxB + y * uyB;
|
||||||
|
const across = (x - cx) * rxB + (y - cy) * ryB;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let maxW = 0;
|
||||||
|
const wbins: { bin: number; width: number }[] = [];
|
||||||
|
for (const [bin, minA] of binMin) {
|
||||||
|
const maxA = binMax.get(bin)!;
|
||||||
|
const width = maxA - minA + 1;
|
||||||
|
wbins.push({ bin, width });
|
||||||
|
if (width > maxW) maxW = width;
|
||||||
|
}
|
||||||
|
const thresh = Math.max(4, maxW * BODY_WIDTH_FRAC);
|
||||||
|
wbins.sort((a, b) => a.bin - b.bin);
|
||||||
|
let tipLo = wbins[0].bin, tipHi = wbins[wbins.length - 1].bin;
|
||||||
|
for (const { bin, width } of wbins) {
|
||||||
|
if (width >= thresh) { tipLo = bin; break; }
|
||||||
|
}
|
||||||
|
for (let i = wbins.length - 1; i >= 0; i--) {
|
||||||
|
if (wbins[i].width >= thresh) { tipHi = wbins[i].bin; break; }
|
||||||
|
}
|
||||||
|
const tipStart: [number, number] = [cx + uxB * (tipLo - cProj), cy + uyB * (tipLo - cProj)];
|
||||||
|
const tipEnd: [number, number] = [cx + uxB * (tipHi - cProj), cy + uyB * (tipHi - cProj)];
|
||||||
|
const oriented = pickBottomTowardTop(tipStart, tipEnd);
|
||||||
|
const { ux, uy, bottom, top } = oriented;
|
||||||
|
const [rx, ry] = axisRightUnit(ux, uy);
|
||||||
|
|
||||||
|
const ldx = plan.length.b[0] - plan.length.a[0];
|
||||||
|
const ldy = plan.length.b[1] - plan.length.a[1];
|
||||||
|
const llen = Math.hypot(ldx, ldy) || 1;
|
||||||
|
const lux = ldx / llen, luy = ldy / llen;
|
||||||
|
const lengthDot = lux * ux + luy * uy;
|
||||||
|
assert.ok(
|
||||||
|
lengthDot > 0.9995,
|
||||||
|
`length a→b must be || approved axis (dot=${lengthDot.toFixed(6)}; got (${lux.toFixed(4)},${luy.toFixed(4)}) vs (${ux.toFixed(4)},${uy.toFixed(4)}))`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Endpoints = centerline tip stations + pure axis-right (no screen-only tip translation)
|
||||||
|
for (const [endPt, tip] of [
|
||||||
|
[plan.length.a, bottom],
|
||||||
|
[plan.length.b, top],
|
||||||
|
] as const) {
|
||||||
|
const ox = endPt[0] - tip[0];
|
||||||
|
const oy = endPt[1] - tip[1];
|
||||||
|
const along = ox * ux + oy * uy;
|
||||||
|
const across = ox * rx + oy * ry;
|
||||||
|
// Pure axis-right plus small axial inset toward center (#71 edgeInset)
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(along) <= 4,
|
||||||
|
`length end offset must be axis-right (+tiny axial inset) (along=${along.toFixed(2)}; tip=(${tip[0].toFixed(1)},${tip[1].toFixed(1)}))`,
|
||||||
|
);
|
||||||
|
assert.ok(across > 8, `length end must sit axis-right of centerline tip (across=${across.toFixed(1)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(plan.widths.length > 0, 'expected width marks');
|
||||||
|
for (const w of plan.widths) {
|
||||||
|
const wdx = w.right[0] - w.left[0];
|
||||||
|
const wdy = w.right[1] - w.left[1];
|
||||||
|
const wlen = Math.hypot(wdx, wdy) || 1;
|
||||||
|
const wrx = wdx / wlen, wry = wdy / wlen;
|
||||||
|
const widthDot = wrx * rx + wry * ry;
|
||||||
|
assert.ok(
|
||||||
|
widthDot > 0.999,
|
||||||
|
`width left→right must be || axis-right (dot=${widthDot.toFixed(6)} at station ${w.stationIn})`,
|
||||||
|
);
|
||||||
|
const perp = wrx * ux + wry * uy;
|
||||||
|
assert.ok(Math.abs(perp) < 0.02, `width must be ⊥ length axis (dot=${perp.toFixed(4)})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Dim tips on footprint; labels in-bounds; smaller font (#71) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solid body + dense fringe whiskers past the visual tips.
|
||||||
|
* 0.2%/99.8% axial percentiles still land in the fringe (overshoot);
|
||||||
|
* width-aware tip finding must snap to the solid body ends.
|
||||||
|
*/
|
||||||
|
function fringeTipFixture() {
|
||||||
|
const W = 280, H = 560;
|
||||||
|
const bitset = new Uint8Array(W * H);
|
||||||
|
// Solid body: x=90..190, y=120..440
|
||||||
|
for (let y = 120; y <= 440; y++) {
|
||||||
|
for (let x = 90; x <= 190; x++) bitset[y * W + x] = 1;
|
||||||
|
}
|
||||||
|
// Dense fringe whiskers (~10% of body width) past tips — denser than 0.2%.
|
||||||
|
// Must stay below BODY_WIDTH_FRAC (#73 ~0.15) so width-aware tips exclude them.
|
||||||
|
for (let y = 95; y < 120; y++) {
|
||||||
|
for (let x = 135; x <= 145; x++) bitset[y * W + x] = 1;
|
||||||
|
}
|
||||||
|
for (let y = 441; y <= 465; y++) {
|
||||||
|
for (let x = 135; x <= 145; x++) bitset[y * W + x] = 1;
|
||||||
|
}
|
||||||
|
// Approved axis ~vertical (slight tilt)
|
||||||
|
const ang = (8 * Math.PI) / 180;
|
||||||
|
const ux0 = Math.sin(ang);
|
||||||
|
const uy0 = Math.cos(ang);
|
||||||
|
const rx0 = -uy0;
|
||||||
|
const ry0 = ux0;
|
||||||
|
const cx = 140, cy = 280, halfL = 200, halfW = 70;
|
||||||
|
const corners: [number, number][] = [
|
||||||
|
[cx + ux0 * halfL + rx0 * halfW, cy + uy0 * halfL + ry0 * halfW],
|
||||||
|
[cx + ux0 * halfL - rx0 * halfW, cy + uy0 * halfL - ry0 * halfW],
|
||||||
|
[cx - ux0 * halfL - rx0 * halfW, cy - uy0 * halfL - ry0 * halfW],
|
||||||
|
[cx - ux0 * halfL + rx0 * halfW, cy - uy0 * halfL + ry0 * halfW],
|
||||||
|
];
|
||||||
|
const bbox = { corners, longSidePx: halfL * 2, angleDeg: 90 - 8 };
|
||||||
|
return { W, H, bitset, bbox, solidMinY: 120, solidMaxY: 440, ux0, uy0, cx, cy };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('buildOverlayPlan: length span ≤ solid-body axial extent; fringe whiskers excluded (#71)', () => {
|
||||||
|
const { W, H, bitset, bbox, solidMinY, solidMaxY, ux0, uy0, cx, cy } = fringeTipFixture();
|
||||||
|
const scale = 10;
|
||||||
|
const wood = maskWoodAabb(bitset, W, H);
|
||||||
|
const slabBounds = {
|
||||||
|
x: wood.minX - 10,
|
||||||
|
y: wood.minY - 10,
|
||||||
|
w: wood.maxX - wood.minX + 20,
|
||||||
|
h: wood.maxY - wood.minY + 20,
|
||||||
|
};
|
||||||
|
const outW = 900, outH = 1200;
|
||||||
|
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, outW, outH);
|
||||||
|
const plan = buildOverlayPlan({
|
||||||
|
bitset,
|
||||||
|
widthPx: W,
|
||||||
|
heightPx: H,
|
||||||
|
bbox,
|
||||||
|
scale,
|
||||||
|
lengthIn: 32,
|
||||||
|
lengthLabel: '32"',
|
||||||
|
mapToInventory: inv.mapToInventory,
|
||||||
|
inventoryW: outW,
|
||||||
|
inventoryH: outH,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Solid-body axial projections (exclude fringe by y gate on this fixture)
|
||||||
|
const solidProjs: number[] = [];
|
||||||
|
for (let y = solidMinY; y <= solidMaxY; y++) {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
if (!bitset[y * W + x]) continue;
|
||||||
|
solidProjs.push(x * ux0 + y * uy0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
solidProjs.sort((a, b) => a - b);
|
||||||
|
const solidMin = solidProjs[0];
|
||||||
|
const solidMax = solidProjs[solidProjs.length - 1];
|
||||||
|
const cProj = cx * ux0 + cy * uy0;
|
||||||
|
const solidBot: [number, number] = [cx + ux0 * (solidMin - cProj), cy + uy0 * (solidMin - cProj)];
|
||||||
|
const solidTop: [number, number] = [cx + ux0 * (solidMax - cProj), cy + uy0 * (solidMax - cProj)];
|
||||||
|
const oriented = pickBottomTowardTop(solidBot, solidTop);
|
||||||
|
const mapBot = inv.mapToInventory(oriented.bottom[0], oriented.bottom[1]);
|
||||||
|
const mapTop = inv.mapToInventory(oriented.top[0], oriented.top[1]);
|
||||||
|
const solidAxial = Math.hypot(mapTop[0] - mapBot[0], mapTop[1] - mapBot[1]);
|
||||||
|
const lenAxial = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]);
|
||||||
|
|
||||||
|
// Length must not exceed solid body (percentile fringe would overshoot by ≫2px)
|
||||||
|
assert.ok(
|
||||||
|
lenAxial <= solidAxial + 2,
|
||||||
|
`length span ${lenAxial.toFixed(1)} must be ≤ solid-body span ${solidAxial.toFixed(1)} (fringe excluded)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prove 0.2%/99.8% still overshoots solid body — so this fixture is meaningful
|
||||||
|
const allProjs: number[] = [];
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
if (!bitset[y * W + x]) continue;
|
||||||
|
allProjs.push(x * ux0 + y * uy0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allProjs.sort((a, b) => a - b);
|
||||||
|
const lo = Math.min(Math.floor(allProjs.length * 0.002), allProjs.length - 1);
|
||||||
|
const hi = Math.max(Math.floor(allProjs.length * 0.998), lo);
|
||||||
|
const pctSpan = allProjs[hi] - allProjs[lo];
|
||||||
|
const solidSpan = solidMax - solidMin;
|
||||||
|
assert.ok(
|
||||||
|
pctSpan > solidSpan + 5,
|
||||||
|
`fixture sanity: percentile span ${pctSpan.toFixed(1)} must overshoot solid ${solidSpan.toFixed(1)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Still || approved axis (keep #70)
|
||||||
|
const ldx = plan.length.b[0] - plan.length.a[0];
|
||||||
|
const ldy = plan.length.b[1] - plan.length.a[1];
|
||||||
|
const llen = Math.hypot(ldx, ldy) || 1;
|
||||||
|
const mapOrigin = inv.mapToInventory(0, 0);
|
||||||
|
const mapU = inv.mapToInventory(oriented.ux, oriented.uy);
|
||||||
|
const iux = mapU[0] - mapOrigin[0];
|
||||||
|
const iuy = mapU[1] - mapOrigin[1];
|
||||||
|
const ilen = Math.hypot(iux, iuy) || 1;
|
||||||
|
const lengthDot = (ldx / llen) * (iux / ilen) + (ldy / llen) * (iuy / ilen);
|
||||||
|
assert.ok(lengthDot > 0.999, `length must stay || axis (dot=${lengthDot.toFixed(6)})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildOverlayPlan: width endpoints on/inside wood edges with stroke inset (#71)', () => {
|
||||||
|
const { W, H, bitset, bbox } = fringeTipFixture();
|
||||||
|
const scale = 10;
|
||||||
|
const plan = buildOverlayPlan({
|
||||||
|
bitset,
|
||||||
|
widthPx: W,
|
||||||
|
heightPx: H,
|
||||||
|
bbox,
|
||||||
|
scale,
|
||||||
|
lengthIn: 32,
|
||||||
|
lengthLabel: '32"',
|
||||||
|
mapToInventory: (x, y) => [x, y],
|
||||||
|
inventoryW: W,
|
||||||
|
inventoryH: H,
|
||||||
|
});
|
||||||
|
assert.ok(plan.widths.length > 0, 'expected width stations');
|
||||||
|
|
||||||
|
const wood = maskWoodAabb(bitset, W, H);
|
||||||
|
// Rebuild oriented axis to re-probe full mask extent at each station
|
||||||
|
const { start: aa, end: bb } = axisEndpoints(bbox);
|
||||||
|
const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1;
|
||||||
|
const uxB = ex / elen, uyB = ey / elen;
|
||||||
|
const cx = bbox.corners.reduce((s, c) => s + c[0], 0) / 4;
|
||||||
|
const cy = bbox.corners.reduce((s, c) => s + c[1], 0) / 4;
|
||||||
|
const cProj = cx * uxB + cy * uyB;
|
||||||
|
// Width-aware tips from the same solid body y-range as fringeTipFixture
|
||||||
|
const solidProjs: number[] = [];
|
||||||
|
for (let y = 120; y <= 440; y++) {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
if (!bitset[y * W + x]) continue;
|
||||||
|
solidProjs.push(x * uxB + y * uyB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
solidProjs.sort((a, b) => a - b);
|
||||||
|
const tipStart: [number, number] = [cx + uxB * (solidProjs[0] - cProj), cy + uyB * (solidProjs[0] - cProj)];
|
||||||
|
const tipEnd: [number, number] = [cx + uxB * (solidProjs[solidProjs.length - 1] - cProj), cy + uyB * (solidProjs[solidProjs.length - 1] - cProj)];
|
||||||
|
const oriented = pickBottomTowardTop(tipStart, tipEnd);
|
||||||
|
const { ux, uy, bottom } = oriented;
|
||||||
|
const [rx, ry] = axisRightUnit(ux, uy);
|
||||||
|
|
||||||
|
for (const w of plan.widths) {
|
||||||
|
assert.ok(
|
||||||
|
w.left[0] >= wood.minX - 1 && w.left[0] <= wood.maxX + 1 &&
|
||||||
|
w.left[1] >= wood.minY - 1 && w.left[1] <= wood.maxY + 1,
|
||||||
|
`width left must be on/inside wood AABB`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
w.right[0] >= wood.minX - 1 && w.right[0] <= wood.maxX + 1 &&
|
||||||
|
w.right[1] >= wood.minY - 1 && w.right[1] <= wood.maxY + 1,
|
||||||
|
`width right must be on/inside wood AABB`,
|
||||||
|
);
|
||||||
|
// Full mask probe at this station (no inset)
|
||||||
|
const t = w.stationIn * scale;
|
||||||
|
const ox = bottom[0] + ux * t;
|
||||||
|
const oy = bottom[1] + uy * t;
|
||||||
|
const half = Math.max(2, Math.round((W + H) / 4));
|
||||||
|
let minP = Infinity, maxP = -Infinity;
|
||||||
|
for (let p = -half; p <= half; p++) {
|
||||||
|
const x = Math.round(ox + rx * p);
|
||||||
|
const y = Math.round(oy + ry * 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fullSpan = maxP - minP;
|
||||||
|
const planSpan = Math.hypot(w.right[0] - w.left[0], w.right[1] - w.left[1]);
|
||||||
|
// Inset ≥ ~0.5px each end so stroke/arrowhead visual tips do not past the edge
|
||||||
|
assert.ok(
|
||||||
|
planSpan <= fullSpan - 0.9,
|
||||||
|
`width span ${planSpan.toFixed(2)} must be inset from full mask ${fullSpan} (stroke/arrowhead)`,
|
||||||
|
);
|
||||||
|
// Tips still near the edge (not collapsed)
|
||||||
|
assert.ok(planSpan >= fullSpan - 20, `inset must be modest, got plan=${planSpan} full=${fullSpan}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildOverlayPlan: labels inside inventory PNG; length off wood; smaller font (#71)', () => {
|
||||||
|
const { W, H, bitset, bbox } = fringeTipFixture();
|
||||||
|
const scale = 10;
|
||||||
|
const wood = maskWoodAabb(bitset, W, H);
|
||||||
|
// Tight crop so axis-left width labels would clip without clamping
|
||||||
|
const slabBounds = {
|
||||||
|
x: wood.minX - 4,
|
||||||
|
y: wood.minY - 4,
|
||||||
|
w: wood.maxX - wood.minX + 8,
|
||||||
|
h: wood.maxY - wood.minY + 8,
|
||||||
|
};
|
||||||
|
const outW = 600, outH = 800;
|
||||||
|
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, outW, outH);
|
||||||
|
const plan = buildOverlayPlan({
|
||||||
|
bitset,
|
||||||
|
widthPx: W,
|
||||||
|
heightPx: H,
|
||||||
|
bbox,
|
||||||
|
scale,
|
||||||
|
lengthIn: 32,
|
||||||
|
lengthLabel: '32"',
|
||||||
|
mapToInventory: inv.mapToInventory,
|
||||||
|
inventoryW: outW,
|
||||||
|
inventoryH: outH,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Font: one consistent smaller size than prior max(22, scale*1.0)
|
||||||
|
const priorFont = Math.max(22, Math.round(scale * 1.0));
|
||||||
|
assert.ok(plan.fontPx < priorFont, `fontPx ${plan.fontPx} must be < prior ${priorFont}`);
|
||||||
|
assert.ok(plan.fontPx >= 12, `fontPx ${plan.fontPx} still readable`);
|
||||||
|
|
||||||
|
// Wood AABB in inventory
|
||||||
|
let mMinX = Infinity, mMaxX = -Infinity, mMinY = Infinity, mMaxY = -Infinity;
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
if (!bitset[y * W + x]) continue;
|
||||||
|
const [mx, my] = inv.mapToInventory(x, y);
|
||||||
|
if (mx < mMinX) mMinX = mx;
|
||||||
|
if (mx > mMaxX) mMaxX = mx;
|
||||||
|
if (my < mMinY) mMinY = my;
|
||||||
|
if (my > mMaxY) mMaxY = my;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length label axis-right off wood
|
||||||
|
const lenLabel = plan.length.labelAt;
|
||||||
|
assert.ok(
|
||||||
|
lenLabel[0] > mMaxX + 2 || lenLabel[1] < mMinY - 2 || lenLabel[1] > mMaxY + 2,
|
||||||
|
`length label (${lenLabel[0].toFixed(0)},${lenLabel[1].toFixed(0)}) must be off wood AABB`,
|
||||||
|
);
|
||||||
|
// Glyph margin: keep anchors inside canvas
|
||||||
|
const margin = Math.max(8, plan.fontPx * 0.6);
|
||||||
|
assert.ok(lenLabel[0] >= margin && lenLabel[0] <= outW - margin, `length label x in canvas`);
|
||||||
|
assert.ok(lenLabel[1] >= margin && lenLabel[1] <= outH - margin, `length label y in canvas`);
|
||||||
|
|
||||||
|
for (const w of plan.widths) {
|
||||||
|
// Width labels axis-left off wood
|
||||||
|
assert.ok(
|
||||||
|
w.labelAt[0] < mMinX - 1 || w.labelAt[1] < mMinY - 1 || w.labelAt[1] > mMaxY + 1,
|
||||||
|
`width label ${w.label} must be off wood`,
|
||||||
|
);
|
||||||
|
// Clamped inside inventory PNG (anchor + estimated glyph extent toward axis-left)
|
||||||
|
const glyphPad = plan.fontPx * Math.max(2, w.label.length * 0.55);
|
||||||
|
assert.ok(
|
||||||
|
w.labelAt[0] >= margin && w.labelAt[0] <= outW - margin,
|
||||||
|
`width label x=${w.labelAt[0].toFixed(0)} must be inside canvas [0,${outW}]`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
w.labelAt[1] >= margin && w.labelAt[1] <= outH - margin,
|
||||||
|
`width label y=${w.labelAt[1].toFixed(0)} must be inside canvas [0,${outH}]`,
|
||||||
|
);
|
||||||
|
// textAlign right grows left — left extent of glyphs must stay ≥ 0
|
||||||
|
assert.ok(
|
||||||
|
w.labelAt[0] - glyphPad >= -1,
|
||||||
|
`width label glyphs must not clip left edge (anchor=${w.labelAt[0].toFixed(0)}, pad=${glyphPad.toFixed(0)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- Tapered live-edge tips must not be dropped (#73) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-edge taper: body max width ~100px; tips taper to ~20% of max (~20px).
|
||||||
|
* Old BODY_WIDTH_FRAC=0.32 dropped tip bins; length dim under-reached wood ends.
|
||||||
|
*/
|
||||||
|
function taperedTipFixture() {
|
||||||
|
const W = 240, H = 500;
|
||||||
|
const bitset = new Uint8Array(W * H);
|
||||||
|
const y0 = 40, y1 = 460;
|
||||||
|
const maxHalf = 50; // body half-width → ~100px
|
||||||
|
for (let y = y0; y <= y1; y++) {
|
||||||
|
const t = (y - y0) / (y1 - y0); // 0..1 along length
|
||||||
|
// Cosine taper: tips at ~20% of max width, mid at full
|
||||||
|
const tipFrac = 0.20;
|
||||||
|
const envelope = tipFrac + (1 - tipFrac) * Math.sin(Math.PI * t);
|
||||||
|
const half = Math.max(2, Math.round(maxHalf * envelope));
|
||||||
|
const cx = 120;
|
||||||
|
for (let x = cx - half; x <= cx + half; x++) bitset[y * W + x] = 1;
|
||||||
|
}
|
||||||
|
const ang = (5 * Math.PI) / 180;
|
||||||
|
const ux0 = Math.sin(ang);
|
||||||
|
const uy0 = Math.cos(ang);
|
||||||
|
const rx0 = -uy0;
|
||||||
|
const ry0 = ux0;
|
||||||
|
const cx = 120, cy = 250, halfL = 220, halfW = 70;
|
||||||
|
const corners: [number, number][] = [
|
||||||
|
[cx + ux0 * halfL + rx0 * halfW, cy + uy0 * halfL + ry0 * halfW],
|
||||||
|
[cx + ux0 * halfL - rx0 * halfW, cy + uy0 * halfL - ry0 * halfW],
|
||||||
|
[cx - ux0 * halfL - rx0 * halfW, cy - uy0 * halfL - ry0 * halfW],
|
||||||
|
[cx - ux0 * halfL + rx0 * halfW, cy - uy0 * halfL + ry0 * halfW],
|
||||||
|
];
|
||||||
|
const bbox = { corners, longSidePx: halfL * 2, angleDeg: 90 - 5 };
|
||||||
|
return { W, H, bitset, bbox, y0, y1, tipFrac: 0.20, maxHalf, ux0, uy0, cx, cy };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥ tip span − small inset (#73)', () => {
|
||||||
|
const { W, H, bitset, bbox, y0, y1, tipFrac, maxHalf, ux0, uy0, cx, cy } = taperedTipFixture();
|
||||||
|
// Sanity: tip bins are ~15–25% of max width (would fail old 0.32 thresh)
|
||||||
|
const tipHalf = Math.max(2, Math.round(maxHalf * tipFrac));
|
||||||
|
const tipWidth = tipHalf * 2 + 1;
|
||||||
|
const bodyWidth = maxHalf * 2 + 1;
|
||||||
|
const tipRatio = tipWidth / bodyWidth;
|
||||||
|
assert.ok(tipRatio >= 0.15 && tipRatio <= 0.25, `fixture tip ratio ${tipRatio.toFixed(3)} in [0.15,0.25]`);
|
||||||
|
assert.ok(tipRatio < 0.32, `fixture tip ratio ${tipRatio.toFixed(3)} must be < old 0.32 thresh`);
|
||||||
|
|
||||||
|
const scale = 10;
|
||||||
|
const wood = maskWoodAabb(bitset, W, H);
|
||||||
|
const slabBounds = {
|
||||||
|
x: wood.minX - 8,
|
||||||
|
y: wood.minY - 8,
|
||||||
|
w: wood.maxX - wood.minX + 16,
|
||||||
|
h: wood.maxY - wood.minY + 16,
|
||||||
|
};
|
||||||
|
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, 900, 1200);
|
||||||
|
const plan = buildOverlayPlan({
|
||||||
|
bitset,
|
||||||
|
widthPx: W,
|
||||||
|
heightPx: H,
|
||||||
|
bbox,
|
||||||
|
scale,
|
||||||
|
lengthIn: 42,
|
||||||
|
lengthLabel: '42"',
|
||||||
|
mapToInventory: inv.mapToInventory,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Width-aware tip stations with current BODY_WIDTH_FRAC
|
||||||
|
const { start: aa, end: bb } = axisEndpoints(bbox);
|
||||||
|
const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1;
|
||||||
|
const ux = ex / elen, uy = ey / elen;
|
||||||
|
const cProj = cx * ux + cy * uy;
|
||||||
|
const rx = -uy, ry = ux;
|
||||||
|
const binMin = new Map<number, number>();
|
||||||
|
const binMax = new Map<number, number>();
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
if (!bitset[y * W + 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC);
|
||||||
|
widths.sort((a, b) => a.bin - b.bin);
|
||||||
|
let tipLo = widths[0].bin, 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; }
|
||||||
|
}
|
||||||
|
// Tip bins at the axial extremes must survive the threshold (not dropped)
|
||||||
|
const firstBin = widths[0];
|
||||||
|
const lastBin = widths[widths.length - 1];
|
||||||
|
assert.ok(
|
||||||
|
firstBin.width >= thresh || tipLo <= firstBin.bin + 3,
|
||||||
|
`low tip bin width=${firstBin.width} vs thresh=${thresh.toFixed(1)} must be kept (tipLo=${tipLo} first=${firstBin.bin})`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
lastBin.width >= thresh || tipHi >= lastBin.bin - 3,
|
||||||
|
`high tip bin width=${lastBin.width} vs thresh=${thresh.toFixed(1)} must be kept (tipHi=${tipHi} last=${lastBin.bin})`,
|
||||||
|
);
|
||||||
|
// Axial tip span must reach near the physical wood ends (y0/y1)
|
||||||
|
const tipStart: [number, number] = [cx + ux * (tipLo - cProj), cy + uy * (tipLo - cProj)];
|
||||||
|
const tipEnd: [number, number] = [cx + ux * (tipHi - cProj), cy + uy * (tipHi - cProj)];
|
||||||
|
const oriented = pickBottomTowardTop(tipStart, tipEnd);
|
||||||
|
const mapBot = inv.mapToInventory(oriented.bottom[0], oriented.bottom[1]);
|
||||||
|
const mapTop = inv.mapToInventory(oriented.top[0], oriented.top[1]);
|
||||||
|
const tipAxial = Math.hypot(mapTop[0] - mapBot[0], mapTop[1] - mapBot[1]);
|
||||||
|
const lenAxial = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]);
|
||||||
|
|
||||||
|
// Length must reach tip stations minus only a small axial inset (tighten old −24px)
|
||||||
|
assert.ok(
|
||||||
|
lenAxial <= tipAxial + 2 && lenAxial >= tipAxial - 8,
|
||||||
|
`length span ${lenAxial.toFixed(1)} must match tip span ${tipAxial.toFixed(1)} within small inset (#73)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prove old 0.32 thresh would have under-reached (fixture is meaningful)
|
||||||
|
const oldThresh = Math.max(4, maxWidth * 0.32);
|
||||||
|
let oldLo = widths[0].bin, oldHi = widths[widths.length - 1].bin;
|
||||||
|
for (const { bin, width } of widths) {
|
||||||
|
if (width >= oldThresh) { oldLo = bin; break; }
|
||||||
|
}
|
||||||
|
for (let i = widths.length - 1; i >= 0; i--) {
|
||||||
|
if (widths[i].width >= oldThresh) { oldHi = widths[i].bin; break; }
|
||||||
|
}
|
||||||
|
assert.ok(
|
||||||
|
(tipHi - tipLo) > (oldHi - oldLo) + 10,
|
||||||
|
`new tip span ${tipHi - tipLo} must exceed old-0.32 span ${oldHi - oldLo} (taper kept)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Still no overshoot past wood AABB along axis (keep #69/#71)
|
||||||
|
const woodProjs: number[] = [];
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
if (!bitset[y * W + x]) continue;
|
||||||
|
woodProjs.push(x * ux + y * uy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
woodProjs.sort((a, b) => a - b);
|
||||||
|
const woodSpan = woodProjs[woodProjs.length - 1] - woodProjs[0];
|
||||||
|
const tipSpanSrc = tipHi - tipLo;
|
||||||
|
assert.ok(tipSpanSrc <= woodSpan + 2, `tips must not overshoot wood (${tipSpanSrc} vs ${woodSpan})`);
|
||||||
|
});
|
||||||
|
|||||||
+239
-33
@@ -45,6 +45,8 @@ export interface OverlayWidthMark {
|
|||||||
center: [number, number];
|
center: [number, number];
|
||||||
left: [number, number];
|
left: [number, number];
|
||||||
right: [number, number];
|
right: [number, number];
|
||||||
|
/** Axis-left of the slab, outside the wood edge (opposite of length dim). */
|
||||||
|
labelAt: [number, number];
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +76,75 @@ export interface OverlayPlanInput {
|
|||||||
lengthLabel: string;
|
lengthLabel: string;
|
||||||
/** Map source px → inventory PNG px (identity OK for unit tests). */
|
/** Map source px → inventory PNG px (identity OK for unit tests). */
|
||||||
mapToInventory: (x: number, y: number) => [number, number];
|
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(
|
function maskAxisSegment(
|
||||||
@@ -81,7 +152,16 @@ function maskAxisSegment(
|
|||||||
w: number,
|
w: number,
|
||||||
h: number,
|
h: number,
|
||||||
bbox: BBox,
|
bbox: BBox,
|
||||||
): { start: [number, number]; end: [number, number]; ux: number; uy: number; span: number } {
|
): {
|
||||||
|
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 { start: a, end: b } = axisEndpoints(bbox);
|
||||||
const ex = b[0] - a[0];
|
const ex = b[0] - a[0];
|
||||||
const ey = b[1] - a[1];
|
const ey = b[1] - a[1];
|
||||||
@@ -91,21 +171,56 @@ function maskAxisSegment(
|
|||||||
const cx = (bbox.corners[0][0] + bbox.corners[1][0] + bbox.corners[2][0] + bbox.corners[3][0]) / 4;
|
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 cy = (bbox.corners[0][1] + bbox.corners[1][1] + bbox.corners[2][1] + bbox.corners[3][1]) / 4;
|
||||||
const cProj = cx * ux + cy * uy;
|
const cProj = cx * ux + cy * uy;
|
||||||
let minP = Infinity;
|
// Width-aware tips (#71). Fall back to robust percentiles if profile empty.
|
||||||
let maxP = -Infinity;
|
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++) {
|
for (let y = 0; y < h; y++) {
|
||||||
const row = y * w;
|
const row = y * w;
|
||||||
for (let x = 0; x < w; x++) {
|
for (let x = 0; x < w; x++) {
|
||||||
if (!bitset[row + x]) continue;
|
if (!bitset[row + x]) continue;
|
||||||
const p = x * ux + y * uy;
|
const p = x * ux + y * uy;
|
||||||
if (p < minP) minP = p;
|
const across = Math.abs((x - cx) * -uy + (y - cy) * ux);
|
||||||
if (p > maxP) maxP = p;
|
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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (maxP < minP) return { start: a, end: b, ux, uy, span: 0 };
|
|
||||||
return {
|
return {
|
||||||
start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)],
|
start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)],
|
||||||
end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)],
|
end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)],
|
||||||
|
tipStart,
|
||||||
|
tipEnd,
|
||||||
ux,
|
ux,
|
||||||
uy,
|
uy,
|
||||||
span: maxP - minP,
|
span: maxP - minP,
|
||||||
@@ -151,7 +266,10 @@ function fmtInches(inches: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||||
const { bitset, widthPx, heightPx, bbox, scale, lengthIn, lengthLabel, mapToInventory } = input;
|
const {
|
||||||
|
bitset, widthPx, heightPx, bbox, scale, lengthIn, lengthLabel, mapToInventory,
|
||||||
|
inventoryW, inventoryH,
|
||||||
|
} = input;
|
||||||
const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox);
|
const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox);
|
||||||
// Re-orient span ends so EXIF bottom establishes station origin; axis still
|
// 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).
|
// lies on the approved M1 direction (seg ux/uy flipped only when needed).
|
||||||
@@ -159,6 +277,22 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
|||||||
const { bottom, top, ux, uy } = oriented;
|
const { bottom, top, ux, uy } = oriented;
|
||||||
const [rx, ry] = axisRightUnit(ux, uy);
|
const [rx, ry] = axisRightUnit(ux, uy);
|
||||||
|
|
||||||
|
// 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 stations = overlayWidthStations(lengthIn);
|
||||||
const widths: OverlayWidthMark[] = [];
|
const widths: OverlayWidthMark[] = [];
|
||||||
for (const stationIn of stations) {
|
for (const stationIn of stations) {
|
||||||
@@ -167,15 +301,26 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
|||||||
const oy = bottom[1] + uy * t;
|
const oy = bottom[1] + uy * t;
|
||||||
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
|
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
|
||||||
if (!extent) continue;
|
if (!extent) continue;
|
||||||
// left = opposite of axis-right; right = axis-right tip on slab edge
|
// left/right on mask edges, then inset in inventory space for stroke/arrowhead
|
||||||
const leftSrc: [number, number] = [ox + rx * extent.minP, oy + ry * extent.minP];
|
const leftMapped = mapToInventory(ox + rx * extent.minP, oy + ry * extent.minP);
|
||||||
const rightSrc: [number, number] = [ox + rx * extent.maxP, oy + ry * extent.maxP];
|
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;
|
const widthIn = (extent.maxP - extent.minP + 1) / scale;
|
||||||
widths.push({
|
widths.push({
|
||||||
stationIn,
|
stationIn,
|
||||||
center: mapToInventory(ox, oy),
|
center: mapToInventory(ox, oy),
|
||||||
left: mapToInventory(leftSrc[0], leftSrc[1]),
|
left,
|
||||||
right: mapToInventory(rightSrc[0], rightSrc[1]),
|
right,
|
||||||
|
labelAt: mapToInventory(labelSrcW[0], labelSrcW[1]),
|
||||||
label: fmtInches(widthIn),
|
label: fmtInches(widthIn),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -190,26 +335,67 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
|||||||
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
|
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
|
||||||
if (extent && extent.maxP > maxRight) maxRight = extent.maxP;
|
if (extent && extent.maxP > maxRight) maxRight = extent.maxP;
|
||||||
}
|
}
|
||||||
const pad = Math.max(16, scale * 0.75);
|
|
||||||
const outward = maxRight + pad;
|
const outward = maxRight + pad;
|
||||||
const aSrc: [number, number] = [bottom[0] + rx * outward, bottom[1] + ry * outward];
|
// Classic dimensioning (#70 + #71): width-aware centerline tip stations offset by
|
||||||
const bSrc: [number, number] = [top[0] + rx * outward, top[1] + ry * outward];
|
// pure axis-right (rx,ry)*outward. Length stays || approved (ux,uy); no screen-slide.
|
||||||
const midSrc: [number, number] = [(aSrc[0] + bSrc[0]) / 2, (aSrc[1] + bSrc[1]) / 2];
|
// Axial inset (inventory space) so arrowhead tips sit on footprint, not past.
|
||||||
// Nudge label further outward a bit
|
const aMapped = mapToInventory(bottom[0] + rx * outward, bottom[1] + ry * outward);
|
||||||
const labelSrc: [number, number] = [midSrc[0] + rx * (scale * 0.4), midSrc[1] + ry * (scale * 0.4)];
|
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 fontPx = Math.max(28, Math.round(scale * 1.2));
|
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 {
|
return {
|
||||||
stroke: '#000000',
|
stroke: '#000000',
|
||||||
fontPx,
|
fontPx,
|
||||||
length: {
|
length: lengthMark,
|
||||||
a: mapToInventory(aSrc[0], aSrc[1]),
|
|
||||||
b: mapToInventory(bSrc[0], bSrc[1]),
|
|
||||||
labelAt: mapToInventory(labelSrc[0], labelSrc[1]),
|
|
||||||
label: lengthLabel,
|
|
||||||
outside: true,
|
|
||||||
},
|
|
||||||
widths,
|
widths,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -223,8 +409,9 @@ export function drawOverlayPlan(
|
|||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.strokeStyle = stroke;
|
ctx.strokeStyle = stroke;
|
||||||
ctx.fillStyle = stroke;
|
ctx.fillStyle = stroke;
|
||||||
ctx.lineWidth = Math.max(3, Math.round(fontPx / 10));
|
ctx.lineWidth = Math.max(2, Math.round(fontPx / 10));
|
||||||
ctx.lineCap = 'round';
|
// Butt caps: round caps extend past endpoints and overshoot wood edges (#71).
|
||||||
|
ctx.lineCap = 'butt';
|
||||||
ctx.font = `bold ${fontPx}px sans-serif`;
|
ctx.font = `bold ${fontPx}px sans-serif`;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
@@ -235,7 +422,7 @@ export function drawOverlayPlan(
|
|||||||
const len = Math.hypot(dx, dy) || 1;
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
const ux = dx / len;
|
const ux = dx / len;
|
||||||
const uy = dy / len;
|
const uy = dy / len;
|
||||||
const head = Math.max(10, fontPx * 0.35);
|
const head = Math.max(7, fontPx * 0.32);
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(from[0], from[1]);
|
ctx.moveTo(from[0], from[1]);
|
||||||
ctx.lineTo(to[0], to[1]);
|
ctx.lineTo(to[0], to[1]);
|
||||||
@@ -256,14 +443,31 @@ export function drawOverlayPlan(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Length
|
// Length — grow text further axis-right of the dim line (away from wood)
|
||||||
arrow(plan.length.a, plan.length.b);
|
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]);
|
ctx.fillText(plan.length.label, plan.length.labelAt[0], plan.length.labelAt[1]);
|
||||||
|
|
||||||
// Widths
|
// Widths — grow text away from wood (labelAt is axis-left of left tip)
|
||||||
for (const w of plan.widths) {
|
for (const w of plan.widths) {
|
||||||
arrow(w.left, w.right);
|
arrow(w.left, w.right);
|
||||||
ctx.fillText(w.label, w.center[0], w.center[1]);
|
// 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();
|
ctx.restore();
|
||||||
@@ -282,7 +486,7 @@ export function inventorySourceMap(
|
|||||||
outW: number,
|
outW: number,
|
||||||
outH: number,
|
outH: number,
|
||||||
): InventoryMap {
|
): InventoryMap {
|
||||||
const FILL = 0.8;
|
const FILL = 0.9;
|
||||||
const x0 = Math.max(0, Math.round(slabBounds.x));
|
const x0 = Math.max(0, Math.round(slabBounds.x));
|
||||||
const y0 = Math.max(0, Math.round(slabBounds.y));
|
const y0 = Math.max(0, Math.round(slabBounds.y));
|
||||||
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
|
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
|
||||||
@@ -337,6 +541,8 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
|
|||||||
lengthIn: input.lengthIn,
|
lengthIn: input.lengthIn,
|
||||||
lengthLabel: input.lengthLabel,
|
lengthLabel: input.lengthLabel,
|
||||||
mapToInventory,
|
mapToInventory,
|
||||||
|
inventoryW: outW,
|
||||||
|
inventoryH: outH,
|
||||||
});
|
});
|
||||||
drawOverlayPlan(ctx, plan);
|
drawOverlayPlan(ctx, plan);
|
||||||
return await canvas.convertToBlob({ type: 'image/png' });
|
return await canvas.convertToBlob({ type: 'image/png' });
|
||||||
|
|||||||
@@ -1,31 +1,13 @@
|
|||||||
import { minAreaRect, slabBounds } from '../geometry/rect';
|
import { minAreaRect, slabBounds } from '../geometry/rect';
|
||||||
import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types';
|
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 bitmap: ImageBitmap | null = null;
|
||||||
let data: Uint8ClampedArray | null = null;
|
let data: Uint8ClampedArray | null = null;
|
||||||
let w = 0, h = 0;
|
let w = 0, h = 0;
|
||||||
let current = 0; // highest job id issued by main; older replies are dropped
|
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 {
|
function runMask(params: MaskParams, id: number): WorkerOut {
|
||||||
if (!data) return { id, kind: 'failed', reason: 'no source decoded' };
|
if (!data) return { id, kind: 'failed', reason: 'no source decoded' };
|
||||||
let sheet = params.sheet;
|
let sheet = params.sheet;
|
||||||
@@ -36,7 +18,7 @@ function runMask(params: MaskParams, id: number): WorkerOut {
|
|||||||
const i = (y * w + x) * 4;
|
const i = (y * w + x) * 4;
|
||||||
samples.push({ r: data[i], g: data[i + 1], b: data[i + 2] });
|
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 keep = sheet === 'green' ? greenKeep : blackKeep;
|
||||||
const s = Math.max(0, Math.min(100, params.sensitivity));
|
const s = Math.max(0, Math.min(100, params.sensitivity));
|
||||||
@@ -69,7 +51,7 @@ self.onmessage = (e: MessageEvent<WorkerIn>) => {
|
|||||||
}
|
}
|
||||||
data = null;
|
data = null;
|
||||||
w = h = 0;
|
w = h = 0;
|
||||||
createImageBitmap(msg.blob)
|
decodeOrientedBitmap(msg.blob)
|
||||||
.then((bmp) => {
|
.then((bmp) => {
|
||||||
w = bmp.width;
|
w = bmp.width;
|
||||||
h = bmp.height;
|
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