From 36b29bdcef9ec04a100fa8afcc18c6cdafcbf4fa Mon Sep 17 00:00:00 2001 From: sfja Date: Tue, 13 May 2025 17:58:16 +0200 Subject: [PATCH] add wiring --- src/canvas.ts | 17 +++ src/geometry.ts | 146 +++++++++++++++++++++++ src/grid.ts | 18 +++ src/painter.ts | 10 ++ src/renderer.ts | 3 +- src/simulator.ts | 297 ++++++++++++++++++++++++++++++++++++++++------- 6 files changed, 450 insertions(+), 41 deletions(-) create mode 100644 src/geometry.ts diff --git a/src/canvas.ts b/src/canvas.ts index 76abd2e..998a4a6 100644 --- a/src/canvas.ts +++ b/src/canvas.ts @@ -48,6 +48,23 @@ export class CanvasRenderer implements Renderer { g.fill(); } + strokeLine( + x0: number, + y0: number, + x1: number, + y1: number, + color: string, + lineWidth: number, + ): void { + const { g } = this; + g.strokeStyle = color; + g.lineWidth = lineWidth; + g.beginPath(); + g.moveTo(x0, y0); + g.lineTo(x1, y1); + g.stroke(); + } + putImage( data: RendererImage, x: number, diff --git a/src/geometry.ts b/src/geometry.ts new file mode 100644 index 0000000..0f28466 --- /dev/null +++ b/src/geometry.ts @@ -0,0 +1,146 @@ +import { Renderer } from "./renderer.ts"; + +export class V2 { + constructor( + public x: number, + public y: number, + ) {} + + clone(): V2 { + return new V2(this.x, this.y); + } + + add(other: V2): V2 { + return new V2(this.x + other.x, this.y + other.y); + } + + sub(other: V2): V2 { + return new V2(this.x - other.x, this.y - other.y); + } + + mul(factor: number): V2 { + return new V2(this.x * factor, this.y * factor); + } + + div(factor: number): V2 { + return new V2(this.x / factor, this.y / factor); + } + + pow(factor: number): V2 { + return new V2(this.x ** factor, this.y ** factor); + } + + sum(): number { + return this.x + this.y; + } + + len(): number { + return Math.sqrt(this.pow(2).sum()); + } + + abs(): V2 { + return new V2(Math.abs(this.x), Math.abs(this.y)); + } +} + +export interface Geometry { + pointInside(thisPos: V2, pointPos: V2): boolean; + collidesWith(thisPos: V2, other: Geometry, otherPos: V2): boolean; + render(r: Renderer, pos: V2, color: string): void; +} + +export class Rect implements Geometry { + constructor( + public width: number, + public height: number, + ) {} + + v2(): V2 { + return new V2(this.width, this.height); + } + + pointInside(thisPos: V2, pointPos: V2): boolean { + const { x, y } = thisPos; + const { width: w, height: h } = this; + const { x: ox, y: oy } = pointPos; + return ox > x && ox < x + w && oy > y && oy < y + h; + } + + collidesWith(thisPos: V2, other: Geometry, otherPos: V2): boolean { + if (other instanceof Rect) { + const { x, y } = thisPos; + const { width: w, height: h } = this; + const { x: ox, y: oy } = otherPos; + const { width: ow, height: oh } = other; + return ox + ow > x && ox < x + w && oy + oh > y && oy < y + h; + } + return other.collidesWith(otherPos, this, thisPos); + } + + render(r: Renderer, pos: V2, color: string): void { + r.fillRect(pos.x, pos.y, this.width, this.height, color); + } +} + +export class Circle implements Geometry { + constructor( + public radius: number, + ) {} + + pointInside(thisPos: V2, pointPos: V2): boolean { + return pointPos.sub(thisPos).len() < this.radius; + } + + collidesWith(thisPos: V2, other: Geometry, otherPos: V2): boolean { + if (other instanceof Circle) { + return otherPos.sub(thisPos).len() < other.radius; + } + if (other instanceof Rect) { + const circleDistance = thisPos.sub(otherPos).abs(); + const halfRect = other.v2().div(2); + if ( + circleDistance.x >= thisPos.x + halfRect.x || + circleDistance.y >= thisPos.y + halfRect.y + ) { + return false; + } + if ( + circleDistance.x < halfRect.x || circleDistance.y < halfRect.y + ) { + return true; + } + return circleDistance.add(halfRect).pow(2).sum() < this.radius ** 2; + } + return other.collidesWith(otherPos, other, thisPos); + } + + render(r: Renderer, pos: V2, color: string): void { + r.fillCirc(pos.x, pos.y, this.radius, color); + } +} + +export class Shape implements Geometry { + constructor( + public innerShapes: [V2, Geometry][], + ) {} + + pointInside(thisPos: V2, pointPos: V2): boolean { + return this.innerShapes + .some(([pos, shape]) => + shape.pointInside(thisPos.add(pos), pointPos) + ); + } + + collidesWith(thisPos: V2, other: Geometry, otherPos: V2): boolean { + return this.innerShapes + .some(([pos, shape]) => + other.collidesWith(thisPos.add(pos), shape, otherPos) + ); + } + + render(r: Renderer, pos: V2, color: string): void { + for (const [shapePos, shape] of this.innerShapes) { + shape.render(r, shapePos.add(pos), color); + } + } +} diff --git a/src/grid.ts b/src/grid.ts index 9d7a7ce..397bb55 100644 --- a/src/grid.ts +++ b/src/grid.ts @@ -191,6 +191,24 @@ class TransformingRenderer implements Renderer { const { r, t: { s, ox, oy } } = this; r.fillCirc(x * s + ox, y * s + oy, radius * s, color); } + strokeLine( + x0: number, + y0: number, + x1: number, + y1: number, + color: string, + lineWidth: number, + ): void { + const { r, t: { s, ox, oy } } = this; + r.strokeLine( + x0 * s + ox, + y0 * s + oy, + x1 * s + ox, + y1 * s + oy, + color, + lineWidth * s, + ); + } putImage( data: RendererImage, x: number, diff --git a/src/painter.ts b/src/painter.ts index 1ee3722..7862ed3 100644 --- a/src/painter.ts +++ b/src/painter.ts @@ -44,6 +44,16 @@ export class Painter implements Renderer { fillCirc(x: number, y: number, radius: number, color: string): void { this.r.fillCirc(x, y, radius, color); } + strokeLine( + x0: number, + y0: number, + x1: number, + y1: number, + color: string, + lineWidth: number, + ): void { + this.r.strokeLine(x0, y0, x1, y1, color, lineWidth); + } putImage( data: RendererImage, x: number, diff --git a/src/renderer.ts b/src/renderer.ts index bc215e4..5d9ae79 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -10,8 +10,9 @@ export interface Renderer { get height(): N; clear(color: string): void; fillRect(x: N, y: N, w: N, h: N, color: string): void; - strokeRect(x: N, y: N, w: N, h: N, color: string, lineWidth: number): void; + strokeRect(x: N, y: N, w: N, h: N, color: string, lineWidth: N): void; fillCirc(x: N, y: N, radius: N, color: string): void; + strokeLine(x0: N, y0: N, x1: N, y1: N, color: string, lineWidth: N): void; putImage(data: RendererImage, x: N, y: N): void; putImage(data: RendererImage, x: N, y: N, w: N, h: N): void; putImage( diff --git a/src/simulator.ts b/src/simulator.ts index 48dd179..53c55ae 100644 --- a/src/simulator.ts +++ b/src/simulator.ts @@ -1,3 +1,4 @@ +import { Circle, Geometry, Rect, V2 } from "./geometry.ts"; import { Grid } from "./grid.ts"; import { Mouse } from "./input.ts"; import { Painter } from "./painter.ts"; @@ -23,6 +24,9 @@ export class Simulator { if (this.toolbar.hover() === "break") { break hover; } + if (this.tooltip.hover() === "break") { + break hover; + } document.body.style.cursor = "default"; } @@ -36,11 +40,10 @@ export class Simulator { } class Toolbar { - private tools: ComponentFactory[] = [ + private tools: Component[] = [ new SwitchComponent(), new LedComponent(), ]; - private previews: Component[] = []; private lastWidth = 0; private lastHeight = 0; @@ -51,8 +54,6 @@ class Toolbar { private mouse: Mouse, private tooltip: Tooltip, ) { - this.fillPreviews(); - this.mouse.addOnPress(() => { const { x, y } = this.mouse; @@ -63,14 +64,14 @@ class Toolbar { return "bubble"; } - for (const [i, component] of this.previews.entries()) { + for (const [i, component] of this.tools.entries()) { if ( x >= i * 128 + 96 - 48 && y >= this.lastHeight - 100 - 48 && x < i * 128 + 96 + component.width * 32 + 32 && y < this.lastHeight - 100 + component.height * 32 + 32 ) { - this.tooltip.select(this.tools[i].newInstance()); + this.tooltip.select(this.tools[i]); return "stop"; } } @@ -79,14 +80,9 @@ class Toolbar { }); } - private fillPreviews(): void { - // this.previews = this.tools.map((tool) => tool.newInstance()); - this.previews = this.tools as unknown as Component[]; - } - hover(): "continue" | "break" { const { x, y } = this.mouse; - for (const [i, component] of this.previews.entries()) { + for (const [i, component] of this.tools.entries()) { if ( x >= i * 128 + 96 - 48 && y >= this.lastHeight - 100 - 48 && @@ -107,7 +103,7 @@ class Toolbar { this.lastHeight = r.height; r.fillRect(0, r.height - 200, r.width, 200, "#aaa"); - for (const [i, component] of this.previews.entries()) { + for (const [i, component] of this.tools.entries()) { r.strokeRect( i * 128 + 96 - 48, r.height - 100 - 48, @@ -135,31 +131,69 @@ class Tooltip { private shouldHover = false; + private isWiring = false; + private selectedOutputTerm?: ComponentWireTerm; + private selectedInputTerm?: ComponentWireTerm; + constructor( private circuit: Circuit, public mouse: Mouse, ) { this.mouse.addOnPress(() => { - if (!this.selectedComponent) { - return "bubble"; + if (this.selectedComponent) { + const x = Math.floor(this.mouse.x / 32); + const y = Math.floor(this.mouse.y / 32); + this.circuit.tryPlace(this.selectedComponent.clone(), x, y); + return "stop"; + } else if (!this.isWiring && this.selectedOutputTerm) { + this.isWiring = true; + return "stop"; + } else if (this.isWiring && this.selectedInputTerm) { + if (!this.selectedOutputTerm) { + throw new Error("invalid state"); + } + this.circuit.wire( + this.selectedOutputTerm, + this.selectedInputTerm, + ); + this.isWiring = false; + this.selectedOutputTerm = undefined; + this.selectedInputTerm = undefined; + } else { + this.isWiring = false; + this.selectedOutputTerm = undefined; } - const x = Math.floor(this.mouse.x / 32); - const y = Math.floor(this.mouse.y / 32); - this.circuit.place(this.selectedComponent, x, y); - return "stop"; + return "bubble"; }); } hover(): "continue" | "break" { - if (!this.selectedComponent) { - return "continue"; - } - const x = Math.floor(this.mouse.x / 32); - const y = Math.floor(this.mouse.y / 32); - if (!this.circuit.placeIsOccupied(x, y)) { - this.shouldHover = true; - return "break"; + if (this.selectedComponent) { + const x = Math.floor(this.mouse.x / 32); + const y = Math.floor(this.mouse.y / 32); + + this.shouldHover = false; + if (!this.circuit.placeIsOccupied(x, y)) { + this.shouldHover = true; + return "break"; + } + } else if (!this.isWiring) { + const term = this.circuit.hoveredOutputTerminal(); + this.selectedOutputTerm = undefined; + if (term) { + this.selectedOutputTerm = term; + document.body.style.cursor = "pointer"; + return "break"; + } + } else { + const term = this.circuit.hoveredInputTerminal(); + this.selectedInputTerm = undefined; + if (term) { + this.selectedInputTerm = term; + document.body.style.cursor = "pointer"; + return "break"; + } } return "continue"; @@ -168,7 +202,43 @@ class Tooltip { render(r: Renderer): void { const x = Math.floor(this.mouse.x / 32); const y = Math.floor(this.mouse.y / 32); - this.selectedComponent?.renderTransparent(r, x * 32 + 16, y * 32 + 16); + if (this.selectedComponent) { + if (this.shouldHover) { + this.selectedComponent + .renderTransparent(r, x * 32 + 16, y * 32 + 16); + } + } else if (!this.isWiring) { + const output = this.selectedOutputTerm; + if (output) { + output.geometry.render(r, output.pos, "#777"); + } + } else if (this.isWiring) { + const output = this.selectedOutputTerm; + if (!output) { + throw new Error("invalid state"); + } + const input = this.selectedInputTerm; + if (input) { + input.geometry.render(r, input.pos, "#777"); + r.strokeLine( + output.pos.x, + output.pos.y, + input.pos.x, + input.pos.y, + "222", + 2, + ); + } else { + r.strokeLine( + output.pos.x, + output.pos.y, + this.mouse.x, + this.mouse.y, + "222", + 2, + ); + } + } } select(component: Component): void { @@ -186,8 +256,27 @@ type PlacedComponent = { y: number; }; +interface WireSource { + get x(): number; + get y(): number; + high(): boolean; +} + +interface WireDest { + get x(): number; + get y(): number; + setHigh(): void; + setLow(): void; +} + +type Wire = { + source: WireSource; + dest: WireDest; +}; + class Circuit { private components: PlacedComponent[] = []; + private wires: Wire[] = []; constructor( private mouse: Mouse, @@ -215,37 +304,101 @@ class Circuit { } placeIsOccupied(x: number, y: number): boolean { - return this.components.some((c) => c.x == x && c.y == y); + return this.components.some((c) => + c.component.collidesWith( + new V2(c.x, c.y), + c.component, + new V2(x, y), + ) + ); } - hover(): "continue" | "break" { - return "continue"; + hoveredOutputTerminal(): ComponentWireTerm | null { + for (const { x, y, component } of this.components) { + const term = component.hoveredOutputTerminal( + new V2(x, y).mul(32), + new V2(this.mouse.x, this.mouse.y), + ); + if (term !== null) { + return term; + } + } + return null; } - place(component: Component, x: number, y: number): void { + hoveredInputTerminal(): ComponentWireTerm | null { + for (const { x, y, component } of this.components) { + const term = component.hoveredInputTerminal( + new V2(x, y).mul(32), + new V2(this.mouse.x, this.mouse.y), + ); + if (term !== null) { + return term; + } + } + return null; + } + + tryPlace(component: Component, x: number, y: number): void { + if (this.placeIsOccupied(x, y)) { + return; + } this.components.push({ component, x, y }); } + wire(source: WireSource, dest: WireDest): void { + this.wires.push({ source, dest }); + } + render(r: Renderer): void { + for (const { source, dest } of this.wires) { + r.strokeLine(source.x, source.y, dest.x, dest.y, "black", 3); + } for (const { component, x, y } of this.components) { component.render(r, x * 32 + 16, y * 32 + 16); } } } +class ComponentWireTerm implements WireSource, WireDest { + constructor( + public pos: V2, + public geometry: Geometry, + public component: Component, + ) {} + get x(): number { + return this.pos.x; + } + get y(): number { + return this.pos.y; + } + high(): boolean { + throw new Error("Method not implemented."); + } + setHigh(): void { + throw new Error("Method not implemented."); + } + setLow(): void { + throw new Error("Method not implemented."); + } +} + interface Component { get width(): number; get height(): number; + + collidesWith(thisPos: V2, other: Component, otherPos: V2): boolean; + collidesWithGeometry(thisPos: V2, other: Geometry, otherPos: V2): boolean; render(r: Renderer, x: number, y: number): void; renderTransparent(r: Renderer, x: number, y: number): void; click?(x: number, y: number): void; + hoveredInputTerminal(pos: V2, mousePos: V2): ComponentWireTerm | null; + hoveredOutputTerminal(pos: V2, mousePos: V2): ComponentWireTerm | null; + + clone(): Component; } -interface ComponentFactory { - newInstance(): Component; -} - -class SwitchComponent implements Component, ComponentFactory { +class SwitchComponent implements Component { private graphicOn = new Painter(96, 64); private graphicOff = new Painter(96, 64); @@ -254,6 +407,8 @@ class SwitchComponent implements Component, ComponentFactory { public width = 1; public height = 1; + private geometry = new Rect(2, 1); + constructor() { this.graphicOff.fillRect(64, 30, 16, 4, "black"); this.graphicOff.fillCirc(48 + 32, 32, 8, "black"); @@ -264,10 +419,18 @@ class SwitchComponent implements Component, ComponentFactory { this.graphicOn.fillCirc(48, 32, 16, "red"); } - newInstance(): Component { + clone(): Component { return new SwitchComponent(); } + collidesWith(thisPos: V2, other: Component, otherPos: V2): boolean { + return other.collidesWithGeometry(otherPos, this.geometry, thisPos); + } + + collidesWithGeometry(thisPos: V2, other: Geometry, otherPos: V2): boolean { + return this.geometry.collidesWith(thisPos, other, otherPos); + } + render(r: Renderer, x: number, y: number): void { const graphic = this.switchOn ? this.graphicOn : this.graphicOff; graphic.render(r, x - 48, y - 32); @@ -287,9 +450,29 @@ class SwitchComponent implements Component, ComponentFactory { this.switchOn = !this.switchOn; } } + + hoveredInputTerminal( + _componentPos: V2, + _mousePos: V2, + ): ComponentWireTerm | null { + return null; + } + + hoveredOutputTerminal( + componentPos: V2, + mousePos: V2, + ): ComponentWireTerm | null { + const geometry = new Circle(8); + const pos = new V2(48, 16).add(componentPos); + + if (geometry.pointInside(pos, mousePos)) { + return new ComponentWireTerm(pos, geometry, this); + } + return null; + } } -class LedComponent implements Component, ComponentFactory { +class LedComponent implements Component { private graphicOn = new Painter(96, 64); private graphicOff = new Painter(96, 64); @@ -298,6 +481,8 @@ class LedComponent implements Component, ComponentFactory { public width = 1; public height = 1; + private geometry = new Rect(2, 1); + constructor() { this.graphicOff.fillRect(16, 30, 16, 4, "black"); this.graphicOff.fillCirc(16, 32, 8, "black"); @@ -308,10 +493,22 @@ class LedComponent implements Component, ComponentFactory { this.graphicOn.fillCirc(48, 32, 16, "red"); } - newInstance(): Component { + clone(): Component { return new LedComponent(); } + collidesWith(thisPos: V2, other: Component, otherPos: V2): boolean { + return other.collidesWithGeometry( + otherPos, + this.geometry, + thisPos.sub(new V2(0, 0)), + ); + } + + collidesWithGeometry(thisPos: V2, other: Geometry, otherPos: V2): boolean { + return this.geometry.collidesWith(thisPos, other, otherPos); + } + render(r: Renderer, x: number, y: number): void { const graphic = this.switchOn ? this.graphicOn : this.graphicOff; graphic.render(r, x - 48, y - 32); @@ -321,4 +518,24 @@ class LedComponent implements Component, ComponentFactory { const graphic = this.switchOn ? this.graphicOn : this.graphicOff; graphic.render(r, x - 48, y - 32, 0.5); } + + hoveredInputTerminal( + componentPos: V2, + mousePos: V2, + ): ComponentWireTerm | null { + const geometry = new Circle(8); + const pos = new V2(-16, 16).add(componentPos); + + if (geometry.pointInside(pos, mousePos)) { + return new ComponentWireTerm(pos, geometry, this); + } + return null; + } + + hoveredOutputTerminal( + _componentPos: V2, + _mousePos: V2, + ): ComponentWireTerm | null { + return null; + } }