fix(canvas): rotated-component pin positions in 3 paths (fixes #230, #231, #232)

All three bugs are rotated components whose pin geometry is computed in a
path that ignores the rotation, so pins/wire-starts land tens of pixels off
the visual pin tips. The live rotate action already recalculates correctly;
these are the paths that didn't.

#231 (context-menu 'Tap a pin to wire'): both onPinSelect handlers in
SimulatorCanvas computed the wire start as getBoundingClientRect().left +
pin.x — adding the UNROTATED pin offset to the ROTATED bounding-box corner.
On a 90-deg HC-SR04 that put the start ~70-100px off (measured). Replaced
with calculatePinPosition(id, x+6, y+6, rotation), the same rotation-aware
helper wires and the pin overlay use.

#232 (rotate -> delete -> undo): recordRemoveComponent's undo restored the
component + wires but never recalculated wire endpoints, so a rotated part's
wires kept the unrotated coords captured at delete time. Added a
requestAnimationFrame updateWirePositions(id) after restore.

#230 + #232 (pin boxes wrong after import / undo / load, 'fixes if rotated
again'): PinOverlay captured the wrapper's layout box (the rotation pivot)
once at mount. On import/undo/load the component mounts already-rotated and
its wokwi-element may not be sized on the mount tick, baking a wrong pivot
that only refreshed when rotation changed. PinOverlay now re-measures after
layout (rAF) and whenever it is about to become visible (showPins dep).
This commit is contained in:
David Montero 2026-06-16 05:25:31 +02:00
parent 3f23a7950c
commit 2d23b878e7
3 changed files with 55 additions and 50 deletions

View File

@ -68,6 +68,8 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
const isCoarse = useIsCoarsePointer();
useEffect(() => {
let raf = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const tryRead = () => {
const element = document.getElementById(componentId);
if (element && (element as any).pinInfo) {
@ -84,12 +86,22 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
}
return false;
};
if (!tryRead()) {
// Retry once after a tick in case the element sets pinInfo asynchronously (e.g. via useEffect)
const t = setTimeout(tryRead, 50);
return () => clearTimeout(t);
}
}, [componentId, rotation]);
// Read immediately (correct when the element is already laid out), then
// re-measure after layout. On import / undo / project load the component
// mounts ALREADY rotated and its wokwi-element may not have its final size
// on the mount tick — reading offsetWidth then bakes a wrong rotation pivot
// that previously only refreshed when the user rotated again (issues #230,
// #232). Re-running on `showPins` also re-measures right before the overlay
// becomes visible, by which point the element is laid out.
tryRead();
raf = requestAnimationFrame(() => {
if (!tryRead()) timer = setTimeout(tryRead, 50);
});
return () => {
cancelAnimationFrame(raf);
if (timer) clearTimeout(timer);
};
}, [componentId, rotation, showPins]);
if (!showPins || pins.length === 0) {
return null;

View File

@ -26,6 +26,7 @@ import { PROPERTY_CHANGE_EVENT, type PropertyChangeDetail } from '../../simulati
import { mountDigitalGateEngine } from '../../simulation/digital/digitalGateController';
import { isSpiceMapped } from '../../simulation/spice/componentToSpice';
import { PinOverlay } from './PinOverlay';
import { calculatePinPosition } from '../../utils/pinPositionCalculator';
import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping';
import { autoWireColor, WIRE_KEY_COLORS } from '../../utils/wireUtils';
import {
@ -2714,30 +2715,24 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
setPinPicker(null);
return;
}
// Resolve world coords from the rendered element rect — this
// accounts for wrapper offsets, rotation, and current zoom.
const compEl = document.getElementById(targetId);
const canvasRect = canvasRef.current?.getBoundingClientRect();
const compRect = compEl?.getBoundingClientRect();
// Resolve world coords the SAME way wires + the pin overlay do:
// calculatePinPosition rotates the pin around the wrapper centre
// for the component's rotation. The old getBoundingClientRect path
// added the unrotated pin offset to the ROTATED bounding-box corner,
// landing the wire start ~70-100px off on a rotated part (issue #231).
let worldX: number;
let worldY: number;
if (canvasRect && compRect) {
const screenX = compRect.left + pin.x * zoomRef.current;
const screenY = compRect.top + pin.y * zoomRef.current;
const w = toWorld(screenX, screenY);
worldX = w.x;
worldY = w.y;
if (pinPicker.kind === 'board') {
const b = boards.find((x) => x.id === targetId);
const pos = calculatePinPosition(targetId, pinName, b?.x ?? 0, b?.y ?? 0, 0);
worldX = pos?.x ?? (b?.x ?? 0) + pin.x;
worldY = pos?.y ?? (b?.y ?? 0) + pin.y;
} else {
// Fallback: approximate from the stored x/y of the target.
if (pinPicker.kind === 'board') {
const b = boards.find((x) => x.id === targetId);
worldX = (b?.x ?? 0) + pin.x;
worldY = (b?.y ?? 0) + pin.y;
} else {
const c = components.find((x) => x.id === targetId);
worldX = (c?.x ?? 0) + pin.x;
worldY = (c?.y ?? 0) + pin.y;
}
const c = components.find((x) => x.id === targetId);
const rot = c ? Number(c.properties?.rotation) || 0 : 0;
const pos = calculatePinPosition(targetId, pinName, (c?.x ?? 0) + 6, (c?.y ?? 0) + 6, rot);
worldX = pos?.x ?? (c?.x ?? 0) + pin.x;
worldY = pos?.y ?? (c?.y ?? 0) + pin.y;
}
setPinPicker(null);
handlePinClick(targetId, pinName, worldX, worldY);
@ -2787,30 +2782,19 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
}
}}
onPinSelect={(id, pinName) => {
// Pick world coords from the live element rect when possible —
// it accounts for the wokwi-element wrapper offset and the
// current rotation. Falls back to component origin + pin
// offset if the rect can't be read.
const compEl = document.getElementById(id);
// Resolve world coords the SAME way wires + the pin overlay do,
// via calculatePinPosition, which rotates the pin around the
// wrapper centre for the component's rotation. The old
// getBoundingClientRect path added the unrotated pin offset to the
// ROTATED bounding-box corner, so on a rotated part the wire start
// landed ~70-100px away from the pin (issue #231).
const pin = (pinInfo || []).find((p: { name: string }) => p.name === pinName);
const c = components.find((x) => x.id === id);
if (!c || !pin) return;
const canvasRect = canvasRef.current?.getBoundingClientRect();
const compRect = compEl?.getBoundingClientRect();
let worldX: number;
let worldY: number;
if (canvasRect && compRect) {
// Convert pin's CSS-pixel offset (relative to component) into
// world coords via the canvas pan/zoom transform.
const screenX = compRect.left + pin.x * zoomRef.current;
const screenY = compRect.top + pin.y * zoomRef.current;
const w = toWorld(screenX, screenY);
worldX = w.x;
worldY = w.y;
} else {
worldX = c.x + pin.x;
worldY = c.y + pin.y;
}
const rot = Number(c.properties?.rotation) || 0;
const pos = calculatePinPosition(id, pinName, c.x + 6, c.y + 6, rot);
const worldX = pos?.x ?? c.x + pin.x;
const worldY = pos?.y ?? c.y + pin.y;
setShowPropertyDialog(false);
handlePinClick(id, pinName, worldX, worldY);
}}

View File

@ -2623,11 +2623,20 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
(w) => w.start.componentId !== id && w.end.componentId !== id,
),
})),
undo: () =>
undo: () => {
set((s) => ({
components: [...s.components, removed],
wires: [...s.wires, ...removedWires],
})),
}));
// Recalc this part's wire endpoints once it re-mounts. Without this
// a rotated component restored via Ctrl+Z keeps the unrotated wire
// coords captured at delete time, so its wires sit off the pins
// until the user rotates again (issue #232). rAF waits for the DOM
// node so calculatePinPosition can read the wrapper geometry.
const recalc = () => get().updateWirePositions(id);
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(recalc);
else recalc();
},
});
},