velxio/frontend/src/components/DynamicComponent.tsx

867 lines
35 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.
fix(trace): recognise runtime boards and same-hole junctions in pin tracing An ESP32 clock built by the agent stayed dark while QEMU was verifiably emitting hundreds of GPIO edges per second (437/pin measured on the live websocket). Reload did not help — this was not the seating race. Two independent tracing bugs, reproduced from the real project circuit (fixture included) and each sufficient to kill the display: Boards added at runtime were invisible -------------------------------------- isBoardComponent matches static id prefixes ('arduino-uno', ...), which only covers the default board. Every board added at runtime gets a minted UUID id — the agent's add_board always does — so traceDetailed treated the board endpoint as an unknown component and resolved null, and SimulatorCanvas's direct-wire subscription path skipped it entirely. Every Uno project happened to work because they reuse the default board whose instance id IS the literal 'arduino-uno'. Both sites now consult the live boards list first, keeping isBoardComponent as the legacy-id fallback. Strip walking missed wires stacked on one hole ---------------------------------------------- The breadboard group walk continued the trace from every OTHER wired hole of the strip, excluding the arrival hole by name. But two wires may legitimately share one hole — the agent bridges strips straight into the seat hole (8 of this circuit's 9 bridges land exactly on a resistor's own hole), which is electrically identical to using a free hole of the strip. The name exclusion made those junctions dead ends. Exclusion is now by incoming WIRE id, so same-hole connections resolve; the depth bound already prevents ping-ponging between two wires of one net. With both fixes the exact saved circuit resolves every display pin to its GPIO (A..DP -> 32,33,25,26,27,14,12,13; DIG1..4 -> 15,2,4,5; COM -> GND) and the live project now shows 12:00 on the real QEMU simulation. traceDetailed is exported for the regression test, which drives the real store with the real circuit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:46:23 +07:00
export 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;
fix(trace): recognise runtime boards and same-hole junctions in pin tracing An ESP32 clock built by the agent stayed dark while QEMU was verifiably emitting hundreds of GPIO edges per second (437/pin measured on the live websocket). Reload did not help — this was not the seating race. Two independent tracing bugs, reproduced from the real project circuit (fixture included) and each sufficient to kill the display: Boards added at runtime were invisible -------------------------------------- isBoardComponent matches static id prefixes ('arduino-uno', ...), which only covers the default board. Every board added at runtime gets a minted UUID id — the agent's add_board always does — so traceDetailed treated the board endpoint as an unknown component and resolved null, and SimulatorCanvas's direct-wire subscription path skipped it entirely. Every Uno project happened to work because they reuse the default board whose instance id IS the literal 'arduino-uno'. Both sites now consult the live boards list first, keeping isBoardComponent as the legacy-id fallback. Strip walking missed wires stacked on one hole ---------------------------------------------- The breadboard group walk continued the trace from every OTHER wired hole of the strip, excluding the arrival hole by name. But two wires may legitimately share one hole — the agent bridges strips straight into the seat hole (8 of this circuit's 9 bridges land exactly on a resistor's own hole), which is electrically identical to using a free hole of the strip. The name exclusion made those junctions dead ends. Exclusion is now by incoming WIRE id, so same-hole connections resolve; the depth bound already prevents ping-ponging between two wires of one net. With both fixes the exact saved circuit resolves every display pin to its GPIO (A..DP -> 32,33,25,26,27,14,12,13; DIG1..4 -> 15,2,4,5; COM -> GND) and the live project now shows 12:00 on the real QEMU simulation. traceDetailed is exported for the regression test, which drives the real store with the real circuit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:46:23 +07:00
// A board endpoint is recognised by the LIVE boards list first.
// `isBoardComponent` matches static id prefixes ('arduino-uno', …), which
// only covers the default board — every board added at runtime (the agent
// mints UUID ids) failed the check, so tracing treated it as an unknown
// component and returned null. Symptom: an ESP32 clock whose QEMU was
// emitting hundreds of GPIO edges/second at a display that stayed dark,
// because no resolver ever attached.
const boardEp = state.boards.find((b) => b.id === otherEp.componentId);
if (boardEp || isBoardComponent(otherEp.componentId)) {
const boardKind = boardEp?.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.
fix(trace): recognise runtime boards and same-hole junctions in pin tracing An ESP32 clock built by the agent stayed dark while QEMU was verifiably emitting hundreds of GPIO edges per second (437/pin measured on the live websocket). Reload did not help — this was not the seating race. Two independent tracing bugs, reproduced from the real project circuit (fixture included) and each sufficient to kill the display: Boards added at runtime were invisible -------------------------------------- isBoardComponent matches static id prefixes ('arduino-uno', ...), which only covers the default board. Every board added at runtime gets a minted UUID id — the agent's add_board always does — so traceDetailed treated the board endpoint as an unknown component and resolved null, and SimulatorCanvas's direct-wire subscription path skipped it entirely. Every Uno project happened to work because they reuse the default board whose instance id IS the literal 'arduino-uno'. Both sites now consult the live boards list first, keeping isBoardComponent as the legacy-id fallback. Strip walking missed wires stacked on one hole ---------------------------------------------- The breadboard group walk continued the trace from every OTHER wired hole of the strip, excluding the arrival hole by name. But two wires may legitimately share one hole — the agent bridges strips straight into the seat hole (8 of this circuit's 9 bridges land exactly on a resistor's own hole), which is electrically identical to using a free hole of the strip. The name exclusion made those junctions dead ends. Exclusion is now by incoming WIRE id, so same-hole connections resolve; the depth bound already prevents ping-ponging between two wires of one net. With both fixes the exact saved circuit resolves every display pin to its GPIO (A..DP -> 32,33,25,26,27,14,12,13; DIG1..4 -> 15,2,4,5; COM -> GND) and the live project now shows 12:00 on the real QEMU simulation. traceDetailed is exported for the regression test, which drives the real store with the real circuit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:46:23 +07:00
//
// Exclusion is by INCOMING WIRE, not by hole name: two wires may
// legitimately share one hole (a seated pin plus a jumper landing in
// that same hole — the agent bridges strips straight into the seat
// hole). Excluding the arrival hole made those stacked connections
// invisible: an ESP32 clock with QEMU firing hundreds of GPIO edges
// per second sat dark because every segment's bridge landed on its
// resistor's own seat hole and the trace dead-ended there.
const bbGroup = comp && breadboardGroupKey(comp.metadataId, otherEp.pinName);
if (bbGroup && comp) {
const groupPins = new Set<string>();
for (const gw of state.wires) {
fix(trace): recognise runtime boards and same-hole junctions in pin tracing An ESP32 clock built by the agent stayed dark while QEMU was verifiably emitting hundreds of GPIO edges per second (437/pin measured on the live websocket). Reload did not help — this was not the seating race. Two independent tracing bugs, reproduced from the real project circuit (fixture included) and each sufficient to kill the display: Boards added at runtime were invisible -------------------------------------- isBoardComponent matches static id prefixes ('arduino-uno', ...), which only covers the default board. Every board added at runtime gets a minted UUID id — the agent's add_board always does — so traceDetailed treated the board endpoint as an unknown component and resolved null, and SimulatorCanvas's direct-wire subscription path skipped it entirely. Every Uno project happened to work because they reuse the default board whose instance id IS the literal 'arduino-uno'. Both sites now consult the live boards list first, keeping isBoardComponent as the legacy-id fallback. Strip walking missed wires stacked on one hole ---------------------------------------------- The breadboard group walk continued the trace from every OTHER wired hole of the strip, excluding the arrival hole by name. But two wires may legitimately share one hole — the agent bridges strips straight into the seat hole (8 of this circuit's 9 bridges land exactly on a resistor's own hole), which is electrically identical to using a free hole of the strip. The name exclusion made those junctions dead ends. Exclusion is now by incoming WIRE id, so same-hole connections resolve; the depth bound already prevents ping-ponging between two wires of one net. With both fixes the exact saved circuit resolves every display pin to its GPIO (A..DP -> 32,33,25,26,27,14,12,13; DIG1..4 -> 15,2,4,5; COM -> GND) and the live project now shows 12:00 on the real QEMU simulation. traceDetailed is exported for the regression test, which drives the real store with the real circuit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:46:23 +07:00
if (gw.id === w.id) continue; // never bounce back on the same wire
for (const ep of [gw.start, gw.end]) {
if (
ep.componentId === comp.id &&
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;
feat(breadboard): hover-gated labels + full-footprint seating solver Three changes, all driven by a real project where a 4-digit 7-segment clock was unreadable and half its parts were not actually seated. Labels on hover only -------------------- Eight vertical resistors at 19 px pitch rendered eight 93 px "Resistor 220 Ω" labels on top of each other, hiding the parts and the breadboard holes; the SPICE overlay added ~40 more `0uV` pills. Both are now revealed on hover: hovering a part also lights up the voltages of every wire touching it. The label is hidden with OPACITY and stays in flow. pinPositionCalculator derives the rotation pivot from wrapper.offsetHeight, so taking it out of flow would move the pins of every rotated component in every saved project. Seat-on-drop ------------ The drag-time magnet only aligned the anchor pin and assumed the rest followed, which is how parts ended up HALF-seated: some pins in holes, the rest dead in the air. It looks mounted in a screenshot and silently breaks the circuit. On release we now re-solve properly — nearest position where EVERY pin is in a free hole, sliding past occupied columns — via the new solvePlacement/seatOnDrop. Geometry comes from the element's own pinInfo, so there is no part whitelist. Sub-pitch translation --------------------- solvePlacement first assigned pins to holes at half-pitch, then translates by the centroid of the residuals before judging fit. Pinning the anchor dead centre refused every off-lattice footprint: a diode spans 7.5 pitches, so one leg landed 4.8 px out. Shifted 2.4 px, BOTH legs sit inside tolerance — what bending the leads does on a real board. Measured over the catalog this takes seatable parts from 87 to 125 of 152; diodes, transistors, regulators, optocouplers and flip-flops are rescued with no artwork change. Staying under SEAT_TOLERANCE (< half pitch) keeps each pin's nearest hole unambiguous, so computeSeating resolves the same holes and the netlist is unaffected by the small offset. Also: refuse a placement that would put two of a part's own pins in one strip. A column strip — and far worse, a power rail — is a single net, so such a seating shorts the part to itself. Without it a 7-segment happily lays its pins across a rail. And deduplicate pin names before solving: calculatePinPosition resolves by name and returns the first match, so a board carrying GND x5 collided with itself and was refused outright. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 10:28:40 +07:00
isHovered?: 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,
feat(breadboard): hover-gated labels + full-footprint seating solver Three changes, all driven by a real project where a 4-digit 7-segment clock was unreadable and half its parts were not actually seated. Labels on hover only -------------------- Eight vertical resistors at 19 px pitch rendered eight 93 px "Resistor 220 Ω" labels on top of each other, hiding the parts and the breadboard holes; the SPICE overlay added ~40 more `0uV` pills. Both are now revealed on hover: hovering a part also lights up the voltages of every wire touching it. The label is hidden with OPACITY and stays in flow. pinPositionCalculator derives the rotation pivot from wrapper.offsetHeight, so taking it out of flow would move the pins of every rotated component in every saved project. Seat-on-drop ------------ The drag-time magnet only aligned the anchor pin and assumed the rest followed, which is how parts ended up HALF-seated: some pins in holes, the rest dead in the air. It looks mounted in a screenshot and silently breaks the circuit. On release we now re-solve properly — nearest position where EVERY pin is in a free hole, sliding past occupied columns — via the new solvePlacement/seatOnDrop. Geometry comes from the element's own pinInfo, so there is no part whitelist. Sub-pitch translation --------------------- solvePlacement first assigned pins to holes at half-pitch, then translates by the centroid of the residuals before judging fit. Pinning the anchor dead centre refused every off-lattice footprint: a diode spans 7.5 pitches, so one leg landed 4.8 px out. Shifted 2.4 px, BOTH legs sit inside tolerance — what bending the leads does on a real board. Measured over the catalog this takes seatable parts from 87 to 125 of 152; diodes, transistors, regulators, optocouplers and flip-flops are rescued with no artwork change. Staying under SEAT_TOLERANCE (< half pitch) keeps each pin's nearest hole unambiguous, so computeSeating resolves the same holes and the netlist is unaffected by the small offset. Also: refuse a placement that would put two of a part's own pins in one strip. A column strip — and far worse, a power rail — is a single net, so such a seating shorts the part to itself. Without it a 7-segment happily lays its pins across a rail. And deduplicate pin names before solving: calculatePinPosition resolves by name and returns the first match, so a board carrying GND x5 collided with itself and was refused outright. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 10:28:40 +07:00
isHovered = 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.
*
* Values arriving as strings (agent set_component_property, the text
* inputs in the property dialog) are coerced to the type of the
* metadata DEFAULT for that key. Without this, `el.digits = '4'`
* (string) silently breaks wokwi elements that strict-match
* (`switch (this.digits) { case 4: ... }` -> falls back to the 1-digit
* pinout), and `'false'` stays truthy for boolean props like colon.
*/
useEffect(() => {
if (!elementRef.current) return;
Object.entries(properties).forEach(([key, value]) => {
try {
let coerced: any = value;
if (typeof value === 'string') {
const def = metadata.defaultValues?.[key];
if (typeof def === 'number' && value.trim() !== '' && !Number.isNaN(Number(value))) {
coerced = Number(value);
} else if (typeof def === 'boolean') {
coerced = value === 'true' || value === '1';
}
}
(elementRef.current as any)[key] = coerced;
} catch (error) {
console.warn(`Failed to set property ${key} on ${metadata.tagName}:`, error);
}
});
}, [properties, metadata.tagName]);
/**
* Property changes that swap the element's pin SET (7segment digits,
* LED flip, display pins edge) re-render asynchronously and announce
* themselves with a 'pininfo-change' event. Re-derive the breadboard
* seating then reseating synchronously on the property write would
* read the STALE pinout and seat ghost pins.
*/
useEffect(() => {
const el = elementRef.current;
if (!el) return;
const onPinInfoChange = () => {
try {
useSimulatorStore.getState().reseatComponentOnBreadboard(id);
} catch {
// headless / tests
}
};
el.addEventListener('pininfo-change', onPinInfoChange);
return () => el.removeEventListener('pininfo-change', onPinInfoChange);
}, [id, metadata.tagName]);
fix(breadboard): derive seating at element mount — closes run-before-seating race A part can land in the store at its FINAL position before its element mounts: the agent streams add_component and the seating move in one batch, and updateComponent's reseat then finds no DOM (computeSeating null) and keeps the empty seating. Nothing re-derived it afterwards — the agent-side seat correction skips when the position needs no nudge, and 'pininfo-change' only fires on pin-SET swaps, not on plain init. Meanwhile run_simulation executes right after the SSE round, before the correction's animation frame. Net effect, reported by a user as a suspicion that turned out exactly right: a clock the agent built and ran in one turn showed a dead display, while reloading the project and running it worked — bb seating wires are persisted, so on reload they exist before Run is pressed. DynamicComponent now reseats once the element's pinInfo first becomes measurable (same polling cadence as the pinInfo-ready effect), which closes the hole for every path that stores a final position before mount: agent batches, project load, undo. To keep that free on load, reseatComponentOnBreadboard skips the store write when there is nothing seated and nothing to clear — otherwise every off-board part would churn the wires array identity once per mount. Verified live end-to-end: agent adds + seats + wires + compiles + RUNS in a single turn; the seated LED blinks immediately (4 transitions sampled), with all 4 seated-pin markers present — no reload needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:39:29 +07:00
/**
* Reseat once the element's geometry first becomes measurable.
*
* A part can land in the store at its FINAL position before its element
* mounts the agent streams add_component + a seating move in one batch,
* and `updateComponent`'s reseat then finds no DOM (computeSeating null)
* and keeps the (empty) seating. Nothing re-derived it afterwards: the
* seat-correction skips when the position needs no nudge, and
* 'pininfo-change' only fires on pin-SET swaps, not on plain init. So the
* part had no bb wires until the user dragged it or reloaded a clock
* started by the agent in that window ran against a dead display, while
* reload+run worked (bb wires are persisted). Deriving the seating at
* mount closes that hole for every path (agent, load, undo).
*/
useEffect(() => {
const tryReseat = () => {
try {
const pinInfo = (elementRef.current as any)?.pinInfo;
if (pinInfo && Array.isArray(pinInfo) && pinInfo.length > 0) {
useSimulatorStore.getState().reseatComponentOnBreadboard(id);
return true;
}
} catch {
// element not ready yet / headless tests
}
return false;
};
if (tryReseat()) return;
// Same cadence as the pinInfo-ready poll above: the custom element may
// upgrade a few frames after React commits.
const interval = setInterval(() => {
if (tryReseat()) clearInterval(interval);
}, 100);
const timeout = setTimeout(() => clearInterval(interval), 2000);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
}, [id, 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>
)}
feat(breadboard): hover-gated labels + full-footprint seating solver Three changes, all driven by a real project where a 4-digit 7-segment clock was unreadable and half its parts were not actually seated. Labels on hover only -------------------- Eight vertical resistors at 19 px pitch rendered eight 93 px "Resistor 220 Ω" labels on top of each other, hiding the parts and the breadboard holes; the SPICE overlay added ~40 more `0uV` pills. Both are now revealed on hover: hovering a part also lights up the voltages of every wire touching it. The label is hidden with OPACITY and stays in flow. pinPositionCalculator derives the rotation pivot from wrapper.offsetHeight, so taking it out of flow would move the pins of every rotated component in every saved project. Seat-on-drop ------------ The drag-time magnet only aligned the anchor pin and assumed the rest followed, which is how parts ended up HALF-seated: some pins in holes, the rest dead in the air. It looks mounted in a screenshot and silently breaks the circuit. On release we now re-solve properly — nearest position where EVERY pin is in a free hole, sliding past occupied columns — via the new solvePlacement/seatOnDrop. Geometry comes from the element's own pinInfo, so there is no part whitelist. Sub-pitch translation --------------------- solvePlacement first assigned pins to holes at half-pitch, then translates by the centroid of the residuals before judging fit. Pinning the anchor dead centre refused every off-lattice footprint: a diode spans 7.5 pitches, so one leg landed 4.8 px out. Shifted 2.4 px, BOTH legs sit inside tolerance — what bending the leads does on a real board. Measured over the catalog this takes seatable parts from 87 to 125 of 152; diodes, transistors, regulators, optocouplers and flip-flops are rescued with no artwork change. Staying under SEAT_TOLERANCE (< half pitch) keeps each pin's nearest hole unambiguous, so computeSeating resolves the same holes and the netlist is unaffected by the small offset. Also: refuse a placement that would put two of a part's own pins in one strip. A column strip — and far worse, a power rail — is a single net, so such a seating shorts the part to itself. Without it a 7-segment happily lays its pins across a rail. And deduplicate pin names before solving: calculatePinPosition resolves by name and returns the first match, so a board carrying GND x5 collided with itself and was refused outright. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 10:28:40 +07:00
{/* Component label revealed on hover/selection only.
A dense board (e.g. 8 vertical resistors at 19 px pitch) turned into
a wall of overlapping "Resistor 220 Ω" text that hid the breadboard
holes and the parts themselves. Hidden with OPACITY, never
`display`/`position`: pinPositionCalculator derives the rotation
pivot from `wrapper.offsetHeight`, so taking the label out of flow
would move every rotated component's pins. */}
<div
className="component-label"
style={{
fontSize: '11px',
textAlign: 'center',
marginTop: '4px',
color: '#666',
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
feat(breadboard): hover-gated labels + full-footprint seating solver Three changes, all driven by a real project where a 4-digit 7-segment clock was unreadable and half its parts were not actually seated. Labels on hover only -------------------- Eight vertical resistors at 19 px pitch rendered eight 93 px "Resistor 220 Ω" labels on top of each other, hiding the parts and the breadboard holes; the SPICE overlay added ~40 more `0uV` pills. Both are now revealed on hover: hovering a part also lights up the voltages of every wire touching it. The label is hidden with OPACITY and stays in flow. pinPositionCalculator derives the rotation pivot from wrapper.offsetHeight, so taking it out of flow would move the pins of every rotated component in every saved project. Seat-on-drop ------------ The drag-time magnet only aligned the anchor pin and assumed the rest followed, which is how parts ended up HALF-seated: some pins in holes, the rest dead in the air. It looks mounted in a screenshot and silently breaks the circuit. On release we now re-solve properly — nearest position where EVERY pin is in a free hole, sliding past occupied columns — via the new solvePlacement/seatOnDrop. Geometry comes from the element's own pinInfo, so there is no part whitelist. Sub-pitch translation --------------------- solvePlacement first assigned pins to holes at half-pitch, then translates by the centroid of the residuals before judging fit. Pinning the anchor dead centre refused every off-lattice footprint: a diode spans 7.5 pitches, so one leg landed 4.8 px out. Shifted 2.4 px, BOTH legs sit inside tolerance — what bending the leads does on a real board. Measured over the catalog this takes seatable parts from 87 to 125 of 152; diodes, transistors, regulators, optocouplers and flip-flops are rescued with no artwork change. Staying under SEAT_TOLERANCE (< half pitch) keeps each pin's nearest hole unambiguous, so computeSeating resolves the same holes and the netlist is unaffected by the small offset. Also: refuse a placement that would put two of a part's own pins in one strip. A column strip — and far worse, a power rail — is a single net, so such a seating shorts the part to itself. Without it a 7-segment happily lays its pins across a rail. And deduplicate pin names before solving: calculatePinPosition resolves by name and returns the first match, so a board carrying GND x5 collided with itself and was refused outright. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 10:28:40 +07:00
opacity: isHovered || isSelected ? 1 : 0,
transition: 'opacity 120ms ease-out',
}}
>
{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, '_');
const properties: Record<string, any> = { ...metadata.defaultValues };
// Resistors default to vertical: they read better, take less horizontal
// space, and drop straight into breadboard columns (their pin span
// bridges the center trench). Covers 'resistor' and every preconfigured
// 'resistor-<value>' variant; anything with an explicit rotation in its
// metadata defaults keeps it.
if (metadata.id.startsWith('resistor') && properties.rotation === undefined) {
properties.rotation = 90;
}
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,
};
}