velxio/frontend/src/components/DynamicComponent.tsx

757 lines
29 KiB
TypeScript
Raw Normal View History

/**
* Dynamic Component Renderer
*
* Generic component that renders any wokwi-element web component dynamically.
* Replaces individual React wrapper components (LED.tsx, Resistor.tsx, etc.)
*
* Features:
* - Creates web component from metadata
* - Syncs React props to web component properties
* - Extracts pinInfo from DOM for wire connections
* - Handles component lifecycle
*/
import React, { useRef, useEffect, useCallback } from 'react';
import type { ComponentMetadata } from '../types/component-metadata';
import { useSimulatorStore } from '../store/useSimulatorStore';
fix(canvas): board-less SPICE switches toggle on click instead of opening property dialog In digital / analog board-less examples the user clicks a slide-switch or pushbutton expecting it to flip its state. Until this commit the component property dialog opened instead and the click never reached the wokwi-element underneath, so: - The user couldn't change switch state through the canvas at all. - With no state change the SPICE solver kept the old netlist, and every downstream LED stayed dark — the symptom that read as "voltages change but no LED lights". Root cause was the gating: SimulatorCanvas only suppressed the property dialog when `useSimulatorStore.running` was true, but that flag is bound to an MCU's start/stop. Board-less circuits have no MCU to start so `running` is permanently false, even when the SPICE engine has been live since the example loaded. New derived flag `interactionRunning = running || (boards.length === 0 && !electricalPaused)` — true whenever the user is in an "interactive" session, MCU or SPICE-only. Used in three click-handling paths: - SimulatorCanvas mouse-up handler: dialog is suppressed and the click falls through to the wokwi-element (line 1395). - SimulatorCanvas touch-start passthrough: same for touch (line 474). - SimulatorCanvas touch-end short-tap: same for tap (line 774). Also propagated to DynamicComponent so the cursor becomes pointer (not move) for interactive parts in board-less mode — visual cue that the user can click instead of just drag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:34:58 +07:00
import { useElectricalStore } from '../store/useElectricalStore';
feat(microsd): SD-over-SPI card storage for AVR, RP2040 and ESP32 Add a working microSD card part backed by a FAT16 image, following the Wokwi storage model: the project's own workspace files are auto-copied onto the card (free), and an optional "SD Card" panel uploads extra files (gated as a paid feature by the velxio.dev overlay; OSS default allows it). Frontend (in-browser AVR / RP2040): - ProtocolParts.ts: rewrite the microsd-card part from a handshake stub into a real SD-over-SPI device (reply-first Ncr timing, SDSC byte addressing, single/multi-block read+write, CSD/CID, full CMD set). - utils/fatImage.ts: dependency-free FAT16 super-floppy builder (8.3 + LFN). - utils/sdCardFiles.ts: assemble the card image from workspace files plus uploaded files; base64 helpers. - components/simulator/SdCardPanel.tsx + ComponentPropertyDialog: upload UI. - DynamicComponent + useSimulatorStore: build and inject the image on run. - lib/proSdCardGate.ts: overlay-installable gate for the upload action. - data/examples-storage-microsd.ts: Arduino Uno + ESP32 gallery examples. Backend (ESP32 via QEMU): - services/esp32_sd_slave.py: synchronous SD-over-SPI slave (Python port of the browser part) with a sparse backing store, idle-state R1 tracking and real CRC16 on data blocks when the host enables CRC (CMD59) -- both required by ESP-IDF's sdspi driver. - esp32_worker.py: route SPI bytes to the slave (returns MISO synchronously) and feed write-only bulk transfers. - esp32_lib_manager.py + routes/simulation.py: forward the FAT image (sd_card.image_b64) from the start config into the worker. Tested: - frontend: protocol-parts, fat-image, sd-card-gate and microsd-real-firmware (real Arduino SD.h on avr8js) -- 86 passing. - backend: test_esp32_sd_slave (10) covering the ESP-IDF init sequence and CRC16; validated end to end by running a real SD.h sketch in libqemu-xtensa (mount, directory listing, read and write-readback).
2026-06-11 08:59:53 +07:00
import { useEditorStore } from '../store/useEditorStore';
import { buildProjectSdImage, decodeSdFiles } from '../utils/sdCardFiles';
import { PartSimulationRegistry } from '../simulation/parts';
import { isBoardComponent, boardPinToNumber } from '../utils/boardPinMapping';
import { isKeyBindable, formatKeyLabel } from '../utils/keyButtonBindings';
feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection Adds the architecture pieces for mixed-mode coupling without yet driving the SPICE engine. Components on a path that crosses an active device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route through a new SPICE-resolved PinResolver variant; everything else keeps the digital fast-path from Phase 0. What ships: - simulation/PinResolver.ts * `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES` list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay). * `DetailedPinTrace` / `DetailedPinTracer` types — the trace function now reports whether it crossed an active device, on top of the Arduino pin number. * `createSpiceResolvedPinResolver()` — new factory; reads voltages from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW with hysteresis (thresholdHigh != thresholdLow → Schmitt-like). - simulation/spice/MixedModeScheduler.ts (new) * Singleton orchestrator that holds the NgSpiceInteractive engine and the SpiceVoltageSource subscription registry. * `start()` / `stop()` / `dispose()` lifecycle. * `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource. * `onMcuPinChange()` placeholder for the alter+tran event loop. * Skeleton: subscribers register but never receive events yet. Phase 1b continued will wire NgSpiceInteractive into the loop. - components/DynamicComponent.tsx * Trace function extended with `traceDetailed()` that tracks whether the BFS crossed an active component. * PinResolver factory branches: active-path → SPICE-resolved (uses the scheduler), digital-only → existing default impl. Default threshold = vcc/2 with no hysteresis; Phase 3 will replace with per-logic-family Vil/Vih. Phase 0 LED behavior intact (digital path). Phase 1b SPICE-resolved path falls back to FLOATING until Phase 1b continued wires the engine. Tests: - pin-resolver-phase1b.test.ts (new) — 8/8 passing. Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator metadata id; SPICE-resolved resolver state reporting, threshold conversion, hysteresis dead-band, unsubscribe. - pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression). - tsc --noEmit on the new files: clean. No deploy in this commit — staged for end-of-session rebuild + push per user preference.
2026-05-15 21:38:30 +07:00
import {
createDefaultPinResolver,
createSpiceResolvedPinResolver,
feat(sim): Phase 3 — logic families (TTL/CMOS-5V/LVCMOS33/AVR_HC/Schmitt) Replaces the Phase 1b vcc/2-flat threshold with per-logic-family Vil/Vih thresholds + Schmitt-trigger hysteresis where applicable. SPICE-resolved digital reads now match what real ICs actually do — TTL noise margins, CMOS rail-to-rail, 74HC14 Schmitt hysteresis, LVCMOS33 vs CMOS-5V interop. New module: simulation/LogicFamilies.ts - LogicFamily interface (vcc, vil, vih, vil_schmitt?, vih_schmitt?, cin_pF, vol_max?, voh_min?, output_impedance_ohm?) - FAMILIES catalog: TTL, CMOS-5V, CMOS-5V-SCHMITT, CMOS-5V-TTL-INPUTS, LVCMOS33, AVR_HC, CMOS-3.3V — all sourced from TI / ATmega328P / JEDEC datasheets. - BOARD_FAMILY: per-board lookup. Uno/Mega/Nano/ATtiny → AVR_HC, ESP32 family + Pi Pico → LVCMOS33, fall back to AVR_HC for unknown boards. - getBoardLogicFamily() and getLogicFamilyById() helpers. PinResolver: - SpiceResolvedConfig docstring rewritten with Phase 3 wording. - New `configFromLogicFamily()` builder — picks Schmitt thresholds when the family declares them, falls back to vih/vil otherwise. DynamicComponent: - When the trace crosses an active device, the SPICE-resolved resolver is now built with the OWNER BOARD's logic family instead of vcc/2. Hysteresis comes through automatically for boards whose native family is Schmitt-capable. - Phase 3 continued: per-component logicFamily override from components-metadata.json (so e.g. a 74HC14 placed on an Arduino Uno gets Schmitt thresholds even though the BOARD is AVR_HC). Tests: - logic-families.test.ts (new) — 19/19 passing. Covers catalog sanity (vil < vih, vol_max ≤ vil, voh_min ≥ vih), per-board lookup, Schmitt vs non-Schmitt config, noise rejection behavior of 74HC14 Schmitt resolver, last-state-wins behavior of CMOS-5V dead band. - Phase 0 + Phase 1b regression: 16/16 still passing. - tsc --noEmit on new files: clean. No deploy in this commit — staged for end-of-session rebuild.
2026-05-15 21:42:11 +07:00
configFromLogicFamily,
feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection Adds the architecture pieces for mixed-mode coupling without yet driving the SPICE engine. Components on a path that crosses an active device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route through a new SPICE-resolved PinResolver variant; everything else keeps the digital fast-path from Phase 0. What ships: - simulation/PinResolver.ts * `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES` list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay). * `DetailedPinTrace` / `DetailedPinTracer` types — the trace function now reports whether it crossed an active device, on top of the Arduino pin number. * `createSpiceResolvedPinResolver()` — new factory; reads voltages from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW with hysteresis (thresholdHigh != thresholdLow → Schmitt-like). - simulation/spice/MixedModeScheduler.ts (new) * Singleton orchestrator that holds the NgSpiceInteractive engine and the SpiceVoltageSource subscription registry. * `start()` / `stop()` / `dispose()` lifecycle. * `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource. * `onMcuPinChange()` placeholder for the alter+tran event loop. * Skeleton: subscribers register but never receive events yet. Phase 1b continued will wire NgSpiceInteractive into the loop. - components/DynamicComponent.tsx * Trace function extended with `traceDetailed()` that tracks whether the BFS crossed an active component. * PinResolver factory branches: active-path → SPICE-resolved (uses the scheduler), digital-only → existing default impl. Default threshold = vcc/2 with no hysteresis; Phase 3 will replace with per-logic-family Vil/Vih. Phase 0 LED behavior intact (digital path). Phase 1b SPICE-resolved path falls back to FLOATING until Phase 1b continued wires the engine. Tests: - pin-resolver-phase1b.test.ts (new) — 8/8 passing. Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator metadata id; SPICE-resolved resolver state reporting, threshold conversion, hysteresis dead-band, unsubscribe. - pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression). - tsc --noEmit on the new files: clean. No deploy in this commit — staged for end-of-session rebuild + push per user preference.
2026-05-15 21:38:30 +07:00
isActiveDevice,
type PinResolver,
} from '../simulation/PinResolver';
feat(sim): introduce PinResolver abstraction (Phase 0 of mixed-mode rewrite) Decouple per-component handlers from direct pinManager.onPinChange + getArduinoPinHelper subscriptions by introducing a small PinResolver interface. The Phase 0 default impl is functionally identical to the legacy path — it just routes through PinResolver instead of being inlined in every handler. Zero behavior change. The point is to make Phase 1 possible: swap the default impl for a SPICE-resolved version that watches node voltages and threshold- converts to digital events, without rewriting every handler. Files: - simulation/PinResolver.ts (new) — interface + default factory - parts/PartSimulationRegistry.ts — additive 5th arg to attachEvents (getPinResolver?), legacy 4-arg signatures keep working unchanged - components/DynamicComponent.tsx — assembles the PinResolver from the wire-trace logic + PinManager subscriptions + board Vcc lookup, passes it as the 5th arg to attachEvents - parts/BasicParts.ts — LED handler migrated as proof of concept (resolver-first path, legacy 4-arg path kept as fallback for tests / unmigrated harnesses) - __tests__/pin-resolver.test.ts (new) — 8 unit tests covering FLOATING / GND / HIGH / LOW / GPIO subscriptions / unsubscribe Vitest: 8/8 pin-resolver tests pass. 1300+ existing tests still pass; the one pre-existing flake (spice-rectifier-live-repro timing out >60s) is unrelated to this commit — verified by running the test on plain HEAD without these changes (same timeout). See project/sim-mixedmode/phase-00-pin-resolver.md (in the velxio-prod repo) for full phase context.
2026-05-15 20:50:09 +07:00
import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups';
import { syntheticChipPin } from '../simulation/customChips/syntheticPins';
import { resolveChipNetKey } from '../simulation/customChips/chipNets';
feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection Adds the architecture pieces for mixed-mode coupling without yet driving the SPICE engine. Components on a path that crosses an active device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route through a new SPICE-resolved PinResolver variant; everything else keeps the digital fast-path from Phase 0. What ships: - simulation/PinResolver.ts * `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES` list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay). * `DetailedPinTrace` / `DetailedPinTracer` types — the trace function now reports whether it crossed an active device, on top of the Arduino pin number. * `createSpiceResolvedPinResolver()` — new factory; reads voltages from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW with hysteresis (thresholdHigh != thresholdLow → Schmitt-like). - simulation/spice/MixedModeScheduler.ts (new) * Singleton orchestrator that holds the NgSpiceInteractive engine and the SpiceVoltageSource subscription registry. * `start()` / `stop()` / `dispose()` lifecycle. * `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource. * `onMcuPinChange()` placeholder for the alter+tran event loop. * Skeleton: subscribers register but never receive events yet. Phase 1b continued will wire NgSpiceInteractive into the loop. - components/DynamicComponent.tsx * Trace function extended with `traceDetailed()` that tracks whether the BFS crossed an active component. * PinResolver factory branches: active-path → SPICE-resolved (uses the scheduler), digital-only → existing default impl. Default threshold = vcc/2 with no hysteresis; Phase 3 will replace with per-logic-family Vil/Vih. Phase 0 LED behavior intact (digital path). Phase 1b SPICE-resolved path falls back to FLOATING until Phase 1b continued wires the engine. Tests: - pin-resolver-phase1b.test.ts (new) — 8/8 passing. Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator metadata id; SPICE-resolved resolver state reporting, threshold conversion, hysteresis dead-band, unsubscribe. - pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression). - tsc --noEmit on the new files: clean. No deploy in this commit — staged for end-of-session rebuild + push per user preference.
2026-05-15 21:38:30 +07:00
import { getMixedModeScheduler } from '../simulation/spice/MixedModeScheduler';
feat(sim): Phase 3 — logic families (TTL/CMOS-5V/LVCMOS33/AVR_HC/Schmitt) Replaces the Phase 1b vcc/2-flat threshold with per-logic-family Vil/Vih thresholds + Schmitt-trigger hysteresis where applicable. SPICE-resolved digital reads now match what real ICs actually do — TTL noise margins, CMOS rail-to-rail, 74HC14 Schmitt hysteresis, LVCMOS33 vs CMOS-5V interop. New module: simulation/LogicFamilies.ts - LogicFamily interface (vcc, vil, vih, vil_schmitt?, vih_schmitt?, cin_pF, vol_max?, voh_min?, output_impedance_ohm?) - FAMILIES catalog: TTL, CMOS-5V, CMOS-5V-SCHMITT, CMOS-5V-TTL-INPUTS, LVCMOS33, AVR_HC, CMOS-3.3V — all sourced from TI / ATmega328P / JEDEC datasheets. - BOARD_FAMILY: per-board lookup. Uno/Mega/Nano/ATtiny → AVR_HC, ESP32 family + Pi Pico → LVCMOS33, fall back to AVR_HC for unknown boards. - getBoardLogicFamily() and getLogicFamilyById() helpers. PinResolver: - SpiceResolvedConfig docstring rewritten with Phase 3 wording. - New `configFromLogicFamily()` builder — picks Schmitt thresholds when the family declares them, falls back to vih/vil otherwise. DynamicComponent: - When the trace crosses an active device, the SPICE-resolved resolver is now built with the OWNER BOARD's logic family instead of vcc/2. Hysteresis comes through automatically for boards whose native family is Schmitt-capable. - Phase 3 continued: per-component logicFamily override from components-metadata.json (so e.g. a 74HC14 placed on an Arduino Uno gets Schmitt thresholds even though the BOARD is AVR_HC). Tests: - logic-families.test.ts (new) — 19/19 passing. Covers catalog sanity (vil < vih, vol_max ≤ vil, voh_min ≥ vih), per-board lookup, Schmitt vs non-Schmitt config, noise rejection behavior of 74HC14 Schmitt resolver, last-state-wins behavior of CMOS-5V dead band. - Phase 0 + Phase 1b regression: 16/16 still passing. - tsc --noEmit on new files: clean. No deploy in this commit — staged for end-of-session rebuild.
2026-05-15 21:42:11 +07:00
import { getBoardLogicFamily } from '../simulation/LogicFamilies';
import { breadboardGroupKey } from '../utils/breadboardNets';
// Side-effect imports: register every web component we'll create at runtime.
// `@wokwi/elements` covers the upstream catalog; `../velxio-elements` adds
// the velxio-local elements (e.g. <velxio-capacitor-electrolytic>,
// <velxio-instr-voltmeter>) that don't exist upstream.
import '@wokwi/elements';
import '../velxio-elements';
import './velxio-components/Ssd1306I2cElement'; // registers velxio-ssd1306-i2c-4pin (4-pin I2C OLED)
// Map metadataId → [pinA, pinB] for 2-terminal passives.
// "Tracing through" means: if the caller arrived on pinA, continue from pinB
// (and vice-versa).
//
// NOTE: diodes / transistors / op-amps are NOT traced through as passives —
// they have polarity / Vf / non-linear behaviour that the digital layer
// cannot interpret as "same pin". BJTs are an explicit shortcut for the
// canonical "Arduino digital pin controls a load via transistor" pattern so
// 7-segment multiplex circuits with BJT digit drivers still resolve.
const PASSIVE_PIN_PAIRS_BASE: Record<string, [string, string]> = {
resistor: ['1', '2'],
'resistor-us': ['1', '2'],
capacitor: ['1', '2'],
'capacitor-electrolytic': ['+', ''],
inductor: ['1', '2'],
'analog-resistor': ['A', 'B'],
'analog-capacitor': ['A', 'B'],
'analog-inductor': ['A', 'B'],
'bjt-2n2222': ['C', 'B'],
'bjt-bc547': ['C', 'B'],
'bjt-2n3055': ['C', 'B'],
'bjt-2n3906': ['C', 'B'],
'bjt-bc557': ['C', 'B'],
};
// Preset variants of the generic passives share their parent's tag and pin
// layout. Mirrors the PASSIVE_PRESETS map in spice/componentToSpice.ts.
const PRESET_TO_BASE: Record<string, string> = {
'resistor-220': 'resistor',
'resistor-330': 'resistor',
'resistor-470': 'resistor',
'resistor-1k': 'resistor',
'resistor-2k2': 'resistor',
'resistor-4k7': 'resistor',
'resistor-10k': 'resistor',
'resistor-22k': 'resistor',
'resistor-47k': 'resistor',
'resistor-100k': 'resistor',
'resistor-1m': 'resistor',
'cap-10p': 'capacitor',
'cap-22p': 'capacitor',
'cap-100p': 'capacitor',
'cap-1n': 'capacitor',
'cap-10n': 'capacitor',
'cap-100n': 'capacitor',
'cap-1u': 'capacitor',
'cap-elec-1u': 'capacitor-electrolytic',
'cap-elec-10u': 'capacitor-electrolytic',
'cap-elec-47u': 'capacitor-electrolytic',
'cap-elec-100u': 'capacitor-electrolytic',
'cap-elec-470u': 'capacitor-electrolytic',
'cap-elec-1000u': 'capacitor-electrolytic',
'ind-100u': 'inductor',
'ind-1m': 'inductor',
'ind-10m': 'inductor',
};
const PASSIVE_PIN_PAIRS: Record<string, [string, string]> = {
...PASSIVE_PIN_PAIRS_BASE,
};
for (const [preset, base] of Object.entries(PRESET_TO_BASE)) {
PASSIVE_PIN_PAIRS[preset] = PASSIVE_PIN_PAIRS_BASE[base];
}
type TraceState = ReturnType<typeof useSimulatorStore.getState>;
// Custom-chip output pins get stable synthetic pin numbers from
// simulation/customChips/syntheticPins so the chip is a first-class pin source.
// Depth-limited BFS: trace from (fromId, fromPin) through wires, traversing
// through passive components to reach a board pin. Returns the arduino pin
// plus a `crossedActiveDevice` flag so the resolver factory can decide
// between digital fast-path and SPICE-resolved per-pin.
//
// A real board pin always wins (digital GPIO semantics are unchanged). Only
// when NO board pin is reachable do we fall back to a custom-chip pin on the
// net — either a neighbour chip pin, or (when the trace itself started at a
// chip pin) the starting chip pin — resolving it to its synthetic number.
//
// Lifted to module scope (was inside getArduinoPin) so that getPinResolver
// can call it too — the previous nested-scope version caused a runtime
// ReferenceError "traceDetailed is not defined" on the simulator page.
function traceDetailed(
state: TraceState,
fromId: string,
fromPin: string,
depth: number,
activeSeen = false,
): { arduinoPin: number | null; crossedActiveDevice: boolean } {
if (depth > 6) return { arduinoPin: null, crossedActiveDevice: activeSeen };
const wires = state.wires.filter(
(w) =>
(w.start.componentId === fromId && w.start.pinName === fromPin) ||
(w.end.componentId === fromId && w.end.pinName === fromPin),
);
// Remember a custom-chip neighbour on this net (if any) as a fallback —
// a real board pin found in any branch still takes priority over it.
let chipNeighbour: { id: string; pin: string } | null = null;
for (const w of wires) {
const selfEp =
w.start.componentId === fromId && w.start.pinName === fromPin ? w.start : w.end;
const otherEp = selfEp === w.start ? w.end : w.start;
if (isBoardComponent(otherEp.componentId)) {
const boardKind =
state.boards.find((b) => b.id === otherEp.componentId)?.boardKind ??
otherEp.componentId;
const pin = boardPinToNumber(boardKind, otherEp.pinName);
if (pin !== null) return { arduinoPin: pin, crossedActiveDevice: activeSeen };
} else {
const comp = state.components.find((c) => c.id === otherEp.componentId);
if (!chipNeighbour && comp?.metadataId === 'custom-chip') {
chipNeighbour = { id: otherEp.componentId, pin: otherEp.pinName };
}
const pair = comp && PASSIVE_PIN_PAIRS[comp.metadataId];
if (pair) {
const [p1, p2] = pair;
const otherPin = otherEp.pinName === p1 ? p2 : p1;
const nowActive =
activeSeen || (comp ? isActiveDevice(comp.metadataId) : false);
const result = traceDetailed(
state,
otherEp.componentId,
otherPin,
depth + 1,
nowActive,
);
if (result.arduinoPin !== null) return result;
}
// Breadboards join N holes per internal group (5-hole strip / power
// rail), which the 2-terminal PASSIVE_PIN_PAIRS map can't express.
// Continue the trace from every OTHER wired hole in the same group.
const bbGroup = comp && breadboardGroupKey(comp.metadataId, otherEp.pinName);
if (bbGroup && comp) {
const groupPins = new Set<string>();
for (const gw of state.wires) {
for (const ep of [gw.start, gw.end]) {
if (
ep.componentId === comp.id &&
ep.pinName !== otherEp.pinName &&
breadboardGroupKey(comp.metadataId, ep.pinName) === bbGroup
) {
groupPins.add(ep.pinName);
}
}
}
for (const groupPin of groupPins) {
const result = traceDetailed(state, comp.id, groupPin, depth + 1, activeSeen);
if (result.arduinoPin !== null) return result;
}
}
}
}
// No board pin reachable. Multi-chip digital bus (chipbus flag, Phase 0 of
// project/multichip-bus/): when this net has two or more chip endpoints and
// no board pin, collapse every endpoint onto ONE net-canonical synthetic key
// so a write on one chip is visible to another through the synchronous
// PinManager fan-out (fixes root cause A: per-endpoint keys never matching).
// resolveChipNetKey returns null when the flag is off, when a board owns the
// net, or when there is a single chip endpoint — so the chip-to-component
// rules below (2 and 3) are left exactly as-is. Scoped to depth 0 (the
// starting chip pin); the key is net-bound, so a pin flipping INPUT<->OUTPUT
// keeps the same key with no re-trace.
if (depth === 0) {
const netKey = resolveChipNetKey(state, fromId, fromPin);
if (netKey !== null) {
return { arduinoPin: netKey, crossedActiveDevice: activeSeen };
}
}
// No board pin reachable. Fall back to a custom-chip pin on this net so the
// chip can still drive / read it through the synthetic-pin PinManager key.
if (chipNeighbour) {
return {
arduinoPin: syntheticChipPin(chipNeighbour.id, chipNeighbour.pin),
crossedActiveDevice: activeSeen,
};
}
if (depth === 0 && state.components.find((c) => c.id === fromId)?.metadataId === 'custom-chip') {
return { arduinoPin: syntheticChipPin(fromId, fromPin), crossedActiveDevice: activeSeen };
}
return { arduinoPin: null, crossedActiveDevice: activeSeen };
}
interface DynamicComponentProps {
id: string;
metadata: ComponentMetadata;
properties: Record<string, any>;
x?: number;
y?: number;
isSelected?: boolean;
onMouseDown?: (e: React.MouseEvent) => void;
onDoubleClick?: (e: React.MouseEvent) => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
onPinInfoReady?: (pinInfo: any[]) => void;
}
export const DynamicComponent: React.FC<DynamicComponentProps> = ({
id,
metadata,
properties,
x = 0,
y = 0,
isSelected = false,
onMouseDown,
onDoubleClick,
onMouseEnter,
onMouseLeave,
onPinInfoReady,
}) => {
const elementRef = useRef<HTMLElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const mountedRef = useRef(false);
const handleComponentEvent = useSimulatorStore((s) => s.handleComponentEvent);
const running = useSimulatorStore((s) => s.running);
const simulator = useSimulatorStore((s) => s.simulator);
fix(canvas): board-less SPICE switches toggle on click instead of opening property dialog In digital / analog board-less examples the user clicks a slide-switch or pushbutton expecting it to flip its state. Until this commit the component property dialog opened instead and the click never reached the wokwi-element underneath, so: - The user couldn't change switch state through the canvas at all. - With no state change the SPICE solver kept the old netlist, and every downstream LED stayed dark — the symptom that read as "voltages change but no LED lights". Root cause was the gating: SimulatorCanvas only suppressed the property dialog when `useSimulatorStore.running` was true, but that flag is bound to an MCU's start/stop. Board-less circuits have no MCU to start so `running` is permanently false, even when the SPICE engine has been live since the example loaded. New derived flag `interactionRunning = running || (boards.length === 0 && !electricalPaused)` — true whenever the user is in an "interactive" session, MCU or SPICE-only. Used in three click-handling paths: - SimulatorCanvas mouse-up handler: dialog is suppressed and the click falls through to the wokwi-element (line 1395). - SimulatorCanvas touch-start passthrough: same for touch (line 474). - SimulatorCanvas touch-end short-tap: same for tap (line 774). Also propagated to DynamicComponent so the cursor becomes pointer (not move) for interactive parts in board-less mode — visual cue that the user can click instead of just drag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:34:58 +07:00
// Board-less SPICE circuits (digital / analog gallery) have no MCU to
// run, so `running` is always false — but interactive parts like
// slide-switches and pushbuttons should still show a pointer cursor
// and let the user click them. We treat board-less + un-paused as
// "interactive" so the cursor + dialog gating mirror the MCU mode.
const boardCount = useSimulatorStore((s) => s.boards.length);
const electricalPaused = useElectricalStore((s) => s.paused);
const interactionRunning = running || (boardCount === 0 && !electricalPaused);
// hexEpoch increments each time a new hex is loaded, triggering a fresh
// attachEvents call (and re-registration of I2C devices on the new bus).
// We intentionally do NOT depend on `running` so that I2C displays and
// other protocol parts (SSD1306, DS1307 …) are NOT torn down and
// re-created on every stop/play cycle — which previously caused the
// display to flash blank and lose its frame buffer.
const hexEpoch = useSimulatorStore((s) => s.hexEpoch);
// Runtime burnout (P4): destroyed parts render charred + a smoke badge.
const isBurnt = useSimulatorStore((s) => s.burntComponents.has(id));
// Track wires connected to this component so attachEvents re-runs when
// wires are added or removed (e.g. disconnecting an LED cathode from GND).
const wireFingerprint = useSimulatorStore((s) => {
const myWires = s.wires.filter((w) => w.start.componentId === id || w.end.componentId === id);
return myWires.map((w) => w.id).join(',');
});
// Check if component is interactive (has simulation logic with attachEvents)
const logic = PartSimulationRegistry.get(metadata.id || id.split('-')[0]);
const isInteractive = logic?.attachEvents !== undefined;
/**
* Sync React properties to Web Component
*/
useEffect(() => {
if (!elementRef.current) return;
Object.entries(properties).forEach(([key, value]) => {
try {
(elementRef.current as any)[key] = value;
} catch (error) {
console.warn(`Failed to set property ${key} on ${metadata.tagName}:`, error);
}
});
}, [properties, metadata.tagName]);
/**
* Extract pinInfo from web component after it initializes
*/
useEffect(() => {
if (!elementRef.current || !onPinInfoReady) return;
// Wait for web component to fully initialize
const checkPinInfo = () => {
try {
const pinInfo = (elementRef.current as any)?.pinInfo;
if (pinInfo && Array.isArray(pinInfo) && pinInfo.length > 0) {
onPinInfoReady(pinInfo);
return true;
}
} catch {
// Element not ready yet
}
return false;
};
// Try immediately
if (checkPinInfo()) return;
// Otherwise poll every 100ms for up to 2 seconds
const interval = setInterval(() => {
if (checkPinInfo()) {
clearInterval(interval);
}
}, 100);
const timeout = setTimeout(() => {
clearInterval(interval);
}, 2000);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
}, [onPinInfoReady]);
/**
* Handle mouse events
*/
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
fix(ui+spice+example): interactive wokwi components, NTC formula, photoresistor alias Three independent fixes uncovered during a systematic example-by-example audit (plan/full_test_plan/): 1. DynamicComponent.handleMouseDown was calling e.stopPropagation() unconditionally in the capture phase. That swallowed pointerdown BEFORE wokwi-potentiometer / pushbutton / slide-switch / joystick could see it, so the rotary knob would not rotate and buttons wouldn't press even with a real OS mouse. Now we skip the swallow when the click target is an inner wokwi-* element during a live simulation, letting the wokwi component own its own pointerdown while still allowing the canvas drag-to-rearrange flow on the wrapper / non-interactive surface. 2. examples.ts uno-ntc (and pico-ntc) sketch had the NTC divider formula inverted relative to both the SPICE mapper topology (VCC -> R_NTC -> A1 -> R_pull -> GND, the standard module wiring) and real wokwi-ntc-temperature-sensor modules. Moving the slider to 60 C made the firmware print -3.42 C. Flipped the formula to r = SERIES_R * (VCC - v) / v. Now slider 60 C -> Serial reports 60.12 C and A1 voltmeter shows 4.00 V. 3. componentToSpice.ts photoresistor mapper was only registered under the bare key `photoresistor`, but example components use the metadataId `photoresistor-sensor`. Added an alias so the LDR + pull-down divider gets emitted for the real component instance. All three reproduce visually in seconds; documented per-example in plan/full_test_plan/examples/. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:10:42 +07:00
if (!onMouseDown) return;
// Don't swallow the pointerdown for wokwi components that own their
// own pointer interaction (rotary knobs, pushbuttons, slide-switches,
// joysticks, keypads, encoders). For those the wokwi element binds
// pointerdown/move/up on its shadow-DOM SVG; if we call
// stopPropagation() in the capture phase here the internal logic
// never sees the event and the knob can't rotate, the button never
// reports pressed, etc.
fix(ui+spice+example): interactive wokwi components, NTC formula, photoresistor alias Three independent fixes uncovered during a systematic example-by-example audit (plan/full_test_plan/): 1. DynamicComponent.handleMouseDown was calling e.stopPropagation() unconditionally in the capture phase. That swallowed pointerdown BEFORE wokwi-potentiometer / pushbutton / slide-switch / joystick could see it, so the rotary knob would not rotate and buttons wouldn't press even with a real OS mouse. Now we skip the swallow when the click target is an inner wokwi-* element during a live simulation, letting the wokwi component own its own pointerdown while still allowing the canvas drag-to-rearrange flow on the wrapper / non-interactive surface. 2. examples.ts uno-ntc (and pico-ntc) sketch had the NTC divider formula inverted relative to both the SPICE mapper topology (VCC -> R_NTC -> A1 -> R_pull -> GND, the standard module wiring) and real wokwi-ntc-temperature-sensor modules. Moving the slider to 60 C made the firmware print -3.42 C. Flipped the formula to r = SERIES_R * (VCC - v) / v. Now slider 60 C -> Serial reports 60.12 C and A1 voltmeter shows 4.00 V. 3. componentToSpice.ts photoresistor mapper was only registered under the bare key `photoresistor`, but example components use the metadataId `photoresistor-sensor`. Added an alias so the LDR + pull-down divider gets emitted for the real component instance. All three reproduce visually in seconds; documented per-example in plan/full_test_plan/examples/. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:10:42 +07:00
//
// EVERY OTHER component (sensors, displays, LEDs, resistors, even
// ones with attachEvents for the sensor-update / SPICE-prop bridge)
// expects clicks to bubble up to the canvas → open the property
// dialog or grab for drag-to-rearrange. The previous "swallow only
// when isInteractive" heuristic was too broad: it included DHT22,
// HC-SR04, NTC, photoresistor, LED, etc. — all of which have
// attachEvents but no internal pointer handler, so clicks on them
// SHOULD bubble. With the broad guard, those dialogs never opened.
fix(ui+spice+example): interactive wokwi components, NTC formula, photoresistor alias Three independent fixes uncovered during a systematic example-by-example audit (plan/full_test_plan/): 1. DynamicComponent.handleMouseDown was calling e.stopPropagation() unconditionally in the capture phase. That swallowed pointerdown BEFORE wokwi-potentiometer / pushbutton / slide-switch / joystick could see it, so the rotary knob would not rotate and buttons wouldn't press even with a real OS mouse. Now we skip the swallow when the click target is an inner wokwi-* element during a live simulation, letting the wokwi component own its own pointerdown while still allowing the canvas drag-to-rearrange flow on the wrapper / non-interactive surface. 2. examples.ts uno-ntc (and pico-ntc) sketch had the NTC divider formula inverted relative to both the SPICE mapper topology (VCC -> R_NTC -> A1 -> R_pull -> GND, the standard module wiring) and real wokwi-ntc-temperature-sensor modules. Moving the slider to 60 C made the firmware print -3.42 C. Flipped the formula to r = SERIES_R * (VCC - v) / v. Now slider 60 C -> Serial reports 60.12 C and A1 voltmeter shows 4.00 V. 3. componentToSpice.ts photoresistor mapper was only registered under the bare key `photoresistor`, but example components use the metadataId `photoresistor-sensor`. Added an alias so the LDR + pull-down divider gets emitted for the real component instance. All three reproduce visually in seconds; documented per-example in plan/full_test_plan/examples/. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:10:42 +07:00
//
// The whitelist below is tight on purpose: only add a tag name when
// the wokwi element actually has its own pointerdown handler that
// the user needs to reach. If a new interactive part is added,
// append its tag here.
fix(ui+spice+example): interactive wokwi components, NTC formula, photoresistor alias Three independent fixes uncovered during a systematic example-by-example audit (plan/full_test_plan/): 1. DynamicComponent.handleMouseDown was calling e.stopPropagation() unconditionally in the capture phase. That swallowed pointerdown BEFORE wokwi-potentiometer / pushbutton / slide-switch / joystick could see it, so the rotary knob would not rotate and buttons wouldn't press even with a real OS mouse. Now we skip the swallow when the click target is an inner wokwi-* element during a live simulation, letting the wokwi component own its own pointerdown while still allowing the canvas drag-to-rearrange flow on the wrapper / non-interactive surface. 2. examples.ts uno-ntc (and pico-ntc) sketch had the NTC divider formula inverted relative to both the SPICE mapper topology (VCC -> R_NTC -> A1 -> R_pull -> GND, the standard module wiring) and real wokwi-ntc-temperature-sensor modules. Moving the slider to 60 C made the firmware print -3.42 C. Flipped the formula to r = SERIES_R * (VCC - v) / v. Now slider 60 C -> Serial reports 60.12 C and A1 voltmeter shows 4.00 V. 3. componentToSpice.ts photoresistor mapper was only registered under the bare key `photoresistor`, but example components use the metadataId `photoresistor-sensor`. Added an alias so the LDR + pull-down divider gets emitted for the real component instance. All three reproduce visually in seconds; documented per-example in plan/full_test_plan/examples/. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:10:42 +07:00
const target = e.target as HTMLElement;
const tag = target.tagName?.toLowerCase() ?? '';
const ownsPointer =
fix(ui+spice+example): interactive wokwi components, NTC formula, photoresistor alias Three independent fixes uncovered during a systematic example-by-example audit (plan/full_test_plan/): 1. DynamicComponent.handleMouseDown was calling e.stopPropagation() unconditionally in the capture phase. That swallowed pointerdown BEFORE wokwi-potentiometer / pushbutton / slide-switch / joystick could see it, so the rotary knob would not rotate and buttons wouldn't press even with a real OS mouse. Now we skip the swallow when the click target is an inner wokwi-* element during a live simulation, letting the wokwi component own its own pointerdown while still allowing the canvas drag-to-rearrange flow on the wrapper / non-interactive surface. 2. examples.ts uno-ntc (and pico-ntc) sketch had the NTC divider formula inverted relative to both the SPICE mapper topology (VCC -> R_NTC -> A1 -> R_pull -> GND, the standard module wiring) and real wokwi-ntc-temperature-sensor modules. Moving the slider to 60 C made the firmware print -3.42 C. Flipped the formula to r = SERIES_R * (VCC - v) / v. Now slider 60 C -> Serial reports 60.12 C and A1 voltmeter shows 4.00 V. 3. componentToSpice.ts photoresistor mapper was only registered under the bare key `photoresistor`, but example components use the metadataId `photoresistor-sensor`. Added an alias so the LDR + pull-down divider gets emitted for the real component instance. All three reproduce visually in seconds; documented per-example in plan/full_test_plan/examples/. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:10:42 +07:00
interactionRunning &&
(tag === 'wokwi-pushbutton' ||
tag === 'wokwi-pushbutton-6mm' ||
tag === 'wokwi-potentiometer' ||
tag === 'wokwi-slide-potentiometer' ||
tag === 'wokwi-slide-switch' ||
tag === 'wokwi-dip-switch-8' ||
tag === 'wokwi-analog-joystick' ||
tag === 'wokwi-ky-040' ||
tag === 'wokwi-membrane-keypad' ||
tag === 'wokwi-rotary-dialer');
if (ownsPointer) {
fix(ui+spice+example): interactive wokwi components, NTC formula, photoresistor alias Three independent fixes uncovered during a systematic example-by-example audit (plan/full_test_plan/): 1. DynamicComponent.handleMouseDown was calling e.stopPropagation() unconditionally in the capture phase. That swallowed pointerdown BEFORE wokwi-potentiometer / pushbutton / slide-switch / joystick could see it, so the rotary knob would not rotate and buttons wouldn't press even with a real OS mouse. Now we skip the swallow when the click target is an inner wokwi-* element during a live simulation, letting the wokwi component own its own pointerdown while still allowing the canvas drag-to-rearrange flow on the wrapper / non-interactive surface. 2. examples.ts uno-ntc (and pico-ntc) sketch had the NTC divider formula inverted relative to both the SPICE mapper topology (VCC -> R_NTC -> A1 -> R_pull -> GND, the standard module wiring) and real wokwi-ntc-temperature-sensor modules. Moving the slider to 60 C made the firmware print -3.42 C. Flipped the formula to r = SERIES_R * (VCC - v) / v. Now slider 60 C -> Serial reports 60.12 C and A1 voltmeter shows 4.00 V. 3. componentToSpice.ts photoresistor mapper was only registered under the bare key `photoresistor`, but example components use the metadataId `photoresistor-sensor`. Added an alias so the LDR + pull-down divider gets emitted for the real component instance. All three reproduce visually in seconds; documented per-example in plan/full_test_plan/examples/. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:10:42 +07:00
// Let the wokwi component own this pointerdown.
return;
}
fix(ui+spice+example): interactive wokwi components, NTC formula, photoresistor alias Three independent fixes uncovered during a systematic example-by-example audit (plan/full_test_plan/): 1. DynamicComponent.handleMouseDown was calling e.stopPropagation() unconditionally in the capture phase. That swallowed pointerdown BEFORE wokwi-potentiometer / pushbutton / slide-switch / joystick could see it, so the rotary knob would not rotate and buttons wouldn't press even with a real OS mouse. Now we skip the swallow when the click target is an inner wokwi-* element during a live simulation, letting the wokwi component own its own pointerdown while still allowing the canvas drag-to-rearrange flow on the wrapper / non-interactive surface. 2. examples.ts uno-ntc (and pico-ntc) sketch had the NTC divider formula inverted relative to both the SPICE mapper topology (VCC -> R_NTC -> A1 -> R_pull -> GND, the standard module wiring) and real wokwi-ntc-temperature-sensor modules. Moving the slider to 60 C made the firmware print -3.42 C. Flipped the formula to r = SERIES_R * (VCC - v) / v. Now slider 60 C -> Serial reports 60.12 C and A1 voltmeter shows 4.00 V. 3. componentToSpice.ts photoresistor mapper was only registered under the bare key `photoresistor`, but example components use the metadataId `photoresistor-sensor`. Added an alias so the LDR + pull-down divider gets emitted for the real component instance. All three reproduce visually in seconds; documented per-example in plan/full_test_plan/examples/. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:10:42 +07:00
e.stopPropagation();
onMouseDown(e);
},
[onMouseDown, interactionRunning],
);
const handleDoubleClick = useCallback(
(e: React.MouseEvent) => {
if (onDoubleClick) {
e.stopPropagation();
onDoubleClick(e);
}
},
[onDoubleClick],
);
/**
* Mount web component (only once)
*/
useEffect(() => {
if (!containerRef.current) return;
// Prevent double-mount in React StrictMode
if (mountedRef.current) {
return;
}
const element = document.createElement(metadata.tagName);
element.id = id;
// Set initial properties
Object.entries(properties).forEach(([key, value]) => {
try {
(element as any)[key] = value;
} catch (error) {
console.warn(`Failed to set initial property ${key}:`, error);
}
});
containerRef.current.appendChild(element);
elementRef.current = element;
mountedRef.current = true;
return () => {
if (containerRef.current && element.parentNode === containerRef.current) {
containerRef.current.removeChild(element);
}
elementRef.current = null;
mountedRef.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [metadata.tagName, id]); // Only re-create if tagName or id changes
/**
* Attach component-specific DOM events (like button presses)
*/
useEffect(() => {
const el = elementRef.current;
if (!el) return;
const onButtonPress = (e: Event) => handleComponentEvent(id, 'button-press', e);
const onButtonRelease = (e: Event) => handleComponentEvent(id, 'button-release', e);
el.addEventListener('button-press', onButtonPress);
el.addEventListener('button-release', onButtonRelease);
const logic = PartSimulationRegistry.get(metadata.id || id.split('-')[0]);
let cleanupSimulationEvents: (() => void) | undefined;
if (logic && logic.attachEvents) {
// Board-less circuits (analog/digital SPICE examples) have no MCU
// simulator, but input parts (switches, buttons, DIP switches) still
// need their `change`/`button-press` events to fire `emitPropertyChange`
// so the SPICE solver re-runs. Every part already guards its
// `simulator.setPinState` / `pinManager.onPinChange` calls behind a
// null pin lookup (`getArduinoPin` returns null when there's no board),
// so the stub below is enough — it satisfies the type signature without
// doing anything when called.
const stubSimulator =
simulator ??
({
setPinState: () => {},
isRunning: () => false,
// Board-less circuits have no MCU simulator, but a custom chip still
// needs a real PinManager so its digital pin writes/reads reach the
// components wired to it (LEDs, buttons, other chips). Hand it the
// shared flat PinManager that SimulatorCanvas subscribes LEDs to, so
// both sides talk on the same numeric/synthetic pin ids. Falls back
// to a no-op only if even that isn't ready yet.
pinManager:
(useSimulatorStore.getState().pinManager as any) ?? {
onPinChange: () => () => {},
triggerPinChange: () => {},
},
} as any);
// Helper to find Arduino pin connected to a component pin.
feat: expand SPICE component catalog (fases 9 + 10) Adds 44 SPICE mappers, 58 custom metadata entries, and 12 visual Web Components covering logic gates, transistors, op-amps, regulators, sources, electromechanical parts and integrated-circuit packaging. Fase 9 — component catalog expansion ------------------------------------ - 7 logic gates (AND/OR/NAND/NOR/XOR/XNOR + NOT) as SPICE B-sources - 8 multi-input gates (AND/OR/NAND/NOR with 3 and 4 inputs) - 9 transistors: 5 BJTs (incl. PNP 2N3906/BC557) + 4 MOSFETs (incl. P-channel IRF9540/FQP27P06). NMOS refactored from Level=3 W=0.1 (hangs ngspice) to Level=1 with sane W/L - 5 op-amps: LM358, LM741, TL072, LM324 with per-chip saturation rails + opamp-ideal - 4 linear regulators (7805, 7812, 7905, LM317) with dropout - 3 batteries (9V, AA, coin-cell) with realistic ESR - Signal generator (sine / square / DC) - 2 Schottky diodes (1N5817, 1N5819) + photodiode (lux-driven current source) Fase 10 — electromechanical + ICs --------------------------------- - Relay (SPDT): coil + L + S-switch with native hysteresis + flyback diode, inverted-control trick for the NC contact - Optocouplers 4N25 and PC817 (LED + CCCS with CTR=0.5 / 1.0) - 7 74HC ICs as DIP-14 packages emitting 4 or 6 B-sources per component (first mapper pattern emitting multiple device cards) - 3 flip-flops (D, T, JK) — digital-sim only (edge detection is not representable in ngspice .op) - L293D dual H-bridge motor driver Infrastructure -------------- - scripts/component-overrides.json gains a _customComponents[] array that lets new Velxio-only parts survive metadata regeneration (previously applyOverrides() could only patch wokwi-elements components that had already been scanned) - scripts/generate-component-metadata.ts injects custom entries before the patch loop - New ComponentCategory values: 'logic', 'analog', 'electromech' - frontend/src/components/DynamicComponent.tsx PASSIVE tracing extended from just ['resistor','resistor-us'] to 9 two-terminal passives with per-part pin name maps - New CI workflow test-circuit.yml runs the sandbox on push/PR - frontend-tests.yml regenerates metadata and fails if committed JSON is stale - Documented 2 new ngspice gotchas in circuit-emulation-gotchas.md: unicode in netlist titles silently hangs the parser, and MOSFET Level=3 + W=0.1m causes .op to hang - 164/164 sandbox tests passing in ~9 s (was 88 pre-fase-9) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 06:44:18 +07:00
// Traces through electrically-transparent passive components so that a
// circuit like LED-cathode → resistor → GND returns -1 (GND) instead
// of null. Delegates to the module-level `traceDetailed`.
//
// Two call shapes are supported because this same function is passed
// BOTH to PartSimulationRegistry handlers (which call it as
// `getArduinoPin(componentPinName)`) AND to `createDefaultPinResolver`
// as a `PinTracer` (which calls it as `tracePin(componentId,
// componentPinName)`). When the second arg is present we treat the
// first as a componentId override; otherwise we use the closure-
// captured component id. The previous single-arg signature silently
// matched the PinTracer 2-arg call as `(componentId, undefined)` —
// traceDetailed then looked up a pin literally named "rgb-led-1" on
// component "rgb-led-1", got null, and the PinResolver reported
// FLOATING forever (the canonical "wokwi-rgb-led never lights up
// even though SPICE is driving R/G/B" symptom).
const getArduinoPin = (
componentIdOrPin: string,
maybePinName?: string,
): number | null => {
const state = useSimulatorStore.getState();
const componentId = maybePinName !== undefined ? componentIdOrPin : id;
const componentPinName =
maybePinName !== undefined ? maybePinName : componentIdOrPin;
return traceDetailed(state, componentId, componentPinName, 0).arduinoPin;
};
feat(sim): introduce PinResolver abstraction (Phase 0 of mixed-mode rewrite) Decouple per-component handlers from direct pinManager.onPinChange + getArduinoPinHelper subscriptions by introducing a small PinResolver interface. The Phase 0 default impl is functionally identical to the legacy path — it just routes through PinResolver instead of being inlined in every handler. Zero behavior change. The point is to make Phase 1 possible: swap the default impl for a SPICE-resolved version that watches node voltages and threshold- converts to digital events, without rewriting every handler. Files: - simulation/PinResolver.ts (new) — interface + default factory - parts/PartSimulationRegistry.ts — additive 5th arg to attachEvents (getPinResolver?), legacy 4-arg signatures keep working unchanged - components/DynamicComponent.tsx — assembles the PinResolver from the wire-trace logic + PinManager subscriptions + board Vcc lookup, passes it as the 5th arg to attachEvents - parts/BasicParts.ts — LED handler migrated as proof of concept (resolver-first path, legacy 4-arg path kept as fallback for tests / unmigrated harnesses) - __tests__/pin-resolver.test.ts (new) — 8 unit tests covering FLOATING / GND / HIGH / LOW / GPIO subscriptions / unsubscribe Vitest: 8/8 pin-resolver tests pass. 1300+ existing tests still pass; the one pre-existing flake (spice-rectifier-live-repro timing out >60s) is unrelated to this commit — verified by running the test on plain HEAD without these changes (same timeout). See project/sim-mixedmode/phase-00-pin-resolver.md (in the velxio-prod repo) for full phase context.
2026-05-15 20:50:09 +07:00
// PinResolver factory — Phase 0 of the mixed-mode simulator project
// (see project/sim-mixedmode/ in the velxio-prod repo). For now it
// wraps getArduinoPin + pinManager.onPinChange — zero behavioral
// change vs the legacy path. Phase 1+ will swap in a SPICE-resolved
// implementation that watches node voltages and threshold-converts
// to logic states.
const simState = useSimulatorStore.getState();
const ownerBoard =
simState.boards.find((b) => b.id === simState.activeBoardId) ?? null;
const ownerBoardVcc =
(ownerBoard && BOARD_PIN_GROUPS[ownerBoard.boardKind as keyof typeof BOARD_PIN_GROUPS]?.vcc) ?? 5;
const getPinResolver = (componentPinName: string): PinResolver | null => {
const state = useSimulatorStore.getState();
const pinManager = (stubSimulator as {
pinManager?: {
onPinChange?: (pin: number, cb: (pin: number, state: boolean) => void) => () => void;
getPinState?: (pin: number) => boolean | null;
};
}).pinManager;
feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection Adds the architecture pieces for mixed-mode coupling without yet driving the SPICE engine. Components on a path that crosses an active device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route through a new SPICE-resolved PinResolver variant; everything else keeps the digital fast-path from Phase 0. What ships: - simulation/PinResolver.ts * `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES` list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay). * `DetailedPinTrace` / `DetailedPinTracer` types — the trace function now reports whether it crossed an active device, on top of the Arduino pin number. * `createSpiceResolvedPinResolver()` — new factory; reads voltages from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW with hysteresis (thresholdHigh != thresholdLow → Schmitt-like). - simulation/spice/MixedModeScheduler.ts (new) * Singleton orchestrator that holds the NgSpiceInteractive engine and the SpiceVoltageSource subscription registry. * `start()` / `stop()` / `dispose()` lifecycle. * `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource. * `onMcuPinChange()` placeholder for the alter+tran event loop. * Skeleton: subscribers register but never receive events yet. Phase 1b continued will wire NgSpiceInteractive into the loop. - components/DynamicComponent.tsx * Trace function extended with `traceDetailed()` that tracks whether the BFS crossed an active component. * PinResolver factory branches: active-path → SPICE-resolved (uses the scheduler), digital-only → existing default impl. Default threshold = vcc/2 with no hysteresis; Phase 3 will replace with per-logic-family Vil/Vih. Phase 0 LED behavior intact (digital path). Phase 1b SPICE-resolved path falls back to FLOATING until Phase 1b continued wires the engine. Tests: - pin-resolver-phase1b.test.ts (new) — 8/8 passing. Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator metadata id; SPICE-resolved resolver state reporting, threshold conversion, hysteresis dead-band, unsubscribe. - pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression). - tsc --noEmit on the new files: clean. No deploy in this commit — staged for end-of-session rebuild + push per user preference.
2026-05-15 21:38:30 +07:00
// Phase 1b: detect whether the path between this component pin and
// an Arduino pin passes through any active device (BJT, MOSFET,
// op-amp, diode, regulator). If yes → use the SPICE-resolved
// resolver flavor so the digital state is derived from real node
// voltages (handles transistor inversion, op-amp gain, diode
// forward-drop, etc.). If no → use the legacy digital fast-path
// (zero SPICE cost, identical to Phase 0 behavior).
const detailed = traceDetailed(state, id, componentPinName, 0);
feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection Adds the architecture pieces for mixed-mode coupling without yet driving the SPICE engine. Components on a path that crosses an active device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route through a new SPICE-resolved PinResolver variant; everything else keeps the digital fast-path from Phase 0. What ships: - simulation/PinResolver.ts * `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES` list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay). * `DetailedPinTrace` / `DetailedPinTracer` types — the trace function now reports whether it crossed an active device, on top of the Arduino pin number. * `createSpiceResolvedPinResolver()` — new factory; reads voltages from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW with hysteresis (thresholdHigh != thresholdLow → Schmitt-like). - simulation/spice/MixedModeScheduler.ts (new) * Singleton orchestrator that holds the NgSpiceInteractive engine and the SpiceVoltageSource subscription registry. * `start()` / `stop()` / `dispose()` lifecycle. * `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource. * `onMcuPinChange()` placeholder for the alter+tran event loop. * Skeleton: subscribers register but never receive events yet. Phase 1b continued will wire NgSpiceInteractive into the loop. - components/DynamicComponent.tsx * Trace function extended with `traceDetailed()` that tracks whether the BFS crossed an active component. * PinResolver factory branches: active-path → SPICE-resolved (uses the scheduler), digital-only → existing default impl. Default threshold = vcc/2 with no hysteresis; Phase 3 will replace with per-logic-family Vil/Vih. Phase 0 LED behavior intact (digital path). Phase 1b SPICE-resolved path falls back to FLOATING until Phase 1b continued wires the engine. Tests: - pin-resolver-phase1b.test.ts (new) — 8/8 passing. Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator metadata id; SPICE-resolved resolver state reporting, threshold conversion, hysteresis dead-band, unsubscribe. - pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression). - tsc --noEmit on the new files: clean. No deploy in this commit — staged for end-of-session rebuild + push per user preference.
2026-05-15 21:38:30 +07:00
if (detailed.crossedActiveDevice) {
const scheduler = getMixedModeScheduler();
feat(sim): Phase 3 — logic families (TTL/CMOS-5V/LVCMOS33/AVR_HC/Schmitt) Replaces the Phase 1b vcc/2-flat threshold with per-logic-family Vil/Vih thresholds + Schmitt-trigger hysteresis where applicable. SPICE-resolved digital reads now match what real ICs actually do — TTL noise margins, CMOS rail-to-rail, 74HC14 Schmitt hysteresis, LVCMOS33 vs CMOS-5V interop. New module: simulation/LogicFamilies.ts - LogicFamily interface (vcc, vil, vih, vil_schmitt?, vih_schmitt?, cin_pF, vol_max?, voh_min?, output_impedance_ohm?) - FAMILIES catalog: TTL, CMOS-5V, CMOS-5V-SCHMITT, CMOS-5V-TTL-INPUTS, LVCMOS33, AVR_HC, CMOS-3.3V — all sourced from TI / ATmega328P / JEDEC datasheets. - BOARD_FAMILY: per-board lookup. Uno/Mega/Nano/ATtiny → AVR_HC, ESP32 family + Pi Pico → LVCMOS33, fall back to AVR_HC for unknown boards. - getBoardLogicFamily() and getLogicFamilyById() helpers. PinResolver: - SpiceResolvedConfig docstring rewritten with Phase 3 wording. - New `configFromLogicFamily()` builder — picks Schmitt thresholds when the family declares them, falls back to vih/vil otherwise. DynamicComponent: - When the trace crosses an active device, the SPICE-resolved resolver is now built with the OWNER BOARD's logic family instead of vcc/2. Hysteresis comes through automatically for boards whose native family is Schmitt-capable. - Phase 3 continued: per-component logicFamily override from components-metadata.json (so e.g. a 74HC14 placed on an Arduino Uno gets Schmitt thresholds even though the BOARD is AVR_HC). Tests: - logic-families.test.ts (new) — 19/19 passing. Covers catalog sanity (vil < vih, vol_max ≤ vil, voh_min ≥ vih), per-board lookup, Schmitt vs non-Schmitt config, noise rejection behavior of 74HC14 Schmitt resolver, last-state-wins behavior of CMOS-5V dead band. - Phase 0 + Phase 1b regression: 16/16 still passing. - tsc --noEmit on new files: clean. No deploy in this commit — staged for end-of-session rebuild.
2026-05-15 21:42:11 +07:00
// Phase 3: threshold model from the OWNER BOARD's logic family
// (e.g. AVR_HC for Uno, LVCMOS33 for ESP32). Includes Schmitt
// hysteresis when the family declares it. Phase 3 continued
// will let individual components override via a `logicFamily`
// field in components-metadata.json so e.g. a 74HC14 input
// gets Schmitt behavior even when driven from an AVR.
const family = ownerBoard
? getBoardLogicFamily(ownerBoard.boardKind)
: { vcc: ownerBoardVcc, vil: ownerBoardVcc / 2, vih: ownerBoardVcc / 2 };
return createSpiceResolvedPinResolver(
id,
componentPinName,
scheduler,
configFromLogicFamily(family),
);
feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection Adds the architecture pieces for mixed-mode coupling without yet driving the SPICE engine. Components on a path that crosses an active device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route through a new SPICE-resolved PinResolver variant; everything else keeps the digital fast-path from Phase 0. What ships: - simulation/PinResolver.ts * `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES` list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay). * `DetailedPinTrace` / `DetailedPinTracer` types — the trace function now reports whether it crossed an active device, on top of the Arduino pin number. * `createSpiceResolvedPinResolver()` — new factory; reads voltages from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW with hysteresis (thresholdHigh != thresholdLow → Schmitt-like). - simulation/spice/MixedModeScheduler.ts (new) * Singleton orchestrator that holds the NgSpiceInteractive engine and the SpiceVoltageSource subscription registry. * `start()` / `stop()` / `dispose()` lifecycle. * `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource. * `onMcuPinChange()` placeholder for the alter+tran event loop. * Skeleton: subscribers register but never receive events yet. Phase 1b continued will wire NgSpiceInteractive into the loop. - components/DynamicComponent.tsx * Trace function extended with `traceDetailed()` that tracks whether the BFS crossed an active component. * PinResolver factory branches: active-path → SPICE-resolved (uses the scheduler), digital-only → existing default impl. Default threshold = vcc/2 with no hysteresis; Phase 3 will replace with per-logic-family Vil/Vih. Phase 0 LED behavior intact (digital path). Phase 1b SPICE-resolved path falls back to FLOATING until Phase 1b continued wires the engine. Tests: - pin-resolver-phase1b.test.ts (new) — 8/8 passing. Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator metadata id; SPICE-resolved resolver state reporting, threshold conversion, hysteresis dead-band, unsubscribe. - pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression). - tsc --noEmit on the new files: clean. No deploy in this commit — staged for end-of-session rebuild + push per user preference.
2026-05-15 21:38:30 +07:00
}
feat(sim): introduce PinResolver abstraction (Phase 0 of mixed-mode rewrite) Decouple per-component handlers from direct pinManager.onPinChange + getArduinoPinHelper subscriptions by introducing a small PinResolver interface. The Phase 0 default impl is functionally identical to the legacy path — it just routes through PinResolver instead of being inlined in every handler. Zero behavior change. The point is to make Phase 1 possible: swap the default impl for a SPICE-resolved version that watches node voltages and threshold- converts to digital events, without rewriting every handler. Files: - simulation/PinResolver.ts (new) — interface + default factory - parts/PartSimulationRegistry.ts — additive 5th arg to attachEvents (getPinResolver?), legacy 4-arg signatures keep working unchanged - components/DynamicComponent.tsx — assembles the PinResolver from the wire-trace logic + PinManager subscriptions + board Vcc lookup, passes it as the 5th arg to attachEvents - parts/BasicParts.ts — LED handler migrated as proof of concept (resolver-first path, legacy 4-arg path kept as fallback for tests / unmigrated harnesses) - __tests__/pin-resolver.test.ts (new) — 8 unit tests covering FLOATING / GND / HIGH / LOW / GPIO subscriptions / unsubscribe Vitest: 8/8 pin-resolver tests pass. 1300+ existing tests still pass; the one pre-existing flake (spice-rectifier-live-repro timing out >60s) is unrelated to this commit — verified by running the test on plain HEAD without these changes (same timeout). See project/sim-mixedmode/phase-00-pin-resolver.md (in the velxio-prod repo) for full phase context.
2026-05-15 20:50:09 +07:00
return createDefaultPinResolver(
id,
componentPinName,
{
components: state.components,
boards: state.boards,
wires: state.wires,
ownerBoard,
ownerBoardVcc,
subscribeArduinoPin: (pin, cb) => {
if (!pinManager?.onPinChange) return () => {};
return pinManager.onPinChange(pin, cb);
},
readArduinoPin: (pin) => {
if (!pinManager?.getPinState) return null;
try {
return pinManager.getPinState(pin);
} catch {
return null;
}
},
},
getArduinoPin,
);
};
feat(microsd): SD-over-SPI card storage for AVR, RP2040 and ESP32 Add a working microSD card part backed by a FAT16 image, following the Wokwi storage model: the project's own workspace files are auto-copied onto the card (free), and an optional "SD Card" panel uploads extra files (gated as a paid feature by the velxio.dev overlay; OSS default allows it). Frontend (in-browser AVR / RP2040): - ProtocolParts.ts: rewrite the microsd-card part from a handshake stub into a real SD-over-SPI device (reply-first Ncr timing, SDSC byte addressing, single/multi-block read+write, CSD/CID, full CMD set). - utils/fatImage.ts: dependency-free FAT16 super-floppy builder (8.3 + LFN). - utils/sdCardFiles.ts: assemble the card image from workspace files plus uploaded files; base64 helpers. - components/simulator/SdCardPanel.tsx + ComponentPropertyDialog: upload UI. - DynamicComponent + useSimulatorStore: build and inject the image on run. - lib/proSdCardGate.ts: overlay-installable gate for the upload action. - data/examples-storage-microsd.ts: Arduino Uno + ESP32 gallery examples. Backend (ESP32 via QEMU): - services/esp32_sd_slave.py: synchronous SD-over-SPI slave (Python port of the browser part) with a sparse backing store, idle-state R1 tracking and real CRC16 on data blocks when the host enables CRC (CMD59) -- both required by ESP-IDF's sdspi driver. - esp32_worker.py: route SPI bytes to the slave (returns MISO synchronously) and feed write-only bulk transfers. - esp32_lib_manager.py + routes/simulation.py: forward the FAT image (sd_card.image_b64) from the start config into the worker. Tested: - frontend: protocol-parts, fat-image, sd-card-gate and microsd-real-firmware (real Arduino SD.h on avr8js) -- 86 passing. - backend: test_esp32_sd_slave (10) covering the ESP-IDF init sequence and CRC16; validated end to end by running a real SD.h sketch in libqemu-xtensa (mount, directory listing, read and write-readback).
2026-06-11 08:59:53 +07:00
// microSD auto-copy (free, Wokwi model): bake the project's workspace
// files into a FAT16 image the card serves over SD-over-SPI. Paid uploads
// (the "SD Card" panel) will merge into this list in a later phase.
if (metadata.id === 'microsd-card') {
try {
const uploaded = decodeSdFiles(properties.sdFiles); // paid uploads (if any)
(el as unknown as { sdImageData?: Uint8Array }).sdImageData =
buildProjectSdImage(useEditorStore.getState().files, uploaded);
} catch (e) {
console.warn('[microsd] SD image build failed:', e);
}
}
feat(sim): introduce PinResolver abstraction (Phase 0 of mixed-mode rewrite) Decouple per-component handlers from direct pinManager.onPinChange + getArduinoPinHelper subscriptions by introducing a small PinResolver interface. The Phase 0 default impl is functionally identical to the legacy path — it just routes through PinResolver instead of being inlined in every handler. Zero behavior change. The point is to make Phase 1 possible: swap the default impl for a SPICE-resolved version that watches node voltages and threshold- converts to digital events, without rewriting every handler. Files: - simulation/PinResolver.ts (new) — interface + default factory - parts/PartSimulationRegistry.ts — additive 5th arg to attachEvents (getPinResolver?), legacy 4-arg signatures keep working unchanged - components/DynamicComponent.tsx — assembles the PinResolver from the wire-trace logic + PinManager subscriptions + board Vcc lookup, passes it as the 5th arg to attachEvents - parts/BasicParts.ts — LED handler migrated as proof of concept (resolver-first path, legacy 4-arg path kept as fallback for tests / unmigrated harnesses) - __tests__/pin-resolver.test.ts (new) — 8 unit tests covering FLOATING / GND / HIGH / LOW / GPIO subscriptions / unsubscribe Vitest: 8/8 pin-resolver tests pass. 1300+ existing tests still pass; the one pre-existing flake (spice-rectifier-live-repro timing out >60s) is unrelated to this commit — verified by running the test on plain HEAD without these changes (same timeout). See project/sim-mixedmode/phase-00-pin-resolver.md (in the velxio-prod repo) for full phase context.
2026-05-15 20:50:09 +07:00
cleanupSimulationEvents = logic.attachEvents(
el,
stubSimulator,
getArduinoPin,
id,
getPinResolver,
);
}
return () => {
if (cleanupSimulationEvents) cleanupSimulationEvents();
el.removeEventListener('button-press', onButtonPress);
el.removeEventListener('button-release', onButtonRelease);
};
}, [id, handleComponentEvent, metadata.id, simulator, hexEpoch, wireFingerprint]);
// The wrapper uses `onMouseDownCapture` (not `onMouseDown`) so it sees
// the mousedown BEFORE the inner wokwi-element. Interactive wokwi parts
// (pushbutton, slide-switch, potentiometer …) call stopPropagation in
// their own bubble-phase handlers, which used to prevent any drag from
// starting once the simulator was running. Capture phase fires first
// and lets the canvas's drag-threshold logic distinguish click vs drag
// at mouseup time — so the user can rearrange interactive components
// while simulation is live.
return (
<div
className={`dynamic-component-wrapper${isBurnt ? ' velxio-burnt' : ''}`}
style={{
position: 'absolute',
left: `${x}px`,
top: `${y}px`,
fix(canvas): board-less SPICE switches toggle on click instead of opening property dialog In digital / analog board-less examples the user clicks a slide-switch or pushbutton expecting it to flip its state. Until this commit the component property dialog opened instead and the click never reached the wokwi-element underneath, so: - The user couldn't change switch state through the canvas at all. - With no state change the SPICE solver kept the old netlist, and every downstream LED stayed dark — the symptom that read as "voltages change but no LED lights". Root cause was the gating: SimulatorCanvas only suppressed the property dialog when `useSimulatorStore.running` was true, but that flag is bound to an MCU's start/stop. Board-less circuits have no MCU to start so `running` is permanently false, even when the SPICE engine has been live since the example loaded. New derived flag `interactionRunning = running || (boards.length === 0 && !electricalPaused)` — true whenever the user is in an "interactive" session, MCU or SPICE-only. Used in three click-handling paths: - SimulatorCanvas mouse-up handler: dialog is suppressed and the click falls through to the wokwi-element (line 1395). - SimulatorCanvas touch-start passthrough: same for touch (line 474). - SimulatorCanvas touch-end short-tap: same for tap (line 774). Also propagated to DynamicComponent so the cursor becomes pointer (not move) for interactive parts in board-less mode — visual cue that the user can click instead of just drag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:34:58 +07:00
cursor: interactionRunning && isInteractive ? 'pointer' : 'move',
border: isSelected ? '2px dashed #007acc' : '2px solid transparent',
borderRadius: '4px',
padding: '4px',
userSelect: 'none',
zIndex: isSelected ? 5 : 1,
pointerEvents: 'auto',
transform: properties.rotation ? `rotate(${properties.rotation}deg)` : undefined,
transformOrigin: 'center center',
}}
onMouseDownCapture={handleMouseDown}
onDoubleClick={handleDoubleClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
data-component-id={id}
data-component-type={metadata.id}
>
{/* Container for web component */}
<div ref={containerRef} className="web-component-container" />
{/* Runtime-burnout smoke badge (P4) */}
{isBurnt && (
<div
className="velxio-burnt-smoke"
aria-hidden="true"
style={{ position: 'absolute', top: '-7px', right: '-7px', pointerEvents: 'none', zIndex: 6 }}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<circle cx="8" cy="14" r="5" fill="#6b7280" opacity="0.85" />
<circle cx="14" cy="11" r="6" fill="#9ca3af" opacity="0.85" />
<circle cx="17" cy="16" r="4" fill="#4b5563" opacity="0.85" />
<circle cx="11" cy="8" r="3.5" fill="#9ca3af" opacity="0.7" />
</svg>
</div>
)}
{/* Component label */}
<div
className="component-label"
style={{
fontSize: '11px',
textAlign: 'center',
marginTop: '4px',
color: '#666',
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
}}
>
{properties.pin !== undefined ? `Pin ${properties.pin}` : metadata.name}
{isKeyBindable(metadata.id) && typeof properties.key === 'string' && properties.key && (
<span
style={{
fontSize: '9px',
padding: '1px 5px',
borderRadius: '3px',
backgroundColor: '#2d2d2d',
color: '#ddd',
border: '1px solid #555',
borderBottomWidth: '2px',
fontFamily: "'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
fontWeight: 600,
lineHeight: '1.3',
whiteSpace: 'nowrap',
}}
>
{formatKeyLabel(properties.key)}
</span>
)}
{properties.protocol && (
<span
style={{
fontSize: '9px',
padding: '1px 4px',
borderRadius: '3px',
backgroundColor: properties.protocol === 'spi' ? '#e67e22' : '#3498db',
color: '#fff',
fontWeight: 600,
textTransform: 'uppercase',
lineHeight: '1.2',
}}
>
{String(properties.protocol)}
</span>
)}
</div>
</div>
);
};
/**
* Helper function to create a component instance from metadata
*/
export function createComponentFromMetadata(
metadata: ComponentMetadata,
x: number,
y: number,
): {
id: string;
metadataId: string;
x: number;
y: number;
properties: Record<string, any>;
} {
fix(simulator): underscore-separated component ids for SPICE safety The user reported the default editor canvas — Arduino Uno + LED + 220Ω resistor — was correctly powered (1.84 V at the LED anode, 14 mA through the diode) but the LED visual stayed dark. Only the built-in pin-13 LED on the wokwi-arduino-uno element lit up. Root cause: ngspice's WASM build truncates branch-current vector keys at the first hyphen. A sense source named V_led-builtin_sense ends up exposed under a key like v_led#branch rather than the expected v_led-builtin_sense#branch. CircuitSimulationService and BasicParts.ts both look up the FULL key, miss, and the LED's brightness update treats raw as undefined → digital-fallback path runs but the SPICE memo timestamp is fresh so HOLD keeps zero brightness. Visible symptom: a perfectly conducting LED that never lights. Fix in two places: - Default canvas (useSimulatorStore.ts): rename 'led-builtin' / 'r-builtin' to 'led_builtin' / 'r_builtin' (and the matching wire ids). - DynamicComponent.tsx makeNewComponent: the id template was 'metadata.id-timestamp-rand' producing hyphens for every user-added component too. Switched to underscores, AND replace any hyphens already in metadata.id (e.g. 'led-bar-graph') so the prefix doesn't reintroduce the bug. Existing saved projects whose ids contain hyphens are not migrated here — those will keep the visual bug until either the operator edits the components or we add a sanitisation step inside componentToSpice + BasicParts. The next follow-up commit can add that if you confirm this default-canvas fix works.
2026-05-18 20:01:38 +07:00
// Underscore separators (not '-') so the resulting id is safe to embed
// in SPICE component / source names. ngspice's WASM build truncates
// vector keys at '-', which broke branch-current lookups for any LED /
// ammeter wired up by the user (visible symptom: correct node voltage,
// dark LED). Also strip '-' from metadata.id (e.g. 'led-bar-graph') so
// the prefix doesn't reintroduce a hyphen.
const safePrefix = metadata.id.replace(/-/g, '_');
return {
fix(simulator): underscore-separated component ids for SPICE safety The user reported the default editor canvas — Arduino Uno + LED + 220Ω resistor — was correctly powered (1.84 V at the LED anode, 14 mA through the diode) but the LED visual stayed dark. Only the built-in pin-13 LED on the wokwi-arduino-uno element lit up. Root cause: ngspice's WASM build truncates branch-current vector keys at the first hyphen. A sense source named V_led-builtin_sense ends up exposed under a key like v_led#branch rather than the expected v_led-builtin_sense#branch. CircuitSimulationService and BasicParts.ts both look up the FULL key, miss, and the LED's brightness update treats raw as undefined → digital-fallback path runs but the SPICE memo timestamp is fresh so HOLD keeps zero brightness. Visible symptom: a perfectly conducting LED that never lights. Fix in two places: - Default canvas (useSimulatorStore.ts): rename 'led-builtin' / 'r-builtin' to 'led_builtin' / 'r_builtin' (and the matching wire ids). - DynamicComponent.tsx makeNewComponent: the id template was 'metadata.id-timestamp-rand' producing hyphens for every user-added component too. Switched to underscores, AND replace any hyphens already in metadata.id (e.g. 'led-bar-graph') so the prefix doesn't reintroduce the bug. Existing saved projects whose ids contain hyphens are not migrated here — those will keep the visual bug until either the operator edits the components or we add a sanitisation step inside componentToSpice + BasicParts. The next follow-up commit can add that if you confirm this default-canvas fix works.
2026-05-18 20:01:38 +07:00
id: `${safePrefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
metadataId: metadata.id,
x,
y,
properties: { ...metadata.defaultValues },
};
}