This commit is contained in:
sfja 2026-06-02 05:22:09 +02:00
parent 0ec608ee54
commit 6a5b051872
5 changed files with 401 additions and 121 deletions

View File

@ -1,12 +1,22 @@
import type { Selection } from "./Cx"; import type { Selection } from "./Cx";
import type { Renderer } from "./Renderer"; import type { Renderer } from "./Renderer";
import { pointInsideRect, rectsCollide, v2, V2 } from "./V2"; import {
lineSegmentPointDistance,
pointInsideRect,
rectsCollide,
v2,
V2,
} from "./V2";
export class Board { export class Board {
private components: PlacedComponent[] = []; private components: Component[] = [];
private joints: Joint[] = [];
private wires: Wire[] = [];
private hoveredOverInput: [PlacedComponent, number] | null = null; private hoveredOverInput: [Component, number] | null = null;
private hoveredOverOutput: [PlacedComponent, number] | null = null; private hoveredOverOutput: [Component, number] | null = null;
private hoveredOverJoint: Joint | null = null;
private hoveredOverWire: Wire | null = null;
constructor() {} constructor() {}
@ -17,7 +27,7 @@ export class Board {
} }
placeComponent(kind: ComponentKind, pos: V2) { placeComponent(kind: ComponentKind, pos: V2) {
this.components.push({ kind: kind, pos }); this.components.push(new Component(kind, pos));
} }
render(r: Renderer, selection: Selection | null) { render(r: Renderer, selection: Selection | null) {
@ -29,7 +39,23 @@ export class Board {
r.drawComponentBody(pos, kind); r.drawComponentBody(pos, kind);
} }
for (const { i, pinOffset } of kind.inputPinIter()) { for (const wire of this.wires) {
if (this.hoveredOverWire == wire) {
r.drawWireHovered(wire.beginPos(), wire.endPos());
} else {
r.drawWire(wire.beginPos(), wire.endPos());
}
}
for (const joint of this.joints) {
r.drawJoint(joint.pos);
if (this.hoveredOverJoint === joint) {
r.drawJointHover(joint.pos);
}
}
for (const [i, pinOffset] of kind.inputPinOffsets().entries()) {
if (kind.inputs[i] !== null) { if (kind.inputs[i] !== null) {
throw new Error("pin text not implemented"); throw new Error("pin text not implemented");
} }
@ -43,7 +69,7 @@ export class Board {
} }
} }
for (const { i, pinOffset } of kind.outputPinIter()) { for (const [i, pinOffset] of kind.outputPinOffsets().entries()) {
if (kind.outputs[i] !== null) { if (kind.outputs[i] !== null) {
throw new Error("pin text not implemented"); throw new Error("pin text not implemented");
} }
@ -62,83 +88,93 @@ export class Board {
updateMouseHover(pos: V2) { updateMouseHover(pos: V2) {
this.hoveredOverInput = null; this.hoveredOverInput = null;
this.hoveredOverOutput = null; this.hoveredOverOutput = null;
this.hoveredOverJoint = null;
this.hoveredOverWire = null;
for (const comp of this.components) { for (const comp of this.components) {
const { const mouseOverResult = comp.mouseOver(pos);
pos: { x, y }, switch (mouseOverResult?.tag) {
kind: { case "InputPin":
size: { x: w }, this.hoveredOverInput = [comp, mouseOverResult.i];
}, return;
} = comp; case "OutputPin":
this.hoveredOverOutput = [comp, mouseOverResult.i];
return;
}
}
if ( for (const joint of this.joints) {
!pointInsideRect( if (joint.isMouseOver(pos)) {
pos, this.hoveredOverJoint = joint;
comp.pos.sub(v2(5, 5)), return;
comp.kind.size.add(v2(10, 10)),
)
) {
continue;
} }
for (const { i, pinOffset } of comp.kind.inputPinIter()) { }
if (v2(x, y + pinOffset).distance(pos) < 6) {
this.hoveredOverInput = [comp, i]; for (const wire of this.wires) {
} if (wire.isMouseOver(pos)) {
} this.hoveredOverWire = wire;
for (const { i, pinOffset } of comp.kind.outputPinIter()) { return;
if (v2(x + w, y + pinOffset).distance(pos) < 6) {
this.hoveredOverOutput = [comp, i];
}
} }
} }
} }
handleMouseClick( handleMouseClick(
pos: V2, pos: V2,
inputPinClicked: (comp: PlacedComponent, i: number) => void, actions: {
outputPinClicked: (comp: PlacedComponent, i: number) => void, onInputPinClicked?(comp: Component, i: number): void;
componentClicked: (comp: PlacedComponent) => void, onOutputPinClicked?(comp: Component, i: number): void;
onComponentClicked?(comp: Component): void;
onJointClicked?(joint: Joint): void;
onWireClicked?(wire: Wire): void;
},
): "handled" | "not handled" { ): "handled" | "not handled" {
for (const comp of this.components) { for (const comp of this.components) {
const { const mouseOverResult = comp.mouseOver(pos);
pos: { x, y }, switch (mouseOverResult?.tag) {
kind: { case "Component":
size: { x: w }, actions.onComponentClicked?.(comp);
}, return "handled";
} = comp; case "InputPin":
actions.onInputPinClicked?.(comp, mouseOverResult.i);
return "handled";
case "OutputPin":
actions.onOutputPinClicked?.(comp, mouseOverResult.i);
return "handled";
}
}
if ( for (const joint of this.joints) {
!pointInsideRect( if (joint.isMouseOver(pos)) {
pos, actions.onJointClicked?.(joint);
comp.pos.sub(v2(5, 5)), return "handled";
comp.kind.size.add(v2(10, 10)),
)
) {
continue;
} }
for (const { i, pinOffset } of comp.kind.inputPinIter()) { }
if (v2(x, y + pinOffset).distance(pos) < 6) {
inputPinClicked(comp, i); for (const wire of this.wires) {
return "handled"; if (wire.isMouseOver(pos)) {
} actions.onWireClicked?.(wire);
return "handled";
} }
for (const { i, pinOffset } of comp.kind.outputPinIter()) {
if (v2(x + w, y + pinOffset).distance(pos) < 6) {
outputPinClicked(comp, i);
return "handled";
}
}
componentClicked(comp);
return "handled";
} }
return "not handled"; return "not handled";
} }
componentsInRect(pos: V2, size: V2): PlacedComponent[] { componentsInRect(pos: V2, size: V2): Component[] {
return this.components.filter((comp) => return this.components.filter((comp) =>
rectsCollide(pos, size, comp.pos, comp.kind.size), rectsCollide(pos, size, comp.pos, comp.kind.size),
); );
} }
addJoint(pos: V2): Joint {
const t = new Joint(pos);
this.joints.push(t);
return t;
}
addWire(begin: WireConnection, end: WireConnection): Wire {
const wire = new Wire(begin, end);
this.wires.push(wire);
return wire;
}
} }
export class ComponentRepo { export class ComponentRepo {
@ -167,10 +203,55 @@ export class ComponentRepo {
} }
} }
export type PlacedComponent = { export class Component {
kind: ComponentKind; constructor(
pos: V2; public kind: ComponentKind,
}; public pos: V2,
) {}
mouseOver(pos: V2): ComponentMouseOverResult | null {
const {
pos: { x, y },
kind: {
size: { x: w },
},
} = this;
if (
!pointInsideRect(
pos,
this.pos.sub(v2(5, 5)),
this.kind.size.add(v2(10, 10)),
)
) {
return null;
}
for (const [i, pinOffset] of this.kind.inputPinOffsets().entries()) {
if (v2(x, y + pinOffset).distance(pos) < 6) {
return { tag: "InputPin", i };
}
}
for (const [i, pinOffset] of this.kind.outputPinOffsets().entries()) {
if (v2(x + w, y + pinOffset).distance(pos) < 6) {
return { tag: "OutputPin", i };
}
}
return { tag: "Component" };
}
inputPinPos(i: number): V2 {
return this.pos.add(v2(0, this.kind.inputPinOffsets()[i]));
}
outputPinPos(i: number): V2 {
return this.pos.add(v2(this.kind.size.x, this.kind.outputPinOffsets()[i]));
}
}
type ComponentMouseOverResult =
| { tag: "Component" }
| { tag: "InputPin" | "OutputPin"; i: number };
export class ComponentKind { export class ComponentKind {
constructor( constructor(
@ -180,20 +261,66 @@ export class ComponentKind {
public outputs: (string | null)[], public outputs: (string | null)[],
) {} ) {}
inputPinIter(): { i: number; pinOffset: number }[] { inputPinOffsets(): number[] {
return this.inputs.map((_, i) => ({ return this.inputs.map(
i, (_, i) => ((i + 1) * this.size.y) / (this.inputs.length + 1),
pinOffset: ((i + 1) * this.size.y) / (this.inputs.length + 1), );
}));
} }
outputPinIter(): { i: number; pinOffset: number }[] { outputPinOffsets(): number[] {
return this.outputs.map((_, i) => ({ return this.outputs.map(
i, (_, i) => ((i + 1) * this.size.y) / (this.outputs.length + 1),
pinOffset: ((i + 1) * this.size.y) / (this.outputs.length + 1), );
}));
} }
} }
export class Joint {
constructor(public pos: V2) {}
isMouseOver(pos: V2): boolean {
return this.pos.distance(pos) < 6;
}
}
export class Wire {
constructor(
private begin: WireConnection,
private end: WireConnection,
) {}
isMouseOver(pos: V2): boolean {
const distance = lineSegmentPointDistance(
this.beginPos(),
this.endPos(),
pos,
);
return distance !== null && distance < 6;
}
beginPos(): V2 {
return this.connectionPos(this.begin);
}
endPos(): V2 {
return this.connectionPos(this.end);
}
private connectionPos(connection: WireConnection): V2 {
switch (connection.tag) {
case "InputPin":
return connection.comp.inputPinPos(connection.i);
case "OutputPin":
return connection.comp.outputPinPos(connection.i);
case "Joint":
return connection.joint.pos;
}
}
}
export type WireConnection =
| { tag: "InputPin"; comp: Component; i: number }
| { tag: "OutputPin"; comp: Component; i: number }
| { tag: "Joint"; joint: Joint };
const defaultDefs = [ const defaultDefs = [
{ {
label: "input", label: "input",

View File

@ -1,4 +1,10 @@
import { Board, ComponentRepo, type PlacedComponent } from "./Board"; import {
Board,
ComponentRepo,
Joint,
type Component,
type WireConnection,
} from "./Board";
import { Renderer } from "./Renderer"; import { Renderer } from "./Renderer";
import * as states from "./states"; import * as states from "./states";
import { v2, V2 } from "./V2"; import { v2, V2 } from "./V2";
@ -133,9 +139,10 @@ export class Cx {
} }
export class SelectionBox { export class SelectionBox {
public size = v2(0, 0); constructor(
public pos: V2,
constructor(public pos: V2) {} public size = v2(0, 0),
) {}
render(r: Renderer) { render(r: Renderer) {
r.drawSelectionBox(this.pos, this.size); r.drawSelectionBox(this.pos, this.size);
@ -168,13 +175,13 @@ export class ComponentPlacer {
} }
export class Selection { export class Selection {
selectedComponents = new Set<PlacedComponent>(); selectedComponents = new Set<Component>();
addComponent(comp: PlacedComponent) { addComponent(comp: Component) {
this.selectedComponents.add(comp); this.selectedComponents.add(comp);
} }
toggleComponent(comp: PlacedComponent) { toggleComponent(comp: Component) {
if (this.selectedComponents.has(comp)) { if (this.selectedComponents.has(comp)) {
this.selectedComponents.delete(comp); this.selectedComponents.delete(comp);
} else { } else {
@ -182,7 +189,7 @@ export class Selection {
} }
} }
isComponentSelected(comp: PlacedComponent) { isComponentSelected(comp: Component) {
return this.selectedComponents.has(comp); return this.selectedComponents.has(comp);
} }
} }
@ -197,6 +204,11 @@ export class ConnectingWire {
switch (this.kind.tag) { switch (this.kind.tag) {
case "InputPin": case "InputPin":
case "OutputPin": case "OutputPin":
break;
case "Intermediary":
this.kind.prev.render(r);
r.drawConnectingWirePoint(this.beginPos());
break;
} }
r.drawConnectingWire(this.beginPos(), this.pos); r.drawConnectingWire(this.beginPos(), this.pos);
} }
@ -205,24 +217,62 @@ export class ConnectingWire {
this.pos = pos; this.pos = pos;
} }
connectToInput(board: Board, comp: Component, i: number) {
this.pushWire(board, { tag: "InputPin", comp, i });
}
connectToOutput(board: Board, comp: Component, i: number) {
this.pushWire(board, { tag: "OutputPin", comp, i });
}
private pushWire(board: Board, end: WireConnection) {
switch (this.kind.tag) {
case "InputPin":
case "OutputPin": {
const { tag, comp, i } = this.kind;
board.addWire({ tag, comp, i }, end);
break;
}
case "Intermediary": {
const joint = board.addJoint(this.kind.pos);
board.addWire({ tag: "Joint", joint }, end);
this.kind.prev.pushWire(board, { tag: "Joint", joint });
break;
}
case "Joint": {
const joint = this.kind.joint;
board.addWire({ tag: "Joint", joint }, end);
break;
}
default:
this.kind satisfies never;
}
}
private beginPos(): V2 { private beginPos(): V2 {
switch (this.kind.tag) { switch (this.kind.tag) {
case "InputPin": case "InputPin":
return v2( return v2(
this.kind.comp.pos.x, this.kind.comp.pos.x,
this.kind.comp.pos.y + this.kind.comp.pos.y +
this.kind.comp.kind.inputPinIter()[this.kind.i].pinOffset, this.kind.comp.kind.inputPinOffsets()[this.kind.i],
); );
case "OutputPin": case "OutputPin":
return v2( return v2(
this.kind.comp.pos.x + this.kind.comp.kind.size.x, this.kind.comp.pos.x + this.kind.comp.kind.size.x,
this.kind.comp.pos.y + this.kind.comp.pos.y +
this.kind.comp.kind.outputPinIter()[this.kind.i].pinOffset, this.kind.comp.kind.outputPinOffsets()[this.kind.i],
); );
case "Intermediary":
return this.kind.pos;
case "Joint":
return this.kind.joint.pos;
} }
} }
} }
export type ConnectingWireKind = export type ConnectingWireKind =
| { tag: "InputPin"; comp: PlacedComponent; i: number } | { tag: "InputPin"; comp: Component; i: number }
| { tag: "OutputPin"; comp: PlacedComponent; i: number }; | { tag: "OutputPin"; comp: Component; i: number }
| { tag: "Intermediary"; prev: ConnectingWire; pos: V2 }
| { tag: "Joint"; joint: Joint };

View File

@ -146,6 +146,53 @@ export class Renderer {
c.stroke(); c.stroke();
} }
drawWire(begin: V2, end: V2) {
const { c, offset } = this;
const { x: x0, y: y0 } = begin.add(offset);
const { x: x1, y: y1 } = end.add(offset);
c.strokeStyle = `#333333`;
c.lineWidth = 3;
c.beginPath();
c.moveTo(x0, y0);
c.lineTo(x1, y1);
c.stroke();
}
drawWireHovered(begin: V2, end: V2) {
const { c, offset } = this;
const { x: x0, y: y0 } = begin.add(offset);
const { x: x1, y: y1 } = end.add(offset);
c.strokeStyle = `#444444`;
c.lineWidth = 3;
c.beginPath();
c.moveTo(x0, y0);
c.lineTo(x1, y1);
c.stroke();
}
drawJoint(pos: V2) {
const { c, offset } = this;
const { x: x0, y: y0 } = pos.add(offset);
c.fillStyle = `#333333`;
c.beginPath();
c.arc(x0, y0, 3, 0, Math.PI * 2);
c.fill();
}
drawJointHover(pos: V2) {
const { c, offset } = this;
const { x, y } = pos.add(offset);
c.strokeStyle = `#eee`;
c.lineWidth = 2;
c.beginPath();
c.arc(x, y, 5, 0, Math.PI * 2);
c.stroke();
}
drawConnectingWire(begin: V2, end: V2) { drawConnectingWire(begin: V2, end: V2) {
const { c, offset } = this; const { c, offset } = this;
const { x: x0, y: y0 } = begin.add(offset); const { x: x0, y: y0 } = begin.add(offset);
@ -158,4 +205,14 @@ export class Renderer {
c.lineTo(x1, y1); c.lineTo(x1, y1);
c.stroke(); c.stroke();
} }
drawConnectingWirePoint(pos: V2) {
const { c, offset } = this;
const { x: x0, y: y0 } = pos.add(offset);
c.fillStyle = `#333333`;
c.beginPath();
c.arc(x0, y0, 3, 0, Math.PI * 2);
c.fill();
}
} }

View File

@ -17,14 +17,36 @@ export class V2 {
len(): number { len(): number {
return Math.sqrt(this.x ** 2 + this.y ** 2); return Math.sqrt(this.x ** 2 + this.y ** 2);
} }
distance(other: V2) { distance(other: V2) {
return this.rsub(other).len(); return this.rsub(other).len();
} }
abs(): V2 {
return new V2(Math.abs(this.x), Math.abs(this.y));
}
} }
export const v2 = (x: number, y: number): V2 => new V2(x, y); export const v2 = (x: number, y: number): V2 => new V2(x, y);
export function lineSegmentPointDistance(p1: V2, p2: V2, p: V2): number | null {
const len = p2.sub(p1).len();
const dist1 = p1.sub(p).len();
const dist2 = p2.sub(p).len();
return dist1 < len && dist2 < len ? linePointDistance(p1, p2, p) : null;
}
export function linePointDistance(p1: V2, p2: V2, p: V2): number {
const { x: x1, y: y1 } = p1;
const { x: x2, y: y2 } = p2;
const { x, y } = p;
return (
Math.abs((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1) /
Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
);
}
export function rectsCollide( export function rectsCollide(
{ x: ax, y: ay }: V2, { x: ax, y: ay }: V2,
{ x: aw, y: ah }: V2, { x: aw, y: ah }: V2,

View File

@ -1,5 +1,5 @@
import { type ComponentKind } from "./Board"; import { type ComponentKind } from "./Board";
import { ConnectingWire, Selection } from "./Cx"; import { ConnectingWire, Selection, type ConnectingWireKind } from "./Cx";
import { SelectionBox, type Cx, type Tool } from "./Cx"; import { SelectionBox, type Cx, type Tool } from "./Cx";
import { v2, type V2 } from "./V2"; import { v2, type V2 } from "./V2";
@ -15,50 +15,55 @@ export interface State {
} }
export class Normal implements State { export class Normal implements State {
private dragStart = v2(0, 0);
private isMouseDown = false;
constructor(private cx: Cx) {} constructor(private cx: Cx) {}
onMouseDown(pos: V2): void { onMouseDown(pos: V2): void {
if ( if (
this.cx.board.handleMouseClick( this.cx.board.handleMouseClick(pos.sub(this.cx.offset), {
pos.sub(this.cx.offset), onInputPinClicked: (comp, i) => {
(comp, i) => {
console.log({ comp, i });
this.cx.connectingWire = new ConnectingWire( this.cx.connectingWire = new ConnectingWire(
{ { tag: "InputPin", comp, i },
tag: "InputPin", pos.sub(this.cx.offset),
comp,
i,
},
pos,
); );
this.cx.transitionTo(new Wiring(this.cx)); this.cx.transitionTo(new Wiring(this.cx));
}, },
(comp, i) => { onOutputPinClicked: (comp, i) => {
this.cx.connectingWire = new ConnectingWire( this.cx.connectingWire = new ConnectingWire(
{ { tag: "OutputPin", comp, i },
tag: "OutputPin", pos.sub(this.cx.offset),
comp,
i,
},
pos,
); );
this.cx.transitionTo(new Wiring(this.cx)); this.cx.transitionTo(new Wiring(this.cx));
}, },
(comp) => { onComponentClicked: (comp) => {
this.cx.selection = new Selection(); this.cx.selection = new Selection();
this.cx.selection.addComponent(comp); this.cx.selection.addComponent(comp);
this.cx.transitionTo(new Selecting(this.cx)); this.cx.transitionTo(new Selecting(this.cx));
}, },
) === "handled" onJointClicked: (joint) => {
this.cx.connectingWire = new ConnectingWire(
{ tag: "Joint", joint },
pos.sub(this.cx.offset),
);
this.cx.transitionTo(new Wiring(this.cx));
},
}) !== "handled"
) { ) {
return; this.isMouseDown = true;
} else { this.dragStart = pos;
this.cx.selectionBox = new SelectionBox(pos);
this.cx.transitionTo(new SelectingBox(this.cx));
} }
} }
onMouseMove(_deltaPos: V2, pos: V2): void { onMouseMove(_deltaPos: V2, pos: V2): void {
if (this.isMouseDown && this.dragStart.sub(pos).len() > 40) {
this.cx.selectionBox = new SelectionBox(
this.dragStart,
pos.sub(this.dragStart),
);
this.cx.transitionTo(new SelectingBox(this.cx));
}
this.cx.board.updateMouseHover(pos.sub(this.cx.offset)); this.cx.board.updateMouseHover(pos.sub(this.cx.offset));
} }
@ -159,11 +164,8 @@ export class Selecting implements State {
onMouseDown(pos: V2): void { onMouseDown(pos: V2): void {
if ( if (
this.cx.board.handleMouseClick( this.cx.board.handleMouseClick(pos.sub(this.cx.offset), {
pos.sub(this.cx.offset), onComponentClicked: (comp) => {
(_comp, _i) => {},
(_comp, _i) => {},
(comp) => {
if (this.cx.keysPressed.has("Control")) { if (this.cx.keysPressed.has("Control")) {
this.cx.selection?.toggleComponent(comp); this.cx.selection?.toggleComponent(comp);
} else { } else {
@ -171,10 +173,8 @@ export class Selecting implements State {
this.cx.selection.addComponent(comp); this.cx.selection.addComponent(comp);
} }
}, },
) === "handled" }) !== "handled"
) { ) {
return;
} else {
if (this.cx.keysPressed.has("Control")) { if (this.cx.keysPressed.has("Control")) {
this.cx.selectionBox = new SelectionBox(pos); this.cx.selectionBox = new SelectionBox(pos);
this.cx.transitionTo(new SelectingBox(this.cx)); this.cx.transitionTo(new SelectingBox(this.cx));
@ -225,11 +225,35 @@ export class SelectingBox implements State {
export class Wiring implements State { export class Wiring implements State {
constructor(private cx: Cx) {} constructor(private cx: Cx) {}
onMouseDown(pos: V2): void {
if (
this.cx.board.handleMouseClick(pos.sub(this.cx.offset), {
onInputPinClicked: (comp, i) => {
this.cx.connectingWire!.connectToInput(this.cx.board, comp, i);
this.cx.connectingWire = null;
this.cx.transitionTo(new Normal(this.cx));
},
onOutputPinClicked: (comp, i) => {
this.cx.connectingWire!.connectToInput(this.cx.board, comp, i);
this.cx.connectingWire = null;
this.cx.transitionTo(new Normal(this.cx));
},
}) !== "handled"
) {
const kind: ConnectingWireKind = {
tag: "Intermediary",
prev: this.cx.connectingWire!,
pos: pos.sub(this.cx.offset),
};
this.cx.connectingWire = new ConnectingWire(kind, pos);
}
}
onMouseMove(_deltaPos: V2, pos: V2): void { onMouseMove(_deltaPos: V2, pos: V2): void {
if (!this.cx.connectingWire) { if (!this.cx.connectingWire) {
throw new Error("expected connectingWire to be active"); throw new Error("expected connectingWire to be active");
} }
this.cx.connectingWire.move(pos); this.cx.connectingWire.move(pos.sub(this.cx.offset));
this.cx.board.updateMouseHover(pos.sub(this.cx.offset)); this.cx.board.updateMouseHover(pos.sub(this.cx.offset));
} }