Refactor property synchronization in simulation parts; introduce emitPropertyChange event

- Replaced syncStoreProperty function with emitPropertyChange to decouple parts from Zustand store.
- Updated relay component mapping to ensure proper handling of coil and contact states.
- Added new test cases for half-wave rectifier and relay-controlled LED to ensure correct functionality.
- Introduced InlineComponentSVGs for schematic-style icons of various components.
- Updated submodule references for qemu-lcgamboa, rp2040js, and wokwi-elements to indicate dirty state.
This commit is contained in:
David Montero Crespo 2026-04-21 10:56:20 -03:00
parent 9cc9cfebd6
commit b152cb1919
16 changed files with 829 additions and 281 deletions

View File

@ -1,6 +1,6 @@
{
"version": "1.0.0",
"generatedAt": "2026-04-21T04:09:14.073Z",
"generatedAt": "2026-04-21T13:02:40.071Z",
"components": [
{
"thumbnail": "<svg width=\"64\" height=\"64\" xmlns=\"http://www.w3.org/2000/svg\">\n <rect width=\"64\" height=\"64\" fill=\"#e0e0e0\" rx=\"4\"/>\n <text x=\"50%\" y=\"50%\" text-anchor=\"middle\" dy=\".3em\" font-size=\"10\" fill=\"#666\">\n DIODE-1N4007\n </text>\n </svg>",

View File

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

View File

@ -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

View File

@ -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<string, PinSourceState>): 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);
});
});

View File

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

View File

