Fixed the sticky node name validator issue, where a validator used once was incorrectly reused for all input text dialogs. Added a new feature: the Inspector view now shows calculated routes for nets, including inactive ones.

This commit is contained in:
Taras Greben 2026-07-01 17:38:52 +03:00
commit 114c983b08
4 changed files with 564 additions and 10 deletions

336
docs/inspect-traces.js Normal file
View file

@ -0,0 +1,336 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
/*
* inspect-traces.js - PCB net trace generation, caching and rendering for the
* Inspect view, powered by the WireBender `PcbVisualizer` WASM API.
*
* Design notes
*
* - Traces are routed ONCE in a single reference-image coordinate space using
* one `PcbVisualizer.route()` call (for all nets initially). The resulting
* polylines are cached in memory keyed by net id.
* - Rendering on every other PCB photo reuses the existing perspective
* transform logic (homography projection) instead of recalculating traces
* per view. This keeps the cost flat even with ~12 simultaneous views.
* - The cache lives on the Inspector instance, so it survives navigation
* between tabs/views. Each net carries a content signature; when net data
* changes the affected entries are transparently recomputed.
*/
/**
* Code-level configuration (intentionally NOT exposed in the UI).
*/
const TraceConfig = {
/** Stroke colour for the currently selected (active) net traces. */
ACTIVE_TRACE_COLOR: '#f59e0b', // amber
/** Stroke colour for all other (inactive) net traces. */
INACTIVE_TRACE_COLOR: '#7dd3fc', // light sky cyan
/** Wire stroke width (screen pixels, scale-compensated). */
TRACE_WIDTH: 2.5,
/** Junction dot radius (screen pixels, scale-compensated). */
JUNCTION_RADIUS: 4,
/**
* Global recalculation policy when any single net changes:
* 'single' recompute only the modified net (default, fastest).
* 'all' recompute every net (avoids cross-net routing conflicts).
*/
RECALC_MODE: 'single',
/** WireBender WASM module entry point. */
WASM_URL: 'https://dev-lab.github.io/WireBender/latest/WireBender.js',
/** WireBender WASM binary. */
WASM_BINARY_URL: 'https://dev-lab.github.io/WireBender/latest/WireBender.wasm',
};
/**
* Computes a stable content signature for a net so the cache can detect any
* routing-affecting modification (node add/remove/move, rename, ...).
*/
class NetSignature {
/**
* @param net net record { id, name, nodes:[{id,imgId,x,y,label}] }
* @returns string signature
*/
static of(net) {
const parts = [net.name || ''];
const nodes = (net.nodes || []).slice().sort((a, b) => {
const ka = (a.id || a.label || '') + '';
const kb = (b.id || b.label || '') + '';
return ka < kb ? -1 : ka > kb ? 1 : 0;
});
nodes.forEach(n => parts.push(`${n.id}:${n.imgId}:${n.x}:${n.y}:${n.label}`));
return parts.join('|');
}
}
/**
* In-memory cache of generated traces, keyed by net id. Traces are stored in
* the reference-image coordinate space and never persisted to DB/project files.
*/
class TraceCache {
/**
* @param opts { recalcMode?, moduleLoader?, module? }
* moduleLoader async () => WireBender Module (overridable for tests).
* module pre-resolved WireBender Module (overridable for tests).
*/
constructor(opts = {}) {
/** netId → { sig, name, wires:[[{x,y}]], junctions:[{x,y}] } (reference space). */
this.entries = new Map();
/** Reference image id the cached geometry belongs to. */
this.refId = null;
this.recalcMode = opts.recalcMode || TraceConfig.RECALC_MODE;
this._moduleLoader = opts.moduleLoader || TraceCache.defaultModuleLoader;
this._module = opts.module || null;
/** Diagnostic counters (also used by tests). */
this.stats = { routeCalls: 0, netsRouted: 0 };
}
/**
* Default loader: dynamically imports the WireBender WASM module.
* @returns Promise<Module>
*/
static async defaultModuleLoader() {
const m = await import(TraceConfig.WASM_URL);
return await m.default({
locateFile: f => f === 'WireBender.wasm' ? TraceConfig.WASM_BINARY_URL : f,
});
}
/** Lazily resolve and memoise the WASM module. */
async _getModule() {
if (!this._module) this._module = await this._moduleLoader();
return this._module;
}
/** Drop the cached geometry for one net. */
invalidate(netId) { this.entries.delete(netId); }
/** Drop all cached geometry. */
invalidateAll() { this.entries.clear(); }
/** @returns cached entry { sig, name, wires, junctions } or undefined. */
get(netId) { return this.entries.get(netId); }
/**
* Ensure every net has up-to-date traces in the cache.
*
* Performs at most one `PcbVisualizer.route()` call. Nets whose signature
* is unchanged are reused; removed nets are pruned. In 'single' mode only
* changed nets are re-routed, in 'all' mode (or when forceAll is set) every
* net is re-routed together so cross-net conflicts are resolved globally.
*
* @param nets array of net records
* @param refId reference image id (coordinate space key)
* @param projectNodeToRef (node) => {x,y}|null node native coords ref space
* @param forceAll force a full recompute of all nets
* @returns Promise<boolean> whether any routing was performed
*/
async ensure(nets, refId, projectNodeToRef, forceAll = false) {
if (this.refId !== refId) {
this.invalidateAll();
this.refId = refId;
forceAll = true;
}
// Prune nets that no longer exist.
const present = new Set(nets.map(n => n.id));
for (const id of [...this.entries.keys()]) {
if (!present.has(id)) this.entries.delete(id);
}
// Detect changed nets via signature.
const changed = [];
for (const net of nets) {
const sig = NetSignature.of(net);
const existing = this.entries.get(net.id);
if (forceAll || !existing || existing.sig !== sig) changed.push(net);
}
if (changed.length === 0) return false;
const routeSet = (this.recalcMode === 'all' || forceAll) ? nets : changed;
await this._route(routeSet, projectNodeToRef);
return true;
}
/**
* Route the given nets with a single PcbVisualizer pass and store results.
* @param nets nets to route
* @param projectNodeToRef projection into reference space
*/
async _route(nets, projectNodeToRef) {
// Project pads into the reference coordinate space.
const prepared = nets.map(net => {
const pads = [];
(net.nodes || []).forEach(node => {
const p = projectNodeToRef(node);
if (p && isFinite(p.x) && isFinite(p.y)) pads.push({ x: p.x, y: p.y });
});
return { net, pads };
});
// Nets with < 2 pads cannot be routed — store empty geometry but record
// the current signature so they are not retried every refresh.
prepared.forEach(({ net, pads }) => {
if (pads.length < 2) {
this.entries.set(net.id, {
sig: NetSignature.of(net), name: net.name, wires: [], junctions: [],
});
}
});
const routable = prepared.filter(p => p.pads.length >= 2);
this.stats.routeCalls++;
if (routable.length === 0) return;
const M = await this._getModule();
const pcb = new M.PcbVisualizer();
try {
routable.forEach(({ net, pads }) => {
const vec = new M.VectorPoint2D();
pads.forEach(p => vec.push_back({ x: p.x, y: p.y }));
// Use the net id as routing key to avoid duplicate-name collisions.
pcb.addNet({ name: net.id, pads: vec });
vec.delete();
});
const result = pcb.route();
const byKey = {};
for (let i = 0; i < result.wires.size(); i++) {
const wire = result.wires.get(i);
const key = wire.net;
if (!byKey[key]) byKey[key] = { 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) byKey[key].wires.push(pts);
}
for (let i = 0; i < result.junctions.size(); i++) {
const d = result.junctions.get(i);
const key = d.net;
if (!byKey[key]) byKey[key] = { wires: [], junctions: [] };
byKey[key].junctions.push({ x: d.position.x, y: d.position.y });
}
routable.forEach(({ net }) => {
const data = byKey[net.id] || { wires: [], junctions: [] };
this.entries.set(net.id, {
sig: NetSignature.of(net), name: net.name,
wires: data.wires, junctions: data.junctions,
});
});
this.stats.netsRouted += routable.length;
} finally {
try { if (pcb.clear) pcb.clear(); } catch (_) { /* ignore */ }
try { if (pcb.delete) pcb.delete(); } catch (_) { /* ignore */ }
}
}
}
/**
* Projects cached reference-space traces onto an individual PCB photo and
* paints them. The projection function is supplied by the caller so the
* existing perspective-transform logic is reused unchanged.
*/
class TraceRenderer {
/**
* @param cache TraceCache instance
*/
constructor(cache) {
this.cache = cache;
}
/**
* Build the per-image draw list by projecting reference-space geometry.
*
* @param nets ordered net records (each with id)
* @param activeNetId id of the active net (labels + active colour)
* @param showInactive whether inactive net traces are visible
* @param projectPointFn (pt {x,y}) => {x,y}|null ref space image space
* @returns array of { netId, isActive, color, polylines:[[{x,y}]], junctions:[{x,y}] }
*/
buildDrawList(nets, activeNetId, showInactive, projectPointFn) {
const list = [];
for (const net of nets) {
const isActive = net.id === activeNetId;
if (!isActive && !showInactive) continue;
const entry = this.cache.get(net.id);
if (!entry) continue;
const color = isActive ? TraceConfig.ACTIVE_TRACE_COLOR : TraceConfig.INACTIVE_TRACE_COLOR;
const polylines = entry.wires.map(wire => {
const out = [];
for (const p of wire) {
const q = projectPointFn(p);
if (q) out.push(q);
}
return out;
}).filter(pl => pl.length >= 2);
const junctions = [];
for (const j of entry.junctions) {
const q = projectPointFn(j);
if (q) junctions.push(q);
}
list.push({ netId: net.id, isActive, color, polylines, junctions });
}
// Active net is drawn last so it sits on top of inactive traces.
list.sort((a, b) => (a.isActive ? 1 : 0) - (b.isActive ? 1 : 0));
return list;
}
/**
* Paint a prebuilt draw list onto a canvas context already transformed by
* the viewer (image space). Mirroring is applied per-point to match the
* node-label rendering in inspector.js.
*
* @param ctx 2D canvas context (translated/scaled by the viewer)
* @param drawList output of buildDrawList()
* @param k current viewer scale
* @param mirrorWidth bitmap width when the view is mirrored, otherwise 0
*/
draw(ctx, drawList, k, mirrorWidth) {
if (!drawList || !drawList.length) return;
const ik = 1 / k;
const mx = x => (mirrorWidth ? mirrorWidth - x : x);
for (const item of drawList) {
ctx.strokeStyle = item.color;
ctx.lineWidth = TraceConfig.TRACE_WIDTH * ik;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
for (const pl of item.polylines) {
ctx.beginPath();
pl.forEach((p, i) => {
const x = mx(p.x);
if (i === 0) ctx.moveTo(x, p.y);
else ctx.lineTo(x, p.y);
});
ctx.stroke();
}
ctx.fillStyle = item.color;
for (const j of item.junctions) {
ctx.beginPath();
ctx.arc(mx(j.x), j.y, TraceConfig.JUNCTION_RADIUS * ik, 0, Math.PI * 2);
ctx.fill();
}
}
}
}
// Expose for CommonJS test environments without affecting browser globals.
if (typeof module !== 'undefined' && module.exports) {
module.exports = { TraceConfig, NetSignature, TraceCache, TraceRenderer };
}

