Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abef27cf2a | |||
| 03d3fcf460 | |||
| 037a4b870a | |||
| b8e69b5239 | |||
| 495f179c2f | |||
| b386ab9670 | |||
| 2be2e7d615 | |||
| 6d182db81b | |||
| dc19cffcc0 | |||
| 05447c92fc | |||
| 415158d4d3 |
@@ -1,20 +1,35 @@
|
||||
# Host
|
||||
|
||||
## Overnight Milestone 2 Capture (UAT)
|
||||
## Overnight Capture UAT (primary — when Tailscale is up)
|
||||
|
||||
- Host: `ubudual5060` / Tailscale `100.64.0.9`
|
||||
- Container: `slabuploader-m2-overnight` (image `slabuploader-m2:overnight`)
|
||||
- Container: `slabuploader-m2-overnight` (image `slabuploader-m2:overnight`) — or salvage rebuild of the same role
|
||||
- Port: **10881** → 80
|
||||
- URL: `http://100.64.0.9:10881`
|
||||
- Replaces prior overnight container `slabuploader-m1-overnight` (same port).
|
||||
|
||||
## Ty local Node/SvelteKit (do not overwrite)
|
||||
## Fallback UAT — Grok Bot computer (when Tailscale to 5060 is down)
|
||||
|
||||
Use this path for salvage / Capture UAT until the box can reach `100.64.0.9` again.
|
||||
|
||||
- Host: Grok Bot computer (slab-m1 box)
|
||||
- Serve: static Capture build (`frontend` → `npm run build`, then `vite preview` or equivalent static server)
|
||||
- Port: **18181** (chosen so it never collides with Ty local on 5060)
|
||||
- Bind: `0.0.0.0:18181`
|
||||
- URL (from this box): `http://127.0.0.1:18181` — Ty reaches it via the bot computer / whatever path the app exposes; Tailscale IP of this box when mesh returns
|
||||
- Branch for salvage: `salvage-one-woo-draft` (cut from `m2-ds`)
|
||||
- Do **not** require Docker on the box for this fallback.
|
||||
|
||||
When Tailscale is restored, prefer primary overnight `:10881` again and stop advertising the fallback as the main UAT URL.
|
||||
|
||||
## Ty local Node/SvelteKit on 5060 (do not overwrite)
|
||||
|
||||
- Host: `ubudual5060` / `100.64.0.9`
|
||||
- Container: `slabuploader-m1`
|
||||
- Port: **18080** → 80 (inventory previously listed 10880; do not bind overnight there)
|
||||
- Port: **18080** → 80
|
||||
- URL: `http://100.64.0.9:18080`
|
||||
- **Never** recreate or remap this container for overnight UAT.
|
||||
- **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 Ty’s local port.
|
||||
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.
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import type { BBox } from './capture.types';
|
||||
import { fitDisplay } from './fitDisplay';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
|
||||
// Display-only: renders the photo fitted to device width (height capped) plus
|
||||
// one overlay layer. All math lives on source pixels, not this canvas.
|
||||
// Geometry must use the same EXIF-oriented bitmap space as the mask worker (#72).
|
||||
export let blob: Blob;
|
||||
export let maxH = 420;
|
||||
export let bbox: BBox | null = null;
|
||||
@@ -12,21 +14,12 @@
|
||||
export let maskW = 0;
|
||||
export let maskH = 0;
|
||||
|
||||
let url: string | null = null;
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
let stage: HTMLDivElement | undefined;
|
||||
let imgW = 0, imgH = 0; // source px, learned once per blob
|
||||
let imgW = 0, imgH = 0; // source px (oriented), learned once per blob
|
||||
let parentW = 0;
|
||||
|
||||
const setBlobUrl = (b: Blob) => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
url = URL.createObjectURL(b);
|
||||
};
|
||||
|
||||
$: if (blob) {
|
||||
setBlobUrl(blob);
|
||||
imgW = 0;
|
||||
}
|
||||
let decodeGen = 0;
|
||||
let decoding = false;
|
||||
|
||||
const applyFit = () => {
|
||||
if (!canvas || !imgW) return;
|
||||
@@ -37,73 +30,83 @@
|
||||
canvas.style.height = `${fit.fitH}px`;
|
||||
};
|
||||
|
||||
const redraw = () => {
|
||||
if (!url || !canvas) return;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (!canvas) return;
|
||||
imgW = img.width;
|
||||
imgH = img.height;
|
||||
const paintOverlays = (ctx: CanvasRenderingContext2D) => {
|
||||
if (!canvas) return;
|
||||
if (maskBitset && maskW > 0 && maskH > 0) {
|
||||
const off = document.createElement('canvas');
|
||||
off.width = maskW;
|
||||
off.height = maskH;
|
||||
const octx = off.getContext('2d')!;
|
||||
const d = octx.createImageData(maskW, maskH);
|
||||
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.height = imgH;
|
||||
applyFit();
|
||||
ctx.drawImage(img, 0, 0);
|
||||
if (maskBitset && maskW > 0 && maskH > 0) {
|
||||
const off = document.createElement('canvas');
|
||||
off.width = maskW;
|
||||
off.height = maskH;
|
||||
const octx = off.getContext('2d')!;
|
||||
const d = octx.createImageData(maskW, maskH);
|
||||
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;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(bmp, 0, 0);
|
||||
paintOverlays(ctx);
|
||||
} finally {
|
||||
if (bmp) bmp.close();
|
||||
if (gen === decodeGen) decoding = false;
|
||||
}
|
||||
};
|
||||
|
||||
$: if (url || bbox || maskBitset) void tick().then(redraw);
|
||||
$: if (blob || bbox || maskBitset) void tick().then(redraw);
|
||||
|
||||
onMount(() => {
|
||||
if (stage) parentW = stage.clientWidth;
|
||||
@@ -119,12 +122,12 @@
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
decodeGen += 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="stage" bind:this={stage}>
|
||||
{#if url}
|
||||
{#if imgW > 0 || decoding || blob}
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
{:else}
|
||||
<div class="loading">Decoding photo…</div>
|
||||
|
||||
@@ -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 { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
||||
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
const BUSY_MS = 300;
|
||||
@@ -512,7 +513,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
if (state.k !== 'measure') return;
|
||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
||||
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
||||
const bitmap = await createImageBitmap(source.blob);
|
||||
const bitmap = await decodeOrientedBitmap(source.blob);
|
||||
const base = measure(
|
||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
||||
@@ -587,7 +588,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
widthPx: derived.widthPx,
|
||||
heightPx: derived.heightPx,
|
||||
};
|
||||
const siblingBitmap = await createImageBitmap(photo.blob);
|
||||
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
|
||||
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
||||
try {
|
||||
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';
|
||||
}
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
pickBottomTowardTop,
|
||||
axisRightUnit,
|
||||
buildOverlayPlan,
|
||||
inventorySourceMap,
|
||||
BODY_WIDTH_FRAC,
|
||||
type OverlayPlan,
|
||||
} from './overlay.ts';
|
||||
import { orientedRect, minAreaRect } from '../geometry/rect.ts';
|
||||
import { axisEndpoints } from '../measure/measure.ts';
|
||||
|
||||
// --- 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
|
||||
assert.equal(plan.stroke, '#000000');
|
||||
assert.ok(plan.fontPx >= 18, `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)', () => {
|
||||
@@ -270,7 +273,7 @@ test('buildOverlayPlan: width labels sit axis-left outside the slab (#67)', () =
|
||||
assert.ok(lenY < 50 - 4, `length dim stays axis-right outside top edge, got y=${lenY}`);
|
||||
});
|
||||
|
||||
test('buildOverlayPlan: font one step smaller than prior 28 / scale*1.2 (#67)', () => {
|
||||
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;
|
||||
@@ -286,9 +289,831 @@ test('buildOverlayPlan: font one step smaller than prior 28 / scale*1.2 (#67)',
|
||||
lengthLabel: '48"',
|
||||
mapToInventory: (x, y) => [x, y],
|
||||
});
|
||||
const oldFont = Math.max(28, Math.round(scale * 1.2));
|
||||
assert.ok(plan.fontPx < oldFont, `fontPx ${plan.fontPx} must be < prior ${oldFont}`);
|
||||
assert.ok(plan.fontPx >= 18, `fontPx ${plan.fontPx} still legible`);
|
||||
// Concrete contract: max(22, round(scale*1.0))
|
||||
assert.equal(plan.fontPx, Math.max(22, Math.round(scale * 1.0)));
|
||||
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})`);
|
||||
});
|
||||
|
||||
@@ -76,6 +76,75 @@ export interface OverlayPlanInput {
|
||||
lengthLabel: string;
|
||||
/** Map source px → inventory PNG px (identity OK for unit tests). */
|
||||
mapToInventory: (x: number, y: number) => [number, number];
|
||||
/** Inventory PNG size — used to clamp labels inside the canvas (#71). */
|
||||
inventoryW?: number;
|
||||
inventoryH?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Min across-width as a fraction of max bin width for an axial bin to count as a tip.
|
||||
* #71 used 0.32 (excluded fringe); #73 lowers to ~0.15 so tapered live-edge tips remain.
|
||||
*/
|
||||
export const BODY_WIDTH_FRAC = 0.15;
|
||||
|
||||
/**
|
||||
* Axial tip stations from across-width profile (#71/#73).
|
||||
* Thin fringe whiskers past the visible body survive 0.2%/99.8% percentiles;
|
||||
* require each axial bin to have meaningful across-width before it counts as a tip.
|
||||
*/
|
||||
function widthAwareAxialRange(
|
||||
bitset: Uint8Array,
|
||||
w: number,
|
||||
h: number,
|
||||
ux: number,
|
||||
uy: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
): { minP: number; maxP: number } | null {
|
||||
const rx = -uy;
|
||||
const ry = ux;
|
||||
const binMin = new Map<number, number>();
|
||||
const binMax = new Map<number, number>();
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
const along = x * ux + y * uy;
|
||||
const across = (x - cx) * rx + (y - cy) * ry;
|
||||
const bin = Math.round(along);
|
||||
const prevMin = binMin.get(bin);
|
||||
if (prevMin === undefined || across < prevMin) binMin.set(bin, across);
|
||||
const prevMax = binMax.get(bin);
|
||||
if (prevMax === undefined || across > prevMax) binMax.set(bin, across);
|
||||
}
|
||||
}
|
||||
if (binMin.size === 0) return null;
|
||||
let maxWidth = 0;
|
||||
const widths: { bin: number; width: number }[] = [];
|
||||
for (const [bin, minA] of binMin) {
|
||||
const maxA = binMax.get(bin)!;
|
||||
const width = maxA - minA + 1;
|
||||
widths.push({ bin, width });
|
||||
if (width > maxWidth) maxWidth = width;
|
||||
}
|
||||
// Body threshold: exclude ~10% fringe whiskers; keep 15–25% live-edge tapers (#73).
|
||||
const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC);
|
||||
widths.sort((a, b) => a.bin - b.bin);
|
||||
let tipLo = widths[0].bin;
|
||||
let tipHi = widths[widths.length - 1].bin;
|
||||
for (const { bin, width } of widths) {
|
||||
if (width >= thresh) {
|
||||
tipLo = bin;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let i = widths.length - 1; i >= 0; i--) {
|
||||
if (widths[i].width >= thresh) {
|
||||
tipHi = widths[i].bin;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { minP: tipLo, maxP: tipHi };
|
||||
}
|
||||
|
||||
function maskAxisSegment(
|
||||
@@ -83,7 +152,16 @@ function maskAxisSegment(
|
||||
w: number,
|
||||
h: number,
|
||||
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 ex = b[0] - a[0];
|
||||
const ey = b[1] - a[1];
|
||||
@@ -93,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 cy = (bbox.corners[0][1] + bbox.corners[1][1] + bbox.corners[2][1] + bbox.corners[3][1]) / 4;
|
||||
const cProj = cx * ux + cy * uy;
|
||||
let minP = Infinity;
|
||||
let maxP = -Infinity;
|
||||
// Width-aware tips (#71). Fall back to robust percentiles if profile empty.
|
||||
const aware = widthAwareAxialRange(bitset, w, h, ux, uy, cx, cy);
|
||||
let minP: number;
|
||||
let maxP: number;
|
||||
if (aware) {
|
||||
minP = aware.minP;
|
||||
maxP = aware.maxP;
|
||||
} else {
|
||||
const projs: number[] = [];
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
projs.push(x * ux + y * uy);
|
||||
}
|
||||
}
|
||||
if (projs.length === 0) return { start: a, end: b, tipStart: a, tipEnd: b, ux, uy, span: 0 };
|
||||
projs.sort((p, q) => p - q);
|
||||
const lo = Math.min(Math.floor(projs.length * 0.002), projs.length - 1);
|
||||
const hi = Math.max(Math.floor(projs.length * 0.998), lo);
|
||||
minP = projs[lo];
|
||||
maxP = projs[hi];
|
||||
}
|
||||
// Tip pixel at each station: closest to the axis centerline
|
||||
let tipStart: [number, number] = a;
|
||||
let tipEnd: [number, number] = b;
|
||||
let bestStart = Infinity;
|
||||
let bestEnd = Infinity;
|
||||
const stationTol = Math.max(1.5, (maxP - minP) * 0.001);
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
const p = x * ux + y * uy;
|
||||
if (p < minP) minP = p;
|
||||
if (p > maxP) maxP = p;
|
||||
const across = Math.abs((x - cx) * -uy + (y - cy) * ux);
|
||||
if (Math.abs(p - minP) <= stationTol && across < bestStart) {
|
||||
bestStart = across;
|
||||
tipStart = [x, y];
|
||||
}
|
||||
if (Math.abs(p - maxP) <= stationTol && across < bestEnd) {
|
||||
bestEnd = across;
|
||||
tipEnd = [x, y];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxP < minP) return { start: a, end: b, ux, uy, span: 0 };
|
||||
return {
|
||||
start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)],
|
||||
end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)],
|
||||
tipStart,
|
||||
tipEnd,
|
||||
ux,
|
||||
uy,
|
||||
span: maxP - minP,
|
||||
@@ -153,7 +266,10 @@ function fmtInches(inches: number): string {
|
||||
}
|
||||
|
||||
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);
|
||||
// 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).
|
||||
@@ -161,7 +277,22 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
const { bottom, top, ux, uy } = oriented;
|
||||
const [rx, ry] = axisRightUnit(ux, uy);
|
||||
|
||||
const pad = Math.max(16, scale * 0.75);
|
||||
// Consistent smaller readable font (#71). Cap by inventory size so high
|
||||
// source scale (≈80 px/in) does not dominate the PNG.
|
||||
const fontPx = (() => {
|
||||
if (inventoryW && inventoryH && inventoryW > 0 && inventoryH > 0) {
|
||||
return Math.max(12, Math.min(16, Math.round(Math.min(inventoryW, inventoryH) * 0.016)));
|
||||
}
|
||||
return Math.max(12, Math.min(16, Math.round(scale * 0.18)));
|
||||
})();
|
||||
// Inset for stroke + arrowhead so visual tips sit on/inside edges (#71).
|
||||
const lineW = Math.max(2, Math.round(fontPx / 10));
|
||||
const arrowHead = Math.max(7, fontPx * 0.32);
|
||||
// Width tips retain stroke/arrowhead protection. Length endpoints need only
|
||||
// half-stroke protection: larger arrowhead-derived inset visibly under-reached tips (#73).
|
||||
const widthEdgeInset = Math.max(lineW / 2 + 0.5, arrowHead * 0.15);
|
||||
const lengthEdgeInset = Math.max(0.5, lineW / 2);
|
||||
const pad = Math.max(10, Math.min(28, fontPx * 1.1));
|
||||
const stations = overlayWidthStations(lengthIn);
|
||||
const widths: OverlayWidthMark[] = [];
|
||||
for (const stationIn of stations) {
|
||||
@@ -170,18 +301,25 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
const oy = bottom[1] + uy * t;
|
||||
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
|
||||
if (!extent) continue;
|
||||
// left = opposite of axis-right; right = axis-right tip on slab edge
|
||||
const leftSrc: [number, number] = [ox + rx * extent.minP, oy + ry * extent.minP];
|
||||
const rightSrc: [number, number] = [ox + rx * extent.maxP, oy + ry * extent.maxP];
|
||||
// left/right on mask edges, then inset in inventory space for stroke/arrowhead
|
||||
const leftMapped = mapToInventory(ox + rx * extent.minP, oy + ry * extent.minP);
|
||||
const rightMapped = mapToInventory(ox + rx * extent.maxP, oy + ry * extent.maxP);
|
||||
const wdx = rightMapped[0] - leftMapped[0];
|
||||
const wdy = rightMapped[1] - leftMapped[1];
|
||||
const wlen = Math.hypot(wdx, wdy) || 1;
|
||||
const wux = wdx / wlen;
|
||||
const wuy = wdy / wlen;
|
||||
const left: [number, number] = [leftMapped[0] + wux * widthEdgeInset, leftMapped[1] + wuy * widthEdgeInset];
|
||||
const right: [number, number] = [rightMapped[0] - wux * widthEdgeInset, rightMapped[1] - wuy * widthEdgeInset];
|
||||
// Width number sits axis-left outside the slab (past left tip).
|
||||
const labelOff = extent.minP - pad - scale * 0.4;
|
||||
const labelOff = extent.minP - pad - fontPx * 0.85;
|
||||
const labelSrcW: [number, number] = [ox + rx * labelOff, oy + ry * labelOff];
|
||||
const widthIn = (extent.maxP - extent.minP + 1) / scale;
|
||||
widths.push({
|
||||
stationIn,
|
||||
center: mapToInventory(ox, oy),
|
||||
left: mapToInventory(leftSrc[0], leftSrc[1]),
|
||||
right: mapToInventory(rightSrc[0], rightSrc[1]),
|
||||
left,
|
||||
right,
|
||||
labelAt: mapToInventory(labelSrcW[0], labelSrcW[1]),
|
||||
label: fmtInches(widthIn),
|
||||
});
|
||||
@@ -198,24 +336,66 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
if (extent && extent.maxP > maxRight) maxRight = extent.maxP;
|
||||
}
|
||||
const outward = maxRight + pad;
|
||||
const aSrc: [number, number] = [bottom[0] + rx * outward, bottom[1] + ry * outward];
|
||||
const bSrc: [number, number] = [top[0] + rx * outward, top[1] + ry * outward];
|
||||
const midSrc: [number, number] = [(aSrc[0] + bSrc[0]) / 2, (aSrc[1] + bSrc[1]) / 2];
|
||||
// Nudge label further outward a bit
|
||||
const labelSrc: [number, number] = [midSrc[0] + rx * (scale * 0.4), midSrc[1] + ry * (scale * 0.4)];
|
||||
// Classic dimensioning (#70 + #71): width-aware centerline tip stations offset by
|
||||
// pure axis-right (rx,ry)*outward. Length stays || approved (ux,uy); no screen-slide.
|
||||
// Axial inset (inventory space) so arrowhead tips sit on footprint, not past.
|
||||
const aMapped = mapToInventory(bottom[0] + rx * outward, bottom[1] + ry * outward);
|
||||
const bMapped = mapToInventory(top[0] + rx * outward, top[1] + ry * outward);
|
||||
const ldx0 = bMapped[0] - aMapped[0];
|
||||
const ldy0 = bMapped[1] - aMapped[1];
|
||||
const llen0 = Math.hypot(ldx0, ldy0) || 1;
|
||||
const lux = ldx0 / llen0;
|
||||
const luy = ldy0 / llen0;
|
||||
const aInv: [number, number] = [aMapped[0] + lux * lengthEdgeInset, aMapped[1] + luy * lengthEdgeInset];
|
||||
const bInv: [number, number] = [bMapped[0] - lux * lengthEdgeInset, bMapped[1] - luy * lengthEdgeInset];
|
||||
const midInv: [number, number] = [(aInv[0] + bInv[0]) / 2, (aInv[1] + bInv[1]) / 2];
|
||||
// Length label further axis-right of dim line so glyphs stay off the wood (#71).
|
||||
const mapOrigin = mapToInventory(0, 0);
|
||||
const mapR = mapToInventory(rx, ry);
|
||||
const irx = mapR[0] - mapOrigin[0];
|
||||
const iry = mapR[1] - mapOrigin[1];
|
||||
const irlen = Math.hypot(irx, iry) || 1;
|
||||
const nrx = irx / irlen;
|
||||
const nry = iry / irlen;
|
||||
const labelAt: [number, number] = [
|
||||
midInv[0] + nrx * (fontPx * 0.85 + 6),
|
||||
midInv[1] + nry * (fontPx * 0.85 + 6),
|
||||
];
|
||||
|
||||
const fontPx = Math.max(22, Math.round(scale * 1.0));
|
||||
const lengthMark: OverlayLengthMark = {
|
||||
a: aInv,
|
||||
b: bInv,
|
||||
labelAt,
|
||||
label: lengthLabel,
|
||||
outside: true,
|
||||
};
|
||||
|
||||
// Clamp all labels inside inventory PNG so nothing clips (#71).
|
||||
if (inventoryW && inventoryH && inventoryW > 0 && inventoryH > 0) {
|
||||
const margin = Math.max(8, fontPx * 0.65);
|
||||
const clamp = (pt: [number, number], growLeft: boolean, text: string): [number, number] => {
|
||||
let [x, y] = pt;
|
||||
const glyphW = Math.max(fontPx * 1.2, text.length * fontPx * 0.55);
|
||||
if (growLeft) {
|
||||
// textAlign right — glyphs extend left of anchor
|
||||
x = Math.max(margin + glyphW, Math.min(inventoryW - margin, x));
|
||||
} else {
|
||||
x = Math.max(margin, Math.min(inventoryW - margin, x));
|
||||
}
|
||||
y = Math.max(margin, Math.min(inventoryH - margin, y));
|
||||
return [x, y];
|
||||
};
|
||||
lengthMark.labelAt = clamp(lengthMark.labelAt, false, lengthLabel);
|
||||
for (const w of widths) {
|
||||
const growLeft = w.labelAt[0] <= w.center[0];
|
||||
w.labelAt = clamp(w.labelAt, growLeft, w.label);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stroke: '#000000',
|
||||
fontPx,
|
||||
length: {
|
||||
a: mapToInventory(aSrc[0], aSrc[1]),
|
||||
b: mapToInventory(bSrc[0], bSrc[1]),
|
||||
labelAt: mapToInventory(labelSrc[0], labelSrc[1]),
|
||||
label: lengthLabel,
|
||||
outside: true,
|
||||
},
|
||||
length: lengthMark,
|
||||
widths,
|
||||
};
|
||||
}
|
||||
@@ -229,8 +409,9 @@ export function drawOverlayPlan(
|
||||
ctx.save();
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.fillStyle = stroke;
|
||||
ctx.lineWidth = Math.max(3, Math.round(fontPx / 10));
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineWidth = Math.max(2, Math.round(fontPx / 10));
|
||||
// Butt caps: round caps extend past endpoints and overshoot wood edges (#71).
|
||||
ctx.lineCap = 'butt';
|
||||
ctx.font = `bold ${fontPx}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
@@ -241,7 +422,7 @@ export function drawOverlayPlan(
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / len;
|
||||
const uy = dy / len;
|
||||
const head = Math.max(10, fontPx * 0.35);
|
||||
const head = Math.max(7, fontPx * 0.32);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from[0], from[1]);
|
||||
ctx.lineTo(to[0], to[1]);
|
||||
@@ -262,13 +443,30 @@ export function drawOverlayPlan(
|
||||
}
|
||||
};
|
||||
|
||||
// Length
|
||||
// Length — grow text further axis-right of the dim line (away from wood)
|
||||
arrow(plan.length.a, plan.length.b);
|
||||
{
|
||||
const [lax, lay] = plan.length.a;
|
||||
const [lbx, lby] = plan.length.b;
|
||||
const midX = (lax + lbx) / 2;
|
||||
const midY = (lay + lby) / 2;
|
||||
const outwardX = plan.length.labelAt[0] - midX;
|
||||
const outwardY = plan.length.labelAt[1] - midY;
|
||||
// Prefer left/right align so glyphs extend away from wood when offset is mostly ±x
|
||||
if (Math.abs(outwardX) >= Math.abs(outwardY)) {
|
||||
ctx.textAlign = outwardX >= 0 ? 'left' : 'right';
|
||||
} else {
|
||||
ctx.textAlign = 'center';
|
||||
}
|
||||
}
|
||||
ctx.fillText(plan.length.label, plan.length.labelAt[0], plan.length.labelAt[1]);
|
||||
|
||||
// Widths
|
||||
// Widths — grow text away from wood (labelAt is axis-left of left tip)
|
||||
for (const w of plan.widths) {
|
||||
arrow(w.left, w.right);
|
||||
// labelAt is on the axis-left side of the station; align so glyphs extend further out
|
||||
const towardLeft = w.labelAt[0] <= w.center[0];
|
||||
ctx.textAlign = towardLeft ? 'right' : 'left';
|
||||
ctx.fillText(w.label, w.labelAt[0], w.labelAt[1]);
|
||||
}
|
||||
|
||||
@@ -343,6 +541,8 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
|
||||
lengthIn: input.lengthIn,
|
||||
lengthLabel: input.lengthLabel,
|
||||
mapToInventory,
|
||||
inventoryW: outW,
|
||||
inventoryH: outH,
|
||||
});
|
||||
drawOverlayPlan(ctx, plan);
|
||||
return await canvas.convertToBlob({ type: 'image/png' });
|
||||
|
||||
@@ -1,31 +1,13 @@
|
||||
import { minAreaRect, slabBounds } from '../geometry/rect';
|
||||
import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { greenKeep, blackKeep, autoPickSheet } from '../mask/sheetKeep';
|
||||
|
||||
let bitmap: ImageBitmap | null = null;
|
||||
let data: Uint8ClampedArray | null = null;
|
||||
let w = 0, h = 0;
|
||||
let current = 0; // highest job id issued by main; older replies are dropped
|
||||
|
||||
// Keep slab pixels; remove the sheet. Higher sensitivity widens the sheet match.
|
||||
const greenKeep = (r: number, g: number, b: number, s: number): boolean => {
|
||||
const t = s / 100;
|
||||
const isSheet = g - Math.max(r, b) > 10 + 35 * t;
|
||||
return !isSheet;
|
||||
};
|
||||
const blackKeep = (r: number, g: number, b: number, s: number): boolean => {
|
||||
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
const isSheet = lum < 40 + 140 * (s / 100);
|
||||
return !isSheet;
|
||||
};
|
||||
const autoPick = (samples: { r: number; g: number; b: number }[]): 'green' | 'black' => {
|
||||
let gr = 0, gl = 0;
|
||||
for (const p of samples) {
|
||||
if (p.g - Math.max(p.r, p.b) > 20) gr++;
|
||||
if (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b < 70) gl++;
|
||||
}
|
||||
return gl >= gr ? 'black' : 'green';
|
||||
};
|
||||
|
||||
function runMask(params: MaskParams, id: number): WorkerOut {
|
||||
if (!data) return { id, kind: 'failed', reason: 'no source decoded' };
|
||||
let sheet = params.sheet;
|
||||
@@ -36,7 +18,7 @@ function runMask(params: MaskParams, id: number): WorkerOut {
|
||||
const i = (y * w + x) * 4;
|
||||
samples.push({ r: data[i], g: data[i + 1], b: data[i + 2] });
|
||||
}
|
||||
sheet = autoPick(samples);
|
||||
sheet = autoPickSheet(samples);
|
||||
}
|
||||
const keep = sheet === 'green' ? greenKeep : blackKeep;
|
||||
const s = Math.max(0, Math.min(100, params.sensitivity));
|
||||
@@ -69,7 +51,7 @@ self.onmessage = (e: MessageEvent<WorkerIn>) => {
|
||||
}
|
||||
data = null;
|
||||
w = h = 0;
|
||||
createImageBitmap(msg.blob)
|
||||
decodeOrientedBitmap(msg.blob)
|
||||
.then((bmp) => {
|
||||
w = bmp.width;
|
||||
h = bmp.height;
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 466 KiB |
@@ -1,11 +1,11 @@
|
||||
# Last green e2e run (#65)
|
||||
# Last green e2e run (#71)
|
||||
|
||||
- **When:** 2026-09-15 2:36 PM PT (`2026-09-15T21:36:28Z`)
|
||||
- **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 (~7s test / ~14s wall with build)
|
||||
- **Mask:** Black sheet @ sensitivity 50 — Approve enabled without sensitivity ladder
|
||||
- **Photos ready numbers (from UI):** widths 6"–9 3/8" (avg 7 1/2"); sqft 2.13; bdft 4.26; scale 76.95 px/in
|
||||
- **Lineup:** Clean calibration → Dimension overlay (48" length + width stations) → Batch photo 2
|
||||
- **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.
|
||||
|
||||