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:
parent
b87f933b9e
commit
da5be3851d
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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++) {
|
||||
|
||||
@ -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
193
src/lib/ellipse-fit.ts
Normal file
@ -0,0 +1,193 @@
|
||||
/**
|
||||
* ellipse-fit.ts — algebraic least-squares ellipse fit from N≥5 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 5–20-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)
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: TL→TR is +x,
|
||||
* TL→BL 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user