diff --git a/frontend/src/__tests__/breadboard-seating.test.ts b/frontend/src/__tests__/breadboard-seating.test.ts index a10bee32..58b48e7d 100644 --- a/frontend/src/__tests__/breadboard-seating.test.ts +++ b/frontend/src/__tests__/breadboard-seating.test.ts @@ -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); + }); +}); diff --git a/frontend/src/__tests__/breadboard-snap.test.ts b/frontend/src/__tests__/breadboard-snap.test.ts index ef6fa85a..9105c131 100644 --- a/frontend/src/__tests__/breadboard-snap.test.ts +++ b/frontend/src/__tests__/breadboard-snap.test.ts @@ -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 = { + 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); + } + }); +}); diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index 8e0a6f1d..8bd28d42 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -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 = ({ x = 0, y = 0, isSelected = false, + isHovered = false, onMouseDown, onDoubleClick, onMouseEnter, @@ -706,7 +708,13 @@ export const DynamicComponent: React.FC = ({ )} - {/* 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. */}
= ({ alignItems: 'center', justifyContent: 'center', gap: '4px', + opacity: isHovered || isSelected ? 1 : 0, + transition: 'opacity 120ms ease-out', }} > {properties.pin !== undefined ? `Pin ${properties.pin}` : metadata.name} diff --git a/frontend/src/components/analog-ui/ElectricalOverlay.tsx b/frontend/src/components/analog-ui/ElectricalOverlay.tsx index a1e5509b..8b50b68b 100644 --- a/frontend/src/components/analog-ui/ElectricalOverlay.tsx +++ b/frontend/src/components/analog-ui/ElectricalOverlay.tsx @@ -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() { - {/* Per-wire voltage labels */} - {labels.map((l) => ( + {/* Per-wire voltage labels — only for what the cursor is on */} + {visibleLabels.map((l) => ( { } }, [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)}
- {/* Electrical simulation overlay (voltages / warnings) */} - + {/* Electrical simulation overlay (voltages / warnings). + Voltage pills are hover-gated — see ElectricalOverlay. */} + {/* Wire creation mode banner — visible on both desktop and mobile */} diff --git a/frontend/src/utils/breadboardSnap.ts b/frontend/src/utils/breadboardSnap.ts index b294046b..73ec1330 100644 --- a/frontend/src/utils/breadboardSnap.ts +++ b/frontend/src/utils/breadboardSnap.ts @@ -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; + 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(); + 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, + 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(); + const groupsHere = new Set(); + 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 { + const taken = new Set(); + 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; +}