diff --git a/editor/src/editor/Board.ts b/editor/src/editor/Board.ts index 2eed23a..908fb34 100644 --- a/editor/src/editor/Board.ts +++ b/editor/src/editor/Board.ts @@ -1,12 +1,22 @@ import type { Selection } from "./Cx"; import type { Renderer } from "./Renderer"; -import { pointInsideRect, rectsCollide, v2, V2 } from "./V2"; +import { + lineSegmentPointDistance, + pointInsideRect, + rectsCollide, + v2, + V2, +} from "./V2"; export class Board { - private components: PlacedComponent[] = []; + private components: Component[] = []; + private joints: Joint[] = []; + private wires: Wire[] = []; - private hoveredOverInput: [PlacedComponent, number] | null = null; - private hoveredOverOutput: [PlacedComponent, number] | null = null; + private hoveredOverInput: [Component, number] | null = null; + private hoveredOverOutput: [Component, number] | null = null; + private hoveredOverJoint: Joint | null = null; + private hoveredOverWire: Wire | null = null; constructor() {} @@ -17,7 +27,7 @@ export class Board { } placeComponent(kind: ComponentKind, pos: V2) { - this.components.push({ kind: kind, pos }); + this.components.push(new Component(kind, pos)); } render(r: Renderer, selection: Selection | null) { @@ -29,7 +39,23 @@ export class Board { 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) { 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) { throw new Error("pin text not implemented"); } @@ -62,83 +88,93 @@ export class Board { updateMouseHover(pos: V2) { this.hoveredOverInput = null; this.hoveredOverOutput = null; + this.hoveredOverJoint = null; + this.hoveredOverWire = null; for (const comp of this.components) { - const { - pos: { x, y }, - kind: { - size: { x: w }, - }, - } = comp; + const mouseOverResult = comp.mouseOver(pos); + switch (mouseOverResult?.tag) { + case "InputPin": + this.hoveredOverInput = [comp, mouseOverResult.i]; + return; + case "OutputPin": + this.hoveredOverOutput = [comp, mouseOverResult.i]; + return; + } + } - if ( - !pointInsideRect( - pos, - comp.pos.sub(v2(5, 5)), - comp.kind.size.add(v2(10, 10)), - ) - ) { - continue; + for (const joint of this.joints) { + if (joint.isMouseOver(pos)) { + this.hoveredOverJoint = joint; + return; } - for (const { i, pinOffset } of comp.kind.inputPinIter()) { - if (v2(x, y + pinOffset).distance(pos) < 6) { - this.hoveredOverInput = [comp, i]; - } - } - for (const { i, pinOffset } of comp.kind.outputPinIter()) { - if (v2(x + w, y + pinOffset).distance(pos) < 6) { - this.hoveredOverOutput = [comp, i]; - } + } + + for (const wire of this.wires) { + if (wire.isMouseOver(pos)) { + this.hoveredOverWire = wire; + return; } } } handleMouseClick( pos: V2, - inputPinClicked: (comp: PlacedComponent, i: number) => void, - outputPinClicked: (comp: PlacedComponent, i: number) => void, - componentClicked: (comp: PlacedComponent) => void, + actions: { + onInputPinClicked?(comp: Component, i: number): void; + onOutputPinClicked?(comp: Component, i: number): void; + onComponentClicked?(comp: Component): void; + onJointClicked?(joint: Joint): void; + onWireClicked?(wire: Wire): void; + }, ): "handled" | "not handled" { for (const comp of this.components) { - const { - pos: { x, y }, - kind: { - size: { x: w }, - }, - } = comp; + const mouseOverResult = comp.mouseOver(pos); + switch (mouseOverResult?.tag) { + case "Component": + actions.onComponentClicked?.(comp); + return "handled"; + case "InputPin": + actions.onInputPinClicked?.(comp, mouseOverResult.i); + return "handled"; + case "OutputPin": + actions.onOutputPinClicked?.(comp, mouseOverResult.i); + return "handled"; + } + } - if ( - !pointInsideRect( - pos, - comp.pos.sub(v2(5, 5)), - comp.kind.size.add(v2(10, 10)), - ) - ) { - continue; + for (const joint of this.joints) { + if (joint.isMouseOver(pos)) { + actions.onJointClicked?.(joint); + return "handled"; } - for (const { i, pinOffset } of comp.kind.inputPinIter()) { - if (v2(x, y + pinOffset).distance(pos) < 6) { - inputPinClicked(comp, i); - return "handled"; - } + } + + for (const wire of this.wires) { + 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"; } - componentsInRect(pos: V2, size: V2): PlacedComponent[] { + componentsInRect(pos: V2, size: V2): Component[] { return this.components.filter((comp) => 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 { @@ -167,10 +203,55 @@ export class ComponentRepo { } } -export type PlacedComponent = { - kind: ComponentKind; - pos: V2; -}; +export class Component { + constructor( + 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 { constructor( @@ -180,20 +261,66 @@ export class ComponentKind { public outputs: (string | null)[], ) {} - inputPinIter(): { i: number; pinOffset: number }[] { - return this.inputs.map((_, i) => ({ - i, - pinOffset: ((i + 1) * this.size.y) / (this.inputs.length + 1), - })); + inputPinOffsets(): number[] { + return this.inputs.map( + (_, i) => ((i + 1) * this.size.y) / (this.inputs.length + 1), + ); } - outputPinIter(): { i: number; pinOffset: number }[] { - return this.outputs.map((_, i) => ({ - i, - pinOffset: ((i + 1) * this.size.y) / (this.outputs.length + 1), - })); + outputPinOffsets(): number[] { + return this.outputs.map( + (_, i) => ((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 = [ { label: "input", diff --git a/editor/src/editor/Cx.ts b/editor/src/editor/Cx.ts index 7f063e5..fcedc4e 100644 --- a/editor/src/editor/Cx.ts +++ b/editor/src/editor/Cx.ts @@ -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 * as states from "./states"; import { v2, V2 } from "./V2"; @@ -133,9 +139,10 @@ export class Cx { } export class SelectionBox { - public size = v2(0, 0); - - constructor(public pos: V2) {} + constructor( + public pos: V2, + public size = v2(0, 0), + ) {} render(r: Renderer) { r.drawSelectionBox(this.pos, this.size); @@ -168,13 +175,13 @@ export class ComponentPlacer { } export class Selection { - selectedComponents = new Set(); + selectedComponents = new Set(); - addComponent(comp: PlacedComponent) { + addComponent(comp: Component) { this.selectedComponents.add(comp); } - toggleComponent(comp: PlacedComponent) { + toggleComponent(comp: Component) { if (this.selectedComponents.has(comp)) { this.selectedComponents.delete(comp); } else { @@ -182,7 +189,7 @@ export class Selection { } } - isComponentSelected(comp: PlacedComponent) { + isComponentSelected(comp: Component) { return this.selectedComponents.has(comp); } } @@ -197,6 +204,11 @@ export class ConnectingWire { switch (this.kind.tag) { case "InputPin": case "OutputPin": + break; + case "Intermediary": + this.kind.prev.render(r); + r.drawConnectingWirePoint(this.beginPos()); + break; } r.drawConnectingWire(this.beginPos(), this.pos); } @@ -205,24 +217,62 @@ export class ConnectingWire { 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 { switch (this.kind.tag) { case "InputPin": return v2( this.kind.comp.pos.x, this.kind.comp.pos.y + - this.kind.comp.kind.inputPinIter()[this.kind.i].pinOffset, + this.kind.comp.kind.inputPinOffsets()[this.kind.i], ); case "OutputPin": return v2( this.kind.comp.pos.x + this.kind.comp.kind.size.x, 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 = - | { tag: "InputPin"; comp: PlacedComponent; i: number } - | { tag: "OutputPin"; comp: PlacedComponent; i: number }; + | { tag: "InputPin"; comp: Component; i: number } + | { tag: "OutputPin"; comp: Component; i: number } + | { tag: "Intermediary"; prev: ConnectingWire; pos: V2 } + | { tag: "Joint"; joint: Joint }; diff --git a/editor/src/editor/Renderer.ts b/editor/src/editor/Renderer.ts index 9dee875..788c0c4 100644 --- a/editor/src/editor/Renderer.ts +++ b/editor/src/editor/Renderer.ts @@ -146,6 +146,53 @@ export class Renderer { 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) { const { c, offset } = this; const { x: x0, y: y0 } = begin.add(offset); @@ -158,4 +205,14 @@ export class Renderer { c.lineTo(x1, y1); 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(); + } } diff --git a/editor/src/editor/V2.ts b/editor/src/editor/V2.ts index d1f93c4..db6b555 100644 --- a/editor/src/editor/V2.ts +++ b/editor/src/editor/V2.ts @@ -17,14 +17,36 @@ export class V2 { len(): number { return Math.sqrt(this.x ** 2 + this.y ** 2); } - distance(other: V2) { 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 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( { x: ax, y: ay }: V2, { x: aw, y: ah }: V2, diff --git a/editor/src/editor/states.ts b/editor/src/editor/states.ts index 89725ea..eb7bc10 100644 --- a/editor/src/editor/states.ts +++ b/editor/src/editor/states.ts @@ -1,5 +1,5 @@ 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 { v2, type V2 } from "./V2"; @@ -15,50 +15,55 @@ export interface State { } export class Normal implements State { + private dragStart = v2(0, 0); + private isMouseDown = false; + constructor(private cx: Cx) {} onMouseDown(pos: V2): void { if ( - this.cx.board.handleMouseClick( - pos.sub(this.cx.offset), - (comp, i) => { - console.log({ comp, i }); + this.cx.board.handleMouseClick(pos.sub(this.cx.offset), { + onInputPinClicked: (comp, i) => { this.cx.connectingWire = new ConnectingWire( - { - tag: "InputPin", - comp, - i, - }, - pos, + { tag: "InputPin", comp, i }, + pos.sub(this.cx.offset), ); this.cx.transitionTo(new Wiring(this.cx)); }, - (comp, i) => { + onOutputPinClicked: (comp, i) => { this.cx.connectingWire = new ConnectingWire( - { - tag: "OutputPin", - comp, - i, - }, - pos, + { tag: "OutputPin", comp, i }, + pos.sub(this.cx.offset), ); this.cx.transitionTo(new Wiring(this.cx)); }, - (comp) => { + onComponentClicked: (comp) => { this.cx.selection = new Selection(); this.cx.selection.addComponent(comp); 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; - } else { - this.cx.selectionBox = new SelectionBox(pos); - this.cx.transitionTo(new SelectingBox(this.cx)); + this.isMouseDown = true; + this.dragStart = pos; } } 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)); } @@ -159,11 +164,8 @@ export class Selecting implements State { onMouseDown(pos: V2): void { if ( - this.cx.board.handleMouseClick( - pos.sub(this.cx.offset), - (_comp, _i) => {}, - (_comp, _i) => {}, - (comp) => { + this.cx.board.handleMouseClick(pos.sub(this.cx.offset), { + onComponentClicked: (comp) => { if (this.cx.keysPressed.has("Control")) { this.cx.selection?.toggleComponent(comp); } else { @@ -171,10 +173,8 @@ export class Selecting implements State { this.cx.selection.addComponent(comp); } }, - ) === "handled" + }) !== "handled" ) { - return; - } else { if (this.cx.keysPressed.has("Control")) { this.cx.selectionBox = new SelectionBox(pos); this.cx.transitionTo(new SelectingBox(this.cx)); @@ -225,11 +225,35 @@ export class SelectingBox implements State { export class Wiring implements State { 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 { if (!this.cx.connectingWire) { 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)); }