diff --git a/frontend/src/capture/AxisPanel.svelte b/frontend/src/capture/AxisPanel.svelte index d98caa9..4a798f8 100644 --- a/frontend/src/capture/AxisPanel.svelte +++ b/frontend/src/capture/AxisPanel.svelte @@ -76,7 +76,7 @@ {tiltShown}° -

±90° · ← → tilt 1° · Shift+← → 0.1° · two-finger twist · or drag the ↻ handle

+

±90° · ← → tilt 1° · Shift+← → 0.1° · two-finger twist (Rotate gesture mode)

diff --git a/frontend/src/capture/MeasurePanel.svelte b/frontend/src/capture/MeasurePanel.svelte index 353afe4..1ad1bc4 100644 --- a/frontend/src/capture/MeasurePanel.svelte +++ b/frontend/src/capture/MeasurePanel.svelte @@ -37,6 +37,8 @@ let speciesLoadErr = ''; let cropFill = getCropFill(); + /** Exclusive stage gestures: Zoom/Pan (default) vs Rotate. Sliders stay available either way. */ + let gestureMode: 'zoompan' | 'rotate' = 'zoompan'; $: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0; $: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0; $: tape = state.axis.tape; @@ -200,12 +202,39 @@

Orient the slab on the fixed tape line, then enter length, thickness, and species.

The black line is fixed: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.

+
+ + +
+

