1113 lines
34 KiB
JavaScript
1113 lines
34 KiB
JavaScript
/*
|
||
* Copyright (c) 2025-2026 Taras Greben
|
||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
|
||
* See LICENSE file for details.
|
||
*/
|
||
|
||
/* inspector.js - Visual Trace Tracking (v6) */
|
||
|
||
class Inspector {
|
||
constructor(db, cv) {
|
||
this.db = db;
|
||
this.cv = cv;
|
||
this.grid = document.getElementById('inspect-grid');
|
||
this.sidebarList = document.getElementById('inspect-layers');
|
||
this.activeNetEl = document.getElementById('inspect-active-net');
|
||
|
||
this.viewers = {};
|
||
this.visibleIds = new Set();
|
||
this.activeNet = null;
|
||
this.masterId = null;
|
||
this.netNodeCache = {}; // Cache for calculated node positions
|
||
this.bomCache = {}; // Cache for projected BOM coordinates
|
||
|
||
// 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;
|
||
}
|
||
|
||
async getImageResolution(img) {
|
||
if (!img || !img.blob) return { w: 0, h: 0 };
|
||
if (this.resolutionCache[img.id]) return this.resolutionCache[img.id];
|
||
try {
|
||
const bmp = await createImageBitmap(img.blob);
|
||
const res = { w: bmp.width, h: bmp.height };
|
||
bmp.close();
|
||
this.resolutionCache[img.id] = res;
|
||
return res;
|
||
} catch (e) { return { w: 0, h: 0 }; }
|
||
}
|
||
|
||
// Wrapper to prevent race conditions when switchView calls init()
|
||
// and loadNet() is called immediately after
|
||
async init() {
|
||
if (this.initPromise) return this.initPromise;
|
||
this.initPromise = this._performInit();
|
||
try {
|
||
await this.initPromise;
|
||
} finally {
|
||
this.initPromise = null;
|
||
}
|
||
}
|
||
|
||
async _performInit() {
|
||
this.bomCache = {}; // Clear cache on re-init
|
||
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';
|
||
|
||
const sortedImgs = [...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);
|
||
});
|
||
|
||
sortedImgs.forEach(img => {
|
||
const row = document.createElement('div');
|
||
row.style.cssText = "display:grid; grid-template-columns: 20px 1fr; align-items:center; gap:5px; color:#334155; font-size:0.85rem; border-bottom:1px solid #f1f5f9; padding-bottom:4px;";
|
||
|
||
const chk = document.createElement('input');
|
||
chk.type = 'checkbox';
|
||
chk.dataset.id = img.id;
|
||
chk.onchange = () => this.toggleLayer(img.id, chk.checked);
|
||
|
||
const label = document.createElement('span');
|
||
label.innerText = img.name;
|
||
label.style.cssText = "white-space:nowrap; overflow:hidden; text-overflow:ellipsis;";
|
||
label.title = img.name;
|
||
|
||
row.appendChild(chk);
|
||
row.appendChild(label);
|
||
this.sidebarList.appendChild(row);
|
||
});
|
||
|
||
if(this.visibleIds.size === 0) {
|
||
const isDesktop = window.matchMedia("(min-width: 800px)").matches;
|
||
const nextVisible = new Set();
|
||
|
||
if (isDesktop) {
|
||
sortedImgs.forEach(img => nextVisible.add(img.id));
|
||
} else {
|
||
await this.selectBestMobilePair(sortedImgs, nextVisible);
|
||
}
|
||
|
||
this.visibleIds = nextVisible;
|
||
Array.from(this.sidebarList.querySelectorAll('input')).forEach(chk => {
|
||
chk.checked = this.visibleIds.has(chk.dataset.id);
|
||
});
|
||
} else {
|
||
Array.from(this.sidebarList.querySelectorAll('input')).forEach(chk => {
|
||
chk.checked = this.visibleIds.has(chk.dataset.id);
|
||
});
|
||
}
|
||
|
||
this.updateNetUI();
|
||
await this.renderGrid();
|
||
}
|
||
|
||
async selectBestMobilePair(sortedImgs, selectionSet) {
|
||
let topCand = [], botCand = [], otherCand = [];
|
||
|
||
for (const img of sortedImgs) {
|
||
const n = img.name.toLowerCase();
|
||
if (n.includes('top') || n.includes('front')) topCand.push(img);
|
||
else if (n.includes('bot') || n.includes('back')) botCand.push(img);
|
||
else otherCand.push(img);
|
||
}
|
||
|
||
let bestPair = null;
|
||
let maxCombinedRes = 0;
|
||
|
||
if (topCand.length > 0 && botCand.length > 0) {
|
||
for (const t of topCand) {
|
||
const paths = await ImageGraph.solvePaths(t.id, this.cv, this.db);
|
||
const reachableIds = new Set(paths.map(p => p.id));
|
||
const resT = await this.getImageResolution(t);
|
||
const pxT = resT.w * resT.h;
|
||
|
||
for (const b of botCand) {
|
||
if (reachableIds.has(b.id)) {
|
||
const resB = await this.getImageResolution(b);
|
||
const totalPx = pxT + (resB.w * resB.h);
|
||
if (totalPx > maxCombinedRes) {
|
||
maxCombinedRes = totalPx;
|
||
bestPair = [t, b];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!bestPair) {
|
||
const pickBest = async (list) => {
|
||
if (list.length === 0) return null;
|
||
let best = list[0];
|
||
let maxP = 0;
|
||
for (const i of list) {
|
||
const r = await this.getImageResolution(i);
|
||
if ((r.w * r.h) > maxP) { maxP = r.w * r.h; best = i; }
|
||
}
|
||
return best;
|
||
};
|
||
const t = await pickBest(topCand);
|
||
const b = await pickBest(botCand);
|
||
if (t) selectionSet.add(t.id);
|
||
if (b) selectionSet.add(b.id);
|
||
} else {
|
||
selectionSet.add(bestPair[0].id);
|
||
selectionSet.add(bestPair[1].id);
|
||
}
|
||
|
||
if (selectionSet.size < 2) {
|
||
const others = [...topCand, ...botCand, ...otherCand].filter(x => !selectionSet.has(x.id));
|
||
for (const o of others) {
|
||
if (selectionSet.size >= 2) break;
|
||
selectionSet.add(o.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
async renderGrid() {
|
||
const savedStates = {};
|
||
if (this.viewers) {
|
||
Object.entries(this.viewers).forEach(([id, v]) => {
|
||
if (v.t) savedStates[id] = { t: { ...v.t }, interacted: v.userInteracted || false };
|
||
});
|
||
}
|
||
|
||
this.grid.innerHTML = '';
|
||
this.viewers = {};
|
||
|
||
if(this.visibleIds.size === 0) {
|
||
this.grid.innerHTML = '<div style="display:flex; align-items:center; justify-content:center; color:#64748b; height:100%;">Select layers to inspect</div>';
|
||
return;
|
||
}
|
||
|
||
// --- SMART GRID CALCULATION (Aspect Ratio Aware) ---
|
||
const count = this.visibleIds.size;
|
||
const rect = this.grid.getBoundingClientRect();
|
||
const width = rect.width || window.innerWidth;
|
||
const height = rect.height || window.innerHeight;
|
||
|
||
// 1. Calculate Average Aspect Ratio
|
||
let totalAR = 0;
|
||
let validARCount = 0;
|
||
for (const id of this.visibleIds) {
|
||
const imgRec = bomImages.find(i => i.id === id);
|
||
if (imgRec) {
|
||
const res = await this.getImageResolution(imgRec);
|
||
if (res.w > 0 && res.h > 0) {
|
||
totalAR += (res.w / res.h);
|
||
validARCount++;
|
||
}
|
||
}
|
||
}
|
||
const avgAR = (validARCount > 0) ? (totalAR / validARCount) : 1.5;
|
||
|
||
// 2. Solve for Best Layout (Maximize Scale)
|
||
let bestCols = 1;
|
||
let maxScale = 0;
|
||
|
||
for (let c = 1; c <= count; c++) {
|
||
const r = Math.ceil(count / c);
|
||
const cellW = width / c;
|
||
const cellH = height / r;
|
||
const scale = Math.min(cellW / avgAR, cellH);
|
||
|
||
if (scale > maxScale) {
|
||
maxScale = scale;
|
||
bestCols = c;
|
||
}
|
||
}
|
||
|
||
const cols = bestCols;
|
||
const rows = Math.ceil(count / cols);
|
||
|
||
this.grid.style.display = 'grid';
|
||
this.grid.style.width = '100%';
|
||
this.grid.style.height = '100%';
|
||
this.grid.style.boxSizing = 'border-box';
|
||
this.grid.style.gap = '2px';
|
||
this.grid.style.background = '#000';
|
||
this.grid.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`;
|
||
this.grid.style.gridTemplateRows = `repeat(${rows}, minmax(0, 1fr))`;
|
||
|
||
if(!this.masterId || !this.visibleIds.has(this.masterId)) {
|
||
this.masterId = this.visibleIds.values().next().value;
|
||
}
|
||
|
||
for(const id of this.visibleIds) {
|
||
const imgRec = bomImages.find(i => i.id === id);
|
||
if(!imgRec) continue;
|
||
|
||
const cell = document.createElement('div');
|
||
cell.style.cssText = "position:relative; overflow:hidden; border:1px solid #334155; background:#000; width:100%; height:100%; min-width:0; min-height:0;";
|
||
|
||
const cvs = document.createElement('canvas');
|
||
cvs.id = `inspect-cvs-${id}`;
|
||
cvs.style.cssText = "display:block; position:absolute; top:0; left:0; width:100%; height:100%;";
|
||
cell.appendChild(cvs);
|
||
|
||
const lbl = document.createElement('div');
|
||
lbl.innerText = imgRec.name;
|
||
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);
|
||
|
||
let wasActiveBeforeDown = false;
|
||
let stateRestored = false;
|
||
|
||
const viewer = new PanZoomCanvas(cvs.id,
|
||
(ctx, k) => this.drawOverlay(id, ctx, k),
|
||
async (x, y, e) => {
|
||
if (e.button !== 0) return;
|
||
if (wasActiveBeforeDown) {
|
||
const hit = await this.handleNodeClick(id, x, y);
|
||
if (!hit) {
|
||
await this.handleAddNode(id, x, y);
|
||
}
|
||
}
|
||
},
|
||
// Drag Handler
|
||
(dx, dy, mode, idx) => {
|
||
if (!this.activeNet) return -1;
|
||
if (mode === 'check') {
|
||
const cache = this.netNodeCache[id];
|
||
if (!cache) return -1;
|
||
// Only allow dragging Source (Blue) nodes
|
||
const foundIdx = cache.findIndex(n => {
|
||
if (!n.isSource) return false;
|
||
const dist = Math.hypot(n.x - dx, n.y - dy);
|
||
return (dist * viewer.t.k) < 20;
|
||
});
|
||
return foundIdx;
|
||
} else if (mode === 'move') {
|
||
const n = this.netNodeCache[id][idx];
|
||
if (n && n.origNode) {
|
||
// Update Data Model
|
||
n.origNode.x += dx;
|
||
n.origNode.y += dy;
|
||
// Update Visual Cache
|
||
n.x += dx;
|
||
n.y += dy;
|
||
viewer.draw();
|
||
this.needsSync = true;
|
||
}
|
||
}
|
||
}
|
||
);
|
||
|
||
viewer.userInteracted = false;
|
||
|
||
viewer.onPointerDown = (e) => {
|
||
viewer.userInteracted = true;
|
||
if (e.isPrimary || e.button === 0) {
|
||
wasActiveBeforeDown = (this.masterId === id);
|
||
if (this.masterId !== id) {
|
||
this.masterId = id;
|
||
this.syncCursors(id, null, null, true);
|
||
}
|
||
const pt = viewer.getImgCoords(e.clientX, e.clientY);
|
||
this.syncCursors(id, pt.x, pt.y);
|
||
}
|
||
};
|
||
|
||
// Trigger re-projection when drag ends
|
||
cvs.addEventListener('pointerup', () => {
|
||
if(this.needsSync) {
|
||
this.updateNetNodeCache();
|
||
this.updateTraceRenderCache();
|
||
this.needsSync = false;
|
||
}
|
||
});
|
||
|
||
cvs.addEventListener('wheel', () => { viewer.userInteracted = true; });
|
||
|
||
viewer.onMouseMove = (x, y) => {
|
||
if(this.masterId === id) this.syncCursors(id, x, y);
|
||
};
|
||
|
||
const updateView = () => {
|
||
if (!viewer.bmp || cvs.width < 20 || cvs.height < 20) return;
|
||
if (savedStates[id] && savedStates[id].interacted && !stateRestored) {
|
||
viewer.t = savedStates[id].t;
|
||
stateRestored = true;
|
||
viewer.userInteracted = true;
|
||
viewer.draw();
|
||
}
|
||
else if (!viewer.userInteracted) {
|
||
viewer.fit();
|
||
}
|
||
};
|
||
|
||
viewer.onResize = (w, h) => updateView();
|
||
|
||
cvs.addEventListener('contextmenu', (e) => {
|
||
e.preventDefault(); e.stopPropagation();
|
||
this.masterId = null;
|
||
this.cursorState = null;
|
||
Object.values(this.viewers).forEach(v => {
|
||
v.cursorPos = null; v.setDimmed(false); v.draw();
|
||
});
|
||
});
|
||
|
||
this.viewers[id] = viewer;
|
||
|
||
try {
|
||
const bmp = await createImageBitmap(imgRec.blob);
|
||
viewer.setImage(bmp);
|
||
updateView();
|
||
|
||
if(imgRec.name.toLowerCase().includes('bot') && !imgRec.name.toLowerCase().includes('top')) {
|
||
viewer.setMirror(true);
|
||
}
|
||
} catch(e) { console.error("Inspector img load error", e); }
|
||
}
|
||
this.updateNetNodeCache();
|
||
this.updateTraceRenderCache();
|
||
if (this.masterId) this.syncCursors(this.masterId, null, null, true);
|
||
}
|
||
|
||
async handleNodeClick(imgId, x, y) {
|
||
if (!this.activeNet || !this.netNodeCache[imgId]) return false;
|
||
const viewer = this.viewers[imgId];
|
||
if (!viewer) return false;
|
||
|
||
const HIT_RADIUS = 20;
|
||
const hit = this.netNodeCache[imgId].find(n => {
|
||
const dist = Math.hypot(n.x - x, n.y - y);
|
||
return (dist * viewer.t.k) < HIT_RADIUS;
|
||
});
|
||
|
||
if (hit) {
|
||
const res = await requestInput("Edit Node", "Node Name", hit.label, {
|
||
extraBtn: { label: 'Delete', value: '__DELETE__', class: 'danger' },
|
||
helpHtml: PIN_HELP_HTML,
|
||
validate: validateNetName,
|
||
validateArgs: this.activeNet ? [this.activeNet.id] : null
|
||
});
|
||
if (res === '__DELETE__') {
|
||
const idx = this.activeNet.nodes.indexOf(hit.origNode);
|
||
if (idx > -1) this.activeNet.nodes.splice(idx, 1);
|
||
} else if (res) {
|
||
hit.origNode.label = res;
|
||
}
|
||
if (res) {
|
||
this.updateNetUI();
|
||
Object.values(this.viewers).forEach(v => v.draw());
|
||
}
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
toggleLayer(id, isVisible) {
|
||
if(isVisible) this.visibleIds.add(id);
|
||
else this.visibleIds.delete(id);
|
||
this.renderGrid();
|
||
}
|
||
|
||
async loadNet(net) {
|
||
// Wait for initialization to complete if triggered by switchView()
|
||
if (this.initPromise) {
|
||
await this.initPromise;
|
||
}
|
||
|
||
this.activeNet = JSON.parse(JSON.stringify(net));
|
||
this.updateNetUI();
|
||
|
||
if(Object.keys(this.viewers).length === 0) {
|
||
await this.renderGrid();
|
||
} else {
|
||
Object.values(this.viewers).forEach(v => v.draw());
|
||
}
|
||
}
|
||
|
||
async updateNetNodeCache() {
|
||
this.netNodeCache = {};
|
||
if (!this.activeNet || !this.activeNet.nodes) return;
|
||
|
||
// Initialize arrays for currently visible layers
|
||
for (const vid of this.visibleIds) this.netNodeCache[vid] = [];
|
||
|
||
for (const node of this.activeNet.nodes) {
|
||
// 1. Direct Nodes (Source)
|
||
// Safety Check: Ensure the cache array exists before pushing
|
||
if (this.netNodeCache[node.imgId]) {
|
||
this.netNodeCache[node.imgId].push({
|
||
x: node.x, y: node.y, label: node.label,
|
||
color: '#2563eb', isSource: true, origNode: node
|
||
});
|
||
}
|
||
|
||
// 2. Inferred Nodes (Projected)
|
||
const paths = await ImageGraph.solvePaths(node.imgId, this.cv, this.db);
|
||
for (const p of paths) {
|
||
// CHANGE: Check this.netNodeCache[p.id] instead of this.visibleIds.has(p.id)
|
||
// This prevents the crash if netNodeCache was reset by a concurrent call
|
||
if (this.netNodeCache[p.id]) {
|
||
const proj = this.cv.projectPoint(node.x, node.y, p.H);
|
||
if (proj) {
|
||
this.netNodeCache[p.id].push({
|
||
x: proj.x, y: proj.y, label: node.label,
|
||
color: '#4ade80', isSource: false, origNode: node
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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) {
|
||
if (mx !== null && my !== null) {
|
||
this.cursorState = { masterId, mx, my };
|
||
} else if (!forceRefresh && !this.cursorState) {
|
||
return;
|
||
}
|
||
|
||
const path = await ImageGraph.solvePaths(masterId, this.cv, this.db);
|
||
const connectedIds = new Set(path.map(p => p.id));
|
||
connectedIds.add(masterId);
|
||
|
||
for(const [id, viewer] of Object.entries(this.viewers)) {
|
||
if(id === masterId) {
|
||
viewer.setDimmed(false);
|
||
viewer.draw();
|
||
continue;
|
||
}
|
||
|
||
if(!connectedIds.has(id)) {
|
||
viewer.cursorPos = null;
|
||
viewer.setDimmed(true);
|
||
viewer.draw();
|
||
continue;
|
||
}
|
||
|
||
const targetPath = path.find(p => p.id === id);
|
||
|
||
if (mx !== null && my !== null && targetPath) {
|
||
const pt = this.cv.projectPoint(mx, my, targetPath.H);
|
||
if(pt) {
|
||
viewer.cursorPos = pt;
|
||
const w = viewer.bmp ? viewer.bmp.width : 1000;
|
||
const h = viewer.bmp ? viewer.bmp.height : 1000;
|
||
|
||
const inside = (pt.x >= 0 && pt.y >= 0 && pt.x <= w && pt.y <= h);
|
||
viewer.setDimmed(!inside);
|
||
|
||
if (inside && viewer.bmp) {
|
||
const k = viewer.t.k;
|
||
const tx = viewer.t.x;
|
||
const ty = viewer.t.y;
|
||
const imgX = viewer.isMirrored ? (w - pt.x) : pt.x;
|
||
const screenX = imgX * k + tx;
|
||
const screenY = pt.y * k + ty;
|
||
const cvsW = viewer.canvas.width;
|
||
const cvsH = viewer.canvas.height;
|
||
const padX = cvsW * 0.25;
|
||
const padY = cvsH * 0.25;
|
||
|
||
let dx = 0, dy = 0;
|
||
if (screenX > cvsW - padX) dx = (cvsW - padX) - screenX;
|
||
else if (screenX < padX) dx = padX - screenX;
|
||
if (screenY > cvsH - padY) dy = (cvsH - padY) - screenY;
|
||
else if (screenY < padY) dy = padY - screenY;
|
||
|
||
if (dx !== 0 || dy !== 0) {
|
||
viewer.t.x += dx;
|
||
viewer.t.y += dy;
|
||
}
|
||
}
|
||
} else {
|
||
viewer.cursorPos = null;
|
||
viewer.setDimmed(true);
|
||
}
|
||
} else {
|
||
viewer.setDimmed(false);
|
||
}
|
||
viewer.draw();
|
||
}
|
||
}
|
||
|
||
drawOverlay(id, ctx, k) {
|
||
const viewer = this.viewers[id];
|
||
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;
|
||
if (viewer.isMirrored && viewer.bmp) drawX = viewer.bmp.width - n.x;
|
||
|
||
ctx.save();
|
||
ctx.translate(drawX, n.y);
|
||
ctx.scale(ik, ik);
|
||
ctx.rotate(-Math.PI / 4);
|
||
|
||
const s = 20, r = 10;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, 0);
|
||
ctx.lineTo(0, -s + r); ctx.arcTo(0, -s, s, -s, r);
|
||
ctx.lineTo(s - r, -s); ctx.arcTo(s, -s, s, 0, r);
|
||
ctx.lineTo(s, -r); ctx.arcTo(s, 0, 0, 0, r);
|
||
ctx.lineTo(0, 0);
|
||
ctx.closePath();
|
||
|
||
ctx.fillStyle = n.color;
|
||
ctx.fill();
|
||
ctx.lineWidth = 1.5;
|
||
ctx.strokeStyle = 'white';
|
||
ctx.stroke();
|
||
|
||
ctx.translate(s/2, -s/2);
|
||
ctx.rotate(Math.PI / 4);
|
||
ctx.fillStyle = 'white';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.font = 'bold 9px sans-serif';
|
||
ctx.fillText(n.label, 0, 0);
|
||
ctx.restore();
|
||
});
|
||
}
|
||
|
||
let cx, cy, color = '#ff0000';
|
||
if(this.cursorState && this.cursorState.masterId === id) {
|
||
cx = this.cursorState.mx; cy = this.cursorState.my;
|
||
if(viewer.isMirrored && viewer.bmp) cx = viewer.bmp.width - cx;
|
||
} else if (viewer.cursorPos) {
|
||
cx = viewer.cursorPos.x; cy = viewer.cursorPos.y;
|
||
if(viewer.isMirrored && viewer.bmp) cx = viewer.bmp.width - cx;
|
||
color = '#facc15';
|
||
}
|
||
|
||
if(cx !== undefined) {
|
||
const len = 100000;
|
||
ctx.lineWidth = 1 * ik;
|
||
ctx.strokeStyle = color;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx - len, cy); ctx.lineTo(cx + len, cy);
|
||
ctx.moveTo(cx, cy - len); ctx.lineTo(cx, cy + len);
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// --- SMART NAMING LOGIC ---
|
||
|
||
async calculateGlobalRotation() {
|
||
// Default to 0 if data missing
|
||
if (typeof currentBomId === 'undefined' || typeof bomData === 'undefined') return 0;
|
||
|
||
const allNets = await this.db.getNets();
|
||
const projectNets = allNets.filter(n => n.projectId === currentBomId);
|
||
|
||
let totalAngle = 0;
|
||
let count = 0;
|
||
|
||
// We only trust 2-pin passive components for orientation
|
||
// (Resistors, Caps, Inductors, Diodes).
|
||
// ICs are complex, Transistors have triangles.
|
||
const SAFE_PREFIXES = ['R', 'C', 'L', 'D', 'VD'];
|
||
|
||
projectNets.forEach(net => {
|
||
net.nodes.forEach(node => {
|
||
// Find the component this node belongs to
|
||
// Node label format "R1.1" -> Ref "R1"
|
||
const parts = node.label.split('.');
|
||
if (parts.length !== 2) return;
|
||
|
||
const ref = parts[0];
|
||
|
||
// Check prefix
|
||
const prefix = ref.match(/^[A-Z]+/);
|
||
if (!prefix || !SAFE_PREFIXES.includes(prefix[0])) return;
|
||
|
||
const comp = bomData.find(c => c.label === ref);
|
||
|
||
// Critical: We must use coordinates from the SAME image to calculate angle
|
||
if (comp && comp.imgId === node.imgId && comp.x !== undefined) {
|
||
const dx = node.x - comp.x;
|
||
const dy = node.y - comp.y;
|
||
|
||
// Calculate raw angle in degrees
|
||
let deg = Math.atan2(dy, dx) * (180 / Math.PI);
|
||
|
||
// Normalize to deviation from nearest 90-degree axis (-45 to +45)
|
||
// Examples:
|
||
// 5 deg -> 5
|
||
// 85 deg -> -5 (relative to 90)
|
||
// 175 deg -> -5 (relative to 180)
|
||
while (deg <= -45) deg += 90;
|
||
while (deg > 45) deg -= 90;
|
||
|
||
// Filter outliers (e.g. diagonal placement)
|
||
// User requested up to 15 degrees, we allow 20 for safety
|
||
if (Math.abs(deg) < 20) {
|
||
totalAngle += deg;
|
||
count++;
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
if (count === 0) return 0;
|
||
|
||
// Return average rotation in Radians
|
||
const avgDeg = totalAngle / count;
|
||
return avgDeg * (Math.PI / 180);
|
||
}
|
||
|
||
async detectBackImages() {
|
||
if (this.backImagesCache) return this.backImagesCache;
|
||
if (typeof currentBomId === 'undefined' || typeof bomImages === 'undefined') return new Set();
|
||
|
||
const overlaps = await this.db._tx('overlappedImages', 'readonly', s => s.getAll());
|
||
|
||
// 1. Build Adjacency Graph (Partitioning)
|
||
const polarity = {}; // 1 vs -1
|
||
const adj = {};
|
||
|
||
overlaps.forEach(ov => {
|
||
if(!adj[ov.fromImageId]) adj[ov.fromImageId] = [];
|
||
if(!adj[ov.toImageId]) adj[ov.toImageId] = [];
|
||
|
||
// Determinant < 0 implies reflection (Flip)
|
||
const h = ov.homography;
|
||
const det = (h[0] * h[4]) - (h[1] * h[3]);
|
||
const isFlip = det < 0;
|
||
|
||
adj[ov.fromImageId].push({ target: ov.toImageId, isFlip });
|
||
adj[ov.toImageId].push({ target: ov.fromImageId, isFlip });
|
||
});
|
||
|
||
// BFS to propagate polarity
|
||
const visited = new Set();
|
||
const queue = [];
|
||
|
||
const startImg = bomImages[0];
|
||
if (!startImg) return new Set();
|
||
|
||
polarity[startImg.id] = 1;
|
||
queue.push(startImg.id);
|
||
visited.add(startImg.id);
|
||
|
||
while(queue.length > 0) {
|
||
const curr = queue.shift();
|
||
const curPol = polarity[curr];
|
||
|
||
if (adj[curr]) {
|
||
adj[curr].forEach(edge => {
|
||
if (!visited.has(edge.target)) {
|
||
visited.add(edge.target);
|
||
polarity[edge.target] = edge.isFlip ? -curPol : curPol;
|
||
queue.push(edge.target);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
const groupA = new Set(Object.keys(polarity).filter(k => polarity[k] === 1));
|
||
const groupB = new Set(Object.keys(polarity).filter(k => polarity[k] === -1));
|
||
|
||
// 2. Heuristic 2: Check existing Resistor Nets (Reliable)
|
||
// Now checks both Pin 1 and Pin 2
|
||
const rot = await this.calculateGlobalRotation();
|
||
const cosR = Math.cos(-rot);
|
||
const sinR = Math.sin(-rot);
|
||
|
||
let scoreA = 0; // Positive = A is Top, Negative = A is Back
|
||
|
||
const allNets = await this.db.getNets();
|
||
const projectNets = allNets.filter(n => n.projectId === currentBomId);
|
||
|
||
for (const net of projectNets) {
|
||
for (const node of net.nodes) {
|
||
// Match R*.1 OR R*.2
|
||
const match = node.label.match(/^(R\d+)\.([12])$/);
|
||
if (!match) continue;
|
||
|
||
const ref = match[1];
|
||
const pinSuffix = match[2]; // '1' or '2'
|
||
|
||
const comp = bomData.find(c => c.label === ref);
|
||
|
||
// Ensure node and component are on the same image
|
||
if (comp && comp.imgId === node.imgId && comp.x !== undefined) {
|
||
const dx = node.x - comp.x;
|
||
const dy = node.y - comp.y;
|
||
|
||
// Rotate to align with horizontal axis
|
||
const rDx = dx * cosR - dy * sinR;
|
||
|
||
// Rule for TOP side:
|
||
// Pin 1 is Left (<0).
|
||
// Pin 2 is Right (>0).
|
||
const isTopBehavior = (pinSuffix === '1') ? (rDx < 0) : (rDx > 0);
|
||
|
||
if (groupA.has(node.imgId)) scoreA += (isTopBehavior ? 1 : -1);
|
||
else if (groupB.has(node.imgId)) scoreA += (isTopBehavior ? -1 : 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (scoreA !== 0) {
|
||
this.backImagesCache = scoreA > 0 ? groupB : groupA;
|
||
return this.backImagesCache;
|
||
}
|
||
|
||
// 3. Heuristic 1: Count (Fallback)
|
||
if (groupB.size === 0) this.backImagesCache = new Set();
|
||
else if (groupA.size === 0) this.backImagesCache = groupA;
|
||
else this.backImagesCache = (groupA.size <= groupB.size) ? groupA : groupB;
|
||
|
||
return this.backImagesCache;
|
||
}
|
||
|
||
async getProjectedComponents(targetImgId) {
|
||
if (this.bomCache[targetImgId]) return this.bomCache[targetImgId];
|
||
|
||
const projected = [];
|
||
|
||
// 1. Calculate paths FROM the target TO everything else
|
||
// We want to answer: "Where is Image X relative to ME (Target)?"
|
||
// This runs Dijkstra once (One-to-Many) instead of Many-to-One
|
||
let pathMap = {};
|
||
|
||
if (typeof ImageGraph !== 'undefined') {
|
||
// solvePaths returns H for: Target -> Remote
|
||
const paths = await ImageGraph.solvePaths(targetImgId, this.cv, this.db);
|
||
|
||
paths.forEach(p => {
|
||
// To render a Remote component on Target, we need: Remote -> Target
|
||
// So we invert the matrix: inv(Target -> Remote)
|
||
const invH = ImageGraph.invertH(p.H);
|
||
if (invH) pathMap[p.id] = invH;
|
||
});
|
||
}
|
||
|
||
// 2. Iterate all components and project them
|
||
if (typeof bomData !== 'undefined') {
|
||
bomData.forEach(c => {
|
||
if (!c.imgId) return;
|
||
|
||
// Case A: Component is on the current image (Direct)
|
||
if (c.imgId === targetImgId) {
|
||
if (c.x !== undefined && c.y !== undefined) {
|
||
projected.push({ ...c, projX: c.x, projY: c.y });
|
||
}
|
||
}
|
||
// Case B: Component is on a connected image (Inferred)
|
||
else if (pathMap[c.imgId]) {
|
||
if (c.x !== undefined && c.y !== undefined) {
|
||
const H = pathMap[c.imgId];
|
||
const pt = this.cv.projectPoint(c.x, c.y, H);
|
||
|
||
// Basic sanity bounds to prevent projecting into infinity
|
||
// (can happen with near-singular matrices or extreme perspective)
|
||
if (pt && Math.abs(pt.x) < 50000 && Math.abs(pt.y) < 50000) {
|
||
projected.push({ ...c, projX: pt.x, projY: pt.y });
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
this.bomCache[targetImgId] = projected;
|
||
return projected;
|
||
}
|
||
|
||
async checkGlobalLabelUsage(label) {
|
||
// dependency: currentBomId is global from studio.js
|
||
if (!label || typeof currentBomId === 'undefined') return false;
|
||
|
||
// 1. Fetch ALL nets (Async)
|
||
// Optimization: In a huge app we'd index this, but filtering memory is fast enough for <10k nets
|
||
const allNets = await this.db.getNets();
|
||
|
||
// 2. Filter for current board
|
||
const projectNets = allNets.filter(n => n.projectId === currentBomId);
|
||
|
||
// 3. Scan for label collision
|
||
for (const net of projectNets) {
|
||
if (net.nodes && net.nodes.some(n => n.label === label)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
async getSuggestedLabel(imgId, x, y) {
|
||
const HIT_RADIUS = 150;
|
||
|
||
// 1. Get Data
|
||
const components = await this.getProjectedComponents(imgId);
|
||
const rotation = await this.calculateGlobalRotation();
|
||
const backImages = await this.detectBackImages();
|
||
const isBack = backImages.has(imgId);
|
||
|
||
const cosR = Math.cos(-rotation);
|
||
const sinR = Math.sin(-rotation);
|
||
|
||
let bestComp = null;
|
||
let minScore = Infinity;
|
||
|
||
// 2. Weighted Scoring Loop
|
||
components.forEach(c => {
|
||
const dx = x - c.projX;
|
||
const dy = y - c.projY;
|
||
const dist = Math.hypot(dx, dy);
|
||
|
||
if (dist < HIT_RADIUS) {
|
||
// Rotate vector
|
||
const rDx = dx * cosR - dy * sinR;
|
||
const rDy = dx * sinR + dy * cosR;
|
||
|
||
let angleDeg = Math.abs(Math.atan2(rDy, rDx) * (180 / Math.PI));
|
||
angleDeg = angleDeg % 90;
|
||
let deviation = Math.min(angleDeg, 90 - angleDeg);
|
||
|
||
const score = dist * (1 + (deviation * 0.1));
|
||
|
||
if (score < minScore) {
|
||
minScore = score;
|
||
bestComp = c;
|
||
}
|
||
}
|
||
});
|
||
|
||
if (!bestComp) return null;
|
||
|
||
// 3. Pin Logic
|
||
const bDx = x - bestComp.projX;
|
||
const bDy = y - bestComp.projY;
|
||
|
||
const rotDx = bDx * cosR - bDy * sinR;
|
||
const rotDy = bDx * sinR + bDy * cosR;
|
||
|
||
// Logic Branch:
|
||
// Top Side: Left/Top is 1 => (rotDx + rotDy) < 0
|
||
// Back Side: Right/Top is 1 => (rotDx - rotDy) > 0
|
||
let isPin1;
|
||
if (isBack) {
|
||
// Back: Favor Right (x>0) and Top (y<0).
|
||
// x - y => pos - neg = pos.
|
||
isPin1 = (rotDx - rotDy) > 0;
|
||
} else {
|
||
// Top: Favor Left (x<0) and Top (y<0).
|
||
// x + y => neg + neg = neg.
|
||
isPin1 = (rotDx + rotDy) < 0;
|
||
}
|
||
|
||
const primaryPin = isPin1 ? '1' : '2';
|
||
const secondaryPin = isPin1 ? '2' : '1';
|
||
|
||
const labelPrimary = `${bestComp.label}.${primaryPin}`;
|
||
const labelSecondary = `${bestComp.label}.${secondaryPin}`;
|
||
|
||
const primaryTaken = await this.checkGlobalLabelUsage(labelPrimary);
|
||
return primaryTaken ? labelSecondary : labelPrimary;
|
||
}
|
||
|
||
startNewNet() {
|
||
this.activeNet = { id: uuid(), name: "New Net", nodes: [], isNew: true };
|
||
this.updateNetUI();
|
||
}
|
||
|
||
async handleAddNode(imgId, x, y) {
|
||
const nextIdx = this.activeNet ? this.activeNet.nodes.length + 1 : 1;
|
||
let defaultLabel = `P${nextIdx}`;
|
||
|
||
// Async Smart Suggestion
|
||
const smartLabel = await this.getSuggestedLabel(imgId, x, y);
|
||
if (smartLabel) defaultLabel = smartLabel;
|
||
|
||
const label = await requestInput("Add Node", "Pad/Pin Name", defaultLabel, {
|
||
helpHtml: (typeof PIN_HELP_HTML !== 'undefined') ? PIN_HELP_HTML : null,
|
||
validate: validateNetName,
|
||
validateArgs: this.activeNet ? [this.activeNet.id] : null
|
||
});
|
||
|
||
if(label) {
|
||
if(!this.activeNet) this.startNewNet();
|
||
this.activeNet.nodes.push({ id: uuid(), imgId: imgId, x: Math.round(x), y: Math.round(y), label: label });
|
||
this.updateNetUI();
|
||
Object.values(this.viewers).forEach(v => v.draw());
|
||
}
|
||
}
|
||
|
||
async saveNet() {
|
||
if(!this.activeNet) return;
|
||
if (this.activeNet.isNew) {
|
||
const name = await requestInput("Save Net", "Net Name", this.activeNet.name);
|
||
if(name) { this.activeNet.name = name; delete this.activeNet.isNew; }
|
||
else return;
|
||
}
|
||
this.activeNet.projectId = currentBomId;
|
||
await this.db.addNet(this.activeNet);
|
||
this.activeNet = null;
|
||
this.updateNetUI();
|
||
if(window.netManager) window.netManager.render();
|
||
}
|
||
|
||
cancelNet() {
|
||
this.activeNet = null;
|
||
this.updateNetUI();
|
||
history.back();
|
||
}
|
||
|
||
updateNetUI() {
|
||
if(!this.activeNet) {
|
||
this.activeNetEl.style.display = 'none';
|
||
} else {
|
||
this.activeNetEl.style.cssText = "pointer-events:auto; background:rgba(15, 23, 42, 0.9); padding:4px 10px; border-radius:20px; border:1px solid #334155; display:flex; color:white; align-items:center; gap:8px; box-shadow:0 4px 6px rgba(0,0,0,0.2); backdrop-filter:blur(4px); font-size:0.85rem; height:auto;";
|
||
this.activeNetEl.innerHTML = `
|
||
<span style="font-weight:600; color:#4ade80; max-width:100px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${this.activeNet.name}</span>
|
||
<span style="color:#94a3b8; border-left:1px solid #475569; padding-left:8px; font-size:0.8rem;">${this.activeNet.nodes.length}</span>
|
||
<button class="primary sm-btn" style="padding:1px 8px; font-size:0.75rem; height:24px; min-height:0; line-height:1;" onclick="inspector.saveNet()">Save</button>
|
||
<button class="danger sm-btn" style="padding:0; width:20px; height:20px; min-height:0; border-radius:50%; line-height:1; display:flex; align-items:center; justify-content:center;" onclick="inspector.cancelNet()">×</button>
|
||
`;
|
||
}
|
||
this.updateNetNodeCache();
|
||
this.updateTraceRenderCache();
|
||
}
|
||
}
|