feat(measurements): add circle tool, annotated exports, and per-image persistence
Three combined additions to the corrected-image viewer: - Circle measurement tool: 2-click placement (center + edge), dedicated hit-test, handle drag preserving radius when grabbing the center. - Annotated PNG exports via two new buttons in the result page: "Download full + measurements" (source resolution, strokes scaled up to read at the same visual weight) and "Download view + measurements" (current pan/zoom). Both respect the existing scale-bar toggle; the view export's bar is sized for canvas-px/mm = image-px/mm × view scale. - Per-image measurement persistence keyed by file hash, mirroring the datum cache. "Clear cache" in the upload step now wipes both. Drawing helpers were refactored to take a RenderCtx (transform + strokeMul + handle/decoration flags) so the same code paths handle live overlay and offscreen export.
This commit is contained in:
parent
93b05f554c
commit
9c47736799
File diff suppressed because it is too large
Load Diff
@ -5,6 +5,7 @@ import { loadImage } from "@/lib/image-loader"
|
||||
import { extractExif } from "@/lib/exif"
|
||||
import { hashFile } from "@/lib/file-hash"
|
||||
import { loadDatums, clearCache, getCacheSize } from "@/lib/datum-cache"
|
||||
import { clearCache as clearMeasurementCache } from "@/lib/measurement-cache"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@ -29,6 +30,7 @@ onMounted(() => {
|
||||
|
||||
function handleClearCache() {
|
||||
clearCache()
|
||||
clearMeasurementCache()
|
||||
cacheCount.value = 0
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,15 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import CorrectedImageViewer from "@/components/CorrectedImageViewer.vue"
|
||||
// `defineExpose` in CorrectedImageViewer makes these methods available on
|
||||
// the template ref, but Vue's ComponentPublicInstance type doesn't surface
|
||||
// them automatically — we type the ref explicitly so the call is checked.
|
||||
type CorrectedImageViewerRef = InstanceType<typeof CorrectedImageViewer> & {
|
||||
exportWithMeasurements: (opts: {
|
||||
scope: "full" | "view"
|
||||
includeScaleBar: boolean
|
||||
}) => Promise<Blob>
|
||||
}
|
||||
import {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
@ -38,6 +47,7 @@ import {
|
||||
|
||||
const store = useAppStore()
|
||||
const resultUrl = ref<string | null>(null)
|
||||
const viewerRef = ref<CorrectedImageViewerRef | null>(null)
|
||||
const error = ref("")
|
||||
const hasRun = ref(false)
|
||||
const cvReady = ref(false)
|
||||
@ -364,6 +374,37 @@ async function download() {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// Download the corrected image with measurement annotations baked in.
|
||||
// scope="full": natural-resolution image + overlay → `-measured.png`.
|
||||
// scope="view": current viewport (zoom/pan) + overlay → `-measured-view.png`.
|
||||
// Both honour the existing `includeScaleBar` toggle. View export's bar is
|
||||
// sized for the on-screen pixel scale (image-px/mm × CSS view scale) so it
|
||||
// represents the same physical mm length the user is actually looking at.
|
||||
async function downloadMeasured(scope: "full" | "view") {
|
||||
const viewer = viewerRef.value
|
||||
if (!viewer || !store.deskewResult) return
|
||||
try {
|
||||
const blob = await viewer.exportWithMeasurements({
|
||||
scope,
|
||||
includeScaleBar: includeScaleBar.value,
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
const baseName =
|
||||
store.originalFile?.name.replace(/\.[^.]+$/, "") ?? "output"
|
||||
a.download =
|
||||
scope === "full"
|
||||
? `${baseName}-measured.png`
|
||||
: `${baseName}-measured-view.png`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
error.value =
|
||||
e instanceof Error ? e.message : "Measured export failed"
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -819,6 +860,7 @@ async function download() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CorrectedImageViewer
|
||||
ref="viewerRef"
|
||||
:image-url="resultUrl"
|
||||
:scale-px-per-mm="store.scalePxPerMm"
|
||||
/>
|
||||
@ -852,7 +894,7 @@ async function download() {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||
<Button size="lg" @click="download">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@ -879,6 +921,74 @@ async function download() {
|
||||
</svg>
|
||||
Download PNG
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
@click="downloadMeasured('full')"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="mr-2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" x2="12" y1="15" y2="3" />
|
||||
<path d="M3 3h6" />
|
||||
</svg>
|
||||
Download full + measurements
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" class="max-w-xs">
|
||||
Source image at full resolution with every
|
||||
measurement (lines, rectangles, ellipses,
|
||||
angles) and labels rendered on top.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
@click="downloadMeasured('view')"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="mr-2"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M9 9h6v6H9z" />
|
||||
</svg>
|
||||
Download view + measurements
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" class="max-w-xs">
|
||||
Captures exactly what's visible in the
|
||||
viewer (current zoom and pan) with every
|
||||
measurement rendered on top.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
|
||||
40
src/lib/measurement-cache.ts
Normal file
40
src/lib/measurement-cache.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { Measurement } from "@/types/measurements"
|
||||
|
||||
const KEY_PREFIX = "skwik-measurements-"
|
||||
|
||||
export function saveMeasurements(
|
||||
hash: string,
|
||||
measurements: Measurement[],
|
||||
): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
KEY_PREFIX + hash,
|
||||
JSON.stringify(measurements),
|
||||
)
|
||||
} catch {
|
||||
// localStorage full or unavailable — silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function loadMeasurements(hash: string): Measurement[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY_PREFIX + hash)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw) as Measurement[]
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCache(): void {
|
||||
const toRemove: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key?.startsWith(KEY_PREFIX)) {
|
||||
toRemove.push(key)
|
||||
}
|
||||
}
|
||||
for (const key of toRemove) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
50
src/types/measurements.ts
Normal file
50
src/types/measurements.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import type { Point } from "@/types"
|
||||
|
||||
/** Measurement geometry lives in image space so it is invariant under
|
||||
* pan/zoom and survives redraws without reprojection. Persisted to
|
||||
* localStorage keyed by file hash; see `src/lib/measurement-cache.ts`. */
|
||||
export interface BaseMeasurement {
|
||||
id: string
|
||||
colorIndex: number
|
||||
}
|
||||
|
||||
export interface LineMeasurement extends BaseMeasurement {
|
||||
type: "line"
|
||||
a: Point
|
||||
b: Point
|
||||
}
|
||||
|
||||
// Corner ordering [TL, TR, BR, BL] mirrors the RectDatum convention from
|
||||
// `src/types/index.ts`. Indices stay stable across drags even if the user
|
||||
// crosses corners.
|
||||
export interface RectMeasurement extends BaseMeasurement {
|
||||
type: "rectangle"
|
||||
corners: [Point, Point, Point, Point]
|
||||
}
|
||||
|
||||
export interface EllipseMeasurement extends BaseMeasurement {
|
||||
type: "ellipse"
|
||||
center: Point
|
||||
axisEndA: Point
|
||||
axisEndB: Point
|
||||
}
|
||||
|
||||
export interface CircleMeasurement extends BaseMeasurement {
|
||||
type: "circle"
|
||||
center: Point
|
||||
edge: Point
|
||||
}
|
||||
|
||||
export interface AngleMeasurement extends BaseMeasurement {
|
||||
type: "angle"
|
||||
vertex: Point
|
||||
armA: Point
|
||||
armB: Point
|
||||
}
|
||||
|
||||
export type Measurement =
|
||||
| LineMeasurement
|
||||
| RectMeasurement
|
||||
| EllipseMeasurement
|
||||
| CircleMeasurement
|
||||
| AngleMeasurement
|
||||
Loading…
x
Reference in New Issue
Block a user