feat(breadboard): green dots on pins plugged into a breadboard

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) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-07-20 04:48:16 +02:00
parent bbd025d1c4
commit d612c3bae7
5 changed files with 220 additions and 17 deletions

View File

@ -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);
}
});
})

View File

@ -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<PinOverlayProps> = ({
// 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 (
<div

View File

@ -0,0 +1,119 @@
/**
* SeatedPinMarkers
*
* A small green dot on each component pin that is PLUGGED INTO a breadboard
* hole (Wokwi-style). Seating is otherwise invisible the pinhole link is a
* zero-length `bb` wire that never renders so without this a user can't tell
* a part that merely sits ON the board from one whose pins are actually
* connected. The dots make "seated & connected" legible at a glance.
*
* Unlike PinOverlay (hover/wiring-gated clickable hit boxes) this layer is
* ALWAYS visible and never interactive. Both reuse `rotatePinLocal` so the
* markers and the hit boxes can never drift apart under rotation.
*/
import React, { useEffect, useState } from 'react';
import { rotatePinLocal } from '../../utils/pinPositionCalculator';
interface PinInfo {
name: string;
x: number;
y: number;
}
interface SeatedPinMarkersProps {
componentId: string;
componentX: number;
componentY: number;
/** Pin names currently plugged into a breadboard hole (from bb wires). */
seatedPins: string[];
rotation?: number;
/** Wrapper padding+border inset, matching PinOverlay. */
wrapperOffsetX?: number;
wrapperOffsetY?: number;
}
/** Green dot diameter in world px (zoom-independent, like the pin hit boxes). */
const DOT_SIZE = 7;
export const SeatedPinMarkers: React.FC<SeatedPinMarkersProps> = ({
componentId,
componentX,
componentY,
seatedPins,
rotation = 0,
wrapperOffsetX = 6,
wrapperOffsetY = 6,
}) => {
const [pins, setPins] = useState<PinInfo[]>([]);
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<typeof setTimeout> | 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 (
<div
style={{
position: 'absolute',
left: `${componentX + wrapperOffsetX}px`,
top: `${componentY + wrapperOffsetY}px`,
pointerEvents: 'none',
// Just above the component body so the dots read as sitting on the
// pins; below the z:30 wire-target overlay so hover boxes still win.
zIndex: 29,
}}
>
{pins.map((pin, index) => {
if (!seated.has(pin.name)) return null;
const { x, y } = rotatePinLocal(pin.x, pin.y, rotation, wrapperBox, wrapperOffsetX, wrapperOffsetY);
return (
<div
key={`${pin.name}-${index}`}
title={`${pin.name} → breadboard`}
style={{
position: 'absolute',
left: `${x - DOT_SIZE / 2}px`,
top: `${y - DOT_SIZE / 2}px`,
width: `${DOT_SIZE}px`,
height: `${DOT_SIZE}px`,
borderRadius: '50%',
backgroundColor: '#22e06a',
border: '1px solid rgba(255,255,255,0.85)',
boxShadow: '0 0 4px 1px rgba(34,224,106,0.85)',
pointerEvents: 'none',
}}
/>
);
})}
</div>
);
};

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 { 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<string, string[]>();
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. */}
<SeatedPinMarkers
componentId={component.id}
componentX={component.x}
componentY={component.y}
seatedPins={seatedPinsByComponent.get(component.id) ?? []}
rotation={Number(component.properties?.rotation) || 0}
/>
{/* Pin overlay for wire creation - hide while interacting/running */}
{!interactionRunning && (
<PinOverlay

View File

@ -128,6 +128,38 @@ export function calculatePinPosition(
return { x: pinX, y: pinY };
}
/**
* Rotate a pin's element-space (x, y) around the DynamicComponent wrapper
* centre, returning coordinates LOCAL to the pin-overlay container (whose
* origin is the inner element top-left, i.e. component pos + wrapper inset).
*
* Shared by every always-/hover-rendered pin layer (wire-target boxes,
* seated-pin markers) so they can never drift apart: the wrapper is rotated
* by CSS `transform: rotate()` about `center center`, but the overlay layers
* live OUTSIDE that wrapper and must reproduce the rotation manually.
* `wrapperBox` is the wrapper's UNROTATED layout box (offsetWidth/Height),
* which is what CSS rotates around; pass null (or angle 0) to skip rotation.
*/
export function rotatePinLocal(
x: number,
y: number,
rotation: number,
wrapperBox: { w: number; h: number } | null,
wrapperOffsetX: number,
wrapperOffsetY: number,
): { x: number; y: number } {
const angle = ((rotation % 360) + 360) % 360;
if (angle === 0 || !wrapperBox) return { x, y };
const pivotX = -wrapperOffsetX + wrapperBox.w / 2;
const pivotY = -wrapperOffsetY + wrapperBox.h / 2;
const theta = (angle * Math.PI) / 180;
const cos = Math.cos(theta);
const sin = Math.sin(theta);
const dx = x - pivotX;
const dy = y - pivotY;
return { x: pivotX + dx * cos - dy * sin, y: pivotY + dx * sin + dy * cos };
}
/**
* Gets all pins for a component with their absolute canvas positions.
* Useful for rendering pin overlays and finding nearby pins.