+ {#if gestureMode === 'zoompan'} + Pinch to zoom and drag to pan. Rotation gestures are ignored — switch to Rotate or use the tilt slider. + {:else} + Two-finger twist to rotate (or use the tilt slider). Pan and pinch-zoom are ignored in this mode. + {/if} +

+ {#if maskedPreview} {:else} @@ -227,7 +256,7 @@ {tiltShown}°
-

±90° · ← → tilt 1° · Shift+← → 0.1° · two-finger twist · or drag the ↻ handle

+

±90° · slider always works · in Rotate mode: ← → tilt 1° · Shift+← → 0.1° · two-finger twist

@@ -408,6 +437,17 @@ .panel { padding: 16px; color: #eee; } h1 { font-size: 1.3rem; margin: 0 0 8px; } .hint { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; } + .mode-toggle { + display: flex; gap: 0; margin: 10px 0 4px; + border: 1px solid #444; border-radius: 10px; overflow: hidden; width: fit-content; max-width: 100%; + } + .mode-btn { + min-height: 44px; padding: 10px 18px; font-size: 0.95rem; font-weight: 600; + border: 0; background: #1d1d1d; color: #aaa; cursor: pointer; + } + .mode-btn + .mode-btn { border-left: 1px solid #444; } + .mode-btn.active { background: #00e5ff; color: #002; } + .mode-hint { margin: 0 0 10px; } .nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; } .preview-loading { min-height: 420px; border: 1px solid #333; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #999; text-align: center; padding: 16px; box-sizing: border-box; } .controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; } diff --git a/frontend/src/capture/PhotoStage.svelte b/frontend/src/capture/PhotoStage.svelte index a542d4f..1fc3d85 100644 --- a/frontend/src/capture/PhotoStage.svelte +++ b/frontend/src/capture/PhotoStage.svelte @@ -25,6 +25,13 @@ export let onTapeChange: ((t: TapeTransform) => void) | null = null; /** When false, tape overlay is display-only (Measure step). */ export let interactive = true; + /** + * Exclusive stage gestures so pinch/drag don't fight. + * zoompan (default): pinch zoom + one-finger pan; ignore rotate gestures. + * rotate: two-finger twist only; ignore pan/zoom from those gestures. + * Tilt/zoom sliders outside the stage stay available either way. + */ + export let gestureMode: 'zoompan' | 'rotate' = 'zoompan'; let canvas: HTMLCanvasElement | undefined; let stage: HTMLDivElement | undefined; @@ -139,7 +146,7 @@ $: if (blob || bbox || maskBitset || mode) void tick().then(redraw); // ---- tape pointer interactions ---- - type DragKind = null | 'pan' | 'tickA' | 'tickB' | 'rot'; + type DragKind = null | 'pan' | 'tickA' | 'tickB'; let drag: { kind: DragKind; x0: number; y0: number; panX0: number; panY0: number; tilt0: number } | null = null; let pointers = new Map(); let pinchStartDist = 0; @@ -170,12 +177,6 @@ return null; }; - const hitRot = (x: number, y: number): boolean => { - const cx = STAGE_W / 2; - const cy = STAGE_H - 26; - return Math.hypot(x - cx, y - cy) <= 22; - }; - const onPointerDown = (e: PointerEvent) => { if (!interactive || mode !== 'tape') return; (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); @@ -198,11 +199,10 @@ drag = { kind: tick === 'A' ? 'tickA' : 'tickB', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg }; return; } - if (hitRot(p.x, p.y)) { - drag = { kind: 'rot', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg }; - return; + // One-finger pan only in Zoom/Pan mode (Rotate mode uses slider + two-finger twist). + if (gestureMode === 'zoompan') { + drag = { kind: 'pan', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg }; } - drag = { kind: 'pan', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg }; }; const onPointerMove = (e: PointerEvent) => { @@ -212,19 +212,24 @@ const pts = [...pointers.values()]; const dx = pts[1].x - pts[0].x; const dy = pts[1].y - pts[0].y; - const dist = Math.hypot(dx, dy) || 1; - const ratio = dist / pinchStartDist; - const softenedRatio = 1 + (ratio - 1) * PINCH_ZOOM_DAMP; - const z = Math.min(3, Math.max(0.5, pinchStartZoom * softenedRatio)); - // 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 * ROTATE_DAMP) }); + if (gestureMode === 'zoompan') { + const dist = Math.hypot(dx, dy) || 1; + const ratio = dist / pinchStartDist; + const softenedRatio = 1 + (ratio - 1) * PINCH_ZOOM_DAMP; + const z = Math.min(3, Math.max(0.5, pinchStartZoom * softenedRatio)); + emit({ ...live, zoom: z }); + } else { + // 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, tiltDeg: clampTiltDeg(pinchStartTilt + deltaDeg * ROTATE_DAMP) }); + } return; } if (!drag) return; const p = stagePoint(e.clientX, e.clientY); if (drag.kind === 'pan') { + if (gestureMode !== 'zoompan') return; emit({ ...live, panX: drag.panX0 + (p.x - drag.x0) * PAN_DAMP, @@ -235,13 +240,6 @@ ? clampTickPair(p.y, live.tickB) : clampTickPair(live.tickA, p.y); emit({ ...live, ...pair }); - } else if (drag.kind === 'rot') { - const cx = STAGE_W / 2; - const cy = STAGE_H / 2; - 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: clampTiltDeg(drag.tilt0 + delta * ROTATE_DAMP) }); } }; @@ -251,7 +249,7 @@ }; const onWheel = (e: WheelEvent) => { - if (!interactive || mode !== 'tape') return; + if (!interactive || mode !== 'tape' || gestureMode !== 'zoompan') return; e.preventDefault(); const factor = e.deltaY > 0 ? 0.92 : 1.08; emit({ ...live, zoom: Math.min(3, Math.max(0.5, live.zoom * factor)) }); @@ -261,6 +259,7 @@ if (!interactive || mode !== 'tape') return; const fine = e.shiftKey; if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + if (gestureMode !== 'rotate') return; e.preventDefault(); const step = fine ? 0.1 : 1; const dir = e.key === 'ArrowLeft' ? -1 : 1; @@ -275,9 +274,11 @@ emit({ ...live, ...clampTickPair(live.tickA, live.tickB + dir * step) }); } } else if (e.key === '+' || e.key === '=') { + if (gestureMode !== 'zoompan') return; e.preventDefault(); emit({ ...live, zoom: Math.min(3, live.zoom * 1.1) }); } else if (e.key === '-' || e.key === '_') { + if (gestureMode !== 'zoompan') return; e.preventDefault(); emit({ ...live, zoom: Math.max(0.5, live.zoom / 1.1) }); } @@ -340,6 +341,7 @@
{/each} - {#if interactive} - - - ↻ - - {/if} {#if decoding && imgW === 0}
Decoding photo…
@@ -400,6 +396,7 @@ touch-action: none; user-select: none; cursor: grab; } .tw.dragging { cursor: grabbing; } + .tw.mode-rotate { cursor: ew-resize; } .vb { position: absolute; top: 0; left: 0; transform-origin: top left; @@ -413,5 +410,4 @@ .band.bot { bottom: 0; } .fixture { position: absolute; inset: 0; z-index: 2; pointer-events: none; } .tick-hit { pointer-events: auto; cursor: ns-resize; } - .rot-handle { pointer-events: auto; cursor: ew-resize; }