feat(crop): rotate + crop step between deskew and measure
This commit is contained in:
parent
415058d7d8
commit
565baddfbf
@ -5,6 +5,7 @@ import ImageUpload from "@/components/ImageUpload.vue"
|
||||
import ExifViewer from "@/components/ExifViewer.vue"
|
||||
import DatumEditor from "@/components/DatumEditor.vue"
|
||||
import DeskewViewer from "@/components/DeskewViewer.vue"
|
||||
import CropViewer from "@/components/CropViewer.vue"
|
||||
import MeasureViewer from "@/components/MeasureViewer.vue"
|
||||
import ThemeToggle from "@/components/ThemeToggle.vue"
|
||||
import SkwikLogo from "@/components/SkwikLogo.vue"
|
||||
@ -76,7 +77,8 @@ const store = useAppStore()
|
||||
<ExifViewer v-else-if="store.currentStep === 2" />
|
||||
<DatumEditor v-else-if="store.currentStep === 3" />
|
||||
<DeskewViewer v-else-if="store.currentStep === 4" />
|
||||
<MeasureViewer v-else-if="store.currentStep === 5" />
|
||||
<CropViewer v-else-if="store.currentStep === 5" />
|
||||
<MeasureViewer v-else-if="store.currentStep === 6" />
|
||||
</main>
|
||||
|
||||
<!-- Footer is fixed to the bottom. On mobile we tuck the theme
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue"
|
||||
import { useMediaQuery } from "@vueuse/core"
|
||||
import { nanoid } from "nanoid"
|
||||
import type { Point } from "@/types"
|
||||
import type { ImagePreTransform, Point } from "@/types"
|
||||
import type {
|
||||
LineMeasurement,
|
||||
RectMeasurement,
|
||||
@ -16,9 +16,37 @@ import { useAppStore } from "@/stores/app"
|
||||
import { loadMeasurements, saveMeasurements } from "@/lib/measurement-cache"
|
||||
import { loadZoom, saveZoom } from "@/lib/zoom-cache"
|
||||
|
||||
// `imageTransform` is an optional pre-transform from
|
||||
// "deskewed-image space" (the original untransformed measurement
|
||||
// coordinate frame) to the bitmap space of the image actually shown
|
||||
// by `imageUrl`. Used by the Crop & Rotate step so measurements stay
|
||||
// anchored to the deskewed image while the user views a rotated +
|
||||
// cropped sub-bitmap. The mapping is:
|
||||
//
|
||||
// measurement_pt → rotate around (srcW/2, srcH/2) by rotationDeg
|
||||
// → translate by (rotW/2, rotH/2)
|
||||
// → subtract (cropX, cropY) → bitmap_pt
|
||||
//
|
||||
// When omitted (or set to identity values), behaviour is unchanged.
|
||||
//
|
||||
// The viewer does NOT rewrite stored measurement coordinates when the
|
||||
// transform changes; it only adjusts the draw-time projection. This is
|
||||
// option (b) from the task brief and means changing the crop/rotation
|
||||
// never invalidates persisted measurements.
|
||||
const props = defineProps<{
|
||||
imageUrl: string
|
||||
scalePxPerMm: number
|
||||
/** Optional pre-transform; identity when not supplied. */
|
||||
imageTransform?: ImagePreTransform
|
||||
}>()
|
||||
|
||||
// Emit when the measurement set changes (add / mutate / delete) so the
|
||||
// parent can refresh the recent-uploads gallery thumbnail. Fired
|
||||
// debounced (via the same `measurements` deep watcher that persists to
|
||||
// localStorage) so high-frequency interactions like dragging an
|
||||
// endpoint don't thrash the encoder.
|
||||
const emit = defineEmits<{
|
||||
(event: "measurements-changed"): void
|
||||
}>()
|
||||
|
||||
const isMobile = useMediaQuery("(max-width: 767px)")
|
||||
@ -356,25 +384,68 @@ function makeLiveCtx(): RenderCtx {
|
||||
}
|
||||
}
|
||||
|
||||
function imgToCtx(pt: Point, t: RenderCtx): Point {
|
||||
// Project a measurement point from deskewed-image space into the
|
||||
// shown bitmap's coordinate frame. When no `imageTransform` is set we
|
||||
// short-circuit to the identity for a tiny perf win and to keep the
|
||||
// behaviour pixel-identical for the legacy non-cropped path.
|
||||
function imgPreTransform(pt: Point): Point {
|
||||
const tr = props.imageTransform
|
||||
if (!tr) return pt
|
||||
const r = (tr.rotationDeg * Math.PI) / 180
|
||||
const cx = tr.srcW / 2
|
||||
const cy = tr.srcH / 2
|
||||
const dx = pt.x - cx
|
||||
const dy = pt.y - cy
|
||||
const cos = Math.cos(r)
|
||||
const sin = Math.sin(r)
|
||||
const rx = dx * cos - dy * sin + tr.rotW / 2
|
||||
const ry = dx * sin + dy * cos + tr.rotH / 2
|
||||
return { x: rx - tr.cropX, y: ry - tr.cropY }
|
||||
}
|
||||
|
||||
// Inverse of `imgPreTransform`: bitmap-space → deskewed-image space.
|
||||
// Used by `screenToImg` so pointer-driven placements / drags stay in
|
||||
// the canonical measurement coordinate frame.
|
||||
function imgPreTransformInverse(pt: Point): Point {
|
||||
const tr = props.imageTransform
|
||||
if (!tr) return pt
|
||||
const r = (tr.rotationDeg * Math.PI) / 180
|
||||
const cx = tr.srcW / 2
|
||||
const cy = tr.srcH / 2
|
||||
const rx = pt.x + tr.cropX
|
||||
const ry = pt.y + tr.cropY
|
||||
const dx = rx - tr.rotW / 2
|
||||
const dy = ry - tr.rotH / 2
|
||||
const cos = Math.cos(r)
|
||||
const sin = Math.sin(r)
|
||||
return {
|
||||
x: pt.x * t.scale + t.offsetX,
|
||||
y: pt.y * t.scale + t.offsetY,
|
||||
x: dx * cos + dy * sin + cx,
|
||||
y: -dx * sin + dy * cos + cy,
|
||||
}
|
||||
}
|
||||
|
||||
function imgToCtx(pt: Point, t: RenderCtx): Point {
|
||||
const b = imgPreTransform(pt)
|
||||
return {
|
||||
x: b.x * t.scale + t.offsetX,
|
||||
y: b.y * t.scale + t.offsetY,
|
||||
}
|
||||
}
|
||||
|
||||
function imgToScreen(pt: Point): Point {
|
||||
const b = imgPreTransform(pt)
|
||||
return {
|
||||
x: pt.x * viewScale.value + viewOffsetX.value,
|
||||
y: pt.y * viewScale.value + viewOffsetY.value,
|
||||
x: b.x * viewScale.value + viewOffsetX.value,
|
||||
y: b.y * viewScale.value + viewOffsetY.value,
|
||||
}
|
||||
}
|
||||
|
||||
function screenToImg(sx: number, sy: number): Point {
|
||||
return {
|
||||
const b: Point = {
|
||||
x: (sx - viewOffsetX.value) / viewScale.value,
|
||||
y: (sy - viewOffsetY.value) / viewScale.value,
|
||||
}
|
||||
return imgPreTransformInverse(b)
|
||||
}
|
||||
|
||||
// If `snapToAngle` is on, rotate `to` around `from` to the nearest 45°
|
||||
@ -2324,6 +2395,7 @@ watch(
|
||||
if (store.fileHash) {
|
||||
saveMeasurements(store.fileHash, next)
|
||||
}
|
||||
emit("measurements-changed")
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
507
src/components/CropViewer.vue
Normal file
507
src/components/CropViewer.vue
Normal file
@ -0,0 +1,507 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue"
|
||||
import { useAppStore } from "@/stores/app"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { saveCropRotate, loadCropRotate } from "@/lib/crop-cache"
|
||||
import { rotatedBboxSize } from "@/lib/crop-transform"
|
||||
import type { Point } from "@/types"
|
||||
|
||||
// Crop & Rotate step.
|
||||
// * The user picks an arbitrary rotation in degrees (-180..180).
|
||||
// * Rotation is applied first, around the deskewed image's centre.
|
||||
// The rotated bitmap's axis-aligned bounding box is the canvas the
|
||||
// crop rectangle lives in.
|
||||
// * The crop rectangle is then dragged via 8 handles (4 corners + 4
|
||||
// edge midpoints). It's stored as fractions of the rotated bbox so
|
||||
// the same crop survives a re-deskew at a different output px/mm.
|
||||
//
|
||||
// Persistence: identical pattern to measurement-cache.ts, keyed by
|
||||
// `store.fileHash`. We write to localStorage on every change.
|
||||
|
||||
const store = useAppStore()
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const overlayRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
const img = ref<HTMLImageElement | null>(null)
|
||||
const imgUrl = ref<string | null>(null)
|
||||
|
||||
// Rotation in degrees. Crop fractions of the rotated bbox.
|
||||
const rotationDeg = ref(0)
|
||||
const cropLeft = ref(0)
|
||||
const cropTop = ref(0)
|
||||
const cropRight = ref(1)
|
||||
const cropBottom = ref(1)
|
||||
|
||||
// Live-fit transform from rotated-bbox space → screen canvas pixels.
|
||||
// Recomputed on resize / rotation change so the user always sees the
|
||||
// whole bbox + a bit of breathing room.
|
||||
const fitScale = ref(1)
|
||||
const fitOffsetX = ref(0)
|
||||
const fitOffsetY = ref(0)
|
||||
|
||||
const HANDLE_HIT_PX = 14
|
||||
|
||||
const rotBbox = computed(() => {
|
||||
const i = img.value
|
||||
if (!i) return { rotW: 1, rotH: 1 }
|
||||
return rotatedBboxSize(i.naturalWidth, i.naturalHeight, rotationDeg.value)
|
||||
})
|
||||
|
||||
function persist() {
|
||||
if (!store.fileHash) return
|
||||
const state = {
|
||||
rotationDeg: rotationDeg.value,
|
||||
crop: {
|
||||
left: cropLeft.value,
|
||||
top: cropTop.value,
|
||||
right: cropRight.value,
|
||||
bottom: cropBottom.value,
|
||||
},
|
||||
}
|
||||
store.setCropRotate(state)
|
||||
saveCropRotate(store.fileHash, state)
|
||||
}
|
||||
|
||||
function loadImage(url: string) {
|
||||
const el = new Image()
|
||||
el.onload = () => {
|
||||
img.value = el
|
||||
fitToContainer()
|
||||
redraw()
|
||||
}
|
||||
el.src = url
|
||||
}
|
||||
|
||||
function fitToContainer() {
|
||||
const c = containerRef.value
|
||||
const i = img.value
|
||||
if (!c || !i) return
|
||||
const cw = c.clientWidth
|
||||
const ch = c.clientHeight
|
||||
if (canvasRef.value) {
|
||||
canvasRef.value.width = cw
|
||||
canvasRef.value.height = ch
|
||||
}
|
||||
if (overlayRef.value) {
|
||||
overlayRef.value.width = cw
|
||||
overlayRef.value.height = ch
|
||||
}
|
||||
const { rotW, rotH } = rotBbox.value
|
||||
const fit = Math.min(cw / rotW, ch / rotH) * 0.9
|
||||
fitScale.value = fit
|
||||
fitOffsetX.value = (cw - rotW * fit) / 2
|
||||
fitOffsetY.value = (ch - rotH * fit) / 2
|
||||
}
|
||||
|
||||
function rotatedToScreen(p: Point): Point {
|
||||
return {
|
||||
x: p.x * fitScale.value + fitOffsetX.value,
|
||||
y: p.y * fitScale.value + fitOffsetY.value,
|
||||
}
|
||||
}
|
||||
|
||||
function screenToRotated(sx: number, sy: number): Point {
|
||||
return {
|
||||
x: (sx - fitOffsetX.value) / fitScale.value,
|
||||
y: (sy - fitOffsetY.value) / fitScale.value,
|
||||
}
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
drawImage()
|
||||
drawOverlay()
|
||||
}
|
||||
|
||||
function drawImage() {
|
||||
const canvas = canvasRef.value
|
||||
const i = img.value
|
||||
if (!canvas || !i) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
const { rotW, rotH } = rotBbox.value
|
||||
|
||||
ctx.save()
|
||||
// Place rotated-bbox origin at fit offset, then scale.
|
||||
ctx.translate(fitOffsetX.value, fitOffsetY.value)
|
||||
ctx.scale(fitScale.value, fitScale.value)
|
||||
// Rotation around the bbox centre — cancels back to deskewed-image
|
||||
// axis-aligned drawing offset by (rotW-srcW)/2 etc.
|
||||
ctx.translate(rotW / 2, rotH / 2)
|
||||
ctx.rotate((rotationDeg.value * Math.PI) / 180)
|
||||
ctx.drawImage(i, -i.naturalWidth / 2, -i.naturalHeight / 2)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function cropRectScreen(): { x: number; y: number; w: number; h: number } {
|
||||
const { rotW, rotH } = rotBbox.value
|
||||
const tl = rotatedToScreen({ x: cropLeft.value * rotW, y: cropTop.value * rotH })
|
||||
const br = rotatedToScreen({ x: cropRight.value * rotW, y: cropBottom.value * rotH })
|
||||
return { x: tl.x, y: tl.y, w: br.x - tl.x, h: br.y - tl.y }
|
||||
}
|
||||
|
||||
function drawOverlay() {
|
||||
const canvas = overlayRef.value
|
||||
const i = img.value
|
||||
if (!canvas || !i) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const r = cropRectScreen()
|
||||
|
||||
// Dim everything outside the crop rect with a 50% black overlay.
|
||||
// Draw a full-canvas dark fill with a punched-out hole — fillRect +
|
||||
// even-odd fill is overkill; clipping with two rects is simpler.
|
||||
ctx.save()
|
||||
ctx.fillStyle = "rgba(0,0,0,0.55)"
|
||||
ctx.fillRect(0, 0, canvas.width, r.y)
|
||||
ctx.fillRect(0, r.y + r.h, canvas.width, canvas.height - (r.y + r.h))
|
||||
ctx.fillRect(0, r.y, r.x, r.h)
|
||||
ctx.fillRect(r.x + r.w, r.y, canvas.width - (r.x + r.w), r.h)
|
||||
ctx.restore()
|
||||
|
||||
// Crop rectangle outline.
|
||||
ctx.save()
|
||||
ctx.strokeStyle = "#fff"
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.setLineDash([6, 4])
|
||||
ctx.strokeRect(r.x, r.y, r.w, r.h)
|
||||
ctx.restore()
|
||||
|
||||
// Rule-of-thirds guides — light grey, no dash.
|
||||
ctx.save()
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.25)"
|
||||
ctx.lineWidth = 1
|
||||
for (let k = 1; k < 3; k++) {
|
||||
const x = r.x + (r.w * k) / 3
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, r.y)
|
||||
ctx.lineTo(x, r.y + r.h)
|
||||
ctx.stroke()
|
||||
const y = r.y + (r.h * k) / 3
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(r.x, y)
|
||||
ctx.lineTo(r.x + r.w, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.restore()
|
||||
|
||||
// Handle dots: 4 corners + 4 edge midpoints.
|
||||
const handles = handlePositionsScreen()
|
||||
ctx.save()
|
||||
ctx.fillStyle = "#fff"
|
||||
ctx.strokeStyle = "rgba(0,0,0,0.5)"
|
||||
ctx.lineWidth = 1
|
||||
for (const h of handles) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(h.pt.x, h.pt.y, 5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
type HandleKey =
|
||||
| "tl" | "tr" | "br" | "bl"
|
||||
| "t" | "r" | "b" | "l"
|
||||
|
||||
function handlePositionsScreen(): { key: HandleKey; pt: Point }[] {
|
||||
const r = cropRectScreen()
|
||||
const cx = r.x + r.w / 2
|
||||
const cy = r.y + r.h / 2
|
||||
return [
|
||||
{ key: "tl", pt: { x: r.x, y: r.y } },
|
||||
{ key: "tr", pt: { x: r.x + r.w, y: r.y } },
|
||||
{ key: "br", pt: { x: r.x + r.w, y: r.y + r.h } },
|
||||
{ key: "bl", pt: { x: r.x, y: r.y + r.h } },
|
||||
{ key: "t", pt: { x: cx, y: r.y } },
|
||||
{ key: "r", pt: { x: r.x + r.w, y: cy } },
|
||||
{ key: "b", pt: { x: cx, y: r.y + r.h } },
|
||||
{ key: "l", pt: { x: r.x, y: cy } },
|
||||
]
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
handle: HandleKey | "body"
|
||||
/** Crop in fractions at drag start, used as the base for delta updates. */
|
||||
startLeft: number
|
||||
startTop: number
|
||||
startRight: number
|
||||
startBottom: number
|
||||
/** Pointer position in rotated-bbox space at drag start. */
|
||||
startRot: Point
|
||||
}
|
||||
let drag: DragState | null = null
|
||||
|
||||
function pickHandle(sx: number, sy: number): HandleKey | null {
|
||||
for (const h of handlePositionsScreen()) {
|
||||
if (Math.hypot(sx - h.pt.x, sy - h.pt.y) <= HANDLE_HIT_PX) return h.key
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function onPointerDown(ev: PointerEvent) {
|
||||
const canvas = overlayRef.value
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const sx = ev.clientX - rect.left
|
||||
const sy = ev.clientY - rect.top
|
||||
const handle = pickHandle(sx, sy)
|
||||
const r = cropRectScreen()
|
||||
const insideBody =
|
||||
handle === null &&
|
||||
sx >= r.x && sx <= r.x + r.w &&
|
||||
sy >= r.y && sy <= r.y + r.h
|
||||
if (!handle && !insideBody) return
|
||||
canvas.setPointerCapture(ev.pointerId)
|
||||
drag = {
|
||||
handle: handle ?? "body",
|
||||
startLeft: cropLeft.value,
|
||||
startTop: cropTop.value,
|
||||
startRight: cropRight.value,
|
||||
startBottom: cropBottom.value,
|
||||
startRot: screenToRotated(sx, sy),
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMove(ev: PointerEvent) {
|
||||
if (!drag) return
|
||||
const canvas = overlayRef.value
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const sx = ev.clientX - rect.left
|
||||
const sy = ev.clientY - rect.top
|
||||
const cur = screenToRotated(sx, sy)
|
||||
const { rotW, rotH } = rotBbox.value
|
||||
const dx = (cur.x - drag.startRot.x) / rotW
|
||||
const dy = (cur.y - drag.startRot.y) / rotH
|
||||
|
||||
const minSize = 0.05 // 5% minimum width/height to keep the crop usable.
|
||||
let l = drag.startLeft
|
||||
let t = drag.startTop
|
||||
let r2 = drag.startRight
|
||||
let b = drag.startBottom
|
||||
|
||||
const k = drag.handle
|
||||
if (k === "body") {
|
||||
const w = drag.startRight - drag.startLeft
|
||||
const h = drag.startBottom - drag.startTop
|
||||
let nl = drag.startLeft + dx
|
||||
let nt = drag.startTop + dy
|
||||
nl = Math.min(Math.max(nl, 0), 1 - w)
|
||||
nt = Math.min(Math.max(nt, 0), 1 - h)
|
||||
l = nl
|
||||
t = nt
|
||||
r2 = nl + w
|
||||
b = nt + h
|
||||
} else {
|
||||
if (k === "tl" || k === "l" || k === "bl") {
|
||||
l = Math.min(Math.max(drag.startLeft + dx, 0), drag.startRight - minSize)
|
||||
}
|
||||
if (k === "tr" || k === "r" || k === "br") {
|
||||
r2 = Math.min(Math.max(drag.startRight + dx, drag.startLeft + minSize), 1)
|
||||
}
|
||||
if (k === "tl" || k === "t" || k === "tr") {
|
||||
t = Math.min(Math.max(drag.startTop + dy, 0), drag.startBottom - minSize)
|
||||
}
|
||||
if (k === "bl" || k === "b" || k === "br") {
|
||||
b = Math.min(Math.max(drag.startBottom + dy, drag.startTop + minSize), 1)
|
||||
}
|
||||
}
|
||||
|
||||
cropLeft.value = l
|
||||
cropTop.value = t
|
||||
cropRight.value = r2
|
||||
cropBottom.value = b
|
||||
drawOverlay()
|
||||
}
|
||||
|
||||
function onPointerUp(ev: PointerEvent) {
|
||||
const canvas = overlayRef.value
|
||||
if (canvas?.hasPointerCapture(ev.pointerId)) {
|
||||
canvas.releasePointerCapture(ev.pointerId)
|
||||
}
|
||||
if (drag) {
|
||||
drag = null
|
||||
persist()
|
||||
}
|
||||
}
|
||||
|
||||
function resetCrop() {
|
||||
cropLeft.value = 0
|
||||
cropTop.value = 0
|
||||
cropRight.value = 1
|
||||
cropBottom.value = 1
|
||||
rotationDeg.value = 0
|
||||
persist()
|
||||
fitToContainer()
|
||||
redraw()
|
||||
}
|
||||
|
||||
function onRotationInput(v: number) {
|
||||
if (!Number.isFinite(v)) return
|
||||
// Clamp to [-180, 180] and normalise so the slider/spinbox can't escape.
|
||||
const clamped = Math.min(Math.max(v, -180), 180)
|
||||
rotationDeg.value = clamped
|
||||
fitToContainer()
|
||||
redraw()
|
||||
persist()
|
||||
}
|
||||
|
||||
function rotateBy(delta: number) {
|
||||
onRotationInput(rotationDeg.value + delta)
|
||||
}
|
||||
|
||||
let resizeObs: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!store.deskewResult) {
|
||||
store.goToStep(4)
|
||||
return
|
||||
}
|
||||
// Pre-fill from the store / cache so the user's previous crop &
|
||||
// rotation come back when they re-enter the step.
|
||||
const cached = store.fileHash ? loadCropRotate(store.fileHash) : null
|
||||
const seed = cached ?? store.cropRotate
|
||||
rotationDeg.value = seed.rotationDeg
|
||||
cropLeft.value = seed.crop.left
|
||||
cropTop.value = seed.crop.top
|
||||
cropRight.value = seed.crop.right
|
||||
cropBottom.value = seed.crop.bottom
|
||||
if (cached) store.setCropRotate(cached)
|
||||
|
||||
imgUrl.value = URL.createObjectURL(store.deskewResult.correctedImageBlob)
|
||||
loadImage(imgUrl.value)
|
||||
|
||||
if (containerRef.value) {
|
||||
resizeObs = new ResizeObserver(() => {
|
||||
fitToContainer()
|
||||
redraw()
|
||||
})
|
||||
resizeObs.observe(containerRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObs?.disconnect()
|
||||
if (imgUrl.value) URL.revokeObjectURL(imgUrl.value)
|
||||
})
|
||||
|
||||
watch(rotationDeg, () => {
|
||||
fitToContainer()
|
||||
redraw()
|
||||
})
|
||||
|
||||
watch([cropLeft, cropTop, cropRight, cropBottom], () => {
|
||||
drawOverlay()
|
||||
})
|
||||
|
||||
function next() {
|
||||
persist()
|
||||
store.goToStep(6)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Crop & Rotate</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Optionally rotate and crop the deskewed image before
|
||||
measuring.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button variant="outline" @click="store.goToStep(4)">Back</Button>
|
||||
<Button variant="outline" @click="resetCrop">Reset</Button>
|
||||
<Button @click="next">Next: Measure</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="space-y-2">
|
||||
<CardTitle class="text-base">Rotation</CardTitle>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min="-180"
|
||||
max="180"
|
||||
step="0.1"
|
||||
:value="rotationDeg"
|
||||
class="h-2 w-full max-w-md flex-1 accent-primary"
|
||||
@input="(e) => onRotationInput(Number((e.target as HTMLInputElement).value))"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 px-2"
|
||||
@click="rotateBy(-90)"
|
||||
>-90°</Button
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 px-2"
|
||||
@click="rotateBy(-1)"
|
||||
>-1°</Button
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min="-180"
|
||||
max="180"
|
||||
step="0.1"
|
||||
:value="rotationDeg.toFixed(1)"
|
||||
class="h-8 w-20 rounded-md border border-input bg-background px-2 text-sm"
|
||||
@input="(e) => onRotationInput(Number((e.target as HTMLInputElement).value))"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">°</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 px-2"
|
||||
@click="rotateBy(1)"
|
||||
>+1°</Button
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 px-2"
|
||||
@click="rotateBy(90)"
|
||||
>+90°</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative h-[calc(100vh-22rem)] min-h-[320px] w-full overflow-hidden rounded-md bg-muted"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="absolute inset-0"
|
||||
/>
|
||||
<canvas
|
||||
ref="overlayRef"
|
||||
class="absolute inset-0 touch-none"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@ -301,7 +301,7 @@ async function runDeskew() {
|
||||
<Button
|
||||
:disabled="!store.canProceedToStep5"
|
||||
@click="store.goToStep(5)"
|
||||
>Next: Measure</Button
|
||||
>Next: Crop</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@ -738,8 +738,8 @@ async function runDeskew() {
|
||||
>Deskewed Preview</CardTitle
|
||||
>
|
||||
<CardDescription>
|
||||
Continue to <strong>Measure</strong> to add
|
||||
annotations and download.
|
||||
Continue to <strong>Crop</strong> to rotate and
|
||||
crop the result before measuring.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@ -757,7 +757,7 @@ async function runDeskew() {
|
||||
|
||||
<div class="flex justify-center pb-8">
|
||||
<Button size="lg" @click="store.goToStep(5)">
|
||||
Continue to Measure
|
||||
Continue to Crop
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
|
||||
@ -9,6 +9,10 @@ import {
|
||||
clearCache as clearMeasurementCache,
|
||||
loadMeasurements,
|
||||
} from "@/lib/measurement-cache"
|
||||
import {
|
||||
clearCache as clearCropCache,
|
||||
loadCropRotate,
|
||||
} from "@/lib/crop-cache"
|
||||
import {
|
||||
saveUpload,
|
||||
loadUpload,
|
||||
@ -82,11 +86,26 @@ async function refreshRecentUploads() {
|
||||
}
|
||||
}
|
||||
// Mint preview URLs for every entry; we keep them alive for the
|
||||
// lifetime of this page mount (revoked in onUnmounted).
|
||||
// lifetime of this page mount (revoked in onUnmounted). Prefer
|
||||
// the annotated thumbnail (rotation + crop + measurements baked
|
||||
// in) generated by the Measure step; fall back to the deskew
|
||||
// result, then the raw original. Existing URL is replaced when
|
||||
// the preview blob has changed since the last refresh.
|
||||
const prevById = new Map<string, Blob>()
|
||||
for (const u of recentUploads.value) {
|
||||
const prev = u.previewBlob ?? u.correctedBlob ?? u.originalBlob
|
||||
prevById.set(u.hash, prev)
|
||||
}
|
||||
for (const u of all) {
|
||||
const next = u.previewBlob ?? u.correctedBlob ?? u.originalBlob
|
||||
const prev = prevById.get(u.hash)
|
||||
const existing = recentUrls.value.get(u.hash)
|
||||
if (existing && prev !== next) {
|
||||
URL.revokeObjectURL(existing)
|
||||
recentUrls.value.delete(u.hash)
|
||||
}
|
||||
if (!recentUrls.value.has(u.hash)) {
|
||||
const preview = u.correctedBlob ?? u.originalBlob
|
||||
recentUrls.value.set(u.hash, URL.createObjectURL(preview))
|
||||
recentUrls.value.set(u.hash, URL.createObjectURL(next))
|
||||
}
|
||||
}
|
||||
recentUploads.value = all
|
||||
@ -108,6 +127,7 @@ function handleClearCacheClick() {
|
||||
clearCache()
|
||||
clearMeasurementCache()
|
||||
clearZoomCache()
|
||||
clearCropCache()
|
||||
// IndexedDB clear is async but the UI doesn't depend on its
|
||||
// completion — fire and forget, then reload the gallery.
|
||||
void clearUploads().then(() => refreshRecentUploads())
|
||||
@ -165,6 +185,9 @@ async function restoreUpload(hash: string) {
|
||||
|
||||
const cachedDatums = loadDatums(hash) ?? []
|
||||
const cachedMeasurements = loadMeasurements(hash) ?? []
|
||||
const cachedCropRotate = loadCropRotate(hash)
|
||||
if (cachedCropRotate) store.setCropRotate(cachedCropRotate)
|
||||
else store.resetCropRotate()
|
||||
|
||||
store.setFileHash(hash)
|
||||
store.setImage(convertedFile, image)
|
||||
@ -200,8 +223,10 @@ async function restoreUpload(hash: string) {
|
||||
}
|
||||
|
||||
// Bump max step so the indicator surfaces every prior step as
|
||||
// navigable, just like a freshly-completed run would.
|
||||
store.goToStep(5)
|
||||
// navigable, just like a freshly-completed run would. Crop
|
||||
// (step 5) is reachable from the indicator if the user wants to
|
||||
// revisit it.
|
||||
store.goToStep(6)
|
||||
} catch (e) {
|
||||
error.value =
|
||||
e instanceof Error ? e.message : "Failed to reopen image"
|
||||
@ -231,6 +256,12 @@ async function handleFile(file: File) {
|
||||
const hash = await hashFile(file)
|
||||
store.setFileHash(hash)
|
||||
|
||||
// Hydrate any saved rotation/crop for this file so re-uploading
|
||||
// the same image keeps the user's prior framing.
|
||||
const cachedCropRotate = loadCropRotate(hash)
|
||||
if (cachedCropRotate) store.setCropRotate(cachedCropRotate)
|
||||
else store.resetCropRotate()
|
||||
|
||||
const cached = loadDatums(hash)
|
||||
if (cached && cached.length > 0) {
|
||||
store.datums = cached
|
||||
@ -257,6 +288,7 @@ async function handleFile(file: File) {
|
||||
correctedBlob: existing?.correctedBlob,
|
||||
diagnostics: existing?.diagnostics,
|
||||
scalePxPerMm: existing?.scalePxPerMm,
|
||||
previewBlob: existing?.previewBlob,
|
||||
}).catch(() => {
|
||||
// IndexedDB unavailable or quota exceeded — non-fatal,
|
||||
// gallery just won't include this upload.
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import CorrectedImageViewer from "@/components/CorrectedImageViewer.vue"
|
||||
import type { ImagePreTransform } from "@/types"
|
||||
// `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.
|
||||
@ -25,13 +26,144 @@ type CorrectedImageViewerRef = InstanceType<typeof CorrectedImageViewer> & {
|
||||
}) => Promise<Blob>
|
||||
}
|
||||
import { loadSettings, saveSettings } from "@/lib/settings-cache"
|
||||
import {
|
||||
rotatedBboxSize,
|
||||
cropPixels,
|
||||
renderRotatedCropped,
|
||||
} from "@/lib/crop-transform"
|
||||
import { patchUpload } from "@/lib/upload-cache"
|
||||
|
||||
const store = useAppStore()
|
||||
const resultUrl = ref<string | null>(null)
|
||||
const imageTransform = ref<ImagePreTransform | null>(null)
|
||||
const viewerRef = ref<CorrectedImageViewerRef | null>(null)
|
||||
const error = ref("")
|
||||
const includeScaleBar = ref(false)
|
||||
|
||||
// Debounce timer for the recent-uploads thumbnail. Measurement
|
||||
// dragging fires the watcher dozens of times a second; we only want
|
||||
// one regen per ~500 ms idle period.
|
||||
let previewTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const PREVIEW_DEBOUNCE_MS = 500
|
||||
const PREVIEW_MAX_WIDTH = 400
|
||||
|
||||
function downscaleToPreview(srcBlob: Blob): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(srcBlob)
|
||||
const el = new Image()
|
||||
el.onload = () => {
|
||||
try {
|
||||
const w = el.naturalWidth
|
||||
const h = el.naturalHeight
|
||||
if (w <= 0 || h <= 0) {
|
||||
reject(new Error("Empty preview source"))
|
||||
return
|
||||
}
|
||||
const ratio = Math.min(1, PREVIEW_MAX_WIDTH / w)
|
||||
const tw = Math.max(1, Math.round(w * ratio))
|
||||
const th = Math.max(1, Math.round(h * ratio))
|
||||
const c = document.createElement("canvas")
|
||||
c.width = tw
|
||||
c.height = th
|
||||
const ctx = c.getContext("2d")
|
||||
if (!ctx) {
|
||||
reject(new Error("No 2D context"))
|
||||
return
|
||||
}
|
||||
ctx.drawImage(el, 0, 0, tw, th)
|
||||
c.toBlob((b) => {
|
||||
if (b) resolve(b)
|
||||
else reject(new Error("preview toBlob failed"))
|
||||
}, "image/png")
|
||||
} finally {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
el.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error("preview load failed"))
|
||||
}
|
||||
el.src = url
|
||||
})
|
||||
}
|
||||
|
||||
async function regeneratePreview() {
|
||||
const viewer = viewerRef.value
|
||||
const hash = store.fileHash
|
||||
if (!viewer || !hash) return
|
||||
try {
|
||||
const annotated = await viewer.exportWithMeasurements({
|
||||
scope: "full",
|
||||
includeScaleBar: false,
|
||||
})
|
||||
const preview = await downscaleToPreview(annotated)
|
||||
await patchUpload(hash, { previewBlob: preview })
|
||||
} catch {
|
||||
// Preview is a nice-to-have — never block the user on a failure.
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePreview() {
|
||||
if (previewTimer) clearTimeout(previewTimer)
|
||||
previewTimer = setTimeout(() => {
|
||||
previewTimer = null
|
||||
void regeneratePreview()
|
||||
}, PREVIEW_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
// Render the rotation + crop applied to the deskew result and surface
|
||||
// it as an object URL + transform for CorrectedImageViewer. We keep
|
||||
// the stored measurements in pre-rotate, pre-crop deskewed-image
|
||||
// space, and pass a pre-transform so the viewer can draw them on the
|
||||
// cropped bitmap correctly. Recomputed on entry to Measure.
|
||||
async function buildTransformedSource() {
|
||||
const result = store.deskewResult
|
||||
if (!result) return
|
||||
const url = URL.createObjectURL(result.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 deskewed image"))
|
||||
}
|
||||
el.src = url
|
||||
},
|
||||
)
|
||||
const state = store.cropRotate
|
||||
const rot = rotatedBboxSize(
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
state.rotationDeg,
|
||||
)
|
||||
const px = cropPixels(state, rot)
|
||||
const out = renderRotatedCropped(image, state)
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
out.toBlob((b) => {
|
||||
if (b) resolve(b)
|
||||
else reject(new Error("Crop render failed"))
|
||||
}, "image/png")
|
||||
})
|
||||
if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
|
||||
resultUrl.value = URL.createObjectURL(blob)
|
||||
imageTransform.value = {
|
||||
rotationDeg: state.rotationDeg,
|
||||
srcW: image.naturalWidth,
|
||||
srcH: image.naturalHeight,
|
||||
rotW: rot.rotW,
|
||||
rotH: rot.rotH,
|
||||
cropX: px.cropX,
|
||||
cropY: px.cropY,
|
||||
}
|
||||
schedulePreview()
|
||||
} finally {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const cached = loadSettings()
|
||||
if (cached) {
|
||||
@ -39,9 +171,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
if (store.deskewResult) {
|
||||
resultUrl.value = URL.createObjectURL(
|
||||
store.deskewResult.correctedImageBlob,
|
||||
)
|
||||
void buildTransformedSource()
|
||||
} else {
|
||||
// No result yet — bounce back to Deskew. Should never happen via
|
||||
// normal navigation since the Next button is gated, but if a user
|
||||
@ -53,6 +183,10 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
|
||||
if (previewTimer) {
|
||||
clearTimeout(previewTimer)
|
||||
previewTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(includeScaleBar, () => {
|
||||
@ -138,14 +272,16 @@ function addScaleBar(image: HTMLImageElement): Promise<Blob> {
|
||||
}
|
||||
|
||||
async function download() {
|
||||
if (!store.deskewResult) return
|
||||
if (!store.deskewResult || !resultUrl.value) return
|
||||
|
||||
let blob: Blob = store.deskewResult.correctedImageBlob
|
||||
// Download the post-crop, post-rotation bitmap so the file matches
|
||||
// what the user has been looking at in the Measure step. When the
|
||||
// user kept the defaults (no rotation, full-image crop) this is
|
||||
// bit-identical to the original deskew blob (modulo PNG re-encode).
|
||||
let blob: Blob = await fetch(resultUrl.value).then((r) => r.blob())
|
||||
|
||||
if (includeScaleBar.value) {
|
||||
const imgUrl = URL.createObjectURL(
|
||||
store.deskewResult.correctedImageBlob,
|
||||
)
|
||||
const imgUrl = URL.createObjectURL(blob)
|
||||
try {
|
||||
const image = await new Promise<HTMLImageElement>(
|
||||
(resolve, reject) => {
|
||||
@ -227,9 +363,9 @@ async function downloadMeasured(scope: "full" | "view") {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="shrink-0"
|
||||
aria-label="Back to Deskew"
|
||||
title="Back to Deskew"
|
||||
@click="store.goToStep(4)"
|
||||
aria-label="Back to Crop"
|
||||
title="Back to Crop"
|
||||
@click="store.goToStep(5)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@ -395,6 +531,8 @@ async function downloadMeasured(scope: "full" | "view") {
|
||||
ref="viewerRef"
|
||||
:image-url="resultUrl"
|
||||
:scale-px-per-mm="store.scalePxPerMm"
|
||||
:image-transform="imageTransform ?? undefined"
|
||||
@measurements-changed="schedulePreview"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -10,7 +10,8 @@ const steps: { num: AppStep; label: string }[] = [
|
||||
{ num: 2, label: "EXIF" },
|
||||
{ num: 3, label: "Datums" },
|
||||
{ num: 4, label: "Deskew" },
|
||||
{ num: 5, label: "Measure" },
|
||||
{ num: 5, label: "Crop" },
|
||||
{ num: 6, label: "Measure" },
|
||||
]
|
||||
|
||||
function isReachable(num: AppStep): boolean {
|
||||
|
||||
49
src/lib/crop-cache.ts
Normal file
49
src/lib/crop-cache.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import type { CropRotateState } from "@/types"
|
||||
|
||||
// Per-file-hash storage of the user's rotation + crop choices for the
|
||||
// Crop & Rotate step. Mirrors the shape of `measurement-cache.ts`. We
|
||||
// store fractional crop bounds (0..1) of the rotated image so the same
|
||||
// crop applies cleanly after a re-deskew at a different output px/mm.
|
||||
|
||||
const KEY_PREFIX = "skwik-crop-"
|
||||
|
||||
export function saveCropRotate(hash: string, state: CropRotateState): void {
|
||||
try {
|
||||
localStorage.setItem(KEY_PREFIX + hash, JSON.stringify(state))
|
||||
} catch {
|
||||
// localStorage full or unavailable — silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function loadCropRotate(hash: string): CropRotateState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY_PREFIX + hash)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as Partial<CropRotateState> | null
|
||||
if (!parsed || typeof parsed !== "object") return null
|
||||
const rot = parsed.rotationDeg
|
||||
const c = parsed.crop
|
||||
if (
|
||||
typeof rot !== "number" ||
|
||||
!c ||
|
||||
typeof c.left !== "number" ||
|
||||
typeof c.top !== "number" ||
|
||||
typeof c.right !== "number" ||
|
||||
typeof c.bottom !== "number"
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return { rotationDeg: rot, crop: { left: c.left, top: c.top, right: c.right, bottom: c.bottom } }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCache(): void {
|
||||
const toRemove: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key?.startsWith(KEY_PREFIX)) toRemove.push(key)
|
||||
}
|
||||
for (const key of toRemove) localStorage.removeItem(key)
|
||||
}
|
||||
90
src/lib/crop-transform.ts
Normal file
90
src/lib/crop-transform.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import type { CropRotateState } from "@/types"
|
||||
|
||||
// Geometry helpers for the Crop & Rotate step. The pipeline:
|
||||
// 1. Start in "deskewed image space" (the bitmap produced by the
|
||||
// solver, dimensions deskewW × deskewH).
|
||||
// 2. Rotate around the deskewed image's centre by `rotationDeg`.
|
||||
// The rotated bitmap is the axis-aligned bounding box of the
|
||||
// rotated rectangle (rotW × rotH); the original corners get
|
||||
// translated so the bbox starts at (0, 0).
|
||||
// 3. Crop a sub-rectangle of that rotated bitmap, defined by
|
||||
// fractions `crop.left/top/right/bottom` of its size.
|
||||
// Measurements live in deskewed-image space; the renderer composes this
|
||||
// transform on top of the live pan/zoom to project them onto the cropped
|
||||
// bitmap, which is what the user is actually looking at.
|
||||
|
||||
interface RotatedSize {
|
||||
rotW: number
|
||||
rotH: number
|
||||
}
|
||||
|
||||
/** Bounding box dimensions of `srcW × srcH` rotated by `rotationDeg`
|
||||
* around its centre. */
|
||||
export function rotatedBboxSize(
|
||||
srcW: number,
|
||||
srcH: number,
|
||||
rotationDeg: number,
|
||||
): RotatedSize {
|
||||
const r = (rotationDeg * Math.PI) / 180
|
||||
const c = Math.abs(Math.cos(r))
|
||||
const s = Math.abs(Math.sin(r))
|
||||
return {
|
||||
rotW: srcW * c + srcH * s,
|
||||
rotH: srcW * s + srcH * c,
|
||||
}
|
||||
}
|
||||
|
||||
interface CropPixels {
|
||||
cropX: number
|
||||
cropY: number
|
||||
cropW: number
|
||||
cropH: number
|
||||
}
|
||||
|
||||
/** Pixel-space crop rect on the rotated bitmap, derived from the
|
||||
* fractional crop. Clamped to the rotated bbox so a stale fraction
|
||||
* never produces a negative-size crop. */
|
||||
export function cropPixels(
|
||||
state: CropRotateState,
|
||||
rot: RotatedSize,
|
||||
): CropPixels {
|
||||
const left = Math.min(Math.max(state.crop.left, 0), 1)
|
||||
const top = Math.min(Math.max(state.crop.top, 0), 1)
|
||||
const right = Math.min(Math.max(state.crop.right, left), 1)
|
||||
const bottom = Math.min(Math.max(state.crop.bottom, top), 1)
|
||||
return {
|
||||
cropX: left * rot.rotW,
|
||||
cropY: top * rot.rotH,
|
||||
cropW: Math.max(1, (right - left) * rot.rotW),
|
||||
cropH: Math.max(1, (bottom - top) * rot.rotH),
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the deskewed bitmap onto a fresh canvas with rotation and
|
||||
* fractional crop applied. Output canvas dims match the cropped sub-
|
||||
* rectangle in pixels. Used to feed Measure/preview/exports. */
|
||||
export function renderRotatedCropped(
|
||||
image: HTMLImageElement | HTMLCanvasElement,
|
||||
state: CropRotateState,
|
||||
): HTMLCanvasElement {
|
||||
const srcW = "naturalWidth" in image ? image.naturalWidth : image.width
|
||||
const srcH = "naturalHeight" in image ? image.naturalHeight : image.height
|
||||
const rot = rotatedBboxSize(srcW, srcH, state.rotationDeg)
|
||||
const px = cropPixels(state, rot)
|
||||
|
||||
const out = document.createElement("canvas")
|
||||
out.width = Math.round(px.cropW)
|
||||
out.height = Math.round(px.cropH)
|
||||
const ctx = out.getContext("2d")
|
||||
if (!ctx) return out
|
||||
|
||||
// Draw with the deskewed-bitmap-centre→rotated-bbox-centre transform,
|
||||
// then translate so the crop's top-left corner becomes (0, 0).
|
||||
ctx.save()
|
||||
ctx.translate(-px.cropX, -px.cropY)
|
||||
ctx.translate(rot.rotW / 2, rot.rotH / 2)
|
||||
ctx.rotate((state.rotationDeg * Math.PI) / 180)
|
||||
ctx.drawImage(image, -srcW / 2, -srcH / 2)
|
||||
ctx.restore()
|
||||
return out
|
||||
}
|
||||
@ -24,6 +24,14 @@ export interface UploadRecord {
|
||||
correctedBlob?: Blob
|
||||
diagnostics?: DeskewDiagnostics
|
||||
scalePxPerMm?: number
|
||||
/** Latest annotated thumbnail (cropped, rotated, with measurements
|
||||
* baked in) generated by the Measure step. Used by the recent-
|
||||
* uploads gallery so each tile reflects the work the user
|
||||
* actually did on it, not just the raw deskew result. Optional —
|
||||
* the gallery falls back to `correctedBlob`/`originalBlob` when
|
||||
* unset (e.g. a freshly-uploaded image that hasn't reached
|
||||
* Measure yet). */
|
||||
previewBlob?: Blob
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { ref, computed } from "vue"
|
||||
import type { AppStep, Datum, DeskewResult, ExifData, Point } from "@/types"
|
||||
import { DEFAULT_SCALE_PX_PER_MM } from "@/types"
|
||||
import type {
|
||||
AppStep,
|
||||
CropRotateState,
|
||||
Datum,
|
||||
DeskewResult,
|
||||
ExifData,
|
||||
Point,
|
||||
} from "@/types"
|
||||
import { DEFAULT_SCALE_PX_PER_MM, IDENTITY_CROP_ROTATE } from "@/types"
|
||||
import { loadSettings } from "@/lib/settings-cache"
|
||||
import { fitEllipse } from "@/lib/ellipse-fit"
|
||||
|
||||
@ -27,6 +34,10 @@ export const useAppStore = defineStore("app", () => {
|
||||
* produces a new result; consumers compare against the live
|
||||
* `scalePxPerMm` to detect when measurements need to be rescaled. */
|
||||
const lastDeskewScale = ref<number | null>(null)
|
||||
/** Rotation (deg) + fractional crop applied on top of the deskew
|
||||
* result. Identity = no rotation, full image. Persisted per-hash via
|
||||
* `src/lib/crop-cache.ts` so it survives reloads and re-deskews. */
|
||||
const cropRotate = ref<CropRotateState>({ ...IDENTITY_CROP_ROTATE })
|
||||
|
||||
const canProceedToStep2 = computed(() => loadedImage.value !== null)
|
||||
const canProceedToStep3 = computed(() => canProceedToStep2.value)
|
||||
@ -48,6 +59,7 @@ export const useAppStore = defineStore("app", () => {
|
||||
const canProceedToStep5 = computed(
|
||||
() => canProceedToStep4.value && deskewResult.value !== null,
|
||||
)
|
||||
const canProceedToStep6 = computed(() => canProceedToStep5.value)
|
||||
|
||||
function setImage(file: File, image: HTMLImageElement) {
|
||||
originalFile.value = file
|
||||
@ -178,6 +190,14 @@ export const useAppStore = defineStore("app", () => {
|
||||
fileHash.value = hash
|
||||
}
|
||||
|
||||
function setCropRotate(state: CropRotateState) {
|
||||
cropRotate.value = state
|
||||
}
|
||||
|
||||
function resetCropRotate() {
|
||||
cropRotate.value = { ...IDENTITY_CROP_ROTATE }
|
||||
}
|
||||
|
||||
function reset() {
|
||||
currentStep.value = 1
|
||||
maxStepReached.value = 1
|
||||
@ -193,6 +213,7 @@ export const useAppStore = defineStore("app", () => {
|
||||
fileHash.value = null
|
||||
cacheRestoreMessage.value = ""
|
||||
lastDeskewScale.value = null
|
||||
cropRotate.value = { ...IDENTITY_CROP_ROTATE }
|
||||
}
|
||||
|
||||
return {
|
||||
@ -214,6 +235,10 @@ export const useAppStore = defineStore("app", () => {
|
||||
canProceedToStep3,
|
||||
canProceedToStep4,
|
||||
canProceedToStep5,
|
||||
canProceedToStep6,
|
||||
cropRotate,
|
||||
setCropRotate,
|
||||
resetCropRotate,
|
||||
setImage,
|
||||
setExif,
|
||||
goToStep,
|
||||
|
||||
@ -118,7 +118,45 @@ export interface DeskewResult {
|
||||
diagnostics: DeskewDiagnostics
|
||||
}
|
||||
|
||||
export type AppStep = 1 | 2 | 3 | 4 | 5
|
||||
export type AppStep = 1 | 2 | 3 | 4 | 5 | 6
|
||||
|
||||
/** Crop rectangle stored as fractions (0..1) of the rotated deskewed
|
||||
* image. Persisting fractions (rather than absolute pixels) means the
|
||||
* same crop survives a re-deskew at a different output px/mm. */
|
||||
export interface CropRectFractions {
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
}
|
||||
|
||||
/** Per-hash post-deskew transform: rotate around the deskewed image's
|
||||
* centre by `rotationDeg`, then crop the bounding box defined by
|
||||
* fractional `crop` values of the rotated image. Default is identity:
|
||||
* rotation 0, crop covering the full image. */
|
||||
export interface CropRotateState {
|
||||
rotationDeg: number
|
||||
crop: CropRectFractions
|
||||
}
|
||||
|
||||
export const IDENTITY_CROP_ROTATE: CropRotateState = {
|
||||
rotationDeg: 0,
|
||||
crop: { left: 0, top: 0, right: 1, bottom: 1 },
|
||||
}
|
||||
|
||||
/** Pre-transform consumed by `CorrectedImageViewer`: maps measurement
|
||||
* points from the original deskewed-image space onto the bitmap that
|
||||
* is actually painted (which may be a rotated + cropped derivative).
|
||||
* See `CorrectedImageViewer.vue` for the formula. */
|
||||
export interface ImagePreTransform {
|
||||
rotationDeg: number
|
||||
srcW: number
|
||||
srcH: number
|
||||
rotW: number
|
||||
rotH: number
|
||||
cropX: number
|
||||
cropY: number
|
||||
}
|
||||
|
||||
/** 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