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">
|
<script lang="ts">
|
||||||
import type { Capture } from './capture.types';
|
import type { Capture } from './capture.types';
|
||||||
import type { TapeTransform } from './tapeGeometry';
|
import type { TapeTransform } from './tapeGeometry';
|
||||||
import { defaultTicks } from './tapeGeometry';
|
import { clampTiltDeg, defaultTicks } from './tapeGeometry';
|
||||||
import PhotoStage from './PhotoStage.svelte';
|
import PhotoStage from './PhotoStage.svelte';
|
||||||
import { getCropFill, setCropFill } from '../settings/cropFill';
|
import { getCropFill, setCropFill } from '../settings/cropFill';
|
||||||
import { cropMarginFraction } from '../png/crop';
|
import { cropMarginFraction } from '../png/crop';
|
||||||
@@ -21,13 +21,14 @@
|
|||||||
const onTapeChange = (t: TapeTransform) => setTape(t);
|
const onTapeChange = (t: TapeTransform) => setTape(t);
|
||||||
|
|
||||||
const nudgeTilt = (delta: number) => {
|
const nudgeTilt = (delta: number) => {
|
||||||
setTape({ ...tape, tiltDeg: tape.tiltDeg + delta });
|
setTape({ ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg + delta) });
|
||||||
};
|
};
|
||||||
|
|
||||||
const setTilt = (deg: number) => {
|
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.
|
// Keep legacy path warm for any callers still using setAxisAngle.
|
||||||
setAxisAngle(deg);
|
setAxisAngle(clamped);
|
||||||
};
|
};
|
||||||
|
|
||||||
const nudgeZoom = (dir: number) => {
|
const nudgeZoom = (dir: number) => {
|
||||||
@@ -67,14 +68,14 @@
|
|||||||
<div class="angle">
|
<div class="angle">
|
||||||
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => nudgeTilt(-1)}>−</button>
|
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => nudgeTilt(-1)}>−</button>
|
||||||
<input
|
<input
|
||||||
type="range" min="0" max="359" step="0.1"
|
type="range" min="-90" max="90" step="0.1"
|
||||||
value={((tiltShown % 360) + 360) % 360}
|
value={clampTiltDeg(tiltShown)}
|
||||||
on:input={(e) => setTilt(Number(e.currentTarget.value))}
|
on:input={(e) => setTilt(Number(e.currentTarget.value))}
|
||||||
/>
|
/>
|
||||||
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
|
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
|
||||||
<span class="val">{tiltShown}°</span>
|
<span class="val">{tiltShown}°</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onDestroy, onMount, tick } from 'svelte';
|
import { onDestroy, onMount, tick } from 'svelte';
|
||||||
import type { BBox } from './capture.types';
|
import type { BBox } from './capture.types';
|
||||||
import type { TapeTransform } from './tapeGeometry';
|
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 { fitDisplay } from './fitDisplay';
|
||||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||||
import { cropMarginFraction } from '../png/crop';
|
import { cropMarginFraction } from '../png/crop';
|
||||||
@@ -144,6 +144,8 @@
|
|||||||
let pointers = new Map<number, { x: number; y: number }>();
|
let pointers = new Map<number, { x: number; y: number }>();
|
||||||
let pinchStartDist = 0;
|
let pinchStartDist = 0;
|
||||||
let pinchStartZoom = 1;
|
let pinchStartZoom = 1;
|
||||||
|
let pinchStartAngle = 0;
|
||||||
|
let pinchStartTilt = 0;
|
||||||
|
|
||||||
const stagePoint = (clientX: number, clientY: number): { x: number; y: number } => {
|
const stagePoint = (clientX: number, clientY: number): { x: number; y: number } => {
|
||||||
const el = tapeWrap;
|
const el = tapeWrap;
|
||||||
@@ -176,8 +178,12 @@
|
|||||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
if (pointers.size === 2) {
|
if (pointers.size === 2) {
|
||||||
const pts = [...pointers.values()];
|
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;
|
pinchStartZoom = live.zoom;
|
||||||
|
pinchStartAngle = Math.atan2(dy, dx);
|
||||||
|
pinchStartTilt = live.tiltDeg;
|
||||||
drag = null;
|
drag = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -200,9 +206,14 @@
|
|||||||
if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
if (pointers.size === 2) {
|
if (pointers.size === 2) {
|
||||||
const pts = [...pointers.values()];
|
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)));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
@@ -220,7 +231,7 @@
|
|||||||
const a0 = Math.atan2(drag.y0 - cy, drag.x0 - cx);
|
const a0 = Math.atan2(drag.y0 - cy, drag.x0 - cx);
|
||||||
const a1 = Math.atan2(p.y - cy, p.x - cx);
|
const a1 = Math.atan2(p.y - cy, p.x - cx);
|
||||||
const delta = ((a1 - a0) * 180) / Math.PI;
|
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();
|
e.preventDefault();
|
||||||
const step = fine ? 0.1 : 1;
|
const step = fine ? 0.1 : 1;
|
||||||
const dir = e.key === 'ArrowLeft' ? -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') {
|
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const step = fine ? 0.1 : 1;
|
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 { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
||||||
import { rotateCalibration } from '../png/rotateCal';
|
import { rotateCalibration } from '../png/rotateCal';
|
||||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
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 { getCropFill } from '../settings/cropFill';
|
||||||
import type { TapeLine } from './capture.types';
|
import type { TapeLine } from './capture.types';
|
||||||
|
|
||||||
@@ -323,7 +323,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
return { ...d.tape };
|
return { ...d.tape };
|
||||||
}
|
}
|
||||||
// Seed tilt so the mask long side sits near vertical under the fixed line.
|
// 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 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 };
|
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 };
|
return { ...t, spanPx: ep.spanPx };
|
||||||
@@ -338,7 +338,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
: bbox.angleDeg;
|
: bbox.angleDeg;
|
||||||
const nextBbox = orientedRect(bbox, angleDeg);
|
const nextBbox = orientedRect(bbox, angleDeg);
|
||||||
const line: TapeLine = {
|
const line: TapeLine = {
|
||||||
tiltDeg: tape.tiltDeg,
|
tiltDeg: clampTiltDeg(tape.tiltDeg),
|
||||||
zoom: tape.zoom,
|
zoom: tape.zoom,
|
||||||
panX: tape.panX,
|
panX: tape.panX,
|
||||||
panY: tape.panY,
|
panY: tape.panY,
|
||||||
@@ -534,7 +534,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
};
|
};
|
||||||
const w = state.source.widthPx;
|
const w = state.source.widthPx;
|
||||||
const h = state.source.heightPx;
|
const h = state.source.heightPx;
|
||||||
const seedTilt = 90 - bbox.angleDeg;
|
const seedTilt = clampTiltDeg(90 - bbox.angleDeg);
|
||||||
const tape0 = defaultTape(getCropFill(), seedTilt);
|
const tape0 = defaultTape(getCropFill(), seedTilt);
|
||||||
const axis = axisWithTape(bbox, tape0, w, h);
|
const axis = axisWithTape(bbox, tape0, w, h);
|
||||||
set({ k: 'axis', source: state.source, approvedMask: overlay, axis });
|
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).
|
// Legacy slider path — maps to photo tilt under the fixed tape line (#99).
|
||||||
setAxisAngle(deg: number) {
|
setAxisAngle(deg: number) {
|
||||||
if (state.k !== 'axis') return;
|
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);
|
const axis = axisWithTape(state.axis.bbox, tape, state.source.widthPx, state.source.heightPx);
|
||||||
set({ ...state, axis });
|
set({ ...state, axis });
|
||||||
void persist();
|
void persist();
|
||||||
@@ -552,7 +552,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
|
|
||||||
setTape(tape: TapeTransform) {
|
setTape(tape: TapeTransform) {
|
||||||
if (state.k !== 'axis') return;
|
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 });
|
set({ ...state, axis });
|
||||||
void persist();
|
void persist();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
STAGE_H, STAGE_W, clampTickPair, containFit, defaultTape, defaultTicks,
|
STAGE_H, STAGE_W, TILT_MAX, TILT_MIN, clampTickPair, clampTiltDeg, containFit,
|
||||||
lineX, stageToImage, tapeEndpoints,
|
defaultTape, defaultTicks, lineX, stageToImage, tapeEndpoints,
|
||||||
} from './tapeGeometry.ts';
|
} from './tapeGeometry.ts';
|
||||||
import { cropMarginFraction, DEFAULT_CROP_FILL } from '../png/crop.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.tickB - p.tickA >= 40);
|
||||||
assert.ok(p.tickA <= p.tickB);
|
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_W = 480;
|
||||||
export const STAGE_H = 640;
|
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 {
|
export interface TapeTransform {
|
||||||
/** Photo tilt in degrees (CSS-style; positive = clockwise). */
|
/** Photo tilt in degrees (CSS-style; positive = clockwise). */
|
||||||
tiltDeg: number;
|
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 {
|
export function defaultTape(fill: number = DEFAULT_CROP_FILL, seedTiltDeg = 0): TapeTransform {
|
||||||
const t = defaultTicks(fill);
|
const t = defaultTicks(fill);
|
||||||
return {
|
return {
|
||||||
tiltDeg: seedTiltDeg,
|
tiltDeg: clampTiltDeg(seedTiltDeg),
|
||||||
zoom: 1,
|
zoom: 1,
|
||||||
panX: 0,
|
panX: 0,
|
||||||
panY: 0,
|
panY: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user