diff --git a/frontend/src/__tests__/pin-position-rotation.test.ts b/frontend/src/__tests__/pin-position-rotation.test.ts index e70bb219..06cb8328 100644 --- a/frontend/src/__tests__/pin-position-rotation.test.ts +++ b/frontend/src/__tests__/pin-position-rotation.test.ts @@ -16,7 +16,7 @@ * - rotating a component through the store re-stamps wire endpoints. */ import { describe, it, expect, beforeEach } from 'vitest'; -import { calculatePinPosition } from '../utils/pinPositionCalculator'; +import { calculatePinPosition, rotatePinLocal } from '../utils/pinPositionCalculator'; import { useSimulatorStore } from '../store/useSimulatorStore'; // Build a minimal DOM matching the runtime layout: @@ -209,3 +209,37 @@ describe('useSimulatorStore — rotating a component re-stamps wires', () => { expect(after.start.y).toBeCloseTo(94, 5); }); }); + +describe('rotatePinLocal — shared by hit boxes and seated-pin markers', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + it('returns the input unchanged at rotation 0', () => { + expect(rotatePinLocal(12, 34, 0, { w: 72, h: 48 }, 6, 6)).toEqual({ x: 12, y: 34 }); + // No wrapper box measured yet → also a no-op. + expect(rotatePinLocal(12, 34, 90, null, 6, 6)).toEqual({ x: 12, y: 34 }); + }); + + it('agrees with calculatePinPosition so green dots track wire endpoints', () => { + // The two must never drift: the marker layer and the wire-endpoint math + // are the same rotation about the same wrapper pivot, differing only by + // the container origin (componentX/Y + wrapper offset). + buildFakeComponent({ + id: 'compR', + wrapperW: 72, + wrapperH: 48, + pins: [{ name: 'A', x: 0, y: 14 }], + }); + const componentX = 106; // inner-element top-left (component.x 100 + 6) + const componentY = 106; + for (const rot of [0, 90, 180, 270]) { + const abs = calculatePinPosition('compR', 'A', componentX, componentY, rot)!; + // rotatePinLocal works in element space (pin.x/pin.y) and returns + // coords local to the container at (component.x + 6, component.y + 6). + const local = rotatePinLocal(0, 14, rot, { w: 72, h: 48 }, 6, 6); + expect(local.x + componentX).toBeCloseTo(abs.x, 5); + expect(local.y + componentY).toBeCloseTo(abs.y, 5); + } + }); +}) diff --git a/frontend/src/components/simulator/PinOverlay.tsx b/frontend/src/components/simulator/PinOverlay.tsx index dbf0c829..a35b7ea7 100644 --- a/frontend/src/components/simulator/PinOverlay.tsx +++ b/frontend/src/components/simulator/PinOverlay.tsx @@ -10,6 +10,7 @@ import React, { useEffect, useState } from 'react'; import { useIsCoarsePointer } from '../../utils/useTouchDevice'; +import { rotatePinLocal } from '../../utils/pinPositionCalculator'; /** Minimum visual pin size in *world* pixels at zoom 1 */ const PIN_VISUAL = 12; @@ -147,22 +148,14 @@ export const PinOverlay: React.FC = ({ // The wrapper itself sits at (componentX, componentY), so its // top-left in container-local coords is (-wrapperOffsetX, // -wrapperOffsetY). CSS rotates around the wrapper's centre. - let pinX = pin.x; - let pinY = pin.y; - const angle = ((rotation % 360) + 360) % 360; - if (angle !== 0 && wrapperBox) { - const wrapperLeftLocal = -wrapperOffsetX; - const wrapperTopLocal = -wrapperOffsetY; - const pivotX = wrapperLeftLocal + wrapperBox.w / 2; - const pivotY = wrapperTopLocal + wrapperBox.h / 2; - const theta = (angle * Math.PI) / 180; - const cos = Math.cos(theta); - const sin = Math.sin(theta); - const dx = pin.x - pivotX; - const dy = pin.y - pivotY; - pinX = pivotX + dx * cos - dy * sin; - pinY = pivotY + dx * sin + dy * cos; - } + const { x: pinX, y: pinY } = rotatePinLocal( + pin.x, + pin.y, + rotation, + wrapperBox, + wrapperOffsetX, + wrapperOffsetY, + ); return (
= ({ + componentId, + componentX, + componentY, + seatedPins, + rotation = 0, + wrapperOffsetX = 6, + wrapperOffsetY = 6, +}) => { + const [pins, setPins] = useState([]); + const [wrapperBox, setWrapperBox] = useState<{ w: number; h: number } | null>(null); + + // Same DOM read as PinOverlay: pinInfo gives element-space pin coords, and + // the wrapper's unrotated layout box gives the rotation pivot. Re-measure + // after layout because a part imported already-rotated may not have its + // final size on the mount tick (issues #230/#232). Re-run when the seating + // changes so a freshly plugged pin appears without a hover. + const seatedKey = seatedPins.join(','); + useEffect(() => { + let raf = 0; + let timer: ReturnType | undefined; + const tryRead = (): boolean => { + const element = document.getElementById(componentId); + if (element && (element as unknown as { pinInfo?: PinInfo[] }).pinInfo) { + setPins((element as unknown as { pinInfo: PinInfo[] }).pinInfo); + const wrapper = element.closest('.dynamic-component-wrapper') as HTMLElement | null; + if (wrapper) setWrapperBox({ w: wrapper.offsetWidth, h: wrapper.offsetHeight }); + return true; + } + return false; + }; + tryRead(); + raf = requestAnimationFrame(() => { + if (!tryRead()) timer = setTimeout(tryRead, 50); + }); + return () => { + cancelAnimationFrame(raf); + if (timer) clearTimeout(timer); + }; + }, [componentId, rotation, seatedKey]); + + if (seatedPins.length === 0 || pins.length === 0) return null; + const seated = new Set(seatedPins); + + return ( +
+ {pins.map((pin, index) => { + if (!seated.has(pin.name)) return null; + const { x, y } = rotatePinLocal(pin.x, pin.y, rotation, wrapperBox, wrapperOffsetX, wrapperOffsetY); + return ( +
+ ); + })} +
+ ); +}; diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 33a2c259..da789076 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -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 { SeatedPinMarkers } from './SeatedPinMarkers'; import { calculatePinPosition } from '../../utils/pinPositionCalculator'; import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping'; import { autoWireColor, WIRE_KEY_COLORS, expandOrthogonalPoints } from '../../utils/wireUtils'; @@ -2044,6 +2045,20 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { return () => clearTimeout(timer); }, [components.length]); + // Which pins of each component are plugged into a breadboard hole. Seating + // is an invisible `bb` wire (component pin = start, hole = end), so one pass + // over the wires gives every part its seated-pin names for the green markers. + const seatedPinsByComponent = React.useMemo(() => { + const map = new Map(); + for (const w of wires) { + if (!w.bb) continue; + const arr = map.get(w.start.componentId); + if (arr) arr.push(w.start.pinName); + else map.set(w.start.componentId, [w.start.pinName]); + } + return map; + }, [wires]); + // Render component using dynamic renderer const renderComponent = (component: any) => { // SPICE probes are React components, not web components — render them @@ -2168,6 +2183,16 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { }} /> + {/* Green dots on pins plugged into a breadboard — always visible so + "seated & connected" is legible without hovering. */} + + {/* Pin overlay for wire creation - hide while interacting/running */} {!interactionRunning && (