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 { extractExif } from "@/lib/exif"
|
||||||
import { hashFile } from "@/lib/file-hash"
|
import { hashFile } from "@/lib/file-hash"
|
||||||
import { loadDatums, clearCache, getCacheSize } from "@/lib/datum-cache"
|
import { loadDatums, clearCache, getCacheSize } from "@/lib/datum-cache"
|
||||||
|
import { clearCache as clearMeasurementCache } from "@/lib/measurement-cache"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@ -29,6 +30,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
function handleClearCache() {
|
function handleClearCache() {
|
||||||
clearCache()
|
clearCache()
|
||||||
|
clearMeasurementCache()
|
||||||
cacheCount.value = 0
|
cacheCount.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,15 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import CorrectedImageViewer from "@/components/CorrectedImageViewer.vue"
|
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 {
|
import {
|
||||||
loadSettings,
|
loadSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
@ -38,6 +47,7 @@ import {
|
|||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const resultUrl = ref<string | null>(null)
|
const resultUrl = ref<string | null>(null)
|
||||||
|
const viewerRef = ref<CorrectedImageViewerRef | null>(null)
|
||||||
const error = ref("")
|
const error = ref("")
|
||||||
const hasRun = ref(false)
|
const hasRun = ref(false)
|
||||||
const cvReady = ref(false)
|
const cvReady = ref(false)
|
||||||
@ -364,6 +374,37 @@ async function download() {
|
|||||||
URL.revokeObjectURL(url)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -819,6 +860,7 @@ async function download() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<CorrectedImageViewer
|
<CorrectedImageViewer
|
||||||
|
ref="viewerRef"
|
||||||
:image-url="resultUrl"
|
:image-url="resultUrl"
|
||||||
:scale-px-per-mm="store.scalePxPerMm"
|
:scale-px-per-mm="store.scalePxPerMm"
|
||||||
/>
|
/>
|
||||||
@ -852,7 +894,7 @@ async function download() {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||||
<Button size="lg" @click="download">
|
<Button size="lg" @click="download">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@ -879,6 +921,74 @@ async function download() {
|
|||||||
</svg>
|
</svg>
|
||||||
Download PNG
|
Download PNG
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
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