fix: complete rewrite of component evaluation system

Major fixes for custom components when used in the main circuit:

- Add outputValues[] array for multi-output component gates, so each
  output port carries its own independent value
- readSourcePort() reads the correct port value from source gates
  instead of always reading gate.value
- evaluateComponent() now uses iterative fixed-point evaluation
  (matching main evaluateAll) instead of a simple 10-pass loop
- Store inputIds/outputIds in component definition for consistent
  port-to-gate mapping across save/load
- Renderer reads per-port values for connection color and port glow
- Added debug logs for component save and evaluation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-20 04:10:28 +01:00
parent bc8823bcd4
commit 1c45dc6104
3 changed files with 123 additions and 102 deletions

View File

@@ -18,15 +18,22 @@ export function saveComponentFromCircuit(name) {
const outputGates = state.gates.filter(g => g.type === 'OUTPUT'); const outputGates = state.gates.filter(g => g.type === 'OUTPUT');
if (inputGates.length === 0 || outputGates.length === 0) { if (inputGates.length === 0 || outputGates.length === 0) {
alert('Component must have at least one INPUT and one OUTPUT');
return { success: false, error: 'Component must have at least one INPUT and one OUTPUT' }; return { success: false, error: 'Component must have at least one INPUT and one OUTPUT' };
} }
// Store the input/output gate IDs in order so we can map ports consistently
const inputIds = inputGates.map(g => g.id);
const outputIds = outputGates.map(g => g.id);
// Create component definition // Create component definition
const component = { const component = {
id: sanitizeComponentName(name), id: sanitizeComponentName(name),
name, name,
inputCount: inputGates.length, inputCount: inputGates.length,
outputCount: outputGates.length, outputCount: outputGates.length,
inputIds,
outputIds,
gates: JSON.parse(JSON.stringify(state.gates)), gates: JSON.parse(JSON.stringify(state.gates)),
connections: JSON.parse(JSON.stringify(state.connections)) connections: JSON.parse(JSON.stringify(state.connections))
}; };
@@ -37,125 +44,108 @@ export function saveComponentFromCircuit(name) {
} }
state.customComponents[component.id] = component; state.customComponents[component.id] = component;
console.log(`[component] saved "${name}" (${component.inputCount} in, ${component.outputCount} out)`,
`inputIds=${inputIds}`, `outputIds=${outputIds}`);
return { success: true, component }; return { success: true, component };
} }
/** /**
* Instantiate a component on the canvas * Evaluate a component instance.
*/ * Simulates the internal circuit and returns an array of output values.
export function instantiateComponent(componentId, x, y) {
if (!state.customComponents || !state.customComponents[componentId]) {
return { success: false, error: 'Component not found' };
}
const component = state.customComponents[componentId];
const instanceId = state.nextId++;
// Create a component instance gate
const gate = {
id: instanceId,
type: `COMPONENT:${componentId}`,
x,
y,
value: 0,
component
};
state.gates.push(gate);
return { success: true, gate };
}
/**
* Evaluate a component instance
* Simulates the internal circuit and returns output
*/ */
export function evaluateComponent(gate, inputs) { export function evaluateComponent(gate, inputs) {
if (!gate.component) return 0; if (!gate.component) {
console.warn('[component] evaluateComponent called without component data', gate);
return [0];
}
const comp = gate.component; const comp = gate.component;
const internalState = {
gates: JSON.parse(JSON.stringify(comp.gates)),
connections: JSON.parse(JSON.stringify(comp.connections)),
nextId: Math.max(...comp.gates.map(g => g.id), 0) + 1
};
// Set inputs // Deep clone internal circuit for simulation
const inputGates = internalState.gates.filter(g => g.type === 'INPUT'); const internalGates = JSON.parse(JSON.stringify(comp.gates));
inputs.forEach((val, i) => { const internalConns = comp.connections; // read-only, no need to clone
if (inputGates[i]) inputGates[i].value = val;
});
// Evaluate internal circuit // Map external inputs to internal INPUT gates using stored inputIds
evaluateInternalCircuit(internalState); const inputIds = comp.inputIds || [];
for (let i = 0; i < inputs.length; i++) {
const targetId = inputIds[i];
const inputGate = targetId != null
? internalGates.find(g => g.id === targetId)
: internalGates.filter(g => g.type === 'INPUT')[i]; // fallback for old components
if (inputGate) {
inputGate.value = inputs[i];
}
}
// Get outputs // Iterative fixed-point evaluation (same approach as main evaluateAll)
const outputGates = internalState.gates.filter(g => g.type === 'OUTPUT'); const MAX_ITER = 20;
const outputs = outputGates.map(g => g.value || 0); for (let iter = 0; iter < MAX_ITER; iter++) {
let changed = false;
for (const g of internalGates) {
if (g.type === 'INPUT' || g.type === 'CLOCK') continue;
const inCount = getGateInputCount(g.type);
const gInputs = [];
for (let j = 0; j < inCount; j++) {
const conn = internalConns.find(c => c.to === g.id && c.toPort === j);
if (conn) {
const src = internalGates.find(s => s.id === conn.from);
gInputs.push(src ? (src.value || 0) : 0);
} else {
gInputs.push(0);
}
}
let result = 0;
switch (g.type) {
case 'AND': result = (gInputs[0] && gInputs[1]) ? 1 : 0; break;
case 'OR': result = (gInputs[0] || gInputs[1]) ? 1 : 0; break;
case 'NOT': result = gInputs[0] ? 0 : 1; break;
case 'NAND': result = (gInputs[0] && gInputs[1]) ? 0 : 1; break;
case 'NOR': result = (gInputs[0] || gInputs[1]) ? 0 : 1; break;
case 'XOR': result = (gInputs[0] !== gInputs[1]) ? 1 : 0; break;
case 'OUTPUT': result = gInputs[0] || 0; break;
default: result = 0;
}
if (result !== g.value) {
g.value = result;
changed = true;
}
}
if (!changed) break;
}
// Read outputs using stored outputIds
const outputIds = comp.outputIds || [];
const outputs = [];
if (outputIds.length > 0) {
for (const outId of outputIds) {
const outGate = internalGates.find(g => g.id === outId);
outputs.push(outGate ? (outGate.value || 0) : 0);
}
} else {
// Fallback for old components without outputIds
const outputGates = internalGates.filter(g => g.type === 'OUTPUT');
for (const g of outputGates) {
outputs.push(g.value || 0);
}
}
console.log(`[component] eval "${comp.name}" inputs=[${inputs}] → outputs=[${outputs}]`);
return outputs; return outputs;
} }
/** /**
* Helper to evaluate internal circuit * Get input count for a gate type
*/
function evaluateInternalCircuit(internalState) {
const { gates, connections } = internalState;
// Simple evaluation - may need optimization for complex circuits
for (let i = 0; i < 10; i++) {
for (const gate of gates) {
if (gate.type === 'INPUT' || gate.type === 'CLOCK') continue;
const inputCount = getGateInputCount(gate.type);
const inputs = [];
for (let j = 0; j < inputCount; j++) {
const conn = connections.find(c => c.to === gate.id && c.toPort === j);
if (conn) {
const srcGate = gates.find(g => g.id === conn.from);
inputs.push(srcGate ? srcGate.value || 0 : 0);
} else {
inputs.push(0);
}
}
// Evaluate based on gate type
let result = 0;
if (gate.type === 'AND') result = (inputs[0] && inputs[1]) ? 1 : 0;
else if (gate.type === 'OR') result = (inputs[0] || inputs[1]) ? 1 : 0;
else if (gate.type === 'NOT') result = inputs[0] ? 0 : 1;
else if (gate.type === 'NAND') result = (inputs[0] && inputs[1]) ? 0 : 1;
else if (gate.type === 'NOR') result = (inputs[0] || inputs[1]) ? 0 : 1;
else if (gate.type === 'XOR') result = (inputs[0] !== inputs[1]) ? 1 : 0;
else if (gate.type === 'OUTPUT') result = inputs[0] || 0;
gate.value = result;
}
}
}
/**
* Get input count for a gate type (includes component types)
*/ */
function getGateInputCount(type) { function getGateInputCount(type) {
if (type === 'CLOCK' || type === 'INPUT') return 0; if (type === 'CLOCK' || type === 'INPUT') return 0;
if (type === 'NOT' || type === 'OUTPUT') return 1; if (type === 'NOT' || type === 'OUTPUT') return 1;
if (type.startsWith('COMPONENT:')) {
// Return the component's input count
return 2; // Default for now, should lookup
}
return 2; return 2;
} }
/**
* Get output count for a gate type
*/
function getGateOutputCount(type) {
if (type === 'OUTPUT') return 0;
return 1;
}
/** /**
* Sanitize component name for use as ID * Sanitize component name for use as ID
*/ */

