feat(breadboard): hover-gated labels + full-footprint seating solver

Three changes, all driven by a real project where a 4-digit 7-segment clock
was unreadable and half its parts were not actually seated.

Labels on hover only
--------------------
Eight vertical resistors at 19 px pitch rendered eight 93 px "Resistor 220 Ω"
labels on top of each other, hiding the parts and the breadboard holes; the
SPICE overlay added ~40 more `0uV` pills. Both are now revealed on hover:
hovering a part also lights up the voltages of every wire touching it.

The label is hidden with OPACITY and stays in flow. pinPositionCalculator
derives the rotation pivot from wrapper.offsetHeight, so taking it out of
flow would move the pins of every rotated component in every saved project.

Seat-on-drop
------------
The drag-time magnet only aligned the anchor pin and assumed the rest
followed, which is how parts ended up HALF-seated: some pins in holes, the
rest dead in the air. It looks mounted in a screenshot and silently breaks
the circuit. On release we now re-solve properly — nearest position where
EVERY pin is in a free hole, sliding past occupied columns — via the new
solvePlacement/seatOnDrop. Geometry comes from the element's own pinInfo,
so there is no part whitelist.

Sub-pitch translation
---------------------
solvePlacement first assigned pins to holes at half-pitch, then translates
by the centroid of the residuals before judging fit. Pinning the anchor dead
centre refused every off-lattice footprint: a diode spans 7.5 pitches, so
one leg landed 4.8 px out. Shifted 2.4 px, BOTH legs sit inside tolerance —
what bending the leads does on a real board. Measured over the catalog this
takes seatable parts from 87 to 125 of 152; diodes, transistors, regulators,
optocouplers and flip-flops are rescued with no artwork change.

Staying under SEAT_TOLERANCE (< half pitch) keeps each pin's nearest hole
unambiguous, so computeSeating resolves the same holes and the netlist is
unaffected by the small offset.

Also: refuse a placement that would put two of a part's own pins in one
strip. A column strip — and far worse, a power rail — is a single net, so
such a seating shorts the part to itself. Without it a 7-segment happily
lays its pins across a rail. And deduplicate pin names before solving:
calculatePinPosition resolves by name and returns the first match, so a
board carrying GND x5 collided with itself and was refused outright.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-07-19 05:28:40 +02:00
parent 206cda78af
commit 66c6c7d813
6 changed files with 792 additions and 10 deletions

View File

@ -7,6 +7,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useSimulatorStore } from '../store/useSimulatorStore';
import { BREADBOARD_PINS } from '../velxio-elements/breadboard-element';
import { computeSeating, seatOnDrop } from '../utils/breadboardSnap';
const RES_PIN_INFO = [
{ name: '1', x: 0, y: 5.65, signals: [] },
@ -79,3 +80,95 @@ describe('breadboard seating via updateComponent', () => {
expect(holes).toEqual(['11t.a', '5t.a']); // same holes, new location
});
});
/**
* Drop-time auto-seating (seatOnDrop). This is the generic path: pin
* geometry comes from the element's `pinInfo`, so no part is special-cased.
*/
describe('seatOnDrop', () => {
/** Real wokwi 1-digit 7segment pinInfo (pins='top'), mm*3.78 -> CSS px. */
const SEG7_PIN_INFO = [
{ name: 'COM.1', x: 23.72, y: 71.82, signals: [] },
{ name: 'COM.2', x: 23.72, y: 3.78, signals: [] },
{ name: 'A', x: 33.32, y: 3.78, signals: [] },
{ name: 'B', x: 42.92, y: 3.78, signals: [] },
{ name: 'C', x: 33.32, y: 71.82, signals: [] },
{ name: 'D', x: 14.12, y: 71.82, signals: [] },
{ name: 'E', x: 4.52, y: 71.82, signals: [] },
{ name: 'F', x: 14.12, y: 3.78, signals: [] },
{ name: 'G', x: 4.52, y: 3.78, signals: [] },
{ name: 'DP', x: 42.92, y: 71.82, signals: [] },
];
const bb = { id: 'bb1', metadataId: 'breadboard', x: 0, y: 0, properties: {} };
/** Every pin of `comp` that is within seat tolerance of a hole. */
const seatedCount = (comp: never) =>
(computeSeating(comp, [bb, comp] as never) ?? []).length;
beforeEach(() => {
mountFakeElement('seg1', SEG7_PIN_INFO);
mountFakeElement('res1', RES_PIN_INFO);
});
it('fully seats a 7-segment dropped a few px off — never half-seated', () => {
// The exact bug from the reported project: dropped slightly high, the
// top pin row grazes bank-a and the bottom row lands on nothing.
const comp = { id: 'seg1', metadataId: '7segment', x: 40, y: 30, properties: {} };
const placed = seatOnDrop(comp as never, 43, 27, [bb, comp] as never);
expect(placed).not.toBeNull();
expect(placed!.holes).toHaveLength(SEG7_PIN_INFO.length);
const seated = { ...comp, x: placed!.x, y: placed!.y };
expect(seatedCount(seated as never)).toBe(SEG7_PIN_INFO.length);
});
it('straddles the trench: top pin row in bank-t, bottom row in bank-b', () => {
const comp = { id: 'seg1', metadataId: '7segment', x: 40, y: 30, properties: {} };
const placed = seatOnDrop(comp as never, 43, 27, [bb, comp] as never)!;
const holeOf = (pin: string) => placed.holes.find((h) => h.pinName === pin)!.holeName;
// COM.2 is a top-row pin, COM.1 the bottom-row one directly below it.
expect(holeOf('COM.2')).toMatch(/t\.[a-e]$/);
expect(holeOf('COM.1')).toMatch(/b\.[f-j]$/);
// Same column — the part is rigid.
expect(holeOf('COM.2').split('t.')[0]).toBe(holeOf('COM.1').split('b.')[0]);
});
it('never assigns two pins to the same hole', () => {
const comp = { id: 'seg1', metadataId: '7segment', x: 40, y: 30, properties: {} };
const placed = seatOnDrop(comp as never, 43, 27, [bb, comp] as never)!;
const names = placed.holes.map((h) => h.holeName);
expect(new Set(names).size).toBe(names.length);
});
it('slides clear of a part already occupying the target holes', () => {
const seg = { id: 'seg1', metadataId: '7segment', x: 40, y: 30, properties: {} };
const first = seatOnDrop(seg as never, 43, 27, [bb, seg] as never)!;
const seated = { ...seg, x: first.x, y: first.y };
// Drop a resistor right on top of the seated display.
const res = { id: 'res1', metadataId: 'resistor', x: first.x, y: first.y, properties: {} };
const placed = seatOnDrop(res as never, first.x, first.y, [bb, seated, res] as never);
expect(placed).not.toBeNull();
const taken = new Set(first.holes.map((h) => h.holeName));
for (const h of placed!.holes) expect(taken.has(h.holeName)).toBe(false);
});
it('leaves a part dropped away from any breadboard alone', () => {
const comp = { id: 'res1', metadataId: 'resistor', x: 5000, y: 5000, properties: {} };
expect(seatOnDrop(comp as never, 5000, 5000, [bb, comp] as never)).toBeNull();
});
it('works off pinInfo alone — an unknown part type seats just the same', () => {
// No whitelist: a made-up component with plausible 2-pin geometry.
mountFakeElement('mystery1', [
{ name: 'P1', x: 0, y: 0, signals: [] },
{ name: 'P2', x: 9.6 * 3, y: 0, signals: [] },
]);
const comp = { id: 'mystery1', metadataId: 'totally-unknown-part', x: 40, y: 30, properties: {} };
const placed = seatOnDrop(comp as never, 42, 31, [bb, comp] as never);
expect(placed).not.toBeNull();
expect(placed!.holes).toHaveLength(2);
});
});

