diff --git a/frontend/src/components/analog-ui/ElectricalOverlay.tsx b/frontend/src/components/analog-ui/ElectricalOverlay.tsx index fdff1974..34880509 100644 --- a/frontend/src/components/analog-ui/ElectricalOverlay.tsx +++ b/frontend/src/components/analog-ui/ElectricalOverlay.tsx @@ -1,7 +1,8 @@ /** * Floating SVG overlay that renders voltage labels at wire midpoints. * Only visible when electrical mode is active. Reads voltages from - * `useElectricalStore.nodeVoltages` (updated by the scheduler). + * `useElectricalStore.nodeVoltages` (updated by the scheduler) and maps + * wires to nets via `buildWireNetMap`. * * This is a read-only, zero-interactivity layer — it sits ABOVE the wire * layer but below the component layer so labels remain legible without @@ -10,10 +11,12 @@ import { useMemo } from 'react'; import { useElectricalStore } from '../../store/useElectricalStore'; import { useSimulatorStore } from '../../store/useSimulatorStore'; +import { buildWireNetMap } from '../../simulation/spice/NetlistBuilder'; +import { BOARD_PIN_GROUPS } from '../../simulation/spice/boardPinGroups'; function formatV(v: number): string { const abs = Math.abs(v); - if (abs < 1e-3) return `${(v * 1e6).toFixed(1)}µV`; + if (abs < 1e-3) return `${(v * 1e6).toFixed(0)}uV`; if (abs < 1) return `${(v * 1e3).toFixed(1)}mV`; if (abs < 100) return `${v.toFixed(2)}V`; return `${v.toFixed(1)}V`; @@ -27,28 +30,56 @@ export function ElectricalOverlay() { const solveMs = useElectricalStore((s) => s.lastSolveMs); const wires = useSimulatorStore((s) => s.wires); + const components = useSimulatorStore((s) => s.components); + const boards = useSimulatorStore((s) => s.boards); - // Pre-compute midpoint labels from wires (we don't currently map wire → net, - // but for the initial version we label every wire's midpoint with all the - // voltages the solver emitted and let the user cross-check). const labels = useMemo(() => { - if (mode === 'off') return []; + if (mode === 'off' || Object.keys(nodeVoltages).length === 0) return []; + + const boardsForSpice = boards.map((b) => { + const pg = BOARD_PIN_GROUPS[b.boardKind] ?? BOARD_PIN_GROUPS.default; + return { + id: b.id, + vcc: pg.vcc, + groundPinNames: pg.gnd, + vccPinNames: pg.vcc_pins, + pins: {}, + }; + }); + const compsForSpice = components.map((c) => ({ + id: c.id, + metadataId: c.metadataId, + properties: c.properties ?? {}, + })); + const wiresForSpice = wires.map((w) => ({ + id: w.id, + start: { componentId: w.start.componentId, pinName: w.start.pinName }, + end: { componentId: w.end.componentId, pinName: w.end.pinName }, + })); + + const wireNetMap = buildWireNetMap({ + components: compsForSpice, + wires: wiresForSpice, + boards: boardsForSpice, + }); + return wires.map((w) => { const mx = (w.start.x + w.end.x) / 2; const my = (w.start.y + w.end.y) / 2; - return { id: w.id, x: mx, y: my }; - }); - }, [wires, mode]); + const netName = wireNetMap.get(w.id); + const v = netName ? nodeVoltages[netName] : undefined; + return { id: w.id, x: mx, y: my, v, netName }; + }).filter((l) => l.v !== undefined && l.netName !== '0'); + }, [wires, components, boards, mode, nodeVoltages]); if (mode === 'off') return null; - // A summary pill in the top-left of the canvas const summaryLines: string[] = []; - if (error) summaryLines.push(`⚠ ${error}`); - else if (!converged) summaryLines.push('⚠ did not converge'); + if (error) summaryLines.push(`Warning: ${error}`); + else if (!converged) summaryLines.push('Warning: did not converge'); else { const n = Object.keys(nodeVoltages).length; - summaryLines.push(`${n} nets • solved in ${solveMs.toFixed(0)} ms`); + summaryLines.push(`${n} nets | ${solveMs.toFixed(0)} ms`); } return ( @@ -70,20 +101,39 @@ export function ElectricalOverlay() { y={0} rx={4} ry={4} - width={260} - height={28} + width={220} + height={24} fill="rgba(26, 26, 26, 0.85)" stroke={error ? '#ff6666' : '#ffa500'} /> - - {summaryLines.join(' · ')} + + SPICE {summaryLines.join(' ')} - {/* Per-wire midpoint markers (minimal — only a small dot so we don't - clutter until we have wire→net mapping in Phase 8.3.1) */} + {/* Per-wire voltage labels */} {labels.map((l) => ( - + + + + {formatV(l.v!)} + + ))} ); diff --git a/frontend/src/simulation/parts/BasicParts.ts b/frontend/src/simulation/parts/BasicParts.ts index e50bce7b..8f73be06 100644 --- a/frontend/src/simulation/parts/BasicParts.ts +++ b/frontend/src/simulation/parts/BasicParts.ts @@ -132,7 +132,7 @@ PartSimulationRegistry.register('dip-switch-8', { * LED stays off regardless of the anode state. */ PartSimulationRegistry.register('led', { - attachEvents: (element, simulator, getArduinoPinHelper) => { + attachEvents: (element, simulator, getArduinoPinHelper, componentId) => { const pinManager = (simulator as any).pinManager; if (!pinManager) return () => {}; @@ -141,23 +141,36 @@ PartSimulationRegistry.register('led', { let anodeHigh = false; let cathodeLow = false; - const update = () => { el.value = anodeHigh && cathodeLow; }; + const update = () => { + // When electrical mode is active, use real branch current for + // analog brightness (0..1) instead of boolean on/off. The SPICE + // mapper emits a diode card `D_` whose branch current + // is stored in `branchCurrents`. + try { + const { useElectricalStore } = require('../../store/useElectricalStore'); + const { mode, branchCurrents } = useElectricalStore.getState(); + if (mode !== 'off') { + const iKey = `d_${componentId}`; + const current = Math.abs(branchCurrents[iKey] ?? 0); + el.value = current > 1e-6; + el.brightness = Math.min(1, current / 0.020); // 20 mA = full brightness + return; + } + } catch { /* store not available — fall through to digital mode */ } + el.value = anodeHigh && cathodeLow; + }; // Cathode pin: -1 means wired to GND (always LOW), >=0 means GPIO const cathodePin = getArduinoPinHelper('C'); if (cathodePin === -1) { - // Wired to GND — always LOW cathodeLow = true; } else if (cathodePin !== null && cathodePin >= 0) { - // Wired to a GPIO — track its state unsubs.push(pinManager.onPinChange(cathodePin, (_: number, state: boolean) => { - cathodeLow = !state; // cathode needs to be LOW for current to flow + cathodeLow = !state; update(); })); } - // cathodePin === null → not wired → cathodeLow stays false → LED off - // Anode pin const anodePin = getArduinoPinHelper('A'); if (anodePin !== null && anodePin >= 0) { unsubs.push(pinManager.onPinChange(anodePin, (_: number, state: boolean) => { @@ -166,6 +179,18 @@ PartSimulationRegistry.register('led', { })); } + // Also subscribe to electrical store changes to update brightness + // whenever the SPICE solver delivers a new result. + try { + const { useElectricalStore } = require('../../store/useElectricalStore'); + const unsubElectrical = useElectricalStore.subscribe( + (state: { branchCurrents: Record }, prev: { branchCurrents: Record }) => { + if (state.branchCurrents !== prev.branchCurrents) update(); + }, + ); + unsubs.push(unsubElectrical); + } catch { /* store not available */ } + return () => { unsubs.forEach(u => u()); }; }, }); diff --git a/frontend/src/simulation/spice/NetlistBuilder.ts b/frontend/src/simulation/spice/NetlistBuilder.ts index 9441bf4e..20880010 100644 --- a/frontend/src/simulation/spice/NetlistBuilder.ts +++ b/frontend/src/simulation/spice/NetlistBuilder.ts @@ -219,5 +219,48 @@ function detectFloatingNets(netNames: Map, cards: string[]): Set return floating; } +/** + * Build a wireId → netName map using the same Union-Find logic as buildNetlist. + * Lightweight (no SPICE call) — suitable for the overlay to look up voltages. + */ +export function buildWireNetMap( + input: Pick, +): Map { + const { wires, boards, components } = input; + const uf = new UnionFind(); + const pin = (cId: string, pName: string) => `${cId}:${pName}`; + + for (const w of wires) { + const a = pin(w.start.componentId, w.start.pinName); + const b = pin(w.end.componentId, w.end.pinName); + uf.add(a); + uf.add(b); + uf.union(a, b); + } + + for (const board of boards) { + for (const pName of board.groundPinNames ?? []) uf.setCanonical(pin(board.id, pName), '0'); + for (const pName of board.vccPinNames ?? []) uf.setCanonical(pin(board.id, pName), 'vcc_rail'); + } + for (const comp of components) { + if (comp.metadataId.startsWith('instr-')) continue; + for (const pName of pinsReferencedByWires(comp.id, wires)) { + if (GROUND_PIN_RE.test(pName)) uf.setCanonical(pin(comp.id, pName), '0'); + else if (VCC_PIN_RE.test(pName)) uf.setCanonical(pin(comp.id, pName), 'vcc_rail'); + } + } + + const netNames = assignDeterministicNetNames(uf); + const result = new Map(); + for (const w of wires) { + const key = pin(w.start.componentId, w.start.pinName); + if (uf.has(key)) { + const netName = netNames.get(uf.find(key)); + if (netName) result.set(w.id, netName); + } + } + return result; +} + /** Re-export types for callers. */ export type { BuildNetlistInput, ComponentForSpice, BoardForSpice, WireForSpice } from './types'; diff --git a/frontend/src/simulation/spice/subscribeToStore.ts b/frontend/src/simulation/spice/subscribeToStore.ts index 6c1af82f..2c41d0a0 100644 --- a/frontend/src/simulation/spice/subscribeToStore.ts +++ b/frontend/src/simulation/spice/subscribeToStore.ts @@ -14,21 +14,52 @@ import type { PinSourceState } from './types'; import type { BoardKind } from '../../types/board'; // Which Arduino-style pin name maps to which ADC channel, per board. -// (Keep narrow for Phase 8.3 — extend as boards are added.) +// Used to inject SPICE-solved voltages back into the MCU's ADC peripheral. +function adcRange(prefix: string, start: number, count: number) { + return Array.from({ length: count }, (_, i) => ({ + pinName: `${prefix}${start + i}`, + channel: i, + })); +} + +const ADC_6CH = adcRange('A', 0, 6); // A0..A5 +const ADC_8CH = adcRange('A', 0, 8); // A0..A7 +const ADC_16CH = adcRange('A', 0, 16); // A0..A15 + const ADC_PIN_MAP: Partial>> = { - 'arduino-uno': [ - { pinName: 'A0', channel: 0 }, - { pinName: 'A1', channel: 1 }, - { pinName: 'A2', channel: 2 }, - { pinName: 'A3', channel: 3 }, - { pinName: 'A4', channel: 4 }, - { pinName: 'A5', channel: 5 }, + // AVR boards + 'arduino-uno': ADC_6CH, + 'arduino-nano': ADC_8CH, + 'arduino-mega': ADC_16CH, + 'attiny85': adcRange('A', 0, 4), // A0..A3 (PB2-PB5) + + // RP2040 boards — 4 ADC channels (GP26-GP29) + 'raspberry-pi-pico': [ + { pinName: 'GP26', channel: 0 }, { pinName: 'GP27', channel: 1 }, + { pinName: 'GP28', channel: 2 }, { pinName: 'GP29', channel: 3 }, ], - 'arduino-nano': [ - { pinName: 'A0', channel: 0 }, { pinName: 'A1', channel: 1 }, { pinName: 'A2', channel: 2 }, - { pinName: 'A3', channel: 3 }, { pinName: 'A4', channel: 4 }, { pinName: 'A5', channel: 5 }, - { pinName: 'A6', channel: 6 }, { pinName: 'A7', channel: 7 }, + 'pi-pico-w': [ + { pinName: 'GP26', channel: 0 }, { pinName: 'GP27', channel: 1 }, + { pinName: 'GP28', channel: 2 }, { pinName: 'GP29', channel: 3 }, ], + + // ESP32 variants — most GPIOs can be ADC but the common ones are: + // ADC1: GPIO 32-39 (channels 0-7), ADC2: GPIO 0,2,4,12-15,25-27 + // Simplified to the 8 most-used pins (GPIO 32-39 = ADC1) + 'esp32': adcRange('GPIO', 32, 8), + 'esp32-devkit-c-v4': adcRange('GPIO', 32, 8), + 'esp32-cam': adcRange('GPIO', 32, 8), + 'wemos-lolin32-lite': adcRange('GPIO', 32, 8), + + // ESP32-S3 — ADC1 channels on GPIO 1-10, ADC2 on GPIO 11-20 + 'esp32-s3': adcRange('GPIO', 1, 10), + 'xiao-esp32-s3': adcRange('GPIO', 1, 10), + 'arduino-nano-esp32': adcRange('A', 0, 8), + + // ESP32-C3 — ADC1 channels on GPIO 0-4, ADC2 on GPIO 5 + 'esp32-c3': adcRange('GPIO', 0, 6), + 'xiao-esp32-c3': adcRange('GPIO', 0, 6), + 'aitewinrobot-esp32c3-supermini': adcRange('GPIO', 0, 6), }; export function wireElectricalSolver(): () => void { diff --git a/frontend/src/store/useElectricalStore.ts b/frontend/src/store/useElectricalStore.ts index d6dfec92..ca35e319 100644 --- a/frontend/src/store/useElectricalStore.ts +++ b/frontend/src/store/useElectricalStore.ts @@ -58,7 +58,7 @@ export const useElectricalStore = create((set, get) => { }); return { - mode: 'off', + mode: ELECTRICAL_SIM_ENABLED ? 'spice' : 'off', nodeVoltages: {}, branchCurrents: {}, converged: true,