Compare commits
12 Commits
bc8823bcd4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbde11dfc7 | ||
|
|
a1cc631406 | ||
|
|
c116b6cf84 | ||
|
|
9ec3367253 | ||
|
|
12d7331d2c | ||
|
|
99f0fefe5c | ||
|
|
89d118f738 | ||
|
|
2fd22cc79d | ||
|
|
5bd157c059 | ||
|
|
eb22a5ff62 | ||
|
|
817dab43df | ||
|
|
1c45dc6104 |
12
js/app.js
12
js/app.js
@@ -2,9 +2,21 @@
|
|||||||
import { initRenderer } from './renderer.js';
|
import { initRenderer } from './renderer.js';
|
||||||
import { initEvents } from './events.js';
|
import { initEvents } from './events.js';
|
||||||
import { initPuzzleUI } from './puzzleUI.js';
|
import { initPuzzleUI } from './puzzleUI.js';
|
||||||
|
import { loadFromStorage, startAutoSave } from './saveLoad.js';
|
||||||
|
import { updateComponentButtons } from './components.js';
|
||||||
|
import { evaluateAll } from './gates.js';
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initRenderer();
|
initRenderer();
|
||||||
initEvents();
|
initEvents();
|
||||||
initPuzzleUI();
|
initPuzzleUI();
|
||||||
|
|
||||||
|
// Restore previous session from localStorage
|
||||||
|
if (loadFromStorage()) {
|
||||||
|
updateComponentButtons();
|
||||||
|
evaluateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-save every 3 seconds + on page unload
|
||||||
|
startAutoSave(3000);
|
||||||
});
|
});
|
||||||
|
|||||||
197
js/bus.js
Normal file
197
js/bus.js
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
// Bus system — shift+drag to cut wires and create paired bus terminals + cable
|
||||||
|
import { state } from './state.js';
|
||||||
|
import { getOutputPorts, getInputPorts, evaluateAll } from './gates.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample a cubic bezier curve into discrete line segments.
|
||||||
|
*/
|
||||||
|
function sampleBezier(p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y, steps = 24) {
|
||||||
|
const points = [];
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const t = i / steps;
|
||||||
|
const mt = 1 - t;
|
||||||
|
const mt2 = mt * mt, mt3 = mt2 * mt;
|
||||||
|
const t2 = t * t, t3 = t2 * t;
|
||||||
|
points.push({
|
||||||
|
x: mt3 * p0x + 3 * mt2 * t * p1x + 3 * mt * t2 * p2x + t3 * p3x,
|
||||||
|
y: mt3 * p0y + 3 * mt2 * t * p1y + 3 * mt * t2 * p2y + t3 * p3y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test intersection between two line segments.
|
||||||
|
*/
|
||||||
|
function segmentIntersect(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) {
|
||||||
|
const dx1 = ax2 - ax1, dy1 = ay2 - ay1;
|
||||||
|
const dx2 = bx2 - bx1, dy2 = by2 - by1;
|
||||||
|
const d = dx1 * dy2 - dy1 * dx2;
|
||||||
|
if (Math.abs(d) < 1e-10) return null;
|
||||||
|
|
||||||
|
const t = ((bx1 - ax1) * dy2 - (by1 - ay1) * dx2) / d;
|
||||||
|
const u = ((bx1 - ax1) * dy1 - (by1 - ay1) * dx1) / d;
|
||||||
|
|
||||||
|
if (t >= 0 && t <= 1 && u >= 0 && u <= 1) {
|
||||||
|
return { x: ax1 + t * dx1, y: ay1 + t * dy1, t };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bezier control points for a connection (matching renderer.js drawConnection).
|
||||||
|
*/
|
||||||
|
function getConnectionBezier(conn) {
|
||||||
|
const fromGate = state.gates.find(g => g.id === conn.from);
|
||||||
|
const toGate = state.gates.find(g => g.id === conn.to);
|
||||||
|
if (!fromGate || !toGate) return null;
|
||||||
|
|
||||||
|
const fromPort = getOutputPorts(fromGate)[conn.fromPort];
|
||||||
|
const toPort = getInputPorts(toGate)[conn.toPort];
|
||||||
|
if (!fromPort || !toPort) return null;
|
||||||
|
|
||||||
|
const midX = (fromPort.x + toPort.x) / 2;
|
||||||
|
return {
|
||||||
|
p0x: fromPort.x, p0y: fromPort.y,
|
||||||
|
p1x: midX, p1y: fromPort.y,
|
||||||
|
p2x: midX, p2y: toPort.y,
|
||||||
|
p3x: toPort.x, p3y: toPort.y
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all connections that intersect a cut line.
|
||||||
|
* Returns array of { conn, hitPoint } sorted by position along the cut line.
|
||||||
|
*/
|
||||||
|
export function findIntersectingConnections(startX, startY, endX, endY) {
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const conn of state.connections) {
|
||||||
|
const bez = getConnectionBezier(conn);
|
||||||
|
if (!bez) continue;
|
||||||
|
|
||||||
|
const points = sampleBezier(
|
||||||
|
bez.p0x, bez.p0y, bez.p1x, bez.p1y,
|
||||||
|
bez.p2x, bez.p2y, bez.p3x, bez.p3y
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
const hit = segmentIntersect(
|
||||||
|
startX, startY, endX, endY,
|
||||||
|
points[i].x, points[i].y, points[i + 1].x, points[i + 1].y
|
||||||
|
);
|
||||||
|
if (hit) {
|
||||||
|
results.push({ conn, hitPoint: hit });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort((a, b) => a.hitPoint.t - b.hitPoint.t);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create two paired BUS terminals from a cut line and rewire connections through them.
|
||||||
|
*
|
||||||
|
* Layout:
|
||||||
|
* Wire1 ──┐ ┌── Wire1
|
||||||
|
* Wire2 ──┤ ═══════ ├── Wire2
|
||||||
|
* Wire3 ──┘ └── Wire3
|
||||||
|
* Terminal IN Terminal OUT
|
||||||
|
*
|
||||||
|
* The thick cable between them is rendered by the renderer using busPairId.
|
||||||
|
*/
|
||||||
|
export function createBusFromCut() {
|
||||||
|
const cut = state.busCutting;
|
||||||
|
if (!cut) return;
|
||||||
|
|
||||||
|
const hits = findIntersectingConnections(cut.startX, cut.startY, cut.endX, cut.endY);
|
||||||
|
if (hits.length === 0) {
|
||||||
|
console.log('[bus] no connections intersected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = hits.length;
|
||||||
|
console.log(`[bus] cut intersected ${n} connection(s)`);
|
||||||
|
|
||||||
|
// Calculate position from hit points
|
||||||
|
const avgX = hits.reduce((s, h) => s + h.hitPoint.x, 0) / n;
|
||||||
|
const avgY = hits.reduce((s, h) => s + h.hitPoint.y, 0) / n;
|
||||||
|
const busH = Math.max(40, (n + 1) * 22);
|
||||||
|
const gap = 120; // horizontal distance between the two terminals
|
||||||
|
|
||||||
|
// Reserve IDs
|
||||||
|
const busInId = state.nextId++;
|
||||||
|
const busOutId = state.nextId++;
|
||||||
|
|
||||||
|
// Create BUS_IN terminal (left — collects wires into bus, only input pins)
|
||||||
|
const busIn = {
|
||||||
|
id: busInId,
|
||||||
|
type: `BUS_IN:${n}`,
|
||||||
|
x: avgX - gap / 2 - 15,
|
||||||
|
y: avgY - busH / 2,
|
||||||
|
value: 0,
|
||||||
|
busValues: new Array(n).fill(0),
|
||||||
|
busPairId: busOutId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create BUS_OUT terminal (right — distributes bus back to wires, only output pins)
|
||||||
|
const busOut = {
|
||||||
|
id: busOutId,
|
||||||
|
type: `BUS_OUT:${n}`,
|
||||||
|
x: avgX + gap / 2 - 15,
|
||||||
|
y: avgY - busH / 2,
|
||||||
|
value: 0,
|
||||||
|
outputValues: new Array(n).fill(0),
|
||||||
|
busPairId: busInId
|
||||||
|
};
|
||||||
|
|
||||||
|
state.gates.push(busIn, busOut);
|
||||||
|
|
||||||
|
// Rewire: source → BUS_IN input, BUS_OUT output → destination
|
||||||
|
// No internal connections — BUS_OUT reads from BUS_IN directly via busPairId
|
||||||
|
hits.forEach((hit, i) => {
|
||||||
|
const orig = hit.conn;
|
||||||
|
|
||||||
|
// Remove original connection
|
||||||
|
state.connections = state.connections.filter(c => c !== orig);
|
||||||
|
|
||||||
|
// Source → BUS_IN input[i]
|
||||||
|
state.connections.push({
|
||||||
|
from: orig.from,
|
||||||
|
fromPort: orig.fromPort,
|
||||||
|
to: busIn.id,
|
||||||
|
toPort: i
|
||||||
|
});
|
||||||
|
|
||||||
|
// BUS_OUT output[i] → original destination
|
||||||
|
state.connections.push({
|
||||||
|
from: busOut.id,
|
||||||
|
fromPort: i,
|
||||||
|
to: orig.to,
|
||||||
|
toPort: orig.toPort
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[bus] created BUS_IN#${busIn.id} ↔ BUS_OUT#${busOut.id} with ${n} channels`);
|
||||||
|
evaluateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all bus pairs for rendering the bus cables.
|
||||||
|
* Returns array of { inGate, outGate } for each pair.
|
||||||
|
*/
|
||||||
|
export function getBusPairs() {
|
||||||
|
const pairs = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const gate of state.gates) {
|
||||||
|
if (!gate.type.startsWith('BUS_IN:') || !gate.busPairId || seen.has(gate.id)) continue;
|
||||||
|
const outGate = state.gates.find(g => g.id === gate.busPairId);
|
||||||
|
if (!outGate || !outGate.type.startsWith('BUS_OUT:')) continue;
|
||||||
|
seen.add(gate.id);
|
||||||
|
seen.add(outGate.id);
|
||||||
|
pairs.push({ inGate: gate, outGate });
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
252
js/components.js
252
js/components.js
@@ -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,114 @@ 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) {
|
* IMPORTANT: Uses persistent internal state so latches/flip-flops retain
|
||||||
if (!state.customComponents || !state.customComponents[componentId]) {
|
* their values between evaluations (just like the main circuit).
|
||||||
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
|
// Persist internal gate state on the gate instance so latches hold their value
|
||||||
const inputGates = internalState.gates.filter(g => g.type === 'INPUT');
|
if (!gate._internalGates) {
|
||||||
inputs.forEach((val, i) => {
|
gate._internalGates = JSON.parse(JSON.stringify(comp.gates));
|
||||||
if (inputGates[i]) inputGates[i].value = val;
|
}
|
||||||
});
|
const internalGates = gate._internalGates;
|
||||||
|
const internalConns = comp.connections; // read-only, no need to clone
|
||||||
|
|
||||||
// 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}]`,
|
||||||
|
`(internal state preserved: ${gate._internalGates ? 'yes' : 'no'})`);
|
||||||
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
|
||||||
*/
|
*/
|
||||||
@@ -212,7 +208,7 @@ export function importComponent(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enter component editor mode
|
* Enter component editor mode (new component)
|
||||||
*/
|
*/
|
||||||
export function enterComponentEditor() {
|
export function enterComponentEditor() {
|
||||||
// Save current main circuit
|
// Save current main circuit
|
||||||
@@ -227,6 +223,7 @@ export function enterComponentEditor() {
|
|||||||
state.connections = [];
|
state.connections = [];
|
||||||
state.nextId = 1;
|
state.nextId = 1;
|
||||||
state.componentEditorActive = true;
|
state.componentEditorActive = true;
|
||||||
|
state.editingComponentId = null; // new component, not editing existing
|
||||||
state.placingGate = null;
|
state.placingGate = null;
|
||||||
state.connecting = null;
|
state.connecting = null;
|
||||||
|
|
||||||
@@ -239,6 +236,42 @@ export function enterComponentEditor() {
|
|||||||
if (resizeCallback) resizeCallback();
|
if (resizeCallback) resizeCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter component editor to edit an existing component's blueprint.
|
||||||
|
* Loads the component's internal circuit for modification.
|
||||||
|
*/
|
||||||
|
export function editComponentBlueprint(gate) {
|
||||||
|
if (!gate.component) return;
|
||||||
|
const comp = gate.component;
|
||||||
|
|
||||||
|
// Save current main circuit
|
||||||
|
state.savedMainCircuit = {
|
||||||
|
gates: JSON.parse(JSON.stringify(state.gates)),
|
||||||
|
connections: JSON.parse(JSON.stringify(state.connections)),
|
||||||
|
nextId: state.nextId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load the component's internal circuit into the canvas
|
||||||
|
state.gates = JSON.parse(JSON.stringify(comp.gates));
|
||||||
|
state.connections = JSON.parse(JSON.stringify(comp.connections));
|
||||||
|
// Set nextId to max existing id + 1 so new gates don't collide
|
||||||
|
state.nextId = state.gates.reduce((max, g) => Math.max(max, g.id), 0) + 1;
|
||||||
|
state.componentEditorActive = true;
|
||||||
|
state.editingComponentId = comp.id; // track which component we're editing
|
||||||
|
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: ${comp.name}`;
|
||||||
|
|
||||||
|
console.log(`[component] editing blueprint of "${comp.name}" (${comp.inputCount} in, ${comp.outputCount} out)`);
|
||||||
|
|
||||||
|
// Resize canvas to account for editor bar
|
||||||
|
if (resizeCallback) resizeCallback();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exit component editor mode
|
* Exit component editor mode
|
||||||
*/
|
*/
|
||||||
@@ -246,9 +279,29 @@ export function exitComponentEditor(name, shouldSave) {
|
|||||||
const overlay = document.getElementById('component-editor-overlay');
|
const overlay = document.getElementById('component-editor-overlay');
|
||||||
overlay.style.display = 'none';
|
overlay.style.display = 'none';
|
||||||
|
|
||||||
|
const editingId = state.editingComponentId;
|
||||||
|
|
||||||
if (shouldSave && name) {
|
if (shouldSave && name) {
|
||||||
// Save the component
|
// Save the component (works for both new and edited)
|
||||||
saveComponentFromCircuit(name);
|
const result = saveComponentFromCircuit(name);
|
||||||
|
|
||||||
|
// Update all placed instances of this component in the main circuit.
|
||||||
|
// Handles both: editing existing component (editingId matches) AND
|
||||||
|
// creating a "new" component that overwrites an existing one (same sanitized name).
|
||||||
|
if (result.success && state.savedMainCircuit) {
|
||||||
|
const updatedComp = state.customComponents[result.component.id];
|
||||||
|
if (updatedComp) {
|
||||||
|
const matchId = editingId || result.component.id;
|
||||||
|
for (const gate of state.savedMainCircuit.gates) {
|
||||||
|
if (gate.component && gate.component.id === matchId) {
|
||||||
|
gate.component = updatedComp;
|
||||||
|
// Clear persisted internal state so it re-initializes from updated blueprint
|
||||||
|
delete gate._internalGates;
|
||||||
|
console.log(`[component] updated instance #${gate.id} with new blueprint`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore main circuit
|
// Restore main circuit
|
||||||
@@ -260,6 +313,7 @@ export function exitComponentEditor(name, shouldSave) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.componentEditorActive = false;
|
state.componentEditorActive = false;
|
||||||
|
state.editingComponentId = null;
|
||||||
state.placingGate = null;
|
state.placingGate = null;
|
||||||
|
|
||||||
// Update component buttons to show newly saved component
|
// Update component buttons to show newly saved component
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export const PORT_R = 7;
|
|||||||
export const GATE_COLORS = {
|
export const GATE_COLORS = {
|
||||||
AND: '#00e599', OR: '#3388ff', NOT: '#ff6644',
|
AND: '#00e599', OR: '#3388ff', NOT: '#ff6644',
|
||||||
NAND: '#e5cc00', NOR: '#cc44ff', XOR: '#ff44aa',
|
NAND: '#e5cc00', NOR: '#cc44ff', XOR: '#ff44aa',
|
||||||
INPUT: '#3388ff', CLOCK: '#ff44aa', OUTPUT: '#ff8833'
|
INPUT: '#3388ff', CLOCK: '#ff44aa', OUTPUT: '#ff8833',
|
||||||
|
BUS: '#44ddff'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SIGNAL_COLORS = [
|
export const SIGNAL_COLORS = [
|
||||||
|
|||||||
165
js/events.js
165
js/events.js
@@ -8,8 +8,9 @@ import { resize, screenToWorld } from './renderer.js';
|
|||||||
import { puzzleMode, currentLevel, showLevelPanel } from './puzzleUI.js';
|
import { puzzleMode, currentLevel, showLevelPanel } from './puzzleUI.js';
|
||||||
import { getLevel } from './levels.js';
|
import { getLevel } from './levels.js';
|
||||||
import { saveState, loadState, exportAsFile, importFromFile } from './saveLoad.js';
|
import { saveState, loadState, exportAsFile, importFromFile } from './saveLoad.js';
|
||||||
import { enterComponentEditor, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js';
|
import { enterComponentEditor, editComponentBlueprint, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js';
|
||||||
import { getExampleList, loadExample } from './examples.js';
|
import { getExampleList, loadExample } from './examples.js';
|
||||||
|
import { createBusFromCut } from './bus.js';
|
||||||
|
|
||||||
const PAN_SPEED = 40;
|
const PAN_SPEED = 40;
|
||||||
|
|
||||||
@@ -31,6 +32,43 @@ export function initEvents() {
|
|||||||
|
|
||||||
// Convert to world coords for gate/port detection
|
// Convert to world coords for gate/port detection
|
||||||
const world = screenToWorld(e.offsetX, e.offsetY);
|
const world = screenToWorld(e.offsetX, e.offsetY);
|
||||||
|
|
||||||
|
// Update bus cut line endpoint
|
||||||
|
if (state.busCutting) {
|
||||||
|
state.busCutting.endX = world.x;
|
||||||
|
state.busCutting.endY = world.y;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update selection box
|
||||||
|
if (state.selectionBox) {
|
||||||
|
state.selectionBox.endX = world.x;
|
||||||
|
state.selectionBox.endY = world.y;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-drag selected gates
|
||||||
|
if (state.multiDrag) {
|
||||||
|
if (dragStartPos && !dragMoved) {
|
||||||
|
const dx = e.offsetX - dragStartPos.x;
|
||||||
|
const dy = e.offsetY - dragStartPos.y;
|
||||||
|
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) dragMoved = true;
|
||||||
|
}
|
||||||
|
if (dragMoved) {
|
||||||
|
const dx = world.x - state.multiDrag.startX;
|
||||||
|
const dy = world.y - state.multiDrag.startY;
|
||||||
|
for (const orig of state.multiDrag.origins) {
|
||||||
|
const gate = state.gates.find(g => g.id === orig.id);
|
||||||
|
if (gate) {
|
||||||
|
gate.x = orig.x + dx;
|
||||||
|
gate.y = orig.y + dy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
evaluateAll();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state.hoveredPort = findPortAt(world.x, world.y);
|
state.hoveredPort = findPortAt(world.x, world.y);
|
||||||
state.hoveredGate = state.hoveredPort ? state.hoveredPort.gate : findGateAt(world.x, world.y);
|
state.hoveredGate = state.hoveredPort ? state.hoveredPort.gate : findGateAt(world.x, world.y);
|
||||||
|
|
||||||
@@ -51,6 +89,7 @@ export function initEvents() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
canvas.style.cursor = state.placingGate ? 'crosshair'
|
canvas.style.cursor = state.placingGate ? 'crosshair'
|
||||||
|
: state.selectionBox ? 'crosshair'
|
||||||
: state.hoveredPort ? 'pointer'
|
: state.hoveredPort ? 'pointer'
|
||||||
: state.hoveredGate ? 'grab'
|
: state.hoveredGate ? 'grab'
|
||||||
: 'default';
|
: 'default';
|
||||||
@@ -65,6 +104,19 @@ export function initEvents() {
|
|||||||
dragStartPos = { x: e.offsetX, y: e.offsetY };
|
dragStartPos = { x: e.offsetX, y: e.offsetY };
|
||||||
dragMoved = false;
|
dragMoved = false;
|
||||||
|
|
||||||
|
// Shift+click on empty space → start bus cut
|
||||||
|
if (e.shiftKey && !state.placingGate) {
|
||||||
|
const port = findPortAt(world.x, world.y);
|
||||||
|
const gate = findGateAt(world.x, world.y);
|
||||||
|
if (!port && !gate) {
|
||||||
|
state.busCutting = {
|
||||||
|
startX: world.x, startY: world.y,
|
||||||
|
endX: world.x, endY: world.y
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Placing a new gate
|
// Placing a new gate
|
||||||
if (state.placingGate) {
|
if (state.placingGate) {
|
||||||
let w = GATE_W, h = GATE_H;
|
let w = GATE_W, h = GATE_H;
|
||||||
@@ -90,7 +142,7 @@ export function initEvents() {
|
|||||||
}
|
}
|
||||||
state.gates.push(newGate);
|
state.gates.push(newGate);
|
||||||
evaluateAll();
|
evaluateAll();
|
||||||
state.placingGate = null;
|
// Keep placingGate active so user can place multiple — right-click to cancel
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,13 +177,78 @@ export function initEvents() {
|
|||||||
// Drag any gate (including INPUT/CLOCK)
|
// Drag any gate (including INPUT/CLOCK)
|
||||||
const gate = findGateAt(world.x, world.y);
|
const gate = findGateAt(world.x, world.y);
|
||||||
if (gate) {
|
if (gate) {
|
||||||
|
// If clicking a selected gate → multi-drag all selected
|
||||||
|
if (state.selectedGates.includes(gate.id)) {
|
||||||
|
state.multiDrag = {
|
||||||
|
startX: world.x,
|
||||||
|
startY: world.y,
|
||||||
|
origins: state.selectedGates.map(id => {
|
||||||
|
const g = state.gates.find(g => g.id === id);
|
||||||
|
return g ? { id: g.id, x: g.x, y: g.y } : null;
|
||||||
|
}).filter(Boolean)
|
||||||
|
};
|
||||||
|
canvas.style.cursor = 'grabbing';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Clicking an unselected gate → clear selection, drag just this one
|
||||||
|
state.selectedGates = [];
|
||||||
state.dragging = gate;
|
state.dragging = gate;
|
||||||
state.dragOffset = { x: world.x - gate.x, y: world.y - gate.y };
|
state.dragOffset = { x: world.x - gate.x, y: world.y - gate.y };
|
||||||
canvas.style.cursor = 'grabbing';
|
canvas.style.cursor = 'grabbing';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Click on empty space → clear selection and start selection box
|
||||||
|
state.selectedGates = [];
|
||||||
|
state.selectionBox = {
|
||||||
|
startX: world.x, startY: world.y,
|
||||||
|
endX: world.x, endY: world.y
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
canvas.addEventListener('mouseup', e => {
|
canvas.addEventListener('mouseup', e => {
|
||||||
|
// Finish bus cut
|
||||||
|
if (state.busCutting) {
|
||||||
|
createBusFromCut();
|
||||||
|
state.busCutting = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish selection box → select gates inside
|
||||||
|
if (state.selectionBox) {
|
||||||
|
const box = state.selectionBox;
|
||||||
|
const x1 = Math.min(box.startX, box.endX);
|
||||||
|
const y1 = Math.min(box.startY, box.endY);
|
||||||
|
const x2 = Math.max(box.startX, box.endX);
|
||||||
|
const y2 = Math.max(box.startY, box.endY);
|
||||||
|
|
||||||
|
// Only select if box is big enough (not just a click)
|
||||||
|
if (Math.abs(x2 - x1) > 5 || Math.abs(y2 - y1) > 5) {
|
||||||
|
state.selectedGates = state.gates
|
||||||
|
.filter(g => {
|
||||||
|
const isDynamic = g.type.startsWith('COMPONENT:') || g.type.startsWith('BUS_IN:') || g.type.startsWith('BUS_OUT:');
|
||||||
|
const gw = isDynamic ? getComponentWidth(g) : GATE_W;
|
||||||
|
const gh = isDynamic ? getComponentHeight(g) : GATE_H;
|
||||||
|
// Gate overlaps selection box
|
||||||
|
return g.x + gw > x1 && g.x < x2 && g.y + gh > y1 && g.y < y2;
|
||||||
|
})
|
||||||
|
.map(g => g.id);
|
||||||
|
if (state.selectedGates.length > 0) {
|
||||||
|
console.log(`[select] ${state.selectedGates.length} gate(s) selected`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.selectionBox = null;
|
||||||
|
dragStartPos = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish multi-drag
|
||||||
|
if (state.multiDrag) {
|
||||||
|
state.multiDrag = null;
|
||||||
|
dragStartPos = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle INPUT/CLOCK only on click (no drag movement)
|
// Toggle INPUT/CLOCK only on click (no drag movement)
|
||||||
if (state.dragging && !dragMoved) {
|
if (state.dragging && !dragMoved) {
|
||||||
const gate = state.dragging;
|
const gate = state.dragging;
|
||||||
@@ -139,7 +256,6 @@ export function initEvents() {
|
|||||||
gate.value = gate.value ? 0 : 1;
|
gate.value = gate.value ? 0 : 1;
|
||||||
console.log(`[toggle] ${gate.type}#${gate.id} → ${gate.value}`);
|
console.log(`[toggle] ${gate.type}#${gate.id} → ${gate.value}`);
|
||||||
evaluateAll(true); // record waveform on intentional toggle
|
evaluateAll(true); // record waveform on intentional toggle
|
||||||
// Log all gate values after evaluation
|
|
||||||
console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', '));
|
console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', '));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,11 +263,20 @@ export function initEvents() {
|
|||||||
dragStartPos = null;
|
dragStartPos = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Double-click to rename INPUT/OUTPUT/CLOCK gates
|
// Double-click to rename INPUT/OUTPUT/CLOCK gates, or edit component blueprint
|
||||||
canvas.addEventListener('dblclick', e => {
|
canvas.addEventListener('dblclick', e => {
|
||||||
const world = screenToWorld(e.offsetX, e.offsetY);
|
const world = screenToWorld(e.offsetX, e.offsetY);
|
||||||
const gate = findGateAt(world.x, world.y);
|
const gate = findGateAt(world.x, world.y);
|
||||||
if (gate && (gate.type === 'INPUT' || gate.type === 'OUTPUT' || gate.type === 'CLOCK')) {
|
if (!gate) return;
|
||||||
|
|
||||||
|
// Double-click on component gate → edit its blueprint
|
||||||
|
if (gate.type.startsWith('COMPONENT:') && gate.component) {
|
||||||
|
editComponentBlueprint(gate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-click on I/O gates → rename (only inside component editor)
|
||||||
|
if (state.componentEditorActive && (gate.type === 'INPUT' || gate.type === 'OUTPUT' || gate.type === 'CLOCK')) {
|
||||||
const current = gate.label || '';
|
const current = gate.label || '';
|
||||||
const label = prompt(`Label for ${gate.type}#${gate.id}:`, current);
|
const label = prompt(`Label for ${gate.type}#${gate.id}:`, current);
|
||||||
if (label !== null) {
|
if (label !== null) {
|
||||||
@@ -163,6 +288,13 @@ export function initEvents() {
|
|||||||
|
|
||||||
canvas.addEventListener('contextmenu', e => {
|
canvas.addEventListener('contextmenu', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Right-click cancels placing mode
|
||||||
|
if (state.placingGate) {
|
||||||
|
state.placingGate = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const world = screenToWorld(e.offsetX, e.offsetY);
|
const world = screenToWorld(e.offsetX, e.offsetY);
|
||||||
const port = findPortAt(world.x, world.y);
|
const port = findPortAt(world.x, world.y);
|
||||||
if (port && port.type === 'input') {
|
if (port && port.type === 'input') {
|
||||||
@@ -196,8 +328,22 @@ export function initEvents() {
|
|||||||
keysDown.add(e.key);
|
keysDown.add(e.key);
|
||||||
|
|
||||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
if (state.hoveredGate && document.activeElement === document.body) {
|
if (document.activeElement !== document.body) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Delete all selected gates
|
||||||
|
if (state.selectedGates.length > 0) {
|
||||||
|
for (const gateId of state.selectedGates) {
|
||||||
|
state.connections = state.connections.filter(c => c.from !== gateId && c.to !== gateId);
|
||||||
|
state.gates = state.gates.filter(g => g.id !== gateId);
|
||||||
|
delete state.waveData[gateId];
|
||||||
|
}
|
||||||
|
console.log(`[delete] removed ${state.selectedGates.length} gate(s)`);
|
||||||
|
state.selectedGates = [];
|
||||||
|
state.hoveredGate = null;
|
||||||
|
evaluateAll();
|
||||||
|
} else if (state.hoveredGate) {
|
||||||
|
// Delete single hovered gate
|
||||||
const gateId = state.hoveredGate.id;
|
const gateId = state.hoveredGate.id;
|
||||||
state.connections = state.connections.filter(c => c.from !== gateId && c.to !== gateId);
|
state.connections = state.connections.filter(c => c.from !== gateId && c.to !== gateId);
|
||||||
state.gates = state.gates.filter(g => g.id !== gateId);
|
state.gates = state.gates.filter(g => g.id !== gateId);
|
||||||
@@ -209,6 +355,7 @@ export function initEvents() {
|
|||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
state.placingGate = null;
|
state.placingGate = null;
|
||||||
state.connecting = null;
|
state.connecting = null;
|
||||||
|
state.selectedGates = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pan with arrow keys
|
// Pan with arrow keys
|
||||||
@@ -415,7 +562,11 @@ export function initEvents() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('component-editor-save').addEventListener('click', () => {
|
document.getElementById('component-editor-save').addEventListener('click', () => {
|
||||||
const name = prompt('Component name:', 'MyComponent');
|
// If editing existing, pre-fill with current name
|
||||||
|
const existingName = state.editingComponentId
|
||||||
|
? (state.customComponents[state.editingComponentId]?.name || 'MyComponent')
|
||||||
|
: 'MyComponent';
|
||||||
|
const name = prompt('Component name:', existingName);
|
||||||
if (name && name.trim()) {
|
if (name && name.trim()) {
|
||||||
exitComponentEditor(name.trim(), true);
|
exitComponentEditor(name.trim(), true);
|
||||||
}
|
}
|
||||||
|
|||||||
85
js/gates.js
85
js/gates.js
@@ -4,13 +4,15 @@ import { state } from './state.js';
|
|||||||
import { recordSample, setEvaluateAll } from './waveform.js';
|
import { recordSample, setEvaluateAll } from './waveform.js';
|
||||||
import { evaluateComponent } from './components.js';
|
import { evaluateComponent } from './components.js';
|
||||||
|
|
||||||
// Wrappers that handle component types
|
// Wrappers that handle component and BUS types
|
||||||
export function gateInputCount(type) {
|
export function gateInputCount(type) {
|
||||||
if (type.startsWith('COMPONENT:')) {
|
if (type.startsWith('COMPONENT:')) {
|
||||||
const componentId = type.substring(10);
|
const componentId = type.substring(10);
|
||||||
const component = state.customComponents?.[componentId];
|
const component = state.customComponents?.[componentId];
|
||||||
return component ? component.inputCount : 0;
|
return component ? component.inputCount : 0;
|
||||||
}
|
}
|
||||||
|
if (type.startsWith('BUS_IN:')) return parseInt(type.substring(7)) || 0;
|
||||||
|
if (type.startsWith('BUS_OUT:')) return 0;
|
||||||
return baseGateInputCount(type);
|
return baseGateInputCount(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,10 +22,23 @@ export function gateOutputCount(type) {
|
|||||||
const component = state.customComponents?.[componentId];
|
const component = state.customComponents?.[componentId];
|
||||||
return component ? component.outputCount : 0;
|
return component ? component.outputCount : 0;
|
||||||
}
|
}
|
||||||
|
if (type.startsWith('BUS_IN:')) return 0;
|
||||||
|
if (type.startsWith('BUS_OUT:')) return parseInt(type.substring(8)) || 0;
|
||||||
return baseGateOutputCount(type);
|
return baseGateOutputCount(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBusType(type) {
|
||||||
|
return type.startsWith('BUS_IN:') || type.startsWith('BUS_OUT:');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBusSize(type) {
|
||||||
|
if (type.startsWith('BUS_IN:')) return parseInt(type.substring(7)) || 1;
|
||||||
|
if (type.startsWith('BUS_OUT:')) return parseInt(type.substring(8)) || 1;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
export function getComponentWidth(gate) {
|
export function getComponentWidth(gate) {
|
||||||
|
if (isBusType(gate.type)) return 30;
|
||||||
if (gate.type.startsWith('COMPONENT:')) {
|
if (gate.type.startsWith('COMPONENT:')) {
|
||||||
const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1);
|
const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1);
|
||||||
return Math.max(120, (count + 1) * 25);
|
return Math.max(120, (count + 1) * 25);
|
||||||
@@ -32,6 +47,10 @@ export function getComponentWidth(gate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getComponentHeight(gate) {
|
export function getComponentHeight(gate) {
|
||||||
|
if (isBusType(gate.type)) {
|
||||||
|
const n = getBusSize(gate.type);
|
||||||
|
return Math.max(40, (n + 1) * 22);
|
||||||
|
}
|
||||||
if (gate.type.startsWith('COMPONENT:')) {
|
if (gate.type.startsWith('COMPONENT:')) {
|
||||||
const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1);
|
const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1);
|
||||||
return Math.max(60, (count + 1) * 25);
|
return Math.max(60, (count + 1) * 25);
|
||||||
@@ -42,8 +61,8 @@ export function getComponentHeight(gate) {
|
|||||||
export function getInputPorts(gate) {
|
export function getInputPorts(gate) {
|
||||||
const count = gateInputCount(gate.type);
|
const count = gateInputCount(gate.type);
|
||||||
const ports = [];
|
const ports = [];
|
||||||
const isComponent = gate.type.startsWith('COMPONENT:');
|
const isDynamic = gate.type.startsWith('COMPONENT:') || isBusType(gate.type);
|
||||||
const gateHeight = isComponent ? getComponentHeight(gate) : GATE_H;
|
const gateHeight = isDynamic ? getComponentHeight(gate) : GATE_H;
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const spacing = gateHeight / (count + 1);
|
const spacing = gateHeight / (count + 1);
|
||||||
@@ -55,9 +74,9 @@ export function getInputPorts(gate) {
|
|||||||
export function getOutputPorts(gate) {
|
export function getOutputPorts(gate) {
|
||||||
const count = gateOutputCount(gate.type);
|
const count = gateOutputCount(gate.type);
|
||||||
const ports = [];
|
const ports = [];
|
||||||
const isComponent = gate.type.startsWith('COMPONENT:');
|
const isDynamic = gate.type.startsWith('COMPONENT:') || isBusType(gate.type);
|
||||||
const gateWidth = isComponent ? getComponentWidth(gate) : GATE_W;
|
const gateWidth = isDynamic ? getComponentWidth(gate) : GATE_W;
|
||||||
const gateHeight = isComponent ? getComponentHeight(gate) : GATE_H;
|
const gateHeight = isDynamic ? getComponentHeight(gate) : GATE_H;
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const spacing = gateHeight / (count + 1);
|
const spacing = gateHeight / (count + 1);
|
||||||
@@ -66,9 +85,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 +111,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,9 +119,28 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BUS_IN: collect input values and store them for the paired BUS_OUT
|
||||||
|
if (gate.type.startsWith('BUS_IN:')) {
|
||||||
|
gate.busValues = [...inputs];
|
||||||
|
gate.value = inputs[0] || 0;
|
||||||
|
return gate.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BUS_OUT: read values from paired BUS_IN terminal
|
||||||
|
if (gate.type.startsWith('BUS_OUT:')) {
|
||||||
|
const pair = state.gates.find(g => g.id === gate.busPairId);
|
||||||
|
if (pair && pair.busValues) {
|
||||||
|
gate.outputValues = [...pair.busValues];
|
||||||
|
gate.value = gate.outputValues[0] || 0;
|
||||||
|
}
|
||||||
|
return gate.value || 0;
|
||||||
|
}
|
||||||
|
|
||||||
switch (gate.type) {
|
switch (gate.type) {
|
||||||
case 'AND': return (inputs[0] && inputs[1]) ? 1 : 0;
|
case 'AND': return (inputs[0] && inputs[1]) ? 1 : 0;
|
||||||
case 'OR': return (inputs[0] || inputs[1]) ? 1 : 0;
|
case 'OR': return (inputs[0] || inputs[1]) ? 1 : 0;
|
||||||
@@ -115,14 +166,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) {
|
||||||
@@ -142,8 +204,9 @@ setEvaluateAll(evaluateAll);
|
|||||||
|
|
||||||
export function findGateAt(x, y) {
|
export function findGateAt(x, y) {
|
||||||
return state.gates.find(g => {
|
return state.gates.find(g => {
|
||||||
const w = g.type.startsWith('COMPONENT:') ? getComponentWidth(g) : GATE_W;
|
const isDynamic = g.type.startsWith('COMPONENT:') || isBusType(g.type);
|
||||||
const h = g.type.startsWith('COMPONENT:') ? getComponentHeight(g) : GATE_H;
|
const w = isDynamic ? getComponentWidth(g) : GATE_W;
|
||||||
|
const h = isDynamic ? getComponentHeight(g) : GATE_H;
|
||||||
return x >= g.x && x <= g.x + w && y >= g.y && y <= g.y + h;
|
return x >= g.x && x <= g.x + w && y >= g.y && y <= g.y + h;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
338
js/renderer.js
338
js/renderer.js
@@ -3,9 +3,24 @@ import { GATE_W, GATE_H, COMP_W, PORT_R, GATE_COLORS } from './constants.js';
|
|||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { getInputPorts, getOutputPorts, getComponentWidth, getComponentHeight } from './gates.js';
|
import { getInputPorts, getOutputPorts, getComponentWidth, getComponentHeight } from './gates.js';
|
||||||
import { getGateLabel, drawWaveLabels, drawWaveforms } from './waveform.js';
|
import { getGateLabel, drawWaveLabels, drawWaveforms } from './waveform.js';
|
||||||
|
import { getBusPairs } from './bus.js';
|
||||||
|
|
||||||
let canvas, ctx;
|
let canvas, ctx;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the value arriving at an input port by looking up the source gate/port.
|
||||||
|
* Handles multi-output sources (BUS_OUT, COMPONENT) via outputValues[].
|
||||||
|
*/
|
||||||
|
function getSourcePortValue(conn) {
|
||||||
|
if (!conn) return 0;
|
||||||
|
const srcGate = state.gates.find(g => g.id === conn.from);
|
||||||
|
if (!srcGate) return 0;
|
||||||
|
if (srcGate.outputValues && conn.fromPort < srcGate.outputValues.length) {
|
||||||
|
return srcGate.outputValues[conn.fromPort] || 0;
|
||||||
|
}
|
||||||
|
return srcGate.value || 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function initRenderer() {
|
export function initRenderer() {
|
||||||
canvas = document.getElementById('canvas');
|
canvas = document.getElementById('canvas');
|
||||||
ctx = canvas.getContext('2d');
|
ctx = canvas.getContext('2d');
|
||||||
@@ -31,11 +46,29 @@ export function screenToWorld(sx, sy) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBusType(type) {
|
||||||
|
return type.startsWith('BUS_IN:') || type.startsWith('BUS_OUT:');
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSelectionHighlight(gate) {
|
||||||
|
if (!state.selectedGates.includes(gate.id)) return;
|
||||||
|
const isDynamic = gate.type.startsWith('COMPONENT:') || isBusType(gate.type);
|
||||||
|
const w = isDynamic ? getComponentWidth(gate) : GATE_W;
|
||||||
|
const h = isDynamic ? getComponentHeight(gate) : GATE_H;
|
||||||
|
const pad = 4;
|
||||||
|
ctx.strokeStyle = '#44ddff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.setLineDash([5, 3]);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(gate.x - pad, gate.y - pad, w + pad * 2, h + pad * 2, 10);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
function drawGate(gate) {
|
function drawGate(gate) {
|
||||||
// Component gates have different rendering
|
// Special gate types have different rendering
|
||||||
if (gate.type.startsWith('COMPONENT:')) {
|
if (isBusType(gate.type)) { drawBusGate(gate); drawSelectionHighlight(gate); return; }
|
||||||
return drawComponentGate(gate);
|
if (gate.type.startsWith('COMPONENT:')) { drawComponentGate(gate); drawSelectionHighlight(gate); return; }
|
||||||
}
|
|
||||||
|
|
||||||
const color = GATE_COLORS[gate.type];
|
const color = GATE_COLORS[gate.type];
|
||||||
const isHovered = state.hoveredGate === gate;
|
const isHovered = state.hoveredGate === gate;
|
||||||
@@ -98,7 +131,7 @@ function drawGate(gate) {
|
|||||||
state.hoveredPort.index === p.index &&
|
state.hoveredPort.index === p.index &&
|
||||||
state.hoveredPort.type === 'input';
|
state.hoveredPort.type === 'input';
|
||||||
const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index);
|
const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index);
|
||||||
const portActive = conn ? state.gates.find(g => g.id === conn.from)?.value : 0;
|
const portActive = getSourcePortValue(conn);
|
||||||
|
|
||||||
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);
|
||||||
@@ -115,15 +148,159 @@ 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;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
drawSelectionHighlight(gate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBusGate(gate) {
|
||||||
|
const isHovered = state.hoveredGate === gate;
|
||||||
|
const w = getComponentWidth(gate); // 30
|
||||||
|
const h = getComponentHeight(gate);
|
||||||
|
const color = '#44ddff';
|
||||||
|
const isIn = gate.type.startsWith('BUS_IN:');
|
||||||
|
const n = isIn
|
||||||
|
? parseInt(gate.type.substring(7)) || 1
|
||||||
|
: parseInt(gate.type.substring(8)) || 1;
|
||||||
|
|
||||||
|
// Check if any channel is active
|
||||||
|
const values = isIn ? gate.busValues : gate.outputValues;
|
||||||
|
const hasActive = values?.some(v => v === 1);
|
||||||
|
|
||||||
|
if (hasActive) {
|
||||||
|
ctx.shadowColor = color;
|
||||||
|
ctx.shadowBlur = 12 * state.zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main bus bar
|
||||||
|
ctx.fillStyle = hasActive ? color + '22' : '#14141e';
|
||||||
|
ctx.strokeStyle = isHovered ? '#fff' : color;
|
||||||
|
ctx.lineWidth = isHovered ? 2.5 : 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(gate.x, gate.y, w, h, 4);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// Thick center line (bus bar visual)
|
||||||
|
ctx.strokeStyle = isHovered ? '#fff' : color;
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(gate.x + w / 2, gate.y + 6);
|
||||||
|
ctx.lineTo(gate.x + w / 2, gate.y + h - 6);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Bus size label + role indicator
|
||||||
|
ctx.font = 'bold 9px monospace';
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
const roleIcon = isIn ? '▶' : '◀';
|
||||||
|
ctx.fillText(`${roleIcon} ${n}`, gate.x + w / 2, gate.y + h + 10);
|
||||||
|
|
||||||
|
// BUS_IN: only input ports (left side)
|
||||||
|
if (isIn) {
|
||||||
|
getInputPorts(gate).forEach(p => {
|
||||||
|
const isPortHovered = state.hoveredPort &&
|
||||||
|
state.hoveredPort.gate === gate &&
|
||||||
|
state.hoveredPort.index === p.index &&
|
||||||
|
state.hoveredPort.type === 'input';
|
||||||
|
const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index);
|
||||||
|
const portActive = getSourcePortValue(conn);
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, PORT_R - 1, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = isPortHovered ? '#fff' : (portActive ? '#00ff88' : '#1a1a2e');
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// BUS_OUT: only output ports (right side)
|
||||||
|
if (!isIn) {
|
||||||
|
getOutputPorts(gate).forEach(p => {
|
||||||
|
const isPortHovered = state.hoveredPort &&
|
||||||
|
state.hoveredPort.gate === gate &&
|
||||||
|
state.hoveredPort.index === p.index &&
|
||||||
|
state.hoveredPort.type === 'output';
|
||||||
|
const portVal = gate.outputValues ? (gate.outputValues[p.index] || 0) : 0;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, PORT_R - 1, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = isPortHovered ? '#fff' : (portVal ? '#00ff88' : '#1a1a2e');
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBusCables() {
|
||||||
|
const pairs = getBusPairs();
|
||||||
|
for (const { inGate, outGate } of pairs) {
|
||||||
|
const inW = getComponentWidth(inGate);
|
||||||
|
const inH = getComponentHeight(inGate);
|
||||||
|
const outH = getComponentHeight(outGate);
|
||||||
|
|
||||||
|
// Cable runs from right edge of inGate to left edge of outGate
|
||||||
|
const x1 = inGate.x + inW;
|
||||||
|
const y1 = inGate.y + inH / 2;
|
||||||
|
const x2 = outGate.x;
|
||||||
|
const y2 = outGate.y + outH / 2;
|
||||||
|
|
||||||
|
// Check if any channel is active
|
||||||
|
const hasActive = inGate.busValues?.some(v => v === 1);
|
||||||
|
const n = parseInt(inGate.type.substring(7)) || 1;
|
||||||
|
|
||||||
|
// Outer thick cable (bus background)
|
||||||
|
const cableWidth = Math.max(6, n * 2.5);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x1, y1);
|
||||||
|
const midX = (x1 + x2) / 2;
|
||||||
|
ctx.bezierCurveTo(midX, y1, midX, y2, x2, y2);
|
||||||
|
ctx.strokeStyle = hasActive ? '#44ddff33' : '#44ddff11';
|
||||||
|
ctx.lineWidth = cableWidth + 4;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Inner cable
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x1, y1);
|
||||||
|
ctx.bezierCurveTo(midX, y1, midX, y2, x2, y2);
|
||||||
|
ctx.strokeStyle = hasActive ? '#44ddff' : '#44ddff66';
|
||||||
|
ctx.lineWidth = cableWidth;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Channel count label at midpoint
|
||||||
|
const labelX = (x1 + x2) / 2;
|
||||||
|
const labelY = (y1 + y2) / 2 - cableWidth / 2 - 6;
|
||||||
|
ctx.font = 'bold 10px monospace';
|
||||||
|
ctx.fillStyle = '#44ddff';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(`/${n}`, labelX, labelY);
|
||||||
|
|
||||||
|
// Draw small diagonal slash across cable (bus notation)
|
||||||
|
const slashX = labelX;
|
||||||
|
const slashY = (y1 + y2) / 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(slashX - 6, slashY + 6);
|
||||||
|
ctx.lineTo(slashX + 6, slashY - 6);
|
||||||
|
ctx.strokeStyle = '#44ddff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawComponentGate(gate) {
|
function drawComponentGate(gate) {
|
||||||
@@ -161,6 +338,25 @@ function drawComponentGate(gate) {
|
|||||||
ctx.fillStyle = '#444';
|
ctx.fillStyle = '#444';
|
||||||
ctx.fillText(getGateLabel(gate), gate.x + w / 2, gate.y + h - 6);
|
ctx.fillText(getGateLabel(gate), gate.x + w / 2, gate.y + h - 6);
|
||||||
|
|
||||||
|
// Get port labels from the authoritative component definition (customComponents)
|
||||||
|
// This must match the source used by gateOutputCount/gateInputCount for port counts
|
||||||
|
const compId = gate.type.substring(10);
|
||||||
|
const comp = state.customComponents?.[compId] || gate.component;
|
||||||
|
const inputLabels = [];
|
||||||
|
const outputLabels = [];
|
||||||
|
if (comp) {
|
||||||
|
const inputIds = comp.inputIds || [];
|
||||||
|
const outputIds = comp.outputIds || [];
|
||||||
|
for (const id of inputIds) {
|
||||||
|
const g = comp.gates.find(g => g.id === id);
|
||||||
|
inputLabels.push(g?.label || '');
|
||||||
|
}
|
||||||
|
for (const id of outputIds) {
|
||||||
|
const g = comp.gates.find(g => g.id === id);
|
||||||
|
outputLabels.push(g?.label || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Input ports
|
// Input ports
|
||||||
getInputPorts(gate).forEach(p => {
|
getInputPorts(gate).forEach(p => {
|
||||||
const isPortHovered = state.hoveredPort &&
|
const isPortHovered = state.hoveredPort &&
|
||||||
@@ -168,7 +364,7 @@ function drawComponentGate(gate) {
|
|||||||
state.hoveredPort.index === p.index &&
|
state.hoveredPort.index === p.index &&
|
||||||
state.hoveredPort.type === 'input';
|
state.hoveredPort.type === 'input';
|
||||||
const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index);
|
const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index);
|
||||||
const portActive = conn ? state.gates.find(g => g.id === conn.from)?.value : 0;
|
const portActive = getSourcePortValue(conn);
|
||||||
|
|
||||||
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);
|
||||||
@@ -177,6 +373,16 @@ function drawComponentGate(gate) {
|
|||||||
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Port label (inside the gate, to the right of the port)
|
||||||
|
const label = inputLabels[p.index];
|
||||||
|
if (label) {
|
||||||
|
ctx.font = '9px "Segoe UI", system-ui, sans-serif';
|
||||||
|
ctx.fillStyle = '#aaa';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(label, p.x + PORT_R + 4, p.y);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Output ports
|
// Output ports
|
||||||
@@ -185,14 +391,25 @@ 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;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Port label (inside the gate, to the left of the port)
|
||||||
|
const label = outputLabels[p.index];
|
||||||
|
if (label) {
|
||||||
|
ctx.font = '9px "Segoe UI", system-ui, sans-serif';
|
||||||
|
ctx.fillStyle = '#aaa';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(label, p.x - PORT_R - 4, p.y);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +422,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();
|
||||||
@@ -263,6 +483,103 @@ function drawConnectingWire() {
|
|||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawBusCutLine() {
|
||||||
|
if (!state.busCutting) return;
|
||||||
|
const cut = state.busCutting;
|
||||||
|
|
||||||
|
// Dashed cyan line showing the cut
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cut.startX, cut.startY);
|
||||||
|
ctx.lineTo(cut.endX, cut.endY);
|
||||||
|
ctx.strokeStyle = '#44ddff';
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.setLineDash([8, 5]);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// Small circles at endpoints
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cut.startX, cut.startY, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#44ddff';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cut.endX, cut.endY, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Highlight intersecting wires
|
||||||
|
// Import is circular so we compute inline: sample bezier + test intersection
|
||||||
|
const hits = countCutIntersections(cut);
|
||||||
|
if (hits > 0) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.resetTransform();
|
||||||
|
ctx.fillStyle = '#44ddff';
|
||||||
|
ctx.font = 'bold 12px "Segoe UI", system-ui, sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(`✂ ${hits} wire${hits > 1 ? 's' : ''}`, 10, canvas.height - 10);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick inline intersection count for preview (avoids circular import from bus.js)
|
||||||
|
*/
|
||||||
|
function countCutIntersections(cut) {
|
||||||
|
let count = 0;
|
||||||
|
for (const conn of state.connections) {
|
||||||
|
const fromGate = state.gates.find(g => g.id === conn.from);
|
||||||
|
const toGate = state.gates.find(g => g.id === conn.to);
|
||||||
|
if (!fromGate || !toGate) continue;
|
||||||
|
const fp = getOutputPorts(fromGate)[conn.fromPort];
|
||||||
|
const tp = getInputPorts(toGate)[conn.toPort];
|
||||||
|
if (!fp || !tp) continue;
|
||||||
|
|
||||||
|
const midX = (fp.x + tp.x) / 2;
|
||||||
|
// Sample bezier at 16 points
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
const t1 = i / 16, t2 = (i + 1) / 16;
|
||||||
|
const bx1 = bezAt(fp.x, midX, midX, tp.x, t1);
|
||||||
|
const by1 = bezAt(fp.y, fp.y, tp.y, tp.y, t1);
|
||||||
|
const bx2 = bezAt(fp.x, midX, midX, tp.x, t2);
|
||||||
|
const by2 = bezAt(fp.y, fp.y, tp.y, tp.y, t2);
|
||||||
|
if (segsHit(cut.startX, cut.startY, cut.endX, cut.endY, bx1, by1, bx2, by2)) {
|
||||||
|
count++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bezAt(p0, p1, p2, p3, t) {
|
||||||
|
const mt = 1 - t;
|
||||||
|
return mt * mt * mt * p0 + 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t * p3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function segsHit(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) {
|
||||||
|
const d = (ax2 - ax1) * (by2 - by1) - (ay2 - ay1) * (bx2 - bx1);
|
||||||
|
if (Math.abs(d) < 1e-10) return false;
|
||||||
|
const t = ((bx1 - ax1) * (by2 - by1) - (by1 - ay1) * (bx2 - bx1)) / d;
|
||||||
|
const u = ((bx1 - ax1) * (ay2 - ay1) - (by1 - ay1) * (ax2 - ax1)) / d;
|
||||||
|
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSelectionBox() {
|
||||||
|
if (!state.selectionBox) return;
|
||||||
|
const box = state.selectionBox;
|
||||||
|
const x = Math.min(box.startX, box.endX);
|
||||||
|
const y = Math.min(box.startY, box.endY);
|
||||||
|
const w = Math.abs(box.endX - box.startX);
|
||||||
|
const h = Math.abs(box.endY - box.startY);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#44ddff0a';
|
||||||
|
ctx.fillRect(x, y, w, h);
|
||||||
|
ctx.strokeStyle = '#44ddff88';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.setLineDash([6, 3]);
|
||||||
|
ctx.strokeRect(x, y, w, h);
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
function drawPlacingGhost() {
|
function drawPlacingGhost() {
|
||||||
if (!state.placingGate) return;
|
if (!state.placingGate) return;
|
||||||
ctx.globalAlpha = 0.5;
|
ctx.globalAlpha = 0.5;
|
||||||
@@ -312,8 +629,11 @@ function draw() {
|
|||||||
|
|
||||||
drawGrid();
|
drawGrid();
|
||||||
state.connections.forEach(drawConnection);
|
state.connections.forEach(drawConnection);
|
||||||
|
drawBusCables();
|
||||||
state.gates.forEach(drawGate);
|
state.gates.forEach(drawGate);
|
||||||
drawConnectingWire();
|
drawConnectingWire();
|
||||||
|
drawBusCutLine();
|
||||||
|
drawSelectionBox();
|
||||||
drawPlacingGhost();
|
drawPlacingGhost();
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { progress } from './levels.js';
|
import { progress } from './levels.js';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'logiclab_state';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save complete application state to JSON
|
* Save complete application state to JSON
|
||||||
*/
|
*/
|
||||||
@@ -54,6 +56,16 @@ export function loadState(data) {
|
|||||||
state.customComponents = JSON.parse(JSON.stringify(data.components));
|
state.customComponents = JSON.parse(JSON.stringify(data.components));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-link gate.component references to customComponents (authoritative source)
|
||||||
|
for (const gate of state.gates) {
|
||||||
|
if (gate.type.startsWith('COMPONENT:')) {
|
||||||
|
const compId = gate.type.substring(10);
|
||||||
|
if (state.customComponents[compId]) {
|
||||||
|
gate.component = state.customComponents[compId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load progress
|
// Load progress
|
||||||
if (data.progress) {
|
if (data.progress) {
|
||||||
progress.unlockedLevels = data.progress.unlockedLevels || ['buffer'];
|
progress.unlockedLevels = data.progress.unlockedLevels || ['buffer'];
|
||||||
@@ -147,3 +159,46 @@ export async function pasteFromClipboard() {
|
|||||||
return { success: false, error: e.message };
|
return { success: false, error: e.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save state to localStorage
|
||||||
|
*/
|
||||||
|
export function saveToStorage() {
|
||||||
|
try {
|
||||||
|
const data = saveState();
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[storage] failed to save:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load state from localStorage (returns true if state was restored)
|
||||||
|
*/
|
||||||
|
export function loadFromStorage() {
|
||||||
|
try {
|
||||||
|
const json = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!json) return false;
|
||||||
|
const data = JSON.parse(json);
|
||||||
|
const result = loadState(data);
|
||||||
|
if (result.success) {
|
||||||
|
console.log('[storage] restored state from localStorage');
|
||||||
|
}
|
||||||
|
return result.success;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[storage] failed to load:', e.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start auto-saving to localStorage on an interval
|
||||||
|
*/
|
||||||
|
let autoSaveInterval = null;
|
||||||
|
export function startAutoSave(intervalMs = 3000) {
|
||||||
|
if (autoSaveInterval) clearInterval(autoSaveInterval);
|
||||||
|
autoSaveInterval = setInterval(saveToStorage, intervalMs);
|
||||||
|
// Also save on page unload
|
||||||
|
window.addEventListener('beforeunload', saveToStorage);
|
||||||
|
console.log(`[storage] auto-save enabled (every ${intervalMs}ms)`);
|
||||||
|
}
|
||||||
|
|||||||
11
js/state.js
11
js/state.js
@@ -41,5 +41,14 @@ export const state = {
|
|||||||
// Component Editor
|
// Component Editor
|
||||||
componentEditorActive: false,
|
componentEditorActive: false,
|
||||||
savedMainCircuit: null, // { gates, connections, nextId } saved before entering editor
|
savedMainCircuit: null, // { gates, connections, nextId } saved before entering editor
|
||||||
componentEditorName: ''
|
componentEditorName: '',
|
||||||
|
editingComponentId: null, // ID of component being edited (null = new component)
|
||||||
|
|
||||||
|
// Bus cutting (shift+drag)
|
||||||
|
busCutting: null, // { startX, startY, endX, endY } in world coords, or null
|
||||||
|
|
||||||
|
// Multi-selection
|
||||||
|
selectedGates: [], // array of gate IDs currently selected
|
||||||
|
selectionBox: null, // { startX, startY, endX, endY } in world coords while dragging
|
||||||
|
multiDrag: null // { startX, startY, origins: [{id, x, y}] } while dragging selected gates
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user