feat(pipeline): split Deskew + Measure into separate steps
Step 4 (was "Result") now only runs the perspective correction and shows
diagnostics + a small preview at the standard column width. Step 5
("Measure") is a new full-bleed view dedicated to annotation, with the
download buttons promoted to the top of the page.
Re-running the deskew at a different output px/mm now rescales any
measurements already saved for the image (cached by file hash) so they
stay anchored to the same physical features instead of drifting.
Theme tweaks: card surfaces are now visibly distinct from the page
background in light mode, and dark mode is a touch lighter than the
previous near-black. The "Made by" footer is pinned to the bottom of
the viewport via fixed positioning, with corresponding pb on <main>.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b28ffe267b
commit
1118de74da
13
src/App.vue
13
src/App.vue
@ -4,7 +4,8 @@ import StepIndicator from "@/components/StepIndicator.vue"
|
||||
import ImageUpload from "@/components/ImageUpload.vue"
|
||||
import ExifViewer from "@/components/ExifViewer.vue"
|
||||
import DatumEditor from "@/components/DatumEditor.vue"
|
||||
import ResultViewer from "@/components/ResultViewer.vue"
|
||||
import DeskewViewer from "@/components/DeskewViewer.vue"
|
||||
import MeasureViewer from "@/components/MeasureViewer.vue"
|
||||
import ThemeToggle from "@/components/ThemeToggle.vue"
|
||||
import SkwikLogo from "@/components/SkwikLogo.vue"
|
||||
|
||||
@ -50,15 +51,19 @@ const store = useAppStore()
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-7xl px-4 py-6">
|
||||
<!-- Bottom padding clears the fixed footer so content never sits
|
||||
underneath it. Footer height ≈ py-3 + 1lh ≈ 2.5rem; add a
|
||||
small buffer. -->
|
||||
<main class="mx-auto max-w-7xl px-4 pb-16 pt-6">
|
||||
<ImageUpload v-if="store.currentStep === 1" />
|
||||
<ExifViewer v-else-if="store.currentStep === 2" />
|
||||
<DatumEditor v-else-if="store.currentStep === 3" />
|
||||
<ResultViewer v-else-if="store.currentStep === 4" />
|
||||
<DeskewViewer v-else-if="store.currentStep === 4" />
|
||||
<MeasureViewer v-else-if="store.currentStep === 5" />
|
||||
</main>
|
||||
|
||||
<footer
|
||||
class="border-t border-border/50 py-4 text-center text-xs text-muted-foreground"
|
||||
class="fixed inset-x-0 bottom-0 z-40 border-t border-border/50 bg-background/95 py-3 text-center text-xs text-muted-foreground backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
>
|
||||
Made by
|
||||
<a
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card: oklch(0.965 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
@ -86,11 +86,11 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.13 0 0);
|
||||
--background: oklch(0.185 0 0);
|
||||
--foreground: oklch(0.95 0 0);
|
||||
--card: oklch(0.175 0 0);
|
||||
--card: oklch(0.235 0 0);
|
||||
--card-foreground: oklch(0.95 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover: oklch(0.235 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
|
||||
@ -2085,7 +2085,7 @@ function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||
}
|
||||
|
||||
// Legacy export: bare image + scale bar, no measurements. Preserved as-is
|
||||
// for any caller still wired to it (currently none — ResultViewer's
|
||||
// for any caller still wired to it (currently none — MeasureViewer's
|
||||
// addScaleBar handles the no-measurements case directly).
|
||||
function exportWithScaleBar(): Promise<Blob> {
|
||||
const image = img.value
|
||||
@ -2441,9 +2441,10 @@ watch(
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Canvas + side list. The parent ResultViewer clamps width to
|
||||
max-w-4xl; widening the canvas beyond that requires a parent
|
||||
change (see ResultViewer.vue root container). -->
|
||||
<!-- Canvas + side list. Width is dictated by the parent — when
|
||||
rendered inside MeasureViewer the surrounding container spans
|
||||
the full viewport width; inside DeskewViewer it is capped at
|
||||
the standard step width. -->
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_220px]">
|
||||
<div
|
||||
ref="containerRef"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue"
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue"
|
||||
import { useAppStore } from "@/stores/app"
|
||||
import { deskewImage, waitForOpenCV } from "@/lib/deskew"
|
||||
import type { Datum } from "@/types"
|
||||
@ -8,12 +8,6 @@ import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@ -30,30 +24,20 @@ import {
|
||||
TableHeader,
|
||||
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 } from "@/lib/settings-cache"
|
||||
import {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
} from "@/lib/settings-cache"
|
||||
loadMeasurements,
|
||||
saveMeasurements,
|
||||
scaleMeasurements,
|
||||
} from "@/lib/measurement-cache"
|
||||
|
||||
const store = useAppStore()
|
||||
const resultUrl = ref<string | null>(null)
|
||||
const viewerRef = ref<CorrectedImageViewerRef | null>(null)
|
||||
const previewUrl = ref<string | null>(null)
|
||||
const error = ref("")
|
||||
const hasRun = ref(false)
|
||||
const cvReady = ref(false)
|
||||
const cvLoading = ref(false)
|
||||
const showAlgoDetails = ref(false)
|
||||
const includeScaleBar = ref(false)
|
||||
const scaleInput = ref(String(store.scalePxPerMm))
|
||||
const scaleValid = computed(() => {
|
||||
const n = Number(scaleInput.value)
|
||||
@ -149,29 +133,28 @@ function computeAutoScale(): number {
|
||||
|
||||
onMounted(() => {
|
||||
const cached = loadSettings()
|
||||
if (cached) {
|
||||
includeScaleBar.value = cached.includeScaleBar
|
||||
if (cached && cached.scalePxPerMm !== DEFAULT_SCALE_PX_PER_MM) {
|
||||
// Only use cached scale if it was explicitly set before
|
||||
if (cached.scalePxPerMm !== DEFAULT_SCALE_PX_PER_MM) {
|
||||
scaleInput.value = String(cached.scalePxPerMm)
|
||||
return
|
||||
}
|
||||
scaleInput.value = String(cached.scalePxPerMm)
|
||||
} else {
|
||||
// Auto-compute a sensible default scale
|
||||
const auto = computeAutoScale()
|
||||
store.scalePxPerMm = auto
|
||||
scaleInput.value = String(auto)
|
||||
}
|
||||
// Re-create the preview URL if a deskew result is already cached on the
|
||||
// store (e.g. user navigated back from Measure).
|
||||
if (store.deskewResult) {
|
||||
previewUrl.value = URL.createObjectURL(
|
||||
store.deskewResult.correctedImageBlob,
|
||||
)
|
||||
hasRun.value = true
|
||||
}
|
||||
// Auto-compute a sensible default scale
|
||||
const auto = computeAutoScale()
|
||||
store.scalePxPerMm = auto
|
||||
scaleInput.value = String(auto)
|
||||
})
|
||||
|
||||
watch(
|
||||
[() => store.scalePxPerMm, includeScaleBar],
|
||||
() => {
|
||||
saveSettings({
|
||||
scalePxPerMm: store.scalePxPerMm,
|
||||
includeScaleBar: includeScaleBar.value,
|
||||
})
|
||||
},
|
||||
)
|
||||
onUnmounted(() => {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
})
|
||||
|
||||
// Progress tracking
|
||||
const progressStep = ref(0)
|
||||
@ -234,11 +217,14 @@ async function runDeskew() {
|
||||
requestAnimationFrame(r)
|
||||
})
|
||||
|
||||
const newScale = store.scalePxPerMm
|
||||
const oldScale = store.lastDeskewScale
|
||||
|
||||
const result = await deskewImage({
|
||||
image: store.loadedImage,
|
||||
datums: store.datums,
|
||||
exif: store.exifData,
|
||||
scalePxPerMm: store.scalePxPerMm,
|
||||
scalePxPerMm: newScale,
|
||||
onProgress: (step, total, label) => {
|
||||
progressStep.value = step
|
||||
progressTotal.value = total
|
||||
@ -247,10 +233,28 @@ async function runDeskew() {
|
||||
},
|
||||
})
|
||||
|
||||
store.setResult(result)
|
||||
// If the user changed the output scale between runs, the new
|
||||
// corrected image is a different size — rescale any measurements
|
||||
// already cached for this image so they stay anchored to the same
|
||||
// physical features. CorrectedImageViewer reads from cache on
|
||||
// mount, so writing here is enough; no in-memory state to sync.
|
||||
if (
|
||||
oldScale !== null &&
|
||||
oldScale > 0 &&
|
||||
oldScale !== newScale &&
|
||||
store.fileHash
|
||||
) {
|
||||
const cached = loadMeasurements(store.fileHash)
|
||||
if (cached && cached.length > 0) {
|
||||
const scaled = scaleMeasurements(cached, newScale / oldScale)
|
||||
saveMeasurements(store.fileHash, scaled)
|
||||
}
|
||||
}
|
||||
|
||||
if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
|
||||
resultUrl.value = URL.createObjectURL(result.correctedImageBlob)
|
||||
store.setResult(result, newScale)
|
||||
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = URL.createObjectURL(result.correctedImageBlob)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Deskew failed"
|
||||
} finally {
|
||||
@ -258,168 +262,27 @@ async function runDeskew() {
|
||||
store.processingStatus = ""
|
||||
}
|
||||
}
|
||||
|
||||
function addScaleBar(image: HTMLImageElement): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const iw = image.naturalWidth
|
||||
const ih = image.naturalHeight
|
||||
const scale = store.scalePxPerMm
|
||||
|
||||
const unit = Math.max(iw / 100, 8)
|
||||
const barHeightPx = Math.round(unit * 5)
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = iw
|
||||
canvas.height = ih + barHeightPx
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) {
|
||||
reject(new Error("No 2D context"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.drawImage(image, 0, 0)
|
||||
|
||||
ctx.fillStyle = "#000"
|
||||
ctx.fillRect(0, ih, iw, barHeightPx)
|
||||
|
||||
const imgWidthMm = iw / scale
|
||||
const niceSteps = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]
|
||||
const targetMm = imgWidthMm * 0.2
|
||||
let barMm = niceSteps[0] ?? 10
|
||||
for (const s of niceSteps) {
|
||||
barMm = s
|
||||
if (s >= targetMm) break
|
||||
}
|
||||
const barWidthPx = barMm * scale
|
||||
|
||||
const margin = Math.round(unit * 2)
|
||||
const barX = margin
|
||||
const barY = ih + barHeightPx / 2
|
||||
const barThick = Math.max(Math.round(unit * 0.6), 4)
|
||||
const tickH = Math.round(unit * 1.5)
|
||||
const tickW = Math.max(2, Math.round(unit * 0.15))
|
||||
|
||||
ctx.fillStyle = "#fff"
|
||||
ctx.fillRect(barX, barY - barThick / 2, barWidthPx, barThick)
|
||||
ctx.fillRect(barX, barY - tickH / 2, tickW, tickH)
|
||||
ctx.fillRect(
|
||||
barX + barWidthPx - tickW,
|
||||
barY - tickH / 2,
|
||||
tickW,
|
||||
tickH,
|
||||
)
|
||||
|
||||
const fontSize = Math.round(unit * 1.4)
|
||||
ctx.font = `bold ${String(fontSize)}px monospace`
|
||||
ctx.fillStyle = "#fff"
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "bottom"
|
||||
ctx.fillText(
|
||||
`${String(barMm)} mm`,
|
||||
barX + barWidthPx / 2,
|
||||
barY - tickH / 2 - Math.round(unit * 0.3),
|
||||
)
|
||||
|
||||
const smallFont = Math.round(unit * 1)
|
||||
ctx.textAlign = "right"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.font = `${String(smallFont)}px monospace`
|
||||
ctx.fillStyle = "rgba(255,255,255,0.6)"
|
||||
ctx.fillText(`${String(scale)} px/mm`, iw - margin, barY)
|
||||
|
||||
canvas.toBlob((b) => {
|
||||
if (b) resolve(b)
|
||||
else reject(new Error("toBlob failed"))
|
||||
}, "image/png")
|
||||
})
|
||||
}
|
||||
|
||||
async function download() {
|
||||
if (!store.deskewResult) return
|
||||
|
||||
let blob: Blob = store.deskewResult.correctedImageBlob
|
||||
|
||||
console.log("[download] includeScaleBar =", includeScaleBar.value)
|
||||
if (includeScaleBar.value) {
|
||||
// Load the corrected image into an HTMLImageElement for drawing
|
||||
const imgUrl = URL.createObjectURL(
|
||||
store.deskewResult.correctedImageBlob,
|
||||
)
|
||||
try {
|
||||
const image = await new Promise<HTMLImageElement>(
|
||||
(resolve, reject) => {
|
||||
const el = new Image()
|
||||
el.onload = () => {
|
||||
resolve(el)
|
||||
}
|
||||
el.onerror = () => {
|
||||
reject(new Error("Failed to load image"))
|
||||
}
|
||||
el.src = imgUrl
|
||||
},
|
||||
)
|
||||
blob = await addScaleBar(image)
|
||||
} finally {
|
||||
URL.revokeObjectURL(imgUrl)
|
||||
}
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
const baseName =
|
||||
store.originalFile?.name.replace(/\.[^.]+$/, "") ?? "output"
|
||||
a.download = `${baseName}-skwik.png`
|
||||
a.click()
|
||||
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>
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Process & Download</h2>
|
||||
<h2 class="text-xl font-semibold">Deskew</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Set the output scale, run perspective correction, and
|
||||
download.
|
||||
Set the output scale and run perspective correction.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" @click="store.goToStep(3)"
|
||||
>Back</Button
|
||||
>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button variant="outline" @click="store.goToStep(3)"
|
||||
>Back</Button
|
||||
>
|
||||
<Button
|
||||
:disabled="!store.canProceedToStep5"
|
||||
@click="store.goToStep(5)"
|
||||
>Next: Measure</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scale setting -->
|
||||
@ -510,7 +373,7 @@ async function downloadMeasured(scope: "full" | "view") {
|
||||
{{ datum.label }}
|
||||
<span class="ml-1 font-mono text-xs">{{
|
||||
datum.type === "rectangle"
|
||||
? `${datum.widthMm}\u00D7${datum.heightMm}mm`
|
||||
? `${datum.widthMm}×${datum.heightMm}mm`
|
||||
: datum.type === "line"
|
||||
? `${datum.lengthMm}mm`
|
||||
: `⌀${datum.diameterMm}mm`
|
||||
@ -594,7 +457,7 @@ async function downloadMeasured(scope: "full" | "view") {
|
||||
</p>
|
||||
|
||||
<!-- Result -->
|
||||
<template v-if="store.deskewResult && resultUrl">
|
||||
<template v-if="store.deskewResult && previewUrl">
|
||||
<!-- Diagnostics -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@ -847,173 +710,49 @@ async function downloadMeasured(scope: "full" | "view") {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Corrected image with tools — full-bleed to use the whole page width
|
||||
even though the surrounding column is capped at max-w-4xl. -->
|
||||
<div
|
||||
class="relative left-1/2 w-screen -translate-x-1/2 px-4"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base"
|
||||
>Corrected Image</CardTitle
|
||||
>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CorrectedImageViewer
|
||||
ref="viewerRef"
|
||||
:image-url="resultUrl"
|
||||
:scale-px-per-mm="store.scalePxPerMm"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Download -->
|
||||
<div class="flex flex-col items-center gap-3 pb-8">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 select-none"
|
||||
>
|
||||
<input
|
||||
v-model="includeScaleBar"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 accent-primary"
|
||||
/>
|
||||
<span
|
||||
class="text-sm text-muted-foreground"
|
||||
>Include scale bar in export</span
|
||||
>
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" class="max-w-xs">
|
||||
Appends a black bar at the bottom of the
|
||||
exported image with a measurement scale and
|
||||
px/mm annotation.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||
<Button size="lg" @click="download">
|
||||
<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"
|
||||
/>
|
||||
</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"
|
||||
@click="store.reset()"
|
||||
<!-- Deskewed preview -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base"
|
||||
>Deskewed Preview</CardTitle
|
||||
>
|
||||
Process New Image
|
||||
</Button>
|
||||
</div>
|
||||
<p
|
||||
class="font-mono text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
store.deskewResult.diagnostics.outputWidthPx
|
||||
}}×{{
|
||||
store.deskewResult.diagnostics.outputHeightPx
|
||||
}} px —
|
||||
{{
|
||||
(
|
||||
store.deskewResult
|
||||
.correctedImageBlob.size /
|
||||
1024 /
|
||||
1024
|
||||
).toFixed(1)
|
||||
}} MB
|
||||
</p>
|
||||
<CardDescription>
|
||||
Continue to <strong>Measure</strong> to add
|
||||
annotations and download.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
class="flex items-center justify-center overflow-hidden rounded-md bg-muted"
|
||||
>
|
||||
<img
|
||||
:src="previewUrl"
|
||||
alt="Deskewed image preview"
|
||||
class="max-h-[480px] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="flex justify-center pb-8">
|
||||
<Button size="lg" @click="store.goToStep(5)">
|
||||
Continue to Measure
|
||||
<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="ml-2"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
372
src/components/MeasureViewer.vue
Normal file
372
src/components/MeasureViewer.vue
Normal file
@ -0,0 +1,372 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from "vue"
|
||||
import { useAppStore } from "@/stores/app"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
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 } from "@/lib/settings-cache"
|
||||
|
||||
const store = useAppStore()
|
||||
const resultUrl = ref<string | null>(null)
|
||||
const viewerRef = ref<CorrectedImageViewerRef | null>(null)
|
||||
const error = ref("")
|
||||
const includeScaleBar = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const cached = loadSettings()
|
||||
if (cached) {
|
||||
includeScaleBar.value = cached.includeScaleBar
|
||||
}
|
||||
|
||||
if (store.deskewResult) {
|
||||
resultUrl.value = URL.createObjectURL(
|
||||
store.deskewResult.correctedImageBlob,
|
||||
)
|
||||
} else {
|
||||
// No result yet — bounce back to Deskew. Should never happen via
|
||||
// normal navigation since the Next button is gated, but if a user
|
||||
// edits the URL or hot-reloads we don't want to render an empty
|
||||
// viewer.
|
||||
store.goToStep(4)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
|
||||
})
|
||||
|
||||
watch(includeScaleBar, () => {
|
||||
saveSettings({
|
||||
scalePxPerMm: store.scalePxPerMm,
|
||||
includeScaleBar: includeScaleBar.value,
|
||||
})
|
||||
})
|
||||
|
||||
function addScaleBar(image: HTMLImageElement): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const iw = image.naturalWidth
|
||||
const ih = image.naturalHeight
|
||||
const scale = store.scalePxPerMm
|
||||
|
||||
const unit = Math.max(iw / 100, 8)
|
||||
const barHeightPx = Math.round(unit * 5)
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = iw
|
||||
canvas.height = ih + barHeightPx
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) {
|
||||
reject(new Error("No 2D context"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.drawImage(image, 0, 0)
|
||||
|
||||
ctx.fillStyle = "#000"
|
||||
ctx.fillRect(0, ih, iw, barHeightPx)
|
||||
|
||||
const imgWidthMm = iw / scale
|
||||
const niceSteps = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]
|
||||
const targetMm = imgWidthMm * 0.2
|
||||
let barMm = niceSteps[0] ?? 10
|
||||
for (const s of niceSteps) {
|
||||
barMm = s
|
||||
if (s >= targetMm) break
|
||||
}
|
||||
const barWidthPx = barMm * scale
|
||||
|
||||
const margin = Math.round(unit * 2)
|
||||
const barX = margin
|
||||
const barY = ih + barHeightPx / 2
|
||||
const barThick = Math.max(Math.round(unit * 0.6), 4)
|
||||
const tickH = Math.round(unit * 1.5)
|
||||
const tickW = Math.max(2, Math.round(unit * 0.15))
|
||||
|
||||
ctx.fillStyle = "#fff"
|
||||
ctx.fillRect(barX, barY - barThick / 2, barWidthPx, barThick)
|
||||
ctx.fillRect(barX, barY - tickH / 2, tickW, tickH)
|
||||
ctx.fillRect(
|
||||
barX + barWidthPx - tickW,
|
||||
barY - tickH / 2,
|
||||
tickW,
|
||||
tickH,
|
||||
)
|
||||
|
||||
const fontSize = Math.round(unit * 1.4)
|
||||
ctx.font = `bold ${String(fontSize)}px monospace`
|
||||
ctx.fillStyle = "#fff"
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "bottom"
|
||||
ctx.fillText(
|
||||
`${String(barMm)} mm`,
|
||||
barX + barWidthPx / 2,
|
||||
barY - tickH / 2 - Math.round(unit * 0.3),
|
||||
)
|
||||
|
||||
const smallFont = Math.round(unit * 1)
|
||||
ctx.textAlign = "right"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.font = `${String(smallFont)}px monospace`
|
||||
ctx.fillStyle = "rgba(255,255,255,0.6)"
|
||||
ctx.fillText(`${String(scale)} px/mm`, iw - margin, barY)
|
||||
|
||||
canvas.toBlob((b) => {
|
||||
if (b) resolve(b)
|
||||
else reject(new Error("toBlob failed"))
|
||||
}, "image/png")
|
||||
})
|
||||
}
|
||||
|
||||
async function download() {
|
||||
if (!store.deskewResult) return
|
||||
|
||||
let blob: Blob = store.deskewResult.correctedImageBlob
|
||||
|
||||
if (includeScaleBar.value) {
|
||||
const imgUrl = URL.createObjectURL(
|
||||
store.deskewResult.correctedImageBlob,
|
||||
)
|
||||
try {
|
||||
const image = await new Promise<HTMLImageElement>(
|
||||
(resolve, reject) => {
|
||||
const el = new Image()
|
||||
el.onload = () => {
|
||||
resolve(el)
|
||||
}
|
||||
el.onerror = () => {
|
||||
reject(new Error("Failed to load image"))
|
||||
}
|
||||
el.src = imgUrl
|
||||
},
|
||||
)
|
||||
blob = await addScaleBar(image)
|
||||
} finally {
|
||||
URL.revokeObjectURL(imgUrl)
|
||||
}
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
const baseName =
|
||||
store.originalFile?.name.replace(/\.[^.]+$/, "") ?? "output"
|
||||
a.download = `${baseName}-skwik.png`
|
||||
a.click()
|
||||
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>
|
||||
<!-- Break out of <main>'s max-w-7xl so the Measure step spans the full
|
||||
viewport width — annotation work benefits from the extra room. -->
|
||||
<div class="relative left-1/2 w-screen -translate-x-1/2 space-y-4 px-4">
|
||||
<!-- Top header with downloads + back -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Measure</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Annotate the corrected image and download.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 select-none"
|
||||
>
|
||||
<input
|
||||
v-model="includeScaleBar"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 accent-primary"
|
||||
/>
|
||||
<span
|
||||
class="text-sm text-muted-foreground"
|
||||
>Scale bar</span
|
||||
>
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" class="max-w-xs">
|
||||
Appends a black bar at the bottom of the
|
||||
exported image with a measurement scale and
|
||||
px/mm annotation.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div class="mx-1 h-6 w-px bg-border" />
|
||||
<Button variant="outline" @click="store.goToStep(4)"
|
||||
>Back</Button
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="store.reset()"
|
||||
>New Image</Button
|
||||
>
|
||||
<Button @click="download">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
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" />
|
||||
</svg>
|
||||
PNG
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@click="downloadMeasured('full')"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
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>
|
||||
Full + measurements
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" 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
|
||||
variant="secondary"
|
||||
@click="downloadMeasured('view')"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
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>
|
||||
View + measurements
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-destructive">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<!-- Full-width corrected image with measurement tools -->
|
||||
<Card v-if="resultUrl">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Corrected Image</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CorrectedImageViewer
|
||||
ref="viewerRef"
|
||||
:image-url="resultUrl"
|
||||
:scale-px-per-mm="store.scalePxPerMm"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@ -9,7 +9,8 @@ const steps: { num: AppStep; label: string }[] = [
|
||||
{ num: 1, label: "Upload" },
|
||||
{ num: 2, label: "EXIF" },
|
||||
{ num: 3, label: "Datums" },
|
||||
{ num: 4, label: "Result" },
|
||||
{ num: 4, label: "Deskew" },
|
||||
{ num: 5, label: "Measure" },
|
||||
]
|
||||
|
||||
function isReachable(num: AppStep): boolean {
|
||||
|
||||
@ -1,7 +1,59 @@
|
||||
import type { Point } from "@/types"
|
||||
import type { Measurement } from "@/types/measurements"
|
||||
|
||||
const KEY_PREFIX = "skwik-measurements-"
|
||||
|
||||
/** Scale every image-space point in `m` by `ratio`. Used when the user
|
||||
* re-runs the deskew at a different output px/mm — the corrected image
|
||||
* changes size, so measurements (stored in image-pixel coords) must move
|
||||
* with it to stay anchored to the same physical features. */
|
||||
function scalePoint(p: Point, ratio: number): Point {
|
||||
return { x: p.x * ratio, y: p.y * ratio }
|
||||
}
|
||||
|
||||
export function scaleMeasurements(
|
||||
measurements: Measurement[],
|
||||
ratio: number,
|
||||
): Measurement[] {
|
||||
if (ratio === 1) return measurements
|
||||
return measurements.map((m) => {
|
||||
switch (m.type) {
|
||||
case "line":
|
||||
return { ...m, a: scalePoint(m.a, ratio), b: scalePoint(m.b, ratio) }
|
||||
case "rectangle":
|
||||
return {
|
||||
...m,
|
||||
corners: [
|
||||
scalePoint(m.corners[0], ratio),
|
||||
scalePoint(m.corners[1], ratio),
|
||||
scalePoint(m.corners[2], ratio),
|
||||
scalePoint(m.corners[3], ratio),
|
||||
],
|
||||
}
|
||||
case "ellipse":
|
||||
return {
|
||||
...m,
|
||||
center: scalePoint(m.center, ratio),
|
||||
axisEndA: scalePoint(m.axisEndA, ratio),
|
||||
axisEndB: scalePoint(m.axisEndB, ratio),
|
||||
}
|
||||
case "circle":
|
||||
return {
|
||||
...m,
|
||||
center: scalePoint(m.center, ratio),
|
||||
edge: scalePoint(m.edge, ratio),
|
||||
}
|
||||
case "angle":
|
||||
return {
|
||||
...m,
|
||||
vertex: scalePoint(m.vertex, ratio),
|
||||
armA: scalePoint(m.armA, ratio),
|
||||
armB: scalePoint(m.armB, ratio),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function saveMeasurements(
|
||||
hash: string,
|
||||
measurements: Measurement[],
|
||||
|
||||
@ -23,6 +23,10 @@ export const useAppStore = defineStore("app", () => {
|
||||
)
|
||||
const fileHash = ref<string | null>(null)
|
||||
const cacheRestoreMessage = ref("")
|
||||
/** Output px/mm of the current `deskewResult`. Set whenever a deskew
|
||||
* produces a new result; consumers compare against the live
|
||||
* `scalePxPerMm` to detect when measurements need to be rescaled. */
|
||||
const lastDeskewScale = ref<number | null>(null)
|
||||
|
||||
const canProceedToStep2 = computed(() => loadedImage.value !== null)
|
||||
const canProceedToStep3 = computed(() => canProceedToStep2.value)
|
||||
@ -34,6 +38,9 @@ export const useAppStore = defineStore("app", () => {
|
||||
return d.diameterMm > 0
|
||||
})
|
||||
})
|
||||
const canProceedToStep5 = computed(
|
||||
() => canProceedToStep4.value && deskewResult.value !== null,
|
||||
)
|
||||
|
||||
function setImage(file: File, image: HTMLImageElement) {
|
||||
originalFile.value = file
|
||||
@ -141,8 +148,9 @@ export const useAppStore = defineStore("app", () => {
|
||||
}
|
||||
}
|
||||
|
||||
function setResult(result: DeskewResult) {
|
||||
function setResult(result: DeskewResult, scalePxPerMmUsed: number) {
|
||||
deskewResult.value = result
|
||||
lastDeskewScale.value = scalePxPerMmUsed
|
||||
}
|
||||
|
||||
function setFileHash(hash: string) {
|
||||
@ -163,6 +171,7 @@ export const useAppStore = defineStore("app", () => {
|
||||
scalePxPerMm.value = DEFAULT_SCALE_PX_PER_MM
|
||||
fileHash.value = null
|
||||
cacheRestoreMessage.value = ""
|
||||
lastDeskewScale.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
@ -179,9 +188,11 @@ export const useAppStore = defineStore("app", () => {
|
||||
scalePxPerMm,
|
||||
fileHash,
|
||||
cacheRestoreMessage,
|
||||
lastDeskewScale,
|
||||
canProceedToStep2,
|
||||
canProceedToStep3,
|
||||
canProceedToStep4,
|
||||
canProceedToStep5,
|
||||
setImage,
|
||||
setExif,
|
||||
goToStep,
|
||||
|
||||
@ -114,7 +114,7 @@ export interface DeskewResult {
|
||||
diagnostics: DeskewDiagnostics
|
||||
}
|
||||
|
||||
export type AppStep = 1 | 2 | 3 | 4
|
||||
export type AppStep = 1 | 2 | 3 | 4 | 5
|
||||
|
||||
/** Pixels per mm in the output image. Default 10 (= 100 px/cm). */
|
||||
export const DEFAULT_SCALE_PX_PER_MM = 10
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user