475 lines
18 KiB
JavaScript
475 lines
18 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.
|
||
*/
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// schema/db.js — Database layer for Schematic ReTrace
|
||
//
|
||
// Opens the shared PcbReTrace IndexedDB at version 2.
|
||
// Provides typed read/write methods for both existing Studio
|
||
// stores (read-only from Schema's perspective) and the new
|
||
// Schema ReTrace stores introduced in version 2.
|
||
//
|
||
// Coordinate convention for stored positions:
|
||
// x, y — world-space centre coordinates (pixels, floating)
|
||
// rotation — integer degrees (0, 90, 180, 270)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
const DB_NAME = window.PcbDbCore.DB_NAME;
|
||
const DB_VER = window.PcbDbCore.DB_VER;
|
||
|
||
// ── uuid helper (shared from db-core.js) ────────────
|
||
export const uuid = window.uuid;
|
||
|
||
// ── Seed data ─────────────────────────────────────────────────
|
||
// pinDirections rows — id values intentionally match WireBender PinDirection flags.
|
||
const SEED_PIN_DIRECTIONS = [
|
||
{ id: 0, name: 'None', description: 'No preferred direction', angle: null },
|
||
{ id: 1, name: 'Up', description: 'Wire exits upward', angle: 270 },
|
||
{ id: 2, name: 'Down', description: 'Wire exits downward', angle: 90 },
|
||
{ id: 4, name: 'Left', description: 'Wire exits to the left', angle: 180 },
|
||
{ id: 8, name: 'Right', description: 'Wire exits to the right', angle: 0 },
|
||
{ id: 15, name: 'All', description: 'Router chooses best direction', angle: null },
|
||
];
|
||
|
||
// symbolTypes — classification tree.
|
||
// IDs are stable UUIDs so they can be referenced as FKs from componentTypes.
|
||
const ST_PASSIVE = 'st-passive-0000-0000-000000000001';
|
||
const ST_SEMI = 'st-semi-00000-0000-0000-000000000002';
|
||
const ST_IC = 'st-ic-000000-0000-0000-000000000003';
|
||
const ST_RESISTOR = 'st-resistor-00-0000-0000-000000000004';
|
||
const ST_CAPACITOR = 'st-capacitor-0-0000-0000-000000000005';
|
||
const ST_INDUCTOR = 'st-inductor-00-0000-0000-000000000006';
|
||
const ST_DIODE = 'st-diode-0000-0000-0000-000000000007';
|
||
const ST_ZENER = 'st-zener-0000-0000-0000-000000000008';
|
||
const ST_GENERIC_IC = 'st-generic-ic-0-000-0000-000000000009';
|
||
|
||
const SEED_SYMBOL_TYPES = [
|
||
{ id: ST_PASSIVE, name: 'Passive', parentId: null },
|
||
{ id: ST_SEMI, name: 'Semiconductor', parentId: null },
|
||
{ id: ST_IC, name: 'IC', parentId: null },
|
||
{ id: ST_RESISTOR, name: 'Resistor', parentId: ST_PASSIVE },
|
||
{ id: ST_CAPACITOR, name: 'Capacitor', parentId: ST_PASSIVE },
|
||
{ id: ST_INDUCTOR, name: 'Inductor', parentId: ST_PASSIVE },
|
||
{ id: ST_DIODE, name: 'Diode', parentId: ST_SEMI },
|
||
{ id: ST_ZENER, name: 'Zener Diode', parentId: ST_DIODE },
|
||
{ id: ST_GENERIC_IC, name: 'Generic IC', parentId: ST_IC },
|
||
];
|
||
|
||
// componentTypes — built-in types matching components.js classes.
|
||
// Stable IDs so schemaComponents can reference them reliably.
|
||
export const CT_RESISTOR = 'ct-resistor-000-0000-0000-000000000001';
|
||
export const CT_CAPACITOR = 'ct-capacitor-0-0000-0000-000000000002';
|
||
export const CT_INDUCTOR = 'ct-inductor-00-0000-0000-000000000003';
|
||
export const CT_DIODE = 'ct-diode-0000-0000-0000-000000000004';
|
||
export const CT_ZENER = 'ct-zener-0000-0000-0000-000000000005';
|
||
export const CT_GENERIC_IC = 'ct-generic-ic-0-000-0000-000000000006';
|
||
|
||
// Maps the type key used in S.components[].type → componentType id
|
||
export const TYPE_KEY_TO_CT = {
|
||
R: CT_RESISTOR,
|
||
C: CT_CAPACITOR,
|
||
L: CT_INDUCTOR,
|
||
D: CT_DIODE,
|
||
Z: CT_ZENER,
|
||
IC: CT_GENERIC_IC,
|
||
};
|
||
|
||
const NOW = () => Date.now();
|
||
|
||
const SEED_COMPONENT_TYPES = [
|
||
{ id: CT_RESISTOR, symbolTypeId: ST_RESISTOR, name: 'Resistor', pinCount: 2, created: 0, lastModified: 0 },
|
||
{ id: CT_CAPACITOR, symbolTypeId: ST_CAPACITOR, name: 'Capacitor', pinCount: 2, created: 0, lastModified: 0 },
|
||
{ id: CT_INDUCTOR, symbolTypeId: ST_INDUCTOR, name: 'Inductor', pinCount: 2, created: 0, lastModified: 0 },
|
||
{ id: CT_DIODE, symbolTypeId: ST_DIODE, name: 'Diode', pinCount: 2, created: 0, lastModified: 0 },
|
||
{ id: CT_ZENER, symbolTypeId: ST_ZENER, name: 'Zener Diode', pinCount: 2, created: 0, lastModified: 0 },
|
||
{ id: CT_GENERIC_IC, symbolTypeId: ST_GENERIC_IC, name: 'Generic IC', pinCount: null, created: 0, lastModified: 0 },
|
||
];
|
||
|
||
// componentTypePins — pin positions in centre-origin local space (pixels).
|
||
// DirLeft=4, DirRight=8, DirUp=1, DirDown=2 (matches pinDirections.id).
|
||
// Dimensions match geometry defined in components.js:
|
||
// Resistor: 60×24 Capacitor: 28×40 Inductor: 60×20 Diode/Zener: 44×24
|
||
const SEED_COMPONENT_TYPE_PINS = [
|
||
// Resistor — horizontal, pins left/right at half-width
|
||
{ id: uuid(), componentTypeId: CT_RESISTOR, number: 1, name: '1', description: null, x: -30, y: 0, directionId: 4 },
|
||
{ id: uuid(), componentTypeId: CT_RESISTOR, number: 2, name: '2', description: null, x: 30, y: 0, directionId: 8 },
|
||
// Capacitor — vertical, pins top/bottom at half-height
|
||
{ id: uuid(), componentTypeId: CT_CAPACITOR, number: 1, name: '+', description: 'Positive / anode', x: 0, y: -20, directionId: 1 },
|
||
{ id: uuid(), componentTypeId: CT_CAPACITOR, number: 2, name: '-', description: 'Negative / cathode', x: 0, y: 20, directionId: 2 },
|
||
// Inductor — horizontal
|
||
{ id: uuid(), componentTypeId: CT_INDUCTOR, number: 1, name: '1', description: null, x: -30, y: 0, directionId: 4 },
|
||
{ id: uuid(), componentTypeId: CT_INDUCTOR, number: 2, name: '2', description: null, x: 30, y: 0, directionId: 8 },
|
||
// Diode — Anode left, Cathode right
|
||
{ id: uuid(), componentTypeId: CT_DIODE, number: 1, name: 'A', description: 'Anode', x: -22, y: 0, directionId: 4 },
|
||
{ id: uuid(), componentTypeId: CT_DIODE, number: 2, name: 'K', description: 'Cathode', x: 22, y: 0, directionId: 8 },
|
||
// Zener — same physical layout as Diode
|
||
{ id: uuid(), componentTypeId: CT_ZENER, number: 1, name: 'A', description: 'Anode', x: -22, y: 0, directionId: 4 },
|
||
{ id: uuid(), componentTypeId: CT_ZENER, number: 2, name: 'K', description: 'Cathode', x: 22, y: 0, directionId: 8 },
|
||
// Generic IC — no fixed pins in type definition; pins are instance-specific
|
||
];
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// SchemaDatabase class
|
||
// ═══════════════════════════════════════════════════════════════
|
||
export class SchemaDatabase {
|
||
constructor() {
|
||
this._db = null;
|
||
}
|
||
|
||
// ── Initialise: open DB, upgrade if needed, seed static data ─
|
||
async init() {
|
||
await this._open();
|
||
await this._seed();
|
||
await this._migratePinCounts();
|
||
}
|
||
|
||
_open() {
|
||
return new Promise((resolve, reject) => {
|
||
const req = indexedDB.open(DB_NAME, DB_VER);
|
||
|
||
req.onupgradeneeded = e => {
|
||
console.log('[schema/db] onupgradeneeded — upgrading from v', e.oldVersion, '→', e.newVersion);
|
||
window.PcbDbCore.setupDatabase(e.target.result, e.target.transaction);
|
||
};
|
||
req.onsuccess = e => { this._db = e.target.result; console.log('[schema/db] opened v', this._db.version); resolve(); };
|
||
req.onerror = e => { console.error('[SchemaDB] Open error:', e); reject(e); };
|
||
});
|
||
}
|
||
|
||
// ── Seed static lookup data on first run ──────────────────────
|
||
async _seed() {
|
||
// pinDirections — only seed if table is empty
|
||
const existingDirs = await this._getAll('pinDirections');
|
||
if (existingDirs.length === 0) {
|
||
const tx = this._db.transaction('pinDirections', 'readwrite');
|
||
const st = tx.objectStore('pinDirections');
|
||
SEED_PIN_DIRECTIONS.forEach(d => st.put(d));
|
||
await this._txDone(tx);
|
||
}
|
||
|
||
// symbolTypes — only seed if table is empty
|
||
const existingSymTypes = await this._getAll('symbolTypes');
|
||
if (existingSymTypes.length === 0) {
|
||
const tx = this._db.transaction('symbolTypes', 'readwrite');
|
||
const st = tx.objectStore('symbolTypes');
|
||
SEED_SYMBOL_TYPES.forEach(s => st.put(s));
|
||
await this._txDone(tx);
|
||
}
|
||
|
||
// componentTypes — only seed if table is empty
|
||
const existingTypes = await this._getAll('componentTypes');
|
||
if (existingTypes.length === 0) {
|
||
const tx = this._db.transaction(['componentTypes', 'componentTypePins'], 'readwrite');
|
||
SEED_COMPONENT_TYPES.forEach(t => tx.objectStore('componentTypes').put(t));
|
||
SEED_COMPONENT_TYPE_PINS.forEach(p => tx.objectStore('componentTypePins').put(p));
|
||
await this._txDone(tx);
|
||
}
|
||
}
|
||
|
||
// ── Low-level helpers ─────────────────────────────────────────
|
||
_tx(store, mode, cb) {
|
||
return new Promise((resolve, reject) => {
|
||
const t = this._db.transaction(store, mode);
|
||
const q = cb(t.objectStore(store));
|
||
q.onsuccess = () => resolve(q.result);
|
||
q.onerror = () => reject(q.error);
|
||
});
|
||
}
|
||
|
||
_ix(store, index, value) {
|
||
return new Promise((resolve, reject) => {
|
||
const q = this._db.transaction(store, 'readonly')
|
||
.objectStore(store).index(index).getAll(value);
|
||
q.onsuccess = () => resolve(q.result);
|
||
q.onerror = () => reject(q.error);
|
||
});
|
||
}
|
||
|
||
_getAll(store) {
|
||
return this._tx(store, 'readonly', s => s.getAll());
|
||
}
|
||
|
||
_txDone(tx) {
|
||
return new Promise((resolve, reject) => {
|
||
tx.oncomplete = resolve;
|
||
tx.onerror = () => reject(tx.error);
|
||
});
|
||
}
|
||
|
||
// ── Studio store reads (read-only from Schema's perspective) ──
|
||
|
||
getDevice(id) {
|
||
return this._tx('devices', 'readonly', s => s.get(id));
|
||
}
|
||
|
||
getDevices() {
|
||
return this._getAll('devices');
|
||
}
|
||
|
||
getBoard(id) {
|
||
return this._tx('boards', 'readonly', s => s.get(id));
|
||
}
|
||
|
||
getBoardsByDevice(deviceId) {
|
||
return this._ix('boards', 'deviceId', deviceId);
|
||
}
|
||
|
||
/** All components belonging to a specific board. */
|
||
getComponentsByBoard(boardId) {
|
||
return this._ix('components', 'boardId', boardId);
|
||
}
|
||
|
||
/**
|
||
* All nets whose projectId matches boardId.
|
||
* Falls back to a full-table JS filter when the projectId index
|
||
* doesn't exist yet (DB still at v1 before Studio upgrade runs).
|
||
*/
|
||
async getNetsByBoard(boardId) {
|
||
const store = this._db.transaction('nets', 'readonly').objectStore('nets');
|
||
if (store.indexNames.contains('projectId')) {
|
||
return this._ix('nets', 'projectId', boardId);
|
||
}
|
||
// Index not yet created — full scan + filter
|
||
return new Promise((resolve, reject) => {
|
||
const req = store.getAll();
|
||
req.onsuccess = () => resolve((req.result || []).filter(n => n.projectId === boardId));
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
}
|
||
|
||
// ── Schemas ───────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Returns the existing schema for this board, or creates one.
|
||
* boardId may be null for device-level schemas.
|
||
*/
|
||
async getOrCreateSchema(deviceId, boardId) {
|
||
// Try to find an existing schema for this board
|
||
let existing = null;
|
||
if (boardId) {
|
||
const byBoard = await this._ix('schemas', 'boardId', boardId);
|
||
existing = byBoard.find(s => s.deviceId === deviceId) || null;
|
||
} else {
|
||
// Device-level schema: boardId is null — find by deviceId with null boardId
|
||
const byDevice = await this._ix('schemas', 'deviceId', deviceId);
|
||
existing = byDevice.find(s => !s.boardId) || null;
|
||
}
|
||
if (existing) return existing;
|
||
|
||
const schema = {
|
||
id: uuid(),
|
||
deviceId,
|
||
boardId: boardId || null,
|
||
name: 'Main Schema',
|
||
created: NOW(),
|
||
lastModified: NOW(),
|
||
viewportZoom: 1,
|
||
viewportX: 0,
|
||
viewportY: 0,
|
||
};
|
||
await this._tx('schemas', 'readwrite', s => s.put(schema));
|
||
return schema;
|
||
}
|
||
|
||
saveSchema(schema) {
|
||
schema.lastModified = NOW();
|
||
return this._tx('schemas', 'readwrite', s => s.put(schema));
|
||
}
|
||
|
||
// ── SchemaComponents ──────────────────────────────────────────
|
||
|
||
getSchemaComponents(schemaId) {
|
||
return this._ix('schemaComponents', 'schemaId', schemaId);
|
||
}
|
||
|
||
saveSchemaComponent(sc) {
|
||
return this._tx('schemaComponents', 'readwrite', s => s.put(sc));
|
||
}
|
||
|
||
deleteSchemaComponent(id) {
|
||
return this._tx('schemaComponents', 'readwrite', s => s.delete(id));
|
||
}
|
||
|
||
/**
|
||
* Upsert a schemaComponent record for a given component within a schema.
|
||
* Creates a new record if none exists, updates existing one if found.
|
||
* Sets locked=true so this position survives future auto-layouts.
|
||
*/
|
||
async upsertSchemaComponent(schemaId, componentId, { x, y, rotation, flipX, componentTypeId, pinCount, overrides } = {}) {
|
||
const existing = await this._findSchemaComponent(schemaId, componentId);
|
||
const sc = existing || {
|
||
id: uuid(),
|
||
schemaId,
|
||
componentId,
|
||
componentTypeId: null,
|
||
pinCount: null,
|
||
overrides: null,
|
||
};
|
||
// Always update position/rotation/lock
|
||
sc.x = x;
|
||
sc.y = y;
|
||
sc.rotation = rotation; // degrees
|
||
sc.flipX = flipX;
|
||
sc.locked = true;
|
||
if (componentTypeId !== undefined) sc.componentTypeId = componentTypeId;
|
||
if (pinCount !== undefined) sc.pinCount = pinCount;
|
||
if (overrides !== undefined) sc.overrides = overrides;
|
||
return this._tx('schemaComponents', 'readwrite', s => s.put(sc));
|
||
}
|
||
|
||
/** Find a schemaComponent record for a specific component within a schema. */
|
||
async findSchemaComponent(schemaId, componentId) {
|
||
const all = await this._ix('schemaComponents', 'schemaId', schemaId);
|
||
return all.find(sc => sc.componentId === componentId) || null;
|
||
}
|
||
|
||
async createMissingComponent(boardId, label, value) {
|
||
const id = uuid();
|
||
await this._tx('components', 'readwrite', s => s.put(
|
||
{ id, boardId, label, value, desc: '' }
|
||
));
|
||
return id;
|
||
}
|
||
|
||
// Keep private alias for internal use
|
||
_findSchemaComponent(schemaId, componentId) {
|
||
return this.findSchemaComponent(schemaId, componentId);
|
||
}
|
||
|
||
// ── KiCad Symbols ─────────────────────────────────────────────
|
||
async getKicadSymbols(library) {
|
||
return this._ix('kicadSymbols', 'library', library);
|
||
}
|
||
|
||
async getKicadSymbolsByPinCount(pinCount) {
|
||
return this._ix('kicadSymbols', 'pinCount', pinCount);
|
||
}
|
||
|
||
async _migratePinCounts() {
|
||
const syms = await this._getAll('kicadSymbols');
|
||
const needsMigration = syms.filter(s => s.pinCount === undefined);
|
||
if (needsMigration.length === 0) return;
|
||
|
||
console.log(`[schema/db] Migrating ${needsMigration.length} KiCad symbols to add pinCount...`);
|
||
const tx = this._db.transaction('kicadSymbols', 'readwrite');
|
||
const st = tx.objectStore('kicadSymbols');
|
||
for (const s of needsMigration) {
|
||
try {
|
||
const parsed = JSON.parse(s.parsedData);
|
||
s.pinCount = parsed.pins ? parsed.pins.length : 0;
|
||
st.put(s);
|
||
} catch (e) {
|
||
console.warn('Failed to migrate symbol', s.id);
|
||
}
|
||
}
|
||
await this._txDone(tx);
|
||
console.log('[schema/db] Migration complete.');
|
||
}
|
||
|
||
async saveKicadSymbol(sym) {
|
||
return this._tx('kicadSymbols', 'readwrite', s => s.put(sym));
|
||
}
|
||
|
||
async saveKicadSymbolsBatch(syms) {
|
||
const tx = this._db.transaction('kicadSymbols', 'readwrite');
|
||
const st = tx.objectStore('kicadSymbols');
|
||
syms.forEach(sym => st.put(sym));
|
||
return this._txDone(tx);
|
||
}
|
||
|
||
async saveComponentType(ct) {
|
||
return this._tx('componentTypes', 'readwrite', s => s.put(ct));
|
||
}
|
||
|
||
async saveComponentTypeKicadSymbol(ctks) {
|
||
return this._tx('componentTypeKicadSymbols', 'readwrite', s => s.put(ctks));
|
||
}
|
||
|
||
async getKicadSymbolById(id) {
|
||
return this._tx('kicadSymbols', 'readonly', s => s.get(id));
|
||
}
|
||
|
||
async getComponentTypeForKicadSymbol(kicadSymbolId) {
|
||
const links = await this._ix('componentTypeKicadSymbols', 'kicadSymbolId', kicadSymbolId);
|
||
if (links && links.length > 0) return this.getComponentType(links[0].componentTypeId);
|
||
return null;
|
||
}
|
||
|
||
async getResolvedKicadDataForComponentType(componentTypeId) {
|
||
const links = await this._ix('componentTypeKicadSymbols', 'componentTypeId', componentTypeId);
|
||
if (!links || links.length === 0) return null;
|
||
return this.getKicadSymbolById(links[0].kicadSymbolId);
|
||
}
|
||
|
||
// ── Component type catalogue ──────────────────────────────────
|
||
|
||
getComponentTypes() {
|
||
return this._getAll('componentTypes');
|
||
}
|
||
|
||
getComponentType(id) {
|
||
return this._tx('componentTypes', 'readonly', s => s.get(id));
|
||
}
|
||
|
||
getComponentTypePins(componentTypeId) {
|
||
return this._ix('componentTypePins', 'componentTypeId', componentTypeId);
|
||
}
|
||
|
||
getPinDirections() {
|
||
return this._getAll('pinDirections');
|
||
}
|
||
|
||
getSymbolTypes() {
|
||
return this._getAll('symbolTypes');
|
||
}
|
||
|
||
// ── Standalone netlist import ─────────────────────────────────
|
||
/**
|
||
* Creates a device + board + components + nets in one logical operation.
|
||
* The netlist is in Schema ReTrace's internal parsed format:
|
||
* { components: [{ref, value, type}], nets: [{name, nodes:[{ref,pin}]}] }
|
||
* Returns { deviceId, boardId }.
|
||
*/
|
||
async importNetlist(name, parsed) {
|
||
const deviceId = uuid();
|
||
const boardId = uuid();
|
||
const ts = NOW();
|
||
|
||
// Build component records — map ref → id for net node resolution
|
||
const refToId = {};
|
||
const compRecords = parsed.components.map(c => {
|
||
const id = uuid();
|
||
refToId[c.ref] = id;
|
||
return { id, boardId, label: c.ref, value: c.value || '', desc: '' };
|
||
});
|
||
|
||
// Build net records using Studio's format (nodes as "ref.pin" label strings)
|
||
const netRecords = parsed.nets.map(n => ({
|
||
id: uuid(),
|
||
projectId: boardId,
|
||
name: n.name,
|
||
nodes: (n.nodes || []).map(nd => ({ label: `${nd.ref}.${nd.pin}` })),
|
||
}));
|
||
|
||
// Single transaction for atomicity
|
||
const stores = ['devices', 'boards', 'components', 'nets'];
|
||
const tx = this._db.transaction(stores, 'readwrite');
|
||
tx.objectStore('devices').put({ id: deviceId, name });
|
||
tx.objectStore('boards').put({
|
||
id: boardId, deviceId,
|
||
name: 'Main Board',
|
||
section: '', sortMode: 'none', lastModified: ts,
|
||
});
|
||
compRecords.forEach(c => tx.objectStore('components').put(c));
|
||
netRecords.forEach(n => tx.objectStore('nets').put(n));
|
||
await this._txDone(tx);
|
||
|
||
return { deviceId, boardId };
|
||
}
|
||
}
|
||
|
||
// ── Module-level singleton ────────────────────────────────────
|
||
export const db = new SchemaDatabase();
|