velxio/frontend/src/utils/loadExample.ts

301 lines
12 KiB
TypeScript
Raw Normal View History

/**
* Shared utility to load an example project into the editor and simulator stores.
* Used by both ExamplesPage (gallery click) and ExampleLoaderPage (direct URL).
*/
import type { ExampleProject } from '../data/examples';
import type { BoardKind } from '../types/board';
import { useEditorStore } from '../store/useEditorStore';
import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../store/useSimulatorStore';
import { useElectricalStore } from '../store/useElectricalStore';
fix(loadExample): clear currentProject before mutating stores Critical data-loss bug. Repro: 1. User opens a saved project at /<username>/<slug>. The page sets useProjectStore.currentProject = { id, slug, ownerUsername, ... }. Auto-save kicks in and starts watching simulator/editor stores. 2. User clicks the "Examples" link, picks an example, hits Run. 3. loadExample mutates useSimulatorStore (setComponents, setWires, addBoard, removeBoard) and useEditorStore (loadFiles). 4. Auto-save sees the change. Its eligibility check finds currentProject still pointing at the user's saved project (we never touched useProjectStore). It debounces a PUT /api/projects/<old-id> with the EXAMPLE's components/wires/ files. The user's saved project is overwritten with the example contents. The URL changing to /editor isn't enough — useProjectStore is store state, not router state. ProjectPage / ProjectByIdPage set it on mount; nothing clears it when the user navigates away. Fix: loadExample calls useProjectStore.getState().clearCurrentProject() BEFORE the simulator/editor mutations. autoSaveImpl is subscribed to useProjectStore via subscribe((s, prev) => ... reset() if id changed), and Zustand notifies subscribers synchronously inside set(), so the reset (projectId=null, baseline hash=null) runs in the same tick. Every subsequent setComponents/setWires/loadFiles fires onChange in the hook, which now sees projectId=null and returns early. No PUT ever goes out. The reset is order-sensitive: it must run BEFORE the store mutations or the hook would already have queued a save with the old projectId before we cleared. Comment in the source spells this out so it doesn't get reordered in a future refactor. In-flight saves are not affected: buildSavePayload() snapshots state before its `await updateProject(...)`, so a save that started right before the example load still sends the user's pre-example state to the right project. Worst case: the save completes after clear, and the hook quietly returns idle. Build verified (vite OSS+pro, 285 SEO pages). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:02:05 +07:00
import { useProjectStore } from '../store/useProjectStore';
import { useVfsStore } from '../store/useVfsStore';
import { isBoardComponent } from './boardPinMapping';
import { getInstalledLibraries, installLibrary } from '../services/libraryService';
import { trackOpenExample } from './analytics';
import { stripBrandPrefix } from './exampleToBuildNetlistInput';
export interface LibraryInstallProgress {
total: number;
done: number;
current: string;
}
/**
* Install any missing Arduino libraries required by an example.
* Calls onProgress for UI updates; silently continues on failure.
*/
export async function ensureLibraries(
libs: string[],
onProgress?: (progress: LibraryInstallProgress | null) => void,
): Promise<void> {
if (libs.length === 0) return;
try {
const installed = await getInstalledLibraries();
const installedNames = new Set(
installed.map((l) => (l.library?.name ?? l.name ?? '').toLowerCase()),
);
const missing = libs.filter((l) => !installedNames.has(l.toLowerCase()));
if (missing.length === 0) return;
onProgress?.({ total: missing.length, done: 0, current: missing[0] });
for (let i = 0; i < missing.length; i++) {
onProgress?.({ total: missing.length, done: i, current: missing[i] });
await installLibrary(missing[i]);
}
onProgress?.(null);
} catch {
onProgress?.(null);
}
}
/**
* Load an example project into the editor + simulator stores.
* Does NOT navigate the caller is responsible for navigation.
*/
export async function loadExample(
example: ExampleProject,
onLibraryProgress?: (progress: LibraryInstallProgress | null) => void,
): Promise<void> {
trackOpenExample(example.title);
fix(loadExample): clear currentProject before mutating stores Critical data-loss bug. Repro: 1. User opens a saved project at /<username>/<slug>. The page sets useProjectStore.currentProject = { id, slug, ownerUsername, ... }. Auto-save kicks in and starts watching simulator/editor stores. 2. User clicks the "Examples" link, picks an example, hits Run. 3. loadExample mutates useSimulatorStore (setComponents, setWires, addBoard, removeBoard) and useEditorStore (loadFiles). 4. Auto-save sees the change. Its eligibility check finds currentProject still pointing at the user's saved project (we never touched useProjectStore). It debounces a PUT /api/projects/<old-id> with the EXAMPLE's components/wires/ files. The user's saved project is overwritten with the example contents. The URL changing to /editor isn't enough — useProjectStore is store state, not router state. ProjectPage / ProjectByIdPage set it on mount; nothing clears it when the user navigates away. Fix: loadExample calls useProjectStore.getState().clearCurrentProject() BEFORE the simulator/editor mutations. autoSaveImpl is subscribed to useProjectStore via subscribe((s, prev) => ... reset() if id changed), and Zustand notifies subscribers synchronously inside set(), so the reset (projectId=null, baseline hash=null) runs in the same tick. Every subsequent setComponents/setWires/loadFiles fires onChange in the hook, which now sees projectId=null and returns early. No PUT ever goes out. The reset is order-sensitive: it must run BEFORE the store mutations or the hook would already have queued a save with the old projectId before we cleared. Comment in the source spells this out so it doesn't get reordered in a future refactor. In-flight saves are not affected: buildSavePayload() snapshots state before its `await updateProject(...)`, so a save that started right before the example load still sends the user's pre-example state to the right project. Worst case: the save completes after clear, and the hook quietly returns idle. Build verified (vite OSS+pro, 285 SEO pages). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:02:05 +07:00
// CRITICAL — clear currentProject FIRST, before touching any other store.
//
// Otherwise: user has a saved project open (currentProject = { id, slug, …}),
// navigates to /examples, clicks an example. We mutate the simulator +
// editor stores below; the auto-save hook is still subscribed and still
// thinks the active project is the user's saved one. It debounces a
// PUT /api/projects/<old-id> with the example's components/wires/files
// and OVERWRITES the user's saved project with the example contents.
//
// The auto-save hook is subscribed to useProjectStore and resets its
// baseline (projectId=null, lastSavedHash=null) whenever currentProject?.id
// changes. Clearing here BEFORE the mutations below guarantees the hook
// sees null as projectId during every subsequent simulator/editor change,
// so no PUT goes out.
useProjectStore.getState().clearCurrentProject();
// Loading a new example always starts unpaused — otherwise the canvas
// would open with every LED frozen at the previous example's state.
useElectricalStore.getState().setPaused(false);
// Auto-install required libraries
if (example.libraries && example.libraries.length > 0) {
await ensureLibraries(example.libraries, onLibraryProgress);
}
const {
setComponents,
setWires,
setBoardType,
setBoardLanguageMode,
activeBoardId,
boards,
addBoard,
removeBoard,
setActiveBoardId,
recalculateAllWirePositions,
} = useSimulatorStore.getState();
if (example.boards && example.boards.length > 0) {
// ── Multi-board loading ───────────────────────────────────────────────
const currentIds = boards.map((b) => b.id);
currentIds.forEach((id) => removeBoard(id));
example.boards.forEach((eb) => {
addBoard(eb.boardKind as BoardKind, eb.x, eb.y);
});
// Match addBoard's deterministic ID rule (useSimulatorStore.addBoard):
// 1st board of a kind → id = boardKind
// 2nd board of a kind → id = `${boardKind}-2`
// Nth board of a kind → id = `${boardKind}-N`
// This is what wires reference, so the loader must compute the same IDs
// when loading per-board code/vfs.
const kindCount = new Map<string, number>();
const boardIds: string[] = example.boards.map((eb) => {
const n = (kindCount.get(eb.boardKind) ?? 0) + 1;
kindCount.set(eb.boardKind, n);
return n === 1 ? eb.boardKind : `${eb.boardKind}-${n}`;
});
const { boards: newBoards } = useSimulatorStore.getState();
example.boards.forEach((eb, idx) => {
const boardId = boardIds[idx];
const board = newBoards.find((b) => b.id === boardId);
if (!board) return;
if (eb.code) {
// Arduino-style boards (AVR, RP2040, ESP32, …) all need the `.ino`
// extension so arduino-cli auto-includes <Arduino.h>. Only the Pi 3B
// uses a different toolchain (Python via VFS or g++ for `.cpp`).
feat(pi3 phase 3.1+3.2): Pi 3/4/5 family via PI_CONFIGS Backend: extract per-board config into a PI_CONFIGS dict keyed by board_type. Pi 3/4/5 share the same arm64 image set (kernel + initramfs + rootfs) and differ only in QEMU -cpu and -m: raspberry-pi-3 → cortex-a53 + 1G (BCM2837, ARMv8 64-bit) raspberry-pi-4 → cortex-a72 + 2G (BCM2711, ARMv8 64-bit) raspberry-pi-5 → cortex-a76 + 2G (BCM2712, ARMv8 64-bit) PiInstance now carries board_type so the per-board lookup happens once at start_instance time. Unknown board_type falls back to DEFAULT_PI_BOARD ('raspberry-pi-3') instead of erroring out (for back-compat with older clients). Pre-warm hook walks every unique image_set in PI_CONFIGS so the provider only downloads each set once even when several Pi models are registered. Frontend: - BoardKind union gains 'raspberry-pi-4' and 'raspberry-pi-5'. - BOARD_KIND_LABELS + BOARD_KIND_FQBN entries for both new boards (FQBN null since they use the Pi VFS + Python toolchain like Pi 3). - ComponentRegistry inserts two new component metadata entries cloning the Pi 3 board art with different thumbnail colours. Tag name reused so the same velxio-raspberry-pi-3 web element draws the board on the canvas — the 40-pin GPIO layout is identical across Pi 3/4/5. - boardProtocols.ts: Pi 3/4/5 share the BCM physical→GPIO table (PI3_BCM) since the 40-pin header layout is identical. - loadExample.ts: where 'raspberry-pi-3' is special-cased (VFS ingest, .cpp vs .ino filename), now matches Pi 3/4/5 alike. - Interconnect.isPi3Bridge() recognises all three Pi family members so Arduino↔Pi serial routing keeps working. - RaspberryPi3Bridge constructor gained a boardKind parameter defaulting to 'raspberry-pi-3'. The WebSocket 'start_pi' message now ships the actual board kind so the backend knows which PI_CONFIGS entry to use. - useSimulatorStore.addBoard wires bridge construction for all three Pi family members. Pi Zero/Pi 1/Pi 2 (armhf) come in Phase 3.3 — separate kernel package + armhf rootfs build, no change here. Smoke-tested inside the prod container: Pi 4 (cortex-a72) → reached agetty login on hvc0 Pi 5 (cortex-a76) → reached agetty login on hvc0 Both show 'aarch64' in uname -m.
2026-05-18 20:41:18 +07:00
const filename = (eb.boardKind === 'raspberry-pi-3' || eb.boardKind === 'raspberry-pi-4' || eb.boardKind === 'raspberry-pi-5') ? 'main.cpp' : 'sketch.ino';
useEditorStore.getState().setActiveGroup(board.activeFileGroupId);
useEditorStore.getState().loadFiles([{ name: filename, content: eb.code }]);
}
feat(pi3 phase 3.1+3.2): Pi 3/4/5 family via PI_CONFIGS Backend: extract per-board config into a PI_CONFIGS dict keyed by board_type. Pi 3/4/5 share the same arm64 image set (kernel + initramfs + rootfs) and differ only in QEMU -cpu and -m: raspberry-pi-3 → cortex-a53 + 1G (BCM2837, ARMv8 64-bit) raspberry-pi-4 → cortex-a72 + 2G (BCM2711, ARMv8 64-bit) raspberry-pi-5 → cortex-a76 + 2G (BCM2712, ARMv8 64-bit) PiInstance now carries board_type so the per-board lookup happens once at start_instance time. Unknown board_type falls back to DEFAULT_PI_BOARD ('raspberry-pi-3') instead of erroring out (for back-compat with older clients). Pre-warm hook walks every unique image_set in PI_CONFIGS so the provider only downloads each set once even when several Pi models are registered. Frontend: - BoardKind union gains 'raspberry-pi-4' and 'raspberry-pi-5'. - BOARD_KIND_LABELS + BOARD_KIND_FQBN entries for both new boards (FQBN null since they use the Pi VFS + Python toolchain like Pi 3). - ComponentRegistry inserts two new component metadata entries cloning the Pi 3 board art with different thumbnail colours. Tag name reused so the same velxio-raspberry-pi-3 web element draws the board on the canvas — the 40-pin GPIO layout is identical across Pi 3/4/5. - boardProtocols.ts: Pi 3/4/5 share the BCM physical→GPIO table (PI3_BCM) since the 40-pin header layout is identical. - loadExample.ts: where 'raspberry-pi-3' is special-cased (VFS ingest, .cpp vs .ino filename), now matches Pi 3/4/5 alike. - Interconnect.isPi3Bridge() recognises all three Pi family members so Arduino↔Pi serial routing keeps working. - RaspberryPi3Bridge constructor gained a boardKind parameter defaulting to 'raspberry-pi-3'. The WebSocket 'start_pi' message now ships the actual board kind so the backend knows which PI_CONFIGS entry to use. - useSimulatorStore.addBoard wires bridge construction for all three Pi family members. Pi Zero/Pi 1/Pi 2 (armhf) come in Phase 3.3 — separate kernel package + armhf rootfs build, no change here. Smoke-tested inside the prod container: Pi 4 (cortex-a72) → reached agetty login on hvc0 Pi 5 (cortex-a76) → reached agetty login on hvc0 Both show 'aarch64' in uname -m.
2026-05-18 20:41:18 +07:00
if (eb.vfsFiles && (eb.boardKind === 'raspberry-pi-3' || eb.boardKind === 'raspberry-pi-4' || eb.boardKind === 'raspberry-pi-5')) {
const vfsState = useVfsStore.getState();
const tree = vfsState.getTree(boardId);
for (const [nodeId, node] of Object.entries(tree)) {
if (node.type === 'file' && eb.vfsFiles[node.name] !== undefined) {
vfsState.setContent(boardId, nodeId, eb.vfsFiles[node.name]);
}
}
}
});
const firstArduinoIdx = example.boards.findIndex(
(eb) =>
feat(pi3 phase 3.1+3.2): Pi 3/4/5 family via PI_CONFIGS Backend: extract per-board config into a PI_CONFIGS dict keyed by board_type. Pi 3/4/5 share the same arm64 image set (kernel + initramfs + rootfs) and differ only in QEMU -cpu and -m: raspberry-pi-3 → cortex-a53 + 1G (BCM2837, ARMv8 64-bit) raspberry-pi-4 → cortex-a72 + 2G (BCM2711, ARMv8 64-bit) raspberry-pi-5 → cortex-a76 + 2G (BCM2712, ARMv8 64-bit) PiInstance now carries board_type so the per-board lookup happens once at start_instance time. Unknown board_type falls back to DEFAULT_PI_BOARD ('raspberry-pi-3') instead of erroring out (for back-compat with older clients). Pre-warm hook walks every unique image_set in PI_CONFIGS so the provider only downloads each set once even when several Pi models are registered. Frontend: - BoardKind union gains 'raspberry-pi-4' and 'raspberry-pi-5'. - BOARD_KIND_LABELS + BOARD_KIND_FQBN entries for both new boards (FQBN null since they use the Pi VFS + Python toolchain like Pi 3). - ComponentRegistry inserts two new component metadata entries cloning the Pi 3 board art with different thumbnail colours. Tag name reused so the same velxio-raspberry-pi-3 web element draws the board on the canvas — the 40-pin GPIO layout is identical across Pi 3/4/5. - boardProtocols.ts: Pi 3/4/5 share the BCM physical→GPIO table (PI3_BCM) since the 40-pin header layout is identical. - loadExample.ts: where 'raspberry-pi-3' is special-cased (VFS ingest, .cpp vs .ino filename), now matches Pi 3/4/5 alike. - Interconnect.isPi3Bridge() recognises all three Pi family members so Arduino↔Pi serial routing keeps working. - RaspberryPi3Bridge constructor gained a boardKind parameter defaulting to 'raspberry-pi-3'. The WebSocket 'start_pi' message now ships the actual board kind so the backend knows which PI_CONFIGS entry to use. - useSimulatorStore.addBoard wires bridge construction for all three Pi family members. Pi Zero/Pi 1/Pi 2 (armhf) come in Phase 3.3 — separate kernel package + armhf rootfs build, no change here. Smoke-tested inside the prod container: Pi 4 (cortex-a72) → reached agetty login on hvc0 Pi 5 (cortex-a76) → reached agetty login on hvc0 Both show 'aarch64' in uname -m.
2026-05-18 20:41:18 +07:00
eb.boardKind !== 'raspberry-pi-3' && eb.boardKind !== 'raspberry-pi-4' && eb.boardKind !== 'raspberry-pi-5' &&
eb.boardKind !== 'esp32' &&
eb.boardKind !== 'esp32-s3' &&
eb.boardKind !== 'esp32-c3',
);
if (firstArduinoIdx !== -1) {
setActiveBoardId(boardIds[firstArduinoIdx]);
}
const componentsWithoutBoard = example.components.filter(
(comp) =>
!comp.type.includes('arduino') &&
!comp.type.includes('pico') &&
!comp.type.includes('raspberry') &&
!comp.type.includes('esp32'),
);
setComponents(
componentsWithoutBoard.map((comp) => ({
id: comp.id,
metadataId: stripBrandPrefix(comp.type),
x: comp.x,
y: comp.y,
properties: comp.properties,
})),
);
setWires(
example.wires.map((wire) => ({
id: wire.id,
start: { componentId: wire.start.componentId, pinName: wire.start.pinName, x: 0, y: 0 },
end: { componentId: wire.end.componentId, pinName: wire.end.pinName, x: 0, y: 0 },
color: wire.color,
waypoints: [],
})),
);
recalculateAllWirePositions();
} else {
// ── Single-board loading ─────────────────────────────────────────────
// Analog-only and digital-only SPICE examples are board-less. Remove every
// existing board so the canvas opens with just the circuit (boards are now
// optional — you can have 0, 1, or many at any time).
const filter = (example as any).boardFilter;
const isBoardless = filter === 'analog' || filter === 'digital';
if (isBoardless) {
const currentIds = boards.map((b) => b.id);
currentIds.forEach((id) => removeBoard(id));
} else {
const targetBoard = example.boardType || 'arduino-uno';
// If boards[] is empty (e.g. a previous analog example removed every
// board), setBoardType can't work — it only maps over existing entries.
// Add a fresh board instead.
if (useSimulatorStore.getState().boards.length === 0) {
const newId = addBoard(
targetBoard as BoardKind,
DEFAULT_BOARD_POSITION.x,
DEFAULT_BOARD_POSITION.y,
);
setActiveBoardId(newId);
} else {
setBoardType(targetBoard);
}
}
// ── MicroPython + multi-file payloads ────────────────────────────────
// When the example specifies languageMode='micropython' or ships a
// files[] array, we go through setBoardLanguageMode + loadFiles instead
// of the legacy setCode() path so the editor opens the right file
// (main.py) with the right language mode.
const liveBoardId = useSimulatorStore.getState().activeBoardId;
const liveBoard = useSimulatorStore
.getState()
.boards.find((b) => b.id === liveBoardId);
if (example.languageMode === 'micropython' && liveBoard) {
setBoardLanguageMode(liveBoard.id, 'micropython');
}
if (example.files && example.files.length > 0 && liveBoard) {
// Re-resolve the file group ID — setBoardLanguageMode replaces it.
const updatedBoard = useSimulatorStore
.getState()
.boards.find((b) => b.id === liveBoard.id);
const groupId = updatedBoard?.activeFileGroupId ?? liveBoard.activeFileGroupId;
const editorStore = useEditorStore.getState();
editorStore.setActiveGroup(groupId);
editorStore.loadFiles(example.files);
} else if (liveBoard) {
// Single-file Arduino-style example. We must use `loadFiles` (not the
// legacy `setCode`) and explicitly switch the editor store to the
// board's file group: in board-less → board transitions the editor's
// `activeFileId` still points at an orphan ID from the deleted
// group, so `setCode` would silently no-op and the editor would
// appear blank. (Regression test: load-example-transitions.test.ts.)
const editorStore = useEditorStore.getState();
editorStore.setActiveGroup(liveBoard.activeFileGroupId);
feat(pi3 phase 3.1+3.2): Pi 3/4/5 family via PI_CONFIGS Backend: extract per-board config into a PI_CONFIGS dict keyed by board_type. Pi 3/4/5 share the same arm64 image set (kernel + initramfs + rootfs) and differ only in QEMU -cpu and -m: raspberry-pi-3 → cortex-a53 + 1G (BCM2837, ARMv8 64-bit) raspberry-pi-4 → cortex-a72 + 2G (BCM2711, ARMv8 64-bit) raspberry-pi-5 → cortex-a76 + 2G (BCM2712, ARMv8 64-bit) PiInstance now carries board_type so the per-board lookup happens once at start_instance time. Unknown board_type falls back to DEFAULT_PI_BOARD ('raspberry-pi-3') instead of erroring out (for back-compat with older clients). Pre-warm hook walks every unique image_set in PI_CONFIGS so the provider only downloads each set once even when several Pi models are registered. Frontend: - BoardKind union gains 'raspberry-pi-4' and 'raspberry-pi-5'. - BOARD_KIND_LABELS + BOARD_KIND_FQBN entries for both new boards (FQBN null since they use the Pi VFS + Python toolchain like Pi 3). - ComponentRegistry inserts two new component metadata entries cloning the Pi 3 board art with different thumbnail colours. Tag name reused so the same velxio-raspberry-pi-3 web element draws the board on the canvas — the 40-pin GPIO layout is identical across Pi 3/4/5. - boardProtocols.ts: Pi 3/4/5 share the BCM physical→GPIO table (PI3_BCM) since the 40-pin header layout is identical. - loadExample.ts: where 'raspberry-pi-3' is special-cased (VFS ingest, .cpp vs .ino filename), now matches Pi 3/4/5 alike. - Interconnect.isPi3Bridge() recognises all three Pi family members so Arduino↔Pi serial routing keeps working. - RaspberryPi3Bridge constructor gained a boardKind parameter defaulting to 'raspberry-pi-3'. The WebSocket 'start_pi' message now ships the actual board kind so the backend knows which PI_CONFIGS entry to use. - useSimulatorStore.addBoard wires bridge construction for all three Pi family members. Pi Zero/Pi 1/Pi 2 (armhf) come in Phase 3.3 — separate kernel package + armhf rootfs build, no change here. Smoke-tested inside the prod container: Pi 4 (cortex-a72) → reached agetty login on hvc0 Pi 5 (cortex-a76) → reached agetty login on hvc0 Both show 'aarch64' in uname -m.
2026-05-18 20:41:18 +07:00
const filename = (liveBoard.boardKind === 'raspberry-pi-3' || liveBoard.boardKind === 'raspberry-pi-4' || liveBoard.boardKind === 'raspberry-pi-5') ? 'main.cpp' : 'sketch.ino';
editorStore.loadFiles([{ name: filename, content: example.code }]);
} else {
// Truly board-less: write the placeholder code to whatever the editor
// currently shows (boardless examples ship a `void setup()/loop()`
// stub — content barely matters since the user won't compile it).
useEditorStore.getState().setCode(example.code);
}
const componentsWithoutBoard = example.components.filter(
(comp) =>
!comp.type.includes('arduino') &&
!comp.type.includes('pico') &&
!comp.type.includes('esp32'),
);
setComponents(
componentsWithoutBoard.map((comp) => ({
id: comp.id,
metadataId: stripBrandPrefix(comp.type),
x: comp.x,
y: comp.y,
properties: comp.properties,
})),
);
// After possibly removing every board, re-read activeBoardId.
const liveActiveBoardId = useSimulatorStore.getState().activeBoardId;
// For analog (board-less) examples we leave any 'arduino-uno' references
// in wires untouched — there shouldn't be any, but if there are we'd
// rather emit a dangling endpoint than silently graft them onto a board
// that no longer exists.
const remapBoardId = (id: string) =>
isBoardComponent(id) && liveActiveBoardId ? liveActiveBoardId : id;
setWires(
example.wires.map((wire) => ({
id: wire.id,
start: {
componentId: remapBoardId(wire.start.componentId),
pinName: wire.start.pinName,
x: 0,
y: 0,
},
end: {
componentId: remapBoardId(wire.end.componentId),
pinName: wire.end.pinName,
x: 0,
y: 0,
},
color: wire.color,
waypoints: [],
})),
);
recalculateAllWirePositions();
}
}