The label lookup in drawComponentGate read from gate.component (potentially stale copy) while gateOutputCount read from state.customComponents (updated definition), causing a mismatch — fewer ports but old outputIds, so the first (deleted) output's label was shown instead of the surviving one. Three fixes: - renderer: use customComponents as authoritative source for label lookup - saveLoad: re-link gate.component refs to customComponents after loading - components: update existing instances even when a "new" component overwrites an existing definition with the same name Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
638 lines
21 KiB
JavaScript
638 lines
21 KiB
JavaScript
// Canvas rendering — gates, connections, grid
|
|
import { GATE_W, GATE_H, COMP_W, PORT_R, GATE_COLORS } from './constants.js';
|
|
import { state } from './state.js';
|
|
import { getInputPorts, getOutputPorts, getComponentWidth, getComponentHeight } from './gates.js';
|
|
import { getGateLabel, drawWaveLabels, drawWaveforms } from './waveform.js';
|
|
import { getBusPairs } from './bus.js';
|
|
|
|
let canvas, ctx;
|
|
|
|
export function initRenderer() {
|
|
canvas = document.getElementById('canvas');
|
|
ctx = canvas.getContext('2d');
|
|
resize();
|
|
window.addEventListener('resize', resize);
|
|
requestAnimationFrame(draw);
|
|
}
|
|
|
|
export function resize() {
|
|
const sidebarOpen = document.body.classList.contains('puzzle-sidebar-open');
|
|
const sidebarW = sidebarOpen ? 340 : 0;
|
|
canvas.width = window.innerWidth - sidebarW;
|
|
const waveH = state.waveformVisible ? state.waveformHeight : 0;
|
|
const editorH = state.componentEditorActive ? 44 : 0;
|
|
canvas.height = window.innerHeight - 56 - editorH - waveH;
|
|
}
|
|
|
|
// Convert screen coords to world coords (accounting for pan/zoom)
|
|
export function screenToWorld(sx, sy) {
|
|
return {
|
|
x: (sx - state.camX) / state.zoom,
|
|
y: (sy - state.camY) / state.zoom
|
|
};
|
|
}
|
|
|
|
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) {
|
|
// Special gate types have different rendering
|
|
if (isBusType(gate.type)) { drawBusGate(gate); drawSelectionHighlight(gate); return; }
|
|
if (gate.type.startsWith('COMPONENT:')) { drawComponentGate(gate); drawSelectionHighlight(gate); return; }
|
|
|
|
const color = GATE_COLORS[gate.type];
|
|
const isHovered = state.hoveredGate === gate;
|
|
const isActive = gate.value === 1;
|
|
|
|
if (isActive) {
|
|
ctx.shadowColor = color;
|
|
ctx.shadowBlur = 20 * state.zoom;
|
|
}
|
|
|
|
ctx.fillStyle = isActive ? color + '22' : '#14141e';
|
|
ctx.strokeStyle = isHovered ? '#fff' : color;
|
|
ctx.lineWidth = (isHovered ? 2.5 : 1.5);
|
|
|
|
ctx.beginPath();
|
|
ctx.roundRect(gate.x, gate.y, GATE_W, GATE_H, 8);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.shadowBlur = 0;
|
|
|
|
// Label
|
|
ctx.fillStyle = isActive ? '#fff' : color;
|
|
ctx.font = `bold 14px "Segoe UI", system-ui, sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
const isIOType = gate.type === 'INPUT' || gate.type === 'OUTPUT' || gate.type === 'CLOCK';
|
|
|
|
// Show custom label above the gate if it has one
|
|
if (gate.label && isIOType) {
|
|
ctx.font = 'bold 10px "Segoe UI", system-ui, sans-serif';
|
|
ctx.fillStyle = '#888';
|
|
ctx.fillText(gate.label, gate.x + GATE_W / 2, gate.y - 8);
|
|
}
|
|
|
|
ctx.font = `bold 14px "Segoe UI", system-ui, sans-serif`;
|
|
ctx.fillStyle = isActive ? '#fff' : color;
|
|
ctx.fillText(
|
|
gate.label && isIOType ? gate.label : (gate.type === 'CLOCK' ? '⏱ CLK' : gate.type),
|
|
gate.x + GATE_W / 2,
|
|
gate.y + GATE_H / 2 - (isIOType ? 8 : 0)
|
|
);
|
|
|
|
// Small ID label
|
|
ctx.font = '9px monospace';
|
|
ctx.fillStyle = '#444';
|
|
ctx.fillText(getGateLabel(gate), gate.x + GATE_W / 2, gate.y + GATE_H - 6);
|
|
|
|
// Value for INPUT/OUTPUT/CLOCK
|
|
if (isIOType) {
|
|
ctx.font = 'bold 16px monospace';
|
|
ctx.fillStyle = gate.value ? '#00ff88' : '#ff4444';
|
|
ctx.fillText(gate.value ? '1' : '0', gate.x + GATE_W / 2, gate.y + GATE_H / 2 + 10);
|
|
}
|
|
|
|
// Input ports
|
|
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 = conn ? state.gates.find(g => g.id === conn.from)?.value : 0;
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
|
|
ctx.fillStyle = isPortHovered ? '#fff' : (portActive ? '#00ff88' : '#1a1a2e');
|
|
ctx.fill();
|
|
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.stroke();
|
|
});
|
|
|
|
// Output ports
|
|
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) : gate.value;
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
|
|
ctx.fillStyle = isPortHovered ? '#fff' : (portVal ? '#00ff88' : '#1a1a2e');
|
|
ctx.fill();
|
|
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
|
ctx.lineWidth = 1.5;
|
|
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 srcGate = conn ? state.gates.find(g => g.id === conn.from) : null;
|
|
const portActive = srcGate ? (srcGate.outputValues ? (srcGate.outputValues[conn.fromPort] || 0) : srcGate.value) : 0;
|
|
|
|
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) {
|
|
const isHovered = state.hoveredGate === gate;
|
|
const isActive = gate.value === 1;
|
|
const w = getComponentWidth(gate);
|
|
const h = getComponentHeight(gate);
|
|
const color = '#9900ff';
|
|
|
|
if (isActive) {
|
|
ctx.shadowColor = color;
|
|
ctx.shadowBlur = 20 * state.zoom;
|
|
}
|
|
|
|
ctx.fillStyle = isActive ? 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, 8);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.shadowBlur = 0;
|
|
|
|
// Component name label
|
|
ctx.fillStyle = isActive ? '#fff' : color;
|
|
ctx.font = `bold 12px "Segoe UI", system-ui, sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
const componentName = gate.component?.name || 'Component';
|
|
ctx.fillText(componentName, gate.x + w / 2, gate.y + h / 2);
|
|
|
|
// Small ID label
|
|
ctx.font = '9px monospace';
|
|
ctx.fillStyle = '#444';
|
|
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
|
|
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 = conn ? state.gates.find(g => g.id === conn.from)?.value : 0;
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
|
|
ctx.fillStyle = isPortHovered ? '#fff' : (portActive ? '#00ff88' : '#1a1a2e');
|
|
ctx.fill();
|
|
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
|
ctx.lineWidth = 1.5;
|
|
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
|
|
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) : gate.value;
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
|
|
ctx.fillStyle = isPortHovered ? '#fff' : (portVal ? '#00ff88' : '#1a1a2e');
|
|
ctx.fill();
|
|
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
|
ctx.lineWidth = 1.5;
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
function drawConnection(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;
|
|
|
|
const fromPort = getOutputPorts(fromGate)[conn.fromPort];
|
|
const toPort = getInputPorts(toGate)[conn.toPort];
|
|
if (!fromPort || !toPort) return;
|
|
|
|
// 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;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(fromPort.x, fromPort.y);
|
|
ctx.bezierCurveTo(midX, fromPort.y, midX, toPort.y, toPort.x, toPort.y);
|
|
ctx.strokeStyle = active ? '#00ff88' : '#333';
|
|
ctx.lineWidth = active ? 2.5 : 1.5;
|
|
ctx.stroke();
|
|
|
|
if (active) {
|
|
ctx.strokeStyle = '#00ff8844';
|
|
ctx.lineWidth = 6;
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
function drawGrid() {
|
|
const gridSize = 40;
|
|
ctx.strokeStyle = '#111118';
|
|
ctx.lineWidth = 1;
|
|
|
|
// Calculate visible grid range in world coords
|
|
const topLeft = screenToWorld(0, 0);
|
|
const bottomRight = screenToWorld(canvas.width, canvas.height);
|
|
|
|
const startX = Math.floor(topLeft.x / gridSize) * gridSize;
|
|
const startY = Math.floor(topLeft.y / gridSize) * gridSize;
|
|
|
|
for (let x = startX; x < bottomRight.x; x += gridSize) {
|
|
ctx.beginPath(); ctx.moveTo(x, topLeft.y); ctx.lineTo(x, bottomRight.y); ctx.stroke();
|
|
}
|
|
for (let y = startY; y < bottomRight.y; y += gridSize) {
|
|
ctx.beginPath(); ctx.moveTo(topLeft.x, y); ctx.lineTo(bottomRight.x, y); ctx.stroke();
|
|
}
|
|
}
|
|
|
|
function drawConnectingWire() {
|
|
if (!state.connecting) return;
|
|
const gate = state.connecting.gate;
|
|
const port = state.connecting.portType === 'output'
|
|
? getOutputPorts(gate)[state.connecting.portIndex]
|
|
: getInputPorts(gate)[state.connecting.portIndex];
|
|
if (!port) return;
|
|
|
|
// Convert mouse to world coords for the wire endpoint
|
|
const world = screenToWorld(state.mouseX, state.mouseY);
|
|
const midX = (port.x + world.x) / 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(port.x, port.y);
|
|
ctx.bezierCurveTo(midX, port.y, midX, world.y, world.x, world.y);
|
|
ctx.strokeStyle = '#00e59988';
|
|
ctx.lineWidth = 2;
|
|
ctx.setLineDash([6, 4]);
|
|
ctx.stroke();
|
|
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() {
|
|
if (!state.placingGate) return;
|
|
ctx.globalAlpha = 0.5;
|
|
const world = screenToWorld(state.mouseX, state.mouseY);
|
|
|
|
let w = GATE_W, h = GATE_H;
|
|
if (state.placingGate.startsWith('COMPONENT:')) {
|
|
const componentId = state.placingGate.substring(10);
|
|
const component = state.customComponents?.[componentId];
|
|
if (component) {
|
|
const count = Math.max(component.inputCount, component.outputCount);
|
|
w = 120;
|
|
h = Math.max(60, (count + 1) * 25);
|
|
}
|
|
}
|
|
|
|
const ghost = {
|
|
x: world.x - w / 2,
|
|
y: world.y - h / 2,
|
|
type: state.placingGate,
|
|
value: 0,
|
|
id: -1,
|
|
component: state.customComponents?.[state.placingGate.substring(10)]
|
|
};
|
|
drawGate(ghost);
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
function drawZoomIndicator() {
|
|
const pct = Math.round(state.zoom * 100);
|
|
ctx.save();
|
|
ctx.resetTransform();
|
|
ctx.fillStyle = '#333';
|
|
ctx.font = '11px monospace';
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText(`${pct}%`, canvas.width - 10, canvas.height - 10);
|
|
ctx.restore();
|
|
}
|
|
|
|
function draw() {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Apply camera transform
|
|
ctx.save();
|
|
ctx.translate(state.camX, state.camY);
|
|
ctx.scale(state.zoom, state.zoom);
|
|
|
|
drawGrid();
|
|
state.connections.forEach(drawConnection);
|
|
drawBusCables();
|
|
state.gates.forEach(drawGate);
|
|
drawConnectingWire();
|
|
drawBusCutLine();
|
|
drawSelectionBox();
|
|
drawPlacingGhost();
|
|
|
|
ctx.restore();
|
|
|
|
// HUD (drawn without transform)
|
|
drawZoomIndicator();
|
|
|
|
if (state.waveformVisible) {
|
|
drawWaveLabels();
|
|
drawWaveforms();
|
|
}
|
|
|
|
requestAnimationFrame(draw);
|
|
}
|