From b93b1b42c59a2771afb557205f751d06b45510b8 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Fri, 5 Jun 2026 21:58:42 -0300 Subject: [PATCH] feat(digital-gate-engine): 4-bit ripple counter gallery example + sequential controller fix Adds the first board-less SEQUENTIAL gallery example (digital-ripple-counter-4bit): four T flip-flops chained into a ripple counter, LEDs showing the binary count, clocked by a slide switch. Impossible on the SPICE engine (no edge detection at DC) - it runs on the digital gate engine. Controller fix (found by testing the counter live): the controller rebuilt the network on every change, which reset flip-flop state so a counter never counted. Now the network is built once and KEPT ALIVE; a switch toggle applies incrementally via setSwitch (preserving sequential state), and a rebuild happens only on a structural change (components/wires). Correct for combinational AND sequential circuits. examples-digital.test.ts: flip-flop examples are digital-engine-only, so they are exempt from the SPICE-mapping / has-a-gate / netlist checks (the "logic" check now accepts a gate OR a flip-flop). digitalgate-engine-examples: a correctness test clocks the real counter example and asserts it counts 1..15,0 in binary. Verified live (?digitalgates default ON): the counter counts 0..6 on the canvas; and the complex examples all work - comparator-4bit (A=B correct), decoder-3to8 (perfect one-hot x8), alu-slice-1bit (32 combos deterministic), multiplier-2x2 (3*3=9, 7 distinct products), adder-subtractor-4bit (5+3=8). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../digitalgate-engine-examples.test.ts | 14 +++ .../src/__tests__/examples-digital.test.ts | 19 +++- frontend/src/data/examples-digital.ts | 44 +++++++++ .../digital/digitalGateController.ts | 89 ++++++++++++------- 4 files changed, 130 insertions(+), 36 deletions(-) diff --git a/frontend/src/__tests__/digitalgate-engine-examples.test.ts b/frontend/src/__tests__/digitalgate-engine-examples.test.ts index 97fcc002..1351e7b5 100644 --- a/frontend/src/__tests__/digitalgate-engine-examples.test.ts +++ b/frontend/src/__tests__/digitalgate-engine-examples.test.ts @@ -128,4 +128,18 @@ describe('digital-gate-engine Phase 1 — real examples on the engine', () => { expect(r.cout, `${label} carry`).toBe(cout); } }); + + it('digital-ripple-counter-4bit: clocking the switch counts up in binary', () => { + const ex = byId('digital-ripple-counter-4bit'); + const net = buildDigitalNetwork(ex.components, ex.wires); + expect(net.ok).toBe(true); + const read = () => [0, 1, 2, 3].reduce((acc, i) => acc + (net.readLed(`cnt_led${i}`) << i), 0); + expect(read(), 'starts at 0').toBe(0); + // Each LOW->HIGH on the clock switch advances the count. Wrap at 16. + for (let n = 1; n <= 17; n++) { + net.setSwitch('cnt_clk', 1); + net.setSwitch('cnt_clk', 0); + expect(read(), `after ${n} clocks`).toBe(n % 16); + } + }); }); diff --git a/frontend/src/__tests__/examples-digital.test.ts b/frontend/src/__tests__/examples-digital.test.ts index fd7c7fea..680546af 100644 --- a/frontend/src/__tests__/examples-digital.test.ts +++ b/frontend/src/__tests__/examples-digital.test.ts @@ -79,12 +79,15 @@ describe('digitalExamples — shape', () => { } }); - it('every component type has a SPICE mapping', () => { + it('every component type has a SPICE mapping (flip-flops are digital-engine-only)', () => { const mapped = new Set(mappedMetadataIds()); const unmapped = new Set(); for (const ex of digitalExamples) { for (const c of ex.components) { const id = c.type.replace(/^(wokwi|velxio)-/, ''); + // Flip-flops have no SPICE mapper by design (no edge detection at DC); + // they are evaluated by the digital gate engine, not ngspice. + if (id.startsWith('flip-flop')) continue; if (!mapped.has(id)) unmapped.add(`${ex.id}:${c.id}(${id})`); } } @@ -110,17 +113,25 @@ describe('digitalExamples — shape', () => { } }); - it('every example has at least one logic gate', () => { + it('every example has at least one logic gate or flip-flop', () => { for (const ex of digitalExamples) { - const gates = ex.components.filter((c) => c.type.startsWith('velxio-logic-gate-')); - expect(gates.length, `${ex.id} has no logic gate`).toBeGreaterThanOrEqual(1); + const logic = ex.components.filter( + (c) => c.type.startsWith('velxio-logic-gate-') || c.type.startsWith('velxio-flip-flop-'), + ); + expect(logic.length, `${ex.id} has no logic gate or flip-flop`).toBeGreaterThanOrEqual(1); } }); }); +/** A sequential example contains a flip-flop — it is evaluated by the digital + * gate engine, not ngspice, so it is exempt from the SPICE netlist checks. */ +const isSequential = (ex: (typeof digitalExamples)[number]) => + ex.components.some((c) => c.type.startsWith('velxio-flip-flop-')); + describe('digitalExamples — netlist generation', () => { it('each example produces a non-empty netlist with a ground net', () => { for (const ex of digitalExamples) { + if (isSequential(ex)) continue; // flip-flop circuits have no SPICE netlist const { netlist } = buildNetlist({ components: toSpiceComponents(ex), wires: toSpiceWires(ex), diff --git a/frontend/src/data/examples-digital.ts b/frontend/src/data/examples-digital.ts index 3dbc978b..f956528c 100644 --- a/frontend/src/data/examples-digital.ts +++ b/frontend/src/data/examples-digital.ts @@ -67,6 +67,12 @@ function gate(kind: string, id: string, x: number, y: number) { return { type: `velxio-logic-gate-${kind}`, id, x, y, properties: {} }; } +/** Edge-triggered flip-flop (kind: 'd' | 't' | 'jk'). Digital-engine only — + * no SPICE mapper (no edge detection at DC). Pins: CLK + data + Q + Qbar. */ +function ff(kind: 'd' | 't' | 'jk', id: string, x: number, y: number) { + return { type: `velxio-flip-flop-${kind}`, id, x, y, properties: {} }; +} + /** Placeholder code shown in the editor. No MCU is involved. */ const DIGITAL_SKETCH = `// Pure digital circuit — no MCU. // Toggle the electrical-simulation (⚡) button to run the SPICE engine, @@ -2770,4 +2776,42 @@ export const digitalExamples: ExampleProject[] = [ ], ['bcd', '7-segment', 'decoder', 'k-map'], ), + + // ════════════════════════════════════════════════════════════════════════ + // SEQUENTIAL — flip-flops (digital-engine only; no SPICE edge detection) + // ════════════════════════════════════════════════════════════════════════ + + (() => { + const N = 4; + const components: ExampleProject['components'] = [pwr('src', 40, 380)]; + const wires: ExampleProject['wires'] = []; + // CLOCK slide switch -> FF0.CLK (with pull-down). + const clk = switchInput('cnt_clk', 'cnt_clk_r', 'src', 'cnt_ff0', 'CLK', 200, 60, 0, 'cnt_clk'); + components.push(...clk.components); + wires.push(...clk.wires); + const ledColors = ['red', 'green', 'blue', 'yellow']; + const wireColors = [C_OUT_R, C_OUT_G, C_OUT_B, C_OUT_Y]; + for (let i = 0; i < N; i++) { + const id = `cnt_ff${i}`; + components.push(ff('t', id, 440, 120 + i * 150)); + wires.push(w(`cnt_t${i}`, ['src', 'SIG'], [id, 'T'], C_PWR)); // T tied high -> toggle + if (i > 0) wires.push(w(`cnt_rip${i}`, [`cnt_ff${i - 1}`, 'Qbar'], [id, 'CLK'], C_SIG)); // ripple + const lo = ledOutput(`cnt_lr${i}`, `cnt_led${i}`, 'src', id, 'Q', 720, 110 + i * 150, ledColors[i], `cnt_led${i}`, wireColors[i]); + components.push(...lo.components); + wires.push(...lo.wires); + } + return digital( + 'digital-ripple-counter-4bit', + '4-Bit Ripple Counter (T flip-flops)', + 'Four T flip-flops chained into a ripple counter — impossible on the SPICE ' + + 'engine (no edge detection at DC). Each time you slide the CLOCK switch from ' + + 'LOW to HIGH the count advances; the four LEDs show it in binary, LSB at the ' + + 'top. The event-driven digital engine evaluates the flip-flops exactly. ' + + '(Set ?digitalgates=off to see that ngspice cannot run this.)', + 'advanced', + components, + wires, + ['counter', 'sequential', 'flip-flop', 't-ff', 'ripple', 'digital-engine'], + ); + })(), ]; diff --git a/frontend/src/simulation/digital/digitalGateController.ts b/frontend/src/simulation/digital/digitalGateController.ts index 968d40ad..5a02171d 100644 --- a/frontend/src/simulation/digital/digitalGateController.ts +++ b/frontend/src/simulation/digital/digitalGateController.ts @@ -2,43 +2,50 @@ * digitalGateController — Phase 2 of project/digital-gate-engine/. * * Mounts the digital gate engine into the live app: when `?digitalgates=on` and - * the board-less circuit is all-digital, it builds the network from the store on - * every relevant change (switch toggle / load), settles it on the multichip-bus - * kernel, and pushes the resolved levels onto the real `wokwi-led` DOM elements. - * ngspice is told to skip all-digital circuits (CircuitSimulationService guard) - * so the two motors do not fight over the LEDs. + * the board-less circuit is all-digital, it builds the network from the store, + * settles it on the multichip-bus kernel, and pushes the resolved levels onto + * the real `wokwi-led` DOM elements. ngspice is told to skip all-digital + * circuits (CircuitSimulationService guard) so the two motors do not fight. * - * Flag OFF (default) => this is a no-op and nothing changes. Mixed / analog - * circuits never qualify as all-digital, so they stay entirely on ngspice. + * The network is built ONCE and kept alive; a switch toggle applies + * incrementally via setSwitch (NOT a rebuild). That is essential for SEQUENTIAL + * circuits — rebuilding resets flip-flop state, so a counter would never count. + * A rebuild happens only on a structural change (components added/removed, wires + * changed). Flag OFF (default-overridable) => no-op; mixed/analog circuits never + * qualify as all-digital and stay on ngspice. */ import { useSimulatorStore } from '../../store/useSimulatorStore'; import { PinManager } from '../PinManager'; import { resetBusNets } from '../customChips/busNets'; -import { buildDigitalNetwork, digitalGatesEnabled, isAllDigital } from './digitalGateEngine'; -import { PROPERTY_CHANGE_EVENT } from '../parts/partUtils'; +import { + buildDigitalNetwork, + digitalGatesEnabled, + isAllDigital, + type DigitalNetwork, + type DigitalComponent, +} from './digitalGateEngine'; +import { PROPERTY_CHANGE_EVENT, type PropertyChangeDetail } from '../parts/partUtils'; interface LedEl extends HTMLElement { value?: boolean; brightness?: number; } -/** - * Start the controller. Returns an unsubscribe handle. Safe to call when the - * flag is off — it returns a no-op disposer immediately. - */ +const kind = (c: DigitalComponent) => String(c.metadataId ?? c.type ?? '').replace(/^velxio-/, '').replace(/^wokwi-/, ''); + export function mountDigitalGateEngine(): () => void { if (typeof window === 'undefined' || !digitalGatesEnabled()) return () => {}; let disposed = false; + let net: DigitalNetwork | null = null; + let structuralSig = ''; let raf = 0; - const paintLeds = () => { - const st = useSimulatorStore.getState(); - // Mixed / analog circuits belong to ngspice — leave them alone. - if (!isAllDigital(st.components as never[])) return; - resetBusNets(); - const net = buildDigitalNetwork(st.components as never[], st.wires as never[], new PinManager()); - if (!net.ok) return; + const sigOf = (st = useSimulatorStore.getState()) => + st.components.map((c) => c.id).join(',') + '|' + st.wires.length; + + const paint = () => { + if (!net?.ok) return; for (const id of net.ledIds) { const el = document.getElementById(id) as LedEl | null; if (!el) continue; @@ -47,25 +54,43 @@ export function mountDigitalGateEngine(): () => void { el.brightness = lit ? 1 : 0; } }; - - // Coalesce bursts (e.g. loadExample sets many components) into one paint. - const schedule = () => { + const schedulePaint = () => { if (disposed || raf) return; - raf = requestAnimationFrame(() => { - raf = 0; - if (!disposed) paintLeds(); - }); + raf = requestAnimationFrame(() => { raf = 0; if (!disposed) paint(); }); + }; + + // Build once; keep the network (and its flip-flop state) alive. + const rebuild = () => { + const st = useSimulatorStore.getState(); + structuralSig = sigOf(st); + if (!isAllDigital(st.components as never[])) { net = null; return; } + resetBusNets(); + const built = buildDigitalNetwork(st.components as never[], st.wires as never[], new PinManager()); + net = built.ok ? built : null; + schedulePaint(); + }; + + // A switch toggle: apply incrementally so flip-flop state is preserved. + const onProp = (evt: Event) => { + if (!net?.ok) return; + const { componentId, propName, value } = (evt as CustomEvent).detail; + if (propName !== 'value') return; + const c = useSimulatorStore.getState().components.find((x) => x.id === componentId); + if (!c || kind(c as DigitalComponent) !== 'slide-switch') return; + net.setSwitch(componentId, Number(value) === 1 ? 1 : 0); + schedulePaint(); }; - // Switch toggles emit velxio:property-change; structural changes bump the - // store's components/wires references. - const onProp = () => schedule(); window.addEventListener(PROPERTY_CHANGE_EVENT, onProp); + // Rebuild only when the structure changes (load / add / remove / rewire) — NOT + // on every property change, which would wipe sequential state. const unsub = useSimulatorStore.subscribe((n, p) => { - if (n.components !== p.components || n.wires !== p.wires) schedule(); + if (n.components !== p.components || n.wires !== p.wires) { + if (sigOf(n) !== structuralSig) rebuild(); + } }); - schedule(); // initial paint + rebuild(); // initial return () => { disposed = true;