diff --git a/frontend/src/__tests__/pin-position-rotation.test.ts b/frontend/src/__tests__/pin-position-rotation.test.ts
new file mode 100644
index 00000000..2ec9982d
--- /dev/null
+++ b/frontend/src/__tests__/pin-position-rotation.test.ts
@@ -0,0 +1,210 @@
+// @vitest-environment jsdom
+/**
+ * Regression test for the rotation bug: when a component rotates, its
+ * wire endpoints stayed at the unrotated pin positions and the part
+ * visually disconnected from its cables.
+ *
+ * Root cause was twofold:
+ * 1. `useSimulatorStore.updateComponent()` only triggered
+ * `updateWirePositions()` when x or y changed — never on rotation.
+ * 2. `calculatePinPosition()` knew nothing about rotation; it returned
+ * the unrotated offset even when the wrapper had a CSS transform.
+ *
+ * Both paths are now exercised here:
+ * - calculatePinPosition with rotation arg returns the correct rotated
+ * coordinate for a known wrapper + pin layout.
+ * - rotating a component through the store re-stamps wire endpoints.
+ */
+import { describe, it, expect, beforeEach } from 'vitest';
+import { calculatePinPosition } from '../utils/pinPositionCalculator';
+import { useSimulatorStore } from '../store/useSimulatorStore';
+
+// Build a minimal DOM matching the runtime layout:
+//
← what CSS rotates
+//
+// ← .pinInfo carrier
+//
+//
+function buildFakeComponent(opts: {
+ id: string;
+ wrapperW: number;
+ wrapperH: number;
+ pins: Array<{ name: string; x: number; y: number }>;
+}) {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'dynamic-component-wrapper';
+ // jsdom defaults offsetWidth / Height to 0; set them manually so the
+ // rotation math sees a real layout box.
+ Object.defineProperty(wrapper, 'offsetWidth', { value: opts.wrapperW, configurable: true });
+ Object.defineProperty(wrapper, 'offsetHeight', { value: opts.wrapperH, configurable: true });
+
+ const container = document.createElement('div');
+ container.className = 'web-component-container';
+
+ const inner = document.createElement('div');
+ inner.id = opts.id;
+ (inner as any).pinInfo = opts.pins;
+
+ container.appendChild(inner);
+ wrapper.appendChild(container);
+ document.body.appendChild(wrapper);
+ return { wrapper, inner };
+}
+
+describe('calculatePinPosition — rotation math', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('returns the unrotated offset when rotation is 0', () => {
+ buildFakeComponent({
+ id: 'comp0',
+ wrapperW: 72,
+ wrapperH: 48,
+ pins: [{ name: 'A', x: 0, y: 14 }],
+ });
+ // componentX/Y are the inner-element top-left after the +4/+6 wrapper
+ // offset that updateWirePositions applies. With component.x = 100 →
+ // componentX = 104.
+ const pos = calculatePinPosition('comp0', 'A', 104, 106, 0);
+ expect(pos).toEqual({ x: 104, y: 120 });
+ });
+
+ it('rotates a left-side pin to the bottom when rotation = 90°', () => {
+ // Wrapper 72×48 (small 2-input gate). Pin A at (0, 14) on the LEFT
+ // edge. After a 90° CW rotation around the wrapper centre, that pin
+ // should land on the bottom of the wrapper.
+ buildFakeComponent({
+ id: 'comp90',
+ wrapperW: 72,
+ wrapperH: 48,
+ pins: [{ name: 'A', x: 0, y: 14 }],
+ });
+ const pos = calculatePinPosition('comp90', 'A', 104, 106, 90);
+ expect(pos).not.toBeNull();
+ // Walk through the math to keep the assertion expressive:
+ // wrapperLeft = 104 - 4 = 100
+ // wrapperTop = 106 - 6 = 100
+ // pivot = (100 + 36, 100 + 24) = (136, 124)
+ // unrotated = (104 + 0, 106 + 14) = (104, 120)
+ // dx, dy = (-32, -4)
+ // 90° → (dx*0 - dy*1, dx*1 + dy*0) = (4, -32)
+ // result = (136 + 4, 124 - 32) = (140, 92)
+ expect(pos!.x).toBeCloseTo(140, 5);
+ expect(pos!.y).toBeCloseTo(92, 5);
+ });
+
+ it('rotates 180° flips both axes around the wrapper centre', () => {
+ buildFakeComponent({
+ id: 'comp180',
+ wrapperW: 72,
+ wrapperH: 48,
+ pins: [{ name: 'Y', x: 72, y: 24 }],
+ });
+ // unrotated Y is on the right edge midpoint
+ // wrapperLeft = 100, wrapperTop = 100, pivot = (136, 124)
+ // unrotated = (104+72, 106+24) = (176, 130)
+ // dx, dy = (40, 6)
+ // 180° → (-40, -6) → (96, 118)
+ const pos = calculatePinPosition('comp180', 'Y', 104, 106, 180);
+ expect(pos!.x).toBeCloseTo(96, 5);
+ expect(pos!.y).toBeCloseTo(118, 5);
+ });
+
+ it('rotating four 90° steps lands back on the original coordinates', () => {
+ buildFakeComponent({
+ id: 'comp360',
+ wrapperW: 72,
+ wrapperH: 48,
+ pins: [{ name: 'Y', x: 72, y: 24 }],
+ });
+ const base = calculatePinPosition('comp360', 'Y', 104, 106, 0);
+ const full = calculatePinPosition('comp360', 'Y', 104, 106, 360);
+ expect(full!.x).toBeCloseTo(base!.x, 5);
+ expect(full!.y).toBeCloseTo(base!.y, 5);
+ });
+
+ it('accepts negative angles', () => {
+ buildFakeComponent({
+ id: 'compNeg',
+ wrapperW: 72,
+ wrapperH: 48,
+ pins: [{ name: 'A', x: 0, y: 14 }],
+ });
+ // -90° (= 270°) sends a left-edge pin to the TOP of the wrapper.
+ // dx, dy = (-32, -4)
+ // -90° → (-dy, dx) = (4, -32) → wait, that's +90°. Let's check.
+ // Standard 2D rotation matrix [cos -sin; sin cos] with θ=-90°
+ // cos = 0, sin = -1
+ // (dx*0 - dy*(-1), dx*(-1) + dy*0) = (dy, -dx) = (-4, 32)
+ // result = (136 - 4, 124 + 32) = (132, 156)
+ const pos = calculatePinPosition('compNeg', 'A', 104, 106, -90);
+ expect(pos!.x).toBeCloseTo(132, 5);
+ expect(pos!.y).toBeCloseTo(156, 5);
+ });
+});
+
+describe('useSimulatorStore — rotating a component re-stamps wires', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ // Reset the store to a clean slate. Boards aren't needed for this test;
+ // we add the component directly.
+ useSimulatorStore.setState((s) => {
+ const ids = s.boards.map((b) => b.id);
+ ids.forEach((id) => s.removeBoard(id));
+ return s;
+ });
+ useSimulatorStore.setState({
+ components: [],
+ wires: [],
+ });
+ });
+
+ it('updateComponent({rotation}) recomputes wire endpoints', () => {
+ // Set up a gate-shaped component plus a wire anchored to its A pin.
+ buildFakeComponent({
+ id: 'g1',
+ wrapperW: 72,
+ wrapperH: 48,
+ pins: [
+ { name: 'A', x: 0, y: 14 },
+ { name: 'B', x: 0, y: 34 },
+ { name: 'Y', x: 72, y: 24 },
+ ],
+ });
+
+ useSimulatorStore.setState({
+ components: [
+ {
+ id: 'g1',
+ metadataId: 'logic-gate-and',
+ x: 100,
+ y: 100,
+ properties: {},
+ },
+ ],
+ wires: [
+ {
+ id: 'w1',
+ start: { componentId: 'g1', pinName: 'A', x: 104, y: 120 },
+ end: { componentId: 'g1', pinName: 'A', x: 104, y: 120 },
+ color: '#000',
+ waypoints: [],
+ },
+ ],
+ });
+
+ // Initial position should match the unrotated math.
+ const before = useSimulatorStore.getState().wires[0];
+ expect(before.start.x).toBe(104);
+ expect(before.start.y).toBe(120);
+
+ // Now rotate 90° via updateComponent — the wire should follow.
+ useSimulatorStore.getState().updateComponent('g1', {
+ properties: { rotation: 90 },
+ } as any);
+ const after = useSimulatorStore.getState().wires[0];
+ expect(after.start.x).toBeCloseTo(140, 5);
+ expect(after.start.y).toBeCloseTo(92, 5);
+ });
+});
diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts
index fd3025e7..84d77368 100644
--- a/frontend/src/store/useSimulatorStore.ts
+++ b/frontend/src/store/useSimulatorStore.ts
@@ -1839,7 +1839,13 @@ export const useSimulatorStore = create((set, get) => {
set((state) => ({
components: state.components.map((c) => (c.id === id ? { ...c, ...updates } : c)),
}));
- if (updates.x !== undefined || updates.y !== undefined) {
+ // Re-stamp wire endpoints when the geometry of the component changes:
+ // position (x/y) OR rotation. Without this, rotating a component
+ // leaves every wire anchored to the pre-rotation pin positions, so
+ // the part visually disconnects from its cables.
+ const rotationChanged =
+ updates.properties && 'rotation' in updates.properties;
+ if (updates.x !== undefined || updates.y !== undefined || rotationChanged) {
get().updateWirePositions(id);
}
},
@@ -1947,15 +1953,21 @@ export const useSimulatorStore = create((set, get) => {
// Boards are rendered directly without a wrapper, so no offset.
const compX = component ? component.x + 4 : board ? board.x : state.boardPosition.x;
const compY = component ? component.y + 6 : board ? board.y : state.boardPosition.y;
+ // Boards never rotate; components carry their angle in properties.rotation.
+ const rotation = component ? Number(component.properties?.rotation) || 0 : 0;
const updatedWires = state.wires.map((wire) => {
const updated = { ...wire };
if (wire.start.componentId === componentId) {
- const pos = calculatePinPosition(componentId, wire.start.pinName, compX, compY);
+ const pos = calculatePinPosition(
+ componentId, wire.start.pinName, compX, compY, rotation,
+ );
if (pos) updated.start = { ...wire.start, x: pos.x, y: pos.y };
}
if (wire.end.componentId === componentId) {
- const pos = calculatePinPosition(componentId, wire.end.pinName, compX, compY);
+ const pos = calculatePinPosition(
+ componentId, wire.end.pinName, compX, compY, rotation,
+ );
if (pos) updated.end = { ...wire.end, x: pos.x, y: pos.y };
}
return updated;
@@ -1982,11 +1994,13 @@ export const useSimulatorStore = create((set, get) => {
: startBoard
? startBoard.y
: state.boardPosition.y;
+ const startRotation = startComp ? Number(startComp.properties?.rotation) || 0 : 0;
const startPos = calculatePinPosition(
wire.start.componentId,
wire.start.pinName,
startX,
startY,
+ startRotation,
);
updated.start = startPos
? { ...wire.start, x: startPos.x, y: startPos.y }
@@ -1997,7 +2011,10 @@ export const useSimulatorStore = create((set, get) => {
const endBoard = state.boards.find((b) => b.id === wire.end.componentId);
const endX = endComp ? endComp.x + 4 : endBoard ? endBoard.x : state.boardPosition.x;
const endY = endComp ? endComp.y + 6 : endBoard ? endBoard.y : state.boardPosition.y;
- const endPos = calculatePinPosition(wire.end.componentId, wire.end.pinName, endX, endY);
+ const endRotation = endComp ? Number(endComp.properties?.rotation) || 0 : 0;
+ const endPos = calculatePinPosition(
+ wire.end.componentId, wire.end.pinName, endX, endY, endRotation,
+ );
updated.end = endPos
? { ...wire.end, x: endPos.x, y: endPos.y }
: { ...wire.end, x: endX, y: endY };
@@ -2151,22 +2168,29 @@ export const useSimulatorStore = create((set, get) => {
get().pushCommand(
{
description: 'Rotate component',
- execute: () =>
+ execute: () => {
set((s) => ({
components: s.components.map((c) =>
c.id === id
? { ...c, properties: { ...c.properties, rotation: nextRotation } }
: c,
),
- })),
- undo: () =>
+ }));
+ // Wires must follow the part on undo / redo too, otherwise a
+ // Ctrl+Z after a rotate would re-show the post-rotation pin
+ // positions against the now-restored unrotated component.
+ get().updateWirePositions(id);
+ },
+ undo: () => {
set((s) => ({
components: s.components.map((c) =>
c.id === id
? { ...c, properties: { ...c.properties, rotation: prevRotation } }
: c,
),
- })),
+ }));
+ get().updateWirePositions(id);
+ },
},
{ applyNow: false },
);
diff --git a/frontend/src/utils/pinPositionCalculator.ts b/frontend/src/utils/pinPositionCalculator.ts
index 1239ebfa..09329cbf 100644
--- a/frontend/src/utils/pinPositionCalculator.ts
+++ b/frontend/src/utils/pinPositionCalculator.ts
@@ -17,8 +17,14 @@
*
* @param componentId - The DOM ID of the component element
* @param pinName - The name of the pin (e.g., 'A', 'C', 'GND.1', '13')
- * @param componentX - Component's X position on canvas (pixels)
+ * @param componentX - Component's X position on canvas (pixels). For a
+ * rotated component this is still the UNROTATED inner-element top-left
+ * (callers already add the wrapper offset of 4 horizontal / 6 vertical).
* @param componentY - Component's Y position on canvas (pixels)
+ * @param rotation - Optional CSS rotation in degrees applied to the
+ * component's wrapper (0 / 90 / 180 / 270). When non-zero the pin
+ * position is rotated around the wrapper's center so wire endpoints
+ * land on the visually-rotated pin instead of the old layout-space pin.
* @returns Absolute canvas coordinates { x, y } or null if pin not found
*/
export function calculatePinPosition(
@@ -26,6 +32,7 @@ export function calculatePinPosition(
pinName: string,
componentX: number,
componentY: number,
+ rotation: number = 0,
): { x: number; y: number } | null {
// Get the DOM element
const element = document.getElementById(componentId);
@@ -64,9 +71,46 @@ export function calculatePinPosition(
return null;
}
- // Pin coordinates are already in CSS pixels, just add component position
- const pinX = componentX + pin.x;
- const pinY = componentY + pin.y;
+ // Unrotated pin position in canvas space.
+ let pinX = componentX + pin.x;
+ let pinY = componentY + pin.y;
+
+ // Rotation: the DynamicComponent wrapper applies
+ // transform: rotate(deg); transform-origin: center center;
+ // around its OWN center (the wrapper, not the inner web component). So
+ // when the user rotates 90° the pin moves on an arc centered on the
+ // wrapper center, not on the component origin or the pin's own axis.
+ //
+ // We compute the wrapper center in canvas space from `offsetWidth /
+ // offsetHeight` of the wrapper — those reflect the layout box and are
+ // UNAFFECTED by CSS transforms, so reading them right after a state
+ // change but before React commits the new transform is safe.
+ //
+ // Wrapper top-left ≈ inner-element top-left minus the wrapper padding
+ // + border. updateWirePositions / recalculateAllWirePositions add
+ // (+4, +6) to component.x / component.y to land on the inner-element
+ // top-left, so the wrapper top-left is (componentX - 4, componentY - 6).
+ // This convention is hardcoded in the store; we honour it here so the
+ // math stays consistent across the rotation boundary.
+ const angle = ((rotation % 360) + 360) % 360;
+ if (angle !== 0) {
+ const wrapper = element.closest('.dynamic-component-wrapper') as HTMLElement | null;
+ if (wrapper) {
+ const wrapperW = wrapper.offsetWidth;
+ const wrapperH = wrapper.offsetHeight;
+ const wrapperLeft = componentX - 4;
+ const wrapperTop = componentY - 6;
+ const pivotX = wrapperLeft + wrapperW / 2;
+ const pivotY = wrapperTop + wrapperH / 2;
+ const theta = (angle * Math.PI) / 180;
+ const cos = Math.cos(theta);
+ const sin = Math.sin(theta);
+ const dx = pinX - pivotX;
+ const dy = pinY - pivotY;
+ pinX = pivotX + (dx * cos - dy * sin);
+ pinY = pivotY + (dx * sin + dy * cos);
+ }
+ }
return { x: pinX, y: pinY };
}