From 114c983b08bed383527adcba26acb58694a61205 Mon Sep 17 00:00:00 2001 From: Taras Greben Date: Wed, 1 Jul 2026 17:38:52 +0300 Subject: [PATCH 01/10] 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. --- docs/inspect-traces.js | 336 +++++++++++++++++++++++++++++++++++++++++ docs/inspector.js | 138 ++++++++++++++++- docs/studio.html | 83 +++++++++- docs/studio.js | 17 ++- 4 files changed, 564 insertions(+), 10 deletions(-) create mode 100644 docs/inspect-traces.js diff --git a/docs/inspect-traces.js b/docs/inspect-traces.js new file mode 100644 index 0000000..3e31b65 --- /dev/null +++ b/docs/inspect-traces.js @@ -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 + */ + 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 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 }; +} diff --git a/docs/inspector.js b/docs/inspector.js index ec2eb3c..4d29e34 100644 --- a/docs/inspector.js +++ b/docs/inspector.js @@ -24,6 +24,16 @@ class Inspector { // Cache for image dimensions to avoid async bitmap creation on every render 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 this.initPromise = null; this.needsSync = false; @@ -58,6 +68,14 @@ class Inspector { this.backImagesCache = null; // Clear back-side cache 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()"]'); if(newNetBtn) newNetBtn.style.display = 'none'; @@ -256,7 +274,7 @@ class Inspector { const lbl = document.createElement('div'); 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); this.grid.appendChild(cell); @@ -323,6 +341,7 @@ class Inspector { cvs.addEventListener('pointerup', () => { if(this.needsSync) { this.updateNetNodeCache(); + this.updateTraceRenderCache(); this.needsSync = false; } }); @@ -370,6 +389,7 @@ class Inspector { } catch(e) { console.error("Inspector img load error", e); } } this.updateNetNodeCache(); + this.updateTraceRenderCache(); if (this.masterId) this.syncCursors(this.masterId, null, null, true); } @@ -464,6 +484,115 @@ class Inspector { 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 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) { if (mx !== null && my !== null) { this.cursorState = { masterId, mx, my }; @@ -540,6 +669,12 @@ class Inspector { if (!viewer) return; 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]) { this.netNodeCache[id].forEach(n => { let drawX = n.x; @@ -973,5 +1108,6 @@ class Inspector { `; } this.updateNetNodeCache(); + this.updateTraceRenderCache(); } } diff --git a/docs/studio.html b/docs/studio.html index d4b2975..f56e425 100644 --- a/docs/studio.html +++ b/docs/studio.html @@ -28,6 +28,7 @@ + @@ -394,6 +395,63 @@ 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 */ .net-chip { background: #e2e8f0; @@ -878,17 +936,34 @@
- ☰ Layers ▾ -
+ Layers +
+ +
+ + Inactive Traces +
+ +
+
- + + + + + + + - + diff --git a/docs/studio.js b/docs/studio.js index a59a96b..2ed528b 100644 --- a/docs/studio.js +++ b/docs/studio.js @@ -2944,6 +2944,12 @@ function requestInput(title, label, val, opts = {}) { document.getElementById('gim-label').innerText = label; 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 --- helpContent.style.display = 'none'; // Reset to hidden if (opts.helpHtml) { @@ -2962,7 +2968,7 @@ function requestInput(title, label, val, opts = {}) { let resultToResolve = null; let isInputValid = true; // Updated by opts.validate; always true when no validator is set - // 1. Cleanup & Resolve + // 1. Cleanup & Resolve const close = () => { window.removeEventListener('popstate', onPopState); modal.style.display = 'none'; @@ -3001,8 +3007,7 @@ function requestInput(title, label, val, opts = {}) { close(); } }; - - // 4. Setup DOM + // 4. Setup DOM const newOk = document.getElementById('gim-ok-btn').cloneNode(true); const newCancel = document.getElementById('gim-cancel-btn').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. if (opts.validate) { const validateArgs = opts.validateArgs ? opts.validateArgs : []; - inp.addEventListener('input', async () => { + const handler = async () => { const result = await opts.validate(inp.value, ...validateArgs); // Apply sanitized value if the validator changed it @@ -3056,7 +3061,9 @@ function requestInput(title, label, val, opts = {}) { helpToggle.style.display = 'block'; helpContent.style.display = 'block'; helpContent.innerHTML = result.feedbackHtml + (opts.helpHtml || ''); - }); + }; + inp._validateHandler = handler; + inp.addEventListener('input', handler); // Trigger once on open so the field is validated immediately setTimeout(() => inp.dispatchEvent(new Event('input')), 50); } From 10c3fda73b280de3a51b374a68e364d0a3218434 Mon Sep 17 00:00:00 2001 From: Taras Greben Date: Thu, 2 Jul 2026 22:27:12 +0300 Subject: [PATCH 02/10] Changed PCB calculated trace styling to an old-school pre-SMD look. --- docs/inspect-traces.js | 334 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 312 insertions(+), 22 deletions(-) diff --git a/docs/inspect-traces.js b/docs/inspect-traces.js index 3e31b65..4ca892f 100644 --- a/docs/inspect-traces.js +++ b/docs/inspect-traces.js @@ -31,8 +31,12 @@ const TraceConfig = { 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, + /** Junction rounding radius (screen pixels, scale-compensated). */ + JUNCTION_RADIUS: 10, + /** Outer radius of a pin's copper pad (screen pixels, scale-compensated). */ + PAD_OUTER_RADIUS: 4, + /** Radius of the drill hole punched out of the centre of each pad. */ + PAD_HOLE_RADIUS: 2.5, /** * Global recalculation policy when any single net changes: * 'single' — recompute only the modified net (default, fastest). @@ -77,7 +81,7 @@ class TraceCache { * module — pre-resolved WireBender Module (overridable for tests). */ constructor(opts = {}) { - /** netId → { sig, name, wires:[[{x,y}]], junctions:[{x,y}] } (reference space). */ + /** netId → { sig, name, wires:[[{x,y}]], junctions:[{x,y}], pads:[{x,y}] } (reference space). */ this.entries = new Map(); /** Reference image id the cached geometry belongs to. */ this.refId = null; @@ -172,11 +176,12 @@ class TraceCache { }); // Nets with < 2 pads cannot be routed — store empty geometry but record - // the current signature so they are not retried every refresh. + // the current signature so they are not retried every refresh. Pad + // positions are kept regardless, so a lone pin can still be rendered. prepared.forEach(({ net, pads }) => { if (pads.length < 2) { this.entries.set(net.id, { - sig: NetSignature.of(net), name: net.name, wires: [], junctions: [], + sig: NetSignature.of(net), name: net.name, wires: [], junctions: [], pads, }); } }); @@ -218,11 +223,11 @@ class TraceCache { byKey[key].junctions.push({ x: d.position.x, y: d.position.y }); } - routable.forEach(({ net }) => { + routable.forEach(({ net, pads }) => { 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, + wires: data.wires, junctions: data.junctions, pads, }); }); this.stats.netsRouted += routable.length; @@ -244,6 +249,7 @@ class TraceRenderer { */ constructor(cache) { this.cache = cache; + this.tempCanvas = null; // Cached offscreen canvas to prevent frame-rate drops } /** @@ -253,7 +259,7 @@ class TraceRenderer { * @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}] } + * @returns array of { netId, isActive, color, polylines:[[{x,y}]], junctions:[{x,y}], pads:[{x,y}] } */ buildDrawList(nets, activeNetId, showInactive, projectPointFn) { const list = []; @@ -281,7 +287,13 @@ class TraceRenderer { if (q) junctions.push(q); } - list.push({ netId: net.id, isActive, color, polylines, junctions }); + const pads = []; + for (const p of (entry.pads || [])) { + const q = projectPointFn(p); + if (q) pads.push(q); + } + + list.push({ netId: net.id, isActive, color, polylines, junctions, pads }); } // Active net is drawn last so it sits on top of inactive traces. @@ -294,6 +306,11 @@ class TraceRenderer { * the viewer (image space). Mirroring is applied per-point to match the * node-label rendering in inspector.js. * + * Rendering order per net mimics real copper: traces first, a small + * fillet at each junction to blend separate wire segments together, then + * pin pads (ring with a drilled hole) on top so connected pins read as + * through-hole pads rather than bare wire ends. + * * @param ctx 2D canvas context (translated/scaled by the viewer) * @param drawList output of buildDrawList() * @param k current viewer scale @@ -304,29 +321,302 @@ class TraceRenderer { 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'; + // Helper to calculate the shortest distance from point p to segment ab + const distanceToSegment = (p, a, b) => { + const dx = b.x - a.x; + const dy = b.y - a.y; + const l2 = dx * dx + dy * dy; + if (l2 === 0) { + return { dist: Math.hypot(p.x - a.x, p.y - a.y), t: 0 }; + } + let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2; + t = Math.max(0, Math.min(1, t)); + const projX = a.x + t * dx; + const projY = a.y + t * dy; + return { + dist: Math.hypot(p.x - projX, p.y - projY), + t: t + }; + }; + // Helper to walk along trace segments and determine the exact physical room for the fillet. + // Stops instantly if we hit a pad or a sharp turn (>= 45 degrees). + const getSmartPointAlongPolyline = (pl, startIndex, direction, targetDist, padCoords) => { + let accumulatedDist = 0; + let currIdx = startIndex; + let prevDir = null; + let remainingDist = targetDist; + let currentPt = pl[startIndex]; + + while (true) { + const nextIdx = currIdx + direction; + if (nextIdx < 0 || nextIdx >= pl.length || remainingDist <= 0) { + return { pt: currentPt, actualDist: accumulatedDist }; + } + + const p1 = pl[currIdx]; + const p2 = pl[nextIdx]; + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const len = Math.hypot(dx, dy); + + if (len === 0) { + currIdx = nextIdx; + continue; + } + + const unitDir = { x: dx / len, y: dy / len }; + + if (prevDir !== null) { + const dot = prevDir.x * unitDir.x + prevDir.y * unitDir.y; + // Sharp turn of 45 degrees or more (dot < 0.707): stop immediately at the vertex + if (dot < 0.707) { + return { pt: p1, actualDist: accumulatedDist }; + } + } + + // Check if the next vertex p2 is close to a pad + const nearPad = padCoords.some(pad => Math.hypot(p2.x - pad.x, p2.y - pad.y) < 2.0); + + if (len >= remainingDist) { + const targetPt = { + x: p1.x + unitDir.x * remainingDist, + y: p1.y + unitDir.y * remainingDist + }; + return { pt: targetPt, actualDist: accumulatedDist + remainingDist }; + } + + accumulatedDist += len; + remainingDist -= len; + prevDir = unitDir; + currentPt = p2; + + if (nearPad) { + return { pt: p2, actualDist: accumulatedDist }; + } + + currIdx = nextIdx; + } + }; + + // Allocate or resize the offscreen canvas to match the main viewport + if (!this.tempCanvas) { + this.tempCanvas = document.createElement('canvas'); + } + if (this.tempCanvas.width !== ctx.canvas.width || this.tempCanvas.height !== ctx.canvas.height) { + this.tempCanvas.width = ctx.canvas.width; + this.tempCanvas.height = ctx.canvas.height; + } + + const tempCtx = this.tempCanvas.getContext('2d'); + tempCtx.clearRect(0, 0, this.tempCanvas.width, this.tempCanvas.height); + tempCtx.globalCompositeOperation = 'source-over'; + + // Copy transform from main canvas to draw in the correct space + tempCtx.save(); + tempCtx.setTransform(ctx.getTransform()); + + for (const item of drawList) { + tempCtx.strokeStyle = item.color; + tempCtx.fillStyle = item.color; + tempCtx.lineWidth = TraceConfig.TRACE_WIDTH * ik; + tempCtx.lineJoin = 'round'; + tempCtx.lineCap = 'round'; + + // 1. Draw Wires for (const pl of item.polylines) { - ctx.beginPath(); + if (pl.length < 2) continue; + + tempCtx.beginPath(); pl.forEach((p, i) => { const x = mx(p.x); - if (i === 0) ctx.moveTo(x, p.y); - else ctx.lineTo(x, p.y); + if (i === 0) tempCtx.moveTo(x, p.y); + else tempCtx.lineTo(x, p.y); }); - ctx.stroke(); + tempCtx.stroke(); } - ctx.fillStyle = item.color; + // 2. Draw Junctions (filleted smooth corners) + const rJunc = TraceConfig.JUNCTION_RADIUS * ik; + 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(); + const branches = []; + + for (const pl of item.polylines) { + if (pl.length < 2) continue; + + // Find the single closest vertex of this polyline to the junction + let minVertDist = Infinity; + let closestVertIdx = -1; + for (let i = 0; i < pl.length; i++) { + const dist = Math.hypot(pl[i].x - j.x, pl[i].y - j.y); + if (dist < minVertDist) { + minVertDist = dist; + closestVertIdx = i; + } + } + + // Find the single closest segment of this polyline to the junction + let minSegDist = Infinity; + let closestSegIdx = -1; + for (let i = 0; i < pl.length - 1; i++) { + const res = distanceToSegment(j, pl[i], pl[i + 1]); + if (res.dist < minSegDist) { + minSegDist = res.dist; + closestSegIdx = i; + } + } + + // Target fillet size (fully matches JUNCTION_RADIUS) + const targetWalkDist = rJunc; + + if (minVertDist < 1.5) { + const idx = closestVertIdx; + if (idx > 0) { + const res = getSmartPointAlongPolyline(pl, idx, -1, targetWalkDist, item.pads); + const dx = res.pt.x - j.x; + const dy = res.pt.y - j.y; + const len = Math.hypot(dx, dy); + if (len > 0) { + branches.push({ + dir: { x: dx / len, y: dy / len }, + maxLen: len + }); + } + } + if (idx < pl.length - 1) { + const res = getSmartPointAlongPolyline(pl, idx, 1, targetWalkDist, item.pads); + const dx = res.pt.x - j.x; + const dy = res.pt.y - j.y; + const len = Math.hypot(dx, dy); + if (len > 0) { + branches.push({ + dir: { x: dx / len, y: dy / len }, + maxLen: len + }); + } + } + } else if (minSegDist < 2.0) { + const a = pl[closestSegIdx]; + const b = pl[closestSegIdx + 1]; + + // Branch towards a (backward) + const lenA = Math.hypot(a.x - j.x, a.y - j.y); + if (lenA > 0) { + const targetA = Math.max(0, targetWalkDist - lenA); + const resA = getSmartPointAlongPolyline(pl, closestSegIdx, -1, targetA, item.pads); + const dx = resA.pt.x - j.x; + const dy = resA.pt.y - j.y; + const len = Math.hypot(dx, dy); + if (len > 0) { + branches.push({ + dir: { x: dx / len, y: dy / len }, + maxLen: len + }); + } + } + + // Branch towards b (forward) + const lenB = Math.hypot(b.x - j.x, b.y - j.y); + if (lenB > 0) { + const targetB = Math.max(0, targetWalkDist - lenB); + const resB = getSmartPointAlongPolyline(pl, closestSegIdx + 1, 1, targetB, item.pads); + const dx = resB.pt.x - j.x; + const dy = resB.pt.y - j.y; + const len = Math.hypot(dx, dy); + if (len > 0) { + branches.push({ + dir: { x: dx / len, y: dy / len }, + maxLen: len + }); + } + } + } + } + + // Deduplicate branch directions pointing the same way (within ~5.7 degrees) + const uniqueBranches = []; + for (const b of branches) { + const angle = Math.atan2(b.dir.y, b.dir.x); + let duplicate = false; + for (const ub of uniqueBranches) { + let diff = Math.abs(angle - ub.angle); + if (diff > Math.PI) diff = 2 * Math.PI - diff; + if (diff < 0.1) { + duplicate = true; + ub.maxLen = Math.min(ub.maxLen, b.maxLen); + break; + } + } + if (!duplicate) { + uniqueBranches.push({ + dir: b.dir, + angle: angle, + maxLen: b.maxLen + }); + } + } + + if (uniqueBranches.length >= 2) { + uniqueBranches.sort((a, b) => a.angle - b.angle); + + for (let i = 0; i < uniqueBranches.length; i++) { + const b1 = uniqueBranches[i]; + const b2 = uniqueBranches[(i + 1) % uniqueBranches.length]; + + // Avoid drawing flat fillets on straight runs (180 degrees) + const dot = b1.dir.x * b2.dir.x + b1.dir.y * b2.dir.y; + if (dot < -0.99) continue; + + // Use the physical distances calculated by the path walker directly + const r1 = b1.maxLen; + const r2 = b2.maxLen; + + const p1 = { x: j.x + b1.dir.x * r1, y: j.y + b1.dir.y * r1 }; + const p2 = { x: j.x + b2.dir.x * r2, y: j.y + b2.dir.y * r2 }; + + tempCtx.beginPath(); + tempCtx.moveTo(mx(j.x), j.y); + tempCtx.lineTo(mx(p1.x), p1.y); + tempCtx.quadraticCurveTo(mx(j.x), j.y, mx(p2.x), p2.y); + tempCtx.closePath(); + tempCtx.fill(); + } + } else { + // Fallback to solid circular dot if we cannot resolve multiple branch directions + tempCtx.beginPath(); + tempCtx.arc(mx(j.x), j.y, rJunc, 0, Math.PI * 2); + tempCtx.fill(); + } + } + + // 3. Draw Solid Pads + for (const p of item.pads) { + const cx = mx(p.x), cy = p.y; + tempCtx.beginPath(); + tempCtx.arc(cx, cy, TraceConfig.PAD_OUTER_RADIUS * ik, 0, Math.PI * 2); + tempCtx.fill(); } } + + // 4. Cleanly "drill" the holes through copper layer using transparent compositing + tempCtx.globalCompositeOperation = 'destination-out'; + for (const item of drawList) { + for (const p of item.pads) { + const cx = mx(p.x), cy = p.y; + tempCtx.beginPath(); + tempCtx.arc(cx, cy, TraceConfig.PAD_HOLE_RADIUS * ik, 0, Math.PI * 2); + tempCtx.fill(); + } + } + + tempCtx.restore(); + + // Overlay the final rendered offscreen layers onto the main canvas + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset transform for direct 1:1 pixel copy + ctx.drawImage(this.tempCanvas, 0, 0); + ctx.restore(); } } From ce2106feaa755866d51fff559e1a298d2c63f6f3 Mon Sep 17 00:00:00 2001 From: Taras Greben Date: Fri, 3 Jul 2026 14:40:02 +0300 Subject: [PATCH 03/10] Don't let Schema ReTrace panic over 3-leg diodes or caps. --- docs/schema/components.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/schema/components.js b/docs/schema/components.js index bb81dd9..feed1fb 100644 --- a/docs/schema/components.js +++ b/docs/schema/components.js @@ -598,8 +598,15 @@ export class ZenerComp extends _DiodeBase { export class ICComp extends CompBase { static prefixes = []; // catch-all — matched when no other class claims the prefix static typeKey = 'IC'; - get color() { return '#a78bfa'; } - get label() { return 'IC'; } + + constructor(state) { + super(state); + const Cls = _byType.get(state.type); + this._delegate = (Cls && Cls !== ICComp && Cls !== KiCadComp) ? new Cls(state) : null; + } + + get color() { return this._delegate ? this._delegate.color : '#a78bfa'; } + get label() { return this._delegate ? this._delegate.label : 'IC'; } _buildGeometry() { const cp = this._s.pins; @@ -847,6 +854,9 @@ const _byPrefix = ALL_TYPES */ export function createComp(state) { const Cls = _byType.get(state.type) ?? ICComp; + if (Cls !== ICComp && Cls !== KiCadComp && state.pins && state.pins.length > 2) { + return new ICComp(state); + } return new Cls(state); } From d27e2ca26754b939c090f26a8de38e47f30233cc Mon Sep 17 00:00:00 2001 From: Taras Greben Date: Fri, 3 Jul 2026 17:16:21 +0300 Subject: [PATCH 04/10] Add Re-route button to Schema ReTrace. --- docs/schema.html | 18 ++++++++++++++---- docs/schema/ui.js | 8 ++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/schema.html b/docs/schema.html index ddc5a0e..399bb84 100644 --- a/docs/schema.html +++ b/docs/schema.html @@ -149,9 +149,10 @@ .mobile-only { display: inline-flex !important; } .hide-mobile { display: none !important; } .responsive-text::after { content: attr(data-short); } + #header { gap: 6px; padding: 4px 8px; overflow-x: auto; padding-bottom: 2px; } + .btn-group { gap: 4px; } #header .btn { padding: 4px 8px; } #header .sep { display: none; } - #header { overflow-x: auto; padding-bottom: 2px; } #header::-webkit-scrollbar { height: 4px; } #header::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } @@ -246,8 +247,15 @@
- - + + +
@@ -281,7 +289,9 @@ - +
diff --git a/docs/schema/ui.js b/docs/schema/ui.js index 7a8b55f..ed01e35 100644 --- a/docs/schema/ui.js +++ b/docs/schema/ui.js @@ -406,6 +406,14 @@ export function initUI(mode, importNetlistStandalone, resetLayout, hasLayoutData }; } + document.getElementById('btn-reroute').onclick = async () => { + if (!S.hasData) { toast('No netlist', 'warn'); return; } + pushHistory(); + const l = await import('./layout.js'); + await l.buildAndRoute({}, false); + toast('Re-routed schematic diagram'); + }; + document.getElementById('btn-auto-place').onclick = async () => { if (!S.hasData) { toast('No netlist', 'warn'); return; } if (hasLayoutData && hasLayoutData()) { From c5e66e8b97b15264a8d00813c962b4720b817df2 Mon Sep 17 00:00:00 2001 From: Taras Greben Date: Fri, 3 Jul 2026 17:54:47 +0300 Subject: [PATCH 05/10] Sort Nets and Components lists in Schema ReTrace, navigate through items using Up/Down keys. --- docs/schema/app.js | 5 +++++ docs/schema/interaction.js | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/docs/schema/app.js b/docs/schema/app.js index fd80d08..cffeec4 100644 --- a/docs/schema/app.js +++ b/docs/schema/app.js @@ -302,6 +302,11 @@ async function _ingestParsed(parsed, studioComps = [], boardId = null) { isWip: (n.nodes || []).length <= 1, })); + // Sort globally once per ingestion using natural (BOM) collation order + const naturalCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); + S.components.sort((a, b) => naturalCollator.compare(a.ref, b.ref)); + S.nets.sort((a, b) => naturalCollator.compare(a.name, b.name)); + S.hasData = true; S.selectedComp = null; S.selectedNet = null; diff --git a/docs/schema/interaction.js b/docs/schema/interaction.js index b7c19a7..b37f6a1 100644 --- a/docs/schema/interaction.js +++ b/docs/schema/interaction.js @@ -527,6 +527,45 @@ export function initInteraction(saveComponentLayout, saveViewport) { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; _hideContextMenu(); + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + const isUp = e.key === 'ArrowUp'; + const naturalCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); + + if (S.selectedComp) { + e.preventDefault(); + const idx = S.components.findIndex(c => c.id === S.selectedComp); + if (idx !== -1) { + const nextIdx = isUp ? idx - 1 : idx + 1; + if (nextIdx >= 0 && nextIdx < S.components.length) { + const nextComp = S.components[nextIdx]; + S.selectedComp = nextComp.id; + showProperties(nextComp); + updateSidePanels(); + render(); + document.querySelector('.comp-item.selected')?.scrollIntoView({ block: 'nearest' }); + } + } + return; + } + + if (S.selectedNet) { + e.preventDefault(); + const idx = S.nets.findIndex(n => n.name === S.selectedNet); + if (idx !== -1) { + const nextIdx = isUp ? idx - 1 : idx + 1; + if (nextIdx >= 0 && nextIdx < S.nets.length) { + const nextNet = S.nets[nextIdx]; + S.selectedNet = nextNet.name; + showNetProperties(nextNet); + updateSidePanels(); + render(); + document.querySelector('.net-item.selected')?.scrollIntoView({ block: 'nearest' }); + } + } + return; + } + } + if ((e.key === 'z' || e.key === 'Z') && (e.ctrlKey || e.metaKey)) { undo(); return; } From b4977b2c2a8bde20c4e778489923c36d2c1b61d7 Mon Sep 17 00:00:00 2001 From: Taras Greben Date: Fri, 3 Jul 2026 18:11:45 +0300 Subject: [PATCH 06/10] Ignore release script. --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 7474250..769061f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ bom.json tmp/ temp/ *.log + +release.sh From da0bd681e1c2cee189bfa0764d90ae0653d8d976 Mon Sep 17 00:00:00 2001 From: Taras Greben Date: Fri, 10 Jul 2026 12:02:46 +0300 Subject: [PATCH 07/10] Ignore net attributes to improve schematic diagrams (eliminate wierd bus routing in some cases). --- docs/schema/layout.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/schema/layout.js b/docs/schema/layout.js index 4b55dd4..6c692ce 100644 --- a/docs/schema/layout.js +++ b/docs/schema/layout.js @@ -318,6 +318,17 @@ export async function buildAndRoute(lockedPlacements = {}, runPlacement = true) // 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(); From ff4ac844d01db00851da2a29f03f902b7aad3591 Mon Sep 17 00:00:00 2001 From: Taras Greben Date: Fri, 10 Jul 2026 13:45:28 +0300 Subject: [PATCH 08/10] Navigation (history) from modal boxes fixed. --- docs/studio.js | 35 +++++++++++++---------------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/docs/studio.js b/docs/studio.js index 2ed528b..2edc69c 100644 --- a/docs/studio.js +++ b/docs/studio.js @@ -1407,7 +1407,6 @@ function confirmAction(message, btnText = "Confirm") { const okBtn = document.getElementById('confirm-btn-ok'); const cancelBtn = document.getElementById('confirm-btn-cancel'); const closeBtn = modal.querySelector('.close-btn'); - const modalContext = 'confirmation-modal'; msgEl.innerText = message; okBtn.innerText = btnText; @@ -1416,24 +1415,24 @@ function confirmAction(message, btnText = "Confirm") { // 1. Cleanup & Resolve const close = () => { - window.removeEventListener('popstate', onPopState); + window.removeEventListener('keydown', onKeyDown, true); modal.style.display = 'none'; resolve(resultToResolve); }; - // 2. Handle History Changes (Back Button / Escape) - const onPopState = () => { - close(); + // 2. Escape Key Listener (Capturing phase to intercept before NavManager) + const onKeyDown = (e) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + commit(false); + } }; // 3. Handle UI Actions (OK / Cancel) const commit = (res) => { resultToResolve = res; - if (history.state && history.state.context === modalContext) { - history.back(); // This triggers onPopState -> close() - } else { - close(); - } + close(); }; // 4. Setup DOM (Clone to cleanly remove old listeners) @@ -1446,12 +1445,8 @@ function confirmAction(message, btnText = "Confirm") { newCancel.onclick = (e) => { e.stopPropagation(); commit(false); }; closeBtn.onclick = (e) => { e.stopPropagation(); e.preventDefault(); commit(false); }; - // 5. Open & Push State - if (!history.state || history.state.context !== modalContext) { - history.pushState({ context: modalContext }, "", ""); - } - - window.addEventListener('popstate', onPopState); + // 5. Setup Listeners and Display + window.addEventListener('keydown', onKeyDown, true); modal.style.display = 'flex'; newCancel.focus(); // Default focus on Cancel }); @@ -2634,12 +2629,8 @@ const NavManager = { if (typeof iframe !== 'undefined') { iframe.style.display = 'none'; iframe.src = 'about:blank'; } // 2. Restore View - if (ctx === 'map') { - switchView('map', true); // Pass true to skip pushState - } else if (ctx === 'nets') { - switchView('nets', true); - } else if (ctx === 'inspect') { - switchView('inspect', true); + if (ctx && document.getElementById('view-' + ctx)) { + switchView(ctx, true); } else { // Default to List switchView('list', true); From e3d1052faaf3d61c6808d6cc9f548872a9b29efe Mon Sep 17 00:00:00 2001 From: Taras Greben Date: Thu, 27 Aug 2026 23:35:45 +0300 Subject: [PATCH 09/10] tune linguist to not skip docs --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d85544f --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +docs/** linguist-documentation=false From 5e4c7245b6f845603347888c8cc1a2db72c4c42b Mon Sep 17 00:00:00 2001 From: Stuart Date: Thu, 3 Sep 2026 14:02:16 +1000 Subject: [PATCH 10/10] CMMS fork: re-vendor all runtime deps locally, strip analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream loads zip.js, OpenCV.js, WireBender (JS+WASM) and Google Fonts from CDNs at runtime, and includes a Google Tag / gtag.js snippet. The CMMS deployment forbids external requests. Changes: - docs/vendor/ — local copies of opencv.js (Apache-2.0), zip.min.js (BSD-3-Clause), WireBender.{js,wasm} (AGPL-3.0/commercial, source at github.com/dev-lab/WireBender), and the three Google fonts (OFL, latin subset). SOURCE.txt records provenance. - studio.html / schema.html — zip.js + opencv.js + fonts @import point at docs/vendor/. - inspect-traces.js / schema/layout.js — WireBender import() + locateFile point at docs/vendor/wirebender/. - every *.html — Google Tag / gtag.js snippet removed. No behaviour change; kicad-symbols zip was already bundled + local. CMMS embedding hooks (window.PCBRETRACE_CONFIG, save-back, AI bridge) land in a later commit. --- docs/coil.html | 5 +- docs/guide.html | 5 +- docs/index.html | 5 +- docs/inductor.html | 5 +- docs/inspect-traces.js | 4 +- docs/resistor.html | 5 +- docs/schema.html | 9 ++-- docs/schema/layout.js | 4 +- docs/studio.html | 9 ++-- docs/vendor/SOURCE.txt | 36 ++++++++++++++ docs/vendor/fonts/fonts.css | 56 ++++++++++++++++++++++ docs/vendor/fonts/rajdhani-400.woff2 | Bin 0 -> 14976 bytes docs/vendor/fonts/rajdhani-500.woff2 | Bin 0 -> 15084 bytes docs/vendor/fonts/rajdhani-600.woff2 | Bin 0 -> 15732 bytes docs/vendor/fonts/rajdhani-700.woff2 | Bin 0 -> 15688 bytes docs/vendor/fonts/sharetechmono-400.woff2 | Bin 0 -> 13500 bytes docs/vendor/fonts/spacemono-400.woff2 | Bin 0 -> 16520 bytes docs/vendor/fonts/spacemono-700.woff2 | Bin 0 -> 16724 bytes docs/vendor/opencv.js | 48 +++++++++++++++++++ docs/vendor/wirebender/WireBender.js | 16 +++++++ docs/vendor/wirebender/WireBender.wasm | Bin 0 -> 946167 bytes docs/vendor/zip.min.js | 1 + 22 files changed, 172 insertions(+), 36 deletions(-) create mode 100644 docs/vendor/SOURCE.txt create mode 100644 docs/vendor/fonts/fonts.css create mode 100644 docs/vendor/fonts/rajdhani-400.woff2 create mode 100644 docs/vendor/fonts/rajdhani-500.woff2 create mode 100644 docs/vendor/fonts/rajdhani-600.woff2 create mode 100644 docs/vendor/fonts/rajdhani-700.woff2 create mode 100644 docs/vendor/fonts/sharetechmono-400.woff2 create mode 100644 docs/vendor/fonts/spacemono-400.woff2 create mode 100644 docs/vendor/fonts/spacemono-700.woff2 create mode 100644 docs/vendor/opencv.js create mode 100644 docs/vendor/wirebender/WireBender.js create mode 100644 docs/vendor/wirebender/WireBender.wasm create mode 100644 docs/vendor/zip.min.js diff --git a/docs/coil.html b/docs/coil.html index 29fe9d4..45cd89e 100644 --- a/docs/coil.html +++ b/docs/coil.html @@ -7,10 +7,7 @@ - - - - + diff --git a/docs/guide.html b/docs/guide.html index fe8bfb7..27b91c8 100644 --- a/docs/guide.html +++ b/docs/guide.html @@ -7,10 +7,7 @@ - - - - + diff --git a/docs/index.html b/docs/index.html index 1365914..b61d8ae 100644 --- a/docs/index.html +++ b/docs/index.html @@ -7,10 +7,7 @@ - - - - + diff --git a/docs/inductor.html b/docs/inductor.html index cf19dc4..4a5fe41 100644 --- a/docs/inductor.html +++ b/docs/inductor.html @@ -7,10 +7,7 @@ - - - - + diff --git a/docs/inspect-traces.js b/docs/inspect-traces.js index 4ca892f..b6a2e14 100644 --- a/docs/inspect-traces.js +++ b/docs/inspect-traces.js @@ -44,9 +44,9 @@ const TraceConfig = { */ RECALC_MODE: 'single', /** WireBender WASM module entry point. */ - WASM_URL: 'https://dev-lab.github.io/WireBender/latest/WireBender.js', + WASM_URL: 'vendor/wirebender/WireBender.js', /** WireBender WASM binary. */ - WASM_BINARY_URL: 'https://dev-lab.github.io/WireBender/latest/WireBender.wasm', + WASM_BINARY_URL: 'vendor/wirebender/WireBender.wasm', }; /** diff --git a/docs/resistor.html b/docs/resistor.html index c153ca2..d012d9a 100644 --- a/docs/resistor.html +++ b/docs/resistor.html @@ -7,10 +7,7 @@ - - - - + diff --git a/docs/schema.html b/docs/schema.html index 399bb84..c5906fb 100644 --- a/docs/schema.html +++ b/docs/schema.html @@ -7,17 +7,14 @@ - - - - + Schematic ReTrace - +