add wiring

This commit is contained in:
sfja 2025-05-13 17:58:16 +02:00
parent 6ead8c97e2
commit 36b29bdcef
6 changed files with 450 additions and 41 deletions

View File

@ -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,

146
src/geometry.ts Normal file
View File

@ -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);
}
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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(

View File

@ -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;
}
}