View File

@ -4,7 +4,12 @@
*/
import { describe, it, expect } from 'vitest';
import { breadboardHoles, nearestHole, SEAT_TOLERANCE } from '../utils/breadboardSnap';
import {
breadboardHoles,
nearestHole,
solvePlacement,
SEAT_TOLERANCE,
} from '../utils/breadboardSnap';
import { BREADBOARD_PINS } from '../velxio-elements/breadboard-element';
import { BREADBOARD_MINI_PINS } from '../velxio-elements/breadboard-mini-element';
@ -66,3 +71,298 @@ describe('nearestHole', () => {
expect(nearestHole(rotated, world, SEAT_TOLERANCE)).toBeNull();
});
});
describe('solvePlacement', () => {
const bb = { id: 'bb1', metadataId: 'breadboard', x: 100, y: 200 };
const ox = 100 + WRAPPER_INSET;
const oy = 200 + WRAPPER_INSET;
const hole = (name: string) => BREADBOARD_PINS.find((h) => h.name === name)!;
/** Pin offsets for a vertical resistor bridging the trench (rows b -> f). */
const resistorPins = () => {
const b = hole('10t.b');
const f = hole('10b.f');
return [
{ name: '1', dx: 0, dy: 0 },
{ name: '2', dx: f.x - b.x, dy: f.y - b.y },
];
};
/** Position that puts pin 1 exactly on `name`. */
const posFor = (name: string) => ({ x: ox + hole(name).x, y: oy + hole(name).y });
it('leaves an already-correct part exactly where it is', () => {
const want = posFor('10t.b');
const got = solvePlacement(resistorPins(), bb, new Set(), want.x, want.y)!;
expect(got.moved).toBeCloseTo(0, 5);
expect(got.holes.map((h) => h.holeName)).toEqual(['10t.b', '10b.f']);
});
it('pulls a part dropped slightly off back onto the holes', () => {
const want = posFor('10t.b');
const got = solvePlacement(resistorPins(), bb, new Set(), want.x + 3, want.y - 2)!;
expect(got.holes.map((h) => h.holeName)).toEqual(['10t.b', '10b.f']);
expect(got.x).toBeCloseTo(want.x, 5);
expect(got.y).toBeCloseTo(want.y, 5);
});
it('slides to the next free column when the target is occupied', () => {
const want = posFor('10t.b');
const got = solvePlacement(resistorPins(), bb, new Set(['10t.b']), want.x, want.y)!;
expect(got.holes[0].holeName).not.toBe('10t.b');
// Nearest free column, not a jump across the board.
expect(got.moved).toBeLessThanOrEqual(9.6 * 2);
});
it('rejects a placement whose SECOND pin would collide', () => {
const want = posFor('10t.b');
const got = solvePlacement(resistorPins(), bb, new Set(['10b.f']), want.x, want.y)!;
expect(got.holes.map((h) => h.holeName)).not.toContain('10b.f');
});
it('returns null when every hole in range is taken', () => {
const all = new Set(BREADBOARD_PINS.map((h) => h.name));
const want = posFor('10t.b');
expect(solvePlacement(resistorPins(), bb, all, want.x, want.y)).toBeNull();
});
it('never returns a half-seated placement (the 7-segment bug)', () => {
// The real invariant: a returned placement always assigns EVERY pin a
// hole. Never a partial seating, whatever the geometry. Swept across
// spans that are on-pitch, off-pitch and half-pitch.
const want = posFor('10t.b');
for (let extra = 0; extra <= 9.6; extra += 0.4) {
const pins = [
{ name: 'top', dx: 0, dy: 0 },
{ name: 'bottom', dx: 0, dy: 6 * 9.6 + extra },
];
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
if (got) expect(got.holes).toHaveLength(pins.length);
}
});
it('refuses to drag a part more than the search radius', () => {
const want = posFor('10t.b');
// Occupy a wide band around the drop so nothing fits within 6 pitches.
const taken = new Set(
BREADBOARD_PINS.filter((h) => Math.abs(h.x - hole('10t.b').x) < 9.6 * 8).map((h) => h.name),
);
expect(solvePlacement(resistorPins(), bb, taken, want.x, want.y)).toBeNull();
});
});
describe('solvePlacement — sub-pitch translation (off-lattice footprints)', () => {
const bb = { id: 'bb1', metadataId: 'breadboard', x: 100, y: 200 };
const ox = 100 + WRAPPER_INSET;
const oy = 200 + WRAPPER_INSET;
const hole = (name: string) => BREADBOARD_PINS.find((h) => h.name === name)!;
const posFor = (name: string) => ({ x: ox + hole(name).x, y: oy + hole(name).y });
/** Nearest-hole distance for every pin of a placement, for assertions. */
const residuals = (pins: { name: string; dx: number; dy: number }[], p: { x: number; y: number }) =>
pins.map((pin) => {
const px = p.x + pin.dx;
const py = p.y + pin.dy;
let best = Infinity;
for (const h of BREADBOARD_PINS) best = Math.min(best, Math.hypot(ox + h.x - px, oy + h.y - py));
return best;
});
it('seats a diode: 7.5-pitch span splits the error between both legs', () => {
// DiodeElements.ts diodePinInfo(): A at x=0, C at x=72 = 7.5 * 9.6.
// Anchor-exact placement leaves C 4.8 px out; the fine translation puts
// both legs 2.4 px off centre instead, which is inside tolerance.
const pins = [
{ name: 'A', dx: 0, dy: 0 },
{ name: 'C', dx: 72, dy: 0 },
];
const want = posFor('10t.b');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
expect(got).not.toBeNull();
expect(got!.holes).toHaveLength(2);
for (const r of residuals(pins, got!)) {
expect(r).toBeLessThanOrEqual(SEAT_TOLERANCE);
expect(r).toBeGreaterThan(0.5); // genuinely off-centre, not a lucky exact fit
}
});
it('keeps every pin inside tolerance, so hole resolution stays unambiguous', () => {
// SEAT_TOLERANCE < half pitch is what guarantees computeSeating later
// picks the SAME holes the solver assigned — i.e. the netlist is
// unaffected by the part rendering a couple of px off centre.
const pins = [
{ name: 'A', dx: 0, dy: 0 },
{ name: 'C', dx: 72, dy: 0 },
];
const want = posFor('10t.b');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y)!;
for (const r of residuals(pins, got)) expect(r).toBeLessThan(9.6 / 2);
});
it('still refuses a DIP-14 on the wrong pitch — 8 px cannot be rescued', () => {
// LogicICElements.ts dip14Pins(): y = 12 + i*8. Seven pins at 8 px drift
// 1.6 px per step against the 9.6 grid; by pin 7 that is 4.8 px, and no
// single translation can absorb a spread that large.
const pins = Array.from({ length: 7 }, (_, i) => ({ name: `p${i + 1}`, dx: 0, dy: i * 8 }));
const want = posFor('10t.a');
expect(solvePlacement(pins, bb, new Set(), want.x, want.y)).toBeNull();
});
it('makes EVERY two-pin footprint seatable, at any span', () => {
// Guarantee of the centroid rule: with two pins the worst span error
// against the lattice is half a pitch (4.8 px), which splits into 2.4 px
// per pin — always inside SEAT_TOLERANCE. This is what rescues the whole
// 72 px family (diodes, transistors, regulators) with no artwork change.
const want = posFor('10t.b');
for (let span = 9.6; span <= 96; span += 0.4) {
const pins = [
{ name: 'a', dx: 0, dy: 0 },
{ name: 'b', dx: span, dy: 0 },
];
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
expect(got, `span ${span.toFixed(1)} px should seat`).not.toBeNull();
for (const r of residuals(pins, got!)) expect(r).toBeLessThanOrEqual(SEAT_TOLERANCE);
}
});
it('an exactly-on-grid part is still placed dead centre, not nudged', () => {
const pins = [
{ name: '1', dx: 0, dy: 0 },
{ name: '2', dx: 9.6 * 4, dy: 0 },
];
const want = posFor('10t.b');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y)!;
expect(got.moved).toBeCloseTo(0, 6);
for (const r of residuals(pins, got)) expect(r).toBeCloseTo(0, 6);
});
});
describe('solvePlacement — real catalog footprints', () => {
// Coordinates copied verbatim from the element sources, so this locks in
// the measured coverage boundary. If artwork changes, these move with it.
const bb = { id: 'bb1', metadataId: 'breadboard', x: 100, y: 200 };
const ox = 100 + WRAPPER_INSET;
const oy = 200 + WRAPPER_INSET;
const hole = (name: string) => BREADBOARD_PINS.find((h) => h.name === name)!;
const at = (name: string) => ({ x: ox + hole(name).x, y: oy + hole(name).y });
const seats = (pins: { name: string; dx: number; dy: number }[], anchor = '10t.a') => {
const want = at(anchor);
return solvePlacement(pins, bb, new Set(), want.x, want.y);
};
it('SEATS a diode — DiodeElements.ts, A/C 72 px apart', () => {
expect(
seats([
{ name: 'A', dx: 0, dy: 16 },
{ name: 'C', dx: 72, dy: 16 },
]),
).not.toBeNull();
});
it('SEATS a TO-92 transistor — TransistorElements.ts C/B/E', () => {
const got = seats([
{ name: 'C', dx: 60, dy: 0 },
{ name: 'B', dx: 0, dy: 36 },
{ name: 'E', dx: 60, dy: 72 },
]);
expect(got).not.toBeNull();
expect(got!.holes).toHaveLength(3);
// Distinct holes — a transistor shorting two of its own legs is useless.
expect(new Set(got!.holes.map((h) => h.holeName)).size).toBe(3);
});
it('SEATS the 74HC595 — IC74HC595.ts, already on an exact 9.6 pitch', () => {
const xs = [8.1, 17.7, 27.3, 36.9, 46.5, 56.1, 65.7, 75.3];
const pins = [
...xs.map((x, i) => ({ name: `b${i}`, dx: x, dy: 51.3 })),
...xs.map((x, i) => ({ name: `t${i}`, dx: x, dy: 3 })),
];
const got = seats(pins);
expect(got).not.toBeNull();
expect(got!.holes).toHaveLength(16);
});
it('REFUSES the 74HC00 family — LogicICElements.ts dip14Pins() 8 px pitch', () => {
// The one defect no solver can absorb: 1.6 px drift per pin compounds
// to 4.8 px across the package. Needs the artwork fixed against the
// 74HC595 template above.
const DIP14_W = 80;
const pins = Array.from({ length: 14 }, (_, i) => ({
name: `p${i + 1}`,
dx: i < 7 ? 0 : DIP14_W,
dy: i < 7 ? 12 + i * 8 : 12 + (13 - i) * 8,
}));
expect(seats(pins)).toBeNull();
});
});
describe('solvePlacement — never shorts a part to itself', () => {
const bb = { id: 'bb1', metadataId: 'breadboard', x: 100, y: 200 };
const ox = 100 + WRAPPER_INSET;
const oy = 200 + WRAPPER_INSET;
const hole = (name: string) => BREADBOARD_PINS.find((h) => h.name === name)!;
const at = (name: string) => ({ x: ox + hole(name).x, y: oy + hole(name).y });
const groupOf = (h: string) => {
const m = /^(\d+)([tb])\.[a-j]$/.exec(h);
if (m) return `col${m[1]}${m[2]}`;
const r = /^([tb][pn])\.\d+$/.exec(h);
return r ? `rail${r[1]}` : h;
};
it('refuses a footprint whose pins would share one column strip', () => {
// Two pins 9.6 px apart vertically inside a bank land in the same 5-hole
// column, which is a single net.
const pins = [
{ name: 'a', dx: 0, dy: 0 },
{ name: 'b', dx: 0, dy: 9.6 },
];
const want = at('20t.a');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
if (got) {
const gs = got.holes.map((h) => groupOf(h.holeName));
expect(new Set(gs).size).toBe(gs.length);
}
});
it('never lays a multi-pin part across a power rail', () => {
// A rail is one net for the WHOLE board — the worst possible short.
const pins = Array.from({ length: 5 }, (_, i) => ({ name: `p${i}`, dx: i * 9.6, dy: 0 }));
const want = at('20t.a');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
if (got) {
const rails = got.holes.filter((h) => /^[tb][pn]\./.test(h.holeName));
expect(rails.length).toBeLessThanOrEqual(1);
}
});
it('every seated real footprint uses one distinct strip per pin', () => {
const cases: Record<string, { name: string; dx: number; dy: number }[]> = {
diode: [
{ name: 'A', dx: 0, dy: 16 },
{ name: 'C', dx: 72, dy: 16 },
],
to92: [
{ name: 'C', dx: 60, dy: 0 },
{ name: 'B', dx: 0, dy: 36 },
{ name: 'E', dx: 60, dy: 72 },
],
// neopixel: 20 x 10.5 px rectangle — the audit found this one seats
// only by shorting itself, at every anchor and rotation.
neopixel: [
{ name: 'VDD', dx: 0, dy: 0 },
{ name: 'DIN', dx: 0, dy: 10.5 },
{ name: 'DOUT', dx: 20, dy: 0 },
{ name: 'VSS', dx: 20, dy: 10.5 },
],
};
for (const [label, pins] of Object.entries(cases)) {
const want = at('20t.a');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
if (!got) continue; // refusing is a valid answer
const gs = got.holes.map((h) => groupOf(h.holeName));
expect(new Set(gs).size, `${label} shorts itself`).toBe(gs.length);
}
});
});

