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) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-06-05 21:58:42 -03:00
parent 8aa6e93460
commit b93b1b42c5
4 changed files with 130 additions and 36 deletions

View File

@ -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);
}
});
});

View File

@ -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<string>();
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),

View File

@ -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'],
);
})(),
];

View File

@ -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<PropertyChangeDetail>).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;