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';
|
fix(boards): el overlay ya no secuestra el dibujo de las Raspberry Pi
Dos fallos que salieron probando pi-to-arduino-led-control y
pi5-pir-motion-alarm.
1) BoardOnCanvas mira getProBoard() ANTES del switch OSS para decidir si
dibuja un elemento del overlay. El overlay registraba un def minimo
para las seis Pi solo para llevar una linea de setup del guest, y eso
basto para cambiarles el render: en vez de la ilustracion
Raspberry_Pi_3_illustration.svg salia la caja esquematica, con otras
coordenadas de pines, y los cables quedaban colgando en la esquina.
Ahora hay un registro aparte, registerGuestSetup(kind, linea), que
lleva la cadena y nada mas; getGuestSetup() la resuelve dando
prioridad al def del overlay si existe.
2) Las partes de entrada (PIR, botones, sensores) avisan con
simulator.setPinState(pin, nivel). En una placa QEMU-Linux no hay
simulador de MCU -- el CPU es el guest -- asi que la llamada acababa
en la instancia AVR heredada y se perdia: pulsar el sensor no hacia
nada. traceDetailed devuelve ahora tambien la placa a la que llega el
pin, y si es de la familia Pi la parte recibe un simulador que empuja
el nivel al bridge (gpio_in para el guest, el valor pin<N> que leen
los shims del motor de navegador) y al PinManager de esa placa.
2026-07-29 12:34:01 +07:00
|
|
|
import {
|
|
|
|
|
useSimulatorStore,
|
|
|
|
|
getBoardBridge,
|
|
|
|
|
getBoardPinManager,
|
2026-08-15 13:29:39 +07:00
|
|
|
getBoardSimulator,
|
fix(boards): el overlay ya no secuestra el dibujo de las Raspberry Pi
Dos fallos que salieron probando pi-to-arduino-led-control y
pi5-pir-motion-alarm.
1) BoardOnCanvas mira getProBoard() ANTES del switch OSS para decidir si
dibuja un elemento del overlay. El overlay registraba un def minimo
para las seis Pi solo para llevar una linea de setup del guest, y eso
basto para cambiarles el render: en vez de la ilustracion
Raspberry_Pi_3_illustration.svg salia la caja esquematica, con otras
coordenadas de pines, y los cables quedaban colgando en la esquina.
Ahora hay un registro aparte, registerGuestSetup(kind, linea), que
lleva la cadena y nada mas; getGuestSetup() la resuelve dando
prioridad al def del overlay si existe.
2) Las partes de entrada (PIR, botones, sensores) avisan con
simulator.setPinState(pin, nivel). En una placa QEMU-Linux no hay
simulador de MCU -- el CPU es el guest -- asi que la llamada acababa
en la instancia AVR heredada y se perdia: pulsar el sensor no hacia
nada. traceDetailed devuelve ahora tambien la placa a la que llega el
pin, y si es de la familia Pi la parte recibe un simulador que empuja
el nivel al bridge (gpio_in para el guest, el valor pin<N> que leen
los shims del motor de navegador) y al PinManager de esa placa.
2026-07-29 12:34:01 +07:00
|
|
|
} 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-08-15 13:29:39 +07:00
|
|
|
import { dispatchSensorUpdate } from '../simulation/SensorUpdateRegistry';
|
fix(boards): el overlay ya no secuestra el dibujo de las Raspberry Pi
Dos fallos que salieron probando pi-to-arduino-led-control y
pi5-pir-motion-alarm.
1) BoardOnCanvas mira getProBoard() ANTES del switch OSS para decidir si
dibuja un elemento del overlay. El overlay registraba un def minimo
para las seis Pi solo para llevar una linea de setup del guest, y eso
basto para cambiarles el render: en vez de la ilustracion
Raspberry_Pi_3_illustration.svg salia la caja esquematica, con otras
coordenadas de pines, y los cables quedaban colgando en la esquina.
Ahora hay un registro aparte, registerGuestSetup(kind, linea), que
lleva la cadena y nada mas; getGuestSetup() la resuelve dando
prioridad al def del overlay si existe.
2) Las partes de entrada (PIR, botones, sensores) avisan con
simulator.setPinState(pin, nivel). En una placa QEMU-Linux no hay
simulador de MCU -- el CPU es el guest -- asi que la llamada acababa
en la instancia AVR heredada y se perdia: pulsar el sensor no hacia
nada. traceDetailed devuelve ahora tambien la placa a la que llega el
pin, y si es de la familia Pi la parte recibe un simulador que empuja
el nivel al bridge (gpio_in para el guest, el valor pin<N> que leen
los shims del motor de navegador) y al PinManager de esa placa.
2026-07-29 12:34:01 +07:00
|
|
|
import { isPiBoardKind } from '../types/board';
|
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
|
|
|
type PinResolver,
|
|
|
|
|
} from '../simulation/PinResolver';
|
2026-05-15 20:50:09 +07:00
|
|
|
import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups';
|
fix(sim): a resistor divider on a sensor line made the board pin invisible
An HC-SR04 on an ESP32-S3 answered "No echo" forever the moment its ECHO
went through the 1k/2k2 divider a real 5 V sensor needs, and worked again
when the divider came out (reported on Discord, project esp32-s3-distance).
Nothing about the divider is wrong: three separate resolvers could not see
past it.
1. The wire walk treated a net as a chain. Arriving at the divider's tap it
hopped straight across the lower resistor, hit GND, and answered "tied to
a rail" (-1) — never looking at the OTHER wire on that same tap, where
GPIO41 was. A rail was also allowed to end the search, so a plain pull-up
drawn before the signal wire hid the GPIO behind it the same way. The walk
now explores the whole node (same depth, one shared visited set) and
treats a supply pad as the weakest answer: remembered, returned only when
no driven pin was found anywhere.
2. The walk moved to simulation/PinTrace so the store can ask the same
question. It pre-registered backend sensors with its own single-wire
lookup — one wire, far end must be a board — so ECHO (landing on a
resistor) resolved to nothing. That entry REPLACES the part's, keyed by
pin, so the echo pin was silently dropped and the QEMU worker fell back to
TRIG+1: it pulsed GPIO22, which nobody was reading. Both the data pin and
the extra pins now resolve through the shared walk, so a sensor behind a
breadboard strip or a divider registers like any other.
3. Esp32Bridge.sendPinEvent now refuses a pad a registered sensor owns, the
guard the in-browser engines already have. connectDigitalInputsToMcu
thresholds the solved node, and `sourcedNets` does not hold it back once
real components sit on the net — the divider makes ECHO exactly that, and
it solves at ~0 V because nothing models the sensor's output.
Test covers the reported circuit end to end: the walk, and the echo_pin that
reaches the start_esp32 payload.
2026-08-23 04:31:23 +07:00
|
|
|
import { traceDetailed } from '../simulation/PinTrace';
|
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-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
|
|
|
|
fix(sim): a resistor divider on a sensor line made the board pin invisible
An HC-SR04 on an ESP32-S3 answered "No echo" forever the moment its ECHO
went through the 1k/2k2 divider a real 5 V sensor needs, and worked again
when the divider came out (reported on Discord, project esp32-s3-distance).
Nothing about the divider is wrong: three separate resolvers could not see
past it.
1. The wire walk treated a net as a chain. Arriving at the divider's tap it
hopped straight across the lower resistor, hit GND, and answered "tied to
a rail" (-1) — never looking at the OTHER wire on that same tap, where
GPIO41 was. A rail was also allowed to end the search, so a plain pull-up
drawn before the signal wire hid the GPIO behind it the same way. The walk
now explores the whole node (same depth, one shared visited set) and
treats a supply pad as the weakest answer: remembered, returned only when
no driven pin was found anywhere.
2. The walk moved to simulation/PinTrace so the store can ask the same
question. It pre-registered backend sensors with its own single-wire
lookup — one wire, far end must be a board — so ECHO (landing on a
resistor) resolved to nothing. That entry REPLACES the part's, keyed by
pin, so the echo pin was silently dropped and the QEMU worker fell back to
TRIG+1: it pulsed GPIO22, which nobody was reading. Both the data pin and
the extra pins now resolve through the shared walk, so a sensor behind a
breadboard strip or a divider registers like any other.
3. Esp32Bridge.sendPinEvent now refuses a pad a registered sensor owns, the
guard the in-browser engines already have. connectDigitalInputsToMcu
thresholds the solved node, and `sourcedNets` does not hold it back once
real components sit on the net — the divider makes ECHO exactly that, and
it solves at ~0 V because nothing models the sensor's output.
Test covers the reported circuit end to end: the walk, and the echo_pin that
reaches the start_esp32 payload.
2026-08-23 04:31:23 +07:00
|
|
|
// The wire-graph walk that answers "which board pin owns this component pin"
|
|
|
|
|
// lives in simulation/PinTrace so the store can ask the SAME question when it
|
|
|
|
|
// pre-registers backend sensors. Re-exported here: it was this module's API.
|
|
|
|
|
export { traceDetailed };
|
2026-05-16 04:55:07 +07:00
|
|
|
|
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;
|
feat(canvas): ver donde cae lo que anades, y clicks con sentido
Tres cambios que van juntos porque atacan la misma queja: "anado un
elemento y a veces ni veo donde se anadio".
1. Donde cae. Ya se anclaba a la esquina visible, pero la cascada que evita
que se apilen iba indexada por components.length: seguia avanzando
aunque movieras o borraras piezas, asi que las caidas se alejaban cada
vez mas de donde estabas mirando. Ahora toma el primer hueco LIBRE desde
la esquina, bajando en diagonal; si apartas la ultima, la siguiente
recupera su sitio. Extraido a utils/dropSlot con 8 tests, incluido el
caso de "la apartaron" y el tope para no salirse de la vista.
2. Que se vea. El recien anadido queda seleccionado, y la seleccion pasa de
un borde discontinuo quieto a un caminito de hormigas. El movimiento es
lo que capta el ojo en un canvas lleno; un borde fijo se pierde. Va en
un pseudo-elemento por fuera del cuerpo, sin robar clicks ni tapar el
dibujo, y se queda quieto si el sistema pide menos animacion.
3. Clicks. El izquierdo SELECCIONA y ya esta; antes abria el panel de
propiedades, o sea que no podias ni senalar una pieza sin comerte un
popup que luego habia que cerrar. Propiedades y pines pasan al click
derecho, que es donde va lo deliberado. En tactil se mantiene tocar ->
panel, que ahi no hay boton derecho.
2026-07-30 20:40:52 +07:00
|
|
|
/** Right click: the canvas opens the properties + pins dialog here. */
|
|
|
|
|
onContextMenu?: (e: React.MouseEvent) => void;
|
2026-03-04 05:30:25 +07:00
|
|
|
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,
|
feat(canvas): ver donde cae lo que anades, y clicks con sentido
Tres cambios que van juntos porque atacan la misma queja: "anado un
elemento y a veces ni veo donde se anadio".
1. Donde cae. Ya se anclaba a la esquina visible, pero la cascada que evita
que se apilen iba indexada por components.length: seguia avanzando
aunque movieras o borraras piezas, asi que las caidas se alejaban cada
vez mas de donde estabas mirando. Ahora toma el primer hueco LIBRE desde
la esquina, bajando en diagonal; si apartas la ultima, la siguiente
recupera su sitio. Extraido a utils/dropSlot con 8 tests, incluido el
caso de "la apartaron" y el tope para no salirse de la vista.
2. Que se vea. El recien anadido queda seleccionado, y la seleccion pasa de
un borde discontinuo quieto a un caminito de hormigas. El movimiento es
lo que capta el ojo en un canvas lleno; un borde fijo se pierde. Va en
un pseudo-elemento por fuera del cuerpo, sin robar clicks ni tapar el
dibujo, y se queda quieto si el sistema pide menos animacion.
3. Clicks. El izquierdo SELECCIONA y ya esta; antes abria el panel de
propiedades, o sea que no podias ni senalar una pieza sin comerte un
popup que luego habia que cerrar. Propiedades y pines pasan al click
derecho, que es donde va lo deliberado. En tactil se mantiene tocar ->
panel, que ahi no hay boton derecho.
2026-07-30 20:40:52 +07:00
|
|
|
onContextMenu,
|
2026-03-04 05:30:25 +07:00
|
|
|
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
|
|
|
*/
|
2026-08-15 13:29:39 +07:00
|
|
|
// Last property values actually applied to the element. Used to detect
|
|
|
|
|
// real changes below — the store hands us a NEW properties object on every
|
|
|
|
|
// update even when the values are identical, and the change-notification
|
|
|
|
|
// path (dispatch 'input' → part sim → emitPropertyChange → store) would
|
|
|
|
|
// otherwise echo forever.
|
|
|
|
|
const appliedPropsRef = useRef<Record<string, unknown>>({});
|
|
|
|
|
|
2026-03-04 05:30:25 +07:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!elementRef.current) return;
|
|
|
|
|
|
2026-08-15 13:29:39 +07:00
|
|
|
let changed = false;
|
|
|
|
|
const numericChanges: Record<string, number> = {};
|
2026-03-04 05:30:25 +07:00
|
|
|
Object.entries(properties).forEach(([key, value]) => {
|
|
|
|
|
try {
|
2026-08-11 10:19:26 +07:00
|
|
|
// Display framebuffers round-trip through saved projects as strings
|
|
|
|
|
// ('' — the generated metadata lists imageData as a text prop).
|
|
|
|
|
// Assigning that string clobbers the element's live ImageData and
|
|
|
|
|
// crashes wokwi-elements' firstUpdated (putImageData TypeError).
|
|
|
|
|
if (key === 'imageData' && !(value instanceof ImageData)) return;
|
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-08-15 13:29:39 +07:00
|
|
|
if (appliedPropsRef.current[key] !== coerced) {
|
|
|
|
|
appliedPropsRef.current[key] = coerced;
|
|
|
|
|
changed = true;
|
|
|
|
|
if (typeof coerced === 'number') numericChanges[key] = coerced;
|
|
|
|
|
}
|
2026-03-04 05:30:25 +07:00
|
|
|
} catch (error) {
|
|
|
|
|
console.warn(`Failed to set property ${key} on ${metadata.tagName}:`, error);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-08-15 13:29:39 +07:00
|
|
|
|
|
|
|
|
// A bare property assignment is invisible to the part simulators: they
|
|
|
|
|
// bind to DOM events ('input'/'change' — how the element's own knob
|
|
|
|
|
// notifies) or to the SensorUpdateRegistry (how the sensor panel
|
|
|
|
|
// notifies). Programmatic writes — the agent's set_component_property,
|
|
|
|
|
// values restored from a saved project, the property dialog — used to
|
|
|
|
|
// reach the ELEMENT but never the running simulation, so a pot "set" to
|
|
|
|
|
// 800 kept reading ADC 0 until a human touched its knob. Notify both
|
|
|
|
|
// channels, only on real value changes (see appliedPropsRef above).
|
|
|
|
|
if (changed) {
|
|
|
|
|
try {
|
|
|
|
|
elementRef.current.dispatchEvent(new Event('input'));
|
|
|
|
|
elementRef.current.dispatchEvent(new Event('change'));
|
|
|
|
|
if (Object.keys(numericChanges).length) {
|
|
|
|
|
dispatchSensorUpdate(id, numericChanges);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn(`Failed to notify simulation of ${metadata.tagName} change:`, error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [properties, metadata.tagName, id]);
|
2026-03-04 05:30:25 +07:00
|
|
|
|
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;
|
|
|
|
|
};
|
2026-08-09 13:32:20 +07:00
|
|
|
// Wires resolve through the same pinInfo, and the canvas' load-settle
|
|
|
|
|
// timers (100/300/500ms) can ALL fire before this element is findable —
|
|
|
|
|
// an overlay-defined custom element upgrades when its (large) chunk
|
|
|
|
|
// lands, and on the example route the wires themselves are stored after
|
|
|
|
|
// further awaits. So: the first time THIS part's pinInfo is actually
|
|
|
|
|
// readable from the DOM, re-derive every wire endpoint. Measured on
|
|
|
|
|
// staging: without this, all four wires to the part sat on its corner
|
|
|
|
|
// until the user nudged something.
|
|
|
|
|
const tryWires = () => {
|
|
|
|
|
try {
|
|
|
|
|
const el = document.getElementById(id) as (HTMLElement & { pinInfo?: unknown[] }) | null;
|
|
|
|
|
if (el && Array.isArray(el.pinInfo) && el.pinInfo.length > 0) {
|
|
|
|
|
useSimulatorStore.getState().recalculateAllWirePositions();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// headless tests
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
};
|
2026-08-09 13:13:48 +07:00
|
|
|
|
2026-08-09 13:32:20 +07:00
|
|
|
const reseated = tryReseat();
|
|
|
|
|
const wired = tryWires();
|
|
|
|
|
if (reseated && wired) return;
|
|
|
|
|
// Poll until both settle. 10s, not 2s: the overlay chunk that defines
|
|
|
|
|
// the element can take that long on a slow connection, and giving up
|
|
|
|
|
// early is exactly the corner-wire bug again.
|
|
|
|
|
let done = { reseat: reseated, wires: wired };
|
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
|
|
|
const interval = setInterval(() => {
|
2026-08-09 13:32:20 +07:00
|
|
|
if (!done.reseat) done.reseat = tryReseat();
|
|
|
|
|
if (!done.wires) done.wires = tryWires();
|
|
|
|
|
if (done.reseat && done.wires) clearInterval(interval);
|
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
|
|
|
}, 100);
|
2026-08-09 13:32:20 +07:00
|
|
|
const timeout = setTimeout(() => clearInterval(interval), 10000);
|
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
|
|
|
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' ||
|
2026-07-28 20:33:24 +07:00
|
|
|
tag === 'wokwi-rotary-dialer' ||
|
|
|
|
|
// Rule-6a escape hatch: a (possibly private-overlay) element whose
|
|
|
|
|
// surface IS the interaction — a touch screen — declares it via a
|
|
|
|
|
// property instead of this list growing pro tag names. While the
|
|
|
|
|
// sim runs, touching it must touch, not drag: the Round Display's
|
|
|
|
|
// glass was painting the green dot AND dragging the shield around.
|
|
|
|
|
(target as { ownsPointer?: boolean }).ownsPointer === true);
|
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
|
|
|
if (ownsPointer) {
|
2026-07-28 21:09:27 +07:00
|
|
|
// A declared touch SCREEN (ownsPointer property, not the wokwi tag
|
|
|
|
|
// list): its model listens on POINTER events — a separate stream —
|
|
|
|
|
// so stopping THIS mousedown costs it nothing, and it must be
|
|
|
|
|
// stopped: left-drag that reaches the canvas background pans the
|
|
|
|
|
// whole world under the finger mid-swipe. Wokwi knobs keep the
|
|
|
|
|
// legacy pass-through, their internal handlers may bind this very
|
|
|
|
|
// mouse event.
|
|
|
|
|
if ((target as { ownsPointer?: boolean }).ownsPointer === true) e.stopPropagation();
|
|
|
|
|
// Let the component own this pointerdown.
|
2026-05-20 01:10:42 +07:00
|
|
|
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 {
|
2026-08-11 10:45:33 +07:00
|
|
|
// Same guard as the property-sync effect above: the persisted
|
|
|
|
|
// imageData string must never replace the element's live ImageData
|
|
|
|
|
// (it crashes wokwi-elements' firstUpdated on mount).
|
|
|
|
|
if (key === 'imageData' && !(value instanceof ImageData)) return;
|
2026-03-04 05:30:25 +07:00
|
|
|
(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.
|
fix(boards): el overlay ya no secuestra el dibujo de las Raspberry Pi
Dos fallos que salieron probando pi-to-arduino-led-control y
pi5-pir-motion-alarm.
1) BoardOnCanvas mira getProBoard() ANTES del switch OSS para decidir si
dibuja un elemento del overlay. El overlay registraba un def minimo
para las seis Pi solo para llevar una linea de setup del guest, y eso
basto para cambiarles el render: en vez de la ilustracion
Raspberry_Pi_3_illustration.svg salia la caja esquematica, con otras
coordenadas de pines, y los cables quedaban colgando en la esquina.
Ahora hay un registro aparte, registerGuestSetup(kind, linea), que
lleva la cadena y nada mas; getGuestSetup() la resuelve dando
prioridad al def del overlay si existe.
2) Las partes de entrada (PIR, botones, sensores) avisan con
simulator.setPinState(pin, nivel). En una placa QEMU-Linux no hay
simulador de MCU -- el CPU es el guest -- asi que la llamada acababa
en la instancia AVR heredada y se perdia: pulsar el sensor no hacia
nada. traceDetailed devuelve ahora tambien la placa a la que llega el
pin, y si es de la familia Pi la parte recibe un simulador que empuja
el nivel al bridge (gpio_in para el guest, el valor pin<N> que leen
los shims del motor de navegador) y al PinManager de esa placa.
2026-07-29 12:34:01 +07:00
|
|
|
// A QEMU-Linux board (Raspberry Pi family, UNIHIKER) has no MCU
|
|
|
|
|
// simulator: the guest IS the CPU. An input part still calls
|
|
|
|
|
// `simulator.setPinState(pin, level)` to report a button press or a
|
|
|
|
|
// PIR trip, and that call used to land on the legacy AVR instance and
|
|
|
|
|
// vanish — clicking the sensor did nothing at all. Route it to the
|
|
|
|
|
// bridge of the board this component is actually wired to: `gpio_in`
|
|
|
|
|
// for the guest, the canvas-fed `pin<N>` value the browser engine's
|
|
|
|
|
// shims read, and the PinManager so wires and SPICE see the edge.
|
2026-08-15 13:29:39 +07:00
|
|
|
const { piBoardId, wiredBoardId } = (() => {
|
fix(boards): el overlay ya no secuestra el dibujo de las Raspberry Pi
Dos fallos que salieron probando pi-to-arduino-led-control y
pi5-pir-motion-alarm.
1) BoardOnCanvas mira getProBoard() ANTES del switch OSS para decidir si
dibuja un elemento del overlay. El overlay registraba un def minimo
para las seis Pi solo para llevar una linea de setup del guest, y eso
basto para cambiarles el render: en vez de la ilustracion
Raspberry_Pi_3_illustration.svg salia la caja esquematica, con otras
coordenadas de pines, y los cables quedaban colgando en la esquina.
Ahora hay un registro aparte, registerGuestSetup(kind, linea), que
lleva la cadena y nada mas; getGuestSetup() la resuelve dando
prioridad al def del overlay si existe.
2) Las partes de entrada (PIR, botones, sensores) avisan con
simulator.setPinState(pin, nivel). En una placa QEMU-Linux no hay
simulador de MCU -- el CPU es el guest -- asi que la llamada acababa
en la instancia AVR heredada y se perdia: pulsar el sensor no hacia
nada. traceDetailed devuelve ahora tambien la placa a la que llega el
pin, y si es de la familia Pi la parte recibe un simulador que empuja
el nivel al bridge (gpio_in para el guest, el valor pin<N> que leen
los shims del motor de navegador) y al PinManager de esa placa.
2026-07-29 12:34:01 +07:00
|
|
|
const st = useSimulatorStore.getState();
|
|
|
|
|
const ownPins = new Set<string>();
|
|
|
|
|
for (const w of st.wires) {
|
|
|
|
|
if (w.start.componentId === id) ownPins.add(w.start.pinName);
|
|
|
|
|
if (w.end.componentId === id) ownPins.add(w.end.pinName);
|
|
|
|
|
}
|
2026-08-15 13:29:39 +07:00
|
|
|
let anyBoardId: string | null = null;
|
fix(boards): el overlay ya no secuestra el dibujo de las Raspberry Pi
Dos fallos que salieron probando pi-to-arduino-led-control y
pi5-pir-motion-alarm.
1) BoardOnCanvas mira getProBoard() ANTES del switch OSS para decidir si
dibuja un elemento del overlay. El overlay registraba un def minimo
para las seis Pi solo para llevar una linea de setup del guest, y eso
basto para cambiarles el render: en vez de la ilustracion
Raspberry_Pi_3_illustration.svg salia la caja esquematica, con otras
coordenadas de pines, y los cables quedaban colgando en la esquina.
Ahora hay un registro aparte, registerGuestSetup(kind, linea), que
lleva la cadena y nada mas; getGuestSetup() la resuelve dando
prioridad al def del overlay si existe.
2) Las partes de entrada (PIR, botones, sensores) avisan con
simulator.setPinState(pin, nivel). En una placa QEMU-Linux no hay
simulador de MCU -- el CPU es el guest -- asi que la llamada acababa
en la instancia AVR heredada y se perdia: pulsar el sensor no hacia
nada. traceDetailed devuelve ahora tambien la placa a la que llega el
pin, y si es de la familia Pi la parte recibe un simulador que empuja
el nivel al bridge (gpio_in para el guest, el valor pin<N> que leen
los shims del motor de navegador) y al PinManager de esa placa.
2026-07-29 12:34:01 +07:00
|
|
|
for (const pinName of ownPins) {
|
|
|
|
|
const { boardId } = traceDetailed(st, id, pinName, 0);
|
|
|
|
|
const board = boardId ? st.boards.find((b) => b.id === boardId) : undefined;
|
2026-08-15 13:29:39 +07:00
|
|
|
if (!board) continue;
|
|
|
|
|
if (anyBoardId === null) anyBoardId = board.id;
|
|
|
|
|
if (isPiBoardKind(board.boardKind)) return { piBoardId: board.id, wiredBoardId: board.id };
|
fix(boards): el overlay ya no secuestra el dibujo de las Raspberry Pi
Dos fallos que salieron probando pi-to-arduino-led-control y
pi5-pir-motion-alarm.
1) BoardOnCanvas mira getProBoard() ANTES del switch OSS para decidir si
dibuja un elemento del overlay. El overlay registraba un def minimo
para las seis Pi solo para llevar una linea de setup del guest, y eso
basto para cambiarles el render: en vez de la ilustracion
Raspberry_Pi_3_illustration.svg salia la caja esquematica, con otras
coordenadas de pines, y los cables quedaban colgando en la esquina.
Ahora hay un registro aparte, registerGuestSetup(kind, linea), que
lleva la cadena y nada mas; getGuestSetup() la resuelve dando
prioridad al def del overlay si existe.
2) Las partes de entrada (PIR, botones, sensores) avisan con
simulator.setPinState(pin, nivel). En una placa QEMU-Linux no hay
simulador de MCU -- el CPU es el guest -- asi que la llamada acababa
en la instancia AVR heredada y se perdia: pulsar el sensor no hacia
nada. traceDetailed devuelve ahora tambien la placa a la que llega el
pin, y si es de la familia Pi la parte recibe un simulador que empuja
el nivel al bridge (gpio_in para el guest, el valor pin<N> que leen
los shims del motor de navegador) y al PinManager de esa placa.
2026-07-29 12:34:01 +07:00
|
|
|
}
|
2026-08-15 13:29:39 +07:00
|
|
|
return { piBoardId: null, wiredBoardId: anyBoardId };
|
fix(boards): el overlay ya no secuestra el dibujo de las Raspberry Pi
Dos fallos que salieron probando pi-to-arduino-led-control y
pi5-pir-motion-alarm.
1) BoardOnCanvas mira getProBoard() ANTES del switch OSS para decidir si
dibuja un elemento del overlay. El overlay registraba un def minimo
para las seis Pi solo para llevar una linea de setup del guest, y eso
basto para cambiarles el render: en vez de la ilustracion
Raspberry_Pi_3_illustration.svg salia la caja esquematica, con otras
coordenadas de pines, y los cables quedaban colgando en la esquina.
Ahora hay un registro aparte, registerGuestSetup(kind, linea), que
lleva la cadena y nada mas; getGuestSetup() la resuelve dando
prioridad al def del overlay si existe.
2) Las partes de entrada (PIR, botones, sensores) avisan con
simulator.setPinState(pin, nivel). En una placa QEMU-Linux no hay
simulador de MCU -- el CPU es el guest -- asi que la llamada acababa
en la instancia AVR heredada y se perdia: pulsar el sensor no hacia
nada. traceDetailed devuelve ahora tambien la placa a la que llega el
pin, y si es de la familia Pi la parte recibe un simulador que empuja
el nivel al bridge (gpio_in para el guest, el valor pin<N> que leen
los shims del motor de navegador) y al PinManager de esa placa.
2026-07-29 12:34:01 +07:00
|
|
|
})();
|
|
|
|
|
const piSimulator = piBoardId
|
|
|
|
|
? ({
|
|
|
|
|
setPinState: (pin: number, state: boolean) => {
|
|
|
|
|
getBoardBridge(piBoardId)?.sendPinEvent(pin, state);
|
|
|
|
|
getBoardBridge(piBoardId)?.setSensorState({ [`pin${pin}`]: state ? 1 : 0 });
|
|
|
|
|
getBoardPinManager(piBoardId)?.triggerPinChange(pin, state, 'external');
|
|
|
|
|
},
|
|
|
|
|
isRunning: () =>
|
|
|
|
|
!!useSimulatorStore.getState().boards.find((b) => b.id === piBoardId)?.running,
|
|
|
|
|
pinManager: getBoardPinManager(piBoardId),
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
} as any)
|
|
|
|
|
: null;
|
|
|
|
|
|
2026-08-15 13:29:39 +07:00
|
|
|
// Route the part to the simulator of the board it is actually WIRED
|
|
|
|
|
// to. The legacy store `simulator` is the shared AVR instance: handing
|
|
|
|
|
// it to a part on an ESP32 board silently voided every direct analog
|
|
|
|
|
// injection — setAdcVoltage() hit the AVR branch, GPIO 32-39 fell
|
|
|
|
|
// outside its 14-19 window, and the part (analog-joystick was the
|
|
|
|
|
// reported one) read 0 forever while pots survived only via the SPICE
|
|
|
|
|
// solve. getBoardSimulator() returns the per-board shim (ESP32 bridge
|
|
|
|
|
// shim, RP2040, AVR) that partUtils' dispatch understands.
|
|
|
|
|
const wiredSimulator = wiredBoardId ? getBoardSimulator(wiredBoardId) ?? null : null;
|
2026-05-13 00:26:33 +07:00
|
|
|
const stubSimulator =
|
fix(boards): el overlay ya no secuestra el dibujo de las Raspberry Pi
Dos fallos que salieron probando pi-to-arduino-led-control y
pi5-pir-motion-alarm.
1) BoardOnCanvas mira getProBoard() ANTES del switch OSS para decidir si
dibuja un elemento del overlay. El overlay registraba un def minimo
para las seis Pi solo para llevar una linea de setup del guest, y eso
basto para cambiarles el render: en vez de la ilustracion
Raspberry_Pi_3_illustration.svg salia la caja esquematica, con otras
coordenadas de pines, y los cables quedaban colgando en la esquina.
Ahora hay un registro aparte, registerGuestSetup(kind, linea), que
lleva la cadena y nada mas; getGuestSetup() la resuelve dando
prioridad al def del overlay si existe.
2) Las partes de entrada (PIR, botones, sensores) avisan con
simulator.setPinState(pin, nivel). En una placa QEMU-Linux no hay
simulador de MCU -- el CPU es el guest -- asi que la llamada acababa
en la instancia AVR heredada y se perdia: pulsar el sensor no hacia
nada. traceDetailed devuelve ahora tambien la placa a la que llega el
pin, y si es de la familia Pi la parte recibe un simulador que empuja
el nivel al bridge (gpio_in para el guest, el valor pin<N> que leen
los shims del motor de navegador) y al PinManager de esa placa.
2026-07-29 12:34:01 +07:00
|
|
|
piSimulator ??
|
2026-08-15 13:29:39 +07:00
|
|
|
wiredSimulator ??
|
2026-05-13 00:26:33 +07:00
|
|
|
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
|
feat(canvas): ver donde cae lo que anades, y clicks con sentido
Tres cambios que van juntos porque atacan la misma queja: "anado un
elemento y a veces ni veo donde se anadio".
1. Donde cae. Ya se anclaba a la esquina visible, pero la cascada que evita
que se apilen iba indexada por components.length: seguia avanzando
aunque movieras o borraras piezas, asi que las caidas se alejaban cada
vez mas de donde estabas mirando. Ahora toma el primer hueco LIBRE desde
la esquina, bajando en diagonal; si apartas la ultima, la siguiente
recupera su sitio. Extraido a utils/dropSlot con 8 tests, incluido el
caso de "la apartaron" y el tope para no salirse de la vista.
2. Que se vea. El recien anadido queda seleccionado, y la seleccion pasa de
un borde discontinuo quieto a un caminito de hormigas. El movimiento es
lo que capta el ojo en un canvas lleno; un borde fijo se pierde. Va en
un pseudo-elemento por fuera del cuerpo, sin robar clicks ni tapar el
dibujo, y se queda quieto si el sistema pide menos animacion.
3. Clicks. El izquierdo SELECCIONA y ya esta; antes abria el panel de
propiedades, o sea que no podias ni senalar una pieza sin comerte un
popup que luego habia que cerrar. Propiedades y pines pasan al click
derecho, que es donde va lo deliberado. En tactil se mantiene tocar ->
panel, que ahi no hay boton derecho.
2026-07-30 20:40:52 +07:00
|
|
|
className={`dynamic-component-wrapper${isBurnt ? ' velxio-burnt' : ''}${
|
|
|
|
|
isSelected ? ' velxio-ants' : ''
|
|
|
|
|
}`}
|
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',
|
feat(canvas): ver donde cae lo que anades, y clicks con sentido
Tres cambios que van juntos porque atacan la misma queja: "anado un
elemento y a veces ni veo donde se anadio".
1. Donde cae. Ya se anclaba a la esquina visible, pero la cascada que evita
que se apilen iba indexada por components.length: seguia avanzando
aunque movieras o borraras piezas, asi que las caidas se alejaban cada
vez mas de donde estabas mirando. Ahora toma el primer hueco LIBRE desde
la esquina, bajando en diagonal; si apartas la ultima, la siguiente
recupera su sitio. Extraido a utils/dropSlot con 8 tests, incluido el
caso de "la apartaron" y el tope para no salirse de la vista.
2. Que se vea. El recien anadido queda seleccionado, y la seleccion pasa de
un borde discontinuo quieto a un caminito de hormigas. El movimiento es
lo que capta el ojo en un canvas lleno; un borde fijo se pierde. Va en
un pseudo-elemento por fuera del cuerpo, sin robar clicks ni tapar el
dibujo, y se queda quieto si el sistema pide menos animacion.
3. Clicks. El izquierdo SELECCIONA y ya esta; antes abria el panel de
propiedades, o sea que no podias ni senalar una pieza sin comerte un
popup que luego habia que cerrar. Propiedades y pines pasan al click
derecho, que es donde va lo deliberado. En tactil se mantiene tocar ->
panel, que ahi no hay boton derecho.
2026-07-30 20:40:52 +07:00
|
|
|
// The selection outline itself is the .velxio-ants pseudo-element
|
|
|
|
|
// (a static dashed border cannot be animated). The transparent
|
|
|
|
|
// border stays so selecting does not shift the body by 2px.
|
|
|
|
|
border: '2px solid transparent',
|
2026-03-04 05:30:25 +07:00
|
|
|
borderRadius: '4px',
|
|
|
|
|
padding: '4px',
|
|
|
|
|
userSelect: 'none',
|
2026-07-31 21:02:26 +07:00
|
|
|
// Drag-to-front (zRaise) beats the static layers: a part dragged onto
|
|
|
|
|
// a board — or a board dragged onto a part — the last one dragged
|
|
|
|
|
// paints on top. Untouched parts keep the classic selected/idle z.
|
2026-07-31 21:40:01 +07:00
|
|
|
// Local order inside .component-interactive-group only. The
|
|
|
|
|
// drag-to-front rank is applied on that GROUP (SimulatorCanvas) —
|
|
|
|
|
// a z set here is clamped by the group's stacking context and could
|
|
|
|
|
// never lift the part above a dragged board.
|
|
|
|
|
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}
|
feat(canvas): ver donde cae lo que anades, y clicks con sentido
Tres cambios que van juntos porque atacan la misma queja: "anado un
elemento y a veces ni veo donde se anadio".
1. Donde cae. Ya se anclaba a la esquina visible, pero la cascada que evita
que se apilen iba indexada por components.length: seguia avanzando
aunque movieras o borraras piezas, asi que las caidas se alejaban cada
vez mas de donde estabas mirando. Ahora toma el primer hueco LIBRE desde
la esquina, bajando en diagonal; si apartas la ultima, la siguiente
recupera su sitio. Extraido a utils/dropSlot con 8 tests, incluido el
caso de "la apartaron" y el tope para no salirse de la vista.
2. Que se vea. El recien anadido queda seleccionado, y la seleccion pasa de
un borde discontinuo quieto a un caminito de hormigas. El movimiento es
lo que capta el ojo en un canvas lleno; un borde fijo se pierde. Va en
un pseudo-elemento por fuera del cuerpo, sin robar clicks ni tapar el
dibujo, y se queda quieto si el sistema pide menos animacion.
3. Clicks. El izquierdo SELECCIONA y ya esta; antes abria el panel de
propiedades, o sea que no podias ni senalar una pieza sin comerte un
popup que luego habia que cerrar. Propiedades y pines pasan al click
derecho, que es donde va lo deliberado. En tactil se mantiene tocar ->
panel, que ahi no hay boton derecho.
2026-07-30 20:40:52 +07:00
|
|
|
// Capture phase: interactive parts (pushbutton, switch, pot) stop
|
|
|
|
|
// propagation in their own handlers, which would otherwise swallow the
|
|
|
|
|
// right click before the canvas ever saw it.
|
|
|
|
|
onContextMenuCapture={onContextMenu}
|
2026-07-28 21:09:27 +07:00
|
|
|
onTouchStartCapture={(e) => {
|
|
|
|
|
// Mobile mirror of the ownsPointer guard: while the sim runs, a
|
|
|
|
|
// finger on a declared touch screen is INPUT for the screen (its
|
|
|
|
|
// pointer handlers still fire), never a canvas pan/drag gesture.
|
|
|
|
|
const t = e.target as { ownsPointer?: boolean };
|
|
|
|
|
if (interactionRunning && t.ownsPointer === true) e.stopPropagation();
|
|
|
|
|
}}
|
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
|
|
|
};
|
|
|
|
|
}
|