diff --git a/src/components/CorrectedImageViewer.vue b/src/components/CorrectedImageViewer.vue index cbca346..74503b2 100644 --- a/src/components/CorrectedImageViewer.vue +++ b/src/components/CorrectedImageViewer.vue @@ -2,7 +2,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from "vue" import { useMediaQuery } from "@vueuse/core" import { nanoid } from "nanoid" -import type { ImagePreTransform, Point } from "@/types" +import type { CropRotateState, Point } from "@/types" import type { LineMeasurement, RectMeasurement, @@ -15,29 +15,32 @@ import { getDatumColor } from "@/lib/datums" import { useAppStore } from "@/stores/app" import { loadMeasurements, saveMeasurements } from "@/lib/measurement-cache" import { loadZoom, saveZoom } from "@/lib/zoom-cache" +import { rotatedBboxSize, cropPixels } from "@/lib/crop-transform" -// `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: +// `crop` 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. +// When omitted, behaviour is identity. The viewer does NOT rewrite +// stored measurement coordinates when the transform changes; it only +// adjusts the draw-time projection (option (b) from the task brief), +// so changing the crop/rotation never invalidates persisted +// measurements. const props = defineProps<{ imageUrl: string scalePxPerMm: number - /** Optional pre-transform; identity when not supplied. */ - imageTransform?: ImagePreTransform + /** Cropping/rotation transform applied between the deskewed image + * and the bitmap pointed at by `imageUrl`. The viewer derives the + * pixel-space affine internally — callers only supply the + * canonical state plus the source dimensions of the deskewed + * bitmap. Omit for the legacy identity behaviour. */ + crop?: { state: CropRotateState; srcW: number; srcH: number } }>() // Emit when the measurement set changes (add / mutate / delete) so the @@ -384,43 +387,55 @@ function makeLiveCtx(): RenderCtx { } } +// Pre-derived components of the crop pre-transform. Cached because +// `imgPreTransform` is called per-vertex in inner draw loops and the +// trig + bbox math don't change while the user is just panning the +// canvas. Recomputed only when `props.crop` itself changes. +const cropDerived = computed(() => { + const c = props.crop + if (!c) return null + const rot = rotatedBboxSize(c.srcW, c.srcH, c.state.rotationDeg) + const px = cropPixels(c.state, rot) + const r = (c.state.rotationDeg * Math.PI) / 180 + return { + cx: c.srcW / 2, + cy: c.srcH / 2, + cos: Math.cos(r), + sin: Math.sin(r), + halfRotW: rot.rotW / 2, + halfRotH: rot.rotH / 2, + cropX: px.cropX, + cropY: px.cropY, + } +}) + // 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. +// shown bitmap's coordinate frame. Returns the input unchanged when +// no crop transform is configured — keeps the legacy non-cropped +// path pixel-identical. 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 } + const d = cropDerived.value + if (!d) return pt + const dx = pt.x - d.cx + const dy = pt.y - d.cy + const rx = dx * d.cos - dy * d.sin + d.halfRotW + const ry = dx * d.sin + dy * d.cos + d.halfRotH + return { x: rx - d.cropX, y: ry - d.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) + const d = cropDerived.value + if (!d) return pt + const rx = pt.x + d.cropX + const ry = pt.y + d.cropY + const dx = rx - d.halfRotW + const dy = ry - d.halfRotH return { - x: dx * cos + dy * sin + cx, - y: -dx * sin + dy * cos + cy, + x: dx * d.cos + dy * d.sin + d.cx, + y: -dx * d.sin + dy * d.cos + d.cy, } } @@ -1874,6 +1889,19 @@ function pointerUp() { dragState = null } +// Zoom anchoring needs the bitmap-space point under the cursor, not the +// measurement-space point. `screenToImg` peels off the crop pre-transform +// to return the latter, which is correct for measurement placement but +// breaks the zoom-anchor math (`offset = screen - bitmap * scale`). +// Without crop the two are equal, which is why the bug only shows up +// once the user crops the deskew result. +function screenToBitmap(sx: number, sy: number): Point { + return { + x: (sx - viewOffsetX.value) / viewScale.value, + y: (sy - viewOffsetY.value) / viewScale.value, + } +} + function onWheel(e: WheelEvent) { e.preventDefault() const scaleBy = 1.08 @@ -1884,11 +1912,11 @@ function onWheel(e: WheelEvent) { const clamped = Math.max(0.05, Math.min(20, newScale)) const { x: px, y: py } = getCanvasXY(e) - const imgPt = screenToImg(px, py) + const bmp = screenToBitmap(px, py) viewScale.value = clamped - viewOffsetX.value = px - imgPt.x * clamped - viewOffsetY.value = py - imgPt.y * clamped + viewOffsetX.value = px - bmp.x * clamped + viewOffsetY.value = py - bmp.y * clamped redraw() } @@ -2036,11 +2064,11 @@ function onTouchMove(e: TouchEvent) { if (!rect) return const cx = (t0.clientX + t1.clientX) / 2 - rect.left const cy = (t0.clientY + t1.clientY) / 2 - rect.top - const imgPt = screenToImg(cx, cy) + const bmp = screenToBitmap(cx, cy) viewScale.value = newScale - viewOffsetX.value = cx - imgPt.x * newScale - viewOffsetY.value = cy - imgPt.y * newScale + viewOffsetX.value = cx - bmp.x * newScale + viewOffsetY.value = cy - bmp.y * newScale lastPinchDist = dist redraw() diff --git a/src/components/CropViewer.vue b/src/components/CropViewer.vue index 480b53a..03df2c5 100644 --- a/src/components/CropViewer.vue +++ b/src/components/CropViewer.vue @@ -38,12 +38,30 @@ const imgUrl = ref(null) // permanently blank canvas. const loadError = ref(false) -// 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) +// Single source of truth: `store.cropRotate`. Rotations and crop drags +// update the store directly so the live state and the persisted state +// can never drift mid-interaction. localStorage is only written on +// explicit commit (drag end, rotation change, unmount) via +// `flushToCache()` — that keeps the store hot without thrashing the +// disk on every pointermove. +const rotationDeg = computed(() => store.cropRotate.rotationDeg) +const cropLeft = computed(() => store.cropRotate.crop.left) +const cropTop = computed(() => store.cropRotate.crop.top) +const cropRight = computed(() => store.cropRotate.crop.right) +const cropBottom = computed(() => store.cropRotate.crop.bottom) + +function setRotationOnly(deg: number) { + store.setCropRotate({ ...store.cropRotate, rotationDeg: deg }) +} + +function setCropOnly(crop: { + left: number + top: number + right: number + bottom: number +}) { + store.setCropRotate({ ...store.cropRotate, crop }) +} // Live-fit transform from rotated-bbox space → screen canvas pixels. // Recomputed on resize / rotation change so the user always sees the @@ -60,19 +78,9 @@ const rotBbox = computed(() => { return rotatedBboxSize(i.naturalWidth, i.naturalHeight, rotationDeg.value) }) -function persist() { +function flushToCache() { 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) + saveCropRotate(store.fileHash, store.cropRotate) } function loadImage(url: string) { @@ -327,10 +335,7 @@ function onPointerMove(ev: PointerEvent) { } } - cropLeft.value = l - cropTop.value = t - cropRight.value = r2 - cropBottom.value = b + setCropOnly({ left: l, top: t, right: r2, bottom: b }) drawOverlay() } @@ -341,17 +346,13 @@ function onPointerUp(ev: PointerEvent) { } if (drag) { drag = null - persist() + flushToCache() } } function resetCrop() { - cropLeft.value = 0 - cropTop.value = 0 - cropRight.value = 1 - cropBottom.value = 1 - rotationDeg.value = 0 - persist() + store.resetCropRotate() + flushToCache() fitToContainer() redraw() } @@ -360,10 +361,10 @@ 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 + setRotationOnly(clamped) fitToContainer() redraw() - persist() + flushToCache() } function rotateBy(delta: number) { @@ -377,15 +378,11 @@ onMounted(() => { 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. + // Hydrate the store from cache if we have one; otherwise the store + // already holds whatever was set the last time the user came through + // (or `IDENTITY_CROP_ROTATE` on a fresh session). Either way the + // computed refs above will surface the correct values. 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) @@ -401,11 +398,11 @@ onMounted(() => { }) onUnmounted(() => { - // If the user navigated away mid-drag (e.g. tapped Back during a corner - // pull), the local refs hold the latest crop coords but `persist()` only - // fires on `pointerup`. Flush here so cache + store match what was on - // screen. - persist() + // The store is already canonical (drag handlers wrote through), but + // localStorage only flushes on commit boundaries — if the user navigates + // away mid-drag, push the current store state to disk so the cache + // matches what's on screen. + flushToCache() resizeObs?.disconnect() if (imgUrl.value) URL.revokeObjectURL(imgUrl.value) }) @@ -420,7 +417,7 @@ watch([cropLeft, cropTop, cropRight, cropBottom], () => { }) function next() { - persist() + flushToCache() store.goToStep(6) } diff --git a/src/components/MeasureViewer.vue b/src/components/MeasureViewer.vue index 9b1245d..0dd1809 100644 --- a/src/components/MeasureViewer.vue +++ b/src/components/MeasureViewer.vue @@ -15,7 +15,6 @@ 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. @@ -26,16 +25,17 @@ type CorrectedImageViewerRef = InstanceType & { }) => Promise } import { loadSettings, saveSettings } from "@/lib/settings-cache" -import { - rotatedBboxSize, - cropPixels, - renderRotatedCropped, -} from "@/lib/crop-transform" +import { renderRotatedCropped } from "@/lib/crop-render" import { patchUpload } from "@/lib/upload-cache" const store = useAppStore() const resultUrl = ref(null) -const imageTransform = ref(null) +// Source dimensions of the deskewed bitmap (the input to rotate+crop). +// Captured once when we render the cropped output and passed to +// CorrectedImageViewer so it can derive the bitmap-space affine for +// projecting measurement coords. Null until the deskew bitmap is +// decoded for the first time. +const srcDims = ref<{ w: number; h: number } | null>(null) const viewerRef = ref(null) const error = ref("") const includeScaleBar = ref(false) @@ -133,14 +133,7 @@ async function buildTransformedSource() { 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 out = renderRotatedCropped(image, store.cropRotate) const blob = await new Promise((resolve, reject) => { out.toBlob((b) => { if (b) resolve(b) @@ -149,15 +142,7 @@ async function buildTransformedSource() { }) 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, - } + srcDims.value = { w: image.naturalWidth, h: image.naturalHeight } schedulePreview() } finally { URL.revokeObjectURL(url) @@ -531,7 +516,15 @@ async function downloadMeasured(scope: "full" | "view") { ref="viewerRef" :image-url="resultUrl" :scale-px-per-mm="store.scalePxPerMm" - :image-transform="imageTransform ?? undefined" + :crop=" + srcDims + ? { + state: store.cropRotate, + srcW: srcDims.w, + srcH: srcDims.h, + } + : undefined + " @measurements-changed="schedulePreview" /> diff --git a/src/lib/crop-render.ts b/src/lib/crop-render.ts new file mode 100644 index 0000000..6f55bbf --- /dev/null +++ b/src/lib/crop-render.ts @@ -0,0 +1,36 @@ +import type { CropRotateState } from "@/types" +import { rotatedBboxSize, cropPixels } from "@/lib/crop-transform" + +// DOM rasterisation for the Crop & Rotate pipeline. Kept separate from +// `crop-transform.ts` so the pure geometry helpers (rotatedBboxSize, +// cropPixels) stay portable — no canvas / DOM dependency, importable from +// a worker or a Node-based test. + +/** 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 +} diff --git a/src/lib/crop-transform.ts b/src/lib/crop-transform.ts index acb3f00..17d4b58 100644 --- a/src/lib/crop-transform.ts +++ b/src/lib/crop-transform.ts @@ -1,6 +1,6 @@ import type { CropRotateState } from "@/types" -// Geometry helpers for the Crop & Rotate step. The pipeline: +// Pure 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`. @@ -12,6 +12,9 @@ import type { CropRotateState } from "@/types" // 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. +// +// DOM rasterisation lives in `crop-render.ts`. This module stays pure so +// the geometry can run in a worker / be tested without a canvas mock. interface RotatedSize { rotW: number @@ -59,32 +62,3 @@ export function cropPixels( 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 -} diff --git a/src/types/index.ts b/src/types/index.ts index 52889e9..2a66ea2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -144,20 +144,6 @@ export const IDENTITY_CROP_ROTATE: CropRotateState = { 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