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:
Ty
2026-09-24 12:01:18 -07:00
parent 0c00d25cc7
commit 818874e65c
13 changed files with 831 additions and 23 deletions
+10
View File
@@ -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",
+1
View File
@@ -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"
},
+28
View File
@@ -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; }
+2
View File
@@ -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> {
+611
View File
@@ -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)}&#8243;</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>
+1 -1
View File
@@ -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
+12 -2
View File
@@ -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();
}
+13 -1
View File
@@ -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; }
+65
View File
@@ -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;
}
+7 -13
View File
@@ -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')!;
+3 -6
View File
@@ -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,