Compare commits
4 Commits
b28ffe267b
...
9032af426e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9032af426e | ||
|
|
a5f4bf650c | ||
|
|
ed4da082ce | ||
|
|
1118de74da |
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 22 KiB |
BIN
public/example-measured.jpg
Normal file
BIN
public/example-measured.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
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 ImageUpload from "@/components/ImageUpload.vue"
|
||||||
import ExifViewer from "@/components/ExifViewer.vue"
|
import ExifViewer from "@/components/ExifViewer.vue"
|
||||||
import DatumEditor from "@/components/DatumEditor.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 ThemeToggle from "@/components/ThemeToggle.vue"
|
||||||
import SkwikLogo from "@/components/SkwikLogo.vue"
|
import SkwikLogo from "@/components/SkwikLogo.vue"
|
||||||
|
|
||||||
@ -50,15 +51,19 @@ const store = useAppStore()
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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" />
|
<ImageUpload v-if="store.currentStep === 1" />
|
||||||
<ExifViewer v-else-if="store.currentStep === 2" />
|
<ExifViewer v-else-if="store.currentStep === 2" />
|
||||||
<DatumEditor v-else-if="store.currentStep === 3" />
|
<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>
|
</main>
|
||||||
|
|
||||||
<footer
|
<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
|
Made by
|
||||||
<a
|
<a
|
||||||
|
|||||||
@ -53,7 +53,7 @@
|
|||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 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);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
@ -86,11 +86,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.13 0 0);
|
--background: oklch(0.185 0 0);
|
||||||
--foreground: oklch(0.95 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);
|
--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);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.922 0 0);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import type {
|
|||||||
import { getDatumColor } from "@/lib/datums"
|
import { getDatumColor } from "@/lib/datums"
|
||||||
import { useAppStore } from "@/stores/app"
|
import { useAppStore } from "@/stores/app"
|
||||||
import { loadMeasurements, saveMeasurements } from "@/lib/measurement-cache"
|
import { loadMeasurements, saveMeasurements } from "@/lib/measurement-cache"
|
||||||
|
import { loadZoom, saveZoom } from "@/lib/zoom-cache"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
imageUrl: string
|
imageUrl: string
|
||||||
@ -124,11 +125,45 @@ function loadImg() {
|
|||||||
img.value = image
|
img.value = image
|
||||||
imgLoaded.value = true
|
imgLoaded.value = true
|
||||||
fitToContainer()
|
fitToContainer()
|
||||||
|
// After auto-fit, prefer a previously-saved zoom/pan if the
|
||||||
|
// values still place the image inside the container — protects
|
||||||
|
// against stale cache entries (different image dims) that would
|
||||||
|
// otherwise leave the viewer staring at empty canvas.
|
||||||
|
const hash = store.fileHash
|
||||||
|
if (hash) {
|
||||||
|
const cached = loadZoom(hash)
|
||||||
|
if (cached && isZoomReasonable(cached)) {
|
||||||
|
viewScale.value = cached.viewScale
|
||||||
|
viewOffsetX.value = cached.viewOffsetX
|
||||||
|
viewOffsetY.value = cached.viewOffsetY
|
||||||
|
}
|
||||||
|
}
|
||||||
redraw()
|
redraw()
|
||||||
}
|
}
|
||||||
image.src = props.imageUrl
|
image.src = props.imageUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A cached zoom/pan is "reasonable" if the image's bounding box still
|
||||||
|
// intersects the canvas at all under that transform. Catches the
|
||||||
|
// degenerate case where the cache outlived an image dimension change.
|
||||||
|
function isZoomReasonable(z: {
|
||||||
|
viewScale: number
|
||||||
|
viewOffsetX: number
|
||||||
|
viewOffsetY: number
|
||||||
|
}): boolean {
|
||||||
|
if (!Number.isFinite(z.viewScale) || z.viewScale <= 0) return false
|
||||||
|
if (!Number.isFinite(z.viewOffsetX) || !Number.isFinite(z.viewOffsetY))
|
||||||
|
return false
|
||||||
|
const c = containerRef.value
|
||||||
|
const i = img.value
|
||||||
|
if (!c || !i) return false
|
||||||
|
const left = z.viewOffsetX
|
||||||
|
const top = z.viewOffsetY
|
||||||
|
const right = left + i.naturalWidth * z.viewScale
|
||||||
|
const bottom = top + i.naturalHeight * z.viewScale
|
||||||
|
return right > 0 && bottom > 0 && left < c.clientWidth && top < c.clientHeight
|
||||||
|
}
|
||||||
|
|
||||||
function fitToContainer() {
|
function fitToContainer() {
|
||||||
const c = containerRef.value
|
const c = containerRef.value
|
||||||
const i = img.value
|
const i = img.value
|
||||||
@ -2085,7 +2120,7 @@ function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Legacy export: bare image + scale bar, no measurements. Preserved as-is
|
// 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).
|
// addScaleBar handles the no-measurements case directly).
|
||||||
function exportWithScaleBar(): Promise<Blob> {
|
function exportWithScaleBar(): Promise<Blob> {
|
||||||
const image = img.value
|
const image = img.value
|
||||||
@ -2250,6 +2285,24 @@ watch(
|
|||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Persist zoom/pan with a small debounce — wheel events fire rapidly and
|
||||||
|
// resize bursts shouldn't each hit localStorage. The debounce is short
|
||||||
|
// enough that a normal pan-and-pause finishes saving before navigation.
|
||||||
|
let zoomSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
watch([viewScale, viewOffsetX, viewOffsetY], () => {
|
||||||
|
if (!imgLoaded.value || !store.fileHash) return
|
||||||
|
if (zoomSaveTimer) clearTimeout(zoomSaveTimer)
|
||||||
|
const hash = store.fileHash
|
||||||
|
zoomSaveTimer = setTimeout(() => {
|
||||||
|
saveZoom(hash, {
|
||||||
|
viewScale: viewScale.value,
|
||||||
|
viewOffsetX: viewOffsetX.value,
|
||||||
|
viewOffsetY: viewOffsetY.value,
|
||||||
|
})
|
||||||
|
zoomSaveTimer = null
|
||||||
|
}, 250)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -2441,9 +2494,10 @@ watch(
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Canvas + side list. The parent ResultViewer clamps width to
|
<!-- Canvas + side list. Width is dictated by the parent — when
|
||||||
max-w-4xl; widening the canvas beyond that requires a parent
|
rendered inside MeasureViewer the surrounding container spans
|
||||||
change (see ResultViewer.vue root container). -->
|
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 class="grid gap-3 md:grid-cols-[1fr_220px]">
|
||||||
<div
|
<div
|
||||||
ref="containerRef"
|
ref="containerRef"
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { useAppStore } from "@/stores/app"
|
||||||
import { deskewImage, waitForOpenCV } from "@/lib/deskew"
|
import { deskewImage, waitForOpenCV } from "@/lib/deskew"
|
||||||
import type { Datum } from "@/types"
|
import type { Datum } from "@/types"
|
||||||
@ -8,12 +8,6 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip"
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@ -30,30 +24,22 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import CorrectedImageViewer from "@/components/CorrectedImageViewer.vue"
|
import { loadSettings } from "@/lib/settings-cache"
|
||||||
// `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,
|
loadMeasurements,
|
||||||
saveSettings,
|
saveMeasurements,
|
||||||
} from "@/lib/settings-cache"
|
scaleMeasurements,
|
||||||
|
} from "@/lib/measurement-cache"
|
||||||
|
import { patchUpload } from "@/lib/upload-cache"
|
||||||
|
import { clearZoom } from "@/lib/zoom-cache"
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const resultUrl = ref<string | null>(null)
|
const previewUrl = 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)
|
||||||
const cvLoading = ref(false)
|
const cvLoading = ref(false)
|
||||||
const showAlgoDetails = ref(false)
|
const showAlgoDetails = ref(false)
|
||||||
const includeScaleBar = ref(false)
|
|
||||||
const scaleInput = ref(String(store.scalePxPerMm))
|
const scaleInput = ref(String(store.scalePxPerMm))
|
||||||
const scaleValid = computed(() => {
|
const scaleValid = computed(() => {
|
||||||
const n = Number(scaleInput.value)
|
const n = Number(scaleInput.value)
|
||||||
@ -149,29 +135,28 @@ function computeAutoScale(): number {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const cached = loadSettings()
|
const cached = loadSettings()
|
||||||
if (cached) {
|
if (cached && cached.scalePxPerMm !== DEFAULT_SCALE_PX_PER_MM) {
|
||||||
includeScaleBar.value = cached.includeScaleBar
|
|
||||||
// Only use cached scale if it was explicitly set before
|
// Only use cached scale if it was explicitly set before
|
||||||
if (cached.scalePxPerMm !== DEFAULT_SCALE_PX_PER_MM) {
|
|
||||||
scaleInput.value = String(cached.scalePxPerMm)
|
scaleInput.value = String(cached.scalePxPerMm)
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
}
|
|
||||||
// Auto-compute a sensible default scale
|
// Auto-compute a sensible default scale
|
||||||
const auto = computeAutoScale()
|
const auto = computeAutoScale()
|
||||||
store.scalePxPerMm = auto
|
store.scalePxPerMm = auto
|
||||||
scaleInput.value = String(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
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
onUnmounted(() => {
|
||||||
[() => store.scalePxPerMm, includeScaleBar],
|
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||||
() => {
|
})
|
||||||
saveSettings({
|
|
||||||
scalePxPerMm: store.scalePxPerMm,
|
|
||||||
includeScaleBar: includeScaleBar.value,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Progress tracking
|
// Progress tracking
|
||||||
const progressStep = ref(0)
|
const progressStep = ref(0)
|
||||||
@ -234,11 +219,14 @@ async function runDeskew() {
|
|||||||
requestAnimationFrame(r)
|
requestAnimationFrame(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const newScale = store.scalePxPerMm
|
||||||
|
const oldScale = store.lastDeskewScale
|
||||||
|
|
||||||
const result = await deskewImage({
|
const result = await deskewImage({
|
||||||
image: store.loadedImage,
|
image: store.loadedImage,
|
||||||
datums: store.datums,
|
datums: store.datums,
|
||||||
exif: store.exifData,
|
exif: store.exifData,
|
||||||
scalePxPerMm: store.scalePxPerMm,
|
scalePxPerMm: newScale,
|
||||||
onProgress: (step, total, label) => {
|
onProgress: (step, total, label) => {
|
||||||
progressStep.value = step
|
progressStep.value = step
|
||||||
progressTotal.value = total
|
progressTotal.value = total
|
||||||
@ -247,10 +235,47 @@ 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.
|
||||||
|
// The cached zoom/pan also no longer makes sense once the image
|
||||||
|
// dimensions change, so we drop it and let fitToContainer pick
|
||||||
|
// a fresh default.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
clearZoom(store.fileHash)
|
||||||
|
}
|
||||||
|
|
||||||
if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
|
store.setResult(result, newScale)
|
||||||
resultUrl.value = URL.createObjectURL(result.correctedImageBlob)
|
|
||||||
|
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||||
|
previewUrl.value = URL.createObjectURL(result.correctedImageBlob)
|
||||||
|
|
||||||
|
// Persist the deskew artefacts onto the upload record so the
|
||||||
|
// Recent Uploads gallery can reopen straight into Measure. Best
|
||||||
|
// effort: an IndexedDB failure shouldn't break the visible flow.
|
||||||
|
if (store.fileHash) {
|
||||||
|
try {
|
||||||
|
await patchUpload(store.fileHash, {
|
||||||
|
correctedBlob: result.correctedImageBlob,
|
||||||
|
diagnostics: result.diagnostics,
|
||||||
|
scalePxPerMm: newScale,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// ignore — gallery just won't include this entry
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : "Deskew failed"
|
error.value = e instanceof Error ? e.message : "Deskew failed"
|
||||||
} finally {
|
} finally {
|
||||||
@ -258,168 +283,27 @@ async function runDeskew() {
|
|||||||
store.processingStatus = ""
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto max-w-4xl space-y-6">
|
<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>
|
<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">
|
<p class="text-sm text-muted-foreground">
|
||||||
Set the output scale, run perspective correction, and
|
Set the output scale and run perspective correction.
|
||||||
download.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-2">
|
||||||
<Button variant="outline" @click="store.goToStep(3)"
|
<Button variant="outline" @click="store.goToStep(3)"
|
||||||
>Back</Button
|
>Back</Button
|
||||||
>
|
>
|
||||||
|
<Button
|
||||||
|
:disabled="!store.canProceedToStep5"
|
||||||
|
@click="store.goToStep(5)"
|
||||||
|
>Next: Measure</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scale setting -->
|
<!-- Scale setting -->
|
||||||
@ -510,7 +394,7 @@ async function downloadMeasured(scope: "full" | "view") {
|
|||||||
{{ datum.label }}
|
{{ datum.label }}
|
||||||
<span class="ml-1 font-mono text-xs">{{
|
<span class="ml-1 font-mono text-xs">{{
|
||||||
datum.type === "rectangle"
|
datum.type === "rectangle"
|
||||||
? `${datum.widthMm}\u00D7${datum.heightMm}mm`
|
? `${datum.widthMm}×${datum.heightMm}mm`
|
||||||
: datum.type === "line"
|
: datum.type === "line"
|
||||||
? `${datum.lengthMm}mm`
|
? `${datum.lengthMm}mm`
|
||||||
: `⌀${datum.diameterMm}mm`
|
: `⌀${datum.diameterMm}mm`
|
||||||
@ -594,7 +478,7 @@ async function downloadMeasured(scope: "full" | "view") {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Result -->
|
<!-- Result -->
|
||||||
<template v-if="store.deskewResult && resultUrl">
|
<template v-if="store.deskewResult && previewUrl">
|
||||||
<!-- Diagnostics -->
|
<!-- Diagnostics -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -847,55 +731,33 @@ async function downloadMeasured(scope: "full" | "view") {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Corrected image with tools — full-bleed to use the whole page width
|
<!-- Deskewed preview -->
|
||||||
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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-base"
|
<CardTitle class="text-base"
|
||||||
>Corrected Image</CardTitle
|
>Deskewed Preview</CardTitle
|
||||||
>
|
>
|
||||||
|
<CardDescription>
|
||||||
|
Continue to <strong>Measure</strong> to add
|
||||||
|
annotations and download.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<CorrectedImageViewer
|
<div
|
||||||
ref="viewerRef"
|
class="flex items-center justify-center overflow-hidden rounded-md bg-muted"
|
||||||
:image-url="resultUrl"
|
>
|
||||||
:scale-px-per-mm="store.scalePxPerMm"
|
<img
|
||||||
|
:src="previewUrl"
|
||||||
|
alt="Deskewed image preview"
|
||||||
|
class="max-h-[480px] w-full object-contain"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Download -->
|
<div class="flex justify-center pb-8">
|
||||||
<div class="flex flex-col items-center gap-3 pb-8">
|
<Button size="lg" @click="store.goToStep(5)">
|
||||||
<TooltipProvider>
|
Continue to Measure
|
||||||
<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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="18"
|
width="18"
|
||||||
@ -906,114 +768,12 @@ async function downloadMeasured(scope: "full" | "view") {
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
class="mr-2"
|
class="ml-2"
|
||||||
>
|
>
|
||||||
<path
|
<path d="M5 12h14" />
|
||||||
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
|
<path d="m12 5 7 7-7 7" />
|
||||||
/>
|
|
||||||
<polyline points="7 10 12 15 17 10" />
|
|
||||||
<line
|
|
||||||
x1="12"
|
|
||||||
x2="12"
|
|
||||||
y1="15"
|
|
||||||
y2="3"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
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
|
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
|
||||||
@click="store.reset()"
|
|
||||||
>
|
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@ -1,11 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue"
|
import { ref, computed, onMounted, onUnmounted } from "vue"
|
||||||
import { useAppStore } from "@/stores/app"
|
import { useAppStore } from "@/stores/app"
|
||||||
import { loadImage } from "@/lib/image-loader"
|
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 {
|
||||||
|
clearCache as clearMeasurementCache,
|
||||||
|
loadMeasurements,
|
||||||
|
} from "@/lib/measurement-cache"
|
||||||
|
import {
|
||||||
|
saveUpload,
|
||||||
|
loadUpload,
|
||||||
|
listUploads,
|
||||||
|
deleteUpload,
|
||||||
|
clearUploads,
|
||||||
|
type UploadRecord,
|
||||||
|
} from "@/lib/upload-cache"
|
||||||
|
import { clearCache as clearZoomCache } from "@/lib/zoom-cache"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@ -21,17 +33,182 @@ const isDragging = ref(false)
|
|||||||
const error = ref("")
|
const error = ref("")
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const cacheCount = ref(0)
|
const cacheCount = ref(0)
|
||||||
|
const confirmingClear = ref(false)
|
||||||
|
const recentUploads = ref<UploadRecord[]>([])
|
||||||
|
const recentUrls = ref<Map<string, string>>(new Map())
|
||||||
|
|
||||||
const ACCEPTED = "image/*,.heic,.heif"
|
const ACCEPTED = "image/*,.heic,.heif"
|
||||||
|
// Auto-revert the "Are you sure?" prompt after this long of inactivity
|
||||||
|
// so a stray first click can't be confirmed minutes later.
|
||||||
|
const CLEAR_CONFIRM_TIMEOUT_MS = 4000
|
||||||
|
|
||||||
onMounted(() => {
|
let confirmTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
cacheCount.value = getCacheSize()
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleClearCache() {
|
// Only show entries that have a deskew result — those are the ones a
|
||||||
|
// click can meaningfully drop the user back into Measure with. Uploads
|
||||||
|
// that never reached deskew remain saved (so re-uploading the same file
|
||||||
|
// still hits the cache) but don't clutter the gallery.
|
||||||
|
const completedUploads = computed(() =>
|
||||||
|
recentUploads.value.filter(
|
||||||
|
(u) => u.correctedBlob !== undefined && u.diagnostics !== undefined,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
cacheCount.value = getCacheSize()
|
||||||
|
await refreshRecentUploads()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (confirmTimer) clearTimeout(confirmTimer)
|
||||||
|
for (const url of recentUrls.value.values()) URL.revokeObjectURL(url)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refreshRecentUploads() {
|
||||||
|
try {
|
||||||
|
const all = await listUploads()
|
||||||
|
// Revoke any URLs whose entries no longer exist (e.g. after a
|
||||||
|
// delete) before refreshing the map.
|
||||||
|
const nextHashes = new Set(all.map((u) => u.hash))
|
||||||
|
for (const [hash, url] of recentUrls.value) {
|
||||||
|
if (!nextHashes.has(hash)) {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
recentUrls.value.delete(hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mint preview URLs for every entry; we keep them alive for the
|
||||||
|
// lifetime of this page mount (revoked in onUnmounted).
|
||||||
|
for (const u of all) {
|
||||||
|
if (!recentUrls.value.has(u.hash)) {
|
||||||
|
const preview = u.correctedBlob ?? u.originalBlob
|
||||||
|
recentUrls.value.set(u.hash, URL.createObjectURL(preview))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recentUploads.value = all
|
||||||
|
} catch {
|
||||||
|
recentUploads.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ms: number): string {
|
||||||
|
return dateFormatter.format(new Date(ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewUrlFor(hash: string): string {
|
||||||
|
return recentUrls.value.get(hash) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClearCacheClick() {
|
||||||
|
if (confirmingClear.value) {
|
||||||
clearCache()
|
clearCache()
|
||||||
clearMeasurementCache()
|
clearMeasurementCache()
|
||||||
|
clearZoomCache()
|
||||||
|
// IndexedDB clear is async but the UI doesn't depend on its
|
||||||
|
// completion — fire and forget, then reload the gallery.
|
||||||
|
void clearUploads().then(() => refreshRecentUploads())
|
||||||
cacheCount.value = 0
|
cacheCount.value = 0
|
||||||
|
confirmingClear.value = false
|
||||||
|
if (confirmTimer) {
|
||||||
|
clearTimeout(confirmTimer)
|
||||||
|
confirmTimer = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
confirmingClear.value = true
|
||||||
|
if (confirmTimer) clearTimeout(confirmTimer)
|
||||||
|
confirmTimer = setTimeout(() => {
|
||||||
|
confirmingClear.value = false
|
||||||
|
confirmTimer = null
|
||||||
|
}, CLEAR_CONFIRM_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteUpload(hash: string, ev: Event) {
|
||||||
|
ev.stopPropagation()
|
||||||
|
try {
|
||||||
|
await deleteUpload(hash)
|
||||||
|
await refreshRecentUploads()
|
||||||
|
} catch {
|
||||||
|
// ignore — UI will reflect on next refresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reopen a past upload straight in Measure. Replays the upload pipeline
|
||||||
|
// from the cached blob (the original image is needed if the user later
|
||||||
|
// goes back to Datums or Deskew), then stitches the cached deskew
|
||||||
|
// artefacts and datums onto the store before navigating.
|
||||||
|
async function restoreUpload(hash: string) {
|
||||||
|
error.value = ""
|
||||||
|
store.isProcessing = true
|
||||||
|
store.processingStatus = "Loading saved image..."
|
||||||
|
|
||||||
|
try {
|
||||||
|
const record = await loadUpload(hash)
|
||||||
|
if (!record) {
|
||||||
|
error.value = "Saved upload no longer available"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!record.correctedBlob || !record.diagnostics) {
|
||||||
|
error.value = "Saved upload has no deskew result"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const file = new File([record.originalBlob], record.filename, {
|
||||||
|
type: record.mimeType,
|
||||||
|
})
|
||||||
|
const { image, convertedFile } = await loadImage(file, (status) => {
|
||||||
|
store.processingStatus = status
|
||||||
|
})
|
||||||
|
|
||||||
|
const cachedDatums = loadDatums(hash) ?? []
|
||||||
|
const cachedMeasurements = loadMeasurements(hash) ?? []
|
||||||
|
|
||||||
|
store.setFileHash(hash)
|
||||||
|
store.setImage(convertedFile, image)
|
||||||
|
store.setExif(record.exif)
|
||||||
|
store.datums = cachedDatums
|
||||||
|
if (record.scalePxPerMm) {
|
||||||
|
store.scalePxPerMm = record.scalePxPerMm
|
||||||
|
}
|
||||||
|
store.setResult(
|
||||||
|
{
|
||||||
|
correctedImageBlob: record.correctedBlob,
|
||||||
|
diagnostics: record.diagnostics,
|
||||||
|
},
|
||||||
|
record.scalePxPerMm ?? store.scalePxPerMm,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cachedMeasurements.length > 0 || cachedDatums.length > 0) {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (cachedDatums.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
`${String(cachedDatums.length)} datum${cachedDatums.length === 1 ? "" : "s"}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (cachedMeasurements.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
`${String(cachedMeasurements.length)} measurement${cachedMeasurements.length === 1 ? "" : "s"}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
store.cacheRestoreMessage = `Restored ${parts.join(" and ")}`
|
||||||
|
setTimeout(() => {
|
||||||
|
store.cacheRestoreMessage = ""
|
||||||
|
}, 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bump max step so the indicator surfaces every prior step as
|
||||||
|
// navigable, just like a freshly-completed run would.
|
||||||
|
store.goToStep(5)
|
||||||
|
} catch (e) {
|
||||||
|
error.value =
|
||||||
|
e instanceof Error ? e.message : "Failed to reopen image"
|
||||||
|
} finally {
|
||||||
|
store.isProcessing = false
|
||||||
|
store.processingStatus = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
async function handleFile(file: File) {
|
||||||
@ -64,6 +241,27 @@ async function handleFile(file: File) {
|
|||||||
}, 4000)
|
}, 4000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the upload so it shows up in the recent gallery and
|
||||||
|
// can be reopened later. We save the post-conversion JPEG (not
|
||||||
|
// the original HEIC) since that's what the rest of the app
|
||||||
|
// expects to load. Existing entries are merged so we keep any
|
||||||
|
// prior deskew artefacts attached to the same hash.
|
||||||
|
const existing = await loadUpload(hash).catch(() => null)
|
||||||
|
await saveUpload({
|
||||||
|
hash,
|
||||||
|
filename: convertedFile.name,
|
||||||
|
mimeType: convertedFile.type,
|
||||||
|
uploadedAt: existing?.uploadedAt ?? Date.now(),
|
||||||
|
originalBlob: convertedFile,
|
||||||
|
exif,
|
||||||
|
correctedBlob: existing?.correctedBlob,
|
||||||
|
diagnostics: existing?.diagnostics,
|
||||||
|
scalePxPerMm: existing?.scalePxPerMm,
|
||||||
|
}).catch(() => {
|
||||||
|
// IndexedDB unavailable or quota exceeded — non-fatal,
|
||||||
|
// gallery just won't include this upload.
|
||||||
|
})
|
||||||
|
|
||||||
store.setImage(convertedFile, image)
|
store.setImage(convertedFile, image)
|
||||||
store.setExif(exif)
|
store.setExif(exif)
|
||||||
store.goToStep(2)
|
store.goToStep(2)
|
||||||
@ -92,7 +290,47 @@ function onFileSelect(e: Event) {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-[60vh] items-start justify-center pt-12">
|
<div class="flex min-h-[60vh] items-start justify-center pt-12">
|
||||||
<div class="w-full max-w-2xl space-y-6">
|
<div class="w-full max-w-2xl space-y-6">
|
||||||
<Card>
|
<Card class="relative">
|
||||||
|
<!-- Clear-cache lives top-right of the upload card so it's
|
||||||
|
visible without scrolling but doesn't compete with the
|
||||||
|
primary drop target. Two-step confirm: first click
|
||||||
|
arms it; second click within
|
||||||
|
CLEAR_CONFIRM_TIMEOUT_MS commits. -->
|
||||||
|
<Button
|
||||||
|
v-if="cacheCount > 0"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="absolute right-2 top-2 h-7 gap-1.5 text-xs"
|
||||||
|
:class="
|
||||||
|
confirmingClear
|
||||||
|
? 'text-destructive hover:text-destructive'
|
||||||
|
: 'text-muted-foreground/60 hover:text-destructive'
|
||||||
|
"
|
||||||
|
@click="handleClearCacheClick"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 6h18" />
|
||||||
|
<path
|
||||||
|
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
|
||||||
|
/>
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
{{
|
||||||
|
confirmingClear
|
||||||
|
? "Are you sure?"
|
||||||
|
: `Clear cache (${String(cacheCount)})`
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
<CardHeader class="text-center">
|
<CardHeader class="text-center">
|
||||||
<CardTitle class="text-lg">Load Source Image</CardTitle>
|
<CardTitle class="text-lg">Load Source Image</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@ -170,20 +408,54 @@ function onFileSelect(e: Event) {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div
|
|
||||||
v-if="cacheCount > 0"
|
<!-- Recent uploads — only rendered when there's something to
|
||||||
class="flex justify-end"
|
show. Each tile reopens the image directly into Measure
|
||||||
|
with its datums, measurements, scale and last canvas
|
||||||
|
zoom restored. -->
|
||||||
|
<section v-if="completedUploads.length > 0" class="space-y-3">
|
||||||
|
<p
|
||||||
|
class="text-xs font-medium uppercase tracking-wider text-muted-foreground/70"
|
||||||
>
|
>
|
||||||
<Button
|
Recent uploads
|
||||||
variant="ghost"
|
</p>
|
||||||
size="sm"
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
class="h-7 gap-1.5 text-xs text-muted-foreground/60 hover:text-destructive"
|
<button
|
||||||
@click="handleClearCache"
|
v-for="upload in completedUploads"
|
||||||
|
:key="upload.hash"
|
||||||
|
type="button"
|
||||||
|
class="group relative overflow-hidden rounded-lg border border-border bg-card text-left transition-colors hover:border-primary/60 hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
|
@click="restoreUpload(upload.hash)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="aspect-video w-full overflow-hidden bg-muted"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="previewUrlFor(upload.hash)"
|
||||||
|
:alt="upload.filename"
|
||||||
|
class="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-0.5 p-2">
|
||||||
|
<p class="truncate text-xs font-medium text-foreground">
|
||||||
|
{{ upload.filename }}
|
||||||
|
</p>
|
||||||
|
<p class="truncate font-mono text-[10px] text-muted-foreground">
|
||||||
|
{{ formatDate(upload.uploadedAt) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-1 top-1 rounded-md bg-background/80 p-1 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100 focus:opacity-100 focus:outline-none"
|
||||||
|
title="Remove from recent uploads"
|
||||||
|
aria-label="Remove from recent uploads"
|
||||||
|
@click="(e) => handleDeleteUpload(upload.hash, e)"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="12"
|
width="14"
|
||||||
height="12"
|
height="14"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@ -191,15 +463,13 @@ function onFileSelect(e: Event) {
|
|||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
>
|
>
|
||||||
<path d="M3 6h18" />
|
<path d="M18 6 6 18" />
|
||||||
<path
|
<path d="m6 6 12 12" />
|
||||||
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
|
|
||||||
/>
|
|
||||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
|
||||||
</svg>
|
</svg>
|
||||||
Clear cache ({{ cacheCount }})
|
</button>
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="space-y-2 text-left">
|
<div class="space-y-2 text-left">
|
||||||
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">Example</p>
|
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">Example</p>
|
||||||
@ -207,7 +477,7 @@ function onFileSelect(e: Event) {
|
|||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<img
|
<img
|
||||||
src="/example-before.jpg"
|
src="/example-before.jpg"
|
||||||
alt="Before: angled photograph of a Pioneer CDJ-1000MK3 top case"
|
alt="Before: angled photograph with reference paper laid flat"
|
||||||
class="w-full rounded-md border border-border object-cover"
|
class="w-full rounded-md border border-border object-cover"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-muted-foreground">Before — angled shot</p>
|
<p class="text-xs text-muted-foreground">Before — angled shot</p>
|
||||||
@ -215,12 +485,20 @@ function onFileSelect(e: Event) {
|
|||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<img
|
<img
|
||||||
src="/example-after.jpg"
|
src="/example-after.jpg"
|
||||||
alt="After: perspective-corrected front-facing view"
|
alt="After: perspective-corrected top-down view"
|
||||||
class="w-full rounded-md border border-border object-cover"
|
class="w-full rounded-md border border-border object-cover"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-muted-foreground">After — corrected perspective</p>
|
<p class="text-xs text-muted-foreground">After — corrected perspective</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<img
|
||||||
|
src="/example-measured.jpg"
|
||||||
|
alt="Measure: the corrected image with measurement annotations baked in"
|
||||||
|
class="w-full rounded-md border border-border object-cover"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">Measured — annotations baked in</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2 text-left">
|
<div class="space-y-2 text-left">
|
||||||
|
|||||||
402
src/components/MeasureViewer.vue
Normal file
402
src/components/MeasureViewer.vue
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
<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">
|
||||||
|
<!-- Padded right of the fork-me ribbon on desktop so the title
|
||||||
|
text isn't clipped underneath it. -->
|
||||||
|
<div class="md:pl-5">
|
||||||
|
<h2 class="text-xl font-semibold">Measure</h2>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Annotate the corrected image and download.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar row: Back arrow on far left, downloads in the middle,
|
||||||
|
"Start over" zone (dashed New Image) on the far right. -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="shrink-0"
|
||||||
|
aria-label="Back to Deskew"
|
||||||
|
title="Back to Deskew"
|
||||||
|
@click="store.goToStep(4)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<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 @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>
|
||||||
|
|
||||||
|
<!-- Dashed transparent styling marks this as a deliberate,
|
||||||
|
destructive-of-state action so it isn't mis-clicked
|
||||||
|
while reaching for a download. -->
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="border-dashed bg-transparent text-muted-foreground hover:bg-muted/40 hover:text-foreground"
|
||||||
|
@click="store.reset()"
|
||||||
|
>Start Over</Button
|
||||||
|
>
|
||||||
|
</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: 1, label: "Upload" },
|
||||||
{ num: 2, label: "EXIF" },
|
{ num: 2, label: "EXIF" },
|
||||||
{ num: 3, label: "Datums" },
|
{ num: 3, label: "Datums" },
|
||||||
{ num: 4, label: "Result" },
|
{ num: 4, label: "Deskew" },
|
||||||
|
{ num: 5, label: "Measure" },
|
||||||
]
|
]
|
||||||
|
|
||||||
function isReachable(num: AppStep): boolean {
|
function isReachable(num: AppStep): boolean {
|
||||||
|
|||||||
@ -1,7 +1,59 @@
|
|||||||
|
import type { Point } from "@/types"
|
||||||
import type { Measurement } from "@/types/measurements"
|
import type { Measurement } from "@/types/measurements"
|
||||||
|
|
||||||
const KEY_PREFIX = "skwik-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(
|
export function saveMeasurements(
|
||||||
hash: string,
|
hash: string,
|
||||||
measurements: Measurement[],
|
measurements: Measurement[],
|
||||||
|
|||||||
158
src/lib/upload-cache.ts
Normal file
158
src/lib/upload-cache.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import type { DeskewDiagnostics, ExifData } from "@/types"
|
||||||
|
|
||||||
|
// Past uploads + their deskew artefacts live in IndexedDB rather than
|
||||||
|
// localStorage because converted JPEGs routinely exceed localStorage's
|
||||||
|
// per-origin quota (5–10MB) within a few photos. IndexedDB stores Blobs
|
||||||
|
// natively (no base64 inflation) and tolerates GBs, which makes a
|
||||||
|
// "recent uploads" gallery practical.
|
||||||
|
|
||||||
|
const DB_NAME = "skwik-uploads"
|
||||||
|
const DB_VERSION = 1
|
||||||
|
const STORE_NAME = "uploads"
|
||||||
|
|
||||||
|
/** All persisted state for a single past upload. Filled in two phases:
|
||||||
|
* upload time captures `originalBlob` + `exif` + identity; deskew
|
||||||
|
* completion adds `correctedBlob` + `diagnostics` + `scalePxPerMm` so
|
||||||
|
* the upload can be re-opened straight into the Measure step. */
|
||||||
|
export interface UploadRecord {
|
||||||
|
hash: string
|
||||||
|
filename: string
|
||||||
|
mimeType: string
|
||||||
|
uploadedAt: number
|
||||||
|
originalBlob: Blob
|
||||||
|
exif: ExifData
|
||||||
|
correctedBlob?: Blob
|
||||||
|
diagnostics?: DeskewDiagnostics
|
||||||
|
scalePxPerMm?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBDatabase> | null = null
|
||||||
|
|
||||||
|
function openDB(): Promise<IDBDatabase> {
|
||||||
|
if (dbPromise) return dbPromise
|
||||||
|
dbPromise = new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
const db = req.result
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
const store = db.createObjectStore(STORE_NAME, {
|
||||||
|
keyPath: "hash",
|
||||||
|
})
|
||||||
|
store.createIndex("uploadedAt", "uploadedAt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.onsuccess = () => {
|
||||||
|
resolve(req.result)
|
||||||
|
}
|
||||||
|
req.onerror = () => {
|
||||||
|
dbPromise = null
|
||||||
|
reject(req.error ?? new Error("Failed to open uploads DB"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return dbPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert or replace the record for `hash`. Re-saving with new fields
|
||||||
|
* (e.g. corrected blob) overwrites the previous entry. */
|
||||||
|
export async function saveUpload(record: UploadRecord): Promise<void> {
|
||||||
|
const db = await openDB()
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, "readwrite")
|
||||||
|
const req = tx.objectStore(STORE_NAME).put(record)
|
||||||
|
req.onsuccess = () => {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
req.onerror = () => {
|
||||||
|
reject(req.error ?? new Error("Failed to save upload"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the existing record (if any), apply `patch`, and persist. Used by
|
||||||
|
* the Deskew step to attach deskew artefacts without losing the upload
|
||||||
|
* metadata captured earlier. Returns the merged record, or null if no
|
||||||
|
* record exists for `hash`. */
|
||||||
|
export async function patchUpload(
|
||||||
|
hash: string,
|
||||||
|
patch: Partial<UploadRecord>,
|
||||||
|
): Promise<UploadRecord | null> {
|
||||||
|
const existing = await loadUpload(hash)
|
||||||
|
if (!existing) return null
|
||||||
|
const merged: UploadRecord = { ...existing, ...patch, hash }
|
||||||
|
await saveUpload(merged)
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUpload(hash: string): Promise<UploadRecord | null> {
|
||||||
|
const db = await openDB()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, "readonly")
|
||||||
|
const req = tx.objectStore(STORE_NAME).get(hash)
|
||||||
|
req.onsuccess = () => {
|
||||||
|
resolve((req.result as UploadRecord | undefined) ?? null)
|
||||||
|
}
|
||||||
|
req.onerror = () => {
|
||||||
|
reject(req.error ?? new Error("Failed to load upload"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All records, newest-first. Callers filter to entries with the fields
|
||||||
|
* they care about (e.g. require `correctedBlob` for the gallery). */
|
||||||
|
export async function listUploads(): Promise<UploadRecord[]> {
|
||||||
|
const db = await openDB()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, "readonly")
|
||||||
|
const req = tx.objectStore(STORE_NAME).getAll()
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const all = (req.result as UploadRecord[] | undefined) ?? []
|
||||||
|
all.sort((a, b) => b.uploadedAt - a.uploadedAt)
|
||||||
|
resolve(all)
|
||||||
|
}
|
||||||
|
req.onerror = () => {
|
||||||
|
reject(req.error ?? new Error("Failed to list uploads"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUpload(hash: string): Promise<void> {
|
||||||
|
const db = await openDB()
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, "readwrite")
|
||||||
|
const req = tx.objectStore(STORE_NAME).delete(hash)
|
||||||
|
req.onsuccess = () => {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
req.onerror = () => {
|
||||||
|
reject(req.error ?? new Error("Failed to delete upload"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearUploads(): Promise<void> {
|
||||||
|
const db = await openDB()
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, "readwrite")
|
||||||
|
const req = tx.objectStore(STORE_NAME).clear()
|
||||||
|
req.onsuccess = () => {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
req.onerror = () => {
|
||||||
|
reject(req.error ?? new Error("Failed to clear uploads"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countUploads(): Promise<number> {
|
||||||
|
const db = await openDB()
|
||||||
|
return new Promise<number>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, "readonly")
|
||||||
|
const req = tx.objectStore(STORE_NAME).count()
|
||||||
|
req.onsuccess = () => {
|
||||||
|
resolve(req.result)
|
||||||
|
}
|
||||||
|
req.onerror = () => {
|
||||||
|
reject(req.error ?? new Error("Failed to count uploads"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
53
src/lib/zoom-cache.ts
Normal file
53
src/lib/zoom-cache.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// Per-image zoom + pan state for the corrected-image canvas. Persisted
|
||||||
|
// so that revisiting the same upload restores the exact view the user
|
||||||
|
// last left it in. Keyed by file hash, same convention as datums and
|
||||||
|
// measurements.
|
||||||
|
|
||||||
|
const KEY_PREFIX = "skwik-zoom-"
|
||||||
|
|
||||||
|
export interface ZoomState {
|
||||||
|
/** Canvas-px / image-px scale. */
|
||||||
|
viewScale: number
|
||||||
|
/** Image origin in canvas px (top-left of the image in canvas space). */
|
||||||
|
viewOffsetX: number
|
||||||
|
viewOffsetY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveZoom(hash: string, zoom: ZoomState): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY_PREFIX + hash, JSON.stringify(zoom))
|
||||||
|
} catch {
|
||||||
|
// localStorage full or unavailable — silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadZoom(hash: string): ZoomState | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY_PREFIX + hash)
|
||||||
|
if (!raw) return null
|
||||||
|
return JSON.parse(raw) as ZoomState
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearZoom(hash: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(KEY_PREFIX + hash)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,6 +23,10 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
)
|
)
|
||||||
const fileHash = ref<string | null>(null)
|
const fileHash = ref<string | null>(null)
|
||||||
const cacheRestoreMessage = ref("")
|
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 canProceedToStep2 = computed(() => loadedImage.value !== null)
|
||||||
const canProceedToStep3 = computed(() => canProceedToStep2.value)
|
const canProceedToStep3 = computed(() => canProceedToStep2.value)
|
||||||
@ -34,6 +38,9 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
return d.diameterMm > 0
|
return d.diameterMm > 0
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
const canProceedToStep5 = computed(
|
||||||
|
() => canProceedToStep4.value && deskewResult.value !== null,
|
||||||
|
)
|
||||||
|
|
||||||
function setImage(file: File, image: HTMLImageElement) {
|
function setImage(file: File, image: HTMLImageElement) {
|
||||||
originalFile.value = file
|
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
|
deskewResult.value = result
|
||||||
|
lastDeskewScale.value = scalePxPerMmUsed
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFileHash(hash: string) {
|
function setFileHash(hash: string) {
|
||||||
@ -163,6 +171,7 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
scalePxPerMm.value = DEFAULT_SCALE_PX_PER_MM
|
scalePxPerMm.value = DEFAULT_SCALE_PX_PER_MM
|
||||||
fileHash.value = null
|
fileHash.value = null
|
||||||
cacheRestoreMessage.value = ""
|
cacheRestoreMessage.value = ""
|
||||||
|
lastDeskewScale.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -179,9 +188,11 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
scalePxPerMm,
|
scalePxPerMm,
|
||||||
fileHash,
|
fileHash,
|
||||||
cacheRestoreMessage,
|
cacheRestoreMessage,
|
||||||
|
lastDeskewScale,
|
||||||
canProceedToStep2,
|
canProceedToStep2,
|
||||||
canProceedToStep3,
|
canProceedToStep3,
|
||||||
canProceedToStep4,
|
canProceedToStep4,
|
||||||
|
canProceedToStep5,
|
||||||
setImage,
|
setImage,
|
||||||
setExif,
|
setExif,
|
||||||
goToStep,
|
goToStep,
|
||||||
|
|||||||
@ -114,7 +114,7 @@ export interface DeskewResult {
|
|||||||
diagnostics: DeskewDiagnostics
|
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). */
|
/** Pixels per mm in the output image. Default 10 (= 100 px/cm). */
|
||||||
export const DEFAULT_SCALE_PX_PER_MM = 10
|
export const DEFAULT_SCALE_PX_PER_MM = 10
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user