Architectural debt addressed in one pass:
- CropViewer drops its five local refs (rotationDeg, cropLeft/Top/Right
/Bottom) in favour of computed views over `store.cropRotate`. Drag and
rotation handlers write straight to the store via thin setters; the
store is now the canonical source of truth. localStorage is flushed on
commit boundaries (drag end, rotation change, unmount, Next), not on
every pointermove.
- `ImagePreTransform` deleted from `src/types/index.ts`. CorrectedImage-
Viewer now takes `crop?: { state: CropRotateState; srcW; srcH }` and
derives the pixel-space affine internally via a memoised `computed`.
Cuts the second reactive ref in MeasureViewer and removes the
redundant "type that exists only to ferry derived numbers across a
prop boundary."
- `crop-transform.ts` is now pure geometry. The DOM-bound
`renderRotatedCropped` moved to a new `src/lib/crop-render.ts`. The
pure module is now safe to import from a worker / test without a
canvas mock.
Bug fix shipped in the same commit because it fell out of the same
analysis:
- Zoom (wheel + pinch) anchored on a measurement-space point returned
by `screenToImg`, but `viewOffset = screen - bitmap * scale` expects a
bitmap-space point. With identity crop the two were equal, so the bug
was invisible until the user cropped — at that point each zoom step
shifted the image far off-screen ("canvas goes blank/black"). Pan was
fine because it never converted screen↔image. Fix: a tiny
`screenToBitmap` helper used by `onWheel` and the pinch branch of
`onTouchMove`. Measurement placement / drag still go through the
full `screenToImg` so coords stay in the canonical frame.
- Rectangles now follow the same body-click rule as circles: corner dots
drag, body (rim + interior) selects when no tool is active and falls
through to placement when one is. Lets the user draw measurements
inside an existing rectangle.
- Selected shapes keep their own colour for the geometry stroke instead
of swapping to white. Unselected shapes are dimmed to 0.45 alpha so
the selected one still stands out, and the colour stays consistent
with the label pill — easier to pair shape ↔ label.
- Label clearance: anchors that coincide with a handle (ellipse, circle,
angle) now use a larger Y offset so the pill clears the handle disk.
Inter-label gap bumped from 4 to 8 px.
- Export labels are clamped to the destination canvas after collision
resolution so full-resolution exports never clip a pill off the edge.
- Fullscreen toggle in the toolbar (icon + text). Esc exits. Renders the
viewer as a fixed-position overlay; the existing ResizeObserver picks
up the new container and re-fits the image automatically.
Past uploads now persist to IndexedDB (originalBlob, exif, and — once
deskew has run — the corrected blob, diagnostics, and output scale).
The upload page renders a tile grid for any entry that completed a
deskew; clicking one rehydrates the store from the cached artefacts
and drops the user straight back into Measure with their datums and
measurements intact. The section is hidden entirely when there are no
completed entries, and each tile has a hover-only delete affordance.
Canvas zoom + pan are now per-image, persisted to localStorage with a
short debounce. Re-running the deskew at a different scale clears the
saved zoom (the stale offsets would point off-image at the new
dimensions).
Other tweaks bundled here:
• Start Over collapses the previous "Start over | New Image" group
into a single dashed/transparent button.
• The Measure header gets md:pl-5 so the title clears the fork-me
ribbon on desktop.
• Example images on the landing page swap to the IMG_8324 set, with
a third "Measured" tile spanning the combined width that shows the
annotation-baked output.
• Clearing the cache now also wipes the upload + zoom caches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 4 (was "Result") now only runs the perspective correction and shows
diagnostics + a small preview at the standard column width. Step 5
("Measure") is a new full-bleed view dedicated to annotation, with the
download buttons promoted to the top of the page.
Re-running the deskew at a different output px/mm now rescales any
measurements already saved for the image (cached by file hash) so they
stay anchored to the same physical features instead of drifting.
Theme tweaks: card surfaces are now visibly distinct from the page
background in light mode, and dark mode is a touch lighter than the
previous near-black. The "Made by" footer is pinned to the bottom of
the viewport via fixed positioning, with corresponding pb on <main>.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Labels go through a collision-resolver before painting: greedy
top-to-bottom placement pushes overlapping pills downward, and labels
shifted significantly draw a dashed leader line back to their anchor
in the measurement's color. Same two-pass order is used by the live
overlay and the annotated exports.
- Unselected dark label pills now have a colored border matching the
measurement, so a label can be paired to its geometry without relying
on selection state.
- New "Snap 45°" toolbar checkbox: when on, line endpoints and angle
arms snap their direction to multiples of 45° (relative to the fixed
endpoint or angle vertex) during placement and during handle drag.
Length is preserved.
- Circles: only the center and edge handles drag. Body / rim / label
clicks select-only, and when a placement tool is active they fall
through entirely so the user can draw a new measurement on top of an
existing circle.
Three combined additions to the corrected-image viewer:
- Circle measurement tool: 2-click placement (center + edge), dedicated
hit-test, handle drag preserving radius when grabbing the center.
- Annotated PNG exports via two new buttons in the result page:
"Download full + measurements" (source resolution, strokes scaled up
to read at the same visual weight) and "Download view + measurements"
(current pan/zoom). Both respect the existing scale-bar toggle; the
view export's bar is sized for canvas-px/mm = image-px/mm × view scale.
- Per-image measurement persistence keyed by file hash, mirroring the
datum cache. "Clear cache" in the upload step now wipes both.
Drawing helpers were refactored to take a RenderCtx (transform +
strokeMul + handle/decoration flags) so the same code paths handle live
overlay and offscreen export.
Dragging a corner used to move only that corner, turning the rect into
an arbitrary quad. Constrain to axis-aligned: dragged corner follows
the cursor, the diagonally-opposite corner stays put, the two adjacent
corners are recomputed from the cross of (dragged.x, opp.y) and
(opp.x, dragged.y) so the shape stays rectangular. Corner indices stay
stable (c0 is still the logical TL even if the box flips through
itself), so handle keys keep tracking the same corner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror DatumCanvas/Konva: hit-test on every mousedown/touchstart, and
let an existing-measurement hit win over both stage pan and placement
tools. Empty-space presses fall through — to a placement (if a tool is
active) or to a pan.
Suppress the trailing click event when the press picked up a prior
measurement so the placement tool doesn't commit a spurious new point
right after a corner drag ends.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drag was wired to canvas-level mousemove/mouseup, so any time the
cursor left the canvas (or a fast drag outpaced the canvas-bound event
firing) the drag died silently. Also onMouseLeave ended the drag, which
made off-edge nudges feel like they "didn't work".
Move the live mousemove/mouseup listeners to the window for the
duration of a press, leaving onMouseLeave to clear only the placement
preview. Window listeners are attached on the press and detached on
release (and on unmount, in case the user navigates away mid-drag).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Handles previously had a 3 px screen-space dead zone before drag started
(the click-vs-drag heuristic). On a precision-positioning tool that
makes nudges feel mushy, and the datum editor — which uses Konva drag
— has no such threshold. Drop the threshold; selection still happens
on pointerDown so a pure click that doesn't move never enters
pointerMove and the position never drifts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fourth measurement type alongside line/ellipse/angle. 2-click placement
(opposite corners → axis-aligned), corners normalised to TL/TR/BR/BL on
commit then free to drag individually so the user can make the quad
non-rectangular if they need to. Index ordering stays stable across
drags — same as the rect *datum* in step 3.
Hit test: 6 px tolerance on each of the 4 edges, plus an interior point-
in-polygon fill so a big rect can be grabbed anywhere. Handles still
win priority over the geometry fill so a corner grab always beats a
whole-rect grab.
Label at the centroid: `w × h mm · area mm²`. Width/height are means of
opposing edges so reshape doesn't make readings jump; area is computed
with the shoelace formula and survives non-rectangular drags.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The side-panel measurement rows were <button> with a nested <button> for
delete, which HTML doesn't allow and Vite was warning about. Demote the
row to a focusable div with role=button + tabindex=0 + Enter/Space
handlers; keep the inner delete <button> with type=button. Same
keyboard a11y, no nesting violation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Canvas fills the viewport (h-[calc(100vh-12rem)] on desktop, -14rem on
mobile), matching the datum-editor precedent. The side panel tracks
the same height so both read as equal-height siblings.
- Handles are now visible on every measurement, not just the selected
one. Unselected: small (3px), low-alpha, faint white ring. Selected
endpoints: 6.5px with a thick ring. Selected primary handles (ellipse
center / angle vertex): 8px. The invisible grab radius is 14px so the
tiny unselected dots are still easy to target.
- Selected handles keep their palette color (previously they went white
along with the lines, so a selected handle disappeared on light
backgrounds). Matches DatumCanvas's look.
- Hit-test priority is explicit: handles beat geometry, so a precision
grab on an endpoint always wins over a line-body drag — including on
an unselected measurement, which promotes to select-and-drag in a
single gesture.
- Removed the on-canvas delete button next to the selected label. The
side-panel row × and Delete/Backspace still work. HitResult.kind
drops its "delete" variant; the matching draw + dispatch blocks and
the dedicated hit region are gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Datum editor (step 3):
- Add world-axis role to rectangles (isAxisReference) and lines
(axisRole: "x"|"y"). Exclusive via a new store action that clears
any other axis flag on write. The solver's pickPrimary now honors an
explicit user flag ahead of the type-priority fallback; line-primary
correspondences target world +x or +y depending on the flag.
- Panel UI: checkbox on rect cards, three-way button row on line cards,
and an axis badge in each card header.
- Ellipse datum switches to 8 user-placed points on the circle contour.
New src/lib/ellipse-fit.ts does an algebraic LSQ conic fit (data-
normalised, 5x5 Gaussian solve, f=-1 constraint) and returns the
geometric center + perpendicular conjugate semi-axes, which we cache
on the datum for the solver and renderer. Dragging any handle
refits; an extra center handle translates all 8 points together.
datum-cache migrates legacy 3-handle storage by synthesising 8
samples from the old parametric form.
- ResultViewer auto-scale now floors to an int to match the integer-
only scale input (step=1).
Measurement tool (step 4) — CorrectedImageViewer.vue:
- Three measurement tools: line (length), ellipse (semi-axes + area),
angle (0-180 degrees between two rays).
- Persistent, multi-measurement state. Each has id, colorIndex, and
type-specific geometry; colors cycle via the existing getDatumColor
palette with a monotonic counter so deletion doesn't recolor.
- Selection model with hit-testing on handles, geometry, and labels.
Selected draws on top in white; others render dashed with 0.8/0.5
alpha so the active measurement pops.
- Dragging geometry or label moves the whole measurement; dragging a
handle reshapes just that handle. 3px mouse threshold distinguishes
click from drag.
- Side panel lists measurements with color chip, type, value, and a
delete button; clicking selects on canvas. Delete/Backspace deletes
the selected measurement. Escape cancels in-progress placement.
- Live placement preview + inline hint strip describes what the next
click does. Pinch-zoom and single-finger pan still work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New CorrectedImageViewer component with dual-canvas (image + overlay)
- Point-to-point measurement tool: click two points, see mm distance
- Toggleable grid overlay with configurable spacing and major lines
- Scale bar export: appends measurement bar to downloaded PNG
- Progress bar with step labels during algorithm execution
- Estimated output size shown before running, blocks if > 512MB
- Actual output dimensions/filesize shown in diagnostics
- Filename changed to originalname-skwik.png
- Collapsible algorithm explanation with numbered steps
- Process New Image button to reset and start over
- Scale bar tooltip explaining the feature
- Add shadcn-vue Checkbox component
Co-Authored-By: Claude <noreply@anthropic.com>