@ -185,52 +185,55 @@ export const ComponentPickerModal: React.FC<ComponentPickerModalProps> = ({
</div>
) : (
<>
{/* Boards row in "All Components" view */}
{selectedCategory === 'all' && onSelectBoard && (
<div className="components-grid" style={{ borderBottom: '1px solid #333', paddingBottom: 8, marginBottom: 4 }}>
{ALL_BOARDS.filter((k) =>
!searchQuery || BOARD_KIND_LABELS[k].toLowerCase().includes(searchQuery.toLowerCase())
).map((kind) => (
<BoardCard
key={kind}
kind={kind}
onSelect={() => { onSelectBoard(kind); onClose(); }}
/>
))}
</div>
)}
{/* Components Grid */}
<div className="components-grid">
{isLoading ? (
<div className="loading-state">
<div className="spinner"></div>
<p>Loading components...</p>
{/* 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. */}
<div className="components-scroll">
{selectedCategory === 'all' && onSelectBoard && (
<div className="components-grid components-grid--inline" style={{ borderBottom: '1px solid #333', paddingBottom: 8, marginBottom: 4 }}>
{ALL_BOARDS.filter((k) =>
!searchQuery || BOARD_KIND_LABELS[k].toLowerCase().includes(searchQuery.toLowerCase())
).map((kind) => (
<BoardCard
key={kind}
kind={kind}
onSelect={() => { onSelectBoard(kind); onClose(); }}
/>
))}
</div>
) : filteredComponents.length === 0 ? (
<div className="no-results">
<p>No components found</p>
{searchQuery && (
<button
className="clear-filters-btn"
onClick={() => {
setSearchQuery('');
setSelectedCategory('all');
}}
>
Clear filters
</button>
)}
</div>
) : (
filteredComponents.map((component) => (
<ComponentCard
key={component.id}
component={component}
onSelect={() => onSelectComponent(component)}
/>
))
)}
<div className="components-grid components-grid--inline">
{isLoading ? (
<div className="loading-state">
<div className="spinner"></div>
<p>Loading components...</p>
</div>
) : filteredComponents.length === 0 ? (
<div className="no-results">
<p>No components found</p>
{searchQuery && (
<button
className="clear-filters-btn"
onClick={() => {
setSearchQuery('');
setSelectedCategory('all');
}}
>
Clear filters
</button>
)}
</div>
) : (
filteredComponents.map((component) => (
<ComponentCard
key={component.id}
component={component}
onSelect={() => onSelectComponent(component)}
/>
))
)}
</div>
</div>
{/* Footer Info */}

View File

@ -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'
? `
<!-- NPN: arrow points OUT from base, toward emitter -->
<polygon points="40,52 46,48 44,55" fill="${STROKE}"/>`
: `
<!-- PNP: arrow points IN toward base -->
<polygon points="28,44 22,48 26,51" fill="${STROKE}"/>`;
? `<polygon points="44,48 36,44 36,52" fill="${STROKE}"/>`
: `<polygon points="22,48 30,44 30,52" fill="${STROKE}"/>`;
return `
<style>${STYLE}</style>
<svg width="72" height="72" xmlns="http://www.w3.org/2000/svg">
<!-- Base -->
<line x1="0" y1="36" x2="22" y2="36" stroke="${LEAD}" stroke-width="2"/>
<line x1="22" y1="14" x2="22" y2="58" stroke="${STROKE}" stroke-width="2.5"/>
<!-- Body circle (clean outline, no fill TO-92 hint) -->
<circle cx="34" cy="36" r="22" fill="none" stroke="${BODY}" stroke-width="1.2"/>
<!-- Base lead + bar -->
<line x1="0" y1="36" x2="22" y2="36" stroke="${LEAD}" stroke-width="2"/>
<line x1="22" y1="14" x2="22" y2="58" stroke="${STROKE}" stroke-width="3"/>
<!-- Collector side -->
<line x1="22" y1="24" x2="46" y2="24" stroke="${STROKE}" stroke-width="1.5"/>
<line x1="22" y1="24" x2="46" y2="24" stroke="${STROKE}" stroke-width="2"/>
<line x1="46" y1="24" x2="46" y2="6" stroke="${LEAD}" stroke-width="2"/>
<line x1="46" y1="6" x2="60" y2="6" stroke="${LEAD}" stroke-width="2"/>
<line x1="60" y1="0" x2="60" y2="6" stroke="${LEAD}" stroke-width="2"/>
<!-- Emitter side -->
<line x1="22" y1="48" x2="46" y2="48" stroke="${STROKE}" stroke-width="1.5"/>
<line x1="22" y1="48" x2="46" y2="48" stroke="${STROKE}" stroke-width="2"/>
<line x1="46" y1="48" x2="46" y2="66" stroke="${LEAD}" stroke-width="2"/>
<line x1="46" y1="66" x2="60" y2="66" stroke="${LEAD}" stroke-width="2"/>
<line x1="60" y1="66" x2="60" y2="72" stroke="${LEAD}" stroke-width="2"/>
${arrowhead}
<!-- Body circle (optional TO-92 style) -->
<circle cx="34" cy="36" r="22" fill="${FILL}" fill-opacity="0.2" stroke="${STROKE}" stroke-width="1" stroke-opacity="0.4"/>
<!-- Pin labels -->
<text x="50" y="14" font-family="sans-serif" font-size="7" fill="${LABEL}">C</text>
<text x="2" y="32" font-family="sans-serif" font-size="7" fill="${LABEL}">B</text>
<text x="50" y="64" font-family="sans-serif" font-size="7" fill="${LABEL}">E</text>
<text x="50" y="14" font-family="sans-serif" font-size="8" fill="${LABEL}">C</text>
<text x="2" y="32" font-family="sans-serif" font-size="8" fill="${LABEL}">B</text>
<text x="50" y="64" font-family="sans-serif" font-size="8" fill="${LABEL}">E</text>
<!-- Part number -->
<text x="36" y="78" text-anchor="middle" font-family="sans-serif" font-size="7" fill="${LABEL}" font-weight="bold">${text}</text>
</svg>`;
@ -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'
? `<polygon points="24,36 32,32 32,40" fill="${STROKE}"/>` // NMOS arrow into channel
: `<polygon points="32,36 24,32 24,40" fill="${STROKE}"/>`; // PMOS arrow away
? `<polygon points="24,36 18,32 18,40" fill="${STROKE}"/>`
: `<polygon points="18,36 24,32 24,40" fill="${STROKE}"/>`;
return `
<style>${STYLE}</style>
<svg width="72" height="72" xmlns="http://www.w3.org/2000/svg">
<!-- Body circle (clean outline only) -->
<circle cx="34" cy="36" r="22" fill="none" stroke="${BODY}" stroke-width="1.2"/>
<!-- Gate lead and gate plate -->
<line x1="0" y1="36" x2="18" y2="36" stroke="${LEAD}" stroke-width="2"/>
<line x1="18" y1="22" x2="18" y2="50" stroke="${STROKE}" stroke-width="2.5"/>
<!-- Channel line (drain/source side, with small breaks to suggest enhancement mode) -->
<line x1="24" y1="22" x2="24" y2="28" stroke="${STROKE}" stroke-width="1.5"/>
<line x1="24" y1="32" x2="24" y2="40" stroke="${STROKE}" stroke-width="1.5"/>
<line x1="24" y1="44" x2="24" y2="50" stroke="${STROKE}" stroke-width="1.5"/>
<line x1="0" y1="36" x2="16" y2="36" stroke="${LEAD}" stroke-width="2"/>
<line x1="16" y1="22" x2="16" y2="50" stroke="${STROKE}" stroke-width="3"/>
<!-- Channel line with breaks (enhancement-mode hint) -->
<line x1="24" y1="22" x2="24" y2="28" stroke="${STROKE}" stroke-width="2"/>
<line x1="24" y1="32" x2="24" y2="40" stroke="${STROKE}" stroke-width="2"/>
<line x1="24" y1="44" x2="24" y2="50" stroke="${STROKE}" stroke-width="2"/>
<!-- Drain side -->
<line x1="24" y1="22" x2="46" y2="22" stroke="${STROKE}" stroke-width="1.5"/>
<line x1="24" y1="22" x2="46" y2="22" stroke="${STROKE}" stroke-width="2"/>
<line x1="46" y1="22" x2="46" y2="6" stroke="${LEAD}" stroke-width="2"/>
<line x1="46" y1="6" x2="60" y2="6" stroke="${LEAD}" stroke-width="2"/>
<line x1="60" y1="0" x2="60" y2="6" stroke="${LEAD}" stroke-width="2"/>
<!-- Source side + body tie (short horizontal back to channel) -->
<line x1="24" y1="50" x2="46" y2="50" stroke="${STROKE}" stroke-width="1.5"/>
<!-- Source side -->
<line x1="24" y1="50" x2="46" y2="50" stroke="${STROKE}" stroke-width="2"/>
<line x1="46" y1="50" x2="46" y2="66" stroke="${LEAD}" stroke-width="2"/>
<line x1="46" y1="66" x2="60" y2="66" stroke="${LEAD}" stroke-width="2"/>
<line x1="60" y1="66" x2="60" y2="72" stroke="${LEAD}" stroke-width="2"/>
${arrow}
<!-- Body outline -->
<circle cx="34" cy="36" r="22" fill="${FILL}" fill-opacity="0.2" stroke="${STROKE}" stroke-width="1" stroke-opacity="0.4"/>
<!-- Pin labels -->
<text x="50" y="14" font-family="sans-serif" font-size="7" fill="${LABEL}">D</text>
<text x="2" y="32" font-family="sans-serif" font-size="7" fill="${LABEL}">G</text>
<text x="50" y="64" font-family="sans-serif" font-size="7" fill="${LABEL}">S</text>
<text x="50" y="14" font-family="sans-serif" font-size="8" fill="${LABEL}">D</text>
<text x="2" y="32" font-family="sans-serif" font-size="8" fill="${LABEL}">G</text>
<text x="50" y="64" font-family="sans-serif" font-size="8" fill="${LABEL}">S</text>
<!-- Part number -->
<text x="36" y="78" text-anchor="middle" font-family="sans-serif" font-size="7" fill="${LABEL}" font-weight="bold">${text}</text>
</svg>`;

View File

@ -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 <svg> (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<string, any>): 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 }) => (
<svg width={w} height={h} viewBox="0 0 60 40" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="2" width="56" height="36" rx="3" fill="#3b3b3b" stroke="#888" strokeWidth="1" strokeDasharray="3,2"/>
<text x="30" y="24" textAnchor="middle" fontSize="9" fill="#ccc">?</text>
</svg>
);
// 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 320 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<CircuitPreviewProps> = ({
@ -133,7 +191,7 @@ export const CircuitPreview: React.FC<CircuitPreviewProps> = ({
// 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<CircuitPreviewProps> = ({
: 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<CircuitPreviewProps> = ({
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<CircuitPreviewProps> = ({
>
{/* ── 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 (
<div key={id} style={wrapperStyle}>
<Inline w={pw} h={ph} />
</div>
);
}
if (!def.svg) return null;
return (
<img
key={id}
src={`/component-svgs/${def.svg}`}
alt=""
style={{
position: 'absolute',
left: px,
top: py,
width: pw,
height: ph,
imageRendering: 'auto',
// Filter to slightly darken components so they read well on dark bg
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.5))',
}}
style={wrapperStyle}
/>
);
})}

View File

@ -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<InlineSVGProps> = ({ w, h }) => (
<svg width={w} height={h} viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<circle cx="36" cy="36" r="24" fill="#f6f1e8" stroke="#555" strokeWidth="1"/>
<line x1="0" y1="36" x2="22" y2="36" stroke="#555" strokeWidth="2"/>
<line x1="22" y1="20" x2="22" y2="52" stroke="#222" strokeWidth="3"/>
<line x1="22" y1="24" x2="44" y2="6" stroke="#222" strokeWidth="2"/>
<line x1="44" y1="6" x2="60" y2="6" stroke="#555" strokeWidth="2"/>
<line x1="22" y1="48" x2="44" y2="66" stroke="#222" strokeWidth="2"/>
<line x1="44" y1="66" x2="60" y2="66" stroke="#555" strokeWidth="2"/>
<polygon points="40,60 44,66 36,65" fill="#222"/>
<text x="50" y="40" fontSize="8" fill="#333">NPN</text>
</svg>
);
const BjtPnp: React.FC<InlineSVGProps> = ({ w, h }) => (
<svg width={w} height={h} viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<circle cx="36" cy="36" r="24" fill="#f6f1e8" stroke="#555" strokeWidth="1"/>
<line x1="0" y1="36" x2="22" y2="36" stroke="#555" strokeWidth="2"/>
<line x1="22" y1="20" x2="22" y2="52" stroke="#222" strokeWidth="3"/>
<line x1="22" y1="24" x2="44" y2="6" stroke="#222" strokeWidth="2"/>
<line x1="44" y1="6" x2="60" y2="6" stroke="#555" strokeWidth="2"/>
<line x1="22" y1="48" x2="44" y2="66" stroke="#222" strokeWidth="2"/>
<line x1="44" y1="66" x2="60" y2="66" stroke="#555" strokeWidth="2"/>
<polygon points="26,28 22,24 32,26" fill="#222"/>
<text x="50" y="40" fontSize="8" fill="#333">PNP</text>
</svg>
);
const Mosfet: React.FC<InlineSVGProps> = ({ w, h }) => (
<svg width={w} height={h} viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<circle cx="36" cy="36" r="24" fill="#f6f1e8" stroke="#555" strokeWidth="1"/>
<line x1="0" y1="36" x2="20" y2="36" stroke="#555" strokeWidth="2"/>
<line x1="20" y1="22" x2="20" y2="50" stroke="#222" strokeWidth="2"/>
<line x1="24" y1="20" x2="24" y2="32" stroke="#222" strokeWidth="3"/>
<line x1="24" y1="40" x2="24" y2="52" stroke="#222" strokeWidth="3"/>
<line x1="24" y1="26" x2="48" y2="8" stroke="#222" strokeWidth="2"/>
<line x1="48" y1="8" x2="60" y2="8" stroke="#555" strokeWidth="2"/>
<line x1="24" y1="46" x2="48" y2="64" stroke="#222" strokeWidth="2"/>
<line x1="48" y1="64" x2="60" y2="64" stroke="#555" strokeWidth="2"/>
<polygon points="32,40 28,36 32,32" fill="#222"/>
<text x="46" y="42" fontSize="8" fill="#333">MOS</text>
</svg>
);
// ─── Diodes ────────────────────────────────────────────────────────────────
function diodeGlyph(label: string, w: number, h: number): React.ReactElement {
return (
<svg width={w} height={h} viewBox="0 0 72 40" xmlns="http://www.w3.org/2000/svg">
<rect x="14" y="12" width="44" height="16" rx="2" fill="#2a2a2a" stroke="#111" strokeWidth="1"/>
<rect x="18" y="12" width="4" height="16" fill="#eee"/>
<line x1="0" y1="20" x2="14" y2="20" stroke="#888" strokeWidth="2"/>
<line x1="58" y1="20" x2="72" y2="20" stroke="#888" strokeWidth="2"/>
<text x="36" y="25" fontSize="8" fill="#eee" textAnchor="middle" fontFamily="sans-serif">{label}</text>
</svg>
);
}
const Diode1N4007: React.FC<InlineSVGProps> = ({ w, h }) => diodeGlyph('1N4007', w, h);
const Diode1N5817: React.FC<InlineSVGProps> = ({ w, h }) => diodeGlyph('1N5817', w, h);
const DiodeZener: React.FC<InlineSVGProps> = ({ w, h }) => diodeGlyph('ZD', w, h);
// ─── Passives ──────────────────────────────────────────────────────────────
const Capacitor: React.FC<InlineSVGProps> = ({ w, h }) => (
<svg width={w} height={h} viewBox="0 0 56 36" xmlns="http://www.w3.org/2000/svg">
<line x1="0" y1="18" x2="22" y2="18" stroke="#555" strokeWidth="2"/>
<line x1="34" y1="18" x2="56" y2="18" stroke="#555" strokeWidth="2"/>
<line x1="22" y1="6" x2="22" y2="30" stroke="#222" strokeWidth="3"/>
<line x1="34" y1="6" x2="34" y2="30" stroke="#222" strokeWidth="3"/>
<text x="28" y="34" fontSize="6" fill="#666" textAnchor="middle">C</text>
</svg>
);
// ─── Relay ─────────────────────────────────────────────────────────────────
const Relay: React.FC<InlineSVGProps> = ({ w, h }) => (
<svg width={w} height={h} viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="8" width="76" height="80" rx="4" fill="#f8f4ee" stroke="#2a2a2a" strokeWidth="1.5"/>
<line x1="0" y1="16" x2="18" y2="16" stroke="#555" strokeWidth="2"/>
<line x1="0" y1="80" x2="18" y2="80" stroke="#555" strokeWidth="2"/>
<line x1="22" y1="16" x2="22" y2="80" stroke="#8a5a00" strokeWidth="1.5"/>
{[24, 34, 44, 54, 64, 74].map(cy => (
<circle key={cy} cx="22" cy={cy} r="3" fill="none" stroke="#8a5a00" strokeWidth="1.5"/>
))}
<line x1="60" y1="48" x2="80" y2="48" stroke="#555" strokeWidth="2"/>
<line x1="72" y1="20" x2="86" y2="16" stroke="#2a2a2a" strokeWidth="2"/>
<line x1="86" y1="16" x2="96" y2="16" stroke="#555" strokeWidth="2"/>
<line x1="86" y1="80" x2="96" y2="80" stroke="#555" strokeWidth="2"/>
<text x="48" y="54" textAnchor="middle" fontSize="9" fill="#333" fontWeight="bold">RELAY</text>
</svg>
);
// ─── Optocoupler ───────────────────────────────────────────────────────────
function optoGlyph(label: string, w: number, h: number): React.ReactElement {
return (
<svg width={w} height={h} viewBox="0 0 80 64" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="8" width="60" height="48" rx="3" fill="#f8f4ee" stroke="#2a2a2a" strokeWidth="1.5"/>
<line x1="40" y1="8" x2="40" y2="56" stroke="#2a2a2a" strokeWidth="0.8" strokeDasharray="2,2"/>
<polygon points="18,22 18,38 30,30" fill="#f8f4ee" stroke="#2a2a2a" strokeWidth="1.2"/>
<line x1="30" y1="22" x2="30" y2="38" stroke="#2a2a2a" strokeWidth="1.5"/>
<line x1="46" y1="20" x2="46" y2="40" stroke="#2a2a2a" strokeWidth="2"/>
<line x1="46" y1="25" x2="58" y2="18" stroke="#2a2a2a" strokeWidth="1.2"/>
<line x1="46" y1="35" x2="58" y2="42" stroke="#2a2a2a" strokeWidth="1.2"/>
<line x1="0" y1="16" x2="10" y2="16" stroke="#555" strokeWidth="2"/>
<line x1="0" y1="48" x2="10" y2="48" stroke="#555" strokeWidth="2"/>
<line x1="70" y1="16" x2="80" y2="16" stroke="#555" strokeWidth="2"/>
<line x1="70" y1="48" x2="80" y2="48" stroke="#555" strokeWidth="2"/>
<text x="40" y="62" textAnchor="middle" fontSize="7" fill="#333" fontWeight="bold">{label}</text>
</svg>
);
}
const Opto4N25: React.FC<InlineSVGProps> = ({ w, h }) => optoGlyph('4N25', w, h);
// ─── DIP IC block (motor driver, etc.) ─────────────────────────────────────
function dipGlyph(label: string, w: number, h: number): React.ReactElement {
return (
<svg width={w} height={h} viewBox="0 0 100 80" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="4" width="80" height="72" rx="4" fill="#1f2937" stroke="#eef3fa" strokeWidth="1"/>
<circle cx="18" cy="12" r="2" fill="#eef3fa"/>
{[12, 20, 28, 36, 44, 52, 60, 68].map(y => (
<React.Fragment key={y}>
<line x1="0" y1={y} x2="10" y2={y} stroke="#bbb" strokeWidth="1.5"/>
<line x1="90" y1={y} x2="100" y2={y} stroke="#bbb" strokeWidth="1.5"/>
</React.Fragment>
))}
<text x="50" y="44" textAnchor="middle" fontSize="10" fill="#e5e7eb" fontWeight="bold">{label}</text>
</svg>
);
}
const MotorDriverL293D: React.FC<InlineSVGProps> = ({ w, h }) => dipGlyph('L293D', w, h);
// ─── Op-amp ────────────────────────────────────────────────────────────────
function opampGlyph(label: string, w: number, h: number): React.ReactElement {
return (
<svg width={w} height={h} viewBox="0 0 80 72" xmlns="http://www.w3.org/2000/svg">
<polygon points="20,8 20,64 66,36" fill="#f8f4ee" stroke="#2a2a2a" strokeWidth="1.5"/>
<line x1="0" y1="22" x2="20" y2="22" stroke="#555" strokeWidth="2"/>
<line x1="0" y1="50" x2="20" y2="50" stroke="#555" strokeWidth="2"/>
<line x1="66" y1="36" x2="80" y2="36" stroke="#555" strokeWidth="2"/>
<text x="26" y="26" fontSize="10" fill="#333" fontWeight="bold"></text>
<text x="26" y="55" fontSize="10" fill="#333" fontWeight="bold">+</text>
<text x="40" y="68" textAnchor="middle" fontSize="7" fill="#666">{label}</text>
</svg>
);
}
const OpampLM358: React.FC<InlineSVGProps> = ({ 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
? <polygon points="12,10 12,38 46,24" fill="#f8f4ee" stroke="#2a2a2a" strokeWidth="1.5"/>
: isOr
? <path d="M 10 8 Q 28 24 10 40 Q 38 40 54 24 Q 38 8 10 8 Z" fill="#f8f4ee" stroke="#2a2a2a" strokeWidth="1.5"/>
: <path d="M 10 8 L 30 8 Q 54 8 54 24 Q 54 40 30 40 L 10 40 Z" fill="#f8f4ee" stroke="#2a2a2a" strokeWidth="1.5"/>;
return (
<svg width={w} height={h} viewBox="0 0 72 48" xmlns="http://www.w3.org/2000/svg">
<line x1="0" y1="16" x2="10" y2="16" stroke="#555" strokeWidth="2"/>
{!isNot && <line x1="0" y1="32" x2="10" y2="32" stroke="#555" strokeWidth="2"/>}
{body}
{exclusive && <path d="M 4 8 Q 14 24 4 40" fill="none" stroke="#2a2a2a" strokeWidth="1.5"/>}
{negated && <circle cx={isNot ? 50 : 58} cy="24" r="3" fill="#f8f4ee" stroke="#2a2a2a" strokeWidth="1.5"/>}
<line x1={negated ? (isNot ? 53 : 61) : (isNot ? 46 : 54)} y1="24" x2="72" y2="24" stroke="#555" strokeWidth="2"/>
</svg>
);
}
const GateAnd: React.FC<InlineSVGProps> = ({ w, h }) => gateShape('and', w, h);
const GateNand: React.FC<InlineSVGProps> = ({ w, h }) => gateShape('nand', w, h);
const GateOr: React.FC<InlineSVGProps> = ({ w, h }) => gateShape('or', w, h);
const GateNor: React.FC<InlineSVGProps> = ({ w, h }) => gateShape('nor', w, h);
const GateXor: React.FC<InlineSVGProps> = ({ w, h }) => gateShape('xor', w, h);
const GateXnor: React.FC<InlineSVGProps> = ({ w, h }) => gateShape('xnor', w, h);
const GateNot: React.FC<InlineSVGProps> = ({ w, h }) => gateShape('not', w, h);
// ─── Power / instruments ───────────────────────────────────────────────────
function reg3pinGlyph(label: string, w: number, h: number): React.ReactElement {
return (
<svg width={w} height={h} viewBox="0 0 72 56" xmlns="http://www.w3.org/2000/svg">
<rect x="8" y="8" width="56" height="40" rx="4" fill="#2a2a2a" stroke="#111" strokeWidth="1.5"/>
<line x1="20" y1="48" x2="20" y2="56" stroke="#888" strokeWidth="2"/>
<line x1="36" y1="48" x2="36" y2="56" stroke="#888" strokeWidth="2"/>
<line x1="52" y1="48" x2="52" y2="56" stroke="#888" strokeWidth="2"/>
<text x="36" y="32" textAnchor="middle" fontSize="10" fill="#eee" fontWeight="bold">{label}</text>
</svg>
);
}
const Reg7805: React.FC<InlineSVGProps> = ({ w, h }) => reg3pinGlyph('7805', w, h);
const RegLM317: React.FC<InlineSVGProps> = ({ w, h }) => reg3pinGlyph('LM317', w, h);
const Battery9V: React.FC<InlineSVGProps> = ({ w, h }) => (
<svg width={w} height={h} viewBox="0 0 48 72" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="12" width="36" height="52" rx="3" fill="#c9a23a" stroke="#7a5e00" strokeWidth="1.5"/>
<rect x="14" y="4" width="8" height="10" rx="1" fill="#ddd" stroke="#777" strokeWidth="1"/>
<rect x="26" y="4" width="8" height="10" rx="1" fill="#ddd" stroke="#777" strokeWidth="1"/>
<text x="24" y="40" textAnchor="middle" fontSize="12" fill="#3a2d00" fontWeight="bold">9V</text>
</svg>
);
const SignalGenerator: React.FC<InlineSVGProps> = ({ w, h }) => (
<svg width={w} height={h} viewBox="0 0 80 64" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="4" width="72" height="56" rx="5" fill="#1f2937" stroke="#eef3fa" strokeWidth="1.2"/>
<rect x="10" y="10" width="60" height="20" rx="2" fill="#061018" stroke="#0e8c70" strokeWidth="1"/>
<path d="M 14 20 Q 22 12 30 20 T 46 20 T 62 20" fill="none" stroke="#4ade80" strokeWidth="1.5"/>
<circle cx="20" cy="46" r="5" fill="#374151" stroke="#9ca3af" strokeWidth="1"/>
<text x="56" y="50" textAnchor="middle" fontSize="7" fill="#e5e7eb">SIG GEN</text>
</svg>
);
// ─── Registry ──────────────────────────────────────────────────────────────
interface InlineEntry {
component: React.FC<InlineSVGProps>;
w: number;
h: number;
}
export const INLINE_SVGS: Record<string, InlineEntry> = {
// 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 },
};

View File

@ -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<PropertyChangeDetail>).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);

View File

@ -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'),
],
},

View File

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

View File

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

View File

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

View File

@ -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) */

View File

@ -941,29 +941,43 @@ const MAPPERS: Record<string, Mapper> = {
// 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`);
}