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>
494 lines
15 KiB
Vue
494 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted, watch } from "vue"
|
|
import { useAppStore } from "@/stores/app"
|
|
import { getDatumColor } from "@/lib/datums"
|
|
import type { Datum, Point } from "@/types"
|
|
|
|
const store = useAppStore()
|
|
|
|
const containerRef = ref<HTMLDivElement | null>(null)
|
|
const stageWidth = ref(800)
|
|
const stageHeight = ref(600)
|
|
|
|
const scale = ref(1)
|
|
const offsetX = ref(0)
|
|
const offsetY = ref(0)
|
|
|
|
// Touch state for pinch-to-zoom and pan
|
|
let lastPinchDist = 0
|
|
let isPanning = false
|
|
let isPinching = false
|
|
let panStart = { x: 0, y: 0 }
|
|
// Track whether a Konva shape is being dragged (touch)
|
|
let isDraggingShape = false
|
|
|
|
const imageConfig = computed(() => {
|
|
const img = store.loadedImage
|
|
if (!img) return null
|
|
return {
|
|
image: img,
|
|
x: 0,
|
|
y: 0,
|
|
width: img.naturalWidth,
|
|
height: img.naturalHeight,
|
|
}
|
|
})
|
|
|
|
const stageDraggable = ref(true)
|
|
|
|
const stageConfig = computed(() => ({
|
|
width: stageWidth.value,
|
|
height: stageHeight.value,
|
|
scaleX: scale.value,
|
|
scaleY: scale.value,
|
|
x: offsetX.value,
|
|
y: offsetY.value,
|
|
draggable: stageDraggable.value,
|
|
}))
|
|
|
|
function datumIndex(datum: Datum): number {
|
|
return store.datums.findIndex((d) => d.id === datum.id)
|
|
}
|
|
|
|
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[]
|
|
// 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) {
|
|
const color = getDatumColor(dIdx)
|
|
const isSelected = store.selectedDatumId === datum.id
|
|
const points = datumPoints(datum)
|
|
const baseRadius = isSelected ? 6 : 4
|
|
const visualRadius = Math.max(
|
|
baseRadius / scale.value,
|
|
baseRadius * 0.5,
|
|
)
|
|
|
|
return points.map((pt, pIdx) => ({
|
|
// Ellipse center (index 0) is visually bigger + hollow to distinguish it
|
|
x: pt.x,
|
|
y: pt.y,
|
|
radius:
|
|
datum.type === "ellipse" && pIdx === 0
|
|
? visualRadius * 1.4
|
|
: visualRadius,
|
|
fill: datum.type === "ellipse" && pIdx === 0 ? "transparent" : color,
|
|
stroke: isSelected ? "#fff" : color,
|
|
strokeWidth: (datum.type === "ellipse" && pIdx === 0 ? 2.5 : 1.5) /
|
|
scale.value,
|
|
draggable: true,
|
|
_datumId: datum.id,
|
|
_pointIndex: pIdx,
|
|
hitStrokeWidth: 12 / scale.value,
|
|
}))
|
|
}
|
|
|
|
function ellipseCurvePoints(datum: Datum & { type: "ellipse" }): number[] {
|
|
const vAx = datum.axisEndA.x - datum.center.x
|
|
const vAy = datum.axisEndA.y - datum.center.y
|
|
const vBx = datum.axisEndB.x - datum.center.x
|
|
const vBy = datum.axisEndB.y - datum.center.y
|
|
const N = 72
|
|
const pts: number[] = []
|
|
for (let i = 0; i <= N; i++) {
|
|
const t = (2 * Math.PI * i) / N
|
|
const cs = Math.cos(t)
|
|
const sn = Math.sin(t)
|
|
pts.push(
|
|
datum.center.x + vAx * cs + vBx * sn,
|
|
datum.center.y + vAy * cs + vBy * sn,
|
|
)
|
|
}
|
|
return pts
|
|
}
|
|
|
|
function getLineConfigs(datum: Datum, dIdx: number) {
|
|
const color = getDatumColor(dIdx)
|
|
const isSelected = store.selectedDatumId === datum.id
|
|
const dash = isSelected ? [] : [8 / scale.value, 4 / scale.value]
|
|
const strokeWidth = (isSelected ? 3 : 2) / scale.value
|
|
|
|
if (datum.type === "line") {
|
|
return [
|
|
{
|
|
points: [
|
|
datum.endpoints[0].x,
|
|
datum.endpoints[0].y,
|
|
datum.endpoints[1].x,
|
|
datum.endpoints[1].y,
|
|
],
|
|
stroke: color,
|
|
strokeWidth,
|
|
dash,
|
|
},
|
|
]
|
|
}
|
|
|
|
if (datum.type === "rectangle") {
|
|
const c = datum.corners
|
|
const pts = [c[0], c[1], c[2], c[3], c[0]].flatMap((p) => [p.x, p.y])
|
|
return [
|
|
{
|
|
points: pts,
|
|
stroke: color,
|
|
strokeWidth,
|
|
closed: true,
|
|
dash,
|
|
},
|
|
]
|
|
}
|
|
|
|
// Ellipse: sampled curve + two thin axis lines for visual reference
|
|
return [
|
|
{
|
|
points: ellipseCurvePoints(datum),
|
|
stroke: color,
|
|
strokeWidth,
|
|
dash,
|
|
},
|
|
{
|
|
points: [
|
|
datum.center.x,
|
|
datum.center.y,
|
|
datum.axisEndA.x,
|
|
datum.axisEndA.y,
|
|
],
|
|
stroke: color,
|
|
strokeWidth: 1 / scale.value,
|
|
opacity: 0.5,
|
|
},
|
|
{
|
|
points: [
|
|
datum.center.x,
|
|
datum.center.y,
|
|
datum.axisEndB.x,
|
|
datum.axisEndB.y,
|
|
],
|
|
stroke: color,
|
|
strokeWidth: 1 / scale.value,
|
|
opacity: 0.5,
|
|
},
|
|
]
|
|
}
|
|
|
|
function getLabelConfig(datum: Datum, dIdx: number) {
|
|
const color = getDatumColor(dIdx)
|
|
let pos: Point
|
|
|
|
if (datum.type === "rectangle") {
|
|
pos = {
|
|
x: (datum.corners[0].x + datum.corners[2].x) / 2,
|
|
y: (datum.corners[0].y + datum.corners[2].y) / 2,
|
|
}
|
|
} else if (datum.type === "line") {
|
|
pos = {
|
|
x: (datum.endpoints[0].x + datum.endpoints[1].x) / 2,
|
|
y:
|
|
(datum.endpoints[0].y + datum.endpoints[1].y) / 2 -
|
|
20 / scale.value,
|
|
}
|
|
} else {
|
|
pos = {
|
|
x: datum.center.x,
|
|
y: datum.center.y - 20 / scale.value,
|
|
}
|
|
}
|
|
|
|
return {
|
|
x: pos.x,
|
|
y: pos.y,
|
|
text: datum.label,
|
|
fontSize: 14 / scale.value,
|
|
fill: color,
|
|
fontStyle: "bold",
|
|
align: "center" as const,
|
|
offsetX: (datum.label.length * 7) / 2 / scale.value,
|
|
}
|
|
}
|
|
|
|
function onPointDragMove(e: {
|
|
target: {
|
|
x: () => number
|
|
y: () => number
|
|
attrs: { _datumId: string; _pointIndex: number }
|
|
}
|
|
}) {
|
|
const { _datumId, _pointIndex } = e.target.attrs
|
|
const datum = store.datums.find((d) => d.id === _datumId)
|
|
if (!datum) return
|
|
|
|
const newPos: Point = { x: e.target.x(), y: e.target.y() }
|
|
|
|
if (datum.type === "rectangle") {
|
|
const newCorners = [...datum.corners] as [Point, Point, Point, Point]
|
|
newCorners[_pointIndex] = newPos
|
|
store.updateDatum(_datumId, { corners: newCorners })
|
|
} else if (datum.type === "line") {
|
|
const newEndpoints = [...datum.endpoints] as [Point, Point]
|
|
newEndpoints[_pointIndex] = newPos
|
|
store.updateDatum(_datumId, { endpoints: newEndpoints })
|
|
} else if (_pointIndex === 0) {
|
|
// 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
|
|
const translated = datum.points.map((p) => ({
|
|
x: p.x + dx,
|
|
y: p.y + dy,
|
|
}))
|
|
store.updateEllipsePoints(_datumId, translated)
|
|
} else {
|
|
const newPoints = datum.points.map((p, i) =>
|
|
i === _pointIndex - 1 ? newPos : p,
|
|
)
|
|
store.updateEllipsePoints(_datumId, newPoints)
|
|
}
|
|
}
|
|
|
|
function onPointClick(datumId: string) {
|
|
store.selectedDatumId = datumId
|
|
}
|
|
|
|
// Zoom with mouse wheel
|
|
function onWheel(e: WheelEvent) {
|
|
e.preventDefault()
|
|
const scaleBy = 1.08
|
|
const oldScale = scale.value
|
|
const newScale = e.deltaY < 0 ? oldScale * scaleBy : oldScale / scaleBy
|
|
|
|
const clampedScale = Math.max(0.05, Math.min(10, newScale))
|
|
|
|
const rect = containerRef.value?.getBoundingClientRect()
|
|
if (!rect) return
|
|
|
|
const pointerX = e.clientX - rect.left
|
|
const pointerY = e.clientY - rect.top
|
|
|
|
const mousePointTo = {
|
|
x: (pointerX - offsetX.value) / oldScale,
|
|
y: (pointerY - offsetY.value) / oldScale,
|
|
}
|
|
|
|
scale.value = clampedScale
|
|
offsetX.value = pointerX - mousePointTo.x * clampedScale
|
|
offsetY.value = pointerY - mousePointTo.y * clampedScale
|
|
}
|
|
|
|
// Touch handlers for pinch-to-zoom and pan
|
|
function getTouchDistance(t1: Touch, t2: Touch): number {
|
|
return Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY)
|
|
}
|
|
|
|
function getTouchCenter(t1: Touch, t2: Touch): { x: number; y: number } {
|
|
return {
|
|
x: (t1.clientX + t2.clientX) / 2,
|
|
y: (t1.clientY + t2.clientY) / 2,
|
|
}
|
|
}
|
|
|
|
let pendingPanTouch: { x: number; y: number } | null = null
|
|
|
|
function onTouchStart(e: TouchEvent) {
|
|
if (e.touches.length === 2) {
|
|
e.preventDefault()
|
|
isPanning = false
|
|
isDraggingShape = false
|
|
isPinching = true
|
|
pendingPanTouch = null
|
|
// Disable stage drag so Konva doesn't fight with pinch-zoom
|
|
stageDraggable.value = false
|
|
const t0 = e.touches[0]
|
|
const t1 = e.touches[1]
|
|
if (t0 && t1) {
|
|
lastPinchDist = getTouchDistance(t0, t1)
|
|
}
|
|
} else if (e.touches.length === 1) {
|
|
// Record the touch but don't start panning yet —
|
|
// give Konva a chance to claim it as a shape drag.
|
|
const t0 = e.touches[0]
|
|
if (!t0) return
|
|
pendingPanTouch = { x: t0.clientX, y: t0.clientY }
|
|
isPanning = false
|
|
}
|
|
}
|
|
|
|
function onTouchMove(e: TouchEvent) {
|
|
if (e.touches.length === 2) {
|
|
e.preventDefault()
|
|
const t0 = e.touches[0]
|
|
const t1 = e.touches[1]
|
|
if (!t0 || !t1) return
|
|
const dist = getTouchDistance(t0, t1)
|
|
const center = getTouchCenter(t0, t1)
|
|
|
|
const rect = containerRef.value?.getBoundingClientRect()
|
|
if (!rect) return
|
|
|
|
const scaleFactor = dist / lastPinchDist
|
|
const oldScale = scale.value
|
|
const newScale = Math.max(0.05, Math.min(10, oldScale * scaleFactor))
|
|
|
|
const cx = center.x - rect.left
|
|
const cy = center.y - rect.top
|
|
|
|
const mousePointTo = {
|
|
x: (cx - offsetX.value) / oldScale,
|
|
y: (cy - offsetY.value) / oldScale,
|
|
}
|
|
|
|
scale.value = newScale
|
|
offsetX.value = cx - mousePointTo.x * newScale
|
|
offsetY.value = cy - mousePointTo.y * newScale
|
|
|
|
lastPinchDist = dist
|
|
} else if (e.touches.length === 1 && !isDraggingShape) {
|
|
const t0 = e.touches[0]
|
|
if (!t0) return
|
|
|
|
// If we haven't started panning yet, promote the pending touch
|
|
if (!isPanning && pendingPanTouch) {
|
|
isPanning = true
|
|
panStart = {
|
|
x: pendingPanTouch.x - offsetX.value,
|
|
y: pendingPanTouch.y - offsetY.value,
|
|
}
|
|
pendingPanTouch = null
|
|
}
|
|
|
|
if (isPanning) {
|
|
offsetX.value = t0.clientX - panStart.x
|
|
offsetY.value = t0.clientY - panStart.y
|
|
}
|
|
}
|
|
}
|
|
|
|
function onTouchEnd() {
|
|
lastPinchDist = 0
|
|
isPanning = false
|
|
isDraggingShape = false
|
|
pendingPanTouch = null
|
|
if (isPinching) {
|
|
isPinching = false
|
|
// Re-enable stage drag after pinch ends
|
|
stageDraggable.value = true
|
|
}
|
|
}
|
|
|
|
function onPointDragStart() {
|
|
isDraggingShape = true
|
|
isPanning = false
|
|
pendingPanTouch = null
|
|
stageDraggable.value = false
|
|
}
|
|
|
|
function onPointDragEnd() {
|
|
isDraggingShape = false
|
|
stageDraggable.value = true
|
|
}
|
|
|
|
function onStageDragEnd(e: { target: { x: () => number; y: () => number; nodeType: string } }) {
|
|
// Only sync offset when the stage itself was dragged, not a child shape
|
|
if (e.target.nodeType !== "Stage") return
|
|
offsetX.value = e.target.x()
|
|
offsetY.value = e.target.y()
|
|
}
|
|
|
|
// Fit image to canvas on mount
|
|
function fitToCanvas() {
|
|
const img = store.loadedImage
|
|
const container = containerRef.value
|
|
if (!img || !container) return
|
|
|
|
const cw = container.clientWidth
|
|
const ch = container.clientHeight
|
|
stageWidth.value = cw
|
|
stageHeight.value = ch
|
|
|
|
const fitScale =
|
|
Math.min(cw / img.naturalWidth, ch / img.naturalHeight) * 0.9
|
|
scale.value = fitScale
|
|
offsetX.value = (cw - img.naturalWidth * fitScale) / 2
|
|
offsetY.value = (ch - img.naturalHeight * fitScale) / 2
|
|
}
|
|
|
|
let resizeObserver: ResizeObserver | null = null
|
|
|
|
onMounted(() => {
|
|
fitToCanvas()
|
|
if (containerRef.value) {
|
|
resizeObserver = new ResizeObserver(() => {
|
|
if (!containerRef.value) return
|
|
const cw = containerRef.value.clientWidth
|
|
const ch = containerRef.value.clientHeight
|
|
// Skip if the container is hidden (0-sized)
|
|
if (cw === 0 || ch === 0) return
|
|
// Re-fit whenever the container size actually changes
|
|
fitToCanvas()
|
|
})
|
|
resizeObserver.observe(containerRef.value)
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
resizeObserver?.disconnect()
|
|
})
|
|
|
|
watch(() => store.loadedImage, fitToCanvas)
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="containerRef"
|
|
class="h-full w-full cursor-grab overflow-hidden rounded-lg border border-border bg-muted active:cursor-grabbing"
|
|
style="touch-action: none"
|
|
@wheel.prevent="onWheel"
|
|
@touchstart="onTouchStart"
|
|
@touchmove="onTouchMove"
|
|
@touchend="onTouchEnd"
|
|
>
|
|
<v-stage :config="stageConfig" @dragend="onStageDragEnd">
|
|
<v-layer>
|
|
<!-- Background image -->
|
|
<v-image v-if="imageConfig" :config="imageConfig" />
|
|
|
|
<!-- Datum shapes -->
|
|
<template v-for="datum in store.datums" :key="datum.id">
|
|
<!-- Lines/edges -->
|
|
<v-line
|
|
v-for="(lineCfg, li) in getLineConfigs(
|
|
datum,
|
|
datumIndex(datum),
|
|
)"
|
|
:key="`${datum.id}-line-${li}`"
|
|
:config="lineCfg"
|
|
/>
|
|
|
|
<!-- Center label -->
|
|
<v-text
|
|
:config="getLabelConfig(datum, datumIndex(datum))"
|
|
/>
|
|
|
|
<!-- Draggable points -->
|
|
<v-circle
|
|
v-for="ptCfg in getPointConfigs(
|
|
datum,
|
|
datumIndex(datum),
|
|
)"
|
|
:key="`${datum.id}-pt-${ptCfg._pointIndex}`"
|
|
:config="ptCfg"
|
|
@dragstart="onPointDragStart"
|
|
@dragmove="onPointDragMove"
|
|
@dragend="onPointDragEnd"
|
|
@click="onPointClick(datum.id)"
|
|
@tap="onPointClick(datum.id)"
|
|
/>
|
|
</template>
|
|
</v-layer>
|
|
</v-stage>
|
|
</div>
|
|
</template>
|