fix(damage-flow): rotation round-trip (store original-frame coords, display in rotated view)

This commit is contained in:
2026-05-18 00:43:29 +10:00
parent 56f1a2774b
commit abdf0a2dea
@@ -29,6 +29,47 @@ const COMPOSITE_H = 1209;
// Module-level cache for the bbox map // Module-level cache for the bbox map
let bboxCache: Record<string, ZoneBBox> | null = null; let bboxCache: Record<string, ZoneBBox> | 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<Record<string, ZoneBBox>> { async function loadBBoxes(): Promise<Record<string, ZoneBBox>> {
if (bboxCache) return bboxCache; if (bboxCache) return bboxCache;
const res = await fetch("/data/zone_bboxes.json"); const res = await fetch("/data/zone_bboxes.json");
@@ -68,6 +109,7 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
const [svgText, setSvgText] = useState<string | null>(null); const [svgText, setSvgText] = useState<string | null>(null);
const [points, setPoints] = useState<Point[]>([]); const [points, setPoints] = useState<Point[]>([]);
const [viewBox, setViewBox] = useState<string | null>(null); const [viewBox, setViewBox] = useState<string | null>(null);
const [zoneBBox, setZoneBBox] = useState<ZoneBBox | null>(null);
// Load SVG + precomputed bboxes when opening, and pick this zone's viewBox up-front // Load SVG + precomputed bboxes when opening, and pick this zone's viewBox up-front
useEffect(() => { 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 finalVB = b ? paddedViewBox(b) : `0 0 ${COMPOSITE_W} ${COMPOSITE_H}`;
const rotXform = b ? rotationTransform(b) : null; const rotXform = b ? rotationTransform(b) : null;
setViewBox(finalVB); setViewBox(finalVB);
setZoneBBox(b ?? null);
// Rewrite SVG BEFORE mounting so first paint is already zoomed + rotated correctly. // Rewrite SVG BEFORE mounting so first paint is already zoomed + rotated correctly.
let mutated = text; let mutated = text;
@@ -164,10 +207,17 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
pt.y = e.clientY; pt.y = e.clientY;
const ctm = svg.getScreenCTM(); const ctm = svg.getScreenCTM();
if (!ctm) return; 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 svgPt = pt.matrixTransform(ctm.inverse());
const x = svgPt.x / COMPOSITE_W; // Convert back to original composite frame (un-rotate) so stored coord lines up
const y = svgPt.y / COMPOSITE_H; // 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 cx = Math.max(0, Math.min(1, x));
const cy = Math.max(0, Math.min(1, y)); const cy = Math.max(0, Math.min(1, y));
setPoints((prev) => [...prev, { x: cx, y: cy }]); setPoints((prev) => [...prev, { x: cx, y: cy }]);
@@ -199,6 +249,7 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
<PointOverlay <PointOverlay
points={points} points={points}
viewBox={viewBox} viewBox={viewBox}
zoneBBox={zoneBBox}
onRemoveLast={() => setPoints((p) => p.slice(0, -1))} onRemoveLast={() => setPoints((p) => p.slice(0, -1))}
/> />
)} )}
@@ -240,18 +291,24 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
interface PointOverlayProps { interface PointOverlayProps {
points: Point[]; points: Point[];
viewBox: string; viewBox: string;
zoneBBox: ZoneBBox | null;
onRemoveLast: () => void; 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); const [minX, minY, w, h] = viewBox.split(/\s+/).map(Number);
return ( return (
<> <>
{points.map((p, i) => { {points.map((p, i) => {
const xVB = p.x * COMPOSITE_W; // Stored point is in ORIGINAL composite frame (un-rotated).
const yVB = p.y * COMPOSITE_H; // Apply forward rotation to map it back into the visible rotated frame so the dot
const xPct = ((xVB - minX) / w) * 100; // appears at the position the user tapped.
const yPct = ((yVB - minY) / h) * 100; 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; if (xPct < 0 || xPct > 100 || yPct < 0 || yPct > 100) return null;
return ( return (
<button <button