-
-
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 && (
-
{
- setSearchQuery('');
- setSelectedCategory('all');
- }}
- >
- Clear filters
-
- )}
-
- ) : (
- filteredComponents.map((component) => (
-
onSelectComponent(component)}
- />
- ))
)}
+
+
+ {isLoading ? (
+
+
+
Loading components...
+
+ ) : filteredComponents.length === 0 ? (
+
+
No components found
+ {searchQuery && (
+
{
+ setSearchQuery('');
+ setSelectedCategory('all');
+ }}
+ >
+ Clear filters
+
+ )}
+
+ ) : (
+ 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`);
}