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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user