fix(capture): ±90° tilt slider midpoint + two-finger rotate

Clamp tape tilt to [-90, 90] so the slider knob starts centered and
CCW works. PhotoStage pinch now tracks angle as well as zoom.
This commit is contained in:
grokbot_ty
2026-09-26 07:54:36 -07:00
parent ba30a340e6
commit b841b16cc0
5 changed files with 62 additions and 22 deletions
+8 -7
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Capture } from './capture.types';
import type { TapeTransform } from './tapeGeometry';
import { defaultTicks } from './tapeGeometry';
import { clampTiltDeg, defaultTicks } from './tapeGeometry';
import PhotoStage from './PhotoStage.svelte';
import { getCropFill, setCropFill } from '../settings/cropFill';
import { cropMarginFraction } from '../png/crop';
@@ -21,13 +21,14 @@
const onTapeChange = (t: TapeTransform) => setTape(t);
const nudgeTilt = (delta: number) => {
setTape({ ...tape, tiltDeg: tape.tiltDeg + delta });
setTape({ ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg + delta) });
};
const setTilt = (deg: number) => {
setTape({ ...tape, tiltDeg: deg });
const clamped = clampTiltDeg(deg);
setTape({ ...tape, tiltDeg: clamped });
// Keep legacy path warm for any callers still using setAxisAngle.
setAxisAngle(deg);
setAxisAngle(clamped);
};
const nudgeZoom = (dir: number) => {
@@ -67,14 +68,14 @@
<div class="angle">
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => nudgeTilt(-1)}>−</button>
<input
type="range" min="0" max="359" step="0.1"
value={((tiltShown % 360) + 360) % 360}
type="range" min="-90" max="90" step="0.1"
value={clampTiltDeg(tiltShown)}
on:input={(e) => setTilt(Number(e.currentTarget.value))}
/>
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
<span class="val">{tiltShown}°</span>
</div>
<p class="nudge">← → tilt 1° · Shift+← → 0.1° · or drag the ↻ handle</p>
<p class="nudge">±90° · ← → tilt 1° · Shift+← → 0.1° · two-finger twist · or drag the ↻ handle</p>
</div>
<div>
+17 -6
View File
@@ -2,7 +2,7 @@
import { onDestroy, onMount, tick } from 'svelte';
import type { BBox } from './capture.types';
import type { TapeTransform } from './tapeGeometry';
import { STAGE_H, STAGE_W, clampTickPair, defaultTicks, lineX } from './tapeGeometry';
import { STAGE_H, STAGE_W, clampTickPair, clampTiltDeg, defaultTicks, lineX } from './tapeGeometry';
import { fitDisplay } from './fitDisplay';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
import { cropMarginFraction } from '../png/crop';
@@ -144,6 +144,8 @@
let pointers = new Map<number, { x: number; y: number }>();
let pinchStartDist = 0;
let pinchStartZoom = 1;
let pinchStartAngle = 0;
let pinchStartTilt = 0;
const stagePoint = (clientX: number, clientY: number): { x: number; y: number } => {
const el = tapeWrap;
@@ -176,8 +178,12 @@
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 2) {
const pts = [...pointers.values()];
pinchStartDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
const dx = pts[1].x - pts[0].x;
const dy = pts[1].y - pts[0].y;
pinchStartDist = Math.hypot(dx, dy) || 1;
pinchStartZoom = live.zoom;
pinchStartAngle = Math.atan2(dy, dx);
pinchStartTilt = live.tiltDeg;
drag = null;
return;
}
@@ -200,9 +206,14 @@
if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 2) {
const pts = [...pointers.values()];
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
const dx = pts[1].x - pts[0].x;
const dy = pts[1].y - pts[0].y;
const dist = Math.hypot(dx, dy) || 1;
const z = Math.min(3, Math.max(0.5, pinchStartZoom * (dist / pinchStartDist)));
emit({ ...live, zoom: z });
// Screen y-down atan2 delta matches CSS rotate (positive = clockwise).
const angle = Math.atan2(dy, dx);
const deltaDeg = ((angle - pinchStartAngle) * 180) / Math.PI;
emit({ ...live, zoom: z, tiltDeg: clampTiltDeg(pinchStartTilt + deltaDeg) });
return;
}
if (!drag) return;
@@ -220,7 +231,7 @@
const a0 = Math.atan2(drag.y0 - cy, drag.x0 - cx);
const a1 = Math.atan2(p.y - cy, p.x - cx);
const delta = ((a1 - a0) * 180) / Math.PI;
emit({ ...live, tiltDeg: drag.tilt0 + delta });
emit({ ...live, tiltDeg: clampTiltDeg(drag.tilt0 + delta) });
}
};
@@ -243,7 +254,7 @@
e.preventDefault();
const step = fine ? 0.1 : 1;
const dir = e.key === 'ArrowLeft' ? -1 : 1;
emit({ ...live, tiltDeg: live.tiltDeg + dir * step });
emit({ ...live, tiltDeg: clampTiltDeg(live.tiltDeg + dir * step) });
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const step = fine ? 0.1 : 1;
+7 -6
View File
@@ -12,7 +12,7 @@ import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
import { rotateCalibration } from '../png/rotateCal';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
import { defaultTape, tapeEndpoints, type TapeTransform } from './tapeGeometry';
import { clampTiltDeg, defaultTape, tapeEndpoints, type TapeTransform } from './tapeGeometry';
import { getCropFill } from '../settings/cropFill';
import type { TapeLine } from './capture.types';
@@ -323,7 +323,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
return { ...d.tape };
}
// Seed tilt so the mask long side sits near vertical under the fixed line.
const seedTilt = 90 - bbox.angleDeg;
const seedTilt = clampTiltDeg(90 - bbox.angleDeg);
const t = defaultTape(getCropFill(), seedTilt);
const ep = w > 0 && h > 0 ? tapeEndpoints(t, w, h) : { spanPx: 0, start: [0, 0] as [number, number], end: [0, 0] as [number, number], ux: 0, uy: 1 };
return { ...t, spanPx: ep.spanPx };
@@ -338,7 +338,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
: bbox.angleDeg;
const nextBbox = orientedRect(bbox, angleDeg);
const line: TapeLine = {
tiltDeg: tape.tiltDeg,
tiltDeg: clampTiltDeg(tape.tiltDeg),
zoom: tape.zoom,
panX: tape.panX,
panY: tape.panY,
@@ -534,7 +534,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
};
const w = state.source.widthPx;
const h = state.source.heightPx;
const seedTilt = 90 - bbox.angleDeg;
const seedTilt = clampTiltDeg(90 - bbox.angleDeg);
const tape0 = defaultTape(getCropFill(), seedTilt);
const axis = axisWithTape(bbox, tape0, w, h);
set({ k: 'axis', source: state.source, approvedMask: overlay, axis });
@@ -544,7 +544,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
// Legacy slider path — maps to photo tilt under the fixed tape line (#99).
setAxisAngle(deg: number) {
if (state.k !== 'axis') return;
const tape = { ...state.axis.tape, tiltDeg: deg };
const tape = { ...state.axis.tape, tiltDeg: clampTiltDeg(deg) };
const axis = axisWithTape(state.axis.bbox, tape, state.source.widthPx, state.source.heightPx);
set({ ...state, axis });
void persist();
@@ -552,7 +552,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
setTape(tape: TapeTransform) {
if (state.k !== 'axis') return;
const axis = axisWithTape(state.axis.bbox, tape, state.source.widthPx, state.source.heightPx);
const clamped = { ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg) };
const axis = axisWithTape(state.axis.bbox, clamped, state.source.widthPx, state.source.heightPx);
set({ ...state, axis });
void persist();
},
+20 -2
View File
@@ -1,8 +1,8 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
STAGE_H, STAGE_W, clampTickPair, containFit, defaultTape, defaultTicks,
lineX, stageToImage, tapeEndpoints,
STAGE_H, STAGE_W, TILT_MAX, TILT_MIN, clampTickPair, clampTiltDeg, containFit,
defaultTape, defaultTicks, lineX, stageToImage, tapeEndpoints,
} from './tapeGeometry.ts';
import { cropMarginFraction, DEFAULT_CROP_FILL } from '../png/crop.ts';
@@ -51,3 +51,21 @@ test('clampTickPair enforces gap and order', () => {
assert.ok(p.tickB - p.tickA >= 40);
assert.ok(p.tickA <= p.tickB);
});
test('clampTiltDeg clamps to ±90', () => {
assert.equal(TILT_MIN, -90);
assert.equal(TILT_MAX, 90);
assert.equal(clampTiltDeg(0), 0);
assert.equal(clampTiltDeg(45.5), 45.5);
assert.equal(clampTiltDeg(-45.5), -45.5);
assert.equal(clampTiltDeg(90), 90);
assert.equal(clampTiltDeg(-90), -90);
assert.equal(clampTiltDeg(91), 90);
assert.equal(clampTiltDeg(-100), -90);
assert.equal(clampTiltDeg(Number.NaN), 0);
});
test('defaultTape clamps seed tilt', () => {
assert.equal(defaultTape(0.9, 120).tiltDeg, 90);
assert.equal(defaultTape(0.9, -200).tiltDeg, -90);
});
+10 -1
View File
@@ -9,6 +9,15 @@ import { cropMarginFraction, DEFAULT_CROP_FILL } from '../png/crop';
export const STAGE_W = 480;
export const STAGE_H = 640;
/** Interactive tilt limits (degrees). Slider midpoint = 0; both ways ±90. */
export const TILT_MIN = -90;
export const TILT_MAX = 90;
export function clampTiltDeg(deg: number): number {
if (!Number.isFinite(deg)) return 0;
return Math.min(TILT_MAX, Math.max(TILT_MIN, deg));
}
export interface TapeTransform {
/** Photo tilt in degrees (CSS-style; positive = clockwise). */
tiltDeg: number;
@@ -29,7 +38,7 @@ export function defaultTicks(fill: number = DEFAULT_CROP_FILL): { tickA: number;
export function defaultTape(fill: number = DEFAULT_CROP_FILL, seedTiltDeg = 0): TapeTransform {
const t = defaultTicks(fill);
return {
tiltDeg: seedTiltDeg,
tiltDeg: clampTiltDeg(seedTiltDeg),
zoom: 1,
panX: 0,
panY: 0,