From d612c3bae7b0a806749b05b3901aed018282ffe2 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Mon, 20 Jul 2026 04:48:16 +0200 Subject: [PATCH] feat(breadboard): green dots on pins plugged into a breadboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seating is otherwise invisible — a seated pin connects to its hole through a zero-length `bb` wire that never renders — so a user couldn't tell a part that merely sits ON the board from one whose pins are actually connected. This was reported after placing parts that looked seated but gave no signal they were wired in. SeatedPinMarkers draws a small always-on green dot (Wokwi-style) on each pin that has a `bb` wire, derived once per render from the store's wires (component pin = wire start). Non-interactive layer below the wire-target hit boxes; only breadboard-seated pins light up, so board-wired builtins stay unmarked — exactly the "seated vs connected" distinction that was missing. The per-pin rotation math (rotate about the wrapper centre, which the overlay layers live outside of) is extracted from PinOverlay into a shared `rotatePinLocal`, so the dots and the wire-target boxes can never drift apart under rotation. A test asserts rotatePinLocal agrees with calculatePinPosition at 0/90/180/270°. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/pin-position-rotation.test.ts | 36 +++++- .../src/components/simulator/PinOverlay.tsx | 25 ++-- .../components/simulator/SeatedPinMarkers.tsx | 119 ++++++++++++++++++ .../components/simulator/SimulatorCanvas.tsx | 25 ++++ frontend/src/utils/pinPositionCalculator.ts | 32 +++++ 5 files changed, 220 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/simulator/SeatedPinMarkers.tsx 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 && (