feat(capture): tape-line overlay UI replaces axis rectangle (#99)
Pointer-Events pan/zoom/tilt under a fixed black ~2pt line with rifle-scope end ticks (≥44×44 hit). AxisPanel becomes Tape line step; session persists tape transform; scale = tick distance / L. sveltedrag unavailable for Svelte 4 — Pointer Events path used.
This commit is contained in:
@@ -1,58 +1,154 @@
|
||||
<script lang="ts">
|
||||
import type { Capture } from './capture.types';
|
||||
import type { TapeTransform } from './tapeGeometry';
|
||||
import { defaultTicks } from './tapeGeometry';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
import { getCropFill, setCropFill } from '../settings/cropFill';
|
||||
import { cropMarginFraction } from '../png/crop';
|
||||
|
||||
export let state: Extract<Capture, { k: 'axis' }>;
|
||||
export let setAxisAngle: (deg: number) => void;
|
||||
export let setTape: (tape: TapeTransform) => void;
|
||||
export let approveAxis: () => void;
|
||||
export let back: () => void;
|
||||
|
||||
let cropFill = getCropFill();
|
||||
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
|
||||
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
|
||||
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
|
||||
$: tape = state.axis.tape;
|
||||
$: tiltShown = Math.round(tape.tiltDeg * 10) / 10;
|
||||
|
||||
const onTapeChange = (t: TapeTransform) => setTape(t);
|
||||
|
||||
const nudgeTilt = (delta: number) => {
|
||||
setTape({ ...tape, tiltDeg: tape.tiltDeg + delta });
|
||||
};
|
||||
|
||||
const setTilt = (deg: number) => {
|
||||
setTape({ ...tape, tiltDeg: deg });
|
||||
// Keep legacy path warm for any callers still using setAxisAngle.
|
||||
setAxisAngle(deg);
|
||||
};
|
||||
|
||||
const nudgeZoom = (dir: number) => {
|
||||
const z = dir < 0 ? Math.max(0.5, tape.zoom / 1.1) : Math.min(3, tape.zoom * 1.1);
|
||||
setTape({ ...tape, zoom: z });
|
||||
};
|
||||
|
||||
const onFill = (pct: number) => {
|
||||
const fill = Math.min(1, Math.max(0.01, pct / 100));
|
||||
cropFill = setCropFill(fill);
|
||||
const ticks = defaultTicks(cropFill);
|
||||
setTape({ ...tape, ...ticks });
|
||||
};
|
||||
|
||||
const fit = () => setTape({ ...tape, zoom: 1, panX: 0, panY: 0 });
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Length axis</h1>
|
||||
<p class="hint">Rotate the rectangle until it follows the slab length. The rectangle sets direction only; you enter length L on the next step.</p>
|
||||
<h1>Tape line</h1>
|
||||
<p class="hint">Drag each tick to where your tape started and ended.</p>
|
||||
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
|
||||
|
||||
<PhotoStage
|
||||
mode="tape"
|
||||
blob={state.source.blob}
|
||||
bbox={state.axis.bbox}
|
||||
maskBitset={state.approvedMask.bitset}
|
||||
{maskW}
|
||||
{maskH}
|
||||
{tape}
|
||||
{cropFill}
|
||||
onTapeChange={onTapeChange}
|
||||
/>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
|
||||
<input
|
||||
type="range" min="0" max="179" step="1"
|
||||
value={angleDeg}
|
||||
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button>
|
||||
<span class="val">{angleDeg}°</span>
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<p class="ctl-label">Tilt the slab</p>
|
||||
<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}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Zoom the slab</p>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => nudgeZoom(-1)}>−</button>
|
||||
<input
|
||||
type="range" min="50" max="300" step="1"
|
||||
value={Math.round(tape.zoom * 100)}
|
||||
on:input={(e) => setTape({ ...tape, zoom: Number(e.currentTarget.value) / 100 })}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Zoom in" on:click={() => nudgeZoom(1)}>+</button>
|
||||
<span class="val">{Math.round(tape.zoom * 100)}%</span>
|
||||
</div>
|
||||
<div class="row tight">
|
||||
<button type="button" class="ghost" on:click={fit}>Fit — 100%, centred</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Crop fill % <span class="sub">global setting</span></p>
|
||||
<div class="croprow">
|
||||
<input
|
||||
type="range" min="50" max="100" step="1"
|
||||
value={Math.round(cropFill * 100)}
|
||||
aria-label="Crop fill percent"
|
||||
on:input={(e) => onFill(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span class="val">{Math.round(cropFill * 100)}%</span>
|
||||
</div>
|
||||
<p class="nudge">
|
||||
settings.crop_fill (default 90). At {Math.round(cropFill * 100)}% the overlay ends sit
|
||||
{(cropMarginFraction(cropFill) * 100).toFixed(1)}% in from top and bottom.
|
||||
The 3:4 aspect never changes. Stored PNGs are not re-cropped.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="nums">
|
||||
<div><span>Tape span between ticks</span><span class="tab">{tape.spanPx.toFixed(0)} px</span></div>
|
||||
<div><span>Scale (once L entered)</span><span class="tab dim">span / L → px/in</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button type="button" class="primary" on:click={approveAxis}>Approve axis</button>
|
||||
<button type="button" class="primary" on:click={approveAxis}>Approve tape line</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
|
||||
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; }
|
||||
.nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; }
|
||||
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.ctl-label { font-size: 0.78rem; color: #aaa; margin: 0 0 6px; }
|
||||
.ctl-label .sub { font-size: 0.7rem; color: #777; }
|
||||
.angle, .croprow { display: flex; align-items: center; gap: 10px; }
|
||||
.thumb {
|
||||
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
|
||||
width: 44px; height: 44px; border-radius: 50%; font-size: 1.4rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
input[type='range'] { flex: 1; }
|
||||
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.row.tight { margin-top: 8px; }
|
||||
.primary {
|
||||
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
.nums { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
|
||||
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; font-size: 0.84rem; gap: 10px; }
|
||||
.nums span:first-child { color: #aaa; }
|
||||
.tab { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.dim { color: #777; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user