View File

@@ -66,9 +66,22 @@ export function getOutputPorts(gate) {
return ports; return ports;
} }
/**
* Read the value from a source gate at a specific output port.
* For component gates with multiple outputs, reads from outputValues[].
* For normal gates (single output), reads gate.value.
*/
function readSourcePort(srcGate, fromPort) {
if (srcGate.outputValues && fromPort < srcGate.outputValues.length) {
return srcGate.outputValues[fromPort];
}
return srcGate.value || 0;
}
/** /**
* Compute the output of a single gate given its current input values. * Compute the output of a single gate given its current input values.
* Does NOT recurse — just reads source gate .value directly. * Does NOT recurse — just reads source gate .value directly.
* For COMPONENT gates, evaluates internal circuit and stores all outputs.
*/ */
function computeGate(gate) { function computeGate(gate) {
if (gate.type === 'INPUT' || gate.type === 'CLOCK') return gate.value; if (gate.type === 'INPUT' || gate.type === 'CLOCK') return gate.value;
@@ -79,7 +92,7 @@ function computeGate(gate) {
const conn = state.connections.find(c => c.to === gate.id && c.toPort === i); const conn = state.connections.find(c => c.to === gate.id && c.toPort === i);
if (conn) { if (conn) {
const srcGate = state.gates.find(g => g.id === conn.from); const srcGate = state.gates.find(g => g.id === conn.from);
inputs.push(srcGate ? (srcGate.value || 0) : 0); inputs.push(srcGate ? readSourcePort(srcGate, conn.fromPort) : 0);
} else { } else {
inputs.push(0); inputs.push(0);
} }
@@ -87,6 +100,8 @@ function computeGate(gate) {
if (gate.type.startsWith('COMPONENT:')) { if (gate.type.startsWith('COMPONENT:')) {
const outputs = evaluateComponent(gate, inputs); const outputs = evaluateComponent(gate, inputs);
// Store all output values for multi-output components
gate.outputValues = outputs;
return outputs[0] || 0; return outputs[0] || 0;
} }
@@ -115,14 +130,25 @@ export function evaluateAll(recordWave = false) {
let changed = false; let changed = false;
for (const gate of state.gates) { for (const gate of state.gates) {
if (gate.type === 'INPUT' || gate.type === 'CLOCK') continue; if (gate.type === 'INPUT' || gate.type === 'CLOCK') continue;
const oldVal = gate.value;
const oldOutputs = gate.outputValues ? [...gate.outputValues] : null;
const newVal = computeGate(gate); const newVal = computeGate(gate);
if (newVal !== gate.value) { if (newVal !== oldVal) {
gate.value = newVal; gate.value = newVal;
changed = true; changed = true;
} }
// Also check if outputValues changed (for multi-output components)
if (gate.outputValues && oldOutputs) {
for (let i = 0; i < gate.outputValues.length; i++) {
if (gate.outputValues[i] !== oldOutputs[i]) {
changed = true;
break;
}
}
}
} }
if (!changed) { if (!changed) {
console.log(`[eval] stable after ${iter + 1} iteration(s)`); if (iter > 0) console.log(`[eval] stable after ${iter + 1} iteration(s)`);
break; break;
} }
if (iter === MAX_ITERATIONS - 1) { if (iter === MAX_ITERATIONS - 1) {

View File

@@ -115,10 +115,11 @@ function drawGate(gate) {
state.hoveredPort.gate === gate && state.hoveredPort.gate === gate &&
state.hoveredPort.index === p.index && state.hoveredPort.index === p.index &&
state.hoveredPort.type === 'output'; state.hoveredPort.type === 'output';
const portVal = gate.outputValues ? (gate.outputValues[p.index] || 0) : gate.value;
ctx.beginPath(); ctx.beginPath();
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2); ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
ctx.fillStyle = isPortHovered ? '#fff' : (gate.value ? '#00ff88' : '#1a1a2e'); ctx.fillStyle = isPortHovered ? '#fff' : (portVal ? '#00ff88' : '#1a1a2e');
ctx.fill(); ctx.fill();
ctx.strokeStyle = isPortHovered ? '#fff' : '#555'; ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
@@ -185,10 +186,11 @@ function drawComponentGate(gate) {
state.hoveredPort.gate === gate && state.hoveredPort.gate === gate &&
state.hoveredPort.index === p.index && state.hoveredPort.index === p.index &&
state.hoveredPort.type === 'output'; state.hoveredPort.type === 'output';
const portVal = gate.outputValues ? (gate.outputValues[p.index] || 0) : gate.value;
ctx.beginPath(); ctx.beginPath();
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2); ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
ctx.fillStyle = isPortHovered ? '#fff' : (gate.value ? '#00ff88' : '#1a1a2e'); ctx.fillStyle = isPortHovered ? '#fff' : (portVal ? '#00ff88' : '#1a1a2e');
ctx.fill(); ctx.fill();
ctx.strokeStyle = isPortHovered ? '#fff' : '#555'; ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
@@ -205,7 +207,10 @@ function drawConnection(conn) {
const toPort = getInputPorts(toGate)[conn.toPort]; const toPort = getInputPorts(toGate)[conn.toPort];
if (!fromPort || !toPort) return; if (!fromPort || !toPort) return;
const active = fromGate.value === 1; // Read correct output port value for multi-output gates (components)
const active = fromGate.outputValues
? (fromGate.outputValues[conn.fromPort] || 0) === 1
: fromGate.value === 1;
const midX = (fromPort.x + toPort.x) / 2; const midX = (fromPort.x + toPort.x) / 2;
ctx.beginPath(); ctx.beginPath();