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
+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();
}