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:
parent
8aa6e93460
commit
b93b1b42c5
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
);
|
||||
})(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue