From da5be3851d2680d5dceb23be2634655083d30ca2 Mon Sep 17 00:00:00 2001 From: Samuel Prevost Date: Fri, 24 Apr 2026 18:10:22 +0200 Subject: [PATCH] feat: world-axis selector, 8-point circle, annotated measurement tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/CorrectedImageViewer.vue | 1403 +++++++++++++++++++---- src/components/DatumCanvas.vue | 30 +- src/components/DatumPanel.vue | 76 ++ src/components/ResultViewer.vue | 29 +- src/lib/datum-cache.ts | 30 +- src/lib/datums.ts | 11 + src/lib/ellipse-fit.ts | 193 ++++ src/lib/solver.ts | 34 +- src/stores/app.ts | 72 +- src/types/index.ts | 20 +- 10 files changed, 1658 insertions(+), 240 deletions(-) create mode 100644 src/lib/ellipse-fit.ts diff --git a/src/components/CorrectedImageViewer.vue b/src/components/CorrectedImageViewer.vue index 20114f8..9a52c95 100644 --- a/src/components/CorrectedImageViewer.vue +++ b/src/components/CorrectedImageViewer.vue @@ -1,6 +1,8 @@ diff --git a/src/components/DatumCanvas.vue b/src/components/DatumCanvas.vue index 51937c5..d8fcb36 100644 --- a/src/components/DatumCanvas.vue +++ b/src/components/DatumCanvas.vue @@ -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) } } diff --git a/src/components/DatumPanel.vue b/src/components/DatumPanel.vue index 1b89cbd..0778e47 100644 --- a/src/components/DatumPanel.vue +++ b/src/components/DatumPanel.vue @@ -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 +}