pcb-retrace/docs/schema/app.js

504 lines
17 KiB
JavaScript

/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
// ── App entry point ───────────────────────────────────────────
import { autoImportDeviceLib } from './kicad.js';
import { S, NET_COLORS, initCanvas } from './state.js';
import { classifyComp } from './components.js';
import { buildAndRoute, initWireBender, fitView, routeWires } from './layout.js';
import { render } from './draw.js';
import { initInteraction } from './interaction.js';
import { pushHistory } from './history.js';
import { initUI, updateSidePanels, setStatus } from './ui.js';
import { initPostMessageListener, setIOCallbacks } from './io.js';
import { parseFromStudioDB, parseFileContent } from './parsers.js';
import { db, TYPE_KEY_TO_CT } from './db.js';
// ── Mode detection ────────────────────────────────────────────
// embedded: ?embed=true — running inside Studio iframe
// standalone: no params — direct URL, show full import UI
const _params = new URLSearchParams(window.location.search);
export const MODE = _params.get('embed') ? 'embedded' : 'standalone';
export const BOARD_ID = _params.get('boardId') || null;
export const DEV_ID = _params.get('deviceId') || null;
// ── Session state ─────────────────────────────────────────────
// Holds the currently active schema record and the map of
// componentId (Studio) → schemaComponent record.
let _schema = null; // schemas record
let _schemaCompMap = {}; // { [studioComponentId]: schemaComponent }
let _studioIdToS = {}; // { [studioComponentId]: S.components entry }
// Viewport save debounce timer
let _vpTimer = null;
let _pendingBoardId = null, _pendingDeviceId = null;
export function applyPinOverrides(comp) {
if (comp.type !== 'KICAD' || !comp.kicadData || !comp.kicadData.pins) return;
const kPins = comp.kicadData.pins;
comp.pins.forEach((p, i) => {
let targetKicadPinNum = kPins[i] ? kPins[i].num : null;
if (comp.overrides?.type === 'shift') {
const offset = comp.overrides.offset;
const targetIdx = (i + offset) % kPins.length;
const safeIdx = targetIdx < 0 ? targetIdx + kPins.length : targetIdx;
targetKicadPinNum = kPins[safeIdx] ? kPins[safeIdx].num : targetKicadPinNum;
} else if (comp.overrides?.type === 'remap') {
if (comp.overrides.map && comp.overrides.map[p.originalName]) {
targetKicadPinNum = comp.overrides.map[p.originalName];
}
}
if (targetKicadPinNum) p.name = targetKicadPinNum;
});
}
/**
* Load a board from IndexedDB.
*/
export async function loadBoard(boardId, deviceId) {
if (!boardId) {
console.warn('[schema] loadBoard called with no boardId');
S.hasData = false;
S.components = [];
S.nets = [];
S.wires = [];
S.junctionPoints = [];
updateSidePanels();
setStatus('No board selected', 'warn');
render();
return;
}
if (!db._db) {
_pendingBoardId = boardId;
_pendingDeviceId = deviceId;
//console.log('[schema] DB not ready, queuing loadBoard');
return;
}
setStatus('Loading…', '');
try {
const [studioComps, studioNets] = await Promise.all([
db.getComponentsByBoard(boardId),
db.getNetsByBoard(boardId),
]);
if (!studioComps.length && !studioNets.length) {
S.hasData = false;
S.components = [];
S.nets = [];
S.wires = [];
S.junctionPoints = [];
updateSidePanels();
render();
setStatus('No components or nets on this board yet', 'warn');
return;
}
const parsed = parseFromStudioDB(studioComps, studioNets);
// Get or create schema record
_schema = await db.getOrCreateSchema(deviceId || DEV_ID, boardId);
// Load saved layout
const savedScs = await db.getSchemaComponents(_schema.id);
_schemaCompMap = {};
savedScs.forEach(sc => { _schemaCompMap[sc.componentId] = sc; });
// Restore viewport
S.zoom = _schema.viewportZoom || 1;
S.viewX = _schema.viewportX || 0;
S.viewY = _schema.viewportY || 0;
// Ingest parsed netlist into S
await _ingestParsed(parsed, studioComps, boardId);
setStatus(
`Loaded ${S.components.length} components, ${S.nets.length} nets`,
'ok',
`${S.components.length}📦 ${S.nets.length}`
);
} catch (err) {
console.error('[app] loadBoard error:', err);
setStatus('Load error — see console', 'err');
}
}
export async function resetLayout() {
if (!_schema) return;
const scs = await db.getSchemaComponents(_schema.id);
for (const sc of scs) await db.deleteSchemaComponent(sc.id);
_schemaCompMap = {};
await buildAndRoute({});
}
export function hasLayoutData() {
return Object.keys(_schemaCompMap).length > 0;
}
/**
* Ingest a parsed netlist into S and run layout.
*
* Used by both loadBoard (DB-backed) and importNetlistStandalone (file).
* @param studioComps used to link S.components back to Studio IDs
*/
async function _ingestParsed(parsed, studioComps = [], boardId = null) {
pushHistory();
// Auto-add refs mentioned in nets but absent from components
const known = new Set(parsed.components.map(c => c.ref));
parsed.nets.forEach(net =>
(net.nodes || []).forEach(nd => {
if (nd.ref && !known.has(nd.ref)) {
parsed.components.push({ ref: nd.ref, value: '', type: classifyComp(nd.ref) });
known.add(nd.ref);
}
})
);
const studioIdByRef = {};
studioComps.forEach(c => { studioIdByRef[c.label] = c.id; });
// Create DB records for any refs mentioned in nets/components but missing from components table
for (const c of parsed.components) {
if (!studioIdByRef[c.ref]) {
const newId = await db.createMissingComponent(boardId, c.ref, c.value || '');
studioIdByRef[c.ref] = newId;
//console.log('[schema] created missing component in DB:', c.ref, newId);
}
}
// Deduplicate and merge nets by name to prevent multiple entries for the same net
const netRegistry = new Map();
parsed.nets.forEach(n => {
if (!netRegistry.has(n.name)) {
netRegistry.set(n.name, { name: n.name, nodes: [] });
}
const targetNet = netRegistry.get(n.name);
(n.nodes || []).forEach(newNode => {
// Only add node if it's not already registered in this net (ref + pin)
const isDuplicate = targetNet.nodes.some(en => en.ref === newNode.ref && en.pin === newNode.pin);
if (!isDuplicate) targetNet.nodes.push(newNode);
});
});
parsed.nets = Array.from(netRegistry.values());
// Build pin→net map
const pinMap = {}, compPins = {};
parsed.nets.forEach(net =>
(net.nodes || []).forEach(nd => {
if (!pinMap[nd.ref]) pinMap[nd.ref] = {};
if (!compPins[nd.ref]) compPins[nd.ref] = new Set();
pinMap[nd.ref][nd.pin] = net.name;
compPins[nd.ref].add(nd.pin);
})
);
// Prepare KiCad data mappings for previously replaced components
const kicadDataMap = {};
for (const comp of parsed.components) {
const studioId = studioIdByRef[comp.ref] || comp.studioId;
const sc = studioId ? _schemaCompMap[studioId] : null;
if (sc && sc.componentTypeId) {
const kSym = await db.getResolvedKicadDataForComponentType(sc.componentTypeId);
if (kSym && kSym.parsedData) {
kicadDataMap[comp.ref] = {
componentTypeId: sc.componentTypeId,
kicadData: { name: kSym.symbol, ...JSON.parse(kSym.parsedData) }
};
}
}
}
let seenRefs = new Set();
S.components = parsed.components.filter(c => c.ref).map((c, i) => {
const type = c.type || classifyComp(c.ref);
let cId = c.ref;
if (seenRefs.has(cId)) cId = `${c.ref}_${i}`;
seenRefs.add(cId);
const studioId = studioIdByRef[c.ref] || c.studioId || null;
if (!studioId) console.warn('[schema] no studioId for ref:', c.ref);
const saved = studioId ? _schemaCompMap[studioId] : null;
const pinSet = compPins[c.ref] || new Set(['1', '2']);
let pinNames = [...pinSet];
pinNames.sort((a, b) => {
const na = parseInt(a), nb = parseInt(b);
if (!isNaN(na) && !isNaN(nb)) return na - nb;
return a < b ? -1 : a > b ? 1 : 0;
});
const maxPin = Math.max(
...Array.from(pinSet)
.map(p => parseInt(p))
.filter(n => !isNaN(n))
);
// Ensure pinCount is at least the highest numerical pin ID,
// the total number of unique pins found, or the previously saved count.
const highestPinId = isFinite(maxPin) ? maxPin : 0;
let targetPinCount = Math.max(
saved?.pinCount || 0,
pinNames.length,
highestPinId
);
if (['R', 'C', 'L', 'D', 'Z'].includes(type) && targetPinCount < 2) {
targetPinCount = 2;
}
let nextPin = 1;
while (pinNames.length < targetPinCount) {
if (!pinNames.includes(String(nextPin))) pinNames.push(String(nextPin));
nextPin++;
}
// Re-sort after adding missing pins to ensure they map index-for-index chronologically
pinNames.sort((a, b) => {
const na = parseInt(a), nb = parseInt(b);
if (!isNaN(na) && !isNaN(nb)) return na - nb;
return a < b ? -1 : a > b ? 1 : 0;
});
const pins = pinNames.map(pn => ({
name: pn,
originalName: pn,
net: pinMap[c.ref]?.[pn] || null,
}));
const kd = kicadDataMap[c.ref];
const overrides = saved?.overrides ? JSON.parse(saved.overrides) : null;
return {
id: cId,
ref: c.ref,
value: c.value || '',
type: kd ? 'KICAD' : type,
componentTypeId: kd ? kd.componentTypeId : null,
kicadData: kd ? kd.kicadData : null,
overrides: overrides,
x: saved ? saved.x : 0,
y: saved ? saved.y : 0,
rotation: saved ? saved.rotation : 0,
flipX: saved ? saved.flipX : false,
pins,
studioId,
};
});
// Apply mappings before filtering un-netted components
S.components.forEach(comp => applyPinOverrides(comp));
// Don't show components without any connection
S.components = S.components.filter(c => c.pins.some(p => p.net !== null));
S.nets = parsed.nets.map((n, i) => ({
name: n.name,
nodes: n.nodes || [],
color: NET_COLORS[i % NET_COLORS.length],
isWip: (n.nodes || []).length <= 1,
}));
S.hasData = true;
S.selectedComp = null;
S.selectedNet = null;
S.wires = [];
S.junctionPoints = [];
// Build lockedPositions keyed by S.components id (not studioId)
// WireBender uses S.components[].id as componentId in addComponent()
const wbLocks = {};
S.components.forEach(comp => {
if (!comp.studioId) return;
const sc = _schemaCompMap[comp.studioId];
if (sc?.locked) wbLocks[comp.id] = {
position: {x: sc.x, y: sc.y},
transform: {rotation: sc.rotation, flipX: sc.flipX} };
});
// buildAndRoute returns placed positions for new (unlocked) components
const hasNewComps = S.components.some(comp =>
comp.studioId && !_schemaCompMap[comp.studioId]
);
const placed = await buildAndRoute(wbLocks, hasNewComps);
// Persist newly placed components that had no schemaComponent record yet
if (_schema) {
const newComps = S.components.filter(comp => {
if (!comp.studioId) return false;
return !_schemaCompMap[comp.studioId];
});
for (const comp of newComps) {
const pl = placed[comp.id] || { x: comp.x, y: comp.y };
await db.upsertSchemaComponent(_schema.id, comp.studioId, {
x: pl.position.x,
y: pl.position.y,
rotation: pl.transform.rotation,
flipX: pl.transform.flipX,
componentTypeId: TYPE_KEY_TO_CT[comp.type] || null,
pinCount: comp.pins.length,
});
// Mark as not locked — WireBender placed it, user hasn't touched it
const sc = await db._findSchemaComponent(_schema.id, comp.studioId);
if (sc) { sc.locked = false; await db.saveSchemaComponent(sc); }
}
}
updateSidePanels();
}
// ── Autosave: component layout after drag or rotation ─────────
// Called by interaction.js after every drag-drop and rotation.
// compId is S.components[].id (not studioId).
export async function saveComponentLayout(compId) {
if (!_schema) return;
const comp = S.components.find(c => c.id === compId);
// console.log('[schema] saveComponentLayout', compId,
// 'studioId:', comp?.studioId,
// 'ref:', comp?.ref,
// 'x:', comp?.x, 'y:', comp?.y);
if (!comp?.studioId) return;
await db.upsertSchemaComponent(_schema.id, comp.studioId, {
x: comp.x, y: comp.y, rotation: comp.rotation, flipX: comp.flipX,
componentTypeId: comp.componentTypeId || TYPE_KEY_TO_CT[comp.type] || null,
pinCount: comp.pins.length,
overrides: comp.overrides ? JSON.stringify(comp.overrides) : null
});
// Save all other components that don't have a record yet
// so the whole layout is preserved after the first user interaction
for (const c of S.components) {
if (!c.studioId || c.id === compId) continue;
const existing = await db._findSchemaComponent(_schema.id, c.studioId);
if (!existing) {
await db.upsertSchemaComponent(_schema.id, c.studioId, {
x: c.x, y: c.y, rotation: c.rotation, flipX: c.flipX,
componentTypeId: c.componentTypeId || TYPE_KEY_TO_CT[c.type] || null,
pinCount: c.pins.length,
overrides: c.overrides ? JSON.stringify(c.overrides) : null
});
}
}
// Refresh local cache
const savedScs = await db.getSchemaComponents(_schema.id);
_schemaCompMap = {};
savedScs.forEach(sc => { _schemaCompMap[sc.componentId] = sc; });
}
// ── Autosave: viewport after pan/zoom ─────────────────────────
// Debounced — writes at most once per 500ms.
export function saveViewport() {
if (!_schema) return;
clearTimeout(_vpTimer);
_vpTimer = setTimeout(async () => {
_schema.viewportZoom = S.zoom;
_schema.viewportX = S.viewX;
_schema.viewportY = S.viewY;
await db.saveSchema(_schema);
}, 500);
}
// ── Standalone: import a netlist file and create DB records ───
export async function importNetlistStandalone(text, filename) {
try {
const parsed = parseFileContent(text);
const name = (filename || 'Imported Netlist')
.replace(/\.[^.]+$/, ''); // strip extension
const { boardId, deviceId } = await db.importNetlist(name, parsed);
localStorage.setItem('pcb_device_id', deviceId);
localStorage.setItem('pcb_board_id', boardId);
// Reset schema-session state so loadBoard creates a fresh schema
_schema = null;
_schemaCompMap = {};
await loadBoard(boardId, deviceId);
setStatus(
`Imported "${name}" — ${S.components.length} components`,
'ok',
`Imported: ${S.components.length}📦`
);
} catch (err) {
console.error('[app] importNetlistStandalone error:', err);
setStatus('Import error: ' + err.message, 'err');
import('./ui.js').then(({ toast }) => toast('Import failed — see status bar', 'err'));
}
}
// ── Canvas resize handler ─────────────────────────────────────
function resizeCanvas() {
const wrap = document.getElementById('canvas-wrap');
const canvas = document.getElementById('sch-canvas');
if (!wrap || !canvas) return;
const w = wrap.clientWidth;
const h = wrap.clientHeight;
if (w > 0 && h > 0) {
canvas.width = w;
canvas.height = h;
if (S.hasData) fitView();
else render();
}
}
export function triggerResize(w, h) {
resizeCanvas();
}
// ── Boot ──────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
const canvas = document.getElementById('sch-canvas');
if (!canvas) { console.error('[schema] #sch-canvas not found'); return; }
initPostMessageListener();
setIOCallbacks(loadBoard);
initCanvas(canvas);
new ResizeObserver(entries => {
// console.log('[schema] ResizeObserver fired, wrap:',
// entries[0].contentRect.width, entries[0].contentRect.height);
resizeCanvas();
}).observe(document.getElementById('canvas-wrap'));
// Initialise DB (opens connection, runs upgrade if needed, seeds data)
try {
// console.log('[schema] opening DB…');
await db.init();
// console.log('[schema] DB ready');
// Background task: ensure standard KiCad library is available
autoImportDeviceLib();
if (_pendingBoardId) {
await loadBoard(_pendingBoardId, _pendingDeviceId);
_pendingBoardId = null;
_pendingDeviceId = null;
}
} catch (err) {
console.error('[app] DB init failed:', err);
setStatus('Database error — see console', 'err');
// Don't return — allow rendering even without DB
}
// Begin loading WASM in background
initWireBender();
initInteraction(saveComponentLayout, saveViewport);
initUI(MODE, importNetlistStandalone, resetLayout, hasLayoutData);
// console.log('[schema] init complete');
// Size the canvas now (synchronous) so the first render has real dimensions.
// The setTimeout fallback handles any post-layout resize.
resizeCanvas();
try {
if (MODE === 'standalone' && !window._embeddedMode && !S.hasData) {
setStatus('Import a netlist or switch to PCB ReTrace', 'warn');
render();
} else {
render();
}
} catch (err) {
console.error('[schema] post-init error:', err);
}
});