feat: shift+drag to cut wires and create bus connectors
Hold Shift and drag across wires to create a BUS gate that groups them together. The cut line shows a live preview with wire count. BUS gates are pass-through (each input maps to its output) and render as a thin cyan bar with ports on each side. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
161
js/bus.js
Normal file
161
js/bus.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
@@ -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 = [
|
||||||
|
|||||||
29
js/events.js
29
js/events.js
@@ -10,6 +10,7 @@ import { getLevel } from './levels.js';
|
|||||||
import { saveState, loadState, exportAsFile, importFromFile } from './saveLoad.js';
|
import { saveState, loadState, exportAsFile, importFromFile } from './saveLoad.js';
|
||||||
import { enterComponentEditor, editComponentBlueprint, 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,14 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -65,6 +74,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;
|
||||||
@@ -132,6 +154,13 @@ export function initEvents() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
canvas.addEventListener('mouseup', e => {
|
canvas.addEventListener('mouseup', e => {
|
||||||
|
// Finish bus cut
|
||||||
|
if (state.busCutting) {
|
||||||
|
createBusFromCut();
|
||||||
|
state.busCutting = 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;
|
||||||
|
|||||||
30
js/gates.js
30
js/gates.js
@@ -4,13 +4,14 @@ 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:')) return parseInt(type.substring(4)) || 0;
|
||||||
return baseGateInputCount(type);
|
return baseGateInputCount(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,10 +21,12 @@ 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:')) return parseInt(type.substring(4)) || 0;
|
||||||
return baseGateOutputCount(type);
|
return baseGateOutputCount(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getComponentWidth(gate) {
|
export function getComponentWidth(gate) {
|
||||||
|
if (gate.type.startsWith('BUS:')) 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 +35,10 @@ export function getComponentWidth(gate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getComponentHeight(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:')) {
|
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 +49,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:') || gate.type.startsWith('BUS:');
|
||||||
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 +62,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:') || gate.type.startsWith('BUS:');
|
||||||
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);
|
||||||
@@ -105,6 +112,12 @@ function computeGate(gate) {
|
|||||||
return outputs[0] || 0;
|
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) {
|
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;
|
||||||
@@ -168,8 +181,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:') || g.type.startsWith('BUS:');
|
||||||
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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
162
js/renderer.js
162
js/renderer.js
@@ -32,10 +32,9 @@ export function screenToWorld(sx, sy) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawGate(gate) {
|
function drawGate(gate) {
|
||||||
// Component gates have different rendering
|
// Special gate types have different rendering
|
||||||
if (gate.type.startsWith('COMPONENT:')) {
|
if (gate.type.startsWith('BUS:')) return drawBusGate(gate);
|
||||||
return drawComponentGate(gate);
|
if (gate.type.startsWith('COMPONENT:')) return drawComponentGate(gate);
|
||||||
}
|
|
||||||
|
|
||||||
const color = GATE_COLORS[gate.type];
|
const color = GATE_COLORS[gate.type];
|
||||||
const isHovered = state.hoveredGate === gate;
|
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) {
|
function drawComponentGate(gate) {
|
||||||
const isHovered = state.hoveredGate === gate;
|
const isHovered = state.hoveredGate === gate;
|
||||||
const isActive = gate.value === 1;
|
const isActive = gate.value === 1;
|
||||||
@@ -305,6 +378,86 @@ 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 drawPlacingGhost() {
|
function drawPlacingGhost() {
|
||||||
if (!state.placingGate) return;
|
if (!state.placingGate) return;
|
||||||
ctx.globalAlpha = 0.5;
|
ctx.globalAlpha = 0.5;
|
||||||
@@ -356,6 +509,7 @@ function draw() {
|
|||||||
state.connections.forEach(drawConnection);
|
state.connections.forEach(drawConnection);
|
||||||
state.gates.forEach(drawGate);
|
state.gates.forEach(drawGate);
|
||||||
drawConnectingWire();
|
drawConnectingWire();
|
||||||
|
drawBusCutLine();
|
||||||
drawPlacingGhost();
|
drawPlacingGhost();
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|||||||
@@ -42,5 +42,8 @@ export const state = {
|
|||||||
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)
|
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
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user