Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e4c7245b6 | ||
|
|
e3d1052faa | ||
|
|
ff4ac844d0 | ||
|
|
da0bd681e1 | ||
|
|
b4977b2c2a | ||
|
|
c5e66e8b97 | ||
|
|
d27e2ca267 | ||
|
|
ce2106feaa | ||
|
|
10c3fda73b | ||
|
|
114c983b08 |
30 changed files with 1129 additions and 72 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
docs/** linguist-documentation=false
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -42,3 +42,5 @@ bom.json
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
release.sh
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,7 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("consent","default",{ad_storage:"granted",ad_user_data:"granted",ad_personalization:"granted",analytics_storage:"granted"});gtag("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied",region:"AT,BE,BG,HR,CY,CZ,DK,EE,FI,FR,DE,GR,HU,IE,IT,LV,LT,LU,MT,NL,PL,PT,RO,SK,SI,ES,SE,IS,LI,NO,GB,CH,UA".split(",")});</script>
|
<!-- CMMS fork: Google Tag / analytics removed (no external requests) -->
|
||||||
<!-- Google tag (gtag.js) -->
|
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-X1MKL6C4B0"></script>
|
|
||||||
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date());gtag("config","G-X1MKL6C4B0");</script>
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<link rel="icon" href="favicon.ico">
|
<link rel="icon" href="favicon.ico">
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,7 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("consent","default",{ad_storage:"granted",ad_user_data:"granted",ad_personalization:"granted",analytics_storage:"granted"});gtag("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied",region:"AT,BE,BG,HR,CY,CZ,DK,EE,FI,FR,DE,GR,HU,IE,IT,LV,LT,LU,MT,NL,PL,PT,RO,SK,SI,ES,SE,IS,LI,NO,GB,CH,UA".split(",")});</script>
|
<!-- CMMS fork: Google Tag / analytics removed (no external requests) -->
|
||||||
<!-- Google tag (gtag.js) -->
|
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-X1MKL6C4B0"></script>
|
|
||||||
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date());gtag("config","G-X1MKL6C4B0");</script>
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="icon" href="favicon.ico">
|
<link rel="icon" href="favicon.ico">
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,7 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("consent","default",{ad_storage:"granted",ad_user_data:"granted",ad_personalization:"granted",analytics_storage:"granted"});gtag("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied",region:"AT,BE,BG,HR,CY,CZ,DK,EE,FI,FR,DE,GR,HU,IE,IT,LV,LT,LU,MT,NL,PL,PT,RO,SK,SI,ES,SE,IS,LI,NO,GB,CH,UA".split(",")});</script>
|
<!-- CMMS fork: Google Tag / analytics removed (no external requests) -->
|
||||||
<!-- Google tag (gtag.js) -->
|
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-X1MKL6C4B0"></script>
|
|
||||||
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date());gtag("config","G-X1MKL6C4B0");</script>
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="icon" href="favicon.ico">
|
<link rel="icon" href="favicon.ico">
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,7 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("consent","default",{ad_storage:"granted",ad_user_data:"granted",ad_personalization:"granted",analytics_storage:"granted"});gtag("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied",region:"AT,BE,BG,HR,CY,CZ,DK,EE,FI,FR,DE,GR,HU,IE,IT,LV,LT,LU,MT,NL,PL,PT,RO,SK,SI,ES,SE,IS,LI,NO,GB,CH,UA".split(",")});</script>
|
<!-- CMMS fork: Google Tag / analytics removed (no external requests) -->
|
||||||
<!-- Google tag (gtag.js) -->
|
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-X1MKL6C4B0"></script>
|
|
||||||
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date());gtag("config","G-X1MKL6C4B0");</script>
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<link rel="icon" href="favicon.ico">
|
<link rel="icon" href="favicon.ico">
|
||||||
|
|
|
||||||
626
docs/inspect-traces.js
Normal file
626
docs/inspect-traces.js
Normal file
|
|
@ -0,0 +1,626 @@
|
||||||
|
/*
|
||||||
|
* 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 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).
|
||||||
|
* 'all' — recompute every net (avoids cross-net routing conflicts).
|
||||||
|
*/
|
||||||
|
RECALC_MODE: 'single',
|
||||||
|
/** WireBender WASM module entry point. */
|
||||||
|
WASM_URL: 'vendor/wirebender/WireBender.js',
|
||||||
|
/** WireBender WASM binary. */
|
||||||
|
WASM_BINARY_URL: 'vendor/wirebender/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}], pads:[{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. 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: [], pads,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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, 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, pads,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
this.tempCanvas = null; // Cached offscreen canvas to prevent frame-rate drops
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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}], pads:[{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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* @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);
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if (pl.length < 2) continue;
|
||||||
|
|
||||||
|
tempCtx.beginPath();
|
||||||
|
pl.forEach((p, i) => {
|
||||||
|
const x = mx(p.x);
|
||||||
|
if (i === 0) tempCtx.moveTo(x, p.y);
|
||||||
|
else tempCtx.lineTo(x, p.y);
|
||||||
|
});
|
||||||
|
tempCtx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Draw Junctions (filleted smooth corners)
|
||||||
|
const rJunc = TraceConfig.JUNCTION_RADIUS * ik;
|
||||||
|
|
||||||
|
for (const j of item.junctions) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for CommonJS test environments without affecting browser globals.
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { TraceConfig, NetSignature, TraceCache, TraceRenderer };
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,7 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("consent","default",{ad_storage:"granted",ad_user_data:"granted",ad_personalization:"granted",analytics_storage:"granted"});gtag("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied",region:"AT,BE,BG,HR,CY,CZ,DK,EE,FI,FR,DE,GR,HU,IE,IT,LV,LT,LU,MT,NL,PL,PT,RO,SK,SI,ES,SE,IS,LI,NO,GB,CH,UA".split(",")});</script>
|
<!-- CMMS fork: Google Tag / analytics removed (no external requests) -->
|
||||||
<!-- Google tag (gtag.js) -->
|
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-X1MKL6C4B0"></script>
|
|
||||||
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date());gtag("config","G-X1MKL6C4B0");</script>
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<link rel="icon" href="favicon.ico">
|
<link rel="icon" href="favicon.ico">
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,14 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("consent","default",{ad_storage:"granted",ad_user_data:"granted",ad_personalization:"granted",analytics_storage:"granted"});gtag("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied",region:"AT,BE,BG,HR,CY,CZ,DK,EE,FI,FR,DE,GR,HU,IE,IT,LV,LT,LU,MT,NL,PL,PT,RO,SK,SI,ES,SE,IS,LI,NO,GB,CH,UA".split(",")});</script>
|
<!-- CMMS fork: Google Tag / analytics removed (no external requests) -->
|
||||||
<!-- Google tag (gtag.js) -->
|
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-X1MKL6C4B0"></script>
|
|
||||||
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date());gtag("config","G-X1MKL6C4B0");</script>
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Schematic ReTrace</title>
|
<title>Schematic ReTrace</title>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.8.26/dist/zip.min.js"></script>
|
<script src="vendor/zip.min.js"></script>
|
||||||
<script src="db-core.js"></script>
|
<script src="db-core.js"></script>
|
||||||
<style>
|
<style>
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap');
|
@import url('vendor/fonts/fonts.css');
|
||||||
:root {
|
:root {
|
||||||
--bg:#0d1117; --surface:#161b22; --surface2:#1c2230; --border:#30363d;
|
--bg:#0d1117; --surface:#161b22; --surface2:#1c2230; --border:#30363d;
|
||||||
--accent:#00e5a0; --accent2:#00a8ff; --accent3:#ff6b35; --warn:#f0c040;
|
--accent:#00e5a0; --accent2:#00a8ff; --accent3:#ff6b35; --warn:#f0c040;
|
||||||
|
|
@ -149,9 +146,10 @@
|
||||||
.mobile-only { display: inline-flex !important; }
|
.mobile-only { display: inline-flex !important; }
|
||||||
.hide-mobile { display: none !important; }
|
.hide-mobile { display: none !important; }
|
||||||
.responsive-text::after { content: attr(data-short); }
|
.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 .btn { padding: 4px 8px; }
|
||||||
#header .sep { display: none; }
|
#header .sep { display: none; }
|
||||||
#header { overflow-x: auto; padding-bottom: 2px; }
|
|
||||||
#header::-webkit-scrollbar { height: 4px; }
|
#header::-webkit-scrollbar { height: 4px; }
|
||||||
#header::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
#header::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||||
|
|
||||||
|
|
@ -246,8 +244,15 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="sep"></div>
|
<div class="sep"></div>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn" id="btn-auto-place" title="Auto Layout">🔀<span class="hide-mobile"> Auto Layout</span></button>
|
<button class="btn" id="btn-reroute" title="Re-route the Schematic">
|
||||||
<button class="btn" id="btn-zoom-fit" title="Fit View">⛶<span class="hide-mobile"> Fit</span></button>
|
<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="hide-mobile" style="margin-left:5px;">Re-route</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="btn-auto-place" title="Auto Layout">🔀<span class="hide-mobile" style="margin-left:5px;">Auto Layout</span></button>
|
||||||
|
<button class="btn" id="btn-zoom-fit" title="Fit View">
|
||||||
|
<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="M6 3H3v3 M3 18v3h3 M18 21h3v-3 M21 6V3h-3"/></svg>
|
||||||
|
<span class="hide-mobile" style="margin-left:5px;">Fit</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="sep"></div>
|
<div class="sep"></div>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
|
|
@ -281,7 +286,9 @@
|
||||||
<button class="btn mobile-only" id="btn-toggle-left">☰</button>
|
<button class="btn mobile-only" id="btn-toggle-left">☰</button>
|
||||||
<button class="btn" id="btn-zoom-in">+</button>
|
<button class="btn" id="btn-zoom-in">+</button>
|
||||||
<button class="btn" id="btn-zoom-out">−</button>
|
<button class="btn" id="btn-zoom-out">−</button>
|
||||||
<button class="btn" id="btn-zoom-fit2">⛶</button>
|
<button class="btn" id="btn-zoom-fit2" title="Fit View">
|
||||||
|
<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="M6 3H3v3 M3 18v3h3 M18 21h3v-3 M21 6V3h-3"/></svg>
|
||||||
|
</button>
|
||||||
<div class="sep hide-mobile" style="height:20px"></div>
|
<div class="sep hide-mobile" style="height:20px"></div>
|
||||||
<button class="btn" id="btn-undo">↩</button>
|
<button class="btn" id="btn-undo">↩</button>
|
||||||
<button class="btn mobile-only" id="btn-toggle-right">🛈</button>
|
<button class="btn mobile-only" id="btn-toggle-right">🛈</button>
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,11 @@ async function _ingestParsed(parsed, studioComps = [], boardId = null) {
|
||||||
isWip: (n.nodes || []).length <= 1,
|
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.hasData = true;
|
||||||
S.selectedComp = null;
|
S.selectedComp = null;
|
||||||
S.selectedNet = null;
|
S.selectedNet = null;
|
||||||
|
|
|
||||||
|
|
@ -598,8 +598,15 @@ export class ZenerComp extends _DiodeBase {
|
||||||
export class ICComp extends CompBase {
|
export class ICComp extends CompBase {
|
||||||
static prefixes = []; // catch-all — matched when no other class claims the prefix
|
static prefixes = []; // catch-all — matched when no other class claims the prefix
|
||||||
static typeKey = 'IC';
|
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() {
|
_buildGeometry() {
|
||||||
const cp = this._s.pins;
|
const cp = this._s.pins;
|
||||||
|
|
@ -847,6 +854,9 @@ const _byPrefix = ALL_TYPES
|
||||||
*/
|
*/
|
||||||
export function createComp(state) {
|
export function createComp(state) {
|
||||||
const Cls = _byType.get(state.type) ?? ICComp;
|
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);
|
return new Cls(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -527,6 +527,45 @@ export function initInteraction(saveComponentLayout, saveViewport) {
|
||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
_hideContextMenu();
|
_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)) {
|
if ((e.key === 'z' || e.key === 'Z') && (e.ctrlKey || e.metaKey)) {
|
||||||
undo(); return;
|
undo(); return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,9 @@ let _modulePromise = null;
|
||||||
*/
|
*/
|
||||||
export function initWireBender() {
|
export function initWireBender() {
|
||||||
if (_modulePromise) return _modulePromise;
|
if (_modulePromise) return _modulePromise;
|
||||||
_modulePromise = import('https://dev-lab.github.io/WireBender/latest/WireBender.js')
|
_modulePromise = import('../vendor/wirebender/WireBender.js')
|
||||||
.then(m => m.default({
|
.then(m => m.default({
|
||||||
locateFile: f => f === 'WireBender.wasm' ? 'https://dev-lab.github.io/WireBender/latest/WireBender.wasm' : f,
|
locateFile: f => f === 'WireBender.wasm' ? new URL('../vendor/wirebender/WireBender.wasm', import.meta.url).href : f,
|
||||||
}))
|
}))
|
||||||
.then(module => {
|
.then(module => {
|
||||||
_WB = module;
|
_WB = module;
|
||||||
|
|
@ -318,6 +318,17 @@ export async function buildAndRoute(lockedPlacements = {}, runPlacement = true)
|
||||||
|
|
||||||
// Classify (bus detection)
|
// Classify (bus detection)
|
||||||
const cls = _wb.classify();
|
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);
|
_wb.applyClassification(cls);
|
||||||
cls.delete();
|
cls.delete();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
document.getElementById('btn-auto-place').onclick = async () => {
|
||||||
if (!S.hasData) { toast('No netlist', 'warn'); return; }
|
if (!S.hasData) { toast('No netlist', 'warn'); return; }
|
||||||
if (hasLayoutData && hasLayoutData()) {
|
if (hasLayoutData && hasLayoutData()) {
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,15 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("consent","default",{ad_storage:"granted",ad_user_data:"granted",ad_personalization:"granted",analytics_storage:"granted"});gtag("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied",region:"AT,BE,BG,HR,CY,CZ,DK,EE,FI,FR,DE,GR,HU,IE,IT,LV,LT,LU,MT,NL,PL,PT,RO,SK,SI,ES,SE,IS,LI,NO,GB,CH,UA".split(",")});</script>
|
<!-- CMMS fork: Google Tag / analytics removed (no external requests) -->
|
||||||
<!-- Google tag (gtag.js) -->
|
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-X1MKL6C4B0"></script>
|
|
||||||
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date());gtag("config","G-X1MKL6C4B0");</script>
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<link rel="icon" href="favicon.ico">
|
<link rel="icon" href="favicon.ico">
|
||||||
<title>PCB ReTrace Studio</title>
|
<title>PCB ReTrace Studio</title>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.8.26/dist/zip.min.js"></script>
|
<script src="vendor/zip.min.js"></script>
|
||||||
|
|
||||||
<!-- LIBRARIES -->
|
<!-- LIBRARIES -->
|
||||||
<script async src="https://docs.opencv.org/4.x/opencv.js" type="text/javascript"></script>
|
<script async src="vendor/opencv.js" type="text/javascript"></script>
|
||||||
<script src="spyglass.js"></script>
|
<script src="spyglass.js"></script>
|
||||||
|
|
||||||
<!-- CORE LOGIC -->
|
<!-- CORE LOGIC -->
|
||||||
|
|
@ -28,6 +25,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 +392,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 +933,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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1407,7 +1407,6 @@ function confirmAction(message, btnText = "Confirm") {
|
||||||
const okBtn = document.getElementById('confirm-btn-ok');
|
const okBtn = document.getElementById('confirm-btn-ok');
|
||||||
const cancelBtn = document.getElementById('confirm-btn-cancel');
|
const cancelBtn = document.getElementById('confirm-btn-cancel');
|
||||||
const closeBtn = modal.querySelector('.close-btn');
|
const closeBtn = modal.querySelector('.close-btn');
|
||||||
const modalContext = 'confirmation-modal';
|
|
||||||
|
|
||||||
msgEl.innerText = message;
|
msgEl.innerText = message;
|
||||||
okBtn.innerText = btnText;
|
okBtn.innerText = btnText;
|
||||||
|
|
@ -1416,24 +1415,24 @@ function confirmAction(message, btnText = "Confirm") {
|
||||||
|
|
||||||
// 1. Cleanup & Resolve
|
// 1. Cleanup & Resolve
|
||||||
const close = () => {
|
const close = () => {
|
||||||
window.removeEventListener('popstate', onPopState);
|
window.removeEventListener('keydown', onKeyDown, true);
|
||||||
modal.style.display = 'none';
|
modal.style.display = 'none';
|
||||||
resolve(resultToResolve);
|
resolve(resultToResolve);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. Handle History Changes (Back Button / Escape)
|
// 2. Escape Key Listener (Capturing phase to intercept before NavManager)
|
||||||
const onPopState = () => {
|
const onKeyDown = (e) => {
|
||||||
close();
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
commit(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. Handle UI Actions (OK / Cancel)
|
// 3. Handle UI Actions (OK / Cancel)
|
||||||
const commit = (res) => {
|
const commit = (res) => {
|
||||||
resultToResolve = res;
|
resultToResolve = res;
|
||||||
if (history.state && history.state.context === modalContext) {
|
close();
|
||||||
history.back(); // This triggers onPopState -> close()
|
|
||||||
} else {
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. Setup DOM (Clone to cleanly remove old listeners)
|
// 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); };
|
newCancel.onclick = (e) => { e.stopPropagation(); commit(false); };
|
||||||
closeBtn.onclick = (e) => { e.stopPropagation(); e.preventDefault(); commit(false); };
|
closeBtn.onclick = (e) => { e.stopPropagation(); e.preventDefault(); commit(false); };
|
||||||
|
|
||||||
// 5. Open & Push State
|
// 5. Setup Listeners and Display
|
||||||
if (!history.state || history.state.context !== modalContext) {
|
window.addEventListener('keydown', onKeyDown, true);
|
||||||
history.pushState({ context: modalContext }, "", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('popstate', onPopState);
|
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
newCancel.focus(); // Default focus on Cancel
|
newCancel.focus(); // Default focus on Cancel
|
||||||
});
|
});
|
||||||
|
|
@ -2634,12 +2629,8 @@ const NavManager = {
|
||||||
if (typeof iframe !== 'undefined') { iframe.style.display = 'none'; iframe.src = 'about:blank'; }
|
if (typeof iframe !== 'undefined') { iframe.style.display = 'none'; iframe.src = 'about:blank'; }
|
||||||
|
|
||||||
// 2. Restore View
|
// 2. Restore View
|
||||||
if (ctx === 'map') {
|
if (ctx && document.getElementById('view-' + ctx)) {
|
||||||
switchView('map', true); // Pass true to skip pushState
|
switchView(ctx, true);
|
||||||
} else if (ctx === 'nets') {
|
|
||||||
switchView('nets', true);
|
|
||||||
} else if (ctx === 'inspect') {
|
|
||||||
switchView('inspect', true);
|
|
||||||
} else {
|
} else {
|
||||||
// Default to List
|
// Default to List
|
||||||
switchView('list', true);
|
switchView('list', true);
|
||||||
|
|
@ -2944,6 +2935,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 +2959,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 +2998,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 +3036,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 +3052,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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
36
docs/vendor/SOURCE.txt
vendored
Normal file
36
docs/vendor/SOURCE.txt
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
Third-party runtime dependencies, vendored locally for the CMMS fork
|
||||||
|
==================================================================
|
||||||
|
|
||||||
|
Upstream pcb-retrace loads these from CDNs at runtime. The CMMS deployment
|
||||||
|
forbids external requests (CSP + offline-capable), so they are copied here and
|
||||||
|
the references in ../studio.html, ../schema.html, ../inspect-traces.js and
|
||||||
|
../schema/layout.js are rewritten to these local paths. Fetched 2026-09-03.
|
||||||
|
|
||||||
|
opencv.js
|
||||||
|
https://docs.opencv.org/4.x/opencv.js (OpenCV 4.x prebuilt, single-file,
|
||||||
|
wasm embedded as a base64 data URI — no separate .wasm)
|
||||||
|
License: Apache-2.0. Used by cv-core.js for ORB feature matching / photo
|
||||||
|
alignment only.
|
||||||
|
|
||||||
|
zip.min.js
|
||||||
|
https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.8.26/dist/zip.min.js
|
||||||
|
License: BSD-3-Clause (Gildas Lormeau). Project-ZIP + kicad-symbols-zip
|
||||||
|
read/write.
|
||||||
|
|
||||||
|
wirebender/WireBender.js + WireBender.wasm
|
||||||
|
https://dev-lab.github.io/WireBender/latest/{WireBender.js,WireBender.wasm}
|
||||||
|
License: dual AGPL-3.0-only OR commercial (Taras Greben). Corresponding
|
||||||
|
source: https://github.com/dev-lab/WireBender (not modified here — the
|
||||||
|
published binary is redistributed as-is alongside this AGPL fork).
|
||||||
|
Cosmetic net-trace routing for the Inspect view + Schema ReTrace layout.
|
||||||
|
ES module (uses import.meta.url); the .wasm is resolved by emscripten's
|
||||||
|
default locateFile relative to the module, or via the explicit URL() in
|
||||||
|
schema/layout.js.
|
||||||
|
|
||||||
|
fonts/ (Rajdhani v17, Share Tech Mono v16, Space Mono v17 — latin subset only)
|
||||||
|
https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Space+Mono:wght@400;700
|
||||||
|
License: SIL Open Font License 1.1. Used by schema.html only. fonts.css
|
||||||
|
replaces the external @import.
|
||||||
|
|
||||||
|
../assets/kicad-symbols-master.zip is bundled by upstream itself (KiCad symbol
|
||||||
|
libraries, CC-BY-SA-4.0 + KiCad library exception) — not added here.
|
||||||
56
docs/vendor/fonts/fonts.css
vendored
Normal file
56
docs/vendor/fonts/fonts.css
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
/*
|
||||||
|
* Local copies of the Google Fonts used by schema.html (Schema ReTrace).
|
||||||
|
* Latin subset only. Fetched 2026-09-03 from fonts.googleapis.com /
|
||||||
|
* fonts.gstatic.com. Rajdhani v17, Share Tech Mono v16, Space Mono v17.
|
||||||
|
* All three are licensed under the SIL Open Font License 1.1.
|
||||||
|
* Replaces an external @import so the app makes no cross-origin requests.
|
||||||
|
*/
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Rajdhani';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./rajdhani-400.woff2) format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Rajdhani';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./rajdhani-500.woff2) format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Rajdhani';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./rajdhani-600.woff2) format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Rajdhani';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./rajdhani-700.woff2) format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Share Tech Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./sharetechmono-400.woff2) format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./spacemono-400.woff2) format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./spacemono-700.woff2) format('woff2');
|
||||||
|
}
|
||||||
BIN
docs/vendor/fonts/rajdhani-400.woff2
vendored
Normal file
BIN
docs/vendor/fonts/rajdhani-400.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/vendor/fonts/rajdhani-500.woff2
vendored
Normal file
BIN
docs/vendor/fonts/rajdhani-500.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/vendor/fonts/rajdhani-600.woff2
vendored
Normal file
BIN
docs/vendor/fonts/rajdhani-600.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/vendor/fonts/rajdhani-700.woff2
vendored
Normal file
BIN
docs/vendor/fonts/rajdhani-700.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/vendor/fonts/sharetechmono-400.woff2
vendored
Normal file
BIN
docs/vendor/fonts/sharetechmono-400.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/vendor/fonts/spacemono-400.woff2
vendored
Normal file
BIN
docs/vendor/fonts/spacemono-400.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/vendor/fonts/spacemono-700.woff2
vendored
Normal file
BIN
docs/vendor/fonts/spacemono-700.woff2
vendored
Normal file
Binary file not shown.
48
docs/vendor/opencv.js
vendored
Normal file
48
docs/vendor/opencv.js
vendored
Normal file
File diff suppressed because one or more lines are too long
16
docs/vendor/wirebender/WireBender.js
vendored
Normal file
16
docs/vendor/wirebender/WireBender.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
docs/vendor/wirebender/WireBender.wasm
vendored
Normal file
BIN
docs/vendor/wirebender/WireBender.wasm
vendored
Normal file
Binary file not shown.
1
docs/vendor/zip.min.js
vendored
Normal file
1
docs/vendor/zip.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue