diff --git a/frontend/public/components-metadata.json b/frontend/public/components-metadata.json index eafc2b0e..ab88766c 100644 --- a/frontend/public/components-metadata.json +++ b/frontend/public/components-metadata.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "generatedAt": "2026-04-21T04:09:14.073Z", + "generatedAt": "2026-04-21T13:02:40.071Z", "components": [ { "thumbnail": "\n \n \n DIODE-1N4007\n \n ", diff --git a/frontend/src/__tests__/spice-rectifier-live-bootstrap.test.ts b/frontend/src/__tests__/spice-rectifier-live-bootstrap.test.ts new file mode 100644 index 00000000..c777f1b1 --- /dev/null +++ b/frontend/src/__tests__/spice-rectifier-live-bootstrap.test.ts @@ -0,0 +1,117 @@ +/** + * Half-Wave Rectifier — wireElectricalSolver live bootstrap. + * + * Extracted from `spice-rectifier-live-repro.test.ts` so this describe runs + * in its own Vitest worker. The ngspice-WASM engine is a singleton that + * holds global heap state; when L1/L3 solves run before this test in the + * same process, realloc explodes with "Not enough memory or heap corruption" + * and the electrical store falls back to `op` analysis. Isolating the live + * bootstrap into its own file gives it a pristine WASM instance. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +function rectifierSnapshot() { + return { + components: [ + { + id: 'sg1', + metadataId: 'signal-generator', + properties: { waveform: 'sine', frequency: 50, amplitude: 5, offset: 0 }, + }, + { id: 'd1', metadataId: 'diode-1n4007', properties: {} }, + { id: 'rl', metadataId: 'resistor', properties: { value: '1000' } }, + ], + wires: [ + { id: 'w1', start: { componentId: 'sg1', pinName: 'SIG' }, end: { componentId: 'd1', pinName: 'A' } }, + { id: 'w2', start: { componentId: 'd1', pinName: 'C' }, end: { componentId: 'rl', pinName: '1' } }, + { id: 'w3', start: { componentId: 'rl', pinName: '2' }, end: { componentId: 'arduino-uno', pinName: 'GND' } }, + { id: 'w4', start: { componentId: 'sg1', pinName: 'GND' }, end: { componentId: 'arduino-uno', pinName: 'GND' } }, + { id: 'w5', start: { componentId: 'd1', pinName: 'C' }, end: { componentId: 'arduino-uno', pinName: 'A0' } }, + ], + boards: [{ + id: 'arduino-uno', + boardKind: 'arduino-uno' as const, + pinStates: {}, + }], + }; +} + +describe('Half-Wave Rectifier — wireElectricalSolver live bootstrap', () => { + let rafCallbacks: Array<() => void>; + + beforeEach(() => { + rafCallbacks = []; + vi.stubGlobal('requestAnimationFrame', (cb: () => void) => { + rafCallbacks.push(cb); + return rafCallbacks.length; + }); + vi.stubGlobal('cancelAnimationFrame', () => {}); + vi.stubGlobal('window', globalThis); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + async function flushRaf() { + while (rafCallbacks.length > 0) { + const cbs = rafCallbacks.splice(0, rafCallbacks.length); + for (const cb of cbs) { + try { cb(); } catch (e) { console.warn('RAF cb threw', e); } + } + break; + } + } + + it('invokes wireElectricalSolver against live stores populated by loadExample', async () => { + const { useSimulatorStore } = await import('../store/useSimulatorStore'); + const { useElectricalStore } = await import('../store/useElectricalStore'); + const { wireElectricalSolver } = await import('../simulation/spice/subscribeToStore'); + + const snap = rectifierSnapshot(); + const store = useSimulatorStore.getState(); + + store.setComponents( + snap.components.map((c) => ({ + id: c.id, + metadataId: c.metadataId, + x: 0, + y: 0, + properties: c.properties, + })), + ); + store.setWires( + snap.wires.map((w) => ({ + id: w.id, + start: { componentId: w.start.componentId, pinName: w.start.pinName, x: 0, y: 0 }, + end: { componentId: w.end.componentId, pinName: w.end.pinName, x: 0, y: 0 }, + color: '#ffaa00', + waypoints: [], + })), + ); + + const unsub = wireElectricalSolver(); + + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const es = useElectricalStore.getState(); + if (es.timeWaveforms) break; + await new Promise((r) => setTimeout(r, 50)); + } + + const finalES = useElectricalStore.getState(); + + const sim = (await import('../store/useSimulatorStore')).getBoardSimulator('arduino-uno'); + if (sim) { + for (let i = 0; i < 10; i++) await flushRaf(); + } + + unsub(); + + expect(finalES.converged).toBe(true); + expect(finalES.analysisMode).toBe('tran'); + expect(finalES.timeWaveforms).toBeDefined(); + expect(finalES.pinNetMap.size).toBeGreaterThan(0); + expect(finalES.pinNetMap.has('arduino-uno:A0')).toBe(true); + }, 45_000); +}); diff --git a/frontend/src/__tests__/spice-rectifier-live-repro.test.ts b/frontend/src/__tests__/spice-rectifier-live-repro.test.ts index 32355b46..43d71d5f 100644 --- a/frontend/src/__tests__/spice-rectifier-live-repro.test.ts +++ b/frontend/src/__tests__/spice-rectifier-live-repro.test.ts @@ -135,13 +135,17 @@ describe('Half-Wave Rectifier — layer-by-layer reproduction', () => { expect(result.timeWaveforms!.nodes.has(a0Net)).toBe(true); // ── L5 ──────────────────────────────────────────────────────────────── + // `rtw.time[last]` is the `.tran` STOP time (~80 ms — four periods of the + // 50 Hz signal), not the signal period. Sample 8 phases across one real + // signal period (1/50 Hz = 20 ms); anything else aliases against the sine. console.log('\n=== L5 interpolateAt sanity at 8 phases ==='); const rtw = result.timeWaveforms!; const rSamples = rtw.nodes.get(a0Net)!; - const periodS = rtw.time[rtw.time.length - 1]; + const signalFreqHz = 50; + const signalPeriodS = 1 / signalFreqHz; const phases: Array<{ t: number; v: number }> = []; for (const q of [0, 1, 2, 3, 4, 5, 6, 7]) { - const t = (q / 8) * periodS; + const t = (q / 8) * signalPeriodS; const v = interpolateAt(rtw.time, rSamples, t); phases.push({ t, v }); console.log(` t = ${(t * 1000).toFixed(2)} ms → V(A0) = ${v.toFixed(3)} V`); @@ -180,7 +184,7 @@ describe('Half-Wave Rectifier — layer-by-layer reproduction', () => { const adchSeries: number[] = []; for (let i = 0; i < STEPS; i++) { const simT = freshAvr.cpu.cycles / CPU_HZ; - const t = simT % periodS; + const t = simT % signalPeriodS; const v = interpolateAt(rtw.time, rSamples, t); setAdcVoltage(freshMock, 14, Math.max(0, Math.min(5, v))); freshAvr.runCycles(STEP_CYCLES); @@ -200,131 +204,12 @@ describe('Half-Wave Rectifier — layer-by-layer reproduction', () => { }, 60_000); }); -// ── L8: live reproduction through the real wireElectricalSolver() ─────── -// This imports the actual store and solver bootstrap, exactly as EditorPage -// does. If the app-level timing / subscription bug exists, this test will -// reproduce it here. We stub `requestAnimationFrame` so we can drive replay -// frames deterministically. -describe('Half-Wave Rectifier — wireElectricalSolver live bootstrap', () => { - let rafCallbacks: Array<() => void>; - - beforeEach(() => { - rafCallbacks = []; - vi.stubGlobal('requestAnimationFrame', (cb: () => void) => { - rafCallbacks.push(cb); - return rafCallbacks.length; - }); - vi.stubGlobal('cancelAnimationFrame', () => {}); - // wireElectricalSolver installs window.__spiceDebug — give it a target - vi.stubGlobal('window', globalThis); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - async function flushRaf() { - // Pop and run any queued callbacks; each one may queue the next frame. - while (rafCallbacks.length > 0) { - const cbs = rafCallbacks.splice(0, rafCallbacks.length); - for (const cb of cbs) { - try { cb(); } catch (e) { console.warn('RAF cb threw', e); } - } - // One trip through the queue per flushRaf call — caller iterates. - break; - } - } - - it('invokes wireElectricalSolver against live stores populated by loadExample', async () => { - const { useSimulatorStore } = await import('../store/useSimulatorStore'); - const { useElectricalStore } = await import('../store/useElectricalStore'); - const { wireElectricalSolver } = await import('../simulation/spice/subscribeToStore'); - - // Replicate what loadExample() does: setComponents + setWires on the real store. - const snap = rectifierSnapshot(); - console.log('\n=== L8 preparing live store ==='); - const store = useSimulatorStore.getState(); - console.log('initial boards:', store.boards.map((b) => ({ id: b.id, kind: b.boardKind }))); - - store.setComponents( - snap.components.map((c) => ({ - id: c.id, - metadataId: c.metadataId, - x: 0, - y: 0, - properties: c.properties, - })), - ); - store.setWires( - snap.wires.map((w) => ({ - id: w.id, - start: { componentId: w.start.componentId, pinName: w.start.pinName, x: 0, y: 0 }, - end: { componentId: w.end.componentId, pinName: w.end.pinName, x: 0, y: 0 }, - color: '#ffaa00', - waypoints: [], - })), - ); - console.log('components set:', useSimulatorStore.getState().components.map((c) => c.id)); - console.log('wires set:', useSimulatorStore.getState().wires.length); - - // Now mount wireElectricalSolver — exactly as EditorPage useEffect does. - console.log('\n=== L8 calling wireElectricalSolver() ==='); - const unsub = wireElectricalSolver(); - - // Give the debounced solve (50ms) + async ngspice time to complete. - // Poll the store until timeWaveforms appears or we time out. - const deadline = Date.now() + 30_000; - while (Date.now() < deadline) { - const es = useElectricalStore.getState(); - if (es.timeWaveforms) break; - await new Promise((r) => setTimeout(r, 50)); - } - - const finalES = useElectricalStore.getState(); - console.log('\n=== L8 electrical store after solve ==='); - console.log('analysisMode:', finalES.analysisMode); - console.log('converged:', finalES.converged, 'error:', finalES.error); - console.log('pinNetMap size:', finalES.pinNetMap.size, 'entries:', [...finalES.pinNetMap.entries()].slice(0, 16)); - console.log('hasTimeWaveforms:', !!finalES.timeWaveforms); - if (finalES.timeWaveforms) { - console.log('timeWaveforms.nodes keys:', [...finalES.timeWaveforms.nodes.keys()]); - const a0Net = finalES.pinNetMap.get('arduino-uno:A0'); - console.log('arduino-uno:A0 → net:', a0Net); - if (a0Net) { - const samples = finalES.timeWaveforms.nodes.get(a0Net); - if (samples) { - console.log(`samples @ A0 net: peak=${Math.max(...samples).toFixed(3)} V, count=${samples.length}`); - } else { - console.log('!!! a0Net has no samples in timeWaveforms.nodes !!!'); - } - } else { - console.log('!!! pinNetMap does not contain arduino-uno:A0 !!!'); - } - } - console.log('RAF queued frames:', rafCallbacks.length); - - // Drain some RAF frames to confirm the replay actually writes into AVRADC. - const sim = (await import('../store/useSimulatorStore')).getBoardSimulator('arduino-uno'); - console.log('live simulator:', sim ? 'present' : 'absent'); - if (sim) { - const adc = (sim as unknown as { getADC: () => { channelValues: Float32Array | number[] } }).getADC(); - console.log('ADC channelValues BEFORE RAF:', adc ? [...adc.channelValues].slice(0, 6) : 'no adc'); - // Drive 10 RAF frames simulating ~160ms of real time - for (let i = 0; i < 10; i++) await flushRaf(); - console.log('RAF callbacks after drain:', rafCallbacks.length); - console.log('ADC channelValues AFTER RAF:', adc ? [...adc.channelValues].slice(0, 6) : 'no adc'); - } - - unsub(); - - // Assertions — the pipeline should have produced a valid waveform. - expect(finalES.converged).toBe(true); - expect(finalES.analysisMode).toBe('tran'); - expect(finalES.timeWaveforms).toBeDefined(); - expect(finalES.pinNetMap.size).toBeGreaterThan(0); - expect(finalES.pinNetMap.has('arduino-uno:A0')).toBe(true); - }, 45_000); -}); +// ── L8 extracted to `spice-rectifier-live-bootstrap.test.ts` ───────────── +// The live-bootstrap block ran against the real singleton ngspice-WASM +// engine. When L1/L3 solved first in the same process, realloc exploded +// with "Not enough memory or heap corruption" and the electrical store +// fell back to `op`. Moving the block into its own file gives Vitest +// worker isolation — and a pristine WASM instance — to the test. // ── L9: per-read onADCRead hook (RAF replay removed in Phase 1) ────────── // The previous version of this block flushed RAF frames and expected diff --git a/frontend/src/__tests__/spice-relay-integration.test.ts b/frontend/src/__tests__/spice-relay-integration.test.ts new file mode 100644 index 00000000..cc61390b --- /dev/null +++ b/frontend/src/__tests__/spice-relay-integration.test.ts @@ -0,0 +1,71 @@ +/** + * Regression: Relay-Controlled LED example (examples-circuits.ts + * `relay-led-switch`). Covers two historical bugs: + * 1) relay mapper returned null when NC was unwired → no relay cards + * emitted at all, LED stuck off regardless of coil drive. + * 2) coil was R || L instead of R — L — in series; at .op the L shorted + * the R, V(COIL+) ≡ V(COIL-), switch control was 0, NO never closed. + * + * Circuit: + * pin 9 → Rb(1k) → Q1(2N2222).B + * 5V → relay.COIL+ ; relay.COIL- → Q1.C ; Q1.E → GND + * 5V → relay.NO ; relay.COM → Rl(220) → LED.A ; LED.C → GND + * relay.NC left unconnected (normal pattern) + */ +import { describe, it, expect } from 'vitest'; +import { buildNetlist } from '../simulation/spice/NetlistBuilder'; +import { runNetlist } from '../simulation/spice/SpiceEngine'; +import type { BuildNetlistInput, PinSourceState } from '../simulation/spice/types'; + +function relayWires() { + return [ + { id: 'w1', start: { componentId: 'arduino-uno', pinName: '9' }, end: { componentId: 'rb', pinName: '1' } }, + { id: 'w2', start: { componentId: 'rb', pinName: '2' }, end: { componentId: 'q1', pinName: 'B' } }, + { id: 'w3', start: { componentId: 'arduino-uno', pinName: '5V' }, end: { componentId: 'rly', pinName: 'COIL+' } }, + { id: 'w4', start: { componentId: 'rly', pinName: 'COIL-' }, end: { componentId: 'q1', pinName: 'C' } }, + { id: 'w5', start: { componentId: 'q1', pinName: 'E' }, end: { componentId: 'arduino-uno', pinName: 'GND' } }, + { id: 'w6', start: { componentId: 'arduino-uno', pinName: '5V' }, end: { componentId: 'rly', pinName: 'NO' } }, + { id: 'w7', start: { componentId: 'rly', pinName: 'COM' }, end: { componentId: 'rl', pinName: '1' } }, + { id: 'w8', start: { componentId: 'rl', pinName: '2' }, end: { componentId: 'led1', pinName: 'A' } }, + { id: 'w9', start: { componentId: 'led1', pinName: 'C' }, end: { componentId: 'arduino-uno', pinName: 'GND' } }, + ]; +} + +function relayInput(pinStates: Record): BuildNetlistInput { + return { + components: [ + { id: 'rb', metadataId: 'resistor', properties: { value: '1000' } }, + { id: 'q1', metadataId: 'bjt-2n2222', properties: {} }, + { id: 'rly', metadataId: 'relay', properties: { coil_voltage: 5 } }, + { id: 'rl', metadataId: 'resistor', properties: { value: '220' } }, + { id: 'led1', metadataId: 'led', properties: { color: 'red' } }, + ], + wires: relayWires(), + boards: [{ + id: 'arduino-uno', + vcc: 5, + pins: pinStates, + groundPinNames: ['GND'], + vccPinNames: ['5V'], + }], + analysis: { kind: 'op' }, + }; +} + +describe('Relay-Controlled LED — SPICE integration', () => { + it('coil energised when pin 9 HIGH → NO closes → LED lights', { timeout: 60_000 }, async () => { + const { netlist } = buildNetlist(relayInput({ '9': { type: 'digital', v: 5 } })); + expect(netlist).toMatch(/R_rly_coil\b/); + expect(netlist).toMatch(/S_rly_no\b/); + const cooked = await runNetlist(netlist); + const iLed = Math.abs(cooked.dcValue('i(v_led1_sense)')); + expect(iLed).toBeGreaterThan(5e-3); + }); + + it('coil idle when pin 9 LOW → NO open → LED dark', { timeout: 60_000 }, async () => { + const { netlist } = buildNetlist(relayInput({})); + const cooked = await runNetlist(netlist); + const iLed = Math.abs(cooked.dcValue('i(v_led1_sense)')); + expect(iLed).toBeLessThan(1e-6); + }); +}); diff --git a/frontend/src/components/ComponentPickerModal.css b/frontend/src/components/ComponentPickerModal.css index d8c70664..2c4823a9 100644 --- a/frontend/src/components/ComponentPickerModal.css +++ b/frontend/src/components/ComponentPickerModal.css @@ -176,6 +176,40 @@ align-content: start; } +/* Single scroll container wrapping the boards row + components grid in the + "All Components" view, so the modal shows only one scrollbar instead of + two stacked ones. */ +.components-scroll { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.components-scroll::-webkit-scrollbar { + width: 8px; +} + +.components-scroll::-webkit-scrollbar-track { + background: #f0f0f0; + border-radius: 4px; +} + +.components-scroll::-webkit-scrollbar-thumb { + background: #ccc; + border-radius: 4px; +} + +.components-scroll::-webkit-scrollbar-thumb:hover { + background: #999; +} + +/* When a grid lives inside .components-scroll it should NOT scroll on its + own — the wrapper handles all scrolling. */ +.components-grid--inline { + flex: none; + overflow: visible; +} + .loading-state { grid-column: 1 / -1; text-align: center; @@ -412,11 +446,13 @@ color: #888; } -.components-grid::-webkit-scrollbar-track { +.components-grid::-webkit-scrollbar-track, +.components-scroll::-webkit-scrollbar-track { background: #3d3d3d; } -.components-grid::-webkit-scrollbar-thumb { +.components-grid::-webkit-scrollbar-thumb, +.components-scroll::-webkit-scrollbar-thumb { background: #555; } diff --git a/frontend/src/components/ComponentPickerModal.tsx b/frontend/src/components/ComponentPickerModal.tsx index 706cc0c0..c68918ae 100644 --- a/frontend/src/components/ComponentPickerModal.tsx +++ b/frontend/src/components/ComponentPickerModal.tsx @@ -185,52 +185,55 @@ export const ComponentPickerModal: React.FC = ({ ) : ( <> - {/* Boards row in "All Components" view */} - {selectedCategory === 'all' && onSelectBoard && ( -
- {ALL_BOARDS.filter((k) => - !searchQuery || BOARD_KIND_LABELS[k].toLowerCase().includes(searchQuery.toLowerCase()) - ).map((kind) => ( - { onSelectBoard(kind); onClose(); }} - /> - ))} -
- )} - - {/* Components Grid */} -
- {isLoading ? ( -
-
-

Loading components...

+ {/* Single scrollable area wrapping both the boards row (only in + "All Components" view) and the components grid, so the modal + shows ONE scrollbar instead of two stacked ones. */} +
+ {selectedCategory === 'all' && onSelectBoard && ( +
+ {ALL_BOARDS.filter((k) => + !searchQuery || BOARD_KIND_LABELS[k].toLowerCase().includes(searchQuery.toLowerCase()) + ).map((kind) => ( + { onSelectBoard(kind); onClose(); }} + /> + ))}
- ) : filteredComponents.length === 0 ? ( -
-

No components found

- {searchQuery && ( - - )} -
- ) : ( - filteredComponents.map((component) => ( - onSelectComponent(component)} - /> - )) )} + +
+ {isLoading ? ( +
+
+

Loading components...

+
+ ) : filteredComponents.length === 0 ? ( +
+

No components found

+ {searchQuery && ( + + )} +
+ ) : ( + filteredComponents.map((component) => ( + onSelectComponent(component)} + /> + )) + )} +
{/* Footer Info */} diff --git a/frontend/src/components/components-wokwi/TransistorElements.ts b/frontend/src/components/components-wokwi/TransistorElements.ts index 4ac0a1ed..3cacb8fc 100644 --- a/frontend/src/components/components-wokwi/TransistorElements.ts +++ b/frontend/src/components/components-wokwi/TransistorElements.ts @@ -23,10 +23,12 @@ */ // ─── Shared colours ─────────────────────────────────────────────────────────── -const FILL = '#f4f0e8'; -const STROKE = '#2a2a2a'; -const LEAD = '#555555'; -const LABEL = '#333333'; +// Tuned for the dark (#1a1a1a) simulator canvas — symbols must read as +// light schematic strokes, not dark-on-dark. +const STROKE = '#e6e6e6'; // primary symbol strokes (base bar, channel) +const LEAD = '#b8b8b8'; // pin leads +const LABEL = '#d0d0d0'; // pin letters / part number +const BODY = '#7a7a7a'; // optional body-circle outline const STYLE = ':host{display:inline-block;line-height:0}'; function threePinInfo(pins: Array<{ name: string; x: number; y: number; number: number }>) { @@ -42,39 +44,36 @@ function threePinInfo(pins: Array<{ name: string; x: number; y: number; number: // Arrow on emitter lead segment (46..56, y=50..56) — NPN points outward, PNP inward. function bjtSvg(arrowDir: 'npn' | 'pnp', text: string): string { - // Emitter arrow: two line pairs forming an arrowhead near the emitter segment + // Symmetric triangle arrowhead on the horizontal emitter line at y=48. + // NPN points OUT (rightward, away from base); PNP points IN (leftward, toward base). const arrowhead = arrowDir === 'npn' - ? ` - - ` - : ` - - `; + ? `` + : ``; return ` - - - + + + + + - + - + ${arrowhead} - - - C - B - E + C + B + E ${text} `; @@ -104,37 +103,40 @@ function makeBjtClass(label: string, polarity: 'npn' | 'pnp') { // for PMOS points FROM channel OUT toward substrate. function mosfetSvg(polarity: 'nmos' | 'pmos', text: string): string { + // Symmetric arrowhead between gate plate and channel. + // NMOS: arrow points INTO the channel (rightward). + // PMOS: arrow points AWAY from channel (leftward). const arrow = polarity === 'nmos' - ? `` // NMOS arrow into channel - : ``; // PMOS arrow away + ? `` + : ``; return ` + + - - - - - - + + + + + + - + - - + + ${arrow} - - - D - G - S + D + G + S ${text} `; diff --git a/frontend/src/components/examples/CircuitPreview.tsx b/frontend/src/components/examples/CircuitPreview.tsx index 07a946e3..207deeb9 100644 --- a/frontend/src/components/examples/CircuitPreview.tsx +++ b/frontend/src/components/examples/CircuitPreview.tsx @@ -18,10 +18,14 @@ import React from 'react'; import type { ExampleProject, ExampleBoard } from '../../data/examples'; +import { INLINE_SVGS } from './InlineComponentSVGs'; // ── Natural display sizes (px on the simulator canvas) ────────────────────── +// A CompDef either points to a static file under /component-svgs/ (svg set) or +// to an inline React component rendered via an inline (inline set). interface CompDef { - svg: string; // filename under /component-svgs/ + svg: string; // filename under /component-svgs/ (empty string for inline) + inline?: React.FC<{ w: number; h: number }>; w: number; // natural width in canvas-space pixels h: number; // natural height in canvas-space pixels } @@ -87,9 +91,20 @@ function getCompDef(type: string, props: Record): CompDef { const colorSvg = LED_COLOR_SVG[(props.color as string)?.toLowerCase()] ?? 'wokwi-led.svg'; return { ...COMP_DEFS['wokwi-led'], svg: colorSvg }; } - return COMP_DEFS[type] ?? { svg: '', w: 50, h: 50 }; + if (COMP_DEFS[type]) return COMP_DEFS[type]; + const inline = INLINE_SVGS[type]; + if (inline) return { svg: '', inline: inline.component, w: inline.w, h: inline.h }; + // Unknown type — fall back to a small generic labeled box so it's visible. + return { svg: '', inline: unknownGlyph, w: 60, h: 40 }; } +const unknownGlyph: React.FC<{ w: number; h: number }> = ({ w, h }) => ( + + + ? + +); + // Whether a component type is the main board (already registered via boardType) function isBoardType(type: string): boolean { return type.includes('arduino-uno') || @@ -116,6 +131,49 @@ interface LayoutItem { x: number; y: number; def: CompDef; + fixed?: boolean; // boards don't move during overlap resolution +} + +/** + * Relax the layout so no two items overlap. Items declared `fixed` (boards) + * act as anchors; non-fixed items are pushed out along whichever axis needs + * the smaller shift. Runs up to MAX_ITER passes — convergence is fast for + * the 3–20 item preview circuits. + */ +function resolveOverlaps(items: LayoutItem[], gap = 8): void { + const MAX_ITER = 30; + for (let iter = 0; iter < MAX_ITER; iter++) { + let moved = false; + for (let i = 0; i < items.length; i++) { + for (let j = i + 1; j < items.length; j++) { + const a = items[i]; + const b = items[j]; + const ax1 = a.x - gap, ax2 = a.x + a.def.w + gap; + const ay1 = a.y - gap, ay2 = a.y + a.def.h + gap; + const bx1 = b.x, bx2 = b.x + b.def.w; + const by1 = b.y, by2 = b.y + b.def.h; + const overlapX = Math.min(ax2, bx2) - Math.max(ax1, bx1); + const overlapY = Math.min(ay2, by2) - Math.max(ay1, by1); + if (overlapX <= 0 || overlapY <= 0) continue; + // Decide which one to move. Fixed (board) never moves; otherwise move `b`. + const target = a.fixed ? b : (b.fixed ? a : b); + const anchor = target === a ? b : a; + if (target.fixed) continue; // both fixed — nothing we can do + // Shift along the axis of least displacement + if (overlapX < overlapY) { + const aCx = anchor.x + anchor.def.w / 2; + const tCx = target.x + target.def.w / 2; + target.x += tCx < aCx ? -overlapX : overlapX; + } else { + const aCy = anchor.y + anchor.def.h / 2; + const tCy = target.y + target.def.h / 2; + target.y += tCy < aCy ? -overlapY : overlapY; + } + moved = true; + } + } + if (!moved) return; + } } export const CircuitPreview: React.FC = ({ @@ -133,7 +191,7 @@ export const CircuitPreview: React.FC = ({ // Multi-board layout example.boards.forEach((b: ExampleBoard) => { const def = BOARD_DEFS[b.boardKind] ?? { svg: '', w: 200, h: 140 }; - items.push({ id: b.boardKind, x: b.x, y: b.y, def }); + items.push({ id: b.boardKind, x: b.x, y: b.y, def, fixed: true }); }); } else { const boardKind = example.boardType ?? 'arduino-uno'; @@ -153,7 +211,7 @@ export const CircuitPreview: React.FC = ({ : 150; const boardX = Math.max(40, minCompX - boardDef.w - 60); const boardY = Math.max(40, avgCompY - boardDef.h / 2); - items.push({ id: boardKind + '-board', x: boardX, y: boardY, def: boardDef }); + items.push({ id: boardKind + '-board', x: boardX, y: boardY, def: boardDef, fixed: true }); } // Add all components from the example @@ -162,10 +220,16 @@ export const CircuitPreview: React.FC = ({ const def = isBoardType(c.type) && boardDef ? boardDef : getCompDef(c.type, c.properties ?? {}); - items.push({ id: c.id, x: c.x, y: c.y, def }); + const fixed = isBoardType(c.type); + items.push({ id: c.id, x: c.x, y: c.y, def, fixed }); }); } + // ── Push overlapping components apart so nothing sits on top of the board + // or another component in the preview (component sizes in inline SVG + // renderers may not exactly match the authored canvas positions). + resolveOverlaps(items); + // ── Bounding box & scale ───────────────────────────────────────────────── const PAD = 12; @@ -222,26 +286,34 @@ export const CircuitPreview: React.FC = ({ > {/* ── Component images ─────────────────────────────────────────────── */} {items.map(({ id, x, y, def }) => { - if (!def.svg) return null; const px = x * scale + dx; const py = y * scale + dy; const pw = def.w * scale; const ph = def.h * scale; + const wrapperStyle: React.CSSProperties = { + position: 'absolute', + left: px, + top: py, + width: pw, + height: ph, + imageRendering: 'auto', + filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.5))', + }; + if (def.inline) { + const Inline = def.inline; + return ( +
+ +
+ ); + } + if (!def.svg) return null; return ( ); })} diff --git a/frontend/src/components/examples/InlineComponentSVGs.tsx b/frontend/src/components/examples/InlineComponentSVGs.tsx new file mode 100644 index 00000000..052f3b2b --- /dev/null +++ b/frontend/src/components/examples/InlineComponentSVGs.tsx @@ -0,0 +1,303 @@ +/** + * InlineComponentSVGs — simple schematic-style icons for the components + * whose SVG isn't pre-rendered into /component-svgs/ (transistors, MOSFETs, + * diodes, capacitors, relays, optocouplers, op-amps, logic gates, signal + * generators, batteries, regulators, motor drivers, etc.). + * + * Used by CircuitPreview.tsx to keep the /examples gallery cards visually + * representative even for parts that weren't extracted from wokwi-elements. + * + * Each renderer receives {w, h} (canvas-space size) and fills its box with + * a recognizable schematic glyph. Sizes chosen to match the on-canvas size + * of the corresponding custom web component, so bounding boxes line up. + */ +import React from 'react'; + +interface InlineSVGProps { + w: number; + h: number; +} + +// ─── Transistors ─────────────────────────────────────────────────────────── +const BjtNpn: React.FC = ({ w, h }) => ( + + + + + + + + + + NPN + +); + +const BjtPnp: React.FC = ({ w, h }) => ( + + + + + + + + + + PNP + +); + +const Mosfet: React.FC = ({ w, h }) => ( + + + + + + + + + + + + MOS + +); + +// ─── Diodes ──────────────────────────────────────────────────────────────── +function diodeGlyph(label: string, w: number, h: number): React.ReactElement { + return ( + + + + + + {label} + + ); +} +const Diode1N4007: React.FC = ({ w, h }) => diodeGlyph('1N4007', w, h); +const Diode1N5817: React.FC = ({ w, h }) => diodeGlyph('1N5817', w, h); +const DiodeZener: React.FC = ({ w, h }) => diodeGlyph('ZD', w, h); + +// ─── Passives ────────────────────────────────────────────────────────────── +const Capacitor: React.FC = ({ w, h }) => ( + + + + + + C + +); + +// ─── Relay ───────────────────────────────────────────────────────────────── +const Relay: React.FC = ({ w, h }) => ( + + + + + + {[24, 34, 44, 54, 64, 74].map(cy => ( + + ))} + + + + + RELAY + +); + +// ─── Optocoupler ─────────────────────────────────────────────────────────── +function optoGlyph(label: string, w: number, h: number): React.ReactElement { + return ( + + + + + + + + + + + + + {label} + + ); +} +const Opto4N25: React.FC = ({ w, h }) => optoGlyph('4N25', w, h); + +// ─── DIP IC block (motor driver, etc.) ───────────────────────────────────── +function dipGlyph(label: string, w: number, h: number): React.ReactElement { + return ( + + + + {[12, 20, 28, 36, 44, 52, 60, 68].map(y => ( + + + + + ))} + {label} + + ); +} +const MotorDriverL293D: React.FC = ({ w, h }) => dipGlyph('L293D', w, h); + +// ─── Op-amp ──────────────────────────────────────────────────────────────── +function opampGlyph(label: string, w: number, h: number): React.ReactElement { + return ( + + + + + + + + + {label} + + ); +} +const OpampLM358: React.FC = ({ w, h }) => opampGlyph('LM358', w, h); + +// ─── Logic gates ─────────────────────────────────────────────────────────── +function gateShape( + shape: 'and' | 'nand' | 'or' | 'nor' | 'xor' | 'xnor' | 'not', + w: number, + h: number, +): React.ReactElement { + const negated = shape === 'nand' || shape === 'nor' || shape === 'xnor' || shape === 'not'; + const exclusive = shape === 'xor' || shape === 'xnor'; + const isOr = shape === 'or' || shape === 'nor' || shape === 'xor' || shape === 'xnor'; + const isNot = shape === 'not'; + const body = isNot + ? + : isOr + ? + : ; + return ( + + + {!isNot && } + {body} + {exclusive && } + {negated && } + + + ); +} +const GateAnd: React.FC = ({ w, h }) => gateShape('and', w, h); +const GateNand: React.FC = ({ w, h }) => gateShape('nand', w, h); +const GateOr: React.FC = ({ w, h }) => gateShape('or', w, h); +const GateNor: React.FC = ({ w, h }) => gateShape('nor', w, h); +const GateXor: React.FC = ({ w, h }) => gateShape('xor', w, h); +const GateXnor: React.FC = ({ w, h }) => gateShape('xnor', w, h); +const GateNot: React.FC = ({ w, h }) => gateShape('not', w, h); + +// ─── Power / instruments ─────────────────────────────────────────────────── +function reg3pinGlyph(label: string, w: number, h: number): React.ReactElement { + return ( + + + + + + {label} + + ); +} +const Reg7805: React.FC = ({ w, h }) => reg3pinGlyph('7805', w, h); +const RegLM317: React.FC = ({ w, h }) => reg3pinGlyph('LM317', w, h); + +const Battery9V: React.FC = ({ w, h }) => ( + + + + + 9V + +); + +const SignalGenerator: React.FC = ({ w, h }) => ( + + + + + + SIG GEN + +); + +// ─── Registry ────────────────────────────────────────────────────────────── +interface InlineEntry { + component: React.FC; + w: number; + h: number; +} + +export const INLINE_SVGS: Record = { + // BJTs + 'wokwi-bjt-2n2222': { component: BjtNpn, w: 72, h: 72 }, + 'wokwi-bjt-2n3904': { component: BjtNpn, w: 72, h: 72 }, + 'wokwi-bjt-2n3906': { component: BjtPnp, w: 72, h: 72 }, + 'wokwi-bjt-bc547': { component: BjtNpn, w: 72, h: 72 }, + // MOSFETs + 'wokwi-mosfet-2n7000': { component: Mosfet, w: 72, h: 72 }, + 'wokwi-mosfet-irf540n': { component: Mosfet, w: 72, h: 72 }, + 'wokwi-mosfet-bs170': { component: Mosfet, w: 72, h: 72 }, + // Diodes + 'wokwi-diode': { component: Diode1N4007, w: 72, h: 40 }, + 'wokwi-diode-1n4007': { component: Diode1N4007, w: 72, h: 40 }, + 'wokwi-diode-1n4148': { component: Diode1N4007, w: 72, h: 40 }, + 'wokwi-diode-1n5817': { component: Diode1N5817, w: 72, h: 40 }, + 'wokwi-diode-1n5819': { component: Diode1N5817, w: 72, h: 40 }, + 'wokwi-zener-1n4733': { component: DiodeZener, w: 72, h: 40 }, + // Passives + 'wokwi-capacitor': { component: Capacitor, w: 56, h: 36 }, + 'wokwi-inductor': { component: Capacitor, w: 56, h: 36 }, + // Electromechanical + 'wokwi-relay': { component: Relay, w: 96, h: 96 }, + // Optocouplers + 'wokwi-opto-4n25': { component: Opto4N25, w: 80, h: 64 }, + 'wokwi-opto-pc817': { component: Opto4N25, w: 80, h: 64 }, + // IC + 'wokwi-motor-driver-l293d': { component: MotorDriverL293D, w: 100, h: 80 }, + 'wokwi-ic-74hc00': { component: MotorDriverL293D, w: 100, h: 80 }, + 'wokwi-ic-74hc04': { component: MotorDriverL293D, w: 100, h: 80 }, + 'wokwi-ic-74hc08': { component: MotorDriverL293D, w: 100, h: 80 }, + 'wokwi-ic-74hc14': { component: MotorDriverL293D, w: 100, h: 80 }, + 'wokwi-ic-74hc32': { component: MotorDriverL293D, w: 100, h: 80 }, + 'wokwi-ic-74hc86': { component: MotorDriverL293D, w: 100, h: 80 }, + // Op-amps + 'wokwi-opamp-ideal': { component: OpampLM358, w: 80, h: 72 }, + 'wokwi-opamp-lm358': { component: OpampLM358, w: 80, h: 72 }, + 'wokwi-opamp-lm741': { component: OpampLM358, w: 80, h: 72 }, + 'wokwi-opamp-lm324': { component: OpampLM358, w: 80, h: 72 }, + 'wokwi-opamp-tl072': { component: OpampLM358, w: 80, h: 72 }, + // Logic gates — examples use both `wokwi-logic-gate-*` and `wokwi-logic-*` naming + 'wokwi-logic-gate-and': { component: GateAnd, w: 72, h: 48 }, + 'wokwi-logic-gate-or': { component: GateOr, w: 72, h: 48 }, + 'wokwi-logic-gate-nand': { component: GateNand, w: 72, h: 48 }, + 'wokwi-logic-gate-nor': { component: GateNor, w: 72, h: 48 }, + 'wokwi-logic-gate-xor': { component: GateXor, w: 72, h: 48 }, + 'wokwi-logic-gate-xnor': { component: GateXnor, w: 72, h: 48 }, + 'wokwi-logic-gate-not': { component: GateNot, w: 72, h: 48 }, + 'wokwi-logic-and': { component: GateAnd, w: 72, h: 48 }, + 'wokwi-logic-or': { component: GateOr, w: 72, h: 48 }, + 'wokwi-logic-nand': { component: GateNand, w: 72, h: 48 }, + 'wokwi-logic-nor': { component: GateNor, w: 72, h: 48 }, + 'wokwi-logic-xor': { component: GateXor, w: 72, h: 48 }, + 'wokwi-logic-xnor': { component: GateXnor, w: 72, h: 48 }, + 'wokwi-logic-not': { component: GateNot, w: 72, h: 48 }, + // Power + 'wokwi-reg-7805': { component: Reg7805, w: 72, h: 56 }, + 'wokwi-reg-7812': { component: Reg7805, w: 72, h: 56 }, + 'wokwi-reg-7905': { component: Reg7805, w: 72, h: 56 }, + 'wokwi-reg-lm317': { component: RegLM317, w: 72, h: 56 }, + 'wokwi-battery-9v': { component: Battery9V, w: 48, h: 72 }, + 'wokwi-battery-aa': { component: Battery9V, w: 48, h: 72 }, + 'wokwi-signal-generator': { component: SignalGenerator, w: 80, h: 64 }, +}; diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index c2926abb..2ebeb03a 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -14,6 +14,7 @@ import type { SegmentHandle } from './WireLayer'; import { ElectricalOverlay } from '../analog-ui/ElectricalOverlay'; import { BoardOnCanvas } from './BoardOnCanvas'; import { PartSimulationRegistry } from '../../simulation/parts'; +import { PROPERTY_CHANGE_EVENT, type PropertyChangeDetail } from '../../simulation/parts/partUtils'; import { isSpiceMapped } from '../../simulation/spice/componentToSpice'; import { PinOverlay } from './PinOverlay'; import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping'; @@ -221,6 +222,27 @@ export const SimulatorCanvas = () => { initSimulator(); }, [initSimulator]); + // Runtime parts (pots, switches, sensor panels) emit + // `velxio:property-change` instead of writing the store directly — one + // listener here routes every mutation through `updateComponent()`, which + // is the same path the Property Dialog uses. Keeps parts decoupled from + // Zustand and guarantees the SPICE netlist memo invalidates on every + // user-driven property change. + useEffect(() => { + const onPropertyChange = (evt: Event) => { + const { componentId, propName, value } = (evt as CustomEvent).detail; + const state = useSimulatorStore.getState(); + const comp = state.components.find((c) => c.id === componentId); + if (!comp) return; + if (String(comp.properties?.[propName]) === String(value)) return; + state.updateComponent(componentId, { + properties: { ...comp.properties, [propName]: value }, + }); + }; + window.addEventListener(PROPERTY_CHANGE_EVENT, onPropertyChange); + return () => window.removeEventListener(PROPERTY_CHANGE_EVENT, onPropertyChange); + }, []); + // Auto-start/stop Pi bridges when simulation state changes const startBoard = useSimulatorStore((s) => s.startBoard); const stopBoard = useSimulatorStore((s) => s.stopBoard); @@ -857,17 +879,23 @@ export const SimulatorCanvas = () => { // Handle component selection from modal const handleSelectComponent = (metadata: ComponentMetadata) => { - // Calculate grid position to avoid overlapping - // Use existing components count to determine position - const componentsCount = components.length; - const gridSize = 250; // Space between components - const cols = 3; // Components per row + // Anchor new components to the visible top-left of the canvas, so they + // appear in the user's current viewport regardless of pan/zoom (instead + // of growing off-screen at fixed world coords like (400, 100 + row*250)). + const rect = canvasRef.current?.getBoundingClientRect(); + const z = zoomRef.current || 1; + const screenMargin = 60; // px on screen — keeps the part off the toolbar/edge + const worldOrigin = rect + ? toWorld(rect.left + screenMargin, rect.top + screenMargin) + : { x: 100, y: 100 }; - const col = componentsCount % cols; - const row = Math.floor(componentsCount / cols); - - const x = 400 + (col * gridSize); - const y = 100 + (row * gridSize); + // Tile additional drops so they don't stack exactly on top of each other, + // while still landing inside the viewport. + const tileStep = 40 / z; // 40 screen-px between successive drops + const cols = 4; + const idx = components.length; + const x = worldOrigin.x + (idx % cols) * tileStep; + const y = worldOrigin.y + Math.floor(idx / cols) * tileStep; const component = createComponentFromMetadata(metadata, x, y); trackAddComponent(metadata.id); diff --git a/frontend/src/data/examples-circuits.ts b/frontend/src/data/examples-circuits.ts index 68036d07..40ab5619 100644 --- a/frontend/src/data/examples-circuits.ts +++ b/frontend/src/data/examples-circuits.ts @@ -934,11 +934,17 @@ void loop() { description: 'Sum = A XOR B XOR Cin, Cout = (A AND B) OR (Cin AND (A XOR B)).', category: 'circuits', difficulty: 'intermediate', code: `// 1-bit full adder in software -void setup() { Serial.begin(9600); pinMode(2,INPUT_PULLUP); pinMode(3,INPUT_PULLUP); pinMode(4,INPUT_PULLUP); } +void setup() { + Serial.begin(9600); + pinMode(2,INPUT_PULLUP); pinMode(3,INPUT_PULLUP); pinMode(4,INPUT_PULLUP); + pinMode(5,OUTPUT); pinMode(6,OUTPUT); +} void loop() { bool a=!digitalRead(2), b=!digitalRead(3), cin=!digitalRead(4); bool sum = a ^ b ^ cin; bool cout = (a&b) | (cin&(a^b)); + digitalWrite(5, sum); + digitalWrite(6, cout); Serial.print("A="); Serial.print(a); Serial.print(" B="); Serial.print(b); Serial.print(" Cin="); Serial.print(cin); Serial.print(" Sum="); Serial.print(sum); Serial.print(" Cout="); Serial.println(cout); @@ -949,8 +955,10 @@ void loop() { { type: 'wokwi-pushbutton', id: 'bA', x: 350, y: 60, properties: {} }, { type: 'wokwi-pushbutton', id: 'bB', x: 350, y: 140, properties: {} }, { type: 'wokwi-pushbutton', id: 'bCin', x: 350, y: 220, properties: {} }, - { type: 'wokwi-led', id: 'sumLed', x: 480, y: 100, properties: { color: 'green' } }, - { type: 'wokwi-led', id: 'coutLed', x: 480, y: 200, properties: { color: 'red' } }, + { type: 'wokwi-resistor', id: 'rSum', x: 440, y: 100, properties: { value: '220' } }, + { type: 'wokwi-resistor', id: 'rCout', x: 440, y: 200, properties: { value: '220' } }, + { type: 'wokwi-led', id: 'sumLed', x: 540, y: 100, properties: { color: 'green' } }, + { type: 'wokwi-led', id: 'coutLed', x: 540, y: 200, properties: { color: 'red' } }, ], wires: [ w('w1', ['arduino-uno','2'], ['bA','1.l']), @@ -959,6 +967,14 @@ void loop() { w('w4', ['bB','2.l'], ['arduino-uno','GND'], '#000000'), w('w5', ['arduino-uno','4'], ['bCin','1.l']), w('w6', ['bCin','2.l'], ['arduino-uno','GND'], '#000000'), + // Sum LED: pin 5 → 220Ω → LED → GND + w('w7', ['arduino-uno','5'], ['rSum','1']), + w('w8', ['rSum','2'], ['sumLed','A']), + w('w9', ['sumLed','C'], ['arduino-uno','GND'], '#000000'), + // Cout LED: pin 6 → 220Ω → LED → GND + w('w10', ['arduino-uno','6'], ['rCout','1']), + w('w11', ['rCout','2'], ['coutLed','A']), + w('w12', ['coutLed','C'], ['arduino-uno','GND'], '#000000'), ], }, diff --git a/frontend/src/simulation/parts/BasicParts.ts b/frontend/src/simulation/parts/BasicParts.ts index 99cb5d6e..bd110a80 100644 --- a/frontend/src/simulation/parts/BasicParts.ts +++ b/frontend/src/simulation/parts/BasicParts.ts @@ -1,6 +1,6 @@ import { PartSimulationRegistry } from './PartSimulationRegistry'; import { useElectricalStore } from '../../store/useElectricalStore'; -import { syncStoreProperty } from './partUtils'; +import { emitPropertyChange } from './partUtils'; /** * Basic Pushbutton implementation (full-size) @@ -14,12 +14,12 @@ PartSimulationRegistry.register('pushbutton', { const onButtonPress = () => { if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, false); // Active LOW (element as any).pressed = true; - syncStoreProperty(componentId, 'pressed', true); + emitPropertyChange(componentId, 'pressed', true); }; const onButtonRelease = () => { if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, true); (element as any).pressed = false; - syncStoreProperty(componentId, 'pressed', false); + emitPropertyChange(componentId, 'pressed', false); }; element.addEventListener('button-press', onButtonPress); @@ -43,12 +43,12 @@ PartSimulationRegistry.register('pushbutton-6mm', { const onPress = () => { if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, false); (element as any).pressed = true; - syncStoreProperty(componentId, 'pressed', true); + emitPropertyChange(componentId, 'pressed', true); }; const onRelease = () => { if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, true); (element as any).pressed = false; - syncStoreProperty(componentId, 'pressed', false); + emitPropertyChange(componentId, 'pressed', false); }; element.addEventListener('button-press', onPress); @@ -72,13 +72,13 @@ PartSimulationRegistry.register('slide-switch', { const raw = (element as any).value; let state = raw === 1 || raw === '1'; if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, state); - syncStoreProperty(componentId, 'value', state ? 1 : 0); + emitPropertyChange(componentId, 'value', state ? 1 : 0); const onChange = () => { const v = (element as any).value; state = v === 1 || v === '1'; if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, state); - syncStoreProperty(componentId, 'value', state ? 1 : 0); + emitPropertyChange(componentId, 'value', state ? 1 : 0); }; element.addEventListener('change', onChange); diff --git a/frontend/src/simulation/parts/ComplexParts.ts b/frontend/src/simulation/parts/ComplexParts.ts index 6b62e51d..ade79fb6 100644 --- a/frontend/src/simulation/parts/ComplexParts.ts +++ b/frontend/src/simulation/parts/ComplexParts.ts @@ -1,7 +1,7 @@ import { PartSimulationRegistry } from './PartSimulationRegistry'; import type { AnySimulator } from './PartSimulationRegistry'; import { RP2040Simulator } from '../RP2040Simulator'; -import { getADC, setAdcVoltage, syncStoreProperty } from './partUtils'; +import { getADC, setAdcVoltage, emitPropertyChange } from './partUtils'; import { registerSensorUpdate, unregisterSensorUpdate } from '../SensorUpdateRegistry'; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -79,7 +79,7 @@ PartSimulationRegistry.register('potentiometer', { } // Mirror to store so the SPICE netlist re-solves (op-amp // comparators, divider-driven circuits etc. depend on this). - syncStoreProperty(componentId, 'value', raw); + emitPropertyChange(componentId, 'value', raw); }; onInput(); @@ -109,7 +109,7 @@ PartSimulationRegistry.register('slide-potentiometer', { const volts = normalized * refVoltage; setAdcVoltage(avrSimulator, arduinoPin, volts); } - syncStoreProperty(componentId, 'value', value); + emitPropertyChange(componentId, 'value', value); }; onInput(); @@ -153,7 +153,7 @@ PartSimulationRegistry.register('photoresistor-sensor', { } // Mirror to store — maps the slider 0-1023 back to lux 0-1000 // so the SPICE photoresistor handler re-computes its R_ldr. - syncStoreProperty(componentId, 'lux', Math.round((val / 1023) * 1000)); + emitPropertyChange(componentId, 'lux', Math.round((val / 1023) * 1000)); } }; element.addEventListener('input', onInput); @@ -172,7 +172,7 @@ PartSimulationRegistry.register('photoresistor-sensor', { if (pinAO !== null) { setAdcVoltage(avrSimulator, pinAO, ((values.lux as number) / 1000) * 5.0); } - syncStoreProperty(componentId, 'lux', values.lux); + emitPropertyChange(componentId, 'lux', values.lux); } }); diff --git a/frontend/src/simulation/parts/SensorParts.ts b/frontend/src/simulation/parts/SensorParts.ts index 27aabd2d..db217d88 100644 --- a/frontend/src/simulation/parts/SensorParts.ts +++ b/frontend/src/simulation/parts/SensorParts.ts @@ -17,7 +17,7 @@ */ import { PartSimulationRegistry } from './PartSimulationRegistry'; -import { setAdcVoltage, syncStoreProperty } from './partUtils'; +import { setAdcVoltage, emitPropertyChange } from './partUtils'; import { registerSensorUpdate, unregisterSensorUpdate } from '../SensorUpdateRegistry'; // ─── Tilt Switch ───────────────────────────────────────────────────────────── @@ -89,7 +89,7 @@ PartSimulationRegistry.register('ntc-temperature-sensor', { } // Mirror to store — the SPICE ntc-temperature-sensor handler // reads comp.properties.temperature when computing R_ntc. - syncStoreProperty(componentId, 'temperature', values.temperature); + emitPropertyChange(componentId, 'temperature', values.temperature); } }); diff --git a/frontend/src/simulation/parts/partUtils.ts b/frontend/src/simulation/parts/partUtils.ts index 4704648a..a7828ce1 100644 --- a/frontend/src/simulation/parts/partUtils.ts +++ b/frontend/src/simulation/parts/partUtils.ts @@ -7,27 +7,28 @@ import type { AnySimulator } from './PartSimulationRegistry'; import { RP2040Simulator } from '../RP2040Simulator'; -import { useSimulatorStore } from '../../store/useSimulatorStore'; + +/** DOM event fired when a runtime part mutates a user-facing property. */ +export const PROPERTY_CHANGE_EVENT = 'velxio:property-change'; + +export interface PropertyChangeDetail { + componentId: string; + propName: string; + value: unknown; +} /** - * Mirror a live DOM / sensor-panel value into the component's store properties - * so SPICE's netlist memo invalidates and the next `maybeSolve()` picks up the - * change. Without this, dragging a potentiometer, pressing a button, or moving - * a sensor slider updates the ADC but leaves the SPICE netlist stale, so any - * analog circuit driven by the input (comparators, op-amp networks, divider - * bridges) freezes at the first `.op` solve. - * - * Idempotent — no-op when the value hasn't changed since the previous sync. + * Dispatch a property change so the canvas can route it through + * `updateComponent()`. Parts call this whenever a DOM / sensor-panel value + * mutates so the SPICE netlist memo invalidates and the next `maybeSolve()` + * picks up the change. Parts stay decoupled from Zustand — `SimulatorCanvas` + * is the single listener that applies the update. */ -export function syncStoreProperty(componentId: string, propName: string, value: unknown): void { - const store = useSimulatorStore.getState(); - const comp = store.components.find((c) => c.id === componentId); - if (!comp) return; - const prev = comp.properties?.[propName]; - if (String(prev) === String(value)) return; - store.updateComponent(componentId, { - properties: { ...comp.properties, [propName]: value }, - }); +export function emitPropertyChange(componentId: string, propName: string, value: unknown): void { + if (typeof window === 'undefined') return; + if (typeof window.dispatchEvent !== 'function' || typeof CustomEvent !== 'function') return; + const detail: PropertyChangeDetail = { componentId, propName, value }; + window.dispatchEvent(new CustomEvent(PROPERTY_CHANGE_EVENT, { detail })); } /** Read the ADC instance from the simulator (returns null if not initialized) */ diff --git a/frontend/src/simulation/spice/componentToSpice.ts b/frontend/src/simulation/spice/componentToSpice.ts index d0294af4..72119e88 100644 --- a/frontend/src/simulation/spice/componentToSpice.ts +++ b/frontend/src/simulation/spice/componentToSpice.ts @@ -941,29 +941,43 @@ const MAPPERS: Record = { // uses a B-source that inverts the coil voltage as its control signal, // because ngspice SW has no "normally closed" mode. // Optional flyback diode across the coil (anode on COIL-, cathode on COIL+). + // NO and NC contact cards are only emitted when their respective pins are + // wired — leaving NC unconnected is a very common pattern and must not + // suppress the rest of the relay (coil + NO switch). relay: (comp, netLookup) => { const cp = netLookup('COIL+'); const cn = netLookup('COIL-'); const com = netLookup('COM'); const no = netLookup('NO'); const nc = netLookup('NC'); - if (!cp || !cn || !com || !no || !nc) return null; + // Coil pins must be present — without them the relay can't be energised. + // COM is required too; without it, neither NO nor NC contact is useful. + if (!cp || !cn || !com) return null; const coilR = Number(comp.properties.coil_resistance ?? 70); const coilV = Number(comp.properties.coil_voltage ?? 5); const threshold = coilV * 0.6; // drop-in at 60% of nominal const hysteresis = coilV * 0.15; const includeFlyback = comp.properties.include_flyback !== false; - const ctrlInvNet = `${comp.id}_ncctrl`; + // A relay coil is a wire-wound inductor: R (of the copper) in SERIES + // with ideal L. Modelling R and L in parallel would make the coil a DC + // short — V(COIL+) ≡ V(COIL-) in .op analysis — so the switch control + // voltage is always 0 and the NO contact never closes. + const coilMidNet = `${comp.id}_coilmid`; const cards = [ - `R_${comp.id}_coil ${cp} ${cn} ${coilR}`, - `L_${comp.id}_coil ${cp} ${cn} 20m`, + `R_${comp.id}_coil ${cp} ${coilMidNet} ${coilR}`, + `L_${comp.id}_coil ${coilMidNet} ${cn} 20m`, + ]; + if (no) { // NO: closes when V_coil > Vt (normal SW behaviour) - `S_${comp.id}_no ${com} ${no} ${cp} ${cn} RELAY_SW`, + cards.push(`S_${comp.id}_no ${com} ${no} ${cp} ${cn} RELAY_SW`); + } + if (nc) { // NC: inverted control — B-source maps (V_coil → Vnom − V_coil) so that // SW still "turns on when ctrl > Vt", but meaning is inverted. - `B_${comp.id}_ncctrl ${ctrlInvNet} 0 V = ${coilV} - (V(${cp}) - V(${cn}))`, - `S_${comp.id}_nc ${com} ${nc} ${ctrlInvNet} 0 RELAY_SW`, - ]; + const ctrlInvNet = `${comp.id}_ncctrl`; + cards.push(`B_${comp.id}_ncctrl ${ctrlInvNet} 0 V = ${coilV} - (V(${cp}) - V(${cn}))`); + cards.push(`S_${comp.id}_nc ${com} ${nc} ${ctrlInvNet} 0 RELAY_SW`); + } if (includeFlyback) { cards.push(`D_${comp.id}_fly ${cn} ${cp} D1N4148`); }