/**
* Ladder Logic Engine
* Evaluates rungs in a scan cycle:
* 1. Read all inputs
* 2. Evaluate rungs top-to-bottom, left-to-right
* 3. Update all outputs
*/
import type {
Contact,
Coil,
Timer,
Counter,
LogicGate,
Rung,
RungElement,
ComparisonCondition,
Program,
EngineState,
Input,
Output,
MemoryBit,
ContactType,
CoilType,
TimerType,
CounterType,
GateType,
ComparisonOp,
Branch,
MathElement,
MathOperator,
OneShot,
MoveElement,
ScaleElement,
NoOp,
CompareElement,
EdgeType,
} from './types';
import {
LadderError,
InvalidMemoryAccessError,
MathOperationError,
} from './errors';
export class LadderEngine {
state: EngineState;
private program: Program | null = null;
private listeners: Set<() => void> = new Set();
private lastRungStates: Map<string, boolean> = new Map();
private stopFn: (() => void) | null = null;
constructor() {
this.state = {
cycle: 0,
inputs: new Map(),
outputs: new Map(),
memory: new Map(),
timers: new Map(),
counters: new Map(),
previousInputs: new Map(),
running: false,
};
}
/** Subscribe to state changes. */
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify() {
this.listeners.forEach((l) => l());
}
/** Initialize or replace the program. */
loadProgram(program: Program): void {
this.stop();
this.program = program;
this.state.outputs.clear();
this.state.inputs.clear();
this.state.memory.clear();
this.state.timers.clear();
this.state.counters.clear();
this.lastRungStates.clear();
this.notify();
}
/** Get current engine state. */
getState(): Readonly<EngineState> {
return this.state;
}
/** Get the loaded program. */
getProgram(): Program | null {
return this.program;
}
// βββ Mutations ββββββββββββββββββββββββββββββββββββββββββββββββββ
/** Update a component's parameters within a rung. */
updateComponent(rungId: string, componentId: string, updates: Partial<any>): void {
if (!this.program) return;
const rung = this.program.rungs.find((r) => r.id === rungId);
if (!rung) return;
const found = this.findComponentRecursive(rung.series, componentId);
if (found) {
Object.assign(found.element, updates);
this.notify();
}
}
/** Move a component from one rung to another or within the same rung. */
moveComponent(
sourceRungId: string,
componentId: string,
targetRungId: string,
targetPosition: number,
): void {
if (!this.program) return;
const sourceRung = this.program.rungs.find((r) => r.id === sourceRungId);
const targetRung = this.program.rungs.find((r) => r.id === targetRungId);
if (!sourceRung || !targetRung) return;
const found = this.findComponentRecursive(sourceRung.series, componentId);
if (!found) return;
const { parent, index, element } = found;
parent.splice(index, 1);
// Insert into target rung's series
(element as any).position = targetPosition;
targetRung.series.splice(targetPosition, 0, element);
// Re-index positions
this.reindexSeries(sourceRung.series);
this.reindexSeries(targetRung.series);
this.notify();
}
/** Toggle an input state. */
toggleInput(address: string): void {
const current = this.state.inputs.get(address)?.value ?? false;
this.setInput(address, !current);
}
// βββ I/O Operations ββββββββββββββββββββββββββββββββββββββββββββ
/** Set an input value. */
setInput(address: string, value: boolean | number, rawValue?: number): void {
const existing = this.state.inputs.get(address);
this.state.inputs.set(address, {
address,
value,
rawValue: rawValue ?? existing?.rawValue,
});
this.notify();
}
/** Read an input value. */
getInput(address: string): boolean | number {
return this.state.inputs.get(address)?.value ?? false;
}
/** Read an output value. */
getOutput(address: string): boolean | number {
return this.state.outputs.get(address)?.value ?? false;
}
/** Get all outputs. */
getOutputs(): Map<string, Output> {
return this.state.outputs;
}
/** Set a memory bit directly. */
setMemory(address: string, value: boolean | number): void {
const existing = this.state.memory.get(address);
if (existing) {
existing.value = value;
} else {
this.state.memory.set(address, { address, value });
}
this.notify();
}
/** Read a memory bit. */
getMemory(address: string): boolean | number {
return this.state.memory.get(address)?.value ?? false;
}
// βββ Scan Cycle ββββββββββββββββββββββββββββββββββββββββββββββββ
/** Run one scan cycle of the ladder program. */
scan(): void {
if (!this.program) return;
this.state.cycle++;
// Save current inputs as previous for edge detection (all address spaces)
const currentInputs = new Map<string, boolean | number>();
this.state.inputs.forEach((input, addr) => {
currentInputs.set(addr, input.value);
});
this.state.memory.forEach((mem, addr) => {
currentInputs.set(addr, mem.value);
});
this.state.outputs.forEach((out, addr) => {
currentInputs.set(addr, out.value);
});
// Handle resets
this.handleCounterResets();
this.handleTimerResets(this.program);
// Evaluate each rung
for (const rung of this.program.rungs) {
try {
const lastRungState = this.lastRungStates.get(rung.id) ?? false;
if (!rung.enabled) {
this.lastRungStates.set(rung.id, false);
continue;
}
const currentRungState = this.evaluateRung(rung, lastRungState);
this.lastRungStates.set(rung.id, currentRungState);
} catch (err) {
console.error(`Error in scan cycle for rung ${rung.id}:`, err);
// We continue to the next rung even if one fails
}
}
// Update previousInputs for next scan cycle
this.state.previousInputs = currentInputs;
this.notify();
}
/** Start the engine running. */
start(): () => void {
if (!this.program) return () => {};
if (this.stopFn) this.stop();
this.state.running = true;
this.notify();
const interval = this.program.cycleTime;
const timer = setInterval(() => this.scan(), interval);
this.stopFn = () => {
this.state.running = false;
this.notify();
clearInterval(timer);
};
return this.stopFn;
}
/** Stop the engine running. */
stop(): void {
if (this.stopFn) {
this.stopFn();
this.stopFn = null;
}
}
private handleCounterResets(): void {
if (!this.program) return;
for (const rung of this.program.rungs) {
this.traverseRung(rung, (element) => {
if (element.type === 'counter') {
const counter = element as Counter;
if (counter.resetAddress) {
const resetValue = this.getInput(counter.resetAddress) || this.getMemory(counter.resetAddress) || this.getOutput(counter.resetAddress);
if (resetValue) {
this.state.counters.set(counter.instanceId, { ...counter, current: 0 });
}
}
}
});
}
}
private handleTimerResets(program: Program): void {
for (const rung of program.rungs) {
if (!rung.enabled) continue;
for (const element of rung.series) {
if (element.type === 'timer') {
const timer = element as Timer;
if (timer.timerType === 'RTO' && timer.resetAddress) {
const resetState =
this.getInput(timer.resetAddress) ||
this.getMemory(timer.resetAddress) ||
this.getOutput(timer.resetAddress) ||
false;
if (resetState) {
const existing = this.state.timers.get(timer.instanceId);
if (existing) {
existing.accumulated = 0;
} else {
this.state.timers.set(timer.instanceId, {
...timer,
accumulated: 0,
});
}
}
}
}
}
}
}
// βββ Rung Evaluation βββββββββββββββββββββββββββββββββββββββββββ
private evaluateRung(rung: Rung, lastRungState: boolean): boolean {
const rungResult = this.evaluateSeries(rung.series, lastRungState);
// The last element in series is typically the output coil
const lastElement = rung.series[rung.series.length - 1];
if (lastElement && lastElement.type === 'coil') {
this.activateCoil(lastElement as Coil, rungResult, lastRungState);
}
return rungResult;
}
private evaluateSeries(elements: RungElement[], lastRungState: boolean): boolean {
let result = true;
for (const element of elements) {
try {
switch (element.type) {
case 'contact':
result = result && this.evaluateContact(element as Contact);
break;
case 'gate':
result = result && this.evaluateGate(element as LogicGate);
break;
case 'timer': {
const timerResult = this.evaluateTimer(element as Timer, result, lastRungState);
const t = element as Timer;
if (t.timerType === 'TOF') {
// TOF controls its own output independently of rung state:
// while timing (off-delay), it overrides the series result
result = timerResult;
} else {
result = result && timerResult;
}
break;
}
case 'counter': {
const counterResult = this.evaluateCounter(element as Counter, result, lastRungState);
result = result && counterResult;
break;
}
case 'branch':
result = result && this.evaluateBranch(element as Branch, lastRungState);
break;
case 'math': {
const mathResult = this.evaluateMath(element as MathElement, result);
result = result && mathResult;
break;
}
case 'oneshot': {
const oneshotResult = this.evaluateOneShot(element as OneShot, result, lastRungState);
result = result && oneshotResult;
break;
}
case 'move': {
const moveResult = this.evaluateMove(element as MoveElement, result);
result = result && moveResult;
break;
}
case 'scale': {
const scaleResult = this.evaluateScale(element as ScaleElement, result);
result = result && scaleResult;
break;
}
case 'noop':
// NOP is a no-op, passes through as true
break;
case 'compare': {
const compareResult = this.evaluateCompare(element as CompareElement, result);
result = result && compareResult;
break;
}
case 'coil':
break;
default:
break;
}
} catch (err) {
console.error(`Error evaluating element ${element.type}:`, err);
// In a real PLC, this might halt the scan or set a fault bit.
// Here, we'll treat it as a failure of this part of the rung.
result = false;
}
}
return result;
}
private evaluateBranch(branch: Branch, lastRungState: boolean): boolean {
if (branch.logic === 'AND') {
return branch.paths.every((path) => this.evaluateSeries(path, lastRungState));
} else {
return branch.paths.some((path) => this.evaluateSeries(path, lastRungState));
}
}
private evaluateContact(contact: Contact): boolean {
let inputValue: boolean | number;
if (this.state.inputs.has(contact.address)) {
inputValue = this.getInput(contact.address);
} else if (this.state.memory.has(contact.address)) {
inputValue = this.getMemory(contact.address);
} else if (this.state.outputs.has(contact.address)) {
inputValue = this.getOutput(contact.address);
} else {
// No address found β treat as false input
inputValue = false;
}
// Evaluate the base contact logic (NO/NC with optional condition)
let baseResult: boolean;
if (contact.condition) {
const rawValue =
this.state.inputs.get(contact.address)?.rawValue ?? inputValue;
if (typeof rawValue !== 'number') return false;
baseResult = this.compare(rawValue, contact.condition.op, contact.condition.value);
} else {
baseResult = !!inputValue;
}
const contactResult = contact.contactType === 'NO' ? baseResult : !baseResult;
// Edge detection: if edgeType is set, only pass on the specified edge
if (contact.edgeType) {
const prevValue = this.state.previousInputs.get(contact.address);
const currentBool = !!inputValue;
const prevBool = prevValue !== undefined ? !!prevValue : false;
if (contact.edgeType === 'RISING') {
return contactResult && currentBool && !prevBool;
} else if (contact.edgeType === 'FALLING') {
return contactResult && !currentBool && prevBool;
} else if (contact.edgeType === 'BOTH') {
return contactResult && (currentBool !== prevBool);
}
}
return contactResult;
}
private activateCoil(coil: Coil, energized: boolean, lastRungState: boolean = false): void {
let newValue: boolean | number;
switch (coil.coilType) {
case 'OUTPUT':
case 'OTE':
// OTE = Output Energize β standard coil that follows rung state
newValue = energized ? (coil.value ?? true) : (coil.value ?? false);
break;
case 'SET':
case 'OTL':
case 'LATCH':
// OTL/LATCH/SET: once energized, stays on until OTU/UNLATCH/RESET clears it
newValue = energized ? true : (this.state.outputs.get(coil.address)?.value ?? false);
break;
case 'RESET':
case 'OTU':
case 'UNLATCH':
// OTU/UNLATCH/RESET: clears a latched output
newValue = energized ? false : (this.state.outputs.get(coil.address)?.value ?? false);
break;
case 'TOGGLE':
// Only toggle on rising edge (FALSEβTRUE transition), not every scan while held
if (energized && !lastRungState) {
const currentToggle = this.state.outputs.get(coil.address)?.value ?? false;
newValue = !currentToggle;
} else {
// Rung held high or false β don't change the output
return;
}
break;
default:
newValue = energized;
}
this.state.outputs.set(coil.address, {
address: coil.address,
value: newValue,
lastUpdated: this.state.cycle,
});
}
private evaluateTimer(
timer: Timer,
rungState: boolean,
lastRungState: boolean,
): boolean {
const existing = this.state.timers.get(timer.instanceId);
const currentTimer = existing ?? { ...timer, accumulated: 0 };
const cycleTime = this.program?.cycleTime ?? 100;
switch (timer.timerType) {
case 'TON':
if (rungState) {
currentTimer.accumulated = Math.min(
currentTimer.accumulated + cycleTime,
timer.preset,
);
} else {
currentTimer.accumulated = 0;
}
break;
case 'TOF':
if (rungState) {
// Input is TRUE β reset timer, output ON immediately
currentTimer.accumulated = 0;
} else {
// Input went FALSE β start off-delay timing
currentTimer.accumulated = Math.min(
currentTimer.accumulated + cycleTime,
timer.preset,
);
}
break;
case 'TP':
if (rungState && !lastRungState) {
currentTimer.accumulated = timer.preset;
} else if (currentTimer.accumulated > 0) {
currentTimer.accumulated = Math.max(
currentTimer.accumulated - cycleTime,
0,
);
}
break;
case 'RTO':
// Retain On-Delay: like TON but accumulated time persists when input goes false
// Needs explicit reset to clear accumulated time
if (timer.resetAddress) {
const resetState =
this.getInput(timer.resetAddress) ||
this.getMemory(timer.resetAddress) ||
this.getOutput(timer.resetAddress) ||
false;
if (resetState) {
currentTimer.accumulated = 0;
break; // Don't accumulate this cycle when reset is asserted
}
}
if (rungState) {
currentTimer.accumulated = Math.min(
currentTimer.accumulated + cycleTime,
timer.preset,
);
}
// When rungState is false, accumulated time is retained (not reset)
break;
}
this.state.timers.set(timer.instanceId, currentTimer);
if (timer.timerType === 'TON') return currentTimer.accumulated >= timer.preset;
if (timer.timerType === 'TOF') {
// TOF: power flows when input is true OR when timer is still timing (not yet done)
// rungState=true β output ON (accumulated=0, not done)
// rungState=false β output ON while accumulated < preset, OFF when done
return rungState || currentTimer.accumulated < timer.preset;
}
if (timer.timerType === 'TP') return currentTimer.accumulated > 0;
if (timer.timerType === 'RTO') return currentTimer.accumulated >= timer.preset;
return false;
}
private evaluateCounter(
counter: Counter,
rungState: boolean,
lastRungState: boolean,
): boolean {
const existing = this.state.counters.get(counter.instanceId);
const currentCounter = existing ?? { ...counter, current: 0 };
// Check for reset first. If resetting, we set current to 0 and do not increment.
if (counter.resetAddress) {
const resetValue = this.getInput(counter.resetAddress) || this.getMemory(counter.resetAddress) || this.getOutput(counter.resetAddress);
if (resetValue) {
currentCounter.current = 0;
currentCounter.ov = false;
currentCounter.und = false;
this.state.counters.set(counter.instanceId, currentCounter);
return false;
}
}
if (rungState && !lastRungState) {
if (counter.counterType === 'CTU' || counter.counterType === 'CTUD') {
currentCounter.current++;
if (currentCounter.current > 9999) {
currentCounter.ov = true;
currentCounter.current = 9999;
}
} else if (counter.counterType === 'CTD') {
currentCounter.current--;
if (currentCounter.current < 0) {
currentCounter.und = true;
currentCounter.current = 0;
}
}
}
this.state.counters.set(counter.instanceId, currentCounter);
if (counter.counterType === 'CTU' || counter.counterType === 'CTUD') {
return currentCounter.current >= counter.preset;
} else if (counter.counterType === 'CTD') {
return currentCounter.current <= 0;
}
return false;
}
private evaluateGate(gate: LogicGate): boolean {
const inputValues = gate.inputs.map(
(addr) => {
if (this.state.inputs.has(addr)) return !!this.getInput(addr);
if (this.state.memory.has(addr)) return !!this.getMemory(addr);
if (this.state.outputs.has(addr)) return !!this.getOutput(addr);
return false;
},
);
switch (gate.gateType) {
case 'AND': return inputValues.every(Boolean);
case 'OR': return inputValues.some(Boolean);
case 'XOR': return inputValues.filter(Boolean).length % 2 === 1;
case 'NOT': return !inputValues[0];
case 'NAND': return !inputValues.every(Boolean);
case 'NOR': return !inputValues.some(Boolean);
default: return false;
}
}
private evaluateMath(math: MathElement, rungState: boolean): boolean {
if (!rungState) return false;
const valA = this.getValueFromAddress(math.inputA);
const valB = this.getValueFromAddress(math.inputB);
if (typeof valA !== 'number' || typeof valB !== 'number') {
throw new MathOperationError(math.operator, 'Inputs must be numbers');
}
let result: number;
switch (math.operator) {
case 'ADD': result = valA + valB; break;
case 'SUB': result = valA - valB; break;
case 'MUL': result = valA * valB; break;
case 'DIV':
if (valB === 0) throw new MathOperationError(math.operator, 'Division by zero');
result = valA / valB;
break;
default: throw new MathOperationError(math.operator, 'Unknown operator');
}
// Integer mode: truncate to whole number (32-bit DINT style)
if (this.program?.integerMode) {
result = Math.trunc(result);
}
// 32-bit overflow protection
result = Math.max(-2147483648, Math.min(2147483647, result));
// Update the output address. Since it's math, we might want to update an output or memory.
// For simplicity, we'll assume it's an output or memory.
// If it's an output, we use the existing logic.
// Note: the current activateCoil only handles boolean/number for COILS.
// We might need a more generic setAddressValue method.
if (this.state.outputs.has(math.outputAddress)) {
const out = this.state.outputs.get(math.outputAddress)!;
this.state.outputs.set(math.outputAddress, { ...out, value: result, lastUpdated: this.state.cycle });
} else if (this.state.memory.has(math.outputAddress)) {
const mem = this.state.memory.get(math.outputAddress)!;
this.state.memory.set(math.outputAddress, { ...mem, value: result });
} else {
this.state.memory.set(math.outputAddress, { address: math.outputAddress, value: result });
}
return true; // Math element itself is "true" if it doesn't error and is evaluated in a series
}
private getValueFromAddress(address: string): boolean | number {
if (this.state.inputs.has(address)) return this.getInput(address);
if (this.state.memory.has(address)) return this.getMemory(address);
if (this.state.outputs.has(address)) return this.getOutput(address);
throw new InvalidMemoryAccessError(address);
}
// βββ New Instruction Evaluators βββββββββββββββββββββββββββββββββ
/** One-Shot: fires true for exactly one scan on the specified edge. */
private evaluateOneShot(oneshot: OneShot, rungState: boolean, lastRungState: boolean): boolean {
if (!rungState) return false;
const currentValue = this.getInput(oneshot.address) || this.getMemory(oneshot.address) || this.getOutput(oneshot.address);
const prevValue = this.state.previousInputs.get(oneshot.address);
const currentBool = !!currentValue;
const prevBool = prevValue !== undefined ? !!prevValue : false;
if (oneshot.edgeType === 'RISING') {
return currentBool && !prevBool;
} else {
// FALLING
return !currentBool && prevBool;
}
}
/** MOV: copies value from source to destination. */
private evaluateMove(move: MoveElement, rungState: boolean): boolean {
if (!rungState) return false;
const sourceValue = this.getValueFromAddress(move.source);
if (this.state.outputs.has(move.destination)) {
const out = this.state.outputs.get(move.destination)!;
this.state.outputs.set(move.destination, { ...out, value: sourceValue, lastUpdated: this.state.cycle });
} else if (this.state.memory.has(move.destination)) {
this.state.memory.set(move.destination, {
address: move.destination,
value: sourceValue,
});
} else {
this.state.memory.set(move.destination, { address: move.destination, value: sourceValue });
}
return true;
}
/** Scale: scales input from one range to another. */
private evaluateScale(scale: ScaleElement, rungState: boolean): boolean {
if (!rungState) return false;
const inputVal = this.getValueFromAddress(scale.inputAddress);
if (typeof inputVal !== 'number') {
throw new MathOperationError('SLC', 'Input must be a number');
}
const range = scale.inMax - scale.inMin;
let scaled: number;
if (range === 0) {
scaled = scale.outMin;
} else {
scaled = scale.outMin + ((inputVal - scale.inMin) / range) * (scale.outMax - scale.outMin);
}
// Clamp to output range
scaled = Math.max(scale.outMin, Math.min(scale.outMax, scaled));
if (this.state.outputs.has(scale.destination)) {
const out = this.state.outputs.get(scale.destination)!;
this.state.outputs.set(scale.destination, { ...out, value: scaled, lastUpdated: this.state.cycle });
} else if (this.state.memory.has(scale.destination)) {
this.state.memory.set(scale.destination, { address: scale.destination, value: scaled });
} else {
this.state.memory.set(scale.destination, { address: scale.destination, value: scaled });
}
return true;
}
/** Compare: evaluate inputA op inputB, write boolean result to outputAddress. */
private evaluateCompare(cmp: CompareElement, rungState: boolean): boolean {
if (!rungState) return false;
const valA = this.getValueFromAddress(cmp.inputA);
const valB = this.getValueFromAddress(cmp.inputB);
if (typeof valA !== 'number' || typeof valB !== 'number') {
throw new MathOperationError('CMP', 'Both inputs must be numbers');
}
const result = this.compare(valA, cmp.op, valB);
if (this.state.outputs.has(cmp.outputAddress)) {
const out = this.state.outputs.get(cmp.outputAddress)!;
this.state.outputs.set(cmp.outputAddress, { ...out, value: result, lastUpdated: this.state.cycle });
} else if (this.state.memory.has(cmp.outputAddress)) {
this.state.memory.set(cmp.outputAddress, { address: cmp.outputAddress, value: result });
} else {
this.state.memory.set(cmp.outputAddress, { address: cmp.outputAddress, value: result });
}
return result;
}
/** Takes a deep snapshot of the current engine state. */
takeSnapshot(): EngineState {
return {
cycle: this.state.cycle,
inputs: new Map(Array.from(this.state.inputs.entries()).map(([k, v]) => [k, { ...v }])),
outputs: new Map(Array.from(this.state.outputs.entries()).map(([k, v]) => [k, { ...v }])),
memory: new Map(Array.from(this.state.memory.entries()).map(([k, v]) => [k, { ...v }])),
timers: new Map(Array.from(this.state.timers.entries()).map(([k, v]) => [k, { ...v }])),
counters: new Map(Array.from(this.state.counters.entries()).map(([k, v]) => [k, { ...v }])),
previousInputs: new Map(this.state.previousInputs),
running: this.state.running,
};
}
/** Restores the engine state from a snapshot. */
restoreSnapshot(snapshot: EngineState): void {
this.state = {
cycle: snapshot.cycle,
inputs: new Map(Array.from(snapshot.inputs.entries()).map(([k, v]) => [k, { ...v }])),
outputs: new Map(Array.from(snapshot.outputs.entries()).map(([k, v]) => [k, { ...v }])),
memory: new Map(Array.from(snapshot.memory.entries()).map(([k, v]) => [k, { ...v }])),
timers: new Map(Array.from(snapshot.timers.entries()).map(([k, v]) => [k, { ...v }])),
counters: new Map(Array.from(snapshot.counters.entries()).map(([k, v]) => [k, { ...v }])),
previousInputs: new Map(snapshot.previousInputs),
running: snapshot.running,
};
this.notify();
}
private compare(a: number, op: string, b: number): boolean {
switch (op) {
case '==': return a === b;
case '!=': return a !== b;
case '>': return a > b;
case '<': return a < b;
case '>=': return a >= b;
case '<=': return a <= b;
default: return false;
}
}
// βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββ
private findComponentRecursive(
elements: RungElement[],
componentId: string,
): { parent: RungElement[]; index: number; element: RungElement } | null {
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
if ((element as any).id === componentId) {
return { parent: elements, index: i, element };
}
if (element.type === 'branch') {
const branch = element as Branch;
for (const path of branch.paths) {
const found = this.findComponentRecursive(path, componentId);
if (found) return found;
}
}
}
return null;
}
private reindexSeries(elements: RungElement[]): void {
elements.forEach((el, i) => {
(el as any).position = i;
if (el.type === 'branch') {
(el as Branch).paths.forEach((path) => this.reindexSeries(path));
}
});
}
private traverseRung(rung: Rung, callback: (el: RungElement) => void): void {
this.traverseElements(rung.series, callback);
}
private traverseElements(
elements: RungElement[],
callback: (el: RungElement) => void,
): void {
for (const el of elements) {
callback(el);
if (el.type === 'branch') {
(el as Branch).paths.forEach((path) =>
this.traverseElements(path, callback),
);
}
}
}
}