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>
198 lines
6.4 KiB
TypeScript
198 lines
6.4 KiB
TypeScript
import { defineStore } from "pinia"
|
|
import { ref, computed } from "vue"
|
|
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()
|
|
|
|
const currentStep = ref<AppStep>(1)
|
|
const maxStepReached = ref<AppStep>(1)
|
|
const originalFile = ref<File | null>(null)
|
|
const loadedImage = ref<HTMLImageElement | null>(null)
|
|
const exifData = ref<ExifData>({})
|
|
const datums = ref<Datum[]>([])
|
|
const deskewResult = ref<DeskewResult | null>(null)
|
|
const isProcessing = ref(false)
|
|
const processingStatus = ref("")
|
|
const selectedDatumId = ref<string | null>(null)
|
|
const scalePxPerMm = ref(
|
|
cached?.scalePxPerMm ?? DEFAULT_SCALE_PX_PER_MM,
|
|
)
|
|
const fileHash = ref<string | null>(null)
|
|
const cacheRestoreMessage = ref("")
|
|
|
|
const canProceedToStep2 = computed(() => loadedImage.value !== null)
|
|
const canProceedToStep3 = computed(() => canProceedToStep2.value)
|
|
const canProceedToStep4 = computed(() => {
|
|
if (!canProceedToStep3.value || datums.value.length === 0) return false
|
|
return datums.value.every((d) => {
|
|
if (d.type === "rectangle") return d.widthMm > 0 && d.heightMm > 0
|
|
if (d.type === "line") return d.lengthMm > 0
|
|
return d.diameterMm > 0
|
|
})
|
|
})
|
|
|
|
function setImage(file: File, image: HTMLImageElement) {
|
|
originalFile.value = file
|
|
loadedImage.value = image
|
|
}
|
|
|
|
function setExif(data: ExifData) {
|
|
exifData.value = data
|
|
}
|
|
|
|
function goToStep(step: AppStep) {
|
|
currentStep.value = step
|
|
if (step > maxStepReached.value) {
|
|
maxStepReached.value = step
|
|
}
|
|
}
|
|
|
|
function addDatum(datum: Datum) {
|
|
datums.value.push(datum)
|
|
selectedDatumId.value = datum.id
|
|
}
|
|
|
|
function updateDatum(id: string, updates: Partial<Datum>) {
|
|
const index = datums.value.findIndex((d) => d.id === id)
|
|
const existing = datums.value[index]
|
|
if (index !== -1 && existing) {
|
|
datums.value[index] = {
|
|
...existing,
|
|
...updates,
|
|
} as Datum
|
|
}
|
|
}
|
|
|
|
/** 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) {
|
|
selectedDatumId.value = datums.value[0]?.id ?? null
|
|
}
|
|
}
|
|
|
|
function setResult(result: DeskewResult) {
|
|
deskewResult.value = result
|
|
}
|
|
|
|
function setFileHash(hash: string) {
|
|
fileHash.value = hash
|
|
}
|
|
|
|
function reset() {
|
|
currentStep.value = 1
|
|
maxStepReached.value = 1
|
|
originalFile.value = null
|
|
loadedImage.value = null
|
|
exifData.value = {}
|
|
datums.value = []
|
|
deskewResult.value = null
|
|
isProcessing.value = false
|
|
processingStatus.value = ""
|
|
selectedDatumId.value = null
|
|
scalePxPerMm.value = DEFAULT_SCALE_PX_PER_MM
|
|
fileHash.value = null
|
|
cacheRestoreMessage.value = ""
|
|
}
|
|
|
|
return {
|
|
currentStep,
|
|
maxStepReached,
|
|
originalFile,
|
|
loadedImage,
|
|
exifData,
|
|
datums,
|
|
deskewResult,
|
|
isProcessing,
|
|
processingStatus,
|
|
selectedDatumId,
|
|
scalePxPerMm,
|
|
fileHash,
|
|
cacheRestoreMessage,
|
|
canProceedToStep2,
|
|
canProceedToStep3,
|
|
canProceedToStep4,
|
|
setImage,
|
|
setExif,
|
|
goToStep,
|
|
addDatum,
|
|
updateDatum,
|
|
updateEllipsePoints,
|
|
setAxisRole,
|
|
removeDatum,
|
|
setResult,
|
|
setFileHash,
|
|
reset,
|
|
}
|
|
})
|