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