/* * Copyright (c) 2025-2026 Taras Greben * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace * See LICENSE file for details. */ // ═══════════════════════════════════════════════════════════════ // LAYOUT — WireBender placement + routing // // Replaces the previous ELK-based layout.js entirely. // // Workflow (mirrors the prototype in wirebender-test.html): // 1. Load WASM module once at startup (initWireBender) // 2. On netlist load / auto-layout: // buildAndRoute() → clears wb, re-adds everything, // classify → computePlacement → routeAll // 3. On component drag drop: // routeWires() → setComponentPlacement → routeAll // (visual-only drag happens in interaction.js; no routing // during the drag, only on drop) // // Persistent instance pattern (from prototype): // A single WireBender instance (_wb) is created once and kept // alive for the session. It is destroyed + recreated only when // buildAndRoute() is called (full re-layout). This is required // because setComponentPlacement / routeAll operate on libavoid's // live router state. // ═══════════════════════════════════════════════════════════════ import { S } from './state.js'; import { createComp } from './components.js'; import { render } from './draw.js'; // ── Module loading ──────────────────────────────────────────── let _WB = null; // WireBender WASM module (Module object) let _wb = null; // persistent WireBender instance let _modulePromise = null; /** * Loads the WASM module once. Subsequent calls return the same promise. * Must be called (and awaited) before any routing. * * Expects WireBender.js and WireBender.wasm to be at the same URL path * as the HTML page (i.e. served from the project root alongside index.html). * @returns Promise */ export function initWireBender() { if (_modulePromise) return _modulePromise; _modulePromise = import('../vendor/wirebender/WireBender.js') .then(m => m.default({ locateFile: f => f === 'WireBender.wasm' ? new URL('../vendor/wirebender/WireBender.wasm', import.meta.url).href : f, })) .then(module => { _WB = module; console.log('[WireBender] WASM module loaded'); }) .catch(err => { console.error('[WireBender] Failed to load WASM module:', err); // Surface to UI via lazy import to avoid circular dep import('./ui.js').then(({ toast }) => toast('WireBender WASM failed to load — check console', 'err')); }); return _modulePromise; } /** * Destroy persistent WireBender instance. */ function _destroyInstance() { if (_wb) { try { _wb.delete(); } catch (_) { /* ignore */ } _wb = null; } } /** * Build WireBender netlist from S * Feeds S.components and S.nets into a freshly-created WireBender * instance. * * Call after _wb is (re)created. */ function _populateWb() { // Step 1: add components S.components.forEach(comp => { const desc = createComp(comp).wbDescriptor(); const pinsVec = new _WB.VectorPinDescriptor(); desc.pins.forEach(p => pinsVec.push_back({ number: p.number, name: p.name, x: p.x, y: p.y, directionFlags: p.directionFlags, })); _wb.addComponent({ id: desc.id, width: desc.width, height: desc.height, padding: desc.padding, pins: pinsVec, }); pinsVec.delete(); }); // Step 2: add nets (only those with ≥ 2 nodes) S.nets.forEach(net => { const nodes = (net.nodes || []).filter(nd => S.components.find(c => c.ref === nd.ref) ); if (nodes.length < 2) return; const pinsVec = new _WB.VectorPinRef(); nodes.forEach(nd => { const comp = S.components.find(c => c.ref === nd.ref); if (!comp) return; // Find pin index (0-based) → WireBender pin number (1-based) const pinIdx = comp.pins.findIndex( p => p.originalName === nd.pin || p.originalName === String(nd.pin) || p.name === nd.pin || p.name === String(nd.pin) ); pinsVec.push_back({ componentId: comp.id, pinNumber: pinIdx >= 0 ? pinIdx + 1 : 1, }); }); _wb.addNet({ name: net.name, pins: pinsVec }); pinsVec.delete(); }); } /** * Apply a SchematicRouteResult into S.wires / S.junctionPoints. * * Converts Emscripten vectors to plain JS arrays. * @param result routing result (wires, junctions) * @param affectedNets: if given, replaces only those nets; otherwise full rebuild. */ function _applyRouteResult(result, affectedNets = null) { // Build map: netName → {wires:[[pts]], junctions:[{x,y}]} const byNet = {}; for (let i = 0; i < result.wires.size(); i++) { const wire = result.wires.get(i); const net = wire.net; if (!byNet[net]) byNet[net] = { wires: [], junctions: [] }; const pts = []; for (let j = 0; j < wire.points.size(); j++) { const p = wire.points.get(j); pts.push({ x: p.x, y: p.y }); } if (pts.length >= 2) byNet[net].wires.push(pts); } for (let i = 0; i < result.junctions.size(); i++) { const jd = result.junctions.get(i); const net = jd.net; if (!byNet[net]) byNet[net] = { wires: [], junctions: [] }; byNet[net].junctions.push({ x: jd.position.x, y: jd.position.y }); } // Helper: write wire data for one net into S const applyNet = netName => { const nd = byNet[netName]; if (!nd) return; // Remove existing wires for this net S.wires = S.wires.filter(w => w.net !== netName); // Remove existing junctions for this net S.junctionPoints = (S.junctionPoints || []).filter(jp => jp.net !== netName); let wireSeq = 0; nd.wires.forEach(pts => { S.wires.push({ id: `wb_${netName}_${wireSeq++}`, net: netName, points: pts, pinA: null, // WireBender owns routing — we don't track pin refs per wire pinB: null, }); }); nd.junctions.forEach(j => { S.junctionPoints.push({ x: j.x, y: j.y, net: netName }); }); }; if (affectedNets) { // Incremental update affectedNets.forEach(applyNet); } else { // Full rebuild S.wires = []; S.junctionPoints = []; Object.keys(byNet).forEach(applyNet); } // Generate WIP stubs for single-node nets _applyWipStubs(); // Extract component label hints if the WASM module provided them S.componentLabels = {}; if (result.componentLabels) { for (let i = 0; i < result.componentLabels.size(); i++) { const hint = result.componentLabels.get(i); S.componentLabels[hint.componentId] = { refPosition: { x: hint.refPosition.x, y: hint.refPosition.y }, refIsVertical: hint.refIsVertical, valuePosition: { x: hint.valuePosition.x, y: hint.valuePosition.y }, valueIsVertical: hint.valueIsVertical }; } } // Generate WIP stubs for single-node nets _applyWipStubs(); } /** * WIP stubs (single-node nets). * * For nets with only one connected pin, draw a short dangling stub * so the connection is visible. WireBender only routes nets with * ≥ 2 nodes; stubs are synthesised here. */ function _applyWipStubs() { S.nets.forEach(net => { if ((net.nodes || []).length !== 1) return; const nd = net.nodes[0]; const comp = S.components.find(c => c.ref === nd.ref); if (!comp) return; const pins = createComp(comp).pinPositions(); const pinIdx = comp.pins.findIndex( p => p.originalName === nd.pin || p.originalName === String(nd.pin) || p.name === nd.pin || p.name === String(nd.pin) ); const p = pins[pinIdx >= 0 ? pinIdx : 0]; if (!p) return; // Remove any previous stub for this net S.wires = S.wires.filter(w => w.id !== `wip_${net.name}`); S.wires.push({ id: `wip_${net.name}`, net: net.name, points: [ { x: p.x, y: p.y }, { x: p.x + p.ex * 32, y: p.y + p.ey * 32 }, ], pinA: { compId: comp.id, pinName: p.pinName }, pinB: null, }); }); } /** * Write component placements from WireBender into S. * @param placements component placements */ function _applyPlacements(placements) { for (const id in placements) { const placement = placements[id]; const comp = S.components.find(c => c.id === id); if (!comp) continue; comp.x = placement.position.x; comp.y = placement.position.y; comp.rotation = placement.transform.rotation; comp.flipX = placement.transform.flipX; } } /** * Fit viewport. * * Always calls render() so every caller - buttons, keyboard, post-route - * doesn't have to remember to chain it. */ export function fitView() { if (!S.components.length) return; let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity; S.components.forEach(c => { const g = createComp(c).whTransformed(); x1 = Math.min(x1, c.x - g.w / 2 - 60); y1 = Math.min(y1, c.y - g.h / 2 - 60); x2 = Math.max(x2, c.x + g.w / 2 + 60); y2 = Math.max(y2, c.y + g.h / 2 + 60); }); const cv = document.getElementById('sch-canvas'); S.zoom = Math.max(0.1, Math.min(0.95, Math.min(cv.width / (x2 - x1), cv.height / (y2 - y1)))); S.viewX = -((x1 + x2) / 2) * S.zoom; S.viewY = -((y1 + y2) / 2) * S.zoom; render(); } /** * Full build + layout. * * Destroys the old WireBender instance, rebuilds the full netlist. * If runPlacement is true, runs classify → computePlacement (respecting locks) → routeAll. * If false, bypasses placement entirely and uses exact coordinates from S.components. * * lockedPlacements: optional map { [compId]: {{x, y}, {rotation, flipX}} } of components * that should not be moved during auto-placement (e.g. previously * user-positioned components loaded from schemaComponents in DB). * Coordinates are world-space centre, same as S.components.x/y. * * Returns the placement result map { [compId]: {{x, y}, {rotation, flipX}} } so the * caller can persist newly-placed components to the DB. */ export async function buildAndRoute(lockedPlacements = {}, runPlacement = true) { if (!S.components.length) { render(); // Force clear canvas and show empty state return {}; } await initWireBender(); if (!_WB) return {}; _destroyInstance(); _wb = new _WB.WireBender(); _populateWb(); // Classify (bus detection) const cls = _wb.classify(); for(let i = 0; i < cls.size(); ++i) { const item = cls.get(i); if(item.isBus) { console.info("Resetting bus:", item.name, "isGround:", item.isGround, "isPositive:", item.isPositive, "busLevel:", item.busLevel); item.isBus = false; item.isGround = false; item.isPositive = false; item.busLevel = -1; cls.set(i, item); } } _wb.applyClassification(cls); cls.delete(); let placements = {}; if (runPlacement) { // Apply locked positions before placement if (Object.keys(lockedPlacements).length > 0) { const locks = new _WB.ComponentPlacements(); locks.fromObject(lockedPlacements); _wb.setLockedPlacements(locks); locks.delete(); } placements = _wb.computePlacements().toObject(); _applyPlacements(placements); } else { // No placement requested: push exact saved placements directly into WB S.components.forEach(comp => { _wb.setComponentPlacement(comp.id, { position: { x: comp.x, y: comp.y }, transform: { rotation: comp.rotation || 0, flipX: comp.flipX || false } }); }); } // Route const result = _wb.routeAll(); _applyRouteResult(result); if (runPlacement) fitView(); else render(); return placements; } /** * Incremental Move or Transform (Rotate, FlipX) Component. * @param compId component ID */ export async function moveComponentRoute(compId) { if (!_wb) { await buildAndRoute(); return; } const comp = S.components.find(c => c.id === compId); if (!comp) return; const incResult = _wb.moveComponent(compId, { position: { x: comp.x, y: comp.y }, transform: { rotation: comp.rotation || 0, flipX: comp.flipX || false } }); // Extract affected nets into a standard JS array const affectedNets = []; for (let i = 0; i < incResult.affectedNets.size(); i++) { affectedNets.push(incResult.affectedNets.get(i)); } // Apply only to the affected nets _applyRouteResult(incResult.routes, affectedNets); render(); } /** * Replace Component (Symbol change). * @param comp component to replace * @param oldToNewMap pin mapping */ export async function replaceComponentRoute(comp, oldToNewMap) { if (!_wb) { await buildAndRoute(); return; } const desc = createComp(comp).wbDescriptor(); const pinsVec = new _WB.VectorPinDescriptor(); desc.pins.forEach(p => pinsVec.push_back({ number: p.number, name: p.name, x: p.x, y: p.y, directionFlags: p.directionFlags, })); const mapObj = new _WB.PinMap(); mapObj.fromObject(oldToNewMap); const replacement = { componentId: comp.id, newDescriptor: { id: desc.id, width: desc.width, height: desc.height, padding: desc.padding, pins: pinsVec }, pinMapping: mapObj }; const result = _wb.replaceComponent(replacement); _applyRouteResult(result); render(); pinsVec.delete(); mapObj.delete(); } /** * Re-route after a manual drag or undo. * Fully syncs all component geometry AND positions into WireBender * before calling routeAll(). This handles any combination of: * - position changes (drag) * - transform changes (undo restoring a previous rotation/flipX) * * TODO: with changes to placement instead of position, not sure * that the fix described below needed. * The critical fix: routeWires() must call addComponent() (not just * setComponentPlacement()) for every component, because addComponent * replaces the geometry registration. Using only setComponentPosition * leaves WireBender with stale pin geometry after rotation/undo, which * produces diagonal wire artifacts and wrong wire endpoints. */ export async function routeWires() { if (!S.components.length) return; if (!_wb) { await buildAndRoute(); return; } // Sync every component's current geometry and position. // addComponent() replaces the existing registration when called with // the same id — so this is always safe to call unconditionally. S.components.forEach(comp => { const desc = createComp(comp).wbDescriptor(); const pinsVec = new _WB.VectorPinDescriptor(); desc.pins.forEach(p => pinsVec.push_back({ number: p.number, name: p.name, x: p.x, y: p.y, directionFlags: p.directionFlags, })); // TODO: check if needed with new setPlacement, find ui undo method and sync wirebender instead _wb.addComponent({ id: desc.id, width: desc.width, height: desc.height, padding: desc.padding, pins: pinsVec, }); pinsVec.delete(); _wb.setComponentPlacement(comp.id, { position: { x: comp.x, y: comp.y }, transform: { rotation: comp.rotation || 0, flipX: comp.flipX || false } }); }); const result = _wb.routeAll(); _applyRouteResult(result); render(); }