feat: world-axis selector, 8-point circle, annotated measurement tool

Datum editor (step 3):
- Add world-axis role to rectangles (isAxisReference) and lines
  (axisRole: "x"|"y"). Exclusive via a new store action that clears
  any other axis flag on write. The solver's pickPrimary now honors an
  explicit user flag ahead of the type-priority fallback; line-primary
  correspondences target world +x or +y depending on the flag.
- Panel UI: checkbox on rect cards, three-way button row on line cards,
  and an axis badge in each card header.
- Ellipse datum switches to 8 user-placed points on the circle contour.
  New src/lib/ellipse-fit.ts does an algebraic LSQ conic fit (data-
  normalised, 5x5 Gaussian solve, f=-1 constraint) and returns the
  geometric center + perpendicular conjugate semi-axes, which we cache
  on the datum for the solver and renderer. Dragging any handle
  refits; an extra center handle translates all 8 points together.
  datum-cache migrates legacy 3-handle storage by synthesising 8
  samples from the old parametric form.
- ResultViewer auto-scale now floors to an int to match the integer-
  only scale input (step=1).

Measurement tool (step 4) — CorrectedImageViewer.vue:
- Three measurement tools: line (length), ellipse (semi-axes + area),
  angle (0-180 degrees between two rays).
- Persistent, multi-measurement state. Each has id, colorIndex, and
  type-specific geometry; colors cycle via the existing getDatumColor
  palette with a monotonic counter so deletion doesn't recolor.
- Selection model with hit-testing on handles, geometry, and labels.
  Selected draws on top in white; others render dashed with 0.8/0.5
  alpha so the active measurement pops.
- Dragging geometry or label moves the whole measurement; dragging a
  handle reshapes just that handle. 3px mouse threshold distinguishes
  click from drag.
- Side panel lists measurements with color chip, type, value, and a
  delete button; clicking selects on canvas. Delete/Backspace deletes
  the selected measurement. Escape cancels in-progress placement.
- Live placement preview + inline hint strip describes what the next
  click does. Pinch-zoom and single-finger pan still work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Prevost 2026-04-24 18:10:22 +02:00
parent b87f933b9e
commit da5be3851d
10 changed files with 1658 additions and 240 deletions

File diff suppressed because it is too large Load Diff

View File

