From abdf0a2deaf3f48c0c51d8fb6464d0d2eb6893f2 Mon Sep 17 00:00:00 2001 From: vladtechno Date: Mon, 18 May 2026 00:43:29 +1000 Subject: [PATCH] fix(damage-flow): rotation round-trip (store original-frame coords, display in rotated view) --- .../components/damage-flow/ZoneDetailView.tsx | 71 +++++++++++++++++-- 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/mechanic-pwa/frontend/src/components/damage-flow/ZoneDetailView.tsx b/mechanic-pwa/frontend/src/components/damage-flow/ZoneDetailView.tsx index 6619180..ae63a84 100644 --- a/mechanic-pwa/frontend/src/components/damage-flow/ZoneDetailView.tsx +++ b/mechanic-pwa/frontend/src/components/damage-flow/ZoneDetailView.tsx @@ -29,6 +29,47 @@ const COMPOSITE_H = 1209; // Module-level cache for the bbox map let bboxCache: Record | null = null; +/** Convert tap point from rotated/visible viewBox frame back to original composite frame. */ +function inverseRotateAroundBboxCenter( + pt: { x: number; y: number }, + b: ZoneBBox +): { x: number; y: number } { + const rot = b.rotation ?? 0; + if (rot === 0) return pt; + const cx = b.x + b.w / 2; + const cy = b.y + b.h / 2; + const dx = pt.x - cx; + const dy = pt.y - cy; + // Inverse rotation (CCW by `rot` degrees, or CW by `-rot`) + const rad = (-rot * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + return { + x: cx + dx * cos - dy * sin, + y: cy + dx * sin + dy * cos, + }; +} + +/** Apply forward rotation to a stored composite point so it appears at the correct visual position in the rotated detail view. */ +function forwardRotateAroundBboxCenter( + pt: { x: number; y: number }, + b: ZoneBBox +): { x: number; y: number } { + const rot = b.rotation ?? 0; + if (rot === 0) return pt; + const cx = b.x + b.w / 2; + const cy = b.y + b.h / 2; + const dx = pt.x - cx; + const dy = pt.y - cy; + const rad = (rot * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + return { + x: cx + dx * cos - dy * sin, + y: cy + dx * sin + dy * cos, + }; +} + async function loadBBoxes(): Promise> { if (bboxCache) return bboxCache; const res = await fetch("/data/zone_bboxes.json"); @@ -68,6 +109,7 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }: const [svgText, setSvgText] = useState(null); const [points, setPoints] = useState([]); const [viewBox, setViewBox] = useState(null); + const [zoneBBox, setZoneBBox] = useState(null); // Load SVG + precomputed bboxes when opening, and pick this zone's viewBox up-front useEffect(() => { @@ -86,6 +128,7 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }: const finalVB = b ? paddedViewBox(b) : `0 0 ${COMPOSITE_W} ${COMPOSITE_H}`; const rotXform = b ? rotationTransform(b) : null; setViewBox(finalVB); + setZoneBBox(b ?? null); // Rewrite SVG BEFORE mounting so first paint is already zoomed + rotated correctly. let mutated = text; @@ -164,10 +207,17 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }: pt.y = e.clientY; const ctm = svg.getScreenCTM(); if (!ctm) return; + // svgPt is in the SVG root user-space (viewBox space), which is the rotated frame. const svgPt = pt.matrixTransform(ctm.inverse()); - const x = svgPt.x / COMPOSITE_W; - const y = svgPt.y / COMPOSITE_H; + // Convert back to original composite frame (un-rotate) so stored coord lines up + // with the position on the unrotated overview SVG. + const original = zoneBBox + ? inverseRotateAroundBboxCenter({ x: svgPt.x, y: svgPt.y }, zoneBBox) + : { x: svgPt.x, y: svgPt.y }; + + const x = original.x / COMPOSITE_W; + const y = original.y / COMPOSITE_H; const cx = Math.max(0, Math.min(1, x)); const cy = Math.max(0, Math.min(1, y)); setPoints((prev) => [...prev, { x: cx, y: cy }]); @@ -199,6 +249,7 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }: setPoints((p) => p.slice(0, -1))} /> )} @@ -240,18 +291,24 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }: interface PointOverlayProps { points: Point[]; viewBox: string; + zoneBBox: ZoneBBox | null; onRemoveLast: () => void; } -function PointOverlay({ points, viewBox, onRemoveLast }: PointOverlayProps) { +function PointOverlay({ points, viewBox, zoneBBox, onRemoveLast }: PointOverlayProps) { const [minX, minY, w, h] = viewBox.split(/\s+/).map(Number); return ( <> {points.map((p, i) => { - const xVB = p.x * COMPOSITE_W; - const yVB = p.y * COMPOSITE_H; - const xPct = ((xVB - minX) / w) * 100; - const yPct = ((yVB - minY) / h) * 100; + // Stored point is in ORIGINAL composite frame (un-rotated). + // Apply forward rotation to map it back into the visible rotated frame so the dot + // appears at the position the user tapped. + const original = { x: p.x * COMPOSITE_W, y: p.y * COMPOSITE_H }; + const rotated = zoneBBox + ? forwardRotateAroundBboxCenter(original, zoneBBox) + : original; + const xPct = ((rotated.x - minX) / w) * 100; + const yPct = ((rotated.y - minY) / h) * 100; if (xPct < 0 || xPct > 100 || yPct < 0 || yPct > 100) return null; return (