feat(m4): crop_fill setting + tape-line prototype (#99)
Add global crop_fill percent (default 90, 1-100) as a settings key with FastAPI 4xx validation. Wires the value through to the inventory PNG encode (non-retro: stored PNGs untouched). Adds a throwaway TapePrototype.svelte behind ?tape-prototype for the #99 design sandbox. Gates: check_requirements PASS, check_coverage PASS, settings tests 8/8.
This commit is contained in:
Generated
+10
@@ -8,6 +8,7 @@
|
||||
"name": "slabuploader-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@neodrag/svelte": "2.3.3",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"svelte": "^4.2.20"
|
||||
},
|
||||
@@ -495,6 +496,15 @@
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@neodrag/svelte": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@neodrag/svelte/-/svelte-2.3.3.tgz",
|
||||
"integrity": "sha512-avXzhrilsBsnMFljhVAQ7h+6hbSIrvRCJ61GCiGbGISkC1QOhjDCNvPZo2+7KVwiYrnUBx4NRH0kTIqrcxv9Lg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@neodrag/svelte": "2.3.3",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"svelte": "^4.2.20"
|
||||
},
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
import AxisPanel from './capture/AxisPanel.svelte';
|
||||
import MeasurePanel from './capture/MeasurePanel.svelte';
|
||||
import Settings from './create/settings/Settings.svelte';
|
||||
// #99 PROTOTYPE: tape-line overlay + per-slab length input + crop setting.
|
||||
// Throwaway — driven by the `tape-prototype` query param. Delete this branch
|
||||
// and the component after the design is settled.
|
||||
import TapePrototype from './capture/TapePrototype.svelte';
|
||||
import SlabList from './create/slabs/SlabList.svelte';
|
||||
import Review from './create/review/Review.svelte';
|
||||
import Publish from './create/publish/Publish.svelte';
|
||||
@@ -22,6 +26,10 @@
|
||||
import { clearDraft } from './draft/idb';
|
||||
|
||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||
// #99 PROTOTYPE entry: `?tape-prototype` boots the tape-line design sandbox.
|
||||
const isTapeProto =
|
||||
typeof window !== 'undefined' &&
|
||||
new URLSearchParams(window.location.search).has('tape-prototype');
|
||||
const session: CaptureSession = createCaptureSession();
|
||||
|
||||
let cap: Capture = { k: 'batch', photos: [] };
|
||||
@@ -47,6 +55,11 @@
|
||||
// ?noload to trigger the Resume prompt deterministically.
|
||||
const boot = new URLSearchParams(window.location.search);
|
||||
if (!boot.get('noload')) void session.resumeProbe();
|
||||
// #99: the global crop fill drives both the reference line and the PNG crop.
|
||||
// Falls back to the 90% default when settings are unreachable.
|
||||
void getSettings()
|
||||
.then((s) => session.setCropFill(s.crop_fill ?? 90))
|
||||
.catch(() => {});
|
||||
});
|
||||
onDestroy(() => {
|
||||
unsubCap();
|
||||
@@ -144,6 +157,15 @@
|
||||
</p>
|
||||
<p class="detail">Missing: {gate}</p>
|
||||
</main>
|
||||
{:else if isTapeProto}
|
||||
<!-- #99 PROTOTYPE: tape-line overlay design sandbox (throwaway). -->
|
||||
<main class="app proto">
|
||||
<div class="proto-bar">
|
||||
<strong>#99 tape-line prototype</strong>
|
||||
<a href="?noload">exit →</a>
|
||||
</div>
|
||||
<TapePrototype />
|
||||
</main>
|
||||
{:else}
|
||||
<main class="app">
|
||||
{#if curFlow.phase === 'review'}
|
||||
@@ -260,6 +282,12 @@
|
||||
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
||||
}
|
||||
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
|
||||
.proto-bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 16px; background: #1d1d1d; color: #00e5ff; font-size: 0.85rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
.proto-bar a { color: #aaa; text-decoration: none; }
|
||||
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
||||
.hardstop h1 { font-size: 1.6rem; }
|
||||
.detail { color: #888; font-size: 0.85rem; }
|
||||
|
||||
@@ -454,6 +454,8 @@ export interface SettingsView {
|
||||
woo_create_status: string;
|
||||
taxonomy_last_sync_at: string | null;
|
||||
taxonomy_anchor: string | null;
|
||||
/** #99: global crop fill %, default 90. Drives the ref line + the PNG crop. */
|
||||
crop_fill: number;
|
||||
}
|
||||
|
||||
export function getSettings(): Promise<SettingsView> {
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
<script lang="ts">
|
||||
// #99 PROTOTYPE — throwaway design sandbox for the tape-line overlay.
|
||||
// NOT production. Delete this file (and the App.svelte branch) once the design
|
||||
// is settled.
|
||||
//
|
||||
// Model per issue #99:
|
||||
// - The MEASURE LINE IS FIXED VERTICAL on screen: it sits at the position the
|
||||
// crop places the slab's long edge (derived from crop fill + the 3:4 aspect
|
||||
// constants — the crop line IS the reference line).
|
||||
// - The SLAB (the photo) is the only thing that pans / zooms / rotates.
|
||||
// - Two rifle-scope ticks slide ALONG the fixed line (1-D drag). The owner
|
||||
// rotates the photo until its taped edge sits on the line, then places the
|
||||
// ticks at the tape start/end.
|
||||
// - Declared L = source-space distance between the ticks ÷ px-per-inch —
|
||||
// NOT a projected mask span. Zooming in makes tick placement finer.
|
||||
// - crop % is a GLOBAL setting (default 90%) and moves the fixed line.
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { maskSpanAlong } from '../geometry/rect';
|
||||
|
||||
// ---- synthetic slab, portrait (long edge ~vertical) --------------------
|
||||
const SW = 300; // source px width (slab width)
|
||||
const SH = 1040; // source px height (slab runs long along Y)
|
||||
let bitset = new Uint8Array(SW * SH);
|
||||
const rnd = (n: number): number => {
|
||||
const x = Math.sin(n * 127.1) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
function vnoise(x: number, y: number): number {
|
||||
const xi = Math.floor(x), yi = Math.floor(y);
|
||||
const xf = x - xi, yf = y - yi;
|
||||
const s = (a: number, b: number) => rnd(a * 374.13 + b * 217.91);
|
||||
const n00 = s(xi, yi), n10 = s(xi + 1, yi), n01 = s(xi, yi + 1), n11 = s(xi + 1, yi + 1);
|
||||
const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
|
||||
const a = n00 * (1 - u) + n10 * u;
|
||||
const b = n01 * (1 - u) + n11 * u;
|
||||
return a * (1 - v) + b * v;
|
||||
}
|
||||
function buildSlab(): void {
|
||||
bitset = new Uint8Array(SW * SH);
|
||||
for (let y = 0; y < SH; y++) {
|
||||
for (let x = 0; x < SW; x++) {
|
||||
const edge = Math.min(y, SH - y);
|
||||
const taper = 1 - 0.22 * Math.max(0, 1 - edge / 170);
|
||||
const wobble =
|
||||
1 + 0.05 * Math.sin(y / 53 + 0.4)
|
||||
+ 0.035 * Math.sin(y / 19 + 1.1)
|
||||
+ 0.02 * Math.sin(y / 8.1 + 2.2);
|
||||
const halfW = (SW / 2) * taper * wobble;
|
||||
const cx = SW / 2 + 4 * Math.sin(y / 71);
|
||||
if (Math.abs(x - cx) <= halfW) bitset[y * SW + x] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
function woodRGB(x: number, y: number): [number, number, number] {
|
||||
const cx = SW / 2;
|
||||
const t = (x - cx) / (SW / 2);
|
||||
let r = 172 - Math.abs(t) * 48;
|
||||
let g = 122 - Math.abs(t) * 42;
|
||||
let b = 78 - Math.abs(t) * 32;
|
||||
const n = vnoise(x * 0.05, y * 0.02);
|
||||
r += (n - 0.5) * 28; g += (n - 0.5) * 24; b += (n - 0.5) * 17;
|
||||
const grain =
|
||||
Math.sin(y * 0.02 + x * 0.5 + Math.sin(x * 0.09) * 2.2) * 0.5 +
|
||||
vnoise(x * 0.4, y * 0.02) * 0.6 - 0.3;
|
||||
const streak = grain * 30;
|
||||
r += streak; g += streak * 0.85; b += streak * 0.6;
|
||||
for (const ky of [240, 560, 820]) {
|
||||
const kx = cx + (rnd(ky) - 0.5) * 34;
|
||||
const d = Math.hypot((x - kx) * 2.0, y - ky);
|
||||
if (d < 22) {
|
||||
const ring = Math.cos(d * 0.7) * 0.5 + 0.5;
|
||||
const f = Math.max(0, 1 - d / 22);
|
||||
r -= f * 60 * (0.4 + 0.6 * (1 - ring));
|
||||
g -= f * 58 * (0.4 + 0.6 * (1 - ring));
|
||||
b -= f * 44 * (0.4 + 0.6 * (1 - ring));
|
||||
}
|
||||
}
|
||||
return [
|
||||
Math.max(38, Math.min(214, Math.round(r))),
|
||||
Math.max(26, Math.min(162, Math.round(g))),
|
||||
Math.max(18, Math.min(112, Math.round(b))),
|
||||
];
|
||||
}
|
||||
// Silhouette path: left edge top→bottom, right edge bottom→top.
|
||||
function slabPath(ctx: CanvasRenderingContext2D, scale: number): void {
|
||||
ctx.beginPath();
|
||||
let started = false;
|
||||
for (let y = 0; y < SH; y++) {
|
||||
for (let x = 0; x < SW; x++) {
|
||||
if (bitset[y * SW + x]) {
|
||||
if (!started) { ctx.moveTo(x * scale, y * scale); started = true; }
|
||||
else ctx.lineTo(x * scale, y * scale);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let y = SH - 1; y >= 0; y--) {
|
||||
for (let x = SW - 1; x >= 0; x--) {
|
||||
if (bitset[y * SW + x]) { ctx.lineTo(x * scale, y * scale); break; }
|
||||
}
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
let slabCanvas: HTMLCanvasElement | null = null;
|
||||
function buildSlabCanvas(): void {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = SW; c.height = SH;
|
||||
const ctx = c.getContext('2d')!;
|
||||
const img = ctx.createImageData(SW, SH);
|
||||
for (let y = 0; y < SH; y++) {
|
||||
for (let x = 0; x < SW; x++) {
|
||||
const i = (y * SW + x) * 4;
|
||||
if (bitset[y * SW + x]) {
|
||||
const [r, g, b] = woodRGB(x, y);
|
||||
img.data[i] = r; img.data[i+1] = g; img.data[i+2] = b; img.data[i+3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
ctx.save();
|
||||
slabPath(ctx, 1);
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = 'rgba(38,22,11,0.85)';
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
slabCanvas = c;
|
||||
}
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
let parentW = 0, parentH = 0;
|
||||
let stage: HTMLDivElement | undefined;
|
||||
|
||||
// ---- the photo transform (pan/zoom/rotate moves ONLY the slab) ---------
|
||||
let panX = 0, panY = 0; // screen-space offset of the slab
|
||||
let zoom = 1;
|
||||
let photoAngle = 0; // deg — rotate the photo until its taped edge hits the line
|
||||
// ---- global crop fill (settings.crop_fill, default 90) ------------------
|
||||
let cropFill = 90;
|
||||
// ---- ticks: pinned to SOURCE pixels on the photo. Their screen position
|
||||
// is wherever the photo transform puts that pixel — which is ON the fixed
|
||||
// line only when the photo is aligned, which is exactly the task. ----
|
||||
let tickAs = 40, tickBs = 1000; // source-space Y on the slab (tape start/end)
|
||||
let selectedTick: 'A' | 'B' = 'A'; // the tick arrow/Shift+arrow nudges
|
||||
let dragging: 'pan' | 'A' | 'B' | null = null;
|
||||
let lastPointer = { x: 0, y: 0 };
|
||||
// Active pointers, for mouse drag vs two-finger pinch (#99 control model).
|
||||
const pointers = new Map<number, { x: number; y: number }>();
|
||||
let pinchDist = 0;
|
||||
|
||||
// ---- layout: a fixed 3:4 crop frame; the line sits where the crop puts
|
||||
// the slab's long (left) edge at the current fill.
|
||||
interface Frame { x: number; y: number; w: number; h: number }
|
||||
function cropFrame(): Frame {
|
||||
const pad = 16;
|
||||
const availW = parentW - pad * 2, availH = parentH - pad * 2;
|
||||
const w = Math.min(availW, availH * (3 / 4));
|
||||
const h = w * (4 / 3);
|
||||
return { x: (parentW - w) / 2, y: (parentH - h) / 2, w, h };
|
||||
}
|
||||
// Base slab scale so the slab fits the frame at fill% (like composeInventoryFrame).
|
||||
function baseScale(fr: Frame): number {
|
||||
return ((fr.h * (cropFill / 100)) / SH);
|
||||
}
|
||||
// The FIXED measure line: screen X where the crop puts the slab's long edge.
|
||||
function refLineX(fr: Frame): number {
|
||||
const s = baseScale(fr);
|
||||
return fr.x + (fr.w - SW * s) / 2;
|
||||
}
|
||||
|
||||
// Source-space → screen (slab only; the line never uses this).
|
||||
function toScreen(sx: number, sy: number): [number, number] {
|
||||
const fr = cropFrame();
|
||||
const s = baseScale(fr) * zoom;
|
||||
const cx = fr.x + fr.w / 2 + panX, cy = fr.y + fr.h / 2 + panY;
|
||||
const rad = (photoAngle * Math.PI) / 180;
|
||||
const cos = Math.cos(rad), sin = Math.sin(rad);
|
||||
const dx = (sx - SW / 2) * s, dy = (sy - SH / 2) * s;
|
||||
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
|
||||
}
|
||||
// Screen → source-space (inverse of toScreen).
|
||||
function toSource(px: number, py: number): [number, number] {
|
||||
const fr = cropFrame();
|
||||
const s = baseScale(fr) * zoom;
|
||||
const cx = fr.x + fr.w / 2 + panX, cy = fr.y + fr.h / 2 + panY;
|
||||
const rad = (-photoAngle * Math.PI) / 180;
|
||||
const cos = Math.cos(rad), sin = Math.sin(rad);
|
||||
const dx = px - cx, dy = py - cy;
|
||||
return [SW / 2 + (dx * cos - dy * sin) / s, SH / 2 + (dx * sin + dy * cos) / s];
|
||||
}
|
||||
|
||||
// Synthetic tape scale: the slab spans SH px ≈ 88 inches.
|
||||
function pxPerIn(): number {
|
||||
const spanPx = maskSpanAlong(bitset, SW, SH, 0, 1);
|
||||
return spanPx / 88;
|
||||
}
|
||||
// L = distance between the two photo-pinned ticks in source px ÷ scale.
|
||||
// Exact for the photo's natural pixels — independent of on-screen zoom.
|
||||
let declaredLenIn = 0;
|
||||
function recompute(): void {
|
||||
declaredLenIn = Math.abs(tickBs - tickAs) / (pxPerIn() || 1);
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
if (!canvas) return;
|
||||
if (!parentW || !parentH) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(parentW * dpr);
|
||||
canvas.height = Math.round(parentH * dpr);
|
||||
canvas.style.width = parentW + 'px';
|
||||
canvas.style.height = parentH + 'px';
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const fr = cropFrame();
|
||||
const lineX = refLineX(fr);
|
||||
|
||||
// 1) workshop backdrop
|
||||
const bg = ctx.createRadialGradient(parentW/2, parentH*0.4, parentW*0.1, parentW/2, parentH/2, parentW*0.75);
|
||||
bg.addColorStop(0, '#242424');
|
||||
bg.addColorStop(1, '#141414');
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, parentW, parentH);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let gx = 40; gx < parentW; gx += 40) { ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, parentH); ctx.stroke(); }
|
||||
for (let gy = 40; gy < parentH; gy += 40) { ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(parentW, gy); ctx.stroke(); }
|
||||
|
||||
// 2) THE 3:4 CROP FRAME — a fixed screen fixture (where output lands).
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(0,229,255,0.35)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([8, 6]);
|
||||
ctx.strokeRect(fr.x, fr.y, fr.w, fr.h);
|
||||
ctx.setLineDash([]);
|
||||
ctx.font = '600 12px system-ui';
|
||||
ctx.fillStyle = 'rgba(0,229,255,0.6)';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText('3:4 crop frame', fr.x + fr.w - 6, Math.max(fr.y - 4, 14));
|
||||
ctx.restore();
|
||||
|
||||
// 3) THE SLAB + the tape + the ticks — all pinned to the photo, so they
|
||||
// pan / zoom / rotate together. The tape only lands on the fixed line
|
||||
// once the owner has rotated the photo into alignment.
|
||||
ctx.save();
|
||||
if (slabCanvas) {
|
||||
const s = baseScale(fr) * zoom;
|
||||
ctx.save();
|
||||
ctx.translate(fr.x + fr.w/2 + panX, fr.y + fr.h/2 + panY);
|
||||
ctx.rotate((photoAngle * Math.PI) / 180);
|
||||
ctx.scale(s, s);
|
||||
ctx.translate(-SW/2, -SH/2);
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.6)';
|
||||
ctx.shadowBlur = 30 / s;
|
||||
ctx.shadowOffsetY = 12 / s;
|
||||
ctx.drawImage(slabCanvas, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
// tape + ticks in source space → screen
|
||||
const [ax, ay] = toScreen(SW / 2, tickAs);
|
||||
const [bx, by] = toScreen(SW / 2, tickBs);
|
||||
const invZoom = 1 / (baseScale(fr) * zoom);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
|
||||
ctx.lineWidth = 5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2.5; // "2 weight"
|
||||
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
|
||||
const tdx = bx - ax, tdy = by - ay, tl = Math.hypot(tdx, tdy) || 1;
|
||||
const px = -tdy / tl, py = tdx / tl, half = 18;
|
||||
for (const [ex, ey, id] of [[ax, ay, 'A'], [bx, by, 'B']] as const) {
|
||||
// The selected tick gets a highlight ring: it is what arrow/Shift+arrow
|
||||
// nudges for pixel-level tape-mark alignment.
|
||||
if (id === selectedTick) {
|
||||
ctx.strokeStyle = 'rgba(255,214,0,0.95)';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.arc(ex, ey, half + 8, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath(); ctx.moveTo(ex - px*half, ey - py*half); ctx.lineTo(ex + px*half, ey + py*half); ctx.stroke();
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.beginPath(); ctx.moveTo(ex - px*half, ey - py*half); ctx.lineTo(ex + px*half, ey + py*half); ctx.stroke();
|
||||
ctx.fillStyle = id === selectedTick ? '#ffd600' : '#ff3d71';
|
||||
ctx.beginPath(); ctx.arc(ex, ey, 6, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
|
||||
}
|
||||
void invZoom;
|
||||
ctx.restore();
|
||||
|
||||
// 4) THE FIXED MEASURE LINE — a screen fixture, drawn ON TOP of the photo,
|
||||
// never pans/zooms/rotates. crop fill moves it (it is where the crop
|
||||
// puts the slab's long edge). The goal: rotate the photo until the black
|
||||
// tape lines up with this line.
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.lineWidth = 5;
|
||||
ctx.setLineDash([2, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lineX, fr.y - 18);
|
||||
ctx.lineTo(lineX, fr.y + fr.h + 18);
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = '#00e5ff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lineX, fr.y - 18);
|
||||
ctx.lineTo(lineX, fr.y + fr.h + 18);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.font = '600 12px system-ui';
|
||||
ctx.fillStyle = '#00e5ff';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
// Clamp above the frame top so the label can never be clipped off-canvas.
|
||||
const lineLabelY = Math.max(fr.y - 22, 15);
|
||||
ctx.fillText(`fixed reference line · crop ${cropFill}%`, lineX, lineLabelY);
|
||||
ctx.restore();
|
||||
|
||||
// 5) length chip beside the fixed line, mid-height (a screen fixture)
|
||||
const my = (ay + by) / 2;
|
||||
const fs = 16;
|
||||
ctx.font = `700 ${fs}px system-ui, -apple-system, sans-serif`;
|
||||
const label = declaredLenIn.toFixed(1) + '\u2033';
|
||||
const tw = ctx.measureText(label).width;
|
||||
const padX = 10, padY = 7, chipX = lineX + 44;
|
||||
ctx.fillStyle = 'rgba(15,15,15,0.92)';
|
||||
ctx.strokeStyle = 'rgba(255,61,113,0.9)';
|
||||
ctx.lineWidth = 1.5;
|
||||
roundRect(ctx, chipX, my - fs/2 - padY, tw + padX*2, fs + padY*2, 8);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = '#ff6b8f';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, chipX + (tw + padX*2)/2, my);
|
||||
|
||||
// Prototype-only debug hook so the behavior tests read geometry directly.
|
||||
(window as any).__tp = {
|
||||
lineX, declaredLenIn, photoAngle, zoom, panX, panY,
|
||||
tickA: { ax, ay }, tickB: { bx, by }, cropFill, frame: fr,
|
||||
tickAs, tickBs, selectedTick, dragging, pointersSize: pointers.size,
|
||||
};
|
||||
}
|
||||
|
||||
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function frame(): void { draw(); } // helper: re-render + re-measure
|
||||
|
||||
onMount(() => {
|
||||
buildSlab();
|
||||
buildSlabCanvas();
|
||||
if (stage) { parentW = stage.clientWidth; parentH = stage.clientHeight; }
|
||||
recompute();
|
||||
const ro = new ResizeObserver((es) => {
|
||||
const e = es[0];
|
||||
if (!e) return;
|
||||
parentW = e.contentRect.width;
|
||||
parentH = e.contentRect.height;
|
||||
recompute();
|
||||
draw();
|
||||
});
|
||||
if (stage) ro.observe(stage);
|
||||
draw();
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
onDestroy(() => {});
|
||||
|
||||
// ---- Pointer Events: pan/zoom the PHOTO; drag ticks ALONG THE LINE -----
|
||||
function pointerPos(e: PointerEvent): { x: number; y: number } {
|
||||
const rect = canvas!.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
}
|
||||
const TICK_HIT = 24; // ≥44×44 effective hit area
|
||||
function tickScreen(): { ax: number; ay: number; bx: number; by: number } {
|
||||
const [ax, ay] = toScreen(SW / 2, tickAs);
|
||||
const [bx, by] = toScreen(SW / 2, tickBs);
|
||||
return { ax, ay, bx, by };
|
||||
}
|
||||
function hitTick(px: number, py: number): 'A' | 'B' | null {
|
||||
const t = tickScreen();
|
||||
if (Math.hypot(px - t.ax, py - t.ay) <= TICK_HIT) return 'A';
|
||||
if (Math.hypot(px - t.bx, py - t.by) <= TICK_HIT) return 'B';
|
||||
return null;
|
||||
}
|
||||
// Zoom about a screen point: the source pixel under the cursor stays put
|
||||
// (#99 control model — "zoom-to-cursor").
|
||||
function zoomAt(px: number, py: number, factor: number): void {
|
||||
const before = toSource(px, py);
|
||||
zoom = Math.max(0.4, Math.min(6, zoom * factor));
|
||||
const [nx, ny] = toScreen(before[0], before[1]);
|
||||
panX += px - nx;
|
||||
panY += py - ny;
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
const pinch = () => {
|
||||
const pts = [...pointers.values()];
|
||||
if (pts.length < 2) return { d: 0, mx: 0, my: 0 };
|
||||
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||
return { d, mx: (pts[0].x + pts[1].x) / 2, my: (pts[0].y + pts[1].y) / 2 };
|
||||
};
|
||||
function onPointerDown(e: PointerEvent): void {
|
||||
const p = pointerPos(e);
|
||||
pointers.set(e.pointerId, p);
|
||||
if (pointers.size === 2) {
|
||||
const { d } = pinch();
|
||||
pinchDist = d;
|
||||
dragging = null; // two fingers = pinch, never a pan/tick drag
|
||||
return;
|
||||
}
|
||||
const t = hitTick(p.x, p.y);
|
||||
if (t) selectedTick = t; // arrow/Shift+arrow then nudges THIS tick
|
||||
dragging = t ?? 'pan';
|
||||
lastPointer = p;
|
||||
canvas?.setPointerCapture(e.pointerId);
|
||||
}
|
||||
function onPointerMove(e: PointerEvent): void {
|
||||
const p = pointerPos(e);
|
||||
if (pointers.has(e.pointerId)) pointers.set(e.pointerId, p);
|
||||
if (pointers.size >= 2) {
|
||||
const { d, mx, my } = pinch();
|
||||
if (pinchDist > 0 && d > 0) zoomAt(mx, my, d / pinchDist);
|
||||
pinchDist = d;
|
||||
return;
|
||||
}
|
||||
if (!dragging) {
|
||||
canvas?.style.setProperty('cursor', hitTick(p.x, p.y) ? 'ns-resize' : 'grab');
|
||||
return;
|
||||
}
|
||||
if (dragging === 'pan') {
|
||||
panX += p.x - lastPointer.x;
|
||||
panY += p.y - lastPointer.y;
|
||||
} else {
|
||||
// The tick slides ALONG the taped edge — source-space Y on the photo.
|
||||
const [, sy] = toSource(p.x, p.y);
|
||||
const y = Math.max(0, Math.min(SH, sy));
|
||||
if (dragging === 'A') tickAs = Math.min(y, tickBs - 4);
|
||||
else tickBs = Math.max(y, tickAs + 4);
|
||||
}
|
||||
lastPointer = p;
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
function onPointerUp(e: PointerEvent): void {
|
||||
pointers.delete(e.pointerId);
|
||||
if (pointers.size < 2) pinchDist = 0;
|
||||
if (pointers.size === 0) dragging = null;
|
||||
canvas?.releasePointerCapture(e.pointerId);
|
||||
canvas?.style.setProperty('cursor', 'grab');
|
||||
}
|
||||
function onWheel(e: WheelEvent): void {
|
||||
e.preventDefault();
|
||||
const rect = canvas!.getBoundingClientRect();
|
||||
// Trackpad pinch arrives as a wheel with ctrlKey set; make it zoom harder
|
||||
// per event than a plain mouse wheel.
|
||||
const f = Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015));
|
||||
zoomAt(e.clientX - rect.left, e.clientY - rect.top, f);
|
||||
}
|
||||
// #99: arrow keys rotate the photo 1° (coarse) / 0.1° (Shift, fine);
|
||||
// with a tick selected they nudge that tick instead — pixel-level alignment.
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
const fine = e.shiftKey;
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
const d = (e.key === 'ArrowUp' ? -1 : 1) * (fine ? 0.1 : 1);
|
||||
if (selectedTick === 'A') tickAs = Math.max(0, Math.min(tickAs + d, tickBs - 4));
|
||||
else tickBs = Math.min(SH, Math.max(tickBs + d, tickAs + 4));
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
const d = (e.key === 'ArrowLeft' ? -1 : 1) * (fine ? 0.1 : 1);
|
||||
photoAngle = ((photoAngle + d) % 360 + 360) % 360;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
function pickTick(t: 'A' | 'B'): void {
|
||||
selectedTick = t;
|
||||
draw();
|
||||
}
|
||||
function nudgePhoto(d: number): void {
|
||||
photoAngle = ((photoAngle + d) % 360 + 360) % 360;
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
function setPhotoAngle(d: number): void {
|
||||
photoAngle = ((d % 360) + 360) % 360;
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
function setCrop(d: number): void {
|
||||
cropFill = d;
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="tp">
|
||||
<h1>Length axis — tape line</h1>
|
||||
<p class="hint">
|
||||
The measure line is <strong>fixed</strong>. Rotate / pan / zoom the <strong>photo</strong>
|
||||
until its taped edge sits on the line, then drag the two ticks along it to where your
|
||||
tape started and ended. L = the distance between the ticks.
|
||||
</p>
|
||||
|
||||
<div class="stage" bind:this={stage} role="application" tabindex="0" on:keydown={onKey}
|
||||
aria-label="Tape-line calibration. Arrow keys rotate the photo; with a tick selected they nudge that tick.">
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
style="cursor:grab"
|
||||
on:pointerdown={onPointerDown}
|
||||
on:pointermove={onPointerMove}
|
||||
on:pointerup={onPointerUp}
|
||||
on:pointercancel={onPointerUp}
|
||||
on:wheel={onWheel}
|
||||
></canvas>
|
||||
</div>
|
||||
<p class="stage-hint">Drag each tick to where your tape started and ended.</p>
|
||||
<p class="stage-sub">Arrow keys rotate 1° (Shift 0.1°) · with a tick selected the arrows nudge it · wheel or pinch zooms at the cursor</p>
|
||||
|
||||
<!-- global crop fill (settings.crop_fill) — moves the fixed line -->
|
||||
<div class="crop">
|
||||
<label for="crop">Global crop fill</label>
|
||||
<input id="crop" type="range" min="50" max="100" step="1" value={cropFill}
|
||||
on:input={(e) => setCrop(Number(e.currentTarget.value))} />
|
||||
<span class="val">{cropFill}%</span>
|
||||
</div>
|
||||
<p class="sub">Default 90% · single setting replaces both duplicated FILL=0.9 · moves the fixed line + re-crops future slabs (non-retro).</p>
|
||||
|
||||
<!-- rotate/pan/zoom the PHOTO (coarse → fine, Adobe nudge model) -->
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Rotate photo counter-clockwise"
|
||||
on:click={() => nudgePhoto(-1)}>−</button>
|
||||
<input type="range" min="0" max="359" step="1" value={Math.round(photoAngle % 360)}
|
||||
on:input={(e) => setPhotoAngle(Number(e.currentTarget.value))} />
|
||||
<button type="button" class="thumb" aria-label="Rotate photo clockwise"
|
||||
on:click={() => nudgePhoto(1)}>+</button>
|
||||
<span class="val">{Math.round(photoAngle % 360)}°</span>
|
||||
<button type="button" class="ghost" on:click={() => { panX = 0; panY = 0; zoom = 1; photoAngle = 0; recompute(); frame(); }}>reset</button>
|
||||
</div>
|
||||
|
||||
<!-- declared length = distance between the two ticks -->
|
||||
<div class="len">
|
||||
<div class="len-head">
|
||||
<span>Declared length (between ticks)</span>
|
||||
<span class="eff">{declaredLenIn.toFixed(1)}″</span>
|
||||
</div>
|
||||
<div class="ticks">
|
||||
<button type="button" class="tickbtn" class:sel={selectedTick === 'A'} on:click={() => pickTick('A')}>
|
||||
tick A {selectedTick === 'A' ? '· arrows nudge' : ''}
|
||||
</button>
|
||||
<button type="button" class="tickbtn" class:sel={selectedTick === 'B'} on:click={() => pickTick('B')}>
|
||||
tick B {selectedTick === 'B' ? '· arrows nudge' : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.tp { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; line-height: 1.5; }
|
||||
.stage {
|
||||
position: relative; width: 100%; height: 56vh; min-height: 360px;
|
||||
background: #181818; border-radius: 10px; overflow: hidden;
|
||||
border: 1px solid #2c2c2c;
|
||||
}
|
||||
canvas { display: block; width: 100%; height: 100%; touch-action: none; }
|
||||
.stage-hint {
|
||||
font-size: 0.78rem; color: #bbb; margin: 8px 0 0;
|
||||
}
|
||||
.stage-sub { font-size: 0.72rem; color: #777; margin: 4px 0 0; }
|
||||
.stage:focus-visible { outline: 2px solid #00e5ff; outline-offset: 2px; }
|
||||
.ticks { display: flex; gap: 10px; margin-top: 8px; }
|
||||
.tickbtn {
|
||||
flex: 1; padding: 10px; font-size: 0.85rem; border-radius: 4px;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #ddd;
|
||||
}
|
||||
.tickbtn.sel { border-color: #ffd600; color: #ffd600; }
|
||||
.crop, .angle { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
|
||||
.crop label { font-size: 0.9rem; color: #ccc; }
|
||||
input[type='range'] { flex: 1; accent-color: #00e5ff; }
|
||||
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; color: #00e5ff; }
|
||||
.ghost {
|
||||
border: 1px solid #444; background: #1d1d1d; color: #ccc; font-size: 0.85rem;
|
||||
padding: 8px 12px; border-radius: 4px;
|
||||
}
|
||||
.sub { color: #888; font-size: 0.82rem; margin-top: 8px; }
|
||||
.thumb {
|
||||
width: 44px; height: 44px; border-radius: 50%; font-size: 1.5rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
.len { margin-top: 18px; }
|
||||
.len-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }
|
||||
.len-head span:first-child { font-size: 0.9rem; color: #aaa; }
|
||||
.eff { color: #ff6b8f; font-weight: 700; font-size: 1.3rem; font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
@@ -43,7 +43,7 @@ export interface Mask {
|
||||
slabPx: number; // 0 ⇒ approve blocked, user retakes
|
||||
bitset: Uint8Array; // source-grid mask, 1 byte/px
|
||||
bbox: BBox;
|
||||
slabBounds: ABox; // axis-aligned extent of slab px, for the 80%-fill PNG crop
|
||||
slabBounds: ABox; // axis-aligned extent of slab px, for the PNG crop (settings.crop_fill, default 90%)
|
||||
}
|
||||
|
||||
// Direction only: an oriented rectangle over the slab. The arrow reads
|
||||
|
||||
@@ -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 { FILL_DEFAULT, fillFromSetting } from '../png/crop';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
@@ -41,6 +42,8 @@ export interface CaptureSession {
|
||||
setSheet(s: Sheet): void;
|
||||
approveMask(): void;
|
||||
setAxisAngle(deg: number): void;
|
||||
/** #99: global crop fill from settings (percent or fraction); drives the PNG crop. */
|
||||
setCropFill(pctOrFraction: number): void;
|
||||
approveAxis(): void;
|
||||
setLength(e: Eighths): void;
|
||||
setThickness(e: Eighths): void;
|
||||
@@ -83,6 +86,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
let busyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pngUrl: string | null = null;
|
||||
let resumed = false;
|
||||
// #99: global crop fill (fraction). Set from settings; FILL_DEFAULT (90%) until told otherwise.
|
||||
let cropFill = FILL_DEFAULT;
|
||||
|
||||
const set = (next: Capture) => {
|
||||
state = next;
|
||||
@@ -487,6 +492,11 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
void persist();
|
||||
},
|
||||
|
||||
setCropFill(pctOrFraction: number) {
|
||||
// #99: non-retro — only future encodes use this; stored PNGs are untouched.
|
||||
cropFill = fillFromSetting(pctOrFraction);
|
||||
},
|
||||
|
||||
approveAxis() {
|
||||
if (state.k !== 'axis') return;
|
||||
set({
|
||||
@@ -523,7 +533,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
let pngBlob: Blob;
|
||||
let belowFloor = false;
|
||||
try {
|
||||
const r = await encodeInventory({ bitmap, mask: approvedMask, source });
|
||||
const r = await encodeInventory({ bitmap, mask: approvedMask, source, fill: cropFill });
|
||||
pngBlob = r.blob;
|
||||
belowFloor = r.sourceBelowFloor;
|
||||
} catch {
|
||||
@@ -593,7 +603,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
|
||||
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
||||
try {
|
||||
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource });
|
||||
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource, fill: cropFill });
|
||||
} finally {
|
||||
siblingBitmap.close();
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
brand_voice: string | null;
|
||||
geo_context: string | null;
|
||||
woo_create_status: string | null;
|
||||
crop_fill: number;
|
||||
}
|
||||
|
||||
interface PriceRule {
|
||||
@@ -205,6 +206,7 @@
|
||||
brand_voice: settings.brand_voice || '',
|
||||
geo_context: settings.geo_context || '',
|
||||
woo_create_status: settings.woo_create_status || 'draft',
|
||||
crop_fill: settings.crop_fill ?? 90,
|
||||
// secrets: prefill when unmasked (UAT), else blank (masked, write-only)
|
||||
woo_app_password: secretValue(settings.woo_app_password),
|
||||
inference_base_url: secretValue(settings.inference_base_url),
|
||||
@@ -230,6 +232,7 @@
|
||||
if (form.brand_voice !== (settings?.brand_voice || '')) payload.brand_voice = form.brand_voice || null;
|
||||
if (form.geo_context !== (settings?.geo_context || '')) payload.geo_context = form.geo_context || null;
|
||||
if (form.woo_create_status !== (settings?.woo_create_status || 'draft')) payload.woo_create_status = form.woo_create_status;
|
||||
if (Number(form.crop_fill) !== (settings?.crop_fill ?? 90)) payload.crop_fill = Number(form.crop_fill);
|
||||
// secrets only if provided (write-only)
|
||||
if (form.woo_app_password) payload.woo_app_password = form.woo_app_password;
|
||||
if (form.inference_base_url) payload.inference_base_url = form.inference_base_url;
|
||||
@@ -351,6 +354,12 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="crop-fill">Crop Fill % (default 90) — inventory PNG + reference line</label>
|
||||
<input id="crop-fill" type="range" min="1" max="100" step="1" bind:value={form.crop_fill} />
|
||||
<span>{form.crop_fill}%</span>
|
||||
</div>
|
||||
|
||||
<button on:click={save} disabled={saving}>Save Changes</button>
|
||||
{#if message}<p class="msg">{message}</p>{/if}
|
||||
|
||||
@@ -415,7 +424,10 @@
|
||||
<style>
|
||||
.settings { max-width: 600px; color: #eee; }
|
||||
label { display: block; margin: 8px 0 4px; }
|
||||
input, select, option { color: #eee; background: #1c1c1c; width: 100%; padding: 6px; }
|
||||
input, select, option { color: #eee; background: #1c1c1c; width: 100%; padding: 6px; box-sizing: border-box; }
|
||||
/* width:100% + the UA default input margins overflowed narrow viewports
|
||||
(mobile Settings scrolled 16px sideways). Zero the horizontal margins. */
|
||||
.settings input, .settings select { margin-left: 0; margin-right: 0; }
|
||||
option { color: #eee; background: #1c1c1c; }
|
||||
button { margin-top: 16px; }
|
||||
.section { margin-top: 20px; padding-top: 12px; border-top: 1px solid #333; }
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Single source of truth for the inventory crop geometry (issue #99, req. 4).
|
||||
*
|
||||
* The 90% fill and the 3:4 aspect used to be duplicated as `const FILL = 0.9`
|
||||
* in `png/inventory.ts` and `png/overlay.ts`. Both now read from here, and the
|
||||
* live fill comes from the global `settings.crop_fill` (default 90%) so the
|
||||
* reference line and the PNG crop can never disagree.
|
||||
*/
|
||||
|
||||
/** Default fill (90%) — mirrors the `settings.crop_fill` default. */
|
||||
export const FILL_DEFAULT = 0.9;
|
||||
export const ASPECT_W = 3;
|
||||
export const ASPECT_H = 4;
|
||||
/** Shorter side of the output frame, px. */
|
||||
export const FLOOR = 800;
|
||||
|
||||
/**
|
||||
* Coerce a `settings.crop_fill` value into a 0 < fill <= 1 scale.
|
||||
* Accepts a percentage (90) or a fraction (0.9); anything unusable falls back
|
||||
* to {@link FILL_DEFAULT} rather than silently cropping to zero.
|
||||
*/
|
||||
export function fillFromSetting(v: unknown): number {
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
if (!Number.isFinite(n) || n <= 0) return FILL_DEFAULT;
|
||||
return Math.min(1, n > 1 ? n / 100 : n);
|
||||
}
|
||||
|
||||
/** Output frame size: shorter side clamped to {@link FLOOR}. */
|
||||
export function cropFrameSize(sw: number, sh: number): { outW: number; outH: number; belowFloor: boolean } {
|
||||
const belowFloor = Math.min(sw, sh) < FLOOR;
|
||||
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
|
||||
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
|
||||
return { outW, outH, belowFloor };
|
||||
}
|
||||
|
||||
export interface CropGeometry {
|
||||
scale: number;
|
||||
dw: number;
|
||||
dh: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
}
|
||||
|
||||
/** Scale + centring offsets of a `sw × sh` slab inside an `outW × outH` frame. */
|
||||
export function cropOffsets(
|
||||
sw: number,
|
||||
sh: number,
|
||||
outW: number,
|
||||
outH: number,
|
||||
fill: number = FILL_DEFAULT,
|
||||
): CropGeometry {
|
||||
const scale = Math.min((outW * fill) / Math.max(sw, 1), (outH * fill) / Math.max(sh, 1));
|
||||
const dw = sw * scale;
|
||||
const dh = sh * scale;
|
||||
return { scale, dw, dh, ox: (outW - dw) / 2, oy: (outH - dh) / 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fraction of the frame's LONG axis taken by the crop margin (issue #99, req. 3):
|
||||
* the slab's long edge sits at `oy` (or `ox`) = `outH * (1 - fill) / 2`, so the
|
||||
* fixed reference line is that same fraction in from the frame edge.
|
||||
*/
|
||||
export function cropMarginFraction(fill: number = FILL_DEFAULT): number {
|
||||
return (1 - fill) / 2;
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
|
||||
const FLOOR = 800; // shorter side, px
|
||||
const FILL = 0.9; // 90% fill at the widest and tallest points of the slab
|
||||
const ASPECT_W = 3;
|
||||
const ASPECT_H = 4;
|
||||
import { FILL_DEFAULT, cropFrameSize, cropOffsets } from './crop';
|
||||
|
||||
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
|
||||
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
|
||||
@@ -12,6 +8,8 @@ export interface InventoryInput {
|
||||
bitmap: ImageBitmap;
|
||||
mask: MaskPick;
|
||||
source: SourcePick;
|
||||
/** Crop fill, 0<fill<=1. Defaults to FILL_DEFAULT (the 90% settings default). */
|
||||
fill?: number;
|
||||
}
|
||||
|
||||
export interface Frame {
|
||||
@@ -25,6 +23,7 @@ export function composeInventoryFrame(
|
||||
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
|
||||
mask: MaskPick,
|
||||
source: SourcePick,
|
||||
fill: number = FILL_DEFAULT,
|
||||
): Frame {
|
||||
const b = mask.slabBounds;
|
||||
const x0 = Math.max(0, Math.round(b.x));
|
||||
@@ -33,14 +32,8 @@ export function composeInventoryFrame(
|
||||
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h)));
|
||||
const sw = x1 - x0;
|
||||
const sh = y1 - y0;
|
||||
const belowFloor = Math.min(sw, sh) < FLOOR;
|
||||
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
|
||||
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
|
||||
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
|
||||
const dw = sw * scale;
|
||||
const dh = sh * scale;
|
||||
const ox = (outW - dw) / 2;
|
||||
const oy = (outH - dh) / 2;
|
||||
const { outW, outH, belowFloor } = cropFrameSize(sw, sh);
|
||||
const { scale, dw, dh, ox, oy } = cropOffsets(sw, sh, outW, outH, fill);
|
||||
const rgba = new Uint8Array(outW * outH * 4);
|
||||
const xEnd = ox + dw;
|
||||
const yEnd = oy + dh;
|
||||
@@ -74,6 +67,7 @@ export async function encodeInventoryPng(
|
||||
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
|
||||
mask,
|
||||
source,
|
||||
input.fill,
|
||||
);
|
||||
const out = new OffscreenCanvas(frame.width, frame.height);
|
||||
const ctx = out.getContext('2d')!;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BBox } from '../capture/capture.types';
|
||||
import { axisEndpoints } from '../measure/measure';
|
||||
import { FILL_DEFAULT, cropOffsets } from './crop';
|
||||
|
||||
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
|
||||
export function overlayWidthStations(lengthIn: number): number[] {
|
||||
@@ -485,19 +486,15 @@ export function inventorySourceMap(
|
||||
source: { widthPx: number; heightPx: number },
|
||||
outW: number,
|
||||
outH: number,
|
||||
fill: number = FILL_DEFAULT,
|
||||
): InventoryMap {
|
||||
const FILL = 0.9;
|
||||
const x0 = Math.max(0, Math.round(slabBounds.x));
|
||||
const y0 = Math.max(0, Math.round(slabBounds.y));
|
||||
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
|
||||
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(slabBounds.h)));
|
||||
const sw = x1 - x0;
|
||||
const sh = y1 - y0;
|
||||
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
|
||||
const dw = sw * scale;
|
||||
const dh = sh * scale;
|
||||
const ox = (outW - dw) / 2;
|
||||
const oy = (outH - dh) / 2;
|
||||
const { scale, ox, oy } = cropOffsets(sw, sh, outW, outH, fill);
|
||||
return {
|
||||
outW,
|
||||
outH,
|
||||
|
||||
Reference in New Issue
Block a user