2349 lines
80 KiB
JavaScript
2349 lines
80 KiB
JavaScript
|
|
/* studio.js - Main Application Logic */
|
|||
|
|
|
|||
|
|
const DB_NAME = 'PcbReTrace'; const DB_VER = 1;
|
|||
|
|
|
|||
|
|
const ICONS = { RESISTOR: `<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="6" width="16" height="12" rx="3" fill="#bae6fd" stroke="#0ea5e9" stroke-width="1"/><rect x="7" y="6" width="2" height="12" fill="#ef4444"/><rect x="11" y="6" width="2" height="12" fill="#000000"/><rect x="15" y="6" width="2" height="12" fill="#ef4444"/><line x1="1" y1="12" x2="4" y2="12" stroke="#94a3b8" stroke-width="2"/><line x1="20" y1="12" x2="23" y2="12" stroke="#94a3b8" stroke-width="2"/></svg>`, INDUCTOR: `<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="5" width="16" height="14" rx="4" fill="#bbf7d0" stroke="#22c55e" stroke-width="1"/><rect x="7" y="5" width="2" height="14" fill="#cbd5e1"/><rect x="11" y="5" width="2" height="14" fill="#ef4444"/><rect x="15" y="5" width="2" height="14" fill="#ef4444"/><line x1="1" y1="12" x2="4" y2="12" stroke="#94a3b8" stroke-width="2"/><line x1="20" y1="12" x2="23" y2="12" stroke="#94a3b8" stroke-width="2"/></svg>`, COIL: `<svg viewBox="0 0 24 24" fill="none" stroke="#ea580c" stroke-width="2" stroke-linecap="round"><line x1="1" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="23" y2="12"/><path d="M5 12 C5 4 9 4 9 12"/><path d="M9 12 C9 19 10 19 10 12" stroke-opacity="0.5"/><path d="M10 12 C10 4 14 4 14 12"/><path d="M14 12 C14 19 15 19 15 12" stroke-opacity="0.5"/><path d="M15 12 C15 4 19 4 19 12"/></svg>` };
|
|||
|
|
const TOOL_REGISTRY = { 'R': [{url:'resistor.html',icon:ICONS.RESISTOR,title:'Resistor'}], 'L': [{url:'inductor.html',icon:ICONS.INDUCTOR,title:'Inductor'},{url:'coil.html',icon:ICONS.COIL,title:'Coil'}] };
|
|||
|
|
const MAX_DIRECT_TOOLS = 3;
|
|||
|
|
|
|||
|
|
// Global State
|
|||
|
|
let db=null, deviceList=[], currentDeviceId=null, bomList=[], currentBomId=null, bomData=[], bomImages=[], currentImgId=null, sortMode='none', editingIndex=-1, mapState={scale:1,x:0,y:0,isDragging:false,startX:0,startY:0};
|
|||
|
|
let skipNextFit = false;
|
|||
|
|
let returnToMap = false;
|
|||
|
|
let spyglass = null;
|
|||
|
|
let isMainViewActive = true; // Tracks if Spyglass is showing the actual source image
|
|||
|
|
|
|||
|
|
// Phase 6 Extensions
|
|||
|
|
let cvManager = null;
|
|||
|
|
let stitchEditor = null;
|
|||
|
|
let currentOverlaps = [];
|
|||
|
|
let netManager = null;
|
|||
|
|
let inspector = null;
|
|||
|
|
|
|||
|
|
// Utility: Global UUID generator (used by StitchEditor too)
|
|||
|
|
window.uuid = function(){ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c=>{var r=Math.random()*16|0,v=c=='x'?r:(r&0x3|0x8);return v.toString(16);}); }
|
|||
|
|
|
|||
|
|
class PcbDatabase {
|
|||
|
|
constructor() { this.name=DB_NAME; this.ver=DB_VER; }
|
|||
|
|
async init() { return new Promise((r,j)=>{
|
|||
|
|
const q=indexedDB.open(this.name,this.ver);
|
|||
|
|
q.onupgradeneeded=e=>{
|
|||
|
|
const d=e.target.result;
|
|||
|
|
if(d.objectStoreNames.contains('projects')) d.deleteObjectStore('projects');
|
|||
|
|
if(!d.objectStoreNames.contains('devices')) d.createObjectStore('devices',{keyPath:'id'});
|
|||
|
|
if(!d.objectStoreNames.contains('boards')) { const bs=d.createObjectStore('boards',{keyPath:'id'}); bs.createIndex('deviceId','deviceId',{unique:false}); }
|
|||
|
|
if(!d.objectStoreNames.contains('components')) { const cs=d.createObjectStore('components',{keyPath:'id'}); cs.createIndex('boardId','boardId',{unique:false}); }
|
|||
|
|
if(!d.objectStoreNames.contains('images')) { const is=d.createObjectStore('images',{keyPath:'id'}); is.createIndex('boardId','boardId',{unique:false}); }
|
|||
|
|
|
|||
|
|
// v6: POI Support
|
|||
|
|
if(!d.objectStoreNames.contains('overlappedImages')) {
|
|||
|
|
const os=d.createObjectStore('overlappedImages',{keyPath:'id'});
|
|||
|
|
os.createIndex('fromImageId','fromImageId');
|
|||
|
|
}
|
|||
|
|
// v7: Nets Store
|
|||
|
|
if(!d.objectStoreNames.contains('nets')) {
|
|||
|
|
const ns=d.createObjectStore('nets',{keyPath:'id'});
|
|||
|
|
// No index needed yet as we usually load all nets for a board via manual filter or ID list
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
};
|
|||
|
|
q.onsuccess=e=>{this.db=e.target.result;r()};
|
|||
|
|
q.onerror=e=>{ console.error("DB Open Error:", e); j(e); };
|
|||
|
|
}); }
|
|||
|
|
|
|||
|
|
// Helpers
|
|||
|
|
async _tx(s,m,cb){ return new Promise((r,j)=>{const t=this.db.transaction(s,m); const q=cb(t.objectStore(s)); q.onsuccess=()=>r(q.result); q.onerror=()=>j(q.error);}); }
|
|||
|
|
async _ix(s,i,v){ return new Promise((r,j)=>{const q=this.db.transaction(s,'readonly').objectStore(s).index(i).getAll(v); q.onsuccess=()=>r(q.result); q.onerror=()=>j(q.error);}); }
|
|||
|
|
|
|||
|
|
// Core CRUD
|
|||
|
|
async getDevices() { return this._tx('devices','readonly',s=>s.getAll()); }
|
|||
|
|
async addDevice(d) { return this._tx('devices','readwrite',s=>s.put(d)); }
|
|||
|
|
async getProjectsByDevice(devId) { return this._ix('boards', 'deviceId', devId); }
|
|||
|
|
async getProject(id) { return this._tx('boards','readonly',s=>s.get(id)); }
|
|||
|
|
async addProject(p) { return this._tx('boards','readwrite',s=>s.put(p)); }
|
|||
|
|
async deleteProject(id) { const cs=await this.getComponents(id); const is=await this.getImages(id); const tx=this.db.transaction(['boards','components','images'],'readwrite'); cs.forEach(c=>tx.objectStore('components').delete(c.id)); is.forEach(i=>tx.objectStore('images').delete(i.id)); tx.objectStore('boards').delete(id); return new Promise(r=>tx.oncomplete=r); }
|
|||
|
|
async getComponents(pid) { return this._ix('components','boardId',pid); }
|
|||
|
|
async addComponent(c) { return this._tx('components','readwrite',s=>s.put(c)); }
|
|||
|
|
async deleteComponent(id) { return this._tx('components','readwrite',s=>s.delete(id)); }
|
|||
|
|
async clearComponents(pid) { const cs=await this.getComponents(pid); const tx=this.db.transaction('components','readwrite'); cs.forEach(c=>tx.objectStore('components').delete(c.id)); return new Promise(r=>tx.oncomplete=r); }
|
|||
|
|
async clearProjectData(pid) { const cs=await this.getComponents(pid); const is=await this.getImages(pid); const tx=this.db.transaction(['components','images'],'readwrite'); cs.forEach(c=>tx.objectStore('components').delete(c.id)); is.forEach(i=>tx.objectStore('images').delete(i.id)); return new Promise(r=>tx.oncomplete=r); }
|
|||
|
|
async getImages(pid) { return this._ix('images','boardId',pid); }
|
|||
|
|
async addImage(i) { return this._tx('images','readwrite',s=>s.put(i)); }
|
|||
|
|
async deleteImage(id) { return this._tx('images','readwrite',s=>s.delete(id)); }
|
|||
|
|
async getImage(id) { return this._tx('images', 'readonly', s => s.get(id)); }
|
|||
|
|
async getNets() { return this._tx('nets','readonly',s=>s.getAll()); }
|
|||
|
|
async addNet(n) { return this._tx('nets','readwrite',s=>s.put(n)); }
|
|||
|
|
async deleteNet(id) { return this._tx('nets','readwrite',s=>s.delete(id)); }
|
|||
|
|
|
|||
|
|
// POI Extensions
|
|||
|
|
async addOverlap(ov) { return this._tx('overlappedImages','readwrite',s=>s.put(ov)); }
|
|||
|
|
async getOverlapsForPair(id1, id2) {
|
|||
|
|
const all = await this._tx('overlappedImages','readonly',s=>s.getAll());
|
|||
|
|
return all.find(x => (x.fromImageId===id1 && x.toImageId===id2) || (x.fromImageId===id2 && x.toImageId===id1));
|
|||
|
|
}
|
|||
|
|
async getOverlapsForImage(id) {
|
|||
|
|
const all = await this._tx('overlappedImages','readonly',s=>s.getAll());
|
|||
|
|
return all.filter(x => x.fromImageId===id || x.toImageId===id);
|
|||
|
|
}
|
|||
|
|
async deleteOverlapsForPair(id1, id2) {
|
|||
|
|
const tx = this.db.transaction('overlappedImages', 'readwrite');
|
|||
|
|
const store = tx.objectStore('overlappedImages');
|
|||
|
|
const req = store.getAll();
|
|||
|
|
return new Promise(resolve => {
|
|||
|
|
req.onsuccess = e => {
|
|||
|
|
const all = e.target.result;
|
|||
|
|
const toDel = all.filter(x => (x.fromImageId===id1 && x.toImageId===id2) || (x.fromImageId===id2 && x.toImageId===id1));
|
|||
|
|
let c=0;
|
|||
|
|
if(toDel.length===0) resolve();
|
|||
|
|
toDel.forEach(item => { store.delete(item.id).onsuccess=()=>{ c++; if(c===toDel.length) resolve(); }});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MAIN INIT
|
|||
|
|
async function init() {
|
|||
|
|
db = new PcbDatabase();
|
|||
|
|
await db.init();
|
|||
|
|
|
|||
|
|
cvManager = new CVManager();
|
|||
|
|
cvManager.init(); // Lazy load (don't await)
|
|||
|
|
|
|||
|
|
// Init net managers
|
|||
|
|
netManager = new NetManager(db);
|
|||
|
|
inspector = new Inspector(db, cvManager);
|
|||
|
|
|
|||
|
|
// [CRITICAL] Expose to window so inline HTML onclicks (generated by Inspector) work
|
|||
|
|
window.netManager = netManager;
|
|||
|
|
window.inspector = inspector;
|
|||
|
|
|
|||
|
|
stitchEditor = new StitchEditor(db, cvManager);
|
|||
|
|
|
|||
|
|
deviceList = await db.getDevices();
|
|||
|
|
if(deviceList.length === 0) {
|
|||
|
|
const defDev = { id: uuid(), name: 'Default Device' };
|
|||
|
|
await db.addDevice(defDev);
|
|||
|
|
deviceList = [defDev];
|
|||
|
|
const bid = uuid();
|
|||
|
|
await db.addProject({ id: bid, deviceId: defDev.id, name: 'Main Board', sortMode:'none' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
currentDeviceId = localStorage.getItem('pcb_dev_id') || deviceList[0].id;
|
|||
|
|
if (!deviceList.find(d => d.id === currentDeviceId)) currentDeviceId = deviceList[0].id;
|
|||
|
|
|
|||
|
|
updateDeviceDropdown();
|
|||
|
|
await loadDeviceBoms();
|
|||
|
|
|
|||
|
|
document.getElementById('add-form').addEventListener('keydown', function(e) { if(e.key === 'Enter') addPart(); });
|
|||
|
|
if (typeof PcbSpyglass !== 'undefined') {
|
|||
|
|
spyglass = new PcbSpyglass('preview-canvas', 'zoom-level', (newX, newY) => {
|
|||
|
|
// When user drags the spyglass, update the hidden inputs (only for the Main View)
|
|||
|
|
if (isMainViewActive) {
|
|||
|
|
document.getElementById('inp-x').value = newX;
|
|||
|
|
document.getElementById('inp-y').value = newY;
|
|||
|
|
}
|
|||
|
|
// Optional: If we want real-time DB saving while dragging in the list view,
|
|||
|
|
// we could enable it, but usually updating inputs + clicking "Add/Update" is safer.
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
setupDragDrop();
|
|||
|
|
NavManager.init();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Helper: Find all transitive connections for a given image
|
|||
|
|
// Returns Array of { sourceId, matrix (Source->Target) }
|
|||
|
|
async function getConnectedImages(targetImgId) {
|
|||
|
|
if (!targetImgId || !cvManager) return [];
|
|||
|
|
|
|||
|
|
// Use Helper
|
|||
|
|
const paths = await ImageGraph.solvePaths(targetImgId, cvManager, db);
|
|||
|
|
|
|||
|
|
// Map to expected format
|
|||
|
|
return paths.map(p => ({
|
|||
|
|
sourceId: p.id,
|
|||
|
|
matrix: p.H
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateDeviceDropdown() {
|
|||
|
|
const s = document.getElementById('device-select'); s.innerHTML='';
|
|||
|
|
deviceList.forEach(d => {
|
|||
|
|
const o = document.createElement('option'); o.value=d.id; o.innerText=d.name;
|
|||
|
|
if(d.id===currentDeviceId) o.selected=true;
|
|||
|
|
s.appendChild(o);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function switchDevice() {
|
|||
|
|
resetStickyEditor();
|
|||
|
|
currentDeviceId = document.getElementById('device-select').value;
|
|||
|
|
localStorage.setItem('pcb_dev_id', currentDeviceId);
|
|||
|
|
await loadDeviceBoms();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadDeviceBoms() {
|
|||
|
|
bomList = await db.getProjectsByDevice(currentDeviceId);
|
|||
|
|
const s = document.getElementById('bom-select'); s.innerHTML='';
|
|||
|
|
if (bomList.length === 0) {
|
|||
|
|
s.innerHTML = '<option disabled selected>No Boards</option>';
|
|||
|
|
currentBomId = null;
|
|||
|
|
} else {
|
|||
|
|
const groups = {};
|
|||
|
|
bomList.forEach(b => {
|
|||
|
|
const sec = b.section || "General";
|
|||
|
|
if(!groups[sec]) groups[sec] = [];
|
|||
|
|
groups[sec].push(b);
|
|||
|
|
});
|
|||
|
|
for (const [secName, boms] of Object.entries(groups)) {
|
|||
|
|
const grp = document.createElement('optgroup'); grp.label = secName;
|
|||
|
|
boms.forEach(b => {
|
|||
|
|
const o = document.createElement('option'); o.value=b.id; o.innerText=b.name;
|
|||
|
|
grp.appendChild(o);
|
|||
|
|
});
|
|||
|
|
s.appendChild(grp);
|
|||
|
|
}
|
|||
|
|
const lastBom = localStorage.getItem('pcb_bom_id');
|
|||
|
|
currentBomId = (lastBom && bomList.find(b=>b.id===lastBom)) ? lastBom : bomList[0].id;
|
|||
|
|
s.value = currentBomId;
|
|||
|
|
}
|
|||
|
|
await loadProjectData();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function switchBom() {
|
|||
|
|
resetStickyEditor();
|
|||
|
|
currentBomId = document.getElementById('bom-select').value;
|
|||
|
|
localStorage.setItem('pcb_bom_id', currentBomId);
|
|||
|
|
currentImgId = null;
|
|||
|
|
await loadProjectData();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadProjectData() {
|
|||
|
|
if (!currentBomId) { bomData = []; bomImages = []; renderList(); return; }
|
|||
|
|
|
|||
|
|
const meta = bomList.find(p=>p.id===currentBomId);
|
|||
|
|
if (meta) {
|
|||
|
|
sortMode = (meta && meta.sortMode) ? meta.sortMode : 'none';
|
|||
|
|
document.getElementById('sort-select').value = sortMode;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bomData = await db.getComponents(currentBomId);
|
|||
|
|
bomImages = await db.getImages(currentBomId);
|
|||
|
|
|
|||
|
|
for (const img of bomImages) {
|
|||
|
|
if (/\.(jpg|jpeg|png|webp)$/i.test(img.name)) {
|
|||
|
|
img.name = img.name.replace(/\.[^/.]+$/, "");
|
|||
|
|
db.addImage(img);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const imgSel = document.getElementById('image-select');
|
|||
|
|
imgSel.innerHTML = '';
|
|||
|
|
if(bomImages.length > 0) {
|
|||
|
|
bomImages.forEach(img => {
|
|||
|
|
const opt = document.createElement('option');
|
|||
|
|
opt.value = img.id; opt.innerText = img.name;
|
|||
|
|
imgSel.appendChild(opt);
|
|||
|
|
});
|
|||
|
|
if(!currentImgId || !bomImages.find(i=>i.id===currentImgId)) currentImgId = bomImages[0].id;
|
|||
|
|
imgSel.value = currentImgId;
|
|||
|
|
if(document.getElementById('view-map').classList.contains('active')) showImage(currentImgId);
|
|||
|
|
} else {
|
|||
|
|
currentImgId = null;
|
|||
|
|
imgSel.innerHTML = '<option disabled selected>No Images</option>';
|
|||
|
|
clearMap();
|
|||
|
|
}
|
|||
|
|
renderList();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function createNewDevice() {
|
|||
|
|
const n = prompt("New Device Name:");
|
|||
|
|
if (n) {
|
|||
|
|
const id = uuid();
|
|||
|
|
await db.addDevice({ id, name: n });
|
|||
|
|
currentDeviceId = id;
|
|||
|
|
const bid = uuid();
|
|||
|
|
await db.addProject({ id: bid, deviceId: id, name: 'Main Board', sortMode: 'none' });
|
|||
|
|
deviceList = await db.getDevices();
|
|||
|
|
updateDeviceDropdown();
|
|||
|
|
await loadDeviceBoms();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function createNewBom() {
|
|||
|
|
if (!currentDeviceId) return alert("Select a device first.");
|
|||
|
|
const n=prompt("New Board Name:");
|
|||
|
|
if(n) {
|
|||
|
|
const sec = prompt("Section (Optional):") || "";
|
|||
|
|
const id=uuid();
|
|||
|
|
await db.addProject({id, deviceId: currentDeviceId, name:n, section: sec, lastModified:Date.now(), sortMode:'none'});
|
|||
|
|
currentBomId=id;
|
|||
|
|
await loadDeviceBoms();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- SETTINGS ---
|
|||
|
|
function openBoardSettings() {
|
|||
|
|
if (!currentBomId) return;
|
|||
|
|
const p = bomList.find(x => x.id === currentBomId);
|
|||
|
|
if (!p) return;
|
|||
|
|
document.getElementById('edit-board-name').value = p.name;
|
|||
|
|
document.getElementById('edit-board-section').value = p.section || "";
|
|||
|
|
const ds = document.getElementById('edit-board-device'); ds.innerHTML = '';
|
|||
|
|
deviceList.forEach(d => {
|
|||
|
|
const o = document.createElement('option'); o.value = d.id; o.innerText = d.name;
|
|||
|
|
if (d.id === p.deviceId) o.selected = true;
|
|||
|
|
ds.appendChild(o);
|
|||
|
|
});
|
|||
|
|
document.getElementById('board-settings-modal').style.display = 'flex';
|
|||
|
|
}
|
|||
|
|
async function saveBoardSettings() {
|
|||
|
|
const p = bomList.find(x => x.id === currentBomId);
|
|||
|
|
if (!p) return;
|
|||
|
|
const newName = document.getElementById('edit-board-name').value;
|
|||
|
|
const newSec = document.getElementById('edit-board-section').value;
|
|||
|
|
const newDevId = document.getElementById('edit-board-device').value;
|
|||
|
|
if (newName) {
|
|||
|
|
p.name = newName; p.section = newSec; p.deviceId = newDevId;
|
|||
|
|
await db.addProject(p);
|
|||
|
|
document.getElementById('board-settings-modal').style.display = 'none';
|
|||
|
|
if (newDevId !== currentDeviceId) {
|
|||
|
|
currentDeviceId = newDevId;
|
|||
|
|
updateDeviceDropdown();
|
|||
|
|
}
|
|||
|
|
await loadDeviceBoms();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
function openDeviceSettings() {
|
|||
|
|
if (!currentDeviceId) return;
|
|||
|
|
const d = deviceList.find(x => x.id === currentDeviceId);
|
|||
|
|
if (!d) return;
|
|||
|
|
document.getElementById('edit-device-name').value = d.name;
|
|||
|
|
document.getElementById('device-settings-modal').style.display = 'flex';
|
|||
|
|
}
|
|||
|
|
async function saveDeviceSettings() {
|
|||
|
|
const d = deviceList.find(x => x.id === currentDeviceId);
|
|||
|
|
if (!d) return;
|
|||
|
|
const newName = document.getElementById('edit-device-name').value;
|
|||
|
|
if (newName && newName !== d.name) {
|
|||
|
|
d.name = newName;
|
|||
|
|
await db.addDevice(d);
|
|||
|
|
deviceList = await db.getDevices();
|
|||
|
|
updateDeviceDropdown();
|
|||
|
|
}
|
|||
|
|
document.getElementById('device-settings-modal').style.display = 'none';
|
|||
|
|
}
|
|||
|
|
async function deleteCurrentDevice() {
|
|||
|
|
if (!currentDeviceId) return;
|
|||
|
|
|
|||
|
|
// 1. Modal Check
|
|||
|
|
if (!await confirmAction("Are you sure? This will PERMANENTLY DELETE the device and ALL its boards, components, and images.\n\nThis action cannot be undone.", "Delete Device")) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const boards = await db.getProjectsByDevice(currentDeviceId);
|
|||
|
|
for (const board of boards) {
|
|||
|
|
const images = await db.getImages(board.id);
|
|||
|
|
for (const img of images) {
|
|||
|
|
const overlaps = await db.getOverlapsForImage(img.id);
|
|||
|
|
const tx = db.db.transaction('overlappedImages', 'readwrite');
|
|||
|
|
const store = tx.objectStore('overlappedImages');
|
|||
|
|
overlaps.forEach(ov => store.delete(ov.id));
|
|||
|
|
await new Promise(r => tx.oncomplete = r);
|
|||
|
|
}
|
|||
|
|
await db.deleteProject(board.id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await db._tx('devices', 'readwrite', s => s.delete(currentDeviceId));
|
|||
|
|
document.getElementById('device-settings-modal').style.display = 'none';
|
|||
|
|
|
|||
|
|
deviceList = await db.getDevices();
|
|||
|
|
if (deviceList.length === 0) {
|
|||
|
|
const defDev = { id: uuid(), name: 'Default Device' };
|
|||
|
|
await db.addDevice(defDev);
|
|||
|
|
deviceList = [defDev];
|
|||
|
|
const bid = uuid();
|
|||
|
|
await db.addProject({ id: bid, deviceId: defDev.id, name: 'Main Board', sortMode:'none' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
currentDeviceId = deviceList[0].id;
|
|||
|
|
localStorage.setItem('pcb_dev_id', currentDeviceId);
|
|||
|
|
|
|||
|
|
updateDeviceDropdown();
|
|||
|
|
await loadDeviceBoms();
|
|||
|
|
resetStickyEditor();
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error("Delete failed:", e);
|
|||
|
|
alert("Error deleting device: " + e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- COMPONENTS ---
|
|||
|
|
async function addPart() {
|
|||
|
|
const id = document.getElementById('inp-id').value;
|
|||
|
|
const label = document.getElementById('inp-label').value.toUpperCase().trim();
|
|||
|
|
const value = document.getElementById('inp-value').value;
|
|||
|
|
const desc = document.getElementById('inp-desc').value;
|
|||
|
|
const x = document.getElementById('inp-x').value;
|
|||
|
|
const y = document.getElementById('inp-y').value;
|
|||
|
|
const imgId = document.getElementById('inp-img-id').value;
|
|||
|
|
|
|||
|
|
if(!label) return alert("Ref required");
|
|||
|
|
|
|||
|
|
// Check collision
|
|||
|
|
const collision = bomData.find(c => c.label === label && c.id !== id);
|
|||
|
|
if (collision) {
|
|||
|
|
if(!await confirmAction("Reference exists. Overwrite?", "Overwrite")) return;
|
|||
|
|
await db.deleteComponent(collision.id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Save
|
|||
|
|
const c = { id: id || uuid(), boardId: currentBomId, label, value, desc };
|
|||
|
|
if(x && y && imgId) { c.x = parseFloat(x); c.y = parseFloat(y); c.imgId = imgId; }
|
|||
|
|
|
|||
|
|
await db.addComponent(c);
|
|||
|
|
document.getElementById('inp-id').value = c.id;
|
|||
|
|
await loadProjectData();
|
|||
|
|
|
|||
|
|
if(returnToMap) { switchView('map'); returnToMap = false; }
|
|||
|
|
else {
|
|||
|
|
// Optional: blink the button or give feedback
|
|||
|
|
const btn = document.querySelector('.btn-add');
|
|||
|
|
const origText = btn.innerText;
|
|||
|
|
btn.innerText = "Saved!";
|
|||
|
|
setTimeout(() => btn.innerText = origText, 1000);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function deleteCurrentPart() {
|
|||
|
|
const id = document.getElementById('inp-id').value;
|
|||
|
|
|
|||
|
|
// Safety: Don't do anything if no component is selected (e.g. creating new)
|
|||
|
|
if (!id) return;
|
|||
|
|
|
|||
|
|
if (await confirmAction("Delete this component?", "Delete")) {
|
|||
|
|
await db.deleteComponent(id);
|
|||
|
|
await loadProjectData();
|
|||
|
|
resetStickyEditor(); // Clear the form/images after deletion
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- IMPORT / EXPORT ---
|
|||
|
|
// Device Export
|
|||
|
|
async function exportDeviceZIP() {
|
|||
|
|
if(!window.JSZip) return alert("JSZip required.");
|
|||
|
|
if(!currentDeviceId) return;
|
|||
|
|
|
|||
|
|
const dev = deviceList.find(d => d.id === currentDeviceId);
|
|||
|
|
const boms = await db.getProjectsByDevice(currentDeviceId);
|
|||
|
|
const zip = new JSZip();
|
|||
|
|
|
|||
|
|
// Fetch ALL nets once (since we don't have an index, we filter in JS)
|
|||
|
|
const allNets = await db.getNets();
|
|||
|
|
|
|||
|
|
// 1. Generate README
|
|||
|
|
const readmeContent = `PCB ReTrace Data Export
|
|||
|
|
Type: Full Device Backup (${dev.name})
|
|||
|
|
Generated by: pcb.etaras.com
|
|||
|
|
Date: ${new Date().toISOString()}
|
|||
|
|
|
|||
|
|
HOW TO USE:
|
|||
|
|
1. Go to https://pcb.etaras.com/studio.html
|
|||
|
|
2. Click "Import Device" or drag and drop this ZIP file into the tool.
|
|||
|
|
`;
|
|||
|
|
zip.file("README.txt", readmeContent);
|
|||
|
|
|
|||
|
|
// 2. Generate Manifest
|
|||
|
|
const manifest = {
|
|||
|
|
device: dev,
|
|||
|
|
version: DB_VER,
|
|||
|
|
source: "pcb.etaras.com",
|
|||
|
|
boards: []
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const imgFolder = zip.folder("images");
|
|||
|
|
|
|||
|
|
for (const bom of boms) {
|
|||
|
|
const comps = await db.getComponents(bom.id);
|
|||
|
|
const imgs = await db.getImages(bom.id);
|
|||
|
|
|
|||
|
|
// Filter nets for this specific board
|
|||
|
|
const boardNets = allNets.filter(n => n.projectId === bom.id);
|
|||
|
|
|
|||
|
|
const overlapsMap = new Map();
|
|||
|
|
for(const img of imgs) {
|
|||
|
|
const ovs = await db.getOverlapsForImage(img.id);
|
|||
|
|
ovs.forEach(o => overlapsMap.set(o.id, o));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const cleanComps = comps.map(c => { const { boardId, ...rest } = c; return rest; });
|
|||
|
|
const imgMeta = imgs.map(img => ({ id: img.id, name: img.name, type: 'image/jpeg' }));
|
|||
|
|
|
|||
|
|
manifest.boards.push({
|
|||
|
|
meta: bom,
|
|||
|
|
components: cleanComps,
|
|||
|
|
images: imgMeta,
|
|||
|
|
overlaps: Array.from(overlapsMap.values()),
|
|||
|
|
nets: boardNets // Add Nets to manifest
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Image Loop
|
|||
|
|
for (const img of imgs) {
|
|||
|
|
let blobToSave = img.blob;
|
|||
|
|
if (img.blob.type !== 'image/jpeg') {
|
|||
|
|
const bmp = await createImageBitmap(img.blob);
|
|||
|
|
const canvas = document.createElement('canvas');
|
|||
|
|
canvas.width = bmp.width;
|
|||
|
|
canvas.height = bmp.height;
|
|||
|
|
canvas.getContext('2d').drawImage(bmp, 0, 0);
|
|||
|
|
bmp.close();
|
|||
|
|
blobToSave = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.85));
|
|||
|
|
}
|
|||
|
|
imgFolder.file(`${img.id}.jpg`, blobToSave);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
zip.file("device.json", JSON.stringify(manifest, null, 2));
|
|||
|
|
zip.generateAsync({type:"blob"}).then(c => dl(c, `${dev.name}_Backup.zip`, 'application/zip'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isValidBoardData(data) {
|
|||
|
|
// 1. Root Object check
|
|||
|
|
if (!data || typeof data !== 'object') return false;
|
|||
|
|
|
|||
|
|
// 2. Meta Object check
|
|||
|
|
if (!data.meta || typeof data.meta !== 'object') return false;
|
|||
|
|
// ID must be a string and not empty
|
|||
|
|
if (typeof data.meta.id !== 'string' || !data.meta.id.trim()) return false;
|
|||
|
|
// Name must be a string (can be empty, but must exist)
|
|||
|
|
if (typeof data.meta.name !== 'string') return false;
|
|||
|
|
|
|||
|
|
// 3. Components Array check
|
|||
|
|
if (!Array.isArray(data.components)) return false;
|
|||
|
|
|
|||
|
|
// 4. Images Array check (optional but must be array if present)
|
|||
|
|
if (data.images && !Array.isArray(data.images)) return false;
|
|||
|
|
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isValidDeviceManifest(data) {
|
|||
|
|
if (!data || typeof data !== 'object') return false;
|
|||
|
|
|
|||
|
|
// 1. Device Object check
|
|||
|
|
if (!data.device || typeof data.device !== 'object') return false;
|
|||
|
|
if (typeof data.device.id !== 'string' || !data.device.id.trim()) return false;
|
|||
|
|
if (typeof data.device.name !== 'string') return false;
|
|||
|
|
|
|||
|
|
// 2. Boards Array check
|
|||
|
|
if (!Array.isArray(data.boards)) return false;
|
|||
|
|
|
|||
|
|
// 3. Version check (optional warning could be added here, but structural check is pass/fail)
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isValidLegacyData(data) {
|
|||
|
|
// Case A: Top-level Array of objects
|
|||
|
|
if (Array.isArray(data)) return true;
|
|||
|
|
|
|||
|
|
// Case B: Object with 'components' or 'data' array
|
|||
|
|
if (data && typeof data === 'object') {
|
|||
|
|
return Array.isArray(data.components) || Array.isArray(data.data);
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function importFile(f) {
|
|||
|
|
if(!f) return;
|
|||
|
|
const name = f.name.toLowerCase();
|
|||
|
|
|
|||
|
|
// 1. ZIP Import (Device Backup or Board Export)
|
|||
|
|
if(name.endsWith('.zip')) {
|
|||
|
|
await processZIP(f);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. JSON Import (Metadata only)
|
|||
|
|
if(name.endsWith('.json')) {
|
|||
|
|
const r = new FileReader();
|
|||
|
|
r.onload = async e => {
|
|||
|
|
try {
|
|||
|
|
const json = JSON.parse(e.target.result);
|
|||
|
|
|
|||
|
|
if (isValidBoardData(json)) {
|
|||
|
|
if (!currentDeviceId) return alert("Select a Device first.");
|
|||
|
|
if(confirm(`Import Board "${json.meta.name}" from JSON?`)) {
|
|||
|
|
json.meta.deviceId = currentDeviceId;
|
|||
|
|
await processImportData(json, null);
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (isValidDeviceManifest(json)) {
|
|||
|
|
alert("Device Manifests should be imported via ZIP to include images.\nImporting metadata only.");
|
|||
|
|
if(confirm("Proceed with metadata-only import?")) {
|
|||
|
|
await restoreDevice(json, null);
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (isValidLegacyData(json)) {
|
|||
|
|
if(confirm("Detected Legacy BOM format. Import into current board?")) {
|
|||
|
|
await processLegacyImport(json);
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
throw new Error("JSON structure does not match known schemas.");
|
|||
|
|
} catch(e) {
|
|||
|
|
console.error(e);
|
|||
|
|
alert("Invalid JSON: " + e.message);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
r.readAsText(f);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. Image Import (Drag & Drop -> Open Editor)
|
|||
|
|
if(/\.(jpg|jpeg|png|webp)$/i.test(name)) {
|
|||
|
|
// SAFETY: Ensure a board is currently active
|
|||
|
|
if(!currentBomId) {
|
|||
|
|
alert("Cannot import image: No board selected.\nPlease select or create a board first.");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Read file as DataURL to pass to the Editor
|
|||
|
|
const r = new FileReader();
|
|||
|
|
r.onload = e => {
|
|||
|
|
if (typeof ImageImporter !== 'undefined') {
|
|||
|
|
// Pre-fill the name
|
|||
|
|
if(ImageImporter.nameInput) {
|
|||
|
|
ImageImporter.nameInput.value = f.name.replace(/\.[^/.]+$/, "");
|
|||
|
|
}
|
|||
|
|
// Open the Modal
|
|||
|
|
ImageImporter.loadImage(e.target.result);
|
|||
|
|
} else {
|
|||
|
|
// Fallback if UI not loaded (unlikely)
|
|||
|
|
if(confirm(`Import image "${f.name}"?`)) {
|
|||
|
|
saveProcessedImageToDB(f, f.name);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
r.readAsDataURL(f);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Button Handler (keeps input element logic)
|
|||
|
|
function handleImport(i) {
|
|||
|
|
const f = i.files[0];
|
|||
|
|
if(f) importFile(f);
|
|||
|
|
i.value = ''; // Reset input so same file can be selected again
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setupDragDrop() {
|
|||
|
|
const zone = document.body;
|
|||
|
|
let dragCounter = 0; // Fixes flickering when dragging over child elements
|
|||
|
|
|
|||
|
|
// 1. Drag Enter
|
|||
|
|
zone.addEventListener('dragenter', e => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
dragCounter++;
|
|||
|
|
zone.classList.add('drag-active');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 2. Drag Leave
|
|||
|
|
zone.addEventListener('dragleave', e => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
dragCounter--;
|
|||
|
|
if(dragCounter === 0) zone.classList.remove('drag-active');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 3. Drag Over (Required to allow dropping)
|
|||
|
|
zone.addEventListener('dragover', e => e.preventDefault());
|
|||
|
|
|
|||
|
|
// 4. Drop
|
|||
|
|
zone.addEventListener('drop', async e => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
dragCounter = 0;
|
|||
|
|
zone.classList.remove('drag-active');
|
|||
|
|
|
|||
|
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|||
|
|
// Process only the first file
|
|||
|
|
await importFile(e.dataTransfer.files[0]);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function processZIP(file) {
|
|||
|
|
if(!window.JSZip) return alert("JSZip library not loaded.");
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const zip = await JSZip.loadAsync(file);
|
|||
|
|
let recognized = false;
|
|||
|
|
|
|||
|
|
// CASE A: Device Backup
|
|||
|
|
if (zip.file("device.json")) {
|
|||
|
|
const content = await zip.file("device.json").async("string");
|
|||
|
|
let manifest;
|
|||
|
|
try { manifest = JSON.parse(content); } catch(e) { throw new Error("device.json is corrupt"); }
|
|||
|
|
|
|||
|
|
// STRICT CHECK
|
|||
|
|
if (isValidDeviceManifest(manifest)) {
|
|||
|
|
recognized = true;
|
|||
|
|
const vMsg = manifest.version ? `(v${manifest.version})` : '';
|
|||
|
|
const sMsg = manifest.source ? `\nSource: ${manifest.source}` : '';
|
|||
|
|
|
|||
|
|
if(confirm(`Restore Device: "${manifest.device.name}" ${vMsg}?${sMsg}\n\nThis will merge boards and overwrite existing components.`)) {
|
|||
|
|
await restoreDevice(manifest, zip);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
throw new Error("ZIP contains device.json, but it is missing required fields (id, name, boards).");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CASE B: Single Board Backup
|
|||
|
|
else if (zip.file("bom.json")) {
|
|||
|
|
const content = await zip.file("bom.json").async("string");
|
|||
|
|
let data;
|
|||
|
|
try { data = JSON.parse(content); } catch(e) { throw new Error("bom.json is corrupt"); }
|
|||
|
|
|
|||
|
|
// STRICT CHECK
|
|||
|
|
if (isValidBoardData(data)) {
|
|||
|
|
recognized = true;
|
|||
|
|
if (!currentDeviceId) {
|
|||
|
|
alert("Please select or create a Device first.");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if(confirm(`Import Board: "${data.meta.name}" into current Device?`)) {
|
|||
|
|
data.meta.deviceId = currentDeviceId;
|
|||
|
|
await processImportData(data, zip);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
throw new Error("ZIP contains bom.json, but it is missing required fields (id, meta, components).");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!recognized) {
|
|||
|
|
alert("Unrecognized ZIP format.\n\nExpected 'device.json' or 'bom.json' with valid structure.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
} catch(e) {
|
|||
|
|
console.error(e);
|
|||
|
|
alert("Import Failed: " + e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function restoreDevice(manifest, zip) {
|
|||
|
|
const devId = manifest.device.id;
|
|||
|
|
const existingDev = deviceList.find(d => d.id === devId);
|
|||
|
|
if (!existingDev) {
|
|||
|
|
await db.addDevice(manifest.device);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- PRUNING PHASE 1: BOARDS ---
|
|||
|
|
const localBoards = await db.getProjectsByDevice(devId);
|
|||
|
|
const manifestBoardIds = new Set(manifest.boards.map(b => b.meta.id));
|
|||
|
|
for (const lb of localBoards) {
|
|||
|
|
if (!manifestBoardIds.has(lb.id)) {
|
|||
|
|
await db.deleteProject(lb.id);
|
|||
|
|
// Note: deleteProject doesn't currently delete Nets automatically in the DB class
|
|||
|
|
// We should ideally clean them up, but for now we focus on the restore logic.
|
|||
|
|
console.log(`Pruned Board: ${lb.name}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const imgFolder = zip.folder("images");
|
|||
|
|
let updatedBoards = 0;
|
|||
|
|
let mergedBoards = 0;
|
|||
|
|
|
|||
|
|
for (const boardData of manifest.boards) {
|
|||
|
|
const boardId = boardData.meta.id;
|
|||
|
|
const localBoard = await db.getProject(boardId);
|
|||
|
|
|
|||
|
|
// Timestamp Logic
|
|||
|
|
const incomingTime = boardData.meta.lastModified || 0;
|
|||
|
|
const localTime = localBoard ? (localBoard.lastModified || 0) : -1;
|
|||
|
|
const isNewer = incomingTime > localTime;
|
|||
|
|
|
|||
|
|
if (isNewer) {
|
|||
|
|
// Update Meta
|
|||
|
|
boardData.meta.deviceId = devId;
|
|||
|
|
await db.addProject(boardData.meta);
|
|||
|
|
if(localBoard) updatedBoards++;
|
|||
|
|
|
|||
|
|
// --- PRUNING PHASE 2: CONTENT ---
|
|||
|
|
// 1. Prune Components
|
|||
|
|
const localComps = await db.getComponents(boardId);
|
|||
|
|
const importCompIds = new Set(boardData.components.map(c => c.id));
|
|||
|
|
for(const lc of localComps) {
|
|||
|
|
if(!importCompIds.has(lc.id)) await db.deleteComponent(lc.id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. Prune Images
|
|||
|
|
const localImages = await db.getImages(boardId);
|
|||
|
|
const importImgIds = new Set(boardData.images.map(i => i.id));
|
|||
|
|
for(const li of localImages) {
|
|||
|
|
if(!importImgIds.has(li.id)) await db.deleteImage(li.id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. Prune Overlaps
|
|||
|
|
if(localImages.length > 0) {
|
|||
|
|
const boardOverlapIds = new Set();
|
|||
|
|
for(const li of localImages) {
|
|||
|
|
const ovs = await db.getOverlapsForImage(li.id);
|
|||
|
|
ovs.forEach(o => boardOverlapIds.add(o.id));
|
|||
|
|
}
|
|||
|
|
const importOvIds = new Set((boardData.overlaps || []).map(o => o.id));
|
|||
|
|
const tx = db.db.transaction('overlappedImages', 'readwrite');
|
|||
|
|
const store = tx.objectStore('overlappedImages');
|
|||
|
|
boardOverlapIds.forEach(ovid => {
|
|||
|
|
if(!importOvIds.has(ovid)) store.delete(ovid);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. Prune Nets [NEW]
|
|||
|
|
const allNets = await db.getNets();
|
|||
|
|
const localNets = allNets.filter(n => n.projectId === boardId);
|
|||
|
|
const importNetIds = new Set((boardData.nets || []).map(n => n.id));
|
|||
|
|
for(const ln of localNets) {
|
|||
|
|
if(!importNetIds.has(ln.id)) await db.deleteNet(ln.id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
} else {
|
|||
|
|
mergedBoards++;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- UPSERT PHASE ---
|
|||
|
|
|
|||
|
|
// Images (unchanged logic...)
|
|||
|
|
if (imgFolder && boardData.images) {
|
|||
|
|
for (const im of boardData.images) {
|
|||
|
|
if (!isNewer) {
|
|||
|
|
const existingImg = await db.getImage(im.id);
|
|||
|
|
if (existingImg) continue;
|
|||
|
|
}
|
|||
|
|
const cleanStoredId = im.id.split('/').pop();
|
|||
|
|
let filename = cleanStoredId + ".jpg";
|
|||
|
|
let file = imgFolder.file(filename);
|
|||
|
|
if (!file) file = zip.file("images/" + filename);
|
|||
|
|
if (!file) {
|
|||
|
|
const cleanExt = (im.type.split('/')[1] || 'png');
|
|||
|
|
filename = cleanStoredId + "." + cleanExt;
|
|||
|
|
file = imgFolder.file(filename);
|
|||
|
|
if (!file) file = zip.file("images/" + filename);
|
|||
|
|
}
|
|||
|
|
if (file) {
|
|||
|
|
const blob = await file.async("blob");
|
|||
|
|
const cleanName = im.name.replace(/\.[^/.]+$/, "");
|
|||
|
|
await db.addImage({ id: im.id, boardId: boardId, blob, name: cleanName });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Components
|
|||
|
|
for (const c of boardData.components) {
|
|||
|
|
c.boardId = boardId;
|
|||
|
|
if (isNewer) {
|
|||
|
|
await db.addComponent(c);
|
|||
|
|
} else {
|
|||
|
|
const existingC = await db._tx('components', 'readonly', s => s.get(c.id));
|
|||
|
|
if (!existingC) await db.addComponent(c);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Overlaps
|
|||
|
|
if (boardData.overlaps) {
|
|||
|
|
for (const ov of boardData.overlaps) {
|
|||
|
|
if (isNewer) {
|
|||
|
|
await db.addOverlap(ov);
|
|||
|
|
} else {
|
|||
|
|
const existingOv = await db._tx('overlappedImages', 'readonly', s => s.get(ov.id));
|
|||
|
|
if (!existingOv) await db.addOverlap(ov);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Nets [NEW]
|
|||
|
|
if (boardData.nets) {
|
|||
|
|
for (const net of boardData.nets) {
|
|||
|
|
// Ensure correct association
|
|||
|
|
net.projectId = boardId;
|
|||
|
|
if (isNewer) {
|
|||
|
|
await db.addNet(net);
|
|||
|
|
} else {
|
|||
|
|
const existingNet = await db._tx('nets', 'readonly', s => s.get(net.id));
|
|||
|
|
if (!existingNet) await db.addNet(net);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
deviceList = await db.getDevices();
|
|||
|
|
currentDeviceId = devId;
|
|||
|
|
updateDeviceDropdown();
|
|||
|
|
await loadDeviceBoms();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function processImportData(data, zipObj) {
|
|||
|
|
const boardId = data.meta.id;
|
|||
|
|
const localBoard = await db.getProject(boardId);
|
|||
|
|
|
|||
|
|
const incomingTime = data.meta.lastModified || 0;
|
|||
|
|
const localTime = localBoard ? (localBoard.lastModified || 0) : -1;
|
|||
|
|
const isNewer = incomingTime > localTime;
|
|||
|
|
|
|||
|
|
if (isNewer) {
|
|||
|
|
// Update Meta
|
|||
|
|
data.meta.deviceId = localBoard ? localBoard.deviceId : currentDeviceId;
|
|||
|
|
await db.addProject(data.meta);
|
|||
|
|
|
|||
|
|
// --- PRUNING PHASE ---
|
|||
|
|
|
|||
|
|
// 1. Components
|
|||
|
|
const localComps = await db.getComponents(boardId);
|
|||
|
|
const importCompIds = new Set(data.components.map(c => c.id));
|
|||
|
|
for(const lc of localComps) {
|
|||
|
|
if(!importCompIds.has(lc.id)) await db.deleteComponent(lc.id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. Images
|
|||
|
|
const localImages = await db.getImages(boardId);
|
|||
|
|
const importImgIds = new Set((data.images || []).map(i => i.id));
|
|||
|
|
for(const li of localImages) {
|
|||
|
|
if(!importImgIds.has(li.id)) await db.deleteImage(li.id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. Overlaps
|
|||
|
|
if(localImages.length > 0) {
|
|||
|
|
const boardOverlapIds = new Set();
|
|||
|
|
for(const li of localImages) {
|
|||
|
|
const ovs = await db.getOverlapsForImage(li.id);
|
|||
|
|
ovs.forEach(o => boardOverlapIds.add(o.id));
|
|||
|
|
}
|
|||
|
|
const importOvIds = new Set((data.overlaps || []).map(o => o.id));
|
|||
|
|
const tx = db.db.transaction('overlappedImages', 'readwrite');
|
|||
|
|
const store = tx.objectStore('overlappedImages');
|
|||
|
|
boardOverlapIds.forEach(ovid => {
|
|||
|
|
if(!importOvIds.has(ovid)) store.delete(ovid);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. Nets [NEW]
|
|||
|
|
const allNets = await db.getNets();
|
|||
|
|
const localNets = allNets.filter(n => n.projectId === boardId);
|
|||
|
|
const importNetIds = new Set((data.nets || []).map(n => n.id));
|
|||
|
|
for(const ln of localNets) {
|
|||
|
|
if(!importNetIds.has(ln.id)) await db.deleteNet(ln.id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
} else {
|
|||
|
|
console.log("Import is older/same. Merging missing items only.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- UPSERT PHASE ---
|
|||
|
|
|
|||
|
|
// Images (unchanged logic...)
|
|||
|
|
if (zipObj && data.images) {
|
|||
|
|
const imgFolder = zipObj.folder("images");
|
|||
|
|
if (imgFolder) {
|
|||
|
|
const imgFiles = [];
|
|||
|
|
imgFolder.forEach((path, file) => imgFiles.push(file));
|
|||
|
|
|
|||
|
|
for(const f of imgFiles) {
|
|||
|
|
const fileName = f.name.split('/').pop();
|
|||
|
|
const idFromName = fileName.split('.')[0];
|
|||
|
|
|
|||
|
|
const metaImg = data.images.find(x => x.id === idFromName || x.id.endsWith(idFromName));
|
|||
|
|
const finalId = metaImg ? metaImg.id : idFromName;
|
|||
|
|
const finalName = metaImg ? metaImg.name.replace(/\.[^/.]+$/, "") : fileName;
|
|||
|
|
const mime = metaImg ? metaImg.type : 'image/jpeg';
|
|||
|
|
|
|||
|
|
if (!isNewer) {
|
|||
|
|
const existing = await db.getImage(finalId);
|
|||
|
|
if (existing) continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const rawBlob = await f.async("blob");
|
|||
|
|
const blob = new Blob([rawBlob], { type: mime });
|
|||
|
|
|
|||
|
|
await db.addImage({ id: finalId, boardId: boardId, blob, name: finalName });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Components
|
|||
|
|
for(const c of data.components) {
|
|||
|
|
c.boardId = boardId;
|
|||
|
|
if (isNewer) {
|
|||
|
|
await db.addComponent(c);
|
|||
|
|
} else {
|
|||
|
|
const existing = await db._tx('components', 'readonly', s => s.get(c.id));
|
|||
|
|
if (!existing) await db.addComponent(c);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Overlaps
|
|||
|
|
if (data.overlaps) {
|
|||
|
|
for (const ov of data.overlaps) {
|
|||
|
|
if (isNewer) {
|
|||
|
|
await db.addOverlap(ov);
|
|||
|
|
} else {
|
|||
|
|
const existing = await db._tx('overlappedImages', 'readonly', s => s.get(ov.id));
|
|||
|
|
if (!existing) await db.addOverlap(ov);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Nets [NEW]
|
|||
|
|
if (data.nets) {
|
|||
|
|
for (const net of data.nets) {
|
|||
|
|
net.projectId = boardId;
|
|||
|
|
if (isNewer) {
|
|||
|
|
await db.addNet(net);
|
|||
|
|
} else {
|
|||
|
|
const existing = await db._tx('nets', 'readonly', s => s.get(net.id));
|
|||
|
|
if (!existing) await db.addNet(net);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await loadDeviceBoms();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function processLegacyImport(b) {
|
|||
|
|
if (!currentBomId) return alert("Create/Select a board first.");
|
|||
|
|
for(const c of (b.components || b.data)) { c.id = uuid(); c.boardId = currentBomId; await db.addComponent(c); }
|
|||
|
|
await loadProjectData();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Board Export
|
|||
|
|
async function exportZIP() {
|
|||
|
|
if(!window.JSZip) return;
|
|||
|
|
const zip = new JSZip();
|
|||
|
|
const meta = bomList.find(x=>x.id===currentBomId);
|
|||
|
|
const images = await db.getImages(currentBomId);
|
|||
|
|
|
|||
|
|
// Fetch and filter Nets
|
|||
|
|
const allNets = await db.getNets();
|
|||
|
|
const boardNets = allNets.filter(n => n.projectId === currentBomId);
|
|||
|
|
|
|||
|
|
const overlapsMap = new Map();
|
|||
|
|
for(const img of images) {
|
|||
|
|
const ovs = await db.getOverlapsForImage(img.id);
|
|||
|
|
ovs.forEach(o => overlapsMap.set(o.id, o));
|
|||
|
|
}
|
|||
|
|
const overlaps = Array.from(overlapsMap.values());
|
|||
|
|
|
|||
|
|
const imgMeta = images.map(i => ({ id: i.id, name: i.name, type: 'image/jpeg' }));
|
|||
|
|
const cleanComps = bomData.map(c => { const { boardId, ...rest } = c; return rest; });
|
|||
|
|
|
|||
|
|
// 1. Generate README
|
|||
|
|
const readmeContent = `PCB ReTrace Data Export
|
|||
|
|
Type: Single Board (${meta.name})
|
|||
|
|
Generated by: pcb.etaras.com
|
|||
|
|
Date: ${new Date().toISOString()}
|
|||
|
|
|
|||
|
|
HOW TO USE:
|
|||
|
|
1. Go to https://pcb.etaras.com/studio.html
|
|||
|
|
2. Select or Create a Device.
|
|||
|
|
3. Click "Import Board" or drag and drop this ZIP file into the component list.
|
|||
|
|
`;
|
|||
|
|
zip.file("README.txt", readmeContent);
|
|||
|
|
|
|||
|
|
// 2. Generate JSON
|
|||
|
|
const data = {
|
|||
|
|
meta,
|
|||
|
|
components: cleanComps,
|
|||
|
|
images: imgMeta,
|
|||
|
|
overlaps: overlaps,
|
|||
|
|
nets: boardNets, // Add Nets
|
|||
|
|
version: DB_VER,
|
|||
|
|
source: "pcb.etaras.com"
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
zip.file("bom.json", JSON.stringify(data, null, 2));
|
|||
|
|
|
|||
|
|
const imgFolder = zip.folder("images");
|
|||
|
|
|
|||
|
|
// Image Loop
|
|||
|
|
for (const img of images) {
|
|||
|
|
let blobToSave = img.blob;
|
|||
|
|
if (img.blob.type !== 'image/jpeg') {
|
|||
|
|
const bmp = await createImageBitmap(img.blob);
|
|||
|
|
const canvas = document.createElement('canvas');
|
|||
|
|
canvas.width = bmp.width;
|
|||
|
|
canvas.height = bmp.height;
|
|||
|
|
canvas.getContext('2d').drawImage(bmp, 0, 0);
|
|||
|
|
bmp.close();
|
|||
|
|
blobToSave = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.85));
|
|||
|
|
}
|
|||
|
|
imgFolder.file(img.id + ".jpg", blobToSave);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
zip.generateAsync({type:"blob"}).then(c => dl(c, meta.name + "_Board.zip", "application/zip"));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function exportCSV() {
|
|||
|
|
const meta = bomList.find(p => p.id === currentBomId);
|
|||
|
|
const view = getSortedView();
|
|||
|
|
let csv = "Reference,Value,Description\n";
|
|||
|
|
view.forEach(r => csv += `${r.label},${r.value},"${(r.desc||'').replace(/"/g,'""')}"\n`);
|
|||
|
|
const blob = new Blob([csv], { type: 'text/csv' });
|
|||
|
|
const url = window.URL.createObjectURL(blob);
|
|||
|
|
const a = document.createElement('a'); a.href = url; a.download = `${meta.name}_BOM.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|||
|
|
}
|
|||
|
|
function dl(c,n,t){ const b=(c instanceof Blob)?c:new Blob([c],{type:t}); const u=window.URL.createObjectURL(b); const a=document.createElement('a'); a.href=u; a.download=n; document.body.appendChild(a); a.click(); document.body.removeChild(a); }
|
|||
|
|
|
|||
|
|
// --- UI HELPERS ---
|
|||
|
|
// Global Confirmation Helper (Promise-based)
|
|||
|
|
function confirmAction(message, btnText = "Confirm") {
|
|||
|
|
return new Promise((resolve) => {
|
|||
|
|
const modal = document.getElementById('confirmation-modal');
|
|||
|
|
const msgEl = document.getElementById('confirm-msg');
|
|||
|
|
const okBtn = document.getElementById('confirm-btn-ok');
|
|||
|
|
const cancelBtn = document.getElementById('confirm-btn-cancel');
|
|||
|
|
|
|||
|
|
msgEl.innerText = message;
|
|||
|
|
okBtn.innerText = btnText;
|
|||
|
|
|
|||
|
|
// Cleanup old handlers by reassigning
|
|||
|
|
okBtn.onclick = () => {
|
|||
|
|
modal.style.display = 'none';
|
|||
|
|
resolve(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
cancelBtn.onclick = () => {
|
|||
|
|
modal.style.display = 'none';
|
|||
|
|
resolve(false);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
modal.style.display = 'flex';
|
|||
|
|
cancelBtn.focus(); // Default focus on Cancel
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function switchView(v) {
|
|||
|
|
// UI Toggles
|
|||
|
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|||
|
|
document.querySelectorAll('.view-section').forEach(s => s.classList.remove('active'));
|
|||
|
|
|
|||
|
|
// Button Active State
|
|||
|
|
const btn = document.getElementById(`tab-${v}`);
|
|||
|
|
if (btn) btn.classList.add('active');
|
|||
|
|
|
|||
|
|
// View Active State
|
|||
|
|
const view = document.getElementById(`view-${v}`);
|
|||
|
|
if (view) view.classList.add('active');
|
|||
|
|
|
|||
|
|
// View Specific Initialization
|
|||
|
|
if (v === 'map') {
|
|||
|
|
if(currentImgId) {
|
|||
|
|
if(!document.getElementById('map-content').querySelector('.pcb-image')) showImage(currentImgId);
|
|||
|
|
else { setTimeout(() => { if(mapState.scale === 1 && mapState.x === 0 && mapState.y === 0) fitMap(); renderPins(); }, 50); }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else if (v === 'nets') {
|
|||
|
|
if(window.netManager) window.netManager.render();
|
|||
|
|
}
|
|||
|
|
else if (v === 'inspect') {
|
|||
|
|
if(window.inspector) window.inspector.init();
|
|||
|
|
}
|
|||
|
|
// 'list' needs no specific init
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resetStickyEditor() {
|
|||
|
|
document.getElementById('inp-id').value = '';
|
|||
|
|
document.getElementById('inp-label').value = '';
|
|||
|
|
document.getElementById('inp-value').value = '';
|
|||
|
|
document.getElementById('inp-desc').value = '';
|
|||
|
|
document.getElementById('inp-x').value = '';
|
|||
|
|
document.getElementById('inp-y').value = '';
|
|||
|
|
document.getElementById('inp-img-id').value = '';
|
|||
|
|
document.getElementById('new-part-tool-container').innerHTML = '';
|
|||
|
|
document.getElementById('inline-thumbs').innerHTML = '';
|
|||
|
|
if(spyglass) spyglass.clear();
|
|||
|
|
}
|
|||
|
|
function parseLabel(l) { const m=l.toUpperCase().match(/^([A-Z]+)(\d+)(.*)$/); return m?{valid:true,prefix:m[1],num:parseInt(m[2])}:{valid:false}; }
|
|||
|
|
async function changeSortMode() { sortMode=document.getElementById('sort-select').value; const m=bomList.find(x=>x.id===currentBomId); if(m){ m.sortMode=sortMode; await db.addProject(m); renderList(); } }
|
|||
|
|
function getSortedView() {
|
|||
|
|
let view = bomData.map((item, index) => ({ ...item, dataIndex: index }));
|
|||
|
|
if (sortMode === 'none') return view;
|
|||
|
|
view.sort((a, b) => {
|
|||
|
|
const pa=parseLabel(a.label), pb=parseLabel(b.label);
|
|||
|
|
if (!pa.valid || !pb.valid) return a.label.localeCompare(b.label);
|
|||
|
|
if (sortMode === 'std') return pa.prefix!==pb.prefix ? pa.prefix.localeCompare(pb.prefix) : pa.num-pb.num;
|
|||
|
|
return pa.num!==pb.num ? pa.num-pb.num : pa.prefix.localeCompare(pb.prefix);
|
|||
|
|
});
|
|||
|
|
return view;
|
|||
|
|
}
|
|||
|
|
function renderList() {
|
|||
|
|
const tb = document.getElementById('bom-body'); tb.innerHTML='';
|
|||
|
|
let view = getSortedView();
|
|||
|
|
if(view.length===0) tb.innerHTML=`<tr><td colspan="4" class="empty-state" style="padding:2rem">Empty BOM</td></tr>`;
|
|||
|
|
view.forEach(p => {
|
|||
|
|
const pinIcon = (p.x !== undefined) ? `<span style="cursor:pointer" onclick="locateComponent('${p.imgId}', ${p.x}, ${p.y}); event.stopPropagation();">🎯</span>` : '';
|
|||
|
|
const tr = document.createElement('tr');
|
|||
|
|
|
|||
|
|
// [NEW] Assign ID for lookup
|
|||
|
|
tr.dataset.id = p.id;
|
|||
|
|
|
|||
|
|
tr.onclick = (e) => {
|
|||
|
|
if(e.target.tagName==='BUTTON' || e.target.closest('button')) return;
|
|||
|
|
fillFormFromData(p);
|
|||
|
|
document.querySelectorAll('tbody tr').forEach(x=>x.classList.remove('editing'));
|
|||
|
|
tr.classList.add('editing');
|
|||
|
|
};
|
|||
|
|
const tools = getToolButtonsHtml(p.label, p.dataIndex);
|
|||
|
|
tr.innerHTML = `<td><strong>${pinIcon} ${p.label}</strong></td>
|
|||
|
|
<td><div class="val-cell"><span class="val-text">${p.value||'-'}</span><div class="tool-group">${tools}<button class="tool-btn copy-btn" onclick="copyPartToForm(${p.dataIndex})" title="Copy">📄</button></div></div></td>
|
|||
|
|
<td>${p.desc}</td>`;
|
|||
|
|
tb.appendChild(tr);
|
|||
|
|
});
|
|||
|
|
// Check if new connections were made, update overlap cache if so (from modal)
|
|||
|
|
if(currentImgId) updateOverlapCache().then(() => {});
|
|||
|
|
}
|
|||
|
|
function toggleExport(e) { e.stopPropagation(); const el = document.getElementById('export-dropdown'); const isShown = el.style.display === 'block'; document.querySelectorAll('.dropdown-content').forEach(d => d.style.display = 'none'); el.style.display = isShown ? 'none' : 'block'; }
|
|||
|
|
window.addEventListener('click', () => document.querySelectorAll('.dropdown-content').forEach(d => d.style.display = 'none'));
|
|||
|
|
async function removePart(idx) {
|
|||
|
|
if(await confirmAction("Remove this component?", "Remove")) {
|
|||
|
|
await db.deleteComponent(bomData[idx].id);
|
|||
|
|
await loadProjectData();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
async function clearCurrentBom() {
|
|||
|
|
if(await confirmAction("Clear ALL components from this board?", "Clear All")) {
|
|||
|
|
await db.clearComponents(currentBomId);
|
|||
|
|
await loadProjectData();
|
|||
|
|
resetStickyEditor();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
async function deleteBom() {
|
|||
|
|
if(bomList.length < 1) return;
|
|||
|
|
if(await confirmAction("Delete this Board and all its content?", "Delete Board")){
|
|||
|
|
await db.deleteProject(currentBomId);
|
|||
|
|
document.getElementById('board-settings-modal').style.display = 'none';
|
|||
|
|
await loadDeviceBoms();
|
|||
|
|
resetStickyEditor();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
function checkToolHint(v) { document.getElementById('new-part-tool-container').innerHTML=getToolButtonsHtml(v,'new'); }
|
|||
|
|
function getToolButtonsHtml(lbl, idx) {
|
|||
|
|
if(!lbl)return ''; const p=lbl.charAt(0).toUpperCase(), ts=TOOL_REGISTRY[p]; if(!ts)return '';
|
|||
|
|
const iStr = (idx==='new')?"'new'":idx; let h='';
|
|||
|
|
const cnt = ts.length > MAX_DIRECT_TOOLS ? MAX_DIRECT_TOOLS-1 : ts.length;
|
|||
|
|
for(let i=0;i<cnt;i++) h+=`<button class="tool-btn" onclick="openToolD(${iStr},'${ts[i].url}','${ts[i].title}')">${ts[i].icon}</button>`;
|
|||
|
|
if(ts.length>MAX_DIRECT_TOOLS) h+=`<button class="tool-btn more-btn" onclick="openToolS(${iStr})">•••</button>`;
|
|||
|
|
return `<div class="tool-group">${h}</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- TOOLS ---
|
|||
|
|
const modal=document.getElementById('tool-modal'), iframe=document.getElementById('tool-frame'), toolTitle=document.getElementById('tool-title');
|
|||
|
|
|
|||
|
|
async function openToolD(idx, url, tit) {
|
|||
|
|
editingIndex = idx;
|
|||
|
|
toolTitle.innerText = tit;
|
|||
|
|
document.getElementById('tool-selector').style.display = 'none';
|
|||
|
|
|
|||
|
|
iframe.style.display = 'none';
|
|||
|
|
modal.style.display = 'flex';
|
|||
|
|
|
|||
|
|
let d = (idx === 'new')
|
|||
|
|
? { v: document.getElementById('inp-value').value, d: document.getElementById('inp-desc').value, comp: null }
|
|||
|
|
: { v: bomData[idx].value, d: bomData[idx].desc, comp: bomData[idx] };
|
|||
|
|
|
|||
|
|
let srcX = null, srcY = null, srcImgId = null;
|
|||
|
|
|
|||
|
|
if (idx !== 'new' && d.comp) {
|
|||
|
|
srcImgId = d.comp.imgId; srcX = d.comp.x; srcY = d.comp.y;
|
|||
|
|
} else if (idx === 'new') {
|
|||
|
|
const iid = document.getElementById('inp-img-id').value;
|
|||
|
|
if (iid) {
|
|||
|
|
srcImgId = iid;
|
|||
|
|
srcX = parseFloat(document.getElementById('inp-x').value);
|
|||
|
|
srcY = parseFloat(document.getElementById('inp-y').value);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const views = [];
|
|||
|
|
const transferables = [];
|
|||
|
|
|
|||
|
|
if (srcImgId && srcX !== null && srcY !== null) {
|
|||
|
|
try {
|
|||
|
|
const imgRec = await db.getImage(srcImgId);
|
|||
|
|
if (imgRec && imgRec.blob) {
|
|||
|
|
const bmp = await createImageBitmap(imgRec.blob);
|
|||
|
|
views.push({ name: imgRec.name + " (Main)", bitmap: bmp, x: srcX, y: srcY, isMain: true });
|
|||
|
|
transferables.push(bmp);
|
|||
|
|
|
|||
|
|
// USE HELPER for Inferred Views
|
|||
|
|
const paths = await ImageGraph.solvePaths(srcImgId, cvManager, db);
|
|||
|
|
|
|||
|
|
for (const pData of paths) {
|
|||
|
|
const pt = cvManager.projectPoint(srcX, srcY, pData.H);
|
|||
|
|
if (pt) {
|
|||
|
|
const targetRec = await db.getImage(pData.id);
|
|||
|
|
if(targetRec) {
|
|||
|
|
const tBmp = await createImageBitmap(targetRec.blob);
|
|||
|
|
if (pt.x > 0 && pt.y > 0 && pt.x < tBmp.width && pt.y < tBmp.height) {
|
|||
|
|
views.push({ name: targetRec.name, bitmap: tBmp, x: pt.x, y: pt.y, isMain: false, imgId: pData.id });
|
|||
|
|
transferables.push(tBmp);
|
|||
|
|
} else {
|
|||
|
|
tBmp.close();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (e) { console.error("Error generating views:", e); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const loadHandler = () => {
|
|||
|
|
iframe.style.display = 'block';
|
|||
|
|
const msg = { type: 'INIT_TOOL', value: d.v, description: d.d, views: views };
|
|||
|
|
iframe.contentWindow.postMessage(msg, '*', transferables);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
iframe.removeEventListener('load', loadHandler);
|
|||
|
|
iframe.addEventListener('load', loadHandler, { once: true });
|
|||
|
|
|
|||
|
|
const finalUrl = url + "?embed=true";
|
|||
|
|
|
|||
|
|
// --- Use location.replace to avoid history pollution ---
|
|||
|
|
if (iframe.contentWindow && iframe.contentWindow.location) {
|
|||
|
|
try {
|
|||
|
|
iframe.contentWindow.location.replace(finalUrl);
|
|||
|
|
} catch(e) {
|
|||
|
|
// Fallback for first load or cross-origin issues (though local)
|
|||
|
|
iframe.src = finalUrl;
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
iframe.src = finalUrl;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openToolS(idx) {
|
|||
|
|
editingIndex=idx; const lbl=(idx==='new')?document.getElementById('inp-label').value:bomData[idx].label; const ts=TOOL_REGISTRY[lbl.charAt(0).toUpperCase()];
|
|||
|
|
toolTitle.innerText="Select Tool"; iframe.style.display='none'; document.getElementById('tool-selector').style.display='flex'; modal.style.display='flex';
|
|||
|
|
const g=document.getElementById('selector-grid'); g.innerHTML='';
|
|||
|
|
ts.forEach(t=>{ const c=document.createElement('div'); c.className='tool-card'; c.innerHTML=`<div class="tool-card-icon">${t.icon}</div><div class="tool-card-title">${t.title}</div>`; c.onclick=()=>openToolD(idx,t.url,t.title); g.appendChild(c); });
|
|||
|
|
}
|
|||
|
|
function closeTool(){ modal.style.display='none'; iframe.src=''; }
|
|||
|
|
window.addEventListener('message', async e=>{
|
|||
|
|
if(e.data.type==='COMPONENT_UPDATE') {
|
|||
|
|
if(editingIndex==='new') { document.getElementById('inp-value').value=e.data.value; if(e.data.description)document.getElementById('inp-desc').value=e.data.description; }
|
|||
|
|
else { const c=bomData[editingIndex]; c.value=e.data.value; if(e.data.description)c.desc=e.data.description; await db.addComponent(c); await loadProjectData(); }
|
|||
|
|
closeTool();
|
|||
|
|
}
|
|||
|
|
// 2. Handle Promotion (Swap Source Image)
|
|||
|
|
else if (e.data.type === 'PROMOTE_VIEW') {
|
|||
|
|
if (editingIndex !== -1 && editingIndex !== 'new') {
|
|||
|
|
const c = bomData[editingIndex];
|
|||
|
|
c.imgId = e.data.imgId; // Switch the source image ID
|
|||
|
|
c.x = e.data.x;
|
|||
|
|
c.y = e.data.y;
|
|||
|
|
await db.addComponent(c);
|
|||
|
|
await loadProjectData(); // Refresh Map/List
|
|||
|
|
closeTool(); // Close tool to force refresh of state
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. Handle Position Adjustment (Drag in Spyglass)
|
|||
|
|
else if (e.data.type === 'UPDATE_POS') {
|
|||
|
|
if (editingIndex !== -1 && editingIndex !== 'new') {
|
|||
|
|
const c = bomData[editingIndex];
|
|||
|
|
c.x = e.data.x;
|
|||
|
|
c.y = e.data.y;
|
|||
|
|
await db.addComponent(c);
|
|||
|
|
// Note: We don't reloadProjectData() here to keep the UI smooth while dragging
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// --- SPYGLASS & MAP (ENHANCED) ---
|
|||
|
|
async function showImage(id) {
|
|||
|
|
currentImgId = id;
|
|||
|
|
const imgSel = document.getElementById('image-select');
|
|||
|
|
if(imgSel && imgSel.value !== id) imgSel.value = id;
|
|||
|
|
const imgObj = bomImages.find(i => i.id === id);
|
|||
|
|
if(!imgObj) return;
|
|||
|
|
const url = URL.createObjectURL(imgObj.blob);
|
|||
|
|
const currentImg = document.getElementById('map-content').querySelector('.pcb-image');
|
|||
|
|
if(!currentImg || currentImg.dataset.id !== id) {
|
|||
|
|
document.getElementById('map-content').innerHTML = `<img src="${url}" data-id="${id}" class="pcb-image">`;
|
|||
|
|
const newImg = document.getElementById('map-content').querySelector('.pcb-image');
|
|||
|
|
newImg.onload = () => fitMap();
|
|||
|
|
}
|
|||
|
|
await updateOverlapCache(); // LOAD OVERLAPS
|
|||
|
|
renderPins();
|
|||
|
|
}
|
|||
|
|
function fitMap() {
|
|||
|
|
if(skipNextFit) { skipNextFit = false; return; }
|
|||
|
|
const img = document.getElementById('map-content').querySelector('.pcb-image');
|
|||
|
|
if(!img) return;
|
|||
|
|
if(img.naturalWidth === 0) { img.onload = () => fitMap(); return; }
|
|||
|
|
const viewport = document.getElementById('map-viewport');
|
|||
|
|
const vw = viewport.clientWidth; const vh = viewport.clientHeight;
|
|||
|
|
if (vw === 0 || vh === 0) return;
|
|||
|
|
const scale = Math.min(vw/img.naturalWidth, vh/img.naturalHeight);
|
|||
|
|
mapState = { scale: scale, x: (vw - img.naturalWidth*scale)/2, y: (vh - img.naturalHeight*scale)/2, isDragging: false };
|
|||
|
|
updateTransform();
|
|||
|
|
}
|
|||
|
|
function switchImage() { showImage(document.getElementById('image-select').value); setTimeout(fitMap, 50); }
|
|||
|
|
async function locateComponent(imgId, x, y) {
|
|||
|
|
if (!imgId || x === undefined || y === undefined) return;
|
|||
|
|
|
|||
|
|
// 1. Switch to Map Tab
|
|||
|
|
switchView('map');
|
|||
|
|
|
|||
|
|
// 2. Load Image (Prevent fitMap from resetting our zoom)
|
|||
|
|
if (currentImgId !== imgId) {
|
|||
|
|
skipNextFit = true;
|
|||
|
|
await showImage(imgId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. Calculate Center & Zoom
|
|||
|
|
const viewport = document.getElementById('map-viewport');
|
|||
|
|
const vw = viewport.clientWidth || window.innerWidth;
|
|||
|
|
const vh = viewport.clientHeight || window.innerHeight;
|
|||
|
|
const targetScale = 4; // Max/High Zoom
|
|||
|
|
|
|||
|
|
mapState = {
|
|||
|
|
scale: targetScale,
|
|||
|
|
x: (vw / 2) - (x * targetScale),
|
|||
|
|
y: (vh / 2) - (y * targetScale),
|
|||
|
|
isDragging: false, startX:0, startY:0
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
updateTransform();
|
|||
|
|
renderPins();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Saves a pre-processed image blob (from the Import Modal) to the database.
|
|||
|
|
* This bypasses the file picker and allows for standardized names/types.
|
|||
|
|
*/
|
|||
|
|
async function saveProcessedImageToDB(blob, name) {
|
|||
|
|
if (!currentBomId) { alert("Please select a Board first."); return; }
|
|||
|
|
|
|||
|
|
const id = uuid();
|
|||
|
|
const newImage = { id: id, boardId: currentBomId, name: name, blob: blob, created: Date.now() };
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await db.addImage(newImage);
|
|||
|
|
await loadProjectData();
|
|||
|
|
|
|||
|
|
const imgSelect = document.getElementById('image-select');
|
|||
|
|
if(imgSelect) { imgSelect.value = id; switchImage(); }
|
|||
|
|
switchView('map');
|
|||
|
|
|
|||
|
|
await autoStitchNewImage(id);
|
|||
|
|
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error("Failed to save image:", e);
|
|||
|
|
alert("Error saving image to database.");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// EXPOSE TO GLOBAL SCOPE so the HTML modal can call it
|
|||
|
|
window.saveProcessedImageToDB = saveProcessedImageToDB;
|
|||
|
|
|
|||
|
|
async function uploadImage(input) {
|
|||
|
|
const file = input.files[0]; if(!file) return;
|
|||
|
|
const name = prompt("Image Name:", file.name);
|
|||
|
|
if(!name) return;
|
|||
|
|
|
|||
|
|
const id = uuid(); // Capture ID to use later
|
|||
|
|
await db.addImage({ id: id, boardId: currentBomId, blob: file, name: name });
|
|||
|
|
|
|||
|
|
await loadProjectData();
|
|||
|
|
switchView('map');
|
|||
|
|
|
|||
|
|
await autoStitchNewImage(id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Open the Image Settings Modal
|
|||
|
|
function openImageSettings() {
|
|||
|
|
if(!currentImgId) return alert("No image selected");
|
|||
|
|
|
|||
|
|
const img = bomImages.find(i => i.id === currentImgId);
|
|||
|
|
if(!img) return;
|
|||
|
|
|
|||
|
|
document.getElementById('edit-image-name').value = img.name;
|
|||
|
|
document.getElementById('image-settings-modal').style.display = 'flex';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Save Rename Changes
|
|||
|
|
async function saveImageSettings() {
|
|||
|
|
if(!currentImgId) return;
|
|||
|
|
|
|||
|
|
const newName = document.getElementById('edit-image-name').value.trim();
|
|||
|
|
if(!newName) return alert("Name cannot be empty");
|
|||
|
|
|
|||
|
|
const img = bomImages.find(i => i.id === currentImgId);
|
|||
|
|
|
|||
|
|
// Only update DB if changed
|
|||
|
|
if(img.name !== newName) {
|
|||
|
|
img.name = newName;
|
|||
|
|
await db.addImage(img); // Upsert to DB
|
|||
|
|
|
|||
|
|
// Update UI Dropdown immediately without full reload
|
|||
|
|
const sel = document.getElementById('image-select');
|
|||
|
|
const opt = sel.querySelector(`option[value="${currentImgId}"]`);
|
|||
|
|
if(opt) opt.innerText = newName;
|
|||
|
|
|
|||
|
|
// Update connections modal source name if it's open or cached
|
|||
|
|
const connName = document.getElementById('conn-src-name');
|
|||
|
|
if(connName) connName.innerText = newName;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.getElementById('image-settings-modal').style.display = 'none';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Delete Image (Close modal on success)
|
|||
|
|
async function deleteCurrentImage() {
|
|||
|
|
if(!currentImgId) return;
|
|||
|
|
|
|||
|
|
const usageCount = bomData.filter(c => c.imgId === currentImgId).length;
|
|||
|
|
if (usageCount > 0) {
|
|||
|
|
alert(`Cannot delete this image.\n\nIt is currently defined as the Main View for ${usageCount} component(s).\nPlease reassign or delete these components first.`);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Note: confirmAction uses z-index 1100, so it appears over settings
|
|||
|
|
if(await confirmAction("Delete current image?", "Delete Image")) {
|
|||
|
|
await db.deleteImage(currentImgId);
|
|||
|
|
|
|||
|
|
// Remove from UI arrays
|
|||
|
|
bomImages = bomImages.filter(i => i.id !== currentImgId);
|
|||
|
|
|
|||
|
|
// Close the settings modal if open
|
|||
|
|
document.getElementById('image-settings-modal').style.display = 'none';
|
|||
|
|
|
|||
|
|
currentImgId = null;
|
|||
|
|
await loadProjectData();
|
|||
|
|
resetStickyEditor();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clearMap() { document.getElementById('map-content').innerHTML = `<div id="map-placeholder" class="empty-state" style="color:white; padding-top:20vh;"><h3>No Image</h3></div>`; }
|
|||
|
|
|
|||
|
|
async function updateOverlapCache() {
|
|||
|
|
if (!currentImgId) { currentOverlaps = []; return; }
|
|||
|
|
|
|||
|
|
const connections = await getConnectedImages(currentImgId);
|
|||
|
|
|
|||
|
|
currentOverlaps = connections.map(conn => {
|
|||
|
|
// Use the clean helper method within ImageGraph
|
|||
|
|
const invMat = ImageGraph.invertH(conn.matrix);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
fromImageId: conn.sourceId,
|
|||
|
|
toImageId: currentImgId,
|
|||
|
|
homography: invMat,
|
|||
|
|
inverseHomography: conn.matrix,
|
|||
|
|
isValid: !!invMat
|
|||
|
|
};
|
|||
|
|
}).filter(o => o.isValid);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderPins() {
|
|||
|
|
document.querySelectorAll('.map-pin').forEach(p => p.remove());
|
|||
|
|
if (!currentImgId) return;
|
|||
|
|
|
|||
|
|
bomData.forEach(c => {
|
|||
|
|
let x = null, y = null;
|
|||
|
|
let isInferred = false;
|
|||
|
|
|
|||
|
|
// A: Defined on this image (Blue)
|
|||
|
|
if (c.imgId === currentImgId && c.x !== undefined) {
|
|||
|
|
x = c.x; y = c.y;
|
|||
|
|
}
|
|||
|
|
// B: Inferred from stitching (Green)
|
|||
|
|
else if (c.imgId && c.imgId !== currentImgId && c.x !== undefined) {
|
|||
|
|
const ov = currentOverlaps.find(o => o.fromImageId === c.imgId || o.toImageId === c.imgId);
|
|||
|
|
if (ov) {
|
|||
|
|
const isForward = (ov.fromImageId === c.imgId);
|
|||
|
|
const H = isForward ? ov.homography : ov.inverseHomography;
|
|||
|
|
const proj = cvManager.projectPoint(c.x, c.y, H);
|
|||
|
|
if (proj && proj.x > 0 && proj.y > 0) {
|
|||
|
|
x = proj.x; y = proj.y; isInferred = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (x !== null && y !== null) {
|
|||
|
|
const pin = document.createElement('div');
|
|||
|
|
pin.className = 'map-pin';
|
|||
|
|
pin.style.left = x + 'px';
|
|||
|
|
pin.style.top = y + 'px';
|
|||
|
|
|
|||
|
|
const color = isInferred ? '#16a34a' : 'var(--primary)';
|
|||
|
|
const zIndex = isInferred ? 40 : 50;
|
|||
|
|
|
|||
|
|
// Both Green and Blue pins now open the Editor
|
|||
|
|
const clickAction = `editCompFromMap('${c.id}', event)`;
|
|||
|
|
|
|||
|
|
pin.innerHTML = `<div class="pin-marker" style="background:${color}; z-index:${zIndex};" onclick="${clickAction}; event.stopPropagation();"><span>${c.label}</span></div>`;
|
|||
|
|
document.getElementById('map-content').appendChild(pin);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
function updateTransform() { document.getElementById('map-content').style.transform = `translate(${mapState.x}px, ${mapState.y}px) scale(${mapState.scale})`; }
|
|||
|
|
const viewport = document.getElementById('map-viewport');
|
|||
|
|
viewport.addEventListener('wheel', e => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const rect = viewport.getBoundingClientRect();
|
|||
|
|
const mx = e.clientX - rect.left; const my = e.clientY - rect.top;
|
|||
|
|
const ix = (mx - mapState.x) / mapState.scale; const iy = (my - mapState.y) / mapState.scale;
|
|||
|
|
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
|||
|
|
const newScale = Math.max(0.1, Math.min(10, mapState.scale + delta));
|
|||
|
|
mapState.x = mx - (ix * newScale); mapState.y = my - (iy * newScale); mapState.scale = newScale;
|
|||
|
|
updateTransform();
|
|||
|
|
});
|
|||
|
|
/* --- POINTER EVENTS (Mobile + Desktop) --- */
|
|||
|
|
|
|||
|
|
let evCache = [];
|
|||
|
|
let prevDiff = -1;
|
|||
|
|
|
|||
|
|
function pointerDownHandler(e) {
|
|||
|
|
if(e.target.closest('.pin-marker')) return;
|
|||
|
|
|
|||
|
|
evCache.push(e);
|
|||
|
|
|
|||
|
|
// 1. Single Pointer (Mouse or One Finger) -> Start Drag
|
|||
|
|
if (evCache.length === 1) {
|
|||
|
|
mapState.isDragging = true;
|
|||
|
|
mapState.startX = e.clientX - mapState.x;
|
|||
|
|
mapState.startY = e.clientY - mapState.y;
|
|||
|
|
mapState.rawStartX = e.clientX;
|
|||
|
|
mapState.rawStartY = e.clientY;
|
|||
|
|
|
|||
|
|
// Listen globally while dragging (Fixes "fast drag" issue on Desktop)
|
|||
|
|
window.addEventListener('pointermove', pointerMoveHandler);
|
|||
|
|
window.addEventListener('pointerup', pointerUpHandler);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. Two Pointers (Pinch) -> Switch to Zoom
|
|||
|
|
else if (evCache.length === 2) {
|
|||
|
|
mapState.isDragging = false; // Disable pan during pinch
|
|||
|
|
const dx = evCache[0].clientX - evCache[1].clientX;
|
|||
|
|
const dy = evCache[0].clientY - evCache[1].clientY;
|
|||
|
|
prevDiff = Math.hypot(dx, dy);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function pointerMoveHandler(e) {
|
|||
|
|
// Update event in cache (for Pinch calc)
|
|||
|
|
const index = evCache.findIndex(cached => cached.pointerId === e.pointerId);
|
|||
|
|
if(index > -1) evCache[index] = e;
|
|||
|
|
|
|||
|
|
// A. PINCH ZOOM (Mobile)
|
|||
|
|
if (evCache.length === 2) {
|
|||
|
|
const dx = evCache[0].clientX - evCache[1].clientX;
|
|||
|
|
const dy = evCache[0].clientY - evCache[1].clientY;
|
|||
|
|
const curDiff = Math.hypot(dx, dy);
|
|||
|
|
|
|||
|
|
if (prevDiff > 0) {
|
|||
|
|
const diff = curDiff - prevDiff;
|
|||
|
|
const newScale = Math.max(0.1, Math.min(10, mapState.scale + (diff * 0.01)));
|
|||
|
|
|
|||
|
|
// Zoom towards center
|
|||
|
|
const rect = viewport.getBoundingClientRect();
|
|||
|
|
const cx = rect.width / 2;
|
|||
|
|
const cy = rect.height / 2;
|
|||
|
|
const ratio = newScale / mapState.scale;
|
|||
|
|
|
|||
|
|
mapState.x = cx - (cx - mapState.x) * ratio;
|
|||
|
|
mapState.y = cy - (cy - mapState.y) * ratio;
|
|||
|
|
mapState.scale = newScale;
|
|||
|
|
|
|||
|
|
updateTransform();
|
|||
|
|
}
|
|||
|
|
prevDiff = curDiff;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// B. PANNING (Desktop + Mobile)
|
|||
|
|
if(!mapState.isDragging) return;
|
|||
|
|
|
|||
|
|
// e.preventDefault() helps stop scrolling on mobile,
|
|||
|
|
// but we check pointerType to avoid selecting text issues on desktop
|
|||
|
|
if(e.pointerType === 'touch') e.preventDefault();
|
|||
|
|
|
|||
|
|
mapState.x = e.clientX - mapState.startX;
|
|||
|
|
mapState.y = e.clientY - mapState.startY;
|
|||
|
|
updateTransform();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function pointerUpHandler(e) {
|
|||
|
|
const index = evCache.findIndex(cached => cached.pointerId === e.pointerId);
|
|||
|
|
if(index > -1) evCache.splice(index, 1);
|
|||
|
|
|
|||
|
|
if (evCache.length < 2) prevDiff = -1;
|
|||
|
|
|
|||
|
|
if (evCache.length === 0) {
|
|||
|
|
mapState.isDragging = false;
|
|||
|
|
// Clean up global listeners
|
|||
|
|
window.removeEventListener('pointermove', pointerMoveHandler);
|
|||
|
|
window.removeEventListener('pointerup', pointerUpHandler);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Only 'pointerdown' needs to be on the viewport
|
|||
|
|
viewport.addEventListener('pointerdown', pointerDownHandler);
|
|||
|
|
|
|||
|
|
// Click Logic (Distinguishes Drag vs Click)
|
|||
|
|
viewport.addEventListener('click', e => {
|
|||
|
|
const dist = Math.hypot(e.clientX - mapState.rawStartX, e.clientY - mapState.rawStartY);
|
|||
|
|
if(dist > 10) return; // Ignore if dragged
|
|||
|
|
|
|||
|
|
if(mapState.isDragging || !currentImgId || e.target.closest('.pin-marker')) return;
|
|||
|
|
|
|||
|
|
const r = document.getElementById('map-content').getBoundingClientRect();
|
|||
|
|
const x = (e.clientX - r.left) / mapState.scale;
|
|||
|
|
const y = (e.clientY - r.top) / mapState.scale;
|
|||
|
|
|
|||
|
|
switchView('list');
|
|||
|
|
|
|||
|
|
// 1. Deselect any active row in the table (Visual cleanup)
|
|||
|
|
document.querySelectorAll('tbody tr').forEach(row => row.classList.remove('editing'));
|
|||
|
|
|
|||
|
|
// 2. Create a temporary "New Component" object
|
|||
|
|
const tempComp = {
|
|||
|
|
label: '',
|
|||
|
|
value: '',
|
|||
|
|
desc: '',
|
|||
|
|
imgId: currentImgId,
|
|||
|
|
x: Math.round(x),
|
|||
|
|
y: Math.round(y)
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 3. Use the standard function to setup UI (Inputs + Spyglass + THUMBNAILS)
|
|||
|
|
fillFormFromData(tempComp);
|
|||
|
|
|
|||
|
|
// 4. Focus for immediate typing
|
|||
|
|
document.getElementById('inp-label').focus();
|
|||
|
|
returnToMap = true;
|
|||
|
|
});
|
|||
|
|
async function editCompFromMap(id, e) {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
const comp = bomData.find(c => c.id === id);
|
|||
|
|
if(!comp) return;
|
|||
|
|
fillFormFromData(comp);
|
|||
|
|
returnToMap = true;
|
|||
|
|
switchView('list');
|
|||
|
|
|
|||
|
|
// [NEW] Synchronize List Selection
|
|||
|
|
setTimeout(() => {
|
|||
|
|
const row = document.querySelector(`#bom-body tr[data-id="${id}"]`);
|
|||
|
|
if(row) {
|
|||
|
|
// Clear existing
|
|||
|
|
document.querySelectorAll('tbody tr').forEach(x => x.classList.remove('editing'));
|
|||
|
|
// Highlight new
|
|||
|
|
row.classList.add('editing');
|
|||
|
|
// Scroll into view
|
|||
|
|
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|||
|
|
}
|
|||
|
|
}, 50); // Small delay to ensure view transition allows scrolling
|
|||
|
|
}
|
|||
|
|
async function fillFormFromData(c) {
|
|||
|
|
// Reset state: Default view is always Main
|
|||
|
|
isMainViewActive = true;
|
|||
|
|
|
|||
|
|
// 1. Fill Text Fields
|
|||
|
|
document.getElementById('inp-id').value = c.id || '';
|
|||
|
|
document.getElementById('inp-label').value = c.label;
|
|||
|
|
document.getElementById('inp-value').value = c.value;
|
|||
|
|
document.getElementById('inp-desc').value = c.desc;
|
|||
|
|
document.getElementById('inp-x').value = (c.x !== undefined) ? c.x : '';
|
|||
|
|
document.getElementById('inp-y').value = (c.y !== undefined) ? c.y : '';
|
|||
|
|
document.getElementById('inp-img-id').value = c.imgId || '';
|
|||
|
|
|
|||
|
|
checkToolHint(c.label);
|
|||
|
|
|
|||
|
|
// 2. Clear previous thumbnails
|
|||
|
|
const thumbBox = document.getElementById('inline-thumbs');
|
|||
|
|
if(thumbBox) thumbBox.innerHTML = '';
|
|||
|
|
|
|||
|
|
// --- HELPER: Set Spyglass Target ---
|
|||
|
|
const setMainView = (imgRecOrSource, x, y) => {
|
|||
|
|
if(!spyglass) return;
|
|||
|
|
if(imgRecOrSource instanceof HTMLElement || imgRecOrSource instanceof ImageBitmap) {
|
|||
|
|
spyglass.setTarget(imgRecOrSource, x, y, false);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if(imgRecOrSource && imgRecOrSource.blob) {
|
|||
|
|
createImageBitmap(imgRecOrSource.blob).then(bmp => {
|
|||
|
|
spyglass.setTarget(bmp, x, y, true);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// --- HELPER: Create Thumbnail ---
|
|||
|
|
const createThumb = (imgRec, tx, ty, isMain) => {
|
|||
|
|
const thumb = document.createElement('div');
|
|||
|
|
thumb.style.cssText = "width:60px; height:60px; border:1px solid #cbd5e1; flex-shrink:0; cursor:pointer; position:relative; background:#eee; display:flex; align-items:center; justify-content:center;";
|
|||
|
|
if(isMain) thumb.style.borderColor = "#2563eb";
|
|||
|
|
|
|||
|
|
// Loader placeholder
|
|||
|
|
thumb.innerHTML = '<span style="font-size:10px; color:#999;">...</span>';
|
|||
|
|
thumbBox.appendChild(thumb);
|
|||
|
|
|
|||
|
|
(async () => {
|
|||
|
|
try {
|
|||
|
|
const bmp = await createImageBitmap(imgRec.blob);
|
|||
|
|
|
|||
|
|
// 1. Clear loader (and anything else)
|
|||
|
|
thumb.innerHTML = '';
|
|||
|
|
|
|||
|
|
// 2. Add Canvas
|
|||
|
|
const cvs = document.createElement('canvas');
|
|||
|
|
cvs.width = 60; cvs.height = 60;
|
|||
|
|
const ctx = cvs.getContext('2d');
|
|||
|
|
ctx.drawImage(bmp, tx-50, ty-50, 100, 100, 0, 0, 60, 60);
|
|||
|
|
thumb.appendChild(cvs);
|
|||
|
|
thumb.title = isMain ? "Main View" : `View on ${imgRec.name}`;
|
|||
|
|
|
|||
|
|
bmp.close();
|
|||
|
|
|
|||
|
|
// 3. Add Locate Button 🎯 (Now safe from being cleared)
|
|||
|
|
const locBtn = document.createElement('button');
|
|||
|
|
locBtn.className = 'thumb-locate-btn';
|
|||
|
|
locBtn.innerHTML = '🎯';
|
|||
|
|
locBtn.title = "Locate on Map";
|
|||
|
|
locBtn.onclick = (e) => { e.stopPropagation(); locateComponent(imgRec.id, tx, ty); };
|
|||
|
|
thumb.appendChild(locBtn);
|
|||
|
|
|
|||
|
|
// 4. Add Promote Button ★
|
|||
|
|
if (!isMain) {
|
|||
|
|
const btn = document.createElement('button');
|
|||
|
|
btn.className = 'thumb-promote-btn';
|
|||
|
|
btn.innerHTML = '★';
|
|||
|
|
btn.title = "Set as Main View";
|
|||
|
|
btn.onclick = async (e) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
|
|||
|
|
// Capture form state
|
|||
|
|
c.id = document.getElementById('inp-id').value;
|
|||
|
|
c.label = document.getElementById('inp-label').value;
|
|||
|
|
c.value = document.getElementById('inp-value').value;
|
|||
|
|
c.desc = document.getElementById('inp-desc').value;
|
|||
|
|
|
|||
|
|
// Update component location
|
|||
|
|
c.imgId = imgRec.id; c.x = tx; c.y = ty;
|
|||
|
|
|
|||
|
|
// Update DB if exists
|
|||
|
|
if(c.id) { await db.addComponent(c); await loadProjectData(); }
|
|||
|
|
|
|||
|
|
// Refresh UI
|
|||
|
|
fillFormFromData(c);
|
|||
|
|
};
|
|||
|
|
thumb.appendChild(btn);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Thumb Click Action
|
|||
|
|
thumb.onclick = () => {
|
|||
|
|
Array.from(thumbBox.children).forEach(t => t.style.borderColor = '#cbd5e1');
|
|||
|
|
thumb.style.borderColor = "#2563eb";
|
|||
|
|
isMainViewActive = isMain;
|
|||
|
|
setMainView(imgRec, tx, ty);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
} catch(e) {
|
|||
|
|
console.error("Thumb error", e);
|
|||
|
|
thumb.innerHTML = '<span style="color:red">x</span>';
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 3. Calculate Views
|
|||
|
|
const viewsToRender = [];
|
|||
|
|
let mainImgRec = null;
|
|||
|
|
if((c.x !== undefined) && (c.y !== undefined) && c.imgId) {
|
|||
|
|
mainImgRec = bomImages.find(i => i.id === c.imgId);
|
|||
|
|
if(mainImgRec) {
|
|||
|
|
viewsToRender.push({ rec: mainImgRec, x: c.x, y: c.y, isMain: true });
|
|||
|
|
|
|||
|
|
// Initial Load: Check if Map is already showing this
|
|||
|
|
const mapImg = document.getElementById('map-content') ? document.getElementById('map-content').querySelector('.pcb-image') : null;
|
|||
|
|
if(mapImg && mapImg.dataset.id === c.imgId && mapImg.complete && mapImg.naturalWidth > 0 && spyglass) {
|
|||
|
|
spyglass.setTarget(mapImg, c.x, c.y, false);
|
|||
|
|
} else {
|
|||
|
|
setMainView(mainImgRec, c.x, c.y);
|
|||
|
|
}
|
|||
|
|
} else if (spyglass) { spyglass.clear(); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (cvManager && db && c.imgId) {
|
|||
|
|
const paths = await ImageGraph.solvePaths(c.imgId, cvManager, db);
|
|||
|
|
|
|||
|
|
for (const pData of paths) {
|
|||
|
|
const H_new = pData.H;
|
|||
|
|
const pt = cvManager.projectPoint(c.x, c.y, H_new);
|
|||
|
|
|
|||
|
|
if (pt && pt.x > 0 && pt.y > 0) {
|
|||
|
|
const targetRec = bomImages.find(i => i.id === pData.id);
|
|||
|
|
if (targetRec) {
|
|||
|
|
// Check bounds if bitmap is available, or trust projectPoint roughly
|
|||
|
|
// Usually we need the bitmap dimensions to be 100% sure,
|
|||
|
|
// but rendering it via createThumb handles out-of-bounds gracefully (crops/blank)
|
|||
|
|
viewsToRender.push({ rec: targetRec, x: pt.x, y: pt.y, isMain: false });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. Render All
|
|||
|
|
viewsToRender.forEach(v => createThumb(v.rec, v.x, v.y, v.isMain));
|
|||
|
|
}
|
|||
|
|
function copyPartToForm(idx) { const c = bomData[idx]; document.getElementById('inp-value').value = c.value; document.getElementById('inp-desc').value = c.desc; }
|
|||
|
|
|
|||
|
|
// --- CONNECTIONS UI ---
|
|||
|
|
// Delete connection function
|
|||
|
|
async function deleteConnection(targetId) {
|
|||
|
|
if(!currentImgId || !targetId) return;
|
|||
|
|
|
|||
|
|
const targetImg = bomImages.find(i => i.id === targetId);
|
|||
|
|
const name = targetImg ? targetImg.name : "this image";
|
|||
|
|
|
|||
|
|
if(await confirmAction(`Remove stitching connection with ${name}?`, "Remove Stitch")) {
|
|||
|
|
await db.deleteOverlapsForPair(currentImgId, targetId);
|
|||
|
|
await renderConnectionsList();
|
|||
|
|
await updateOverlapCache();
|
|||
|
|
renderPins();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Note: Ensure this function is called by openConnectionsModal() in your existing code
|
|||
|
|
async function renderConnectionsList() {
|
|||
|
|
if(!currentImgId) return;
|
|||
|
|
const curImg = bomImages.find(i => i.id === currentImgId);
|
|||
|
|
if(!curImg) return;
|
|||
|
|
|
|||
|
|
const title = document.getElementById('conn-src-name');
|
|||
|
|
if(title) title.innerText = curImg.name;
|
|||
|
|
|
|||
|
|
const list = document.getElementById('conn-list');
|
|||
|
|
if(!list) return;
|
|||
|
|
|
|||
|
|
list.innerHTML = '<div style="text-align:center;color:#888">Loading...</div>';
|
|||
|
|
|
|||
|
|
const overlaps = await db.getOverlapsForImage(currentImgId);
|
|||
|
|
const others = bomImages.filter(i => i.id !== currentImgId);
|
|||
|
|
|
|||
|
|
list.innerHTML = '';
|
|||
|
|
if(others.length === 0) {
|
|||
|
|
list.innerHTML = '<div style="padding:1rem; text-align:center; background:#eee; border-radius:4px;">No other images to stitch with.</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
others.forEach(img => {
|
|||
|
|
const ov = overlaps.find(o => o.fromImageId === img.id || o.toImageId === img.id);
|
|||
|
|
const row = document.createElement('div');
|
|||
|
|
row.style.cssText = "display:flex; justify-content:space-between; align-items:center; padding:0.5rem; border:1px solid #e2e8f0; border-radius:0.3rem; background:white;";
|
|||
|
|
let statusBadge = `<span style="font-size:0.75rem; background:#f1f5f9; color:#94a3b8; padding:2px 6px; border-radius:4px;">Not Connected</span>`;
|
|||
|
|
let actions = `<button class="secondary sm-btn" onclick="launchStitch('${img.id}')">Edit Stitch</button>`;
|
|||
|
|
|
|||
|
|
if(ov) {
|
|||
|
|
if(ov.isManual) statusBadge = `<span style="font-size:0.75rem; background:#dcfce7; color:#166534; padding:2px 6px; border-radius:4px;">Manual (${ov.matchCount} pts)</span>`;
|
|||
|
|
else statusBadge = `<span style="font-size:0.75rem; background:#e0f2fe; color:#0369a1; padding:2px 6px; border-radius:4px;">Auto-CV</span>`;
|
|||
|
|
|
|||
|
|
// Show Delete + Edit buttons
|
|||
|
|
actions = `<div style="display:flex; gap:5px;">
|
|||
|
|
<button class="danger sm-btn" style="padding:0 8px;" onclick="deleteConnection('${img.id}')" title="Remove Connection">🗑️</button>
|
|||
|
|
<button class="secondary sm-btn" onclick="launchStitch('${img.id}')">Edit Stitch</button>
|
|||
|
|
</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
row.innerHTML = `<div><div style="font-weight:600; font-size:0.9rem;">${img.name}</div>${statusBadge}</div>${actions}`;
|
|||
|
|
list.appendChild(row);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Uses the helper above
|
|||
|
|
async function openConnectionsModal() {
|
|||
|
|
if(!currentImgId) return alert("Select an image first.");
|
|||
|
|
document.getElementById('connections-modal').style.display = 'flex';
|
|||
|
|
await renderConnectionsList();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function launchStitch(targetId) {
|
|||
|
|
document.getElementById('connections-modal').style.display = 'none';
|
|||
|
|
if(stitchEditor) stitchEditor.open(currentImgId, targetId);
|
|||
|
|
else alert("Editor not initialized.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function runAutoCVBatch() {
|
|||
|
|
if(!cvManager || !cvManager.ready) { cvManager.init(); return alert("CV initializing... wait."); }
|
|||
|
|
if(!await confirmAction("Run Auto-CV on all unmatched images?", "Run Auto-CV")) return;
|
|||
|
|
|
|||
|
|
// Capture ID locally to prevent errors if user switches image during processing
|
|||
|
|
const sourceId = currentImgId;
|
|||
|
|
if(!sourceId) return;
|
|||
|
|
|
|||
|
|
document.getElementById('conn-list').innerHTML = '<div style="text-align:center; padding:2rem;">Processing...</div>';
|
|||
|
|
|
|||
|
|
// Only filter out self; bomImages is already scoped to project
|
|||
|
|
const others = bomImages.filter(i => i.id !== sourceId);
|
|||
|
|
const sourceBlob = bomImages.find(i => i.id === sourceId).blob;
|
|||
|
|
|
|||
|
|
for(const other of others) {
|
|||
|
|
const existing = await db.getOverlapsForPair(sourceId, other.id);
|
|||
|
|
if(existing && existing.isManual) continue;
|
|||
|
|
|
|||
|
|
const blob1 = sourceBlob;
|
|||
|
|
const blob2 = other.blob;
|
|||
|
|
|
|||
|
|
const f1 = await cvManager.feats(blob1);
|
|||
|
|
const f2 = await cvManager.feats(blob2);
|
|||
|
|
|
|||
|
|
if(f1 && f2) {
|
|||
|
|
const res = cvManager.findH(f1, f2);
|
|||
|
|
if(res) {
|
|||
|
|
await db.deleteOverlapsForPair(sourceId, other.id);
|
|||
|
|
await db.addOverlap({
|
|||
|
|
id: uuid(),
|
|||
|
|
fromImageId: sourceId,
|
|||
|
|
toImageId: other.id,
|
|||
|
|
homography: res.hData,
|
|||
|
|
inverseHomography: res.invHData,
|
|||
|
|
matchCount: res.matches,
|
|||
|
|
isManual: false
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if(f1) { f1.kp.delete(); f1.des.delete(); }
|
|||
|
|
if(f2) { f2.kp.delete(); f2.des.delete(); }
|
|||
|
|
}
|
|||
|
|
await renderConnectionsList();
|
|||
|
|
updateOverlapCache().then(renderPins);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UI Helper: Shows a global blocking overlay with a message
|
|||
|
|
function showBusy(msg) {
|
|||
|
|
let el = document.getElementById('busy-indicator');
|
|||
|
|
if(!el) {
|
|||
|
|
el = document.createElement('div');
|
|||
|
|
el.id = 'busy-indicator';
|
|||
|
|
el.style.cssText = "position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); background:rgba(0,0,0,0.8); color:white; padding:20px; border-radius:8px; z-index:9999; font-weight:bold; display:flex; align-items:center; gap:10px; box-shadow: 0 4px 15px rgba(0,0,0,0.3);";
|
|||
|
|
document.body.appendChild(el);
|
|||
|
|
}
|
|||
|
|
el.innerHTML = `<span style="animation:spin 1s linear infinite; display:inline-block">⏳</span> ${msg}`;
|
|||
|
|
el.style.display = 'flex';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hideBusy() {
|
|||
|
|
const el = document.getElementById('busy-indicator');
|
|||
|
|
if(el) el.style.display = 'none';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Logic: Auto-stitch specific image against all others on the board
|
|||
|
|
async function autoStitchNewImage(newId) {
|
|||
|
|
if(!cvManager) cvManager = new CVManager();
|
|||
|
|
if(!cvManager.ready) {
|
|||
|
|
showBusy("Initializing Computer Vision...");
|
|||
|
|
await cvManager.init();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Find the newly added image object
|
|||
|
|
const newImg = bomImages.find(i => i.id === newId);
|
|||
|
|
if(!newImg) return;
|
|||
|
|
|
|||
|
|
// Filter existing images (exclude the new one)
|
|||
|
|
const others = bomImages.filter(i => i.id !== newId);
|
|||
|
|
if(others.length === 0) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
showBusy("Extracting features from new image...");
|
|||
|
|
|
|||
|
|
// 1. Compute features for the NEW image ONCE
|
|||
|
|
const fNew = await cvManager.feats(newImg.blob);
|
|||
|
|
if(!fNew) { hideBusy(); return; }
|
|||
|
|
|
|||
|
|
let matchesFound = 0;
|
|||
|
|
|
|||
|
|
// 2. Compare against all others
|
|||
|
|
for(let i=0; i<others.length; i++) {
|
|||
|
|
const other = others[i];
|
|||
|
|
showBusy(`Stitching vs ${other.name}... (${i+1}/${others.length})`);
|
|||
|
|
|
|||
|
|
// Tiny pause to let UI render the text update
|
|||
|
|
await new Promise(r => setTimeout(r, 10));
|
|||
|
|
|
|||
|
|
const fOther = await cvManager.feats(other.blob);
|
|||
|
|
if(fOther) {
|
|||
|
|
const res = cvManager.findH(fNew, fOther);
|
|||
|
|
if(res) {
|
|||
|
|
await db.addOverlap({
|
|||
|
|
id: uuid(),
|
|||
|
|
fromImageId: newId,
|
|||
|
|
toImageId: other.id,
|
|||
|
|
homography: res.hData,
|
|||
|
|
inverseHomography: res.invHData,
|
|||
|
|
matchCount: res.matches,
|
|||
|
|
isManual: false
|
|||
|
|
});
|
|||
|
|
matchesFound++;
|
|||
|
|
}
|
|||
|
|
// Free memory for the 'other' image immediately
|
|||
|
|
if(fOther.kp) fOther.kp.delete();
|
|||
|
|
if(fOther.des) fOther.des.delete();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Free memory for the 'new' image
|
|||
|
|
if(fNew.kp) fNew.kp.delete();
|
|||
|
|
if(fNew.des) fNew.des.delete();
|
|||
|
|
|
|||
|
|
} catch(e) {
|
|||
|
|
console.error("Auto-stitch failed", e);
|
|||
|
|
} finally {
|
|||
|
|
hideBusy();
|
|||
|
|
// Refresh overlaps if we successfully matched anything
|
|||
|
|
if(await db.getOverlapsForImage(newId).length > 0) {
|
|||
|
|
await updateOverlapCache();
|
|||
|
|
renderPins();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const ImageGraph = {
|
|||
|
|
// Helper: Invert 3x3 Matrix
|
|||
|
|
invertH(m) {
|
|||
|
|
if (!m || m.length !== 9) return null;
|
|||
|
|
const det = m[0] * (m[4] * m[8] - m[7] * m[5]) -
|
|||
|
|
m[1] * (m[3] * m[8] - m[5] * m[6]) +
|
|||
|
|
m[2] * (m[3] * m[7] - m[4] * m[6]);
|
|||
|
|
if (Math.abs(det) < 1e-10) return null;
|
|||
|
|
const invDet = 1 / det;
|
|||
|
|
return [
|
|||
|
|
(m[4] * m[8] - m[5] * m[7]) * invDet,
|
|||
|
|
(m[2] * m[7] - m[1] * m[8]) * invDet,
|
|||
|
|
(m[1] * m[5] - m[2] * m[4]) * invDet,
|
|||
|
|
(m[5] * m[6] - m[3] * m[8]) * invDet,
|
|||
|
|
(m[0] * m[8] - m[2] * m[6]) * invDet,
|
|||
|
|
(m[2] * m[3] - m[0] * m[5]) * invDet,
|
|||
|
|
(m[3] * m[7] - m[4] * m[6]) * invDet,
|
|||
|
|
(m[1] * m[6] - m[0] * m[7]) * invDet,
|
|||
|
|
(m[0] * m[4] - m[1] * m[3]) * invDet
|
|||
|
|
];
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// Returns array of { id, H, totalCost } for all reachable nodes
|
|||
|
|
async solvePaths(startImgId, cvMgr, db) {
|
|||
|
|
const allOverlaps = await db._tx('overlappedImages', 'readonly', s => s.getAll());
|
|||
|
|
|
|||
|
|
// 1. Build Adjacency List
|
|||
|
|
const graph = {};
|
|||
|
|
allOverlaps.forEach(ov => {
|
|||
|
|
if(!graph[ov.fromImageId]) graph[ov.fromImageId] = [];
|
|||
|
|
// Forward: Source -> Target (Homography)
|
|||
|
|
graph[ov.fromImageId].push({ id: ov.toImageId, H: ov.homography, cost: ov.isManual ? 1 : 1000 });
|
|||
|
|
|
|||
|
|
if(!graph[ov.toImageId]) graph[ov.toImageId] = [];
|
|||
|
|
// Backward: Target -> Source (Inverse)
|
|||
|
|
graph[ov.toImageId].push({ id: ov.fromImageId, H: ov.inverseHomography, cost: ov.isManual ? 1 : 1000 });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 2. Dijkstra Search
|
|||
|
|
const results = [];
|
|||
|
|
const queue = [{ id: startImgId, H: [1,0,0, 0,1,0, 0,0,1], totalCost: 0 }];
|
|||
|
|
const visited = new Set();
|
|||
|
|
|
|||
|
|
while(queue.length > 0) {
|
|||
|
|
queue.sort((a, b) => a.totalCost - b.totalCost);
|
|||
|
|
const curr = queue.shift();
|
|||
|
|
|
|||
|
|
if(visited.has(curr.id)) continue;
|
|||
|
|
visited.add(curr.id);
|
|||
|
|
|
|||
|
|
// Add to results (excluding start node)
|
|||
|
|
if (curr.id !== startImgId) {
|
|||
|
|
results.push({ id: curr.id, H: curr.H, totalCost: curr.totalCost });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if(graph[curr.id]) {
|
|||
|
|
for(const edge of graph[curr.id]) {
|
|||
|
|
if(!visited.has(edge.id)) {
|
|||
|
|
const H_next = cvMgr.multiplyH(edge.H, curr.H);
|
|||
|
|
queue.push({
|
|||
|
|
id: edge.id,
|
|||
|
|
H: H_next,
|
|||
|
|
totalCost: curr.totalCost + edge.cost
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return results;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/* --- NAVIGATION & HISTORY MANAGER --- */
|
|||
|
|
const NavManager = {
|
|||
|
|
init() {
|
|||
|
|
window.addEventListener('popstate', (e) => this.handlePopState(e));
|
|||
|
|
|
|||
|
|
document.addEventListener('keydown', (e) => {
|
|||
|
|
if (e.key === 'Escape') {
|
|||
|
|
if (history.state && history.state.context) {
|
|||
|
|
history.back();
|
|||
|
|
} else {
|
|||
|
|
this.handleEscDirectly();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
window.addEventListener('message', (e) => {
|
|||
|
|
if (e.data.type === 'ESC_PRESSED') {
|
|||
|
|
const visible = Array.from(document.querySelectorAll('.modal-overlay')).some(m => m.style.display === 'flex');
|
|||
|
|
if(visible) history.back();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.querySelectorAll('.close-btn').forEach(btn => {
|
|||
|
|
btn.onclick = (e) => {
|
|||
|
|
e.preventDefault(); e.stopPropagation();
|
|||
|
|
history.back();
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const mm = document.getElementById('mobile-menu-modal');
|
|||
|
|
if(mm) {
|
|||
|
|
mm.onclick = (e) => { if(e.target === mm) history.back(); };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.applyPatches();
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
pushState(context, data = {}) {
|
|||
|
|
// Prevent duplicate pushes of the same context
|
|||
|
|
if (history.state && history.state.context === context) return;
|
|||
|
|
history.pushState({ context, ...data }, "", "");
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
handlePopState(e) {
|
|||
|
|
const state = e.state || {};
|
|||
|
|
const ctx = state.context;
|
|||
|
|
|
|||
|
|
// 1. Close Modals
|
|||
|
|
document.querySelectorAll('.modal-overlay').forEach(el => el.style.display = 'none');
|
|||
|
|
if(typeof ImageImporter !== 'undefined') ImageImporter.close(true);
|
|||
|
|
if(typeof iframe !== 'undefined') { iframe.style.display = 'none'; iframe.src = 'about:blank'; }
|
|||
|
|
|
|||
|
|
// 2. Restore View
|
|||
|
|
if (ctx === 'map') {
|
|||
|
|
switchView('map', true); // Pass true to skip pushState
|
|||
|
|
} else if (ctx === 'nets') {
|
|||
|
|
switchView('nets', true);
|
|||
|
|
} else if (ctx === 'inspect') {
|
|||
|
|
switchView('inspect', true);
|
|||
|
|
} else {
|
|||
|
|
// Default to List
|
|||
|
|
switchView('list', true);
|
|||
|
|
|
|||
|
|
// Restore Tool if needed
|
|||
|
|
if (ctx === 'tool') this.restoreTool();
|
|||
|
|
|
|||
|
|
// Restore Modals
|
|||
|
|
if (ctx && ctx.endsWith('-modal')) {
|
|||
|
|
const el = document.getElementById(ctx);
|
|||
|
|
if(el) el.style.display = 'flex';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
handleEscDirectly() {
|
|||
|
|
const visibleModal = Array.from(document.querySelectorAll('.modal-overlay')).reverse().find(m => m.style.display === 'flex');
|
|||
|
|
if (visibleModal) {
|
|||
|
|
visibleModal.style.display = 'none';
|
|||
|
|
} else {
|
|||
|
|
// If on any tab other than list, go back to list
|
|||
|
|
const activeView = document.querySelector('.view-section.active');
|
|||
|
|
if (activeView && activeView.id !== 'view-list') {
|
|||
|
|
switchView('list');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
restoreTool() {
|
|||
|
|
if (iframe && iframe.src) modal.style.display = 'flex';
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
applyPatches() {
|
|||
|
|
const originalSwitchView = switchView;
|
|||
|
|
// Wrap switchView to handle history pushing automatically
|
|||
|
|
switchView = function(v, fromHistory = false) {
|
|||
|
|
originalSwitchView(v);
|
|||
|
|
if (!fromHistory) {
|
|||
|
|
NavManager.pushState(v);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const originalOpenToolD = openToolD;
|
|||
|
|
openToolD = async function(idx, url, tit) {
|
|||
|
|
await originalOpenToolD(idx, url, tit);
|
|||
|
|
NavManager.pushState('tool');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const originalCloseTool = closeTool;
|
|||
|
|
closeTool = function() {
|
|||
|
|
if(history.state && history.state.context === 'tool') history.back();
|
|||
|
|
else originalCloseTool();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const wrapModalOpener = (fnName, modalId) => {
|
|||
|
|
if (typeof window[fnName] === 'function') {
|
|||
|
|
const original = window[fnName];
|
|||
|
|
window[fnName] = function(...args) {
|
|||
|
|
original.apply(this, args);
|
|||
|
|
NavManager.pushState(modalId);
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
wrapModalOpener('openBoardSettings', 'board-settings-modal');
|
|||
|
|
wrapModalOpener('openDeviceSettings', 'device-settings-modal');
|
|||
|
|
wrapModalOpener('openConnectionsModal', 'connections-modal');
|
|||
|
|
wrapModalOpener('openImageSettings', 'image-settings-modal');
|
|||
|
|
wrapModalOpener('openMobileMenu', 'mobile-menu-modal');
|
|||
|
|
|
|||
|
|
if(typeof stitchEditor !== 'undefined' && stitchEditor) {
|
|||
|
|
const origStitchOpen = stitchEditor.open.bind(stitchEditor);
|
|||
|
|
stitchEditor.open = async function(src, dst) {
|
|||
|
|
await origStitchOpen(src, dst);
|
|||
|
|
NavManager.pushState('stitch-modal');
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if(typeof ImageImporter !== 'undefined') {
|
|||
|
|
const origImgOpen = ImageImporter.openModal.bind(ImageImporter);
|
|||
|
|
ImageImporter.openModal = function(mode) {
|
|||
|
|
origImgOpen(mode);
|
|||
|
|
if(!history.state || history.state.context !== 'import-modal') {
|
|||
|
|
NavManager.pushState('import-modal');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
ImageImporter.close = function(silent = false) {
|
|||
|
|
this.modal.style.display = 'none';
|
|||
|
|
this.stopStream();
|
|||
|
|
this.sourceImage = null;
|
|||
|
|
this.cropRect = null;
|
|||
|
|
if(!silent && history.state && history.state.context === 'import-modal') {
|
|||
|
|
history.back();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Start
|
|||
|
|
window.onload = init;
|
|||
|
|
|
|||
|
|
window.exportKiCad = () => {
|
|||
|
|
if (window.netManager) window.netManager.exportKiCad();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window.startNewNet = () => {
|
|||
|
|
if (window.inspector) window.inspector.startNewNet();
|
|||
|
|
};
|