feat(measurements): add circle tool, annotated exports, and per-image persistence
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled

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:
Samuel Prevost 2026-04-25 11:07:28 +02:00
parent 93b05f554c
commit 9c47736799
5 changed files with 765 additions and 176 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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
}

View File

@ -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"

View 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
View 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