View file

@ -24,6 +24,16 @@ class Inspector {
// Cache for image dimensions to avoid async bitmap creation on every render // Cache for image dimensions to avoid async bitmap creation on every render
this.resolutionCache = {}; this.resolutionCache = {};
// --- PCB trace rendering (WireBender PcbVisualizer) ---
// The cache lives on the instance so generated traces survive navigation
// between tabs/views and are not recalculated unnecessarily.
this.traceCache = new TraceCache();
this.traceRenderer = new TraceRenderer(this.traceCache);
this.traceRenderCache = {}; // imgId → projected draw list
this.traceRefId = null; // reference image id for the routed coordinate space
this.showInactiveTraces = true; // UI toggle for inactive net traces
this._forceTraceRecalc = false; // one-shot full recalculation flag
// Initialization State Lock // Initialization State Lock
this.initPromise = null; this.initPromise = null;
this.needsSync = false; this.needsSync = false;
@ -58,6 +68,14 @@ class Inspector {
this.backImagesCache = null; // Clear back-side cache this.backImagesCache = null; // Clear back-side cache
this.sidebarList.innerHTML = ''; this.sidebarList.innerHTML = '';
// Synchronize Inactive Traces state with restored checkbox values
const cb = document.getElementById('inspect-inactive-cb');
const cbDropdown = document.getElementById('inspect-inactive-in-dropdown-cb');
if (cb) {
this.showInactiveTraces = cb.checked;
if (cbDropdown) cbDropdown.checked = cb.checked;
}
const newNetBtn = document.querySelector('button[onclick="inspector.startNewNet()"]'); const newNetBtn = document.querySelector('button[onclick="inspector.startNewNet()"]');
if(newNetBtn) newNetBtn.style.display = 'none'; if(newNetBtn) newNetBtn.style.display = 'none';
@ -256,7 +274,7 @@ class Inspector {
const lbl = document.createElement('div'); const lbl = document.createElement('div');
lbl.innerText = imgRec.name; lbl.innerText = imgRec.name;
lbl.style.cssText = "position:absolute; top:5px; left:5px; background:rgba(0,0,0,0.7); padding:2px 6px; font-size:0.7rem; pointer-events:none; border-radius:3px; color:white;"; lbl.style.cssText = "position:absolute; top:5px; right:5px; background:rgba(0,0,0,0.7); padding:2px 6px; font-size:0.7rem; pointer-events:none; border-radius:3px; color:white;";
cell.appendChild(lbl); cell.appendChild(lbl);
this.grid.appendChild(cell); this.grid.appendChild(cell);
@ -323,6 +341,7 @@ class Inspector {
cvs.addEventListener('pointerup', () => { cvs.addEventListener('pointerup', () => {
if(this.needsSync) { if(this.needsSync) {
this.updateNetNodeCache(); this.updateNetNodeCache();
this.updateTraceRenderCache();
this.needsSync = false; this.needsSync = false;
} }
}); });
@ -370,6 +389,7 @@ class Inspector {
} catch(e) { console.error("Inspector img load error", e); } } catch(e) { console.error("Inspector img load error", e); }
} }
this.updateNetNodeCache(); this.updateNetNodeCache();
this.updateTraceRenderCache();
if (this.masterId) this.syncCursors(this.masterId, null, null, true); if (this.masterId) this.syncCursors(this.masterId, null, null, true);
} }
@ -464,6 +484,115 @@ class Inspector {
Object.values(this.viewers).forEach(v => v.draw()); Object.values(this.viewers).forEach(v => v.draw());
} }
/**
* Recompute the per-image trace draw lists from the (cached) routed traces.
*
* Traces are routed only once per change set via the PcbVisualizer WASM API
* and stored in this.traceCache (reference-image space). Here we merely reuse
* the existing perspective transform to project them onto each visible view
* no per-view recalculation of routing occurs.
* @param forceAll force a full re-route of every net (cache invalidation)
*/
async updateTraceRenderCache(forceAll = false) {
this.traceRenderCache = {};
if (typeof currentBomId === 'undefined' || !currentBomId) return;
if (typeof ImageGraph === 'undefined') return;
const refId = this._traceReferenceId();
if (!refId) return;
this.traceRefId = refId;
const nets = await this._collectNets();
// One Dijkstra solve gives both directions (via inverse) for every image.
const refPaths = await ImageGraph.solvePaths(refId, this.cv, this.db);
const fwd = {}; // refId → id
const inv = {}; // id → refId
refPaths.forEach(p => {
fwd[p.id] = p.H;
const iH = ImageGraph.invertH(p.H);
if (iH) inv[p.id] = iH;
});
const projectNodeToRef = (node) => {
if (node.imgId === refId) return { x: node.x, y: node.y };
const H = inv[node.imgId];
if (!H) return null;
return this.cv.projectPoint(node.x, node.y, H);
};
try {
await this.traceCache.ensure(nets, refId, projectNodeToRef, forceAll || this._forceTraceRecalc);
} catch (e) {
console.error('[Inspector] trace routing failed', e);
}
this._forceTraceRecalc = false;
const activeId = this.activeNet ? this.activeNet.id : null;
for (const id of this.visibleIds) {
let projectPointFn;
if (id === refId) {
projectPointFn = (pt) => pt;
} else {
const H = fwd[id];
if (!H) { this.traceRenderCache[id] = []; continue; }
projectPointFn = (pt) => this.cv.projectPoint(pt.x, pt.y, H);
}
this.traceRenderCache[id] = this.traceRenderer.buildDrawList(
nets, activeId, this.showInactiveTraces, projectPointFn);
}
Object.values(this.viewers).forEach(v => v.draw());
}
/**
* Collect the nets of the current board, substituting the live (possibly
* unsaved) active net so the Inspect view never shows stale geometry.
* @returns Promise<Array> net records
*/
async _collectNets() {
const all = await this.db.getNets();
let nets = all.filter(n => n.projectId === currentBomId);
if (this.activeNet) {
nets = nets.filter(n => n.id !== this.activeNet.id);
nets.push(this.activeNet);
}
return nets;
}
/**
* Pick a stable reference image (top-most) for the routed coordinate space.
* @returns string|null image id
*/
_traceReferenceId() {
if (typeof bomImages === 'undefined' || !bomImages.length) return null;
const sorted = [...bomImages].sort((a, b) => {
const nA = a.name.toLowerCase(), nB = b.name.toLowerCase();
if (nA.includes('top')) return -1;
if (nB.includes('top')) return 1;
return nA.localeCompare(nB);
});
return sorted[0].id;
}
/**
* UI handler: toggle visibility of inactive net traces.
* @param show whether inactive traces should be visible
*/
toggleInactiveTraces(show) {
this.showInactiveTraces = !!show;
this.updateTraceRenderCache();
}
/**
* UI handler: invalidate the cache and re-route every net of the active
* board with the PcbVisualizer WASM API.
*/
async recalcAllTraces() {
this.traceCache.invalidateAll();
await this.updateTraceRenderCache(true);
}
async syncCursors(masterId, mx, my, forceRefresh = false) { async syncCursors(masterId, mx, my, forceRefresh = false) {
if (mx !== null && my !== null) { if (mx !== null && my !== null) {
this.cursorState = { masterId, mx, my }; this.cursorState = { masterId, mx, my };
@ -540,6 +669,12 @@ class Inspector {
if (!viewer) return; if (!viewer) return;
const ik = 1/k; const ik = 1/k;
// Render generated PCB traces beneath the node labels.
if (this.traceRenderer && this.traceRenderCache[id]) {
const mirrorWidth = (viewer.isMirrored && viewer.bmp) ? viewer.bmp.width : 0;
this.traceRenderer.draw(ctx, this.traceRenderCache[id], k, mirrorWidth);
}
if (this.netNodeCache[id]) { if (this.netNodeCache[id]) {
this.netNodeCache[id].forEach(n => { this.netNodeCache[id].forEach(n => {
let drawX = n.x; let drawX = n.x;
@ -973,5 +1108,6 @@ class Inspector {
`; `;
} }
this.updateNetNodeCache(); this.updateNetNodeCache();
this.updateTraceRenderCache();
} }
} }

View file

@ -28,6 +28,7 @@
<script src="studio.js" defer></script> <script src="studio.js" defer></script>
<script src="canvas-ui.js"></script> <script src="canvas-ui.js"></script>
<script src="nets.js"></script> <script src="nets.js"></script>
<script src="inspect-traces.js"></script>
<script src="inspector.js"></script> <script src="inspector.js"></script>
<link rel="stylesheet" href="common.css"> <link rel="stylesheet" href="common.css">
@ -394,6 +395,63 @@
min-height: 0; /* Critical for nested flex scrolling/sizing */ min-height: 0; /* Critical for nested flex scrolling/sizing */
} }
/* ── Inspect Toolbar ── */
/* Layers pill — shared base */
#inspect-layers-details summary {
cursor: pointer;
color: #334155;
font-weight: 700;
font-size: 0.8rem;
outline: none;
list-style: none;
white-space: nowrap;
user-select: none;
}
/* Kill the browser disclosure marker — shows as "9" on some mobile engines */
#inspect-layers-details summary::marker,
#inspect-layers-details summary::-webkit-details-marker {
display: none;
content: '';
}
#inspect-recalc-btn .label-text {
margin-left: 0.25rem;
}
/* ── Responsive: wide screens ── */
/* On wide screens: hide Inactive Traces from inside the dropdown (shown as toolbar pill instead) */
@media (min-width: 561px) {
#inspect-inactive-in-dropdown {
display: none !important;
}
}
/* ── Responsive: narrow / mobile ── */
@media (max-width: 560px) {
/* 1. Hide "Layers" text, keep only ☰ */
#inspect-layers-details .layers-label {
display: none;
}
/* 2. Hide the toolbar pill — Inactive Traces lives only in the dropdown on mobile */
#inspect-inactive-traces-toggle {
display: none !important;
}
/* 3. Recalc Traces: icon only */
#inspect-recalc-btn .label-text {
display: none;
}
/* Tighten the recalc button to a compact square */
#inspect-recalc-btn {
padding: 0 0.5rem !important;
min-width: 2rem;
}
}
/* Add to common.css or styles */ /* Add to common.css or styles */
.net-chip { .net-chip {
background: #e2e8f0; background: #e2e8f0;
@ -878,17 +936,34 @@
<!-- 1. Layers Dropdown --> <!-- 1. Layers Dropdown -->
<div style="pointer-events:auto; padding:0 0.5rem; background:rgba(255,255,255,0.95); box-shadow:0 2px 10px rgba(0,0,0,0.3); border-radius:4px; height:2rem; display:flex; align-items:center;"> <div style="pointer-events:auto; padding:0 0.5rem; background:rgba(255,255,255,0.95); box-shadow:0 2px 10px rgba(0,0,0,0.3); border-radius:4px; height:2rem; display:flex; align-items:center;">
<details id="inspect-layers-details" style="position:relative;"> <details id="inspect-layers-details" style="position:relative;">
<summary style="cursor:pointer; color:#334155; font-weight:700; font-size:0.8rem; outline:none; white-space:nowrap; list-style:none;">☰ Layers ▾</summary> <summary><span class="layers-label"> Layers</span> <span style="display:inline-block;width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid #334155;vertical-align:middle;margin-left:2px;"></span></summary>
<div id="inspect-layers" style="position:absolute; top:100%; left:-0.5rem; margin-top:0.5rem; background:white; padding:10px; border-radius:4px; box-shadow:0 4px 12px rgba(0,0,0,0.2); display:flex; flex-direction:column; gap:5px; min-width:200px;"></div> <div style="position:absolute; top:100%; left:-0.5rem; margin-top:0.5rem; background:white; padding:10px; border-radius:4px; box-shadow:0 4px 12px rgba(0,0,0,0.2); display:flex; flex-direction:column; gap:5px; min-width:200px;">
<!-- Inactive Traces toggle — always first in the dropdown list -->
<div id="inspect-inactive-in-dropdown" style="display:grid; grid-template-columns:20px 1fr; align-items:center; gap:5px; color:#1e40af; font-size:0.85rem; border-bottom:1px solid #bfdbfe; padding-bottom:4px; margin-bottom:2px; cursor:pointer; background:#eff6ff; border-radius:3px; padding:4px 4px 4px 4px;" title="Show traces of inactive nets" onclick="this.querySelector('input').click()">
<input type="checkbox" id="inspect-inactive-in-dropdown-cb" checked onchange="inspector.toggleInactiveTraces(this.checked); document.getElementById('inspect-inactive-cb').checked = this.checked; event.stopPropagation();" style="cursor:pointer;" onclick="event.stopPropagation();">
<span style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">Inactive Traces</span>
</div>
<!-- Layer images injected by JS into #inspect-layers below -->
<div id="inspect-layers" style="display:flex; flex-direction:column; gap:5px;"></div>
</div>
</details> </details>
</div> </div>
<!-- 2. Active Net Info (Hidden by default) --> <!-- 2. Inactive Traces toggle — shown on wide screens, hidden on mobile -->
<label id="inspect-inactive-traces-toggle" style="pointer-events:auto; display:flex; align-items:center; gap:5px; padding:0 0.6rem; height:2rem; background:rgba(255,255,255,0.95); box-shadow:0 2px 10px rgba(0,0,0,0.3); border-radius:4px; color:#334155; font-size:0.8rem; font-weight:700; white-space:nowrap; cursor:pointer;" title="Show traces of inactive nets">
<input type="checkbox" id="inspect-inactive-cb" checked onchange="inspector.toggleInactiveTraces(this.checked); document.getElementById('inspect-inactive-in-dropdown-cb').checked = this.checked;" style="cursor:pointer;">
<span class="label-text">Inactive Traces</span>
</label>
<!-- 3. Recalc Traces button (uses SVG to avoid ↻ font-fallback) -->
<button id="inspect-recalc-btn" class="secondary sm-btn" style="pointer-events:auto; box-shadow:0 2px 5px rgba(0,0,0,0.5); height:2rem; background:rgba(255,255,255,0.95);" onclick="inspector.recalcAllTraces()" title="Re-route all nets"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg><span class="label-text">Re-route</span></button>
<!-- 4. Active Net Info (Hidden by default) -->
<div id="inspect-active-net" style="pointer-events:auto; background:rgba(30, 41, 59, 0.95); padding:0 0.8rem; border-radius:4px; border:1px solid #475569; display:none; color:white; height:2rem; align-items:center; box-shadow:0 2px 5px rgba(0,0,0,0.5);"> <div id="inspect-active-net" style="pointer-events:auto; background:rgba(30, 41, 59, 0.95); padding:0 0.8rem; border-radius:4px; border:1px solid #475569; display:none; color:white; height:2rem; align-items:center; box-shadow:0 2px 5px rgba(0,0,0,0.5);">
<!-- Injected by JS --> <!-- Injected by JS -->
</div> </div>
<!-- 3. New Net Button --> <!-- 5. New Net Button -->
<button class="primary sm-btn" style="pointer-events:auto; box-shadow:0 2px 5px rgba(0,0,0,0.5); height:2rem;" onclick="inspector.startNewNet()">+ New Net</button> <button class="primary sm-btn" style="pointer-events:auto; box-shadow:0 2px 5px rgba(0,0,0,0.5); height:2rem;" onclick="inspector.startNewNet()">+ New Net</button>
</div> </div>

View file

@ -2944,6 +2944,12 @@ function requestInput(title, label, val, opts = {}) {
document.getElementById('gim-label').innerText = label; document.getElementById('gim-label').innerText = label;
inp.value = val || ''; inp.value = val || '';
// --- Remove any validation listener left over from a previous call ---
if (inp._validateHandler) {
inp.removeEventListener('input', inp._validateHandler);
inp._validateHandler = null;
}
// --- Handle Help Text --- // --- Handle Help Text ---
helpContent.style.display = 'none'; // Reset to hidden helpContent.style.display = 'none'; // Reset to hidden
if (opts.helpHtml) { if (opts.helpHtml) {
@ -2962,7 +2968,7 @@ function requestInput(title, label, val, opts = {}) {
let resultToResolve = null; let resultToResolve = null;
let isInputValid = true; // Updated by opts.validate; always true when no validator is set let isInputValid = true; // Updated by opts.validate; always true when no validator is set
// 1. Cleanup & Resolve // 1. Cleanup & Resolve
const close = () => { const close = () => {
window.removeEventListener('popstate', onPopState); window.removeEventListener('popstate', onPopState);
modal.style.display = 'none'; modal.style.display = 'none';
@ -3001,8 +3007,7 @@ function requestInput(title, label, val, opts = {}) {
close(); close();
} }
}; };
// 4. Setup DOM
// 4. Setup DOM
const newOk = document.getElementById('gim-ok-btn').cloneNode(true); const newOk = document.getElementById('gim-ok-btn').cloneNode(true);
const newCancel = document.getElementById('gim-cancel-btn').cloneNode(true); const newCancel = document.getElementById('gim-cancel-btn').cloneNode(true);
const newExtra = extraBtn.cloneNode(true); const newExtra = extraBtn.cloneNode(true);
@ -3040,7 +3045,7 @@ function requestInput(title, label, val, opts = {}) {
// etc.). requestInput only wires up the plumbing and gates OK on isValid. // etc.). requestInput only wires up the plumbing and gates OK on isValid.
if (opts.validate) { if (opts.validate) {
const validateArgs = opts.validateArgs ? opts.validateArgs : []; const validateArgs = opts.validateArgs ? opts.validateArgs : [];
inp.addEventListener('input', async () => { const handler = async () => {
const result = await opts.validate(inp.value, ...validateArgs); const result = await opts.validate(inp.value, ...validateArgs);
// Apply sanitized value if the validator changed it // Apply sanitized value if the validator changed it
@ -3056,7 +3061,9 @@ function requestInput(title, label, val, opts = {}) {
helpToggle.style.display = 'block'; helpToggle.style.display = 'block';
helpContent.style.display = 'block'; helpContent.style.display = 'block';
helpContent.innerHTML = result.feedbackHtml + (opts.helpHtml || ''); helpContent.innerHTML = result.feedbackHtml + (opts.helpHtml || '');
}); };
inp._validateHandler = handler;
inp.addEventListener('input', handler);
// Trigger once on open so the field is validated immediately // Trigger once on open so the field is validated immediately
setTimeout(() => inp.dispatchEvent(new Event('input')), 50); setTimeout(() => inp.dispatchEvent(new Event('input')), 50);
} }