From 0ec608ee545059504abe1e647e14036b701d28ed Mon Sep 17 00:00:00 2001 From: sfja Date: Wed, 20 May 2026 03:11:40 +0200 Subject: [PATCH] selecting and wiring --- editor/eslint.config.js | 8 +- editor/src/editor/Board.ts | 233 ++++++++++++++++++++-------------- editor/src/editor/Cx.ts | 131 ++++++++++++++----- editor/src/editor/Renderer.ts | 141 +++++++++++--------- editor/src/editor/states.ts | 114 +++++++++++++++-- 5 files changed, 431 insertions(+), 196 deletions(-) diff --git a/editor/eslint.config.js b/editor/eslint.config.js index ef614d2..0b6e0ce 100644 --- a/editor/eslint.config.js +++ b/editor/eslint.config.js @@ -11,12 +11,18 @@ export default defineConfig([ files: ['**/*.{ts,tsx}'], extends: [ js.configs.recommended, - tseslint.configs.recommended, + tseslint.configs.recommendedTypeChecked, reactHooks.configs.flat.recommended, reactRefresh.configs.vite, ], languageOptions: { globals: globals.browser, + parserOptions: { + projectService: true, + } }, + rules: { + "@typescript-eslint/no-unused-vars": "off" + } }, ]) diff --git a/editor/src/editor/Board.ts b/editor/src/editor/Board.ts index 1accdc8..2eed23a 100644 --- a/editor/src/editor/Board.ts +++ b/editor/src/editor/Board.ts @@ -1,25 +1,61 @@ +import type { Selection } from "./Cx"; import type { Renderer } from "./Renderer"; import { pointInsideRect, rectsCollide, v2, V2 } from "./V2"; export class Board { - private components: Component[] = []; + private components: PlacedComponent[] = []; - private hoveredOverInput: [Component, number] | null = null; - private hoveredOverOutput: [Component, number] | null = null; + private hoveredOverInput: [PlacedComponent, number] | null = null; + private hoveredOverOutput: [PlacedComponent, number] | null = null; - canPlaceComponent(def: ComponentDef, pos: V2): boolean { + constructor() {} + + canPlaceComponent(kind: ComponentKind, pos: V2): boolean { return !this.components.some((comp) => - rectsCollide(comp.pos, comp.def.size, pos, def.size), + rectsCollide(comp.pos, comp.kind.size, pos, kind.size), ); } - placeComponent(def: ComponentDef, pos: V2) { - this.components.push({ def, pos }); + placeComponent(kind: ComponentKind, pos: V2) { + this.components.push({ kind: kind, pos }); } - render(r: Renderer) { + render(r: Renderer, selection: Selection | null) { for (const comp of this.components) { - r.drawComponent(comp, this.hoveredOverInput, this.hoveredOverOutput); + const { pos, kind } = comp; + if (selection?.isComponentSelected(comp)) { + r.drawComponentBodySelected(pos, kind); + } else { + r.drawComponentBody(pos, kind); + } + + for (const { i, pinOffset } of kind.inputPinIter()) { + if (kind.inputs[i] !== null) { + throw new Error("pin text not implemented"); + } + r.drawComponentInputPin(pos, pinOffset); + + if ( + this.hoveredOverInput?.[0] === comp && + this.hoveredOverInput[1] === i + ) { + r.drawComponentInputPinHover(pos, pinOffset); + } + } + + for (const { i, pinOffset } of kind.outputPinIter()) { + if (kind.outputs[i] !== null) { + throw new Error("pin text not implemented"); + } + r.drawComponentOutputPin(pos, kind, pinOffset); + + if ( + this.hoveredOverOutput?.[0] === comp && + this.hoveredOverOutput[1] === i + ) { + r.drawComponentOutputPinHover(pos, kind, pinOffset); + } + } } } @@ -30,10 +66,8 @@ export class Board { for (const comp of this.components) { const { pos: { x, y }, - def: { - size: { x: w, y: h }, - inputs, - outputs, + kind: { + size: { x: w }, }, } = comp; @@ -41,25 +75,19 @@ export class Board { !pointInsideRect( pos, comp.pos.sub(v2(5, 5)), - comp.def.size.add(v2(10, 10)), + comp.kind.size.add(v2(10, 10)), ) ) { continue; } - { - const pinSpace = h / (inputs.length + 1); - for (let i = 0; i < inputs.length; ++i) { - if (v2(x, y + (i + 1) * pinSpace).distance(pos) < 5) { - this.hoveredOverInput = [comp, i]; - } + for (const { i, pinOffset } of comp.kind.inputPinIter()) { + if (v2(x, y + pinOffset).distance(pos) < 6) { + this.hoveredOverInput = [comp, i]; } } - { - const pinSpace = h / (outputs.length + 1); - for (let i = 0; i < outputs.length; ++i) { - if (v2(x + w, y + (i + 1) * pinSpace).distance(pos) < 5) { - this.hoveredOverOutput = [comp, i]; - } + for (const { i, pinOffset } of comp.kind.outputPinIter()) { + if (v2(x + w, y + pinOffset).distance(pos) < 6) { + this.hoveredOverOutput = [comp, i]; } } } @@ -67,17 +95,15 @@ export class Board { handleMouseClick( pos: V2, - inputPinClicked: (comp: Component, i: number) => void, - outputPinClicked: (comp: Component, i: number) => void, - componentClicked: (comp: Component) => void, + inputPinClicked: (comp: PlacedComponent, i: number) => void, + outputPinClicked: (comp: PlacedComponent, i: number) => void, + componentClicked: (comp: PlacedComponent) => void, ): "handled" | "not handled" { for (const comp of this.components) { const { pos: { x, y }, - def: { - size: { x: w, y: h }, - inputs, - outputs, + kind: { + size: { x: w }, }, } = comp; @@ -85,27 +111,21 @@ export class Board { !pointInsideRect( pos, comp.pos.sub(v2(5, 5)), - comp.def.size.add(v2(10, 10)), + comp.kind.size.add(v2(10, 10)), ) ) { continue; } - { - const pinSpace = h / (inputs.length + 1); - for (let i = 0; i < inputs.length; ++i) { - if (v2(x, y + (i + 1) * pinSpace).distance(pos) < 5) { - inputPinClicked(comp, i); - return "handled"; - } + for (const { i, pinOffset } of comp.kind.inputPinIter()) { + if (v2(x, y + pinOffset).distance(pos) < 6) { + inputPinClicked(comp, i); + return "handled"; } } - { - const pinSpace = h / (outputs.length + 1); - for (let i = 0; i < outputs.length; ++i) { - if (v2(x + w, y + (i + 1) * pinSpace).distance(pos) < 5) { - outputPinClicked(comp, i); - 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); @@ -113,69 +133,96 @@ export class Board { } return "not handled"; } + + componentsInRect(pos: V2, size: V2): PlacedComponent[] { + return this.components.filter((comp) => + rectsCollide(pos, size, comp.pos, comp.kind.size), + ); + } } export class ComponentRepo { - private defs = new Map(); + private defs = new Map(); static withDefaults(): ComponentRepo { const repo = new ComponentRepo(); - repo.add("input", { - label: "input", - size: v2(80, 40), - inputs: [], - outputs: [null], - }); - repo.add("output", { - label: "output", - size: v2(80, 40), - inputs: [null], - outputs: [], - }); - repo.add("and", { - label: "and", - size: v2(80, 40), - inputs: [null, null], - outputs: [null], - }); - repo.add("or", { - label: "or", - size: v2(80, 40), - inputs: [null, null], - outputs: [null], - }); - repo.add("not", { - label: "not", - size: v2(80, 40), - inputs: [null], - outputs: [null], - }); + for (const { label, size, inputs, outputs } of defaultDefs) { + repo.add(label, new ComponentKind(size, label, inputs, outputs)); + } return repo; } - add(ident: string, def: ComponentDef) { - this.defs.set(ident, def); + add(ident: string, kind: ComponentKind) { + this.defs.set(ident, kind); } - get(ident: string): ComponentDef { - const def = this.defs.get(ident); - if (!def) { + get(ident: string): ComponentKind { + const kind = this.defs.get(ident); + if (!kind) { throw new Error("should be defined"); } - return def; + return kind; } } -export type Component = { - def: ComponentDef; +export type PlacedComponent = { + kind: ComponentKind; pos: V2; }; -export type ComponentDef = { - size: V2; - label: string; - inputs: (string | null)[]; - outputs: (string | null)[]; -}; +export class ComponentKind { + constructor( + public size: V2, + public label: string, + public inputs: (string | null)[], + 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), + })); + } + outputPinIter(): { i: number; pinOffset: number }[] { + return this.outputs.map((_, i) => ({ + i, + pinOffset: ((i + 1) * this.size.y) / (this.outputs.length + 1), + })); + } +} + +const defaultDefs = [ + { + label: "input", + size: v2(80, 40), + inputs: [], + outputs: [null], + }, + { + label: "output", + size: v2(80, 40), + inputs: [null], + outputs: [], + }, + { + label: "and", + size: v2(80, 40), + inputs: [null, null], + outputs: [null], + }, + { + label: "or", + size: v2(80, 40), + inputs: [null, null], + outputs: [null], + }, + { + label: "not", + size: v2(80, 40), + inputs: [null], + outputs: [null], + }, +]; diff --git a/editor/src/editor/Cx.ts b/editor/src/editor/Cx.ts index 0e9bada..7f063e5 100644 --- a/editor/src/editor/Cx.ts +++ b/editor/src/editor/Cx.ts @@ -1,28 +1,35 @@ -import { Board, ComponentRepo } from "./Board"; +import { Board, ComponentRepo, type PlacedComponent } from "./Board"; import { Renderer } from "./Renderer"; import * as states from "./states"; import { v2, V2 } from "./V2"; +export type Tool = string; + export class Cx { public offset = v2(0, 0); private renderNeeded = false; private state = new states.Normal(this) as states.State; private updateActions: (() => void)[] = []; - private selectionBox: SelectionBox | null = null; + public selectionBox: SelectionBox | null = null; private componentPlacer: ComponentPlacer | null = null; + public selection: Selection | null = null; + public connectingWire: ConnectingWire | null = null; public board = new Board(); public componentRepo = ComponentRepo.withDefaults(); + public keysPressed = new Set(); + render(canvas: HTMLCanvasElement) { const r = new Renderer(canvas, this.offset); r.clear(); r.drawGrid(); - this.board.render(r); + this.board.render(r, this.selection); this.selectionBox?.render(r); this.componentPlacer?.render(r); + this.connectingWire?.render(r); } renderIfNeeded(canvas: HTMLCanvasElement) { @@ -32,24 +39,27 @@ export class Cx { } } - setRenderNeeded() { - this.renderNeeded = true; - } - mouseDown(pos: V2) { this.state.onMouseDown?.(pos); + this.renderNeeded = true; } mouseUp(pos: V2) { this.state.onMouseUp?.(pos); + this.renderNeeded = true; } mouseMove(deltaPos: V2, pos: V2) { this.state.onMouseMove?.(deltaPos, pos); + this.renderNeeded = true; } keyDown(key: string) { + this.keysPressed.add(key); this.state.onKeyDown?.(key); + this.renderNeeded = true; } keyUp(key: string) { + this.keysPressed.delete(key); this.state.onKeyUp?.(key); + this.renderNeeded = true; } selectTool(tool: Tool) { switch (tool) { @@ -67,6 +77,9 @@ export class Cx { this.transitionTo(new states.Normal(this)); } } + selectedTool(): Tool { + return this.state.selectedTool?.() || "select"; + } addUpdateAction(action: () => void): object { this.updateActions.push(action); @@ -82,6 +95,7 @@ export class Cx { transitionTo(newState: states.State) { this.state.leaveState?.(); this.state = newState; + console.log(`Entering state ${newState.constructor.name}`); this.state.enterState?.(); this.notifyListeners(); } @@ -95,41 +109,19 @@ export class Cx { moveOffset(deltaPos: V2) { this.offset.x += deltaPos.x; this.offset.y += deltaPos.y; - this.renderNeeded = true; - } - - addSelectionRect(pos: V2) { - this.selectionBox = new SelectionBox(pos, v2(0, 0)); - this.renderNeeded = true; - } - - removeSelectionRect() { - this.selectionBox = null; - this.renderNeeded = true; - } - - moveSelectionRect(deltaPos: V2) { - if (this.selectionBox) { - this.selectionBox.size.x += deltaPos.x; - this.selectionBox.size.y += deltaPos.y; - this.renderNeeded = true; - } } addComponentPlacer(pos: V2, size: V2) { this.componentPlacer = new ComponentPlacer(pos, size); - this.renderNeeded = true; } removeComponentPlacer() { this.componentPlacer = null; - this.renderNeeded = true; } setComponentPlacerPos(pos: V2) { if (this.componentPlacer) { this.componentPlacer.pos = pos; - this.renderNeeded = true; } } @@ -141,14 +133,27 @@ export class Cx { } export class SelectionBox { - constructor( - public pos: V2, - public size: V2, - ) {} + public size = v2(0, 0); + + constructor(public pos: V2) {} render(r: Renderer) { r.drawSelectionBox(this.pos, this.size); } + + move(deltaPos: V2) { + this.size = this.size.add(deltaPos); + } + + normalized(): { pos: V2; size: V2 } { + const normalizedAxis = (p: number, s: number): [number, number] => + s >= 0 ? [p, s] : [p + s, -s]; + + const [x, w] = normalizedAxis(this.pos.x, this.size.x); + const [y, h] = normalizedAxis(this.pos.y, this.size.y); + + return { pos: v2(x, y), size: v2(w, h) }; + } } export class ComponentPlacer { @@ -162,4 +167,62 @@ export class ComponentPlacer { } } -export type Tool = string; +export class Selection { + selectedComponents = new Set(); + + addComponent(comp: PlacedComponent) { + this.selectedComponents.add(comp); + } + + toggleComponent(comp: PlacedComponent) { + if (this.selectedComponents.has(comp)) { + this.selectedComponents.delete(comp); + } else { + this.selectedComponents.add(comp); + } + } + + isComponentSelected(comp: PlacedComponent) { + return this.selectedComponents.has(comp); + } +} + +export class ConnectingWire { + constructor( + public kind: ConnectingWireKind, + public pos: V2, + ) {} + + render(r: Renderer) { + switch (this.kind.tag) { + case "InputPin": + case "OutputPin": + } + r.drawConnectingWire(this.beginPos(), this.pos); + } + + move(pos: V2) { + this.pos = pos; + } + + 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, + ); + 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, + ); + } + } +} + +export type ConnectingWireKind = + | { tag: "InputPin"; comp: PlacedComponent; i: number } + | { tag: "OutputPin"; comp: PlacedComponent; i: number }; diff --git a/editor/src/editor/Renderer.ts b/editor/src/editor/Renderer.ts index b75e75b..9dee875 100644 --- a/editor/src/editor/Renderer.ts +++ b/editor/src/editor/Renderer.ts @@ -1,4 +1,4 @@ -import type { Component } from "./Board"; +import type { ComponentKind } from "./Board"; import { v2, type V2 } from "./V2"; export class Renderer { @@ -57,23 +57,10 @@ export class Renderer { c.strokeRect(x, y, w, h); } - drawComponent( - comp: Component, - hoveredOverInput: [Component, number] | null, - hoveredOverOutput: [Component, number] | null, - ) { + drawComponentBody(pos: V2, kind: ComponentKind) { const { c, offset } = this; - const { - def: { - size: { x: w, y: h }, - label, - inputs, - outputs, - }, - pos, - } = comp; - - const [x, y] = [pos.x + offset.x, pos.y + offset.y]; + const { x, y } = pos.add(offset); + const { x: w, y: h } = kind.size; c.fillStyle = `#6abbde`; c.fillRect(x, y, w, h); @@ -83,52 +70,92 @@ export class Renderer { c.fillStyle = `#333333`; c.font = "bold 16px monospace"; - const textMetrix = c.measureText(label); + const textMetrix = c.measureText(kind.label); c.fillText( - label, + kind.label, x + w / 2 - textMetrix.width / 2, y + 13 + h / 2 - 16 / 2, ); + } + drawComponentBodySelected(pos: V2, kind: ComponentKind) { + const { c, offset } = this; + const { x, y } = pos.add(offset); + const { x: w, y: h } = kind.size; - { - const pinSpace = h / (inputs.length + 1); - for (let i = 0; i < inputs.length; ++i) { - if (inputs[i] !== null) { - throw new Error("pin text not implemented"); - } - c.fillStyle = `#333333`; - c.beginPath(); - c.arc(x, y + (i + 1) * pinSpace, 4, 0, Math.PI * 2); - c.fill(); + c.fillStyle = `#6abbde`; + c.fillRect(x, y, w, h); + c.strokeStyle = `#ff8800`; + c.lineWidth = 2; + c.strokeRect(x, y, w, h); - if (hoveredOverInput?.[0] === comp && hoveredOverInput[1] === i) { - c.strokeStyle = `#bbbbbb`; - c.lineWidth = 2; - c.beginPath(); - c.arc(x, y + (i + 1) * pinSpace, 5, 0, Math.PI * 2); - c.stroke(); - } - } - } - { - const pinSpace = h / (outputs.length + 1); - for (let i = 0; i < outputs.length; ++i) { - if (outputs[i] !== null) { - throw new Error("pin text not implemented"); - } - c.fillStyle = `#333333`; - c.beginPath(); - c.arc(x + w, y + (i + 1) * pinSpace, 4, 0, Math.PI * 2); - c.fill(); + c.fillStyle = `#333333`; + c.font = "bold 16px monospace"; + const textMetrix = c.measureText(kind.label); + c.fillText( + kind.label, + x + w / 2 - textMetrix.width / 2, + y + 13 + h / 2 - 16 / 2, + ); + } - if (hoveredOverOutput?.[0] === comp && hoveredOverOutput[1] === i) { - c.strokeStyle = `#eee`; - c.lineWidth = 2; - c.beginPath(); - c.arc(x + w, y + (i + 1) * pinSpace, 5, 0, Math.PI * 2); - c.stroke(); - } - } - } + drawComponentInputPin(pos: V2, pinOffset: number) { + const { c, offset } = this; + const { x, y } = pos.add(offset); + + c.fillStyle = `#333333`; + c.beginPath(); + c.arc(x, y + pinOffset, 4, 0, Math.PI * 2); + c.fill(); + } + + drawComponentInputPinHover(pos: V2, pinOffset: number) { + const { c, offset } = this; + const { x, y } = pos.add(offset); + + c.strokeStyle = `#eee`; + c.lineWidth = 2; + c.beginPath(); + c.arc(x, y + pinOffset, 5, 0, Math.PI * 2); + c.stroke(); + } + + drawComponentOutputPin(pos: V2, kind: ComponentKind, pinOffset: number) { + const { c, offset } = this; + const { + size: { x: w }, + } = kind; + const { x, y } = pos.add(offset); + + c.fillStyle = `#333333`; + c.beginPath(); + c.arc(x + w, y + pinOffset, 4, 0, Math.PI * 2); + c.fill(); + } + + drawComponentOutputPinHover(pos: V2, kind: ComponentKind, pinOffset: number) { + const { c, offset } = this; + const { + size: { x: w }, + } = kind; + const { x, y } = pos.add(offset); + + c.strokeStyle = `#eee`; + c.lineWidth = 2; + c.beginPath(); + c.arc(x + w, y + pinOffset, 5, 0, Math.PI * 2); + c.stroke(); + } + + drawConnectingWire(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(); } } diff --git a/editor/src/editor/states.ts b/editor/src/editor/states.ts index 121803f..89725ea 100644 --- a/editor/src/editor/states.ts +++ b/editor/src/editor/states.ts @@ -1,5 +1,6 @@ -import type { ComponentDef } from "./Board"; -import type { Cx, Tool } from "./Cx"; +import { type ComponentKind } from "./Board"; +import { ConnectingWire, Selection } from "./Cx"; +import { SelectionBox, type Cx, type Tool } from "./Cx"; import { v2, type V2 } from "./V2"; export interface State { @@ -20,21 +21,45 @@ export class Normal implements State { if ( this.cx.board.handleMouseClick( pos.sub(this.cx.offset), - (comp, i) => {}, - (comp, i) => {}, - (comp) => {}, + (comp, i) => { + console.log({ comp, i }); + this.cx.connectingWire = new ConnectingWire( + { + tag: "InputPin", + comp, + i, + }, + pos, + ); + this.cx.transitionTo(new Wiring(this.cx)); + }, + (comp, i) => { + this.cx.connectingWire = new ConnectingWire( + { + tag: "OutputPin", + comp, + i, + }, + pos, + ); + this.cx.transitionTo(new Wiring(this.cx)); + }, + (comp) => { + this.cx.selection = new Selection(); + this.cx.selection.addComponent(comp); + this.cx.transitionTo(new Selecting(this.cx)); + }, ) === "handled" ) { return; } else { - this.cx.addSelectionRect(pos); + this.cx.selectionBox = new SelectionBox(pos); this.cx.transitionTo(new SelectingBox(this.cx)); } } onMouseMove(_deltaPos: V2, pos: V2): void { this.cx.board.updateMouseHover(pos.sub(this.cx.offset)); - this.cx.setRenderNeeded(); } onKeyDown(key: string): void { @@ -88,7 +113,7 @@ export class Panning implements State { } export class Placing implements State { - private compDef: ComponentDef; + private compDef: ComponentKind; constructor( private cx: Cx, @@ -131,21 +156,88 @@ export class Placing implements State { export class Selecting implements State { constructor(private cx: Cx) {} + + onMouseDown(pos: V2): void { + if ( + this.cx.board.handleMouseClick( + pos.sub(this.cx.offset), + (_comp, _i) => {}, + (_comp, _i) => {}, + (comp) => { + if (this.cx.keysPressed.has("Control")) { + this.cx.selection?.toggleComponent(comp); + } else { + this.cx.selection = new Selection(); + this.cx.selection.addComponent(comp); + } + }, + ) === "handled" + ) { + return; + } else { + if (this.cx.keysPressed.has("Control")) { + this.cx.selectionBox = new SelectionBox(pos); + this.cx.transitionTo(new SelectingBox(this.cx)); + } else { + this.cx.selection = null; + this.cx.transitionTo(new Normal(this.cx)); + } + } + } } export class SelectingBox implements State { constructor(private cx: Cx) {} onMouseUp(_pos: V2): void { - this.cx.removeSelectionRect(); - this.cx.transitionTo(new Normal(this.cx)); + if (!this.cx.selectionBox) { + throw new Error("expected selectionBox to active"); + } + const { pos, size } = this.cx.selectionBox.normalized(); + const selected = this.cx.board.componentsInRect( + pos.sub(this.cx.offset), + size, + ); + if (selected.length > 0) { + this.cx.selection ??= new Selection(); + } + for (const comp of selected) { + this.cx.selection?.addComponent(comp); + } + if (this.cx.selection) { + this.cx.selectionBox = null; + this.cx.transitionTo(new Selecting(this.cx)); + } else { + this.cx.selectionBox = null; + this.cx.transitionTo(new Normal(this.cx)); + } } onMouseMove(deltaPos: V2): void { - this.cx.moveSelectionRect(deltaPos); + this.cx.selectionBox?.move(deltaPos); } selectedTool(): Tool | null { return "select"; } } + +export class Wiring implements State { + constructor(private cx: Cx) {} + + 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.board.updateMouseHover(pos.sub(this.cx.offset)); + } + + onKeyDown(key: string): void { + if (key === "Escape") { + this.cx.transitionTo(new Normal(this.cx)); + this.cx.connectingWire = null; + return; + } + } +}