2026-03-04 05:30:25 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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';
|
2026-03-04 23:36:33 +07:00
|
|
|
|
import { useSimulatorStore } from '../store/useSimulatorStore';
|
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';
|
2026-03-04 23:36:33 +07:00
|
|
|
|
import { PartSimulationRegistry } from '../simulation/parts';
|
2026-03-06 07:07:03 +07:00
|
|
|
|
import { isBoardComponent, boardPinToNumber } from '../utils/boardPinMapping';
|
2026-07-18 07:28:02 +07:00
|
|
|
|
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';
|
2026-05-15 20:50:09 +07:00
|
|
|
|
import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups';
|
2026-06-03 10:40:49 +07:00
|
|
|
|
import { syntheticChipPin } from '../simulation/customChips/syntheticPins';
|
2026-06-05 11:18:08 +07:00
|
|
|
|
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';
|
2026-07-16 02:50:13 +07:00
|
|
|
|
import { breadboardGroupKey } from '../utils/breadboardNets';
|
2026-03-04 05:30:25 +07:00
|
|
|
|
|
2026-04-21 23:50:45 +07:00
|
|
|
|
// Side-effect imports: register every web component we'll create at runtime.
|
2026-04-22 02:45:45 +07:00
|
|
|
|
// `@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.
|
2026-04-21 02:38:31 +07:00
|
|
|
|
import '@wokwi/elements';
|
2026-04-22 02:45:45 +07:00
|
|
|
|
import '../velxio-elements';
|
2026-07-10 04:13:50 +07:00
|
|
|
|
import './velxio-components/Ssd1306I2cElement'; // registers velxio-ssd1306-i2c-4pin (4-pin I2C OLED)
|
2026-04-21 02:38:31 +07:00
|
|
|
|
|
2026-05-16 04:55:07 +07:00
|
|
|
|
// 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>;
|
|
|
|
|
|
|
2026-06-03 10:40:49 +07:00
|
|
|
|
// Custom-chip output pins get stable synthetic pin numbers from
|
|
|
|
|
|
// simulation/customChips/syntheticPins so the chip is a first-class pin source.
|
|
|
|
|
|
|
2026-05-16 04:55:07 +07:00
|
|
|
|
// 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.
|
|
|
|
|
|
//
|
2026-06-03 10:40:49 +07:00
|
|
|
|
// 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.
|
|
|
|
|
|
//
|
2026-05-16 04:55:07 +07:00
|
|
|
|
// 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(
|
2026-05-16 04:55:07 +07:00
|
|
|
|
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),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-03 10:40:49 +07:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-05-16 04:55:07 +07:00
|
|
|
|
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;
|
2026-05-16 04:55:07 +07:00
|
|
|
|
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);
|
2026-06-03 10:40:49 +07:00
|
|
|
|
if (!chipNeighbour && comp?.metadataId === 'custom-chip') {
|
|
|
|
|
|
chipNeighbour = { id: otherEp.componentId, pin: otherEp.pinName };
|
|
|
|
|
|
}
|
2026-05-16 04:55:07 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-07-16 02:50:13 +07:00
|
|
|
|
|
|
|
|
|
|
// 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.
|
2026-07-16 02:50:13 +07:00
|
|
|
|
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
|
2026-07-16 02:50:13 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-16 04:55:07 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-03 10:40:49 +07:00
|
|
|
|
|
2026-06-05 11:18:08 +07:00
|
|
|
|
// 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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 10:40:49 +07:00
|
|
|
|
// 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 };
|
|
|
|
|
|
}
|
2026-05-16 04:55:07 +07:00
|
|
|
|
return { arduinoPin: null, crossedActiveDevice: activeSeen };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 05:30:25 +07:00
|
|
|
|
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;
|
2026-03-04 05:30:25 +07:00
|
|
|
|
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,
|
2026-03-04 05:30:25 +07:00
|
|
|
|
onMouseDown,
|
|
|
|
|
|
onDoubleClick,
|
|
|
|
|
|
onMouseEnter,
|
|
|
|
|
|
onMouseLeave,
|
|
|
|
|
|
onPinInfoReady,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
const elementRef = useRef<HTMLElement>(null);
|
|
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const mountedRef = useRef(false);
|
|
|
|
|
|
|
2026-03-04 23:36:33 +07:00
|
|
|
|
const handleComponentEvent = useSimulatorStore((s) => s.handleComponentEvent);
|
2026-03-05 04:03:54 +07:00
|
|
|
|
const running = useSimulatorStore((s) => s.running);
|
2026-03-05 04:27:14 +07:00
|
|
|
|
const simulator = useSimulatorStore((s) => s.simulator);
|
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);
|
2026-03-09 12:31:04 +07:00
|
|
|
|
// 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);
|
2026-06-18 09:35:56 +07:00
|
|
|
|
// Runtime burnout (P4): destroyed parts render charred + a smoke badge.
|
|
|
|
|
|
const isBurnt = useSimulatorStore((s) => s.burntComponents.has(id));
|
2026-03-05 04:03:54 +07:00
|
|
|
|
|
2026-04-07 21:19:48 +07:00
|
|
|
|
// 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) => {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const myWires = s.wires.filter((w) => w.start.componentId === id || w.end.componentId === id);
|
|
|
|
|
|
return myWires.map((w) => w.id).join(',');
|
2026-04-07 21:19:48 +07:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-05 04:03:54 +07:00
|
|
|
|
// Check if component is interactive (has simulation logic with attachEvents)
|
|
|
|
|
|
const logic = PartSimulationRegistry.get(metadata.id || id.split('-')[0]);
|
|
|
|
|
|
const isInteractive = logic?.attachEvents !== undefined;
|
2026-03-04 23:36:33 +07:00
|
|
|
|
|
2026-03-04 05:30:25 +07:00
|
|
|
|
/**
|
2026-07-19 00:18:27 +07:00
|
|
|
|
* 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.
|
2026-03-04 05:30:25 +07:00
|
|
|
|
*/
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!elementRef.current) return;
|
|
|
|
|
|
|
|
|
|
|
|
Object.entries(properties).forEach(([key, value]) => {
|
|
|
|
|
|
try {
|
2026-07-19 00:18:27 +07:00
|
|
|
|
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;
|
2026-03-04 05:30:25 +07:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn(`Failed to set property ${key} on ${metadata.tagName}:`, error);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [properties, metadata.tagName]);
|
|
|
|
|
|
|
2026-07-19 00:18:27 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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]);
|
|
|
|
|
|
|
2026-03-04 05:30:25 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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) => {
|
2026-05-20 01:10:42 +07:00
|
|
|
|
if (!onMouseDown) return;
|
fix(ui): narrow pointer-passthrough whitelist; restore DHT22/HC-SR04 dialog
The previous fix (dd22bcf) used `isInteractive` to decide whether to let
the wokwi component own the pointerdown. That heuristic was too broad —
DHT22, HC-SR04, NTC, photoresistor, LED all register `attachEvents` for
the SPICE/sensor-update bridge but have NO internal pointer handlers, so
clicks on them got silently swallowed by the wokwi shadow DOM and the
property dialog never opened.
Replace with an explicit whitelist of wokwi tags that ACTUALLY own
pointerdown (rotary knobs, pushbuttons, slide switches, joysticks,
keypads, encoders, rotary dialer). Every other component, including
sensors/displays/LEDs with attachEvents, falls through to the canvas
which decides between drag-to-rearrange and click-to-open-dialog.
Documented the model in docs/wiki/component-interaction.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:16:47 +07:00
|
|
|
|
// 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.
|
2026-05-20 01:10:42 +07:00
|
|
|
|
//
|
fix(ui): narrow pointer-passthrough whitelist; restore DHT22/HC-SR04 dialog
The previous fix (dd22bcf) used `isInteractive` to decide whether to let
the wokwi component own the pointerdown. That heuristic was too broad —
DHT22, HC-SR04, NTC, photoresistor, LED all register `attachEvents` for
the SPICE/sensor-update bridge but have NO internal pointer handlers, so
clicks on them got silently swallowed by the wokwi shadow DOM and the
property dialog never opened.
Replace with an explicit whitelist of wokwi tags that ACTUALLY own
pointerdown (rotary knobs, pushbuttons, slide switches, joysticks,
keypads, encoders, rotary dialer). Every other component, including
sensors/displays/LEDs with attachEvents, falls through to the canvas
which decides between drag-to-rearrange and click-to-open-dialog.
Documented the model in docs/wiki/component-interaction.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:16:47 +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.
|
2026-05-20 01:10:42 +07:00
|
|
|
|
//
|
fix(ui): narrow pointer-passthrough whitelist; restore DHT22/HC-SR04 dialog
The previous fix (dd22bcf) used `isInteractive` to decide whether to let
the wokwi component own the pointerdown. That heuristic was too broad —
DHT22, HC-SR04, NTC, photoresistor, LED all register `attachEvents` for
the SPICE/sensor-update bridge but have NO internal pointer handlers, so
clicks on them got silently swallowed by the wokwi shadow DOM and the
property dialog never opened.
Replace with an explicit whitelist of wokwi tags that ACTUALLY own
pointerdown (rotary knobs, pushbuttons, slide switches, joysticks,
keypads, encoders, rotary dialer). Every other component, including
sensors/displays/LEDs with attachEvents, falls through to the canvas
which decides between drag-to-rearrange and click-to-open-dialog.
Documented the model in docs/wiki/component-interaction.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:16:47 +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.
|
2026-05-20 01:10:42 +07:00
|
|
|
|
const target = e.target as HTMLElement;
|
fix(ui): narrow pointer-passthrough whitelist; restore DHT22/HC-SR04 dialog
The previous fix (dd22bcf) used `isInteractive` to decide whether to let
the wokwi component own the pointerdown. That heuristic was too broad —
DHT22, HC-SR04, NTC, photoresistor, LED all register `attachEvents` for
the SPICE/sensor-update bridge but have NO internal pointer handlers, so
clicks on them got silently swallowed by the wokwi shadow DOM and the
property dialog never opened.
Replace with an explicit whitelist of wokwi tags that ACTUALLY own
pointerdown (rotary knobs, pushbuttons, slide switches, joysticks,
keypads, encoders, rotary dialer). Every other component, including
sensors/displays/LEDs with attachEvents, falls through to the canvas
which decides between drag-to-rearrange and click-to-open-dialog.
Documented the model in docs/wiki/component-interaction.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:16:47 +07:00
|
|
|
|
const tag = target.tagName?.toLowerCase() ?? '';
|
|
|
|
|
|
const ownsPointer =
|
2026-05-20 01:10:42 +07:00
|
|
|
|
interactionRunning &&
|
fix(ui): narrow pointer-passthrough whitelist; restore DHT22/HC-SR04 dialog
The previous fix (dd22bcf) used `isInteractive` to decide whether to let
the wokwi component own the pointerdown. That heuristic was too broad —
DHT22, HC-SR04, NTC, photoresistor, LED all register `attachEvents` for
the SPICE/sensor-update bridge but have NO internal pointer handlers, so
clicks on them got silently swallowed by the wokwi shadow DOM and the
property dialog never opened.
Replace with an explicit whitelist of wokwi tags that ACTUALLY own
pointerdown (rotary knobs, pushbuttons, slide switches, joysticks,
keypads, encoders, rotary dialer). Every other component, including
sensors/displays/LEDs with attachEvents, falls through to the canvas
which decides between drag-to-rearrange and click-to-open-dialog.
Documented the model in docs/wiki/component-interaction.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:16:47 +07:00
|
|
|
|
(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) {
|
2026-05-20 01:10:42 +07:00
|
|
|
|
// Let the wokwi component own this pointerdown.
|
|
|
|
|
|
return;
|
2026-03-04 05:30:25 +07:00
|
|
|
|
}
|
2026-05-20 01:10:42 +07:00
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onMouseDown(e);
|
2026-03-04 05:30:25 +07:00
|
|
|
|
},
|
fix(ui): narrow pointer-passthrough whitelist; restore DHT22/HC-SR04 dialog
The previous fix (dd22bcf) used `isInteractive` to decide whether to let
the wokwi component own the pointerdown. That heuristic was too broad —
DHT22, HC-SR04, NTC, photoresistor, LED all register `attachEvents` for
the SPICE/sensor-update bridge but have NO internal pointer handlers, so
clicks on them got silently swallowed by the wokwi shadow DOM and the
property dialog never opened.
Replace with an explicit whitelist of wokwi tags that ACTUALLY own
pointerdown (rotary knobs, pushbuttons, slide switches, joysticks,
keypads, encoders, rotary dialer). Every other component, including
sensors/displays/LEDs with attachEvents, falls through to the canvas
which decides between drag-to-rearrange and click-to-open-dialog.
Documented the model in docs/wiki/component-interaction.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:16:47 +07:00
|
|
|
|
[onMouseDown, interactionRunning],
|
2026-03-04 05:30:25 +07:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const handleDoubleClick = useCallback(
|
|
|
|
|
|
(e: React.MouseEvent) => {
|
|
|
|
|
|
if (onDoubleClick) {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onDoubleClick(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-04-22 02:45:45 +07:00
|
|
|
|
[onDoubleClick],
|
2026-03-04 05:30:25 +07:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
|
|
|
|
|
|
2026-03-04 23:36:33 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
|
2026-03-05 04:27:14 +07:00
|
|
|
|
const logic = PartSimulationRegistry.get(metadata.id || id.split('-')[0]);
|
2026-03-04 23:36:33 +07:00
|
|
|
|
|
|
|
|
|
|
let cleanupSimulationEvents: (() => void) | undefined;
|
2026-05-13 00:26:33 +07:00
|
|
|
|
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,
|
2026-06-04 02:04:17 +07:00
|
|
|
|
// 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: () => {},
|
|
|
|
|
|
},
|
2026-05-13 00:26:33 +07:00
|
|
|
|
} as any);
|
2026-04-13 21:06:03 +07:00
|
|
|
|
// 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
|
fix(DynamicComponent): pass componentId through to PinTracer
`PinTracer` signature is `(componentId, componentPinName) => number | null`
but the local `getArduinoPin` lambda only accepted one arg and used the
closure-captured `id`. When `createDefaultPinResolver` passed both args
(per the typed signature), JS bound the FIRST arg (the componentId) into
the lambda's single `componentPinName` parameter. `traceDetailed` then
looked up a pin literally named "rgb-led-1" on component "rgb-led-1",
returned null, and the resolver locked itself into 'FLOATING' state —
its onChange path never subscribed and the wokwi-rgb-led element's
ledRed/ledGreen/ledBlue stayed at 0 forever even as the SPICE side
correctly cycled through R, G, B, Y, C, M, W via analogWrite().
Same bug latent for any multi-pin component that goes through the
PinResolver path (multi-pin LEDs, RGB strips, 7-seg drivers, anything
that calls `getPinResolver(<pinName>)` for several pin names).
Fix: lambda now accepts both shapes — `getArduinoPin(pinName)` (legacy
single-arg used by every PartSimulationRegistry handler) AND
`getArduinoPin(componentId, pinName)` (PinTracer 2-arg form used by
createDefaultPinResolver / createSpiceResolvedPinResolver). Picks the
right componentId in either case.
Verified via the rgb-led example: ledRed/ledGreen/ledBlue now cycle
0→255→0 in sync with the SPICE node voltages on pins 9/10/11.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 08:07:42 +07:00
|
|
|
|
// 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 => {
|
2026-03-21 03:11:12 +07:00
|
|
|
|
const state = useSimulatorStore.getState();
|
fix(DynamicComponent): pass componentId through to PinTracer
`PinTracer` signature is `(componentId, componentPinName) => number | null`
but the local `getArduinoPin` lambda only accepted one arg and used the
closure-captured `id`. When `createDefaultPinResolver` passed both args
(per the typed signature), JS bound the FIRST arg (the componentId) into
the lambda's single `componentPinName` parameter. `traceDetailed` then
looked up a pin literally named "rgb-led-1" on component "rgb-led-1",
returned null, and the resolver locked itself into 'FLOATING' state —
its onChange path never subscribed and the wokwi-rgb-led element's
ledRed/ledGreen/ledBlue stayed at 0 forever even as the SPICE side
correctly cycled through R, G, B, Y, C, M, W via analogWrite().
Same bug latent for any multi-pin component that goes through the
PinResolver path (multi-pin LEDs, RGB strips, 7-seg drivers, anything
that calls `getPinResolver(<pinName>)` for several pin names).
Fix: lambda now accepts both shapes — `getArduinoPin(pinName)` (legacy
single-arg used by every PartSimulationRegistry handler) AND
`getArduinoPin(componentId, pinName)` (PinTracer 2-arg form used by
createDefaultPinResolver / createSpiceResolvedPinResolver). Picks the
right componentId in either case.
Verified via the rgb-led example: ledRed/ledGreen/ledBlue now cycle
0→255→0 in sync with the SPICE node voltages on pins 9/10/11.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 08:07:42 +07:00
|
|
|
|
const componentId = maybePinName !== undefined ? componentIdOrPin : id;
|
|
|
|
|
|
const componentPinName =
|
|
|
|
|
|
maybePinName !== undefined ? maybePinName : componentIdOrPin;
|
|
|
|
|
|
return traceDetailed(state, componentId, componentPinName, 0).arduinoPin;
|
2026-03-05 04:27:14 +07:00
|
|
|
|
};
|
2026-03-04 23:36:33 +07:00
|
|
|
|
|
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).
|
2026-05-16 04:55:07 +07:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 20:50:09 +07:00
|
|
|
|
cleanupSimulationEvents = logic.attachEvents(
|
|
|
|
|
|
el,
|
|
|
|
|
|
stubSimulator,
|
|
|
|
|
|
getArduinoPin,
|
|
|
|
|
|
id,
|
|
|
|
|
|
getPinResolver,
|
|
|
|
|
|
);
|
2026-03-04 23:36:33 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (cleanupSimulationEvents) cleanupSimulationEvents();
|
|
|
|
|
|
|
|
|
|
|
|
el.removeEventListener('button-press', onButtonPress);
|
|
|
|
|
|
el.removeEventListener('button-release', onButtonRelease);
|
|
|
|
|
|
};
|
2026-04-07 21:19:48 +07:00
|
|
|
|
}, [id, handleComponentEvent, metadata.id, simulator, hexEpoch, wireFingerprint]);
|
2026-03-04 23:36:33 +07:00
|
|
|
|
|
2026-05-13 21:51:52 +07:00
|
|
|
|
// 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.
|
2026-03-04 05:30:25 +07:00
|
|
|
|
return (
|
|
|
|
|
|
<div
|
2026-06-18 09:35:56 +07:00
|
|
|
|
className={`dynamic-component-wrapper${isBurnt ? ' velxio-burnt' : ''}`}
|
2026-03-04 05:30:25 +07:00
|
|
|
|
style={{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
left: `${x}px`,
|
|
|
|
|
|
top: `${y}px`,
|
2026-05-13 09:34:58 +07:00
|
|
|
|
cursor: interactionRunning && isInteractive ? 'pointer' : 'move',
|
2026-03-04 05:30:25 +07:00
|
|
|
|
border: isSelected ? '2px dashed #007acc' : '2px solid transparent',
|
|
|
|
|
|
borderRadius: '4px',
|
|
|
|
|
|
padding: '4px',
|
|
|
|
|
|
userSelect: 'none',
|
2026-03-05 09:40:17 +07:00
|
|
|
|
zIndex: isSelected ? 5 : 1,
|
2026-03-04 05:30:25 +07:00
|
|
|
|
pointerEvents: 'auto',
|
2026-03-04 06:42:17 +07:00
|
|
|
|
transform: properties.rotation ? `rotate(${properties.rotation}deg)` : undefined,
|
|
|
|
|
|
transformOrigin: 'center center',
|
2026-03-04 05:30:25 +07:00
|
|
|
|
}}
|
2026-05-13 21:51:52 +07:00
|
|
|
|
onMouseDownCapture={handleMouseDown}
|
2026-03-04 05:30:25 +07:00
|
|
|
|
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" />
|
|
|
|
|
|
|
2026-06-18 09:35:56 +07:00
|
|
|
|
{/* 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. */}
|
2026-03-04 05:30:25 +07:00
|
|
|
|
<div
|
|
|
|
|
|
className="component-label"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
fontSize: '11px',
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
marginTop: '4px',
|
|
|
|
|
|
color: '#666',
|
|
|
|
|
|
pointerEvents: 'none',
|
2026-04-07 09:44:24 +07:00
|
|
|
|
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',
|
2026-03-04 05:30:25 +07:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-22 02:45:45 +07:00
|
|
|
|
{properties.pin !== undefined ? `Pin ${properties.pin}` : metadata.name}
|
2026-07-18 07:28:02 +07:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-04-07 09:44:24 +07:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-03-04 05:30:25 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Helper function to create a component instance from metadata
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function createComponentFromMetadata(
|
|
|
|
|
|
metadata: ComponentMetadata,
|
|
|
|
|
|
x: number,
|
2026-04-22 02:45:45 +07:00
|
|
|
|
y: number,
|
2026-03-04 05:30:25 +07:00
|
|
|
|
): {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
metadataId: string;
|
|
|
|
|
|
x: number;
|
|
|
|
|
|
y: number;
|
|
|
|
|
|
properties: Record<string, any>;
|
|
|
|
|
|
} {
|
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, '_');
|
2026-07-18 23:02:41 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-04 05:30:25 +07:00
|
|
|
|
return {
|
2026-05-18 20:01:38 +07:00
|
|
|
|
id: `${safePrefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
2026-03-04 05:30:25 +07:00
|
|
|
|
metadataId: metadata.id,
|
|
|
|
|
|
x,
|
|
|
|
|
|
y,
|
2026-07-18 23:02:41 +07:00
|
|
|
|
properties,
|
2026-03-04 05:30:25 +07:00
|
|
|
|
};
|
|
|
|
|
|
}
|