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>
285 lines
8.9 KiB
JavaScript
285 lines
8.9 KiB
JavaScript
// Custom components system — save and reuse circuits as components
|
|
import { state } from './state.js';
|
|
import { GATE_W, GATE_H } from './constants.js';
|
|
|
|
// Avoid circular imports - resize will be called from events.js
|
|
let resizeCallback = null;
|
|
export function setResizeCallback(fn) {
|
|
resizeCallback = fn;
|
|
}
|
|
|
|
/**
|
|
* Save current circuit as a reusable component
|
|
* Returns the component ID if successful
|
|
*/
|
|
export function saveComponentFromCircuit(name) {
|
|
// Validate inputs exist
|
|
const inputGates = state.gates.filter(g => g.type === 'INPUT');
|
|
const outputGates = state.gates.filter(g => g.type === 'OUTPUT');
|
|
|
|
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' };
|
|
}
|
|
|
|
// 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
|
|
const component = {
|
|
id: sanitizeComponentName(name),
|
|
name,
|
|
inputCount: inputGates.length,
|
|
outputCount: outputGates.length,
|
|
inputIds,
|
|
outputIds,
|
|
gates: JSON.parse(JSON.stringify(state.gates)),
|
|
connections: JSON.parse(JSON.stringify(state.connections))
|
|
};
|
|
|
|
// Store in state
|
|
if (!state.customComponents) {
|
|
state.customComponents = {};
|
|
}
|
|
state.customComponents[component.id] = component;
|
|
|
|
console.log(`[component] saved "${name}" (${component.inputCount} in, ${component.outputCount} out)`,
|
|
`inputIds=${inputIds}`, `outputIds=${outputIds}`);
|
|
|
|
return { success: true, component };
|
|
}
|
|
|
|
/**
|
|
* Evaluate a component instance.
|
|
* Simulates the internal circuit and returns an array of output values.
|
|
*/
|
|
export function evaluateComponent(gate, inputs) {
|
|
if (!gate.component) {
|
|
console.warn('[component] evaluateComponent called without component data', gate);
|
|
return [0];
|
|
}
|
|
|
|
const comp = gate.component;
|
|
|
|
// Deep clone internal circuit for simulation
|
|
const internalGates = JSON.parse(JSON.stringify(comp.gates));
|
|
const internalConns = comp.connections; // read-only, no need to clone
|
|
|
|
// Map external inputs to internal INPUT gates using stored inputIds
|
|
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];
|
|
}
|
|
}
|
|
|
|
// Iterative fixed-point evaluation (same approach as main evaluateAll)
|
|
const MAX_ITER = 20;
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Get input count for a gate type
|
|
*/
|
|
function getGateInputCount(type) {
|
|
if (type === 'CLOCK' || type === 'INPUT') return 0;
|
|
if (type === 'NOT' || type === 'OUTPUT') return 1;
|
|
return 2;
|
|
}
|
|
|
|
/**
|
|
* Sanitize component name for use as ID
|
|
*/
|
|
function sanitizeComponentName(name) {
|
|
return name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9_]/g, '_')
|
|
.replace(/_+/g, '_')
|
|
.replace(/^_|_$/g, '');
|
|
}
|
|
|
|
/**
|
|
* Get all custom components
|
|
*/
|
|
export function getAllComponents() {
|
|
return state.customComponents || {};
|
|
}
|
|
|
|
/**
|
|
* Delete a component
|
|
*/
|
|
export function deleteComponent(componentId) {
|
|
if (state.customComponents) {
|
|
delete state.customComponents[componentId];
|
|
return { success: true };
|
|
}
|
|
return { success: false, error: 'Component not found' };
|
|
}
|
|
|
|
/**
|
|
* Export component data as JSON
|
|
*/
|
|
export function exportComponent(componentId) {
|
|
if (!state.customComponents || !state.customComponents[componentId]) {
|
|
return { success: false, error: 'Component not found' };
|
|
}
|
|
return { success: true, data: state.customComponents[componentId] };
|
|
}
|
|
|
|
/**
|
|
* Import component from JSON
|
|
*/
|
|
export function importComponent(data) {
|
|
if (!data.id || !data.gates || !data.connections) {
|
|
return { success: false, error: 'Invalid component data' };
|
|
}
|
|
|
|
if (!state.customComponents) {
|
|
state.customComponents = {};
|
|
}
|
|
|
|
state.customComponents[data.id] = data;
|
|
return { success: true, component: data };
|
|
}
|
|
|
|
/**
|
|
* Enter component editor mode
|
|
*/
|
|
export function enterComponentEditor() {
|
|
// Save current main circuit
|
|
state.savedMainCircuit = {
|
|
gates: JSON.parse(JSON.stringify(state.gates)),
|
|
connections: JSON.parse(JSON.stringify(state.connections)),
|
|
nextId: state.nextId
|
|
};
|
|
|
|
// Clear canvas for sub-circuit design
|
|
state.gates = [];
|
|
state.connections = [];
|
|
state.nextId = 1;
|
|
state.componentEditorActive = true;
|
|
state.placingGate = null;
|
|
state.connecting = null;
|
|
|
|
// Show editor overlay
|
|
const overlay = document.getElementById('component-editor-overlay');
|
|
overlay.style.display = 'flex';
|
|
document.getElementById('component-editor-title').textContent = 'Editing Component: (New)';
|
|
|
|
// Resize canvas to account for editor bar
|
|
if (resizeCallback) resizeCallback();
|
|
}
|
|
|
|
/**
|
|
* Exit component editor mode
|
|
*/
|
|
export function exitComponentEditor(name, shouldSave) {
|
|
const overlay = document.getElementById('component-editor-overlay');
|
|
overlay.style.display = 'none';
|
|
|
|
if (shouldSave && name) {
|
|
// Save the component
|
|
saveComponentFromCircuit(name);
|
|
}
|
|
|
|
// Restore main circuit
|
|
if (state.savedMainCircuit) {
|
|
state.gates = state.savedMainCircuit.gates;
|
|
state.connections = state.savedMainCircuit.connections;
|
|
state.nextId = state.savedMainCircuit.nextId;
|
|
state.savedMainCircuit = null;
|
|
}
|
|
|
|
state.componentEditorActive = false;
|
|
state.placingGate = null;
|
|
|
|
// Update component buttons to show newly saved component
|
|
updateComponentButtons();
|
|
|
|
// Resize canvas via callback
|
|
if (resizeCallback) resizeCallback();
|
|
}
|
|
|
|
/**
|
|
* Update component buttons in toolbar
|
|
*/
|
|
export function updateComponentButtons() {
|
|
const container = document.getElementById('saved-components');
|
|
container.innerHTML = '';
|
|
|
|
const components = getAllComponents();
|
|
Object.values(components).forEach(comp => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'component-btn';
|
|
btn.dataset.componentId = comp.id;
|
|
btn.textContent = comp.name;
|
|
btn.title = `${comp.inputCount} input(s), ${comp.outputCount} output(s)`;
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
state.placingGate = `COMPONENT:${comp.id}`;
|
|
// Close dropdown
|
|
document.querySelectorAll('.toolbar-dropdown.open').forEach(d => d.classList.remove('open'));
|
|
});
|
|
container.appendChild(btn);
|
|
});
|
|
}
|