diff --git a/js/bus.js b/js/bus.js new file mode 100644 index 0000000..167fce5 --- /dev/null +++ b/js/bus.js @@ -0,0 +1,161 @@ +// Bus system — shift+drag to cut wires and create bus connectors +import { state } from './state.js'; +import { getOutputPorts, getInputPorts, getComponentWidth, getComponentHeight, evaluateAll } from './gates.js'; + +/** + * Sample a cubic bezier curve into discrete line segments. + * Returns array of {x, y} points. + */ +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. + * Returns { x, y, t } if they intersect, null otherwise. + * t is the parameter along the first segment (0..1). + */ +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 // position along the CUT line (for sorting) + }; + } + return null; +} + +/** + * Get the bezier control points for a connection, matching drawConnection in renderer.js. + */ +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 + ); + + // Test each bezier segment against the cut line + 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; // one hit per connection is enough + } + } + } + + // Sort by position along the cut line (t parameter) so bus ports are ordered visually + results.sort((a, b) => a.hitPoint.t - b.hitPoint.t); + return results; +} + +/** + * Create a BUS gate from a cut line and rewire intersecting connections through it. + */ +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 bus position: centroid of all 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; + + // Create BUS gate + const busType = `BUS:${n}`; + const busGate = { + id: state.nextId++, + type: busType, + x: avgX - 15, // half of bus width (30) + y: avgY - Math.max(40, (n + 1) * 22) / 2, + value: 0, + outputValues: new Array(n).fill(0) + }; + state.gates.push(busGate); + + // Rewire: for each intersected connection, split through the bus + hits.forEach((hit, i) => { + const origConn = hit.conn; + + // Remove the original connection + state.connections = state.connections.filter(c => c !== origConn); + + // Source → BUS input port i + state.connections.push({ + from: origConn.from, + fromPort: origConn.fromPort, + to: busGate.id, + toPort: i + }); + + // BUS output port i → original destination + state.connections.push({ + from: busGate.id, + fromPort: i, + to: origConn.to, + toPort: origConn.toPort + }); + }); + + console.log(`[bus] created BUS#${busGate.id} with ${n} channels`); + evaluateAll(); +} diff --git a/js/constants.js b/js/constants.js index dbbc6a4..3c4a2da 100644 --- a/js/constants.js +++ b/js/constants.js @@ -7,7 +7,8 @@ export const PORT_R = 7; export const GATE_COLORS = { AND: '#00e599', OR: '#3388ff', NOT: '#ff6644', NAND: '#e5cc00', NOR: '#cc44ff', XOR: '#ff44aa', - INPUT: '#3388ff', CLOCK: '#ff44aa', OUTPUT: '#ff8833' + INPUT: '#3388ff', CLOCK: '#ff44aa', OUTPUT: '#ff8833', + BUS: '#44ddff' }; export const SIGNAL_COLORS = [ diff --git a/js/events.js b/js/events.js index 14e507e..1d14c3b 100644 --- a/js/events.js +++ b/js/events.js @@ -10,6 +10,7 @@ import { getLevel } from './levels.js'; import { saveState, loadState, exportAsFile, importFromFile } from './saveLoad.js'; import { enterComponentEditor, editComponentBlueprint, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js'; import { getExampleList, loadExample } from './examples.js'; +import { createBusFromCut } from './bus.js'; const PAN_SPEED = 40; @@ -31,6 +32,14 @@ export function initEvents() { // Convert to world coords for gate/port detection 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; + } + state.hoveredPort = findPortAt(world.x, world.y); state.hoveredGate = state.hoveredPort ? state.hoveredPort.gate : findGateAt(world.x, world.y); @@ -65,6 +74,19 @@ export function initEvents() { dragStartPos = { x: e.offsetX, y: e.offsetY }; 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 if (state.placingGate) { let w = GATE_W, h = GATE_H; @@ -132,6 +154,13 @@ export function initEvents() { }); canvas.addEventListener('mouseup', e => { + // Finish bus cut + if (state.busCutting) { + createBusFromCut(); + state.busCutting = null; + return; + } + // Toggle INPUT/CLOCK only on click (no drag movement) if (state.dragging && !dragMoved) { const gate = state.dragging; diff --git a/js/gates.js b/js/gates.js index 3126a16..5a7b9ff 100644 --- a/js/gates.js +++ b/js/gates.js @@ -4,13 +4,14 @@ import { state } from './state.js'; import { recordSample, setEvaluateAll } from './waveform.js'; import { evaluateComponent } from './components.js'; -// Wrappers that handle component types +// Wrappers that handle component and BUS types export function gateInputCount(type) { if (type.startsWith('COMPONENT:')) { const componentId = type.substring(10); const component = state.customComponents?.[componentId]; return component ? component.inputCount : 0; } + if (type.startsWith('BUS:')) return parseInt(type.substring(4)) || 0; return baseGateInputCount(type); } @@ -20,10 +21,12 @@ export function gateOutputCount(type) { const component = state.customComponents?.[componentId]; return component ? component.outputCount : 0; } + if (type.startsWith('BUS:')) return parseInt(type.substring(4)) || 0; return baseGateOutputCount(type); } export function getComponentWidth(gate) { + if (gate.type.startsWith('BUS:')) return 30; if (gate.type.startsWith('COMPONENT:')) { const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1); return Math.max(120, (count + 1) * 25); @@ -32,6 +35,10 @@ export function getComponentWidth(gate) { } export function getComponentHeight(gate) { + if (gate.type.startsWith('BUS:')) { + const n = parseInt(gate.type.substring(4)) || 1; + return Math.max(40, (n + 1) * 22); + } if (gate.type.startsWith('COMPONENT:')) { const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1); return Math.max(60, (count + 1) * 25); @@ -42,8 +49,8 @@ export function getComponentHeight(gate) { export function getInputPorts(gate) { const count = gateInputCount(gate.type); const ports = []; - const isComponent = gate.type.startsWith('COMPONENT:'); - const gateHeight = isComponent ? getComponentHeight(gate) : GATE_H; + const isDynamic = gate.type.startsWith('COMPONENT:') || gate.type.startsWith('BUS:'); + const gateHeight = isDynamic ? getComponentHeight(gate) : GATE_H; for (let i = 0; i < count; i++) { const spacing = gateHeight / (count + 1); @@ -55,9 +62,9 @@ export function getInputPorts(gate) { export function getOutputPorts(gate) { const count = gateOutputCount(gate.type); const ports = []; - const isComponent = gate.type.startsWith('COMPONENT:'); - const gateWidth = isComponent ? getComponentWidth(gate) : GATE_W; - const gateHeight = isComponent ? getComponentHeight(gate) : GATE_H; + const isDynamic = gate.type.startsWith('COMPONENT:') || gate.type.startsWith('BUS:'); + const gateWidth = isDynamic ? getComponentWidth(gate) : GATE_W; + const gateHeight = isDynamic ? getComponentHeight(gate) : GATE_H; for (let i = 0; i < count; i++) { const spacing = gateHeight / (count + 1); @@ -105,6 +112,12 @@ function computeGate(gate) { return outputs[0] || 0; } + // BUS: pass-through, each input maps to corresponding output + if (gate.type.startsWith('BUS:')) { + gate.outputValues = [...inputs]; + return inputs[0] || 0; + } + switch (gate.type) { case 'AND': return (inputs[0] && inputs[1]) ? 1 : 0; case 'OR': return (inputs[0] || inputs[1]) ? 1 : 0; @@ -168,8 +181,9 @@ setEvaluateAll(evaluateAll); export function findGateAt(x, y) { return state.gates.find(g => { - const w = g.type.startsWith('COMPONENT:') ? getComponentWidth(g) : GATE_W; - const h = g.type.startsWith('COMPONENT:') ? getComponentHeight(g) : GATE_H; + const isDynamic = g.type.startsWith('COMPONENT:') || g.type.startsWith('BUS:'); + 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; }); } diff --git a/js/renderer.js b/js/renderer.js index b47189b..a0e0303 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -32,10 +32,9 @@ export function screenToWorld(sx, sy) { } function drawGate(gate) { - // Component gates have different rendering - if (gate.type.startsWith('COMPONENT:')) { - return drawComponentGate(gate); - } + // Special gate types have different rendering + if (gate.type.startsWith('BUS:')) return drawBusGate(gate); + if (gate.type.startsWith('COMPONENT:')) return drawComponentGate(gate); const color = GATE_COLORS[gate.type]; const isHovered = state.hoveredGate === gate; @@ -127,6 +126,80 @@ function drawGate(gate) { }); } +function drawBusGate(gate) { + const isHovered = state.hoveredGate === gate; + const w = getComponentWidth(gate); // 30 + const h = getComponentHeight(gate); + const color = '#44ddff'; + const n = parseInt(gate.type.substring(4)) || 1; + const hasActive = gate.outputValues?.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 + ctx.font = 'bold 9px monospace'; + ctx.fillStyle = color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(`${n}`, gate.x + w / 2, gate.y + h + 10); + + // Input ports (left) + 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 - 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(); + }); + + // Output ports (right) + 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 drawComponentGate(gate) { const isHovered = state.hoveredGate === gate; const isActive = gate.value === 1; @@ -305,6 +378,86 @@ function drawConnectingWire() { 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 drawPlacingGhost() { if (!state.placingGate) return; ctx.globalAlpha = 0.5; @@ -356,6 +509,7 @@ function draw() { state.connections.forEach(drawConnection); state.gates.forEach(drawGate); drawConnectingWire(); + drawBusCutLine(); drawPlacingGhost(); ctx.restore(); diff --git a/js/state.js b/js/state.js index c2ce050..7f56b58 100644 --- a/js/state.js +++ b/js/state.js @@ -42,5 +42,8 @@ export const state = { componentEditorActive: false, savedMainCircuit: null, // { gates, connections, nextId } saved before entering editor componentEditorName: '', - editingComponentId: null // ID of component being edited (null = new component) + 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 };