@ -53,7 +53,9 @@ function datumIndex(datum: Datum): number {
function datumPoints(datum: Datum): Point[] {
if (datum.type === "rectangle") return datum.corners as unknown as Point[]
if (datum.type === "line") return datum.endpoints as unknown as Point[]
return [datum.center, datum.axisEndA, datum.axisEndB]
// Index 0 is the center (translate all); 1..N are the on-curve points
// the user drags to reshape the fitted ellipse.
return [datum.center, ...datum.points]
}
function getPointConfigs(datum: Datum, dIdx: number) {
@ -230,24 +232,20 @@ function onPointDragMove(e: {
newEndpoints[_pointIndex] = newPos
store.updateDatum(_datumId, { endpoints: newEndpoints })
} else if (_pointIndex === 0) {
// Ellipse center translate all three handles together
// Ellipse center translate all on-curve points by the same delta
// and let the store refit.
const dx = newPos.x - datum.center.x
const dy = newPos.y - datum.center.y
store.updateDatum(_datumId, {
center: newPos,
axisEndA: {
x: datum.axisEndA.x + dx,
y: datum.axisEndA.y + dy,
},
axisEndB: {
x: datum.axisEndB.x + dx,
y: datum.axisEndB.y + dy,
},
})
} else if (_pointIndex === 1) {
store.updateDatum(_datumId, { axisEndA: newPos })
const translated = datum.points.map((p) => ({
x: p.x + dx,
y: p.y + dy,
}))
store.updateEllipsePoints(_datumId, translated)
} else {
store.updateDatum(_datumId, { axisEndB: newPos })
const newPoints = datum.points.map((p, i) =>
i === _pointIndex - 1 ? newPos : p,
)
store.updateEllipsePoints(_datumId, newPoints)
}
}

View File

@ -91,6 +91,13 @@ function typeBadge(datum: Datum): string {
if (datum.type === "line") return "Line"
return "Circle"
}
function axisBadge(datum: Datum): string | null {
if (datum.type === "rectangle" && datum.isAxisReference) return "axis"
if (datum.type === "line" && datum.axisRole === "x") return "+x"
if (datum.type === "line" && datum.axisRole === "y") return "+y"
return null
}
</script>
<template>
@ -191,6 +198,13 @@ function typeBadge(datum: Datum): string {
<Badge variant="outline" class="text-xs">
{{ typeBadge(datum) }}
</Badge>
<Badge
v-if="axisBadge(datum)"
variant="default"
class="text-xs"
>
{{ axisBadge(datum) }}
</Badge>
<span class="text-xs text-muted-foreground">{{
formatDimensions(datum)
}}</span>
@ -332,6 +346,68 @@ function typeBadge(datum: Datum): string {
/>
</div>
<!-- World-axis role -->
<div
v-if="datum.type === 'rectangle'"
class="flex items-center justify-between"
>
<Label class="text-xs">World axis reference</Label>
<label
class="flex cursor-pointer items-center gap-1.5"
@click.stop
>
<input
type="checkbox"
class="accent-primary"
:checked="datum.isAxisReference ?? false"
@change="
(e) =>
store.setAxisRole(
datum.id,
(e.target as HTMLInputElement)
.checked
? 'rect'
: null,
)
"
/>
<span class="text-xs text-muted-foreground"
>Use</span
>
</label>
</div>
<div v-else-if="datum.type === 'line'">
<Label class="text-xs">World axis</Label>
<div class="mt-1 grid grid-cols-3 gap-1">
<button
v-for="opt in [
{ value: null, label: 'None' },
{ value: 'x', label: 'X' },
{ value: 'y', label: 'Y' },
]"
:key="String(opt.value)"
type="button"
class="h-7 rounded-md border text-xs font-medium transition-colors"
:class="
(datum.axisRole ?? null) === opt.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-accent'
"
@click.stop="
store.setAxisRole(
datum.id,
opt.value as
| 'x'
| 'y'
| null,
)
"
>
{{ opt.label }}
</button>
</div>
</div>
<!-- Confidence -->
<div>
<div class="flex items-center justify-between">

View File

@ -63,13 +63,24 @@ const MAX_AUTO_SCALE_DIM = 8192
* best datum by type priority (rect > line > ellipse) and then confidence.
* Returns null if no datum gives a usable scale. */
function pickScaleRef(): { srcPxPerMm: number } | null {
const best = [...store.datums].sort((a, b) => {
const rank = (d: Datum) =>
d.type === "rectangle" ? 0 : d.type === "ellipse" ? 1 : 2
const r = rank(a) - rank(b)
if (r !== 0) return r
return b.confidence - a.confidence
})[0]
const axisFlagged = store.datums.find(
(d) =>
(d.type === "rectangle" && d.isAxisReference) ||
(d.type === "line" && d.axisRole),
)
const best =
axisFlagged ??
[...store.datums].sort((a, b) => {
const rank = (d: Datum) =>
d.type === "rectangle"
? 0
: d.type === "ellipse"
? 1
: 2
const r = rank(a) - rank(b)
if (r !== 0) return r
return b.confidence - a.confidence
})[0]
if (!best) return null
if (best.type === "rectangle") {
if (best.widthMm <= 0 || best.heightMm <= 0) return null
@ -120,7 +131,8 @@ function computeAutoScale(): number {
autoScale *= MAX_AUTO_SCALE_DIM / estMax
}
return Math.max(1, Math.round(autoScale * 10) / 10)
// The scale input is integer-only; floor so the shown value round-trips.
return Math.max(1, Math.floor(autoScale))
}
onMounted(() => {
@ -383,6 +395,7 @@ async function download() {
:model-value="scaleInput"
type="number"
min="1"
step="1"
class="w-28 font-mono"
:class="
scaleValid

View File

@ -1,4 +1,4 @@
import type { Datum } from "@/types"
import type { Datum, EllipseDatum, Point } from "@/types"
const KEY_PREFIX = "skwik-datums-"
@ -17,12 +17,38 @@ export function loadDatums(hash: string): Datum[] | null {
try {
const raw = localStorage.getItem(KEY_PREFIX + hash)
if (!raw) return null
return JSON.parse(raw) as Datum[]
const parsed = JSON.parse(raw) as Datum[]
return parsed.map(migrateDatum)
} catch {
return null
}
}
/** Legacy ellipse datums stored only center+axisEndA+axisEndB (pre 8-point
* fit). On load, synthesize 8 sample points from those axes so the new
* fit-based pipeline has something to work with. */
function migrateDatum(d: Datum): Datum {
if (d.type !== "ellipse") return d
const e = d as EllipseDatum
if (Array.isArray(e.points) && e.points.length >= 5) return e
const vAx = e.axisEndA.x - e.center.x
const vAy = e.axisEndA.y - e.center.y
const vBx = e.axisEndB.x - e.center.x
const vBy = e.axisEndB.y - e.center.y
const N = 8
const points: Point[] = []
for (let i = 0; i < N; i++) {
const t = (2 * Math.PI * i) / N
const cs = Math.cos(t)
const sn = Math.sin(t)
points.push({
x: e.center.x + vAx * cs + vBx * sn,
y: e.center.y + vAy * cs + vBy * sn,
})
}
return { ...e, points }
}
export function clearCache(): void {
const toRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) {

View File

@ -89,15 +89,26 @@ export function createLineDatum(center: Point, index: number): LineDatum {
}
}
const DEFAULT_ELLIPSE_POINT_COUNT = 8
export function createEllipseDatum(
center: Point,
index: number,
preset?: CirclePreset,
): EllipseDatum {
const spread = 80
const points: Point[] = []
for (let i = 0; i < DEFAULT_ELLIPSE_POINT_COUNT; i++) {
const t = (2 * Math.PI * i) / DEFAULT_ELLIPSE_POINT_COUNT
points.push({
x: center.x + spread * Math.cos(t),
y: center.y + spread * Math.sin(t),
})
}
return {
id: nanoid(),
type: "ellipse",
points,
center: { x: center.x, y: center.y },
axisEndA: { x: center.x + spread, y: center.y },
axisEndB: { x: center.x, y: center.y + spread },

193
src/lib/ellipse-fit.ts Normal file
View File

@ -0,0 +1,193 @@
/**
* ellipse-fit.ts algebraic least-squares ellipse fit from N5 points.
*
* Solves the conic `a·x² + b·xy + c·y² + d·x + e·y + f = 0` by fixing
* `f = 1` (i.e. solving `a·x² + b·xy + c·y² + d·x + e·y = 1`) after
* data-normalising points to centroid = 0 / RMS distance = 1. This is
* numerically well-behaved for the 520-point user-placed case we need,
* and does not require SVD. Fit can silently degenerate to a hyperbola
* if the user's points don't look like an ellipse we detect that and
* return null.
*/
import type { Point } from "@/types"
interface EllipseFit {
center: Point
/** Offset vector from center to the semi-major axis endpoint. */
semiMajor: Point
/** Offset vector from center to the semi-minor axis endpoint
* (perpendicular to semiMajor). */
semiMinor: Point
}
export function fitEllipse(points: Point[]): EllipseFit | null {
if (points.length < 5) return null
// ── Data normalisation: centroid to origin, mean distance to 1. ─────
let sx = 0
let sy = 0
for (const p of points) {
sx += p.x
sy += p.y
}
const cx = sx / points.length
const cy = sy / points.length
let meanDist = 0
for (const p of points) {
meanDist += Math.hypot(p.x - cx, p.y - cy)
}
meanDist /= points.length
if (meanDist < 1e-9) return null
const s = 1 / meanDist
// ── 5×5 normal equations: Σ rᵢ·rᵢᵀ · p = Σ rᵢ, where rᵢ is the
// row [x², xy, y², x, y] in normalised coords and the RHS is 1
// (the f term we fixed). ──────────────────────────────────────
const M: number[][] = [
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
]
const v: number[] = [0, 0, 0, 0, 0]
for (const p of points) {
const nx = (p.x - cx) * s
const ny = (p.y - cy) * s
const r = [nx * nx, nx * ny, ny * ny, nx, ny]
for (let i = 0; i < 5; i++) {
v[i] = (v[i] ?? 0) + (r[i] ?? 0)
for (let j = 0; j < 5; j++) {
const row = M[i] as number[]
row[j] = (row[j] ?? 0) + (r[i] ?? 0) * (r[j] ?? 0)
}
}
}
const sol = solve5(M, v)
if (!sol) return null
const aN = sol[0] ?? 0
const bN = sol[1] ?? 0
const cN = sol[2] ?? 0
const dN = sol[3] ?? 0
const eN = sol[4] ?? 0
// ── Un-normalise back to original image coords. ─────────────────────
// x' = (x cx)·s ; y' = (y cy)·s ; the conic
// aN·x'² + bN·x'y' + cN·y'² + dN·x' + eN·y' 1 = 0
// expands to `a x² + b xy + c y² + d x + e y + f = 0` with
const s2 = s * s
const a = aN * s2
const b = bN * s2
const c = cN * s2
const d = dN * s - 2 * aN * s2 * cx - bN * s2 * cy
const e = eN * s - bN * s2 * cx - 2 * cN * s2 * cy
const f =
aN * s2 * cx * cx +
bN * s2 * cx * cy +
cN * s2 * cy * cy -
dN * s * cx -
eN * s * cy -
1
// ── Geometric extraction. ───────────────────────────────────────────
// Quadratic-form matrix is [[a, b/2], [b/2, c]] because the xy coef
// is split symmetrically; det = ac b²/4. An ellipse needs det > 0.
const det = a * c - (b * b) / 4
if (det <= 0) return null
// Center from ∇F = 0:
// 2a·x₀ + b·y₀ + d = 0
// b·x₀ + 2c·y₀ + e = 0
const denom = 4 * a * c - b * b
if (Math.abs(denom) < 1e-20) return null
const x0 = (b * e - 2 * c * d) / denom
const y0 = (b * d - 2 * a * e) / denom
// Constant after centering: F(x₀, y₀) = f (a·x₀² + b·x₀·y₀ + c·y₀²),
// so the centered form is a·u² + b·uv + c·v² = K where K = -F(x₀, y₀).
const K = a * x0 * x0 + b * x0 * y0 + c * y0 * y0 - f
if (K <= 0) return null
// Eigen-decompose the quadratic-form matrix to get semi-axis directions.
// λ₁ ≥ λ₂ ≥ 0 ; semi-axis length = √(K / λ). Smaller λ → semi-major.
const trace = a + c
const diff = a - c
const disc = Math.sqrt(diff * diff + b * b)
const lMax = (trace + disc) / 2
const lMin = (trace - disc) / 2
if (lMin <= 0) return null
const rMajor = Math.sqrt(K / lMin)
const rMinor = Math.sqrt(K / lMax)
// Eigenvector for lMin (semi-major axis direction):
// (a lMin) vₓ + (b/2) v_y = 0 ⇒ v = (b/2, lMin a)
// If b is ≈ 0 the matrix is already diagonal, so axes are aligned.
let ux = 0
let uy = 0
if (Math.abs(b) > 1e-12) {
ux = b / 2
uy = lMin - a
} else if (a <= c) {
ux = 1
uy = 0
} else {
ux = 0
uy = 1
}
const n = Math.hypot(ux, uy)
if (n < 1e-12) return null
ux /= n
uy /= n
return {
center: { x: x0, y: y0 },
semiMajor: { x: ux * rMajor, y: uy * rMajor },
// 90° rotation gives the perpendicular (semi-minor) direction.
semiMinor: { x: -uy * rMinor, y: ux * rMinor },
}
}
/** 5×5 linear solve via Gauss-Jordan elimination with partial pivoting.
* Returns null if the matrix is (near-)singular. */
function solve5(M: number[][], v: number[]): number[] | null {
const n = 5
const aug: number[][] = []
for (let i = 0; i < n; i++) {
const row = M[i] as number[]
aug.push([row[0] ?? 0, row[1] ?? 0, row[2] ?? 0, row[3] ?? 0, row[4] ?? 0, v[i] ?? 0])
}
for (let col = 0; col < n; col++) {
let pivot = col
let pivotAbs = Math.abs((aug[col] as number[])[col] ?? 0)
for (let r = col + 1; r < n; r++) {
const vv = Math.abs((aug[r] as number[])[col] ?? 0)
if (vv > pivotAbs) {
pivotAbs = vv
pivot = r
}
}
if (pivotAbs < 1e-12) return null
if (pivot !== col) {
const tmp = aug[col]
aug[col] = aug[pivot] as number[]
aug[pivot] = tmp as number[]
}
const pivRow = aug[col] as number[]
const pv = pivRow[col] as number
for (let c2 = col; c2 <= n; c2++) {
pivRow[c2] = (pivRow[c2] as number) / pv
}
for (let r = 0; r < n; r++) {
if (r === col) continue
const rr = aug[r] as number[]
const factor = rr[col] as number
if (factor === 0) continue
for (let c2 = col; c2 <= n; c2++) {
rr[c2] = (rr[c2] as number) - factor * (pivRow[c2] as number)
}
}
}
return aug.map((row) => (row as number[])[n] as number)
}

View File

@ -237,6 +237,17 @@ type Primary =
function pickPrimary(datums: Datum[]): Primary {
if (datums.length === 0) throw new Error("No datums provided.")
// User-flagged world-axis reference wins regardless of type priority.
for (const d of datums) {
if (d.type === "rectangle" && d.isAxisReference) {
return { kind: "rect", datum: d }
}
if (d.type === "line" && d.axisRole) {
return { kind: "line", datum: d }
}
}
const typeRank = (d: Datum): number =>
d.type === "rectangle" ? 0 : d.type === "ellipse" ? 1 : 2
const sizeKey = (d: Datum): number => {
@ -314,12 +325,23 @@ function primaryLineCorrespondences(
const p2: Point = { x: p0.x - dy, y: p0.y + dx }
const p3: Point = { x: p1.x - dy, y: p1.y + dx }
const srcPts: [Point, Point, Point, Point] = [p0, p1, p2, p3]
const targets: [Point, Point, Point, Point] = [
{ x: 0, y: 0 },
{ x: L, y: 0 },
{ x: 0, y: L },
{ x: L, y: L },
]
// Default: line defines world +x. With axisRole === "y", endpoints land
// along world +y and the synthetic perpendicular lands along +x.
const targets: [Point, Point, Point, Point] =
line.axisRole === "y"
? [
{ x: 0, y: 0 },
{ x: 0, y: L },
{ x: L, y: 0 },
{ x: L, y: L },
]
: [
{ x: 0, y: 0 },
{ x: L, y: 0 },
{ x: 0, y: L },
{ x: L, y: L },
]
const weight = Math.max(
1,
Math.round(line.confidence * PRIMARY_GAUGE_BOOST),

View File

@ -1,8 +1,9 @@
import { defineStore } from "pinia"
import { ref, computed } from "vue"
import type { AppStep, Datum, DeskewResult, ExifData } from "@/types"
import type { AppStep, Datum, DeskewResult, ExifData, Point } from "@/types"
import { DEFAULT_SCALE_PX_PER_MM } from "@/types"
import { loadSettings } from "@/lib/settings-cache"
import { fitEllipse } from "@/lib/ellipse-fit"
export const useAppStore = defineStore("app", () => {
const cached = loadSettings()
@ -66,6 +67,73 @@ export const useAppStore = defineStore("app", () => {
}
}
/** Update an ellipse datum's points and refresh the cached best-fit
* ellipse (`center`/`axisEndA`/`axisEndB`). If the fit degenerates
* (fewer than 5 usable points, collinear, or the system is singular),
* we keep the previous cached axes so downstream consumers never see
* a garbage fit. */
function updateEllipsePoints(id: string, points: Point[]) {
const idx = datums.value.findIndex((d) => d.id === id)
const existing = datums.value[idx]
if (!existing || existing.type !== "ellipse") return
const fit = fitEllipse(points)
if (!fit) {
datums.value[idx] = { ...existing, points }
return
}
datums.value[idx] = {
...existing,
points,
center: fit.center,
axisEndA: {
x: fit.center.x + fit.semiMajor.x,
y: fit.center.y + fit.semiMajor.y,
},
axisEndB: {
x: fit.center.x + fit.semiMinor.x,
y: fit.center.y + fit.semiMinor.y,
},
}
}
/** Set (or clear) the world-axis role on a datum, enforcing that at
* most one datum holds the role at a time.
* `role`: "rect" rectangle.isAxisReference = true
* "x"/"y" line.axisRole = "x"|"y"
* null clear the role on `id` (no-op if it wasn't set). */
function setAxisRole(
id: string,
role: "rect" | "x" | "y" | null,
) {
// Clear any existing flag on other datums.
for (let i = 0; i < datums.value.length; i++) {
const d = datums.value[i]
if (!d || d.id === id) continue
if (d.type === "rectangle" && d.isAxisReference) {
datums.value[i] = { ...d, isAxisReference: false }
} else if (d.type === "line" && d.axisRole) {
datums.value[i] = { ...d, axisRole: null }
}
}
const idx = datums.value.findIndex((d) => d.id === id)
if (idx === -1) return
const target = datums.value[idx]
if (!target) return
if (role === null) {
if (target.type === "rectangle") {
datums.value[idx] = { ...target, isAxisReference: false }
} else if (target.type === "line") {
datums.value[idx] = { ...target, axisRole: null }
}
return
}
if (role === "rect" && target.type === "rectangle") {
datums.value[idx] = { ...target, isAxisReference: true }
} else if ((role === "x" || role === "y") && target.type === "line") {
datums.value[idx] = { ...target, axisRole: role }
}
}
function removeDatum(id: string) {
datums.value = datums.value.filter((d) => d.id !== id)
if (selectedDatumId.value === id) {
@ -119,6 +187,8 @@ export const useAppStore = defineStore("app", () => {
goToStep,
addDatum,
updateDatum,
updateEllipsePoints,
setAxisRole,
removeDatum,
setResult,
setFileHash,

View File

@ -11,8 +11,13 @@ export interface RectDatum {
heightMm: number
confidence: 1 | 2 | 3 | 4 | 5
label: string
/** When true, this rectangle defines the world axes: TLTR is +x,
* TLBL is +y. At most one datum in the set may hold the axis role. */
isAxisReference?: boolean
}
export type LineAxisRole = "x" | "y" | null
export interface LineDatum {
id: string
type: "line"
@ -20,16 +25,25 @@ export interface LineDatum {
lengthMm: number
confidence: 1 | 2 | 3 | 4 | 5
label: string
/** Marks this line as the world axis reference. "x" maps endpoint[0]
* to origin and endpoint[1] to (+L, 0); "y" maps to (0, +L). At most
* one datum in the set may hold the axis role. */
axisRole?: LineAxisRole
}
export interface EllipseDatum {
id: string
type: "ellipse"
/** Image-space ellipse as 3 free points: center + two conjugate
* semi-axis endpoints. axisEndA/axisEndB don't need to be perpendicular;
* together with center they give a full 5-DoF ellipse matrix. */
/** User-placed points on the circle contour (5, default 8). The
* best-fit ellipse is refitted each time this array changes; the
* `center`/`axisEndA`/`axisEndB` fields below are that fit cached
* on the datum for use by renderers and the solver. */
points: Point[]
center: Point
/** Offset endpoint of the fitted semi-major axis from `center`. */
axisEndA: Point
/** Offset endpoint of the fitted semi-minor axis from `center`
* (perpendicular to axisEndA). */
axisEndB: Point
/** Known real-world diameter of the circle being drawn. */
diameterMm: number