2026-03-04 05:30:25 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Dynamic Component Renderer
|
|
|
|
|
|
*
|
|
|
|
|
|
* Generic component that renders any wokwi-element web component dynamically.
|
|
|
|
|
|
* Replaces individual React wrapper components (LED.tsx, Resistor.tsx, etc.)
|
|
|
|
|
|
*
|
|
|
|
|
|
* Features:
|
|
|
|
|
|
* - Creates web component from metadata
|
|
|
|
|
|
* - Syncs React props to web component properties
|
|
|
|
|
|
* - Extracts pinInfo from DOM for wire connections
|
|
|
|
|
|
* - Handles component lifecycle
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import React, { useRef, useEffect, useCallback } from 'react';
|
|
|
|
|
|
import type { ComponentMetadata } from '../types/component-metadata';
|
2026-03-04 23:36:33 +07:00
|
|
|
|
import { useSimulatorStore } from '../store/useSimulatorStore';
|
2026-05-13 09:34:58 +07:00
|
|
|
|
import { useElectricalStore } from '../store/useElectricalStore';
|
2026-03-04 23:36:33 +07:00
|
|
|
|
import { PartSimulationRegistry } from '../simulation/parts';
|
2026-03-06 07:07:03 +07:00
|
|
|
|
import { isBoardComponent, boardPinToNumber } from '../utils/boardPinMapping';
|
feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection
Adds the architecture pieces for mixed-mode coupling without yet
driving the SPICE engine. Components on a path that crosses an active
device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route
through a new SPICE-resolved PinResolver variant; everything else
keeps the digital fast-path from Phase 0.
What ships:
- simulation/PinResolver.ts
* `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES`
list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay).
* `DetailedPinTrace` / `DetailedPinTracer` types — the trace
function now reports whether it crossed an active device, on
top of the Arduino pin number.
* `createSpiceResolvedPinResolver()` — new factory; reads voltages
from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW
with hysteresis (thresholdHigh != thresholdLow → Schmitt-like).
- simulation/spice/MixedModeScheduler.ts (new)
* Singleton orchestrator that holds the NgSpiceInteractive engine
and the SpiceVoltageSource subscription registry.
* `start()` / `stop()` / `dispose()` lifecycle.
* `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource.
* `onMcuPinChange()` placeholder for the alter+tran event loop.
* Skeleton: subscribers register but never receive events yet.
Phase 1b continued will wire NgSpiceInteractive into the loop.
- components/DynamicComponent.tsx
* Trace function extended with `traceDetailed()` that tracks
whether the BFS crossed an active component.
* PinResolver factory branches: active-path → SPICE-resolved (uses
the scheduler), digital-only → existing default impl. Default
threshold = vcc/2 with no hysteresis; Phase 3 will replace with
per-logic-family Vil/Vih.
Phase 0 LED behavior intact (digital path). Phase 1b SPICE-resolved
path falls back to FLOATING until Phase 1b continued wires the engine.
Tests:
- pin-resolver-phase1b.test.ts (new) — 8/8 passing.
Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator
metadata id; SPICE-resolved resolver state reporting, threshold
conversion, hysteresis dead-band, unsubscribe.
- pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression).
- tsc --noEmit on the new files: clean.
No deploy in this commit — staged for end-of-session rebuild + push
per user preference.
2026-05-15 21:38:30 +07:00
|
|
|
|
import {
|
|
|
|
|
|
createDefaultPinResolver,
|
|
|
|
|
|
createSpiceResolvedPinResolver,
|
feat(sim): Phase 3 — logic families (TTL/CMOS-5V/LVCMOS33/AVR_HC/Schmitt)
Replaces the Phase 1b vcc/2-flat threshold with per-logic-family
Vil/Vih thresholds + Schmitt-trigger hysteresis where applicable.
SPICE-resolved digital reads now match what real ICs actually do —
TTL noise margins, CMOS rail-to-rail, 74HC14 Schmitt hysteresis,
LVCMOS33 vs CMOS-5V interop.
New module: simulation/LogicFamilies.ts
- LogicFamily interface (vcc, vil, vih, vil_schmitt?, vih_schmitt?,
cin_pF, vol_max?, voh_min?, output_impedance_ohm?)
- FAMILIES catalog: TTL, CMOS-5V, CMOS-5V-SCHMITT, CMOS-5V-TTL-INPUTS,
LVCMOS33, AVR_HC, CMOS-3.3V — all sourced from TI / ATmega328P /
JEDEC datasheets.
- BOARD_FAMILY: per-board lookup. Uno/Mega/Nano/ATtiny → AVR_HC,
ESP32 family + Pi Pico → LVCMOS33, fall back to AVR_HC for
unknown boards.
- getBoardLogicFamily() and getLogicFamilyById() helpers.
PinResolver:
- SpiceResolvedConfig docstring rewritten with Phase 3 wording.
- New `configFromLogicFamily()` builder — picks Schmitt thresholds
when the family declares them, falls back to vih/vil otherwise.
DynamicComponent:
- When the trace crosses an active device, the SPICE-resolved
resolver is now built with the OWNER BOARD's logic family
instead of vcc/2. Hysteresis comes through automatically for
boards whose native family is Schmitt-capable.
- Phase 3 continued: per-component logicFamily override from
components-metadata.json (so e.g. a 74HC14 placed on an Arduino
Uno gets Schmitt thresholds even though the BOARD is AVR_HC).
Tests:
- logic-families.test.ts (new) — 19/19 passing.
Covers catalog sanity (vil < vih, vol_max ≤ vil, voh_min ≥ vih),
per-board lookup, Schmitt vs non-Schmitt config, noise rejection
behavior of 74HC14 Schmitt resolver, last-state-wins behavior
of CMOS-5V dead band.
- Phase 0 + Phase 1b regression: 16/16 still passing.
- tsc --noEmit on new files: clean.
No deploy in this commit — staged for end-of-session rebuild.
2026-05-15 21:42:11 +07:00
|
|
|
|
configFromLogicFamily,
|
feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection
Adds the architecture pieces for mixed-mode coupling without yet
driving the SPICE engine. Components on a path that crosses an active
device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route
through a new SPICE-resolved PinResolver variant; everything else
keeps the digital fast-path from Phase 0.
What ships:
- simulation/PinResolver.ts
* `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES`
list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay).
* `DetailedPinTrace` / `DetailedPinTracer` types — the trace
function now reports whether it crossed an active device, on
top of the Arduino pin number.
* `createSpiceResolvedPinResolver()` — new factory; reads voltages
from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW
with hysteresis (thresholdHigh != thresholdLow → Schmitt-like).
- simulation/spice/MixedModeScheduler.ts (new)
* Singleton orchestrator that holds the NgSpiceInteractive engine
and the SpiceVoltageSource subscription registry.
* `start()` / `stop()` / `dispose()` lifecycle.
* `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource.
* `onMcuPinChange()` placeholder for the alter+tran event loop.
* Skeleton: subscribers register but never receive events yet.
Phase 1b continued will wire NgSpiceInteractive into the loop.
- components/DynamicComponent.tsx
* Trace function extended with `traceDetailed()` that tracks
whether the BFS crossed an active component.
* PinResolver factory branches: active-path → SPICE-resolved (uses
the scheduler), digital-only → existing default impl. Default
threshold = vcc/2 with no hysteresis; Phase 3 will replace with
per-logic-family Vil/Vih.
Phase 0 LED behavior intact (digital path). Phase 1b SPICE-resolved
path falls back to FLOATING until Phase 1b continued wires the engine.
Tests:
- pin-resolver-phase1b.test.ts (new) — 8/8 passing.
Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator
metadata id; SPICE-resolved resolver state reporting, threshold
conversion, hysteresis dead-band, unsubscribe.
- pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression).
- tsc --noEmit on the new files: clean.
No deploy in this commit — staged for end-of-session rebuild + push
per user preference.
2026-05-15 21:38:30 +07:00
|
|
|
|
isActiveDevice,
|
|
|
|
|
|
type PinResolver,
|
|
|
|
|
|
} from '../simulation/PinResolver';
|
2026-05-15 20:50:09 +07:00
|
|
|
|
import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups';
|
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-04-21 02:38:31 +07:00
|
|
|
|
|
2026-05-16 04:55:07 +07:00
|
|
|
|
// Map metadataId → [pinA, pinB] for 2-terminal passives.
|
|
|
|
|
|
// "Tracing through" means: if the caller arrived on pinA, continue from pinB
|
|
|
|
|
|
// (and vice-versa).
|
|
|
|
|
|
//
|
|
|
|
|
|
// NOTE: diodes / transistors / op-amps are NOT traced through as passives —
|
|
|
|
|
|
// they have polarity / Vf / non-linear behaviour that the digital layer
|
|
|
|
|
|
// cannot interpret as "same pin". BJTs are an explicit shortcut for the
|
|
|
|
|
|
// canonical "Arduino digital pin controls a load via transistor" pattern so
|
|
|
|
|
|
// 7-segment multiplex circuits with BJT digit drivers still resolve.
|
|
|
|
|
|
const PASSIVE_PIN_PAIRS_BASE: Record<string, [string, string]> = {
|
|
|
|
|
|
resistor: ['1', '2'],
|
|
|
|
|
|
'resistor-us': ['1', '2'],
|
|
|
|
|
|
capacitor: ['1', '2'],
|
|
|
|
|
|
'capacitor-electrolytic': ['+', '−'],
|
|
|
|
|
|
inductor: ['1', '2'],
|
|
|
|
|
|
'analog-resistor': ['A', 'B'],
|
|
|
|
|
|
'analog-capacitor': ['A', 'B'],
|
|
|
|
|
|
'analog-inductor': ['A', 'B'],
|
|
|
|
|
|
'bjt-2n2222': ['C', 'B'],
|
|
|
|
|
|
'bjt-bc547': ['C', 'B'],
|
|
|
|
|
|
'bjt-2n3055': ['C', 'B'],
|
|
|
|
|
|
'bjt-2n3906': ['C', 'B'],
|
|
|
|
|
|
'bjt-bc557': ['C', 'B'],
|
|
|
|
|
|
};
|
|
|
|
|
|
// Preset variants of the generic passives share their parent's tag and pin
|
|
|
|
|
|
// layout. Mirrors the PASSIVE_PRESETS map in spice/componentToSpice.ts.
|
|
|
|
|
|
const PRESET_TO_BASE: Record<string, string> = {
|
|
|
|
|
|
'resistor-220': 'resistor',
|
|
|
|
|
|
'resistor-330': 'resistor',
|
|
|
|
|
|
'resistor-470': 'resistor',
|
|
|
|
|
|
'resistor-1k': 'resistor',
|
|
|
|
|
|
'resistor-2k2': 'resistor',
|
|
|
|
|
|
'resistor-4k7': 'resistor',
|
|
|
|
|
|
'resistor-10k': 'resistor',
|
|
|
|
|
|
'resistor-22k': 'resistor',
|
|
|
|
|
|
'resistor-47k': 'resistor',
|
|
|
|
|
|
'resistor-100k': 'resistor',
|
|
|
|
|
|
'resistor-1m': 'resistor',
|
|
|
|
|
|
'cap-10p': 'capacitor',
|
|
|
|
|
|
'cap-22p': 'capacitor',
|
|
|
|
|
|
'cap-100p': 'capacitor',
|
|
|
|
|
|
'cap-1n': 'capacitor',
|
|
|
|
|
|
'cap-10n': 'capacitor',
|
|
|
|
|
|
'cap-100n': 'capacitor',
|
|
|
|
|
|
'cap-1u': 'capacitor',
|
|
|
|
|
|
'cap-elec-1u': 'capacitor-electrolytic',
|
|
|
|
|
|
'cap-elec-10u': 'capacitor-electrolytic',
|
|
|
|
|
|
'cap-elec-47u': 'capacitor-electrolytic',
|
|
|
|
|
|
'cap-elec-100u': 'capacitor-electrolytic',
|
|
|
|
|
|
'cap-elec-470u': 'capacitor-electrolytic',
|
|
|
|
|
|
'cap-elec-1000u': 'capacitor-electrolytic',
|
|
|
|
|
|
'ind-100u': 'inductor',
|
|
|
|
|
|
'ind-1m': 'inductor',
|
|
|
|
|
|
'ind-10m': 'inductor',
|
|
|
|
|
|
};
|
|
|
|
|
|
const PASSIVE_PIN_PAIRS: Record<string, [string, string]> = {
|
|
|
|
|
|
...PASSIVE_PIN_PAIRS_BASE,
|
|
|
|
|
|
};
|
|
|
|
|
|
for (const [preset, base] of Object.entries(PRESET_TO_BASE)) {
|
|
|
|
|
|
PASSIVE_PIN_PAIRS[preset] = PASSIVE_PIN_PAIRS_BASE[base];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type TraceState = ReturnType<typeof useSimulatorStore.getState>;
|
|
|
|
|
|
|
|
|
|
|
|
// Depth-limited BFS: trace from (fromId, fromPin) through wires, traversing
|
|
|
|
|
|
// through passive components to reach a board pin. Returns the arduino pin
|
|
|
|
|
|
// plus a `crossedActiveDevice` flag so the resolver factory can decide
|
|
|
|
|
|
// between digital fast-path and SPICE-resolved per-pin.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Lifted to module scope (was inside getArduinoPin) so that getPinResolver
|
|
|
|
|
|
// can call it too — the previous nested-scope version caused a runtime
|
|
|
|
|
|
// ReferenceError "traceDetailed is not defined" on the simulator page.
|
|
|
|
|
|
function traceDetailed(
|
|
|
|
|
|
state: TraceState,
|
|
|
|
|
|
fromId: string,
|
|
|
|
|
|
fromPin: string,
|
|
|
|
|
|
depth: number,
|
|
|
|
|
|
activeSeen = false,
|
|
|
|
|
|
): { arduinoPin: number | null; crossedActiveDevice: boolean } {
|
|
|
|
|
|
if (depth > 6) return { arduinoPin: null, crossedActiveDevice: activeSeen };
|
|
|
|
|
|
|
|
|
|
|
|
const wires = state.wires.filter(
|
|
|
|
|
|
(w) =>
|
|
|
|
|
|
(w.start.componentId === fromId && w.start.pinName === fromPin) ||
|
|
|
|
|
|
(w.end.componentId === fromId && w.end.pinName === fromPin),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
for (const w of wires) {
|
|
|
|
|
|
const selfEp =
|
|
|
|
|
|
w.start.componentId === fromId && w.start.pinName === fromPin ? w.start : w.end;
|
|
|
|
|
|
const otherEp = selfEp === w.start ? w.end : w.start;
|
|
|
|
|
|
|
|
|
|
|
|
if (isBoardComponent(otherEp.componentId)) {
|
|
|
|
|
|
const boardKind =
|
|
|
|
|
|
state.boards.find((b) => b.id === otherEp.componentId)?.boardKind ??
|
|
|
|
|
|
otherEp.componentId;
|
|
|
|
|
|
const pin = boardPinToNumber(boardKind, otherEp.pinName);
|
|
|
|
|
|
if (pin !== null) return { arduinoPin: pin, crossedActiveDevice: activeSeen };
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const comp = state.components.find((c) => c.id === otherEp.componentId);
|
|
|
|
|
|
const pair = comp && PASSIVE_PIN_PAIRS[comp.metadataId];
|
|
|
|
|
|
if (pair) {
|
|
|
|
|
|
const [p1, p2] = pair;
|
|
|
|
|
|
const otherPin = otherEp.pinName === p1 ? p2 : p1;
|
|
|
|
|
|
const nowActive =
|
|
|
|
|
|
activeSeen || (comp ? isActiveDevice(comp.metadataId) : false);
|
|
|
|
|
|
const result = traceDetailed(
|
|
|
|
|
|
state,
|
|
|
|
|
|
otherEp.componentId,
|
|
|
|
|
|
otherPin,
|
|
|
|
|
|
depth + 1,
|
|
|
|
|
|
nowActive,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (result.arduinoPin !== null) return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return { arduinoPin: null, crossedActiveDevice: activeSeen };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 05:30:25 +07:00
|
|
|
|
interface DynamicComponentProps {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
metadata: ComponentMetadata;
|
|
|
|
|
|
properties: Record<string, any>;
|
|
|
|
|
|
x?: number;
|
|
|
|
|
|
y?: number;
|
|
|
|
|
|
isSelected?: boolean;
|
|
|
|
|
|
onMouseDown?: (e: React.MouseEvent) => void;
|
|
|
|
|
|
onDoubleClick?: (e: React.MouseEvent) => void;
|
|
|
|
|
|
onMouseEnter?: () => void;
|
|
|
|
|
|
onMouseLeave?: () => void;
|
|
|
|
|
|
onPinInfoReady?: (pinInfo: any[]) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const DynamicComponent: React.FC<DynamicComponentProps> = ({
|
|
|
|
|
|
id,
|
|
|
|
|
|
metadata,
|
|
|
|
|
|
properties,
|
|
|
|
|
|
x = 0,
|
|
|
|
|
|
y = 0,
|
|
|
|
|
|
isSelected = false,
|
|
|
|
|
|
onMouseDown,
|
|
|
|
|
|
onDoubleClick,
|
|
|
|
|
|
onMouseEnter,
|
|
|
|
|
|
onMouseLeave,
|
|
|
|
|
|
onPinInfoReady,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
const elementRef = useRef<HTMLElement>(null);
|
|
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const mountedRef = useRef(false);
|
|
|
|
|
|
|
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-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
|
|
|
|
/**
|
|
|
|
|
|
* Sync React properties to Web Component
|
|
|
|
|
|
*/
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!elementRef.current) return;
|
|
|
|
|
|
|
|
|
|
|
|
Object.entries(properties).forEach(([key, value]) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
(elementRef.current as any)[key] = value;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn(`Failed to set property ${key} on ${metadata.tagName}:`, error);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [properties, metadata.tagName]);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Extract pinInfo from web component after it initializes
|
|
|
|
|
|
*/
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!elementRef.current || !onPinInfoReady) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Wait for web component to fully initialize
|
|
|
|
|
|
const checkPinInfo = () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const pinInfo = (elementRef.current as any)?.pinInfo;
|
|
|
|
|
|
if (pinInfo && Array.isArray(pinInfo) && pinInfo.length > 0) {
|
|
|
|
|
|
onPinInfoReady(pinInfo);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Element not ready yet
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Try immediately
|
|
|
|
|
|
if (checkPinInfo()) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Otherwise poll every 100ms for up to 2 seconds
|
|
|
|
|
|
const interval = setInterval(() => {
|
|
|
|
|
|
if (checkPinInfo()) {
|
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
|
|
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [onPinInfoReady]);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Handle mouse events
|
|
|
|
|
|
*/
|
|
|
|
|
|
const handleMouseDown = useCallback(
|
|
|
|
|
|
(e: React.MouseEvent) => {
|
2026-05-20 01:10:42 +07:00
|
|
|
|
if (!onMouseDown) return;
|
|
|
|
|
|
// Don't swallow the pointerdown when the user is interacting with a
|
|
|
|
|
|
// live wokwi component (rotating a potentiometer knob, pressing a
|
|
|
|
|
|
// pushbutton, toggling a slide-switch, dragging the joystick stick,
|
|
|
|
|
|
// etc.). The wokwi-elements internally use pointerdown/move/up on
|
|
|
|
|
|
// their shadow-DOM SVGs; if we call stopPropagation() in the capture
|
|
|
|
|
|
// phase here, that internal logic NEVER receives the event and the
|
|
|
|
|
|
// knob can't rotate, the button never reports pressed, etc.
|
|
|
|
|
|
//
|
|
|
|
|
|
// The canvas's drag-to-rearrange flow still works: the user can grab
|
|
|
|
|
|
// the component WRAPPER (its padding/border) or any non-interactive
|
|
|
|
|
|
// part of the body. For interactive parts the wokwi component owns
|
|
|
|
|
|
// the click — same UX as Wokwi/Tinkercad.
|
|
|
|
|
|
//
|
|
|
|
|
|
// We additionally allow the canvas drag when the simulation is NOT
|
|
|
|
|
|
// running (interactionRunning=false): the user is in "edit mode"
|
|
|
|
|
|
// arranging the board, so capturing the click into a canvas drag
|
|
|
|
|
|
// is the correct behaviour even on interactive components.
|
|
|
|
|
|
const target = e.target as HTMLElement;
|
|
|
|
|
|
const targetIsInteractive =
|
|
|
|
|
|
isInteractive &&
|
|
|
|
|
|
interactionRunning &&
|
|
|
|
|
|
target.tagName.toLowerCase().startsWith('wokwi-');
|
|
|
|
|
|
if (targetIsInteractive) {
|
|
|
|
|
|
// Let the wokwi component own this pointerdown.
|
|
|
|
|
|
return;
|
2026-03-04 05:30:25 +07:00
|
|
|
|
}
|
2026-05-20 01:10:42 +07:00
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onMouseDown(e);
|
2026-03-04 05:30:25 +07:00
|
|
|
|
},
|
2026-05-20 01:10:42 +07:00
|
|
|
|
[onMouseDown, isInteractive, interactionRunning],
|
2026-03-04 05:30:25 +07:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const handleDoubleClick = useCallback(
|
|
|
|
|
|
(e: React.MouseEvent) => {
|
|
|
|
|
|
if (onDoubleClick) {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onDoubleClick(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-04-22 02:45:45 +07:00
|
|
|
|
[onDoubleClick],
|
2026-03-04 05:30:25 +07:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Mount web component (only once)
|
|
|
|
|
|
*/
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!containerRef.current) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Prevent double-mount in React StrictMode
|
|
|
|
|
|
if (mountedRef.current) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const element = document.createElement(metadata.tagName);
|
|
|
|
|
|
element.id = id;
|
|
|
|
|
|
|
|
|
|
|
|
// Set initial properties
|
|
|
|
|
|
Object.entries(properties).forEach(([key, value]) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
(element as any)[key] = value;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn(`Failed to set initial property ${key}:`, error);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
containerRef.current.appendChild(element);
|
|
|
|
|
|
elementRef.current = element;
|
|
|
|
|
|
mountedRef.current = true;
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (containerRef.current && element.parentNode === containerRef.current) {
|
|
|
|
|
|
containerRef.current.removeChild(element);
|
|
|
|
|
|
}
|
|
|
|
|
|
elementRef.current = null;
|
|
|
|
|
|
mountedRef.current = false;
|
|
|
|
|
|
};
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [metadata.tagName, id]); // Only re-create if tagName or id changes
|
|
|
|
|
|
|
2026-03-04 23:36:33 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Attach component-specific DOM events (like button presses)
|
|
|
|
|
|
*/
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const el = elementRef.current;
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
|
|
|
|
|
|
const onButtonPress = (e: Event) => handleComponentEvent(id, 'button-press', e);
|
|
|
|
|
|
const onButtonRelease = (e: Event) => handleComponentEvent(id, 'button-release', e);
|
|
|
|
|
|
|
|
|
|
|
|
el.addEventListener('button-press', onButtonPress);
|
|
|
|
|
|
el.addEventListener('button-release', onButtonRelease);
|
|
|
|
|
|
|
2026-03-05 04:27:14 +07:00
|
|
|
|
const logic = PartSimulationRegistry.get(metadata.id || id.split('-')[0]);
|
2026-03-04 23:36:33 +07:00
|
|
|
|
|
|
|
|
|
|
let cleanupSimulationEvents: (() => void) | undefined;
|
2026-05-13 00:26:33 +07:00
|
|
|
|
if (logic && logic.attachEvents) {
|
|
|
|
|
|
// Board-less circuits (analog/digital SPICE examples) have no MCU
|
|
|
|
|
|
// simulator, but input parts (switches, buttons, DIP switches) still
|
|
|
|
|
|
// need their `change`/`button-press` events to fire `emitPropertyChange`
|
|
|
|
|
|
// so the SPICE solver re-runs. Every part already guards its
|
|
|
|
|
|
// `simulator.setPinState` / `pinManager.onPinChange` calls behind a
|
|
|
|
|
|
// null pin lookup (`getArduinoPin` returns null when there's no board),
|
|
|
|
|
|
// so the stub below is enough — it satisfies the type signature without
|
|
|
|
|
|
// doing anything when called.
|
|
|
|
|
|
const stubSimulator =
|
|
|
|
|
|
simulator ??
|
|
|
|
|
|
({
|
|
|
|
|
|
setPinState: () => {},
|
|
|
|
|
|
isRunning: () => false,
|
|
|
|
|
|
pinManager: {
|
|
|
|
|
|
onPinChange: () => () => {},
|
|
|
|
|
|
triggerPinChange: () => {},
|
|
|
|
|
|
} as any,
|
|
|
|
|
|
} 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,
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
className="dynamic-component-wrapper"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
left: `${x}px`,
|
|
|
|
|
|
top: `${y}px`,
|
2026-05-13 09:34:58 +07:00
|
|
|
|
cursor: interactionRunning && isInteractive ? 'pointer' : 'move',
|
2026-03-04 05:30:25 +07:00
|
|
|
|
border: isSelected ? '2px dashed #007acc' : '2px solid transparent',
|
|
|
|
|
|
borderRadius: '4px',
|
|
|
|
|
|
padding: '4px',
|
|
|
|
|
|
userSelect: 'none',
|
2026-03-05 09:40:17 +07:00
|
|
|
|
zIndex: isSelected ? 5 : 1,
|
2026-03-04 05:30:25 +07:00
|
|
|
|
pointerEvents: 'auto',
|
2026-03-04 06:42:17 +07:00
|
|
|
|
transform: properties.rotation ? `rotate(${properties.rotation}deg)` : undefined,
|
|
|
|
|
|
transformOrigin: 'center center',
|
2026-03-04 05:30:25 +07:00
|
|
|
|
}}
|
2026-05-13 21:51:52 +07:00
|
|
|
|
onMouseDownCapture={handleMouseDown}
|
2026-03-04 05:30:25 +07:00
|
|
|
|
onDoubleClick={handleDoubleClick}
|
|
|
|
|
|
onMouseEnter={onMouseEnter}
|
|
|
|
|
|
onMouseLeave={onMouseLeave}
|
|
|
|
|
|
data-component-id={id}
|
|
|
|
|
|
data-component-type={metadata.id}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* Container for web component */}
|
|
|
|
|
|
<div ref={containerRef} className="web-component-container" />
|
|
|
|
|
|
|
|
|
|
|
|
{/* Component label */}
|
|
|
|
|
|
<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',
|
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-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-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,
|
|
|
|
|
|
properties: { ...metadata.defaultValues },
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|