View File

@ -236,6 +236,7 @@ interface DynamicComponentProps {
x?: number;
y?: number;
isSelected?: boolean;
isHovered?: boolean;
onMouseDown?: (e: React.MouseEvent) => void;
onDoubleClick?: (e: React.MouseEvent) => void;
onMouseEnter?: () => void;
@ -250,6 +251,7 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
x = 0,
y = 0,
isSelected = false,
isHovered = false,
onMouseDown,
onDoubleClick,
onMouseEnter,
@ -706,7 +708,13 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
</div>
)}
{/* Component label */}
{/* Component label revealed on hover/selection only.
A dense board (e.g. 8 vertical resistors at 19 px pitch) turned into
a wall of overlapping "Resistor 220 Ω" text that hid the breadboard
holes and the parts themselves. Hidden with OPACITY, never
`display`/`position`: pinPositionCalculator derives the rotation
pivot from `wrapper.offsetHeight`, so taking the label out of flow
would move every rotated component's pins. */}
<div
className="component-label"
style={{
@ -719,6 +727,8 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
opacity: isHovered || isSelected ? 1 : 0,
transition: 'opacity 120ms ease-out',
}}
>
{properties.pin !== undefined ? `Pin ${properties.pin}` : metadata.name}

View File

@ -11,6 +11,12 @@
* This is a read-only, zero-interactivity layer it sits ABOVE the wire
* layer but below the component layer so labels remain legible without
* blocking clicks.
*
* Labels are HOVER-GATED. Showing every net at once buried dense boards
* (a 4-digit 7-segment clock draws ~40 wires, so ~40 `0uV` pills covered
* the breadboard). A label is drawn only when the user hovers its wire,
* or hovers a component/board that wire lands on so pointing at a part
* reveals every voltage around it at once. The summary pill always shows.
*/
import { useMemo } from 'react';
import { useElectricalStore } from '../../store/useElectricalStore';
@ -27,7 +33,20 @@ function formatV(v: number): string {
return `${v.toFixed(1)}V`;
}
export function ElectricalOverlay() {
interface ElectricalOverlayProps {
/** Wire currently under the cursor (canvas-level hit test). */
hoveredWireId?: string | null;
/** Component under the cursor — reveals every wire touching it. */
hoveredComponentId?: string | null;
/** Board under the cursor — same, for wires landing on board pins. */
hoveredBoardId?: string | null;
}
export function ElectricalOverlay({
hoveredWireId = null,
hoveredComponentId = null,
hoveredBoardId = null,
}: ElectricalOverlayProps = {}) {
const nodeVoltages = useElectricalStore((s) => s.nodeVoltages);
const converged = useElectricalStore((s) => s.converged);
const error = useElectricalStore((s) => s.error);
@ -77,11 +96,33 @@ export function ElectricalOverlay() {
const samples = netName ? timeWaveforms?.nodes.get(netName) : undefined;
const ac = samples && samples.length > 0 && isAC(samples);
const displayV = ac ? rms(samples!) : netName ? nodeVoltages[netName] : undefined;
return { id: w.id, x: mx, y: my, v: displayV, netName, ac };
return {
id: w.id,
x: mx,
y: my,
v: displayV,
netName,
ac,
// Endpoint owners, so hovering a part can reveal its wires.
a: w.start.componentId,
b: w.end.componentId,
};
})
.filter((l) => l.v !== undefined && l.netName !== '0');
}, [wires, components, boards, nodeVoltages, timeWaveforms]);
// Hover gate kept in its own memo: the netlist rebuild above is expensive
// and must not re-run every time the cursor moves.
const visibleLabels = useMemo(() => {
if (!hoveredWireId && !hoveredComponentId && !hoveredBoardId) return [];
return labels.filter(
(l) =>
l.id === hoveredWireId ||
(hoveredComponentId !== null && (l.a === hoveredComponentId || l.b === hoveredComponentId)) ||
(hoveredBoardId !== null && (l.a === hoveredBoardId || l.b === hoveredBoardId)),
);
}, [labels, hoveredWireId, hoveredComponentId, hoveredBoardId]);
const modeBadge = analysisMode === 'tran' ? 'AC' : 'DC';
const badgeColor = analysisMode === 'tran' ? '#4dd0e1' : '#ffa500';
@ -150,8 +191,8 @@ export function ElectricalOverlay() {
</text>
</g>
{/* Per-wire voltage labels */}
{labels.map((l) => (
{/* Per-wire voltage labels — only for what the cursor is on */}
{visibleLabels.map((l) => (
<g key={l.id} transform={`translate(${l.x}, ${l.y})`}>
<rect
x={-22}

View File

@ -32,6 +32,7 @@ import { autoWireColor, WIRE_KEY_COLORS, expandOrthogonalPoints } from '../../ut
import {
isAutoVerticalPart,
isOverBreadboard,
seatOnDrop,
snapPositionToBreadboard,
} from '../../utils/breadboardSnap';
import {
@ -317,6 +318,10 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
}
}, [interactionRunning, setSelectedWire]);
// Lets the touch handlers (defined in an earlier effect closure) reach the
// drop-time breadboard seating declared further down.
const seatDroppedComponentRef = useRef<(componentId: string) => void>(() => {});
const componentsRef = useRef(components);
componentsRef.current = components;
const boardPositionRef = useRef(boardPosition);
@ -972,6 +977,10 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
}
}
}
} else if (touchId !== '__board__' && !touchId.startsWith('__board__:')) {
// Real touch drag (not a tap) — seat it properly on release,
// same as the mouse path.
seatDroppedComponentRef.current(touchId);
}
recalculateAllWirePositions();
@ -1542,6 +1551,24 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
}
};
/**
* Drop-time breadboard seating. The drag-time magnet only aligns the
* anchor pin, which is how parts ended up HALF-seated (some pins in
* holes, the rest dead in the air). On release we re-solve properly:
* nearest position where every pin is in a free hole, sliding past
* occupied columns if needed. Leaves the part untouched when it is not
* over a board or genuinely does not fit.
*/
const seatDroppedComponent = (componentId: string) => {
const state = useSimulatorStore.getState();
const comp = state.components.find((c) => c.id === componentId);
if (!comp) return;
const placement = seatOnDrop(comp, comp.x, comp.y, state.components);
if (!placement || placement.moved < 0.01) return;
updateComponent(componentId, { x: placement.x, y: placement.y } as any);
};
seatDroppedComponentRef.current = seatDroppedComponent;
const handleCanvasMouseUp = (e: React.MouseEvent) => {
// Finish panning — commit ref value to state so React knows the final pan
if (isPanningRef.current) {
@ -1671,7 +1698,14 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
draggedComponentId &&
!draggedComponentId.startsWith('__board__')
) {
const moved = components.find((c) => c.id === draggedComponentId);
// Seat BEFORE recording the move, so undo restores the pre-drag
// position in one step instead of leaving the part mid-seat.
seatDroppedComponent(draggedComponentId);
// Re-read from the store: `components` is the render-time closure
// and does not include the seating correction just applied.
const moved = useSimulatorStore
.getState()
.components.find((c) => c.id === draggedComponentId);
if (moved && (moved.x !== start.x || moved.y !== start.y)) {
recordMove(draggedComponentId, start, { x: moved.x, y: moved.y });
}
@ -2128,6 +2162,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
x={component.x}
y={component.y}
isSelected={isSelected}
isHovered={isHovered}
onMouseDown={(e) => {
handleComponentMouseDown(component.id, e);
}}
@ -2704,8 +2739,13 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
{registryLoaded && components.map(renderComponent)}
</div>
{/* Electrical simulation overlay (voltages / warnings) */}
<ElectricalOverlay />
{/* Electrical simulation overlay (voltages / warnings).
Voltage pills are hover-gated see ElectricalOverlay. */}
<ElectricalOverlay
hoveredWireId={hoveredWireId}
hoveredComponentId={hoveredComponentId}
hoveredBoardId={hoveredBoardId}
/>
</div>
{/* Wire creation mode banner — visible on both desktop and mobile */}

View File

@ -20,7 +20,7 @@ import {
import {
BREADBOARD_MINI_PINS,
} from '../velxio-elements/breadboard-mini-element';
import { isBreadboard } from './breadboardNets';
import { breadboardGroupKey, isBreadboard } from './breadboardNets';
import { calculatePinPosition } from './pinPositionCalculator';
export interface Hole {
@ -223,3 +223,301 @@ export function isOverBreadboard(
export function isAutoVerticalPart(metadataId: string): boolean {
return metadataId.startsWith('resistor') || metadataId.startsWith('wokwi-resistor');
}
// ── Full seating solver ──────────────────────────────────────────────────
//
// `snapPositionToBreadboard` above is the drag-time magnet: it aligns the
// ANCHOR pin only and assumes the rest follow. That is fine while dragging
// but it is what produces HALF-SEATED parts — one pin finds a hole, the
// others hang off the board and are electrically dead (a 7-segment dropped
// slightly high seats its top pin row in bank-a and its bottom row on
// nothing). The solver below runs on DROP and answers a stricter question:
// where is the nearest position at which EVERY pin lands in a free hole?
//
// It is deliberately geometry-only — pin offsets come from the caller, which
// reads them off the live DOM `pinInfo`. So it works for ANY component that
// can be wired at all, with no per-part whitelist.
/** How far the solver may slide a part from where it was dropped, in holes.
* 6 pitches 58 px enough to skip past an occupied neighbour without the
* part appearing to teleport across the board. */
const SEARCH_RADIUS_HOLES = 6;
const HOLE_PITCH = 9.6;
/**
* Radius used to ASSIGN pins to holes before the fine translation half a
* pitch, so every pin sitting over the grid gets exactly one candidate hole.
* Deliberately looser than SEAT_TOLERANCE: assignment answers "which hole",
* the post-translation check answers "does it fit".
*/
const ASSIGN_RADIUS = HOLE_PITCH / 2;
/** Spatial-hash cell size. One pitch keeps ~1 hole per cell, so a 3x3 cell
* probe is a cheap exact-enough nearest lookup. */
const CELL = HOLE_PITCH;
/** A pin's position relative to the component's x/y (rotation applied). */
export interface PinOffset {
name: string;
dx: number;
dy: number;
}
export interface Placement {
x: number;
y: number;
/** hole name per pin, in the same order as the input offsets. */
holes: { pinName: string; holeName: string }[];
/** Distance from the requested position — 0 when it was already correct. */
moved: number;
}
interface IndexedHoles {
cells: Map<string, Hole[]>;
ox: number;
oy: number;
}
function cellKey(x: number, y: number): string {
return `${Math.floor(x / CELL)},${Math.floor(y / CELL)}`;
}
/** Bucket a breadboard's holes by world position for O(1) nearest lookup. */
function indexHoles(bb: ComponentLike): IndexedHoles | null {
const holes = breadboardHoles(bb.metadataId);
if (!holes) return null;
const ox = bb.x + WRAPPER_INSET;
const oy = bb.y + WRAPPER_INSET;
const cells = new Map<string, Hole[]>();
for (const h of holes) {
const key = cellKey(ox + h.x, oy + h.y);
const bucket = cells.get(key);
if (bucket) bucket.push(h);
else cells.set(key, [h]);
}
return { cells, ox, oy };
}
/** Nearest hole to a world point via the spatial hash, or null past `max`. */
function lookupHole(idx: IndexedHoles, x: number, y: number, max: number): Hole | null {
const cx = Math.floor(x / CELL);
const cy = Math.floor(y / CELL);
let best: Hole | null = null;
let bestDist = max;
for (let gx = cx - 1; gx <= cx + 1; gx++) {
for (let gy = cy - 1; gy <= cy + 1; gy++) {
const bucket = idx.cells.get(`${gx},${gy}`);
if (!bucket) continue;
for (const h of bucket) {
const d = Math.hypot(idx.ox + h.x - x, idx.oy + h.y - y);
if (d <= bestDist) {
bestDist = d;
best = h;
}
}
}
}
return best;
}
/**
* Nearest position at which every pin sits in a free hole of ONE breadboard.
*
* Completeness note: candidates are generated by moving the FIRST pin onto
* each nearby hole. That loses nothing in any fully-seated placement every
* pin is on a hole, the first one included so enumerating the first pin's
* possible holes enumerates every valid placement, at 1/N the cost of
* trying all pins.
*
* @param pins pin offsets from the component origin, rotation already applied
* @param bb the target breadboard
* @param occupied hole names already taken on this board (by other parts)
* @param wantX/wantY where the user dropped it
* @returns the closest valid placement, or null when the part does not fit
* anywhere within the search radius.
*/
export function solvePlacement(
pins: PinOffset[],
bb: ComponentLike,
occupied: ReadonlySet<string>,
wantX: number,
wantY: number,
): Placement | null {
if (pins.length === 0) return null;
const idx = indexHoles(bb);
if (!idx) return null;
const holes = breadboardHoles(bb.metadataId)!;
const anchor = pins[0];
const anchorX = wantX + anchor.dx;
const anchorY = wantY + anchor.dy;
const radius = SEARCH_RADIUS_HOLES * HOLE_PITCH;
// Each hole near the anchor is one HYPOTHESIS about which hole the anchor
// belongs to. It is not the final position: see the fine translation below.
const candidates: { x: number; y: number }[] = [];
for (const h of holes) {
const hx = idx.ox + h.x;
const hy = idx.oy + h.y;
if (Math.hypot(hx - anchorX, hy - anchorY) > radius) continue;
candidates.push({ x: hx - anchor.dx, y: hy - anchor.dy });
}
let best: Placement | null = null;
for (const cand of candidates) {
const assigned: { pinName: string; holeName: string; hx: number; hy: number }[] = [];
const usedHere = new Set<string>();
const groupsHere = new Set<string>();
let ok = true;
let sumDx = 0;
let sumDy = 0;
for (const pin of pins) {
const px = cand.x + pin.dx;
const py = cand.y + pin.dy;
// ASSIGN_RADIUS, not SEAT_TOLERANCE: this pass only decides WHICH hole
// each pin belongs to. Judging fit here would reject any footprint the
// anchor hypothesis cannot satisfy exactly.
const hole = lookupHole(idx, px, py, ASSIGN_RADIUS);
// Every pin must find a hole, and no two pins may share one — a part
// whose own pins collide is a geometry bug, not a valid seating.
if (!hole || occupied.has(hole.name) || usedHere.has(hole.name)) {
ok = false;
break;
}
// Nor may two pins land in the same STRIP: a column strip (and worse, a
// power rail) is a single net, so that silently shorts the part to
// itself. Geometrically legal, electrically ruinous — a 7-segment will
// happily lay its pins across a rail without this.
const group = breadboardGroupKey(bb.metadataId, hole.name);
if (group !== null) {
if (groupsHere.has(group)) {
ok = false;
break;
}
groupsHere.add(group);
}
usedHere.add(hole.name);
const hx = idx.ox + hole.x;
const hy = idx.oy + hole.y;
assigned.push({ pinName: pin.name, holeName: hole.name, hx, hy });
sumDx += hx - px;
sumDy += hy - py;
}
if (!ok) continue;
// Fine translation: the centroid of the pin-to-hole residuals, which
// minimises the sum of squared distances for this assignment.
//
// This is what makes off-pitch footprints seatable at all. A diode spans
// 7.5 pitches, so pinning one leg dead-centre leaves the other 4.8 px
// out — beyond tolerance, rejected. Shift the whole part by 2.4 px and
// BOTH legs sit 2.4 px off centre, comfortably inside tolerance. That is
// what bending the leads does on a real board.
const tx = sumDx / pins.length;
const ty = sumDy / pins.length;
const fx = cand.x + tx;
const fy = cand.y + ty;
// Now judge fit, strictly, against the assignment we just committed to.
// Staying under SEAT_TOLERANCE (< half pitch) keeps every pin's nearest
// hole unambiguous, so computeSeating later resolves the same holes and
// the netlist is unaffected by the offset.
for (let i = 0; i < pins.length; i++) {
const a = assigned[i];
if (Math.hypot(a.hx - (fx + pins[i].dx), a.hy - (fy + pins[i].dy)) > SEAT_TOLERANCE) {
ok = false;
break;
}
}
if (!ok) continue;
// Evaluate every candidate rather than taking the first: the translation
// reorders things, so the nearest hypothesis need not yield the nearest
// final position. Ties keep the earlier candidate (grid emission order).
const moved = Math.hypot(fx - wantX, fy - wantY);
if (!best || moved < best.moved) {
best = {
x: fx,
y: fy,
holes: assigned.map((a) => ({ pinName: a.pinName, holeName: a.holeName })),
moved,
};
}
}
return best;
}
/** Pin offsets of a mounted component, read off the live DOM. Generic: any
* element with a `pinInfo` getter works, which is every wireable part. */
function pinOffsets(comp: ComponentLike, atX: number, atY: number): PinOffset[] | null {
const raw = pinNames(comp.id);
if (!raw) return null;
// Deduplicate: some parts declare repeated pin names (STM32 boards carry
// GND x5, 3V3 x4). calculatePinPosition resolves by name and always finds
// the FIRST match, so duplicates would all report the same coordinates and
// then collide on one hole, failing the part outright. Solving the unique
// names seats the part; the repeats ride along on their own strips.
const names = [...new Set(raw)];
const rotation = Number(comp.properties?.rotation) || 0;
const offsets: PinOffset[] = [];
for (const name of names) {
const p = calculatePinPosition(comp.id, name, atX + WRAPPER_INSET, atY + WRAPPER_INSET, rotation);
if (!p) return null; // partial geometry would seat the part wrong
offsets.push({ name, dx: p.x - atX, dy: p.y - atY });
}
return offsets.length > 0 ? offsets : null;
}
/** Holes already taken on `bb`, by every component except `exceptId`. */
function occupiedHoles(
bb: ComponentLike,
components: ComponentLike[],
exceptId: string,
): Set<string> {
const taken = new Set<string>();
for (const other of components) {
if (other.id === exceptId || other.id === bb.id) continue;
if (isBreadboard(other.metadataId)) continue;
const seats = computeSeating(other, components);
if (!seats) continue;
for (const s of seats) {
if (s.bbId === bb.id) taken.add(s.holeName);
}
}
return taken;
}
/**
* Drop-time seating: given where the user let go of a part, return the
* nearest position where it is FULLY seated on a breadboard and collides
* with nothing. Returns null when it does not belong on a board at all, or
* when no free spot exists nearby callers then leave it where it was
* dropped rather than forcing a wrong seating.
*
* Works for every component with `pinInfo`; there is no part whitelist.
*/
export function seatOnDrop(
comp: ComponentLike,
droppedX: number,
droppedY: number,
components: ComponentLike[],
): Placement | null {
if (isBreadboard(comp.metadataId)) return null;
const bbs = breadboardsOf(components);
if (bbs.length === 0) return null;
const pins = pinOffsets(comp, droppedX, droppedY);
if (!pins) return null;
// Only consider boards the part is actually near — dropping a part on the
// left board must not fling it onto one across the canvas.
let best: Placement | null = null;
for (const bb of bbs) {
const placement = solvePlacement(
pins,
bb,
occupiedHoles(bb, components, comp.id),
droppedX,
droppedY,
);
if (placement && (!best || placement.moved < best.moved)) best = placement;
}
return best;
}