Schema ReTrace added (create schematic diagrams from netlists).

This commit is contained in:
Taras Greben 2026-04-24 17:28:59 +03:00
commit d7b4a6c499
27 changed files with 5985 additions and 385 deletions

View file

@ -50,10 +50,10 @@
/* NEW: Smaller Coil Graphic on Mobile */
.coil-viz {
height: 80px; /* Reduced from 120px */
height: 80px; /* Reduced from 120px */
margin-bottom: 0.5rem;
}
}
}
}
</style>
</head>
<body>

84
docs/db-core.js Normal file
View file

@ -0,0 +1,84 @@
/* db-core.js - Shared database initialization and schema definitions */
// Global UUID generator shared across all components
window.uuid = function() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
window.PcbDbCore = {
DB_NAME: 'PcbReTrace',
DB_VER: 3,
setupDatabase: function(d, tx) {
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});
}
if (!d.objectStoreNames.contains('overlappedImages')) {
const os = d.createObjectStore('overlappedImages', {keyPath: 'id'});
os.createIndex('fromImageId', 'fromImageId');
}
if (!d.objectStoreNames.contains('nets')) {
d.createObjectStore('nets', {keyPath: 'id'});
}
const netsStore = tx.objectStore('nets');
if (!netsStore.indexNames.contains('projectId')) {
netsStore.createIndex('projectId', 'projectId', {unique: false});
}
if (!d.objectStoreNames.contains('pinDirections')) {
d.createObjectStore('pinDirections', {keyPath: 'id'});
}
if (!d.objectStoreNames.contains('symbolTypes')) {
const st = d.createObjectStore('symbolTypes', {keyPath: 'id'});
st.createIndex('parentId', 'parentId', {unique: false});
}
if (!d.objectStoreNames.contains('kicadSymbols')) {
const ks = d.createObjectStore('kicadSymbols', {keyPath: 'id'});
ks.createIndex('library', 'library', {unique: false});
ks.createIndex('pinCount', 'pinCount', {unique: false});
} else {
const ks = tx.objectStore('kicadSymbols');
if (!ks.indexNames.contains('pinCount')) {
ks.createIndex('pinCount', 'pinCount', {unique: false});
}
}
if (!d.objectStoreNames.contains('componentTypes')) {
const ct = d.createObjectStore('componentTypes', {keyPath: 'id'});
ct.createIndex('symbolTypeId', 'symbolTypeId', {unique: false});
}
if (!d.objectStoreNames.contains('componentTypeKicadSymbols')) {
const ck = d.createObjectStore('componentTypeKicadSymbols', {keyPath: 'id'});
ck.createIndex('componentTypeId', 'componentTypeId', {unique: false});
ck.createIndex('kicadSymbolId', 'kicadSymbolId', {unique: false});
}
if (!d.objectStoreNames.contains('componentTypePins')) {
const cp = d.createObjectStore('componentTypePins', {keyPath: 'id'});
cp.createIndex('componentTypeId', 'componentTypeId', {unique: false});
}
if (!d.objectStoreNames.contains('schemas')) {
const ss = d.createObjectStore('schemas', {keyPath: 'id'});
ss.createIndex('deviceId', 'deviceId', {unique: false});
ss.createIndex('boardId', 'boardId', {unique: false});
}
if (!d.objectStoreNames.contains('schemaComponents')) {
const sc = d.createObjectStore('schemaComponents', {keyPath: 'id'});
sc.createIndex('schemaId', 'schemaId', {unique: false});
sc.createIndex('componentId', 'componentId', {unique: false});
}
}
};

View file

@ -96,7 +96,16 @@
<li><strong>Smart Naming:</strong> Click near a component to add a node. If the component is defined in the BOM, the tool <strong>auto-suggests the label</strong> (e.g., <code>R1.1</code>) using geometry (Angle & Distance). It automatically accounts for board rotation and flips pin logic for Bottom-side images.</li>
<li><strong>Pinout Help:</strong> Click the <button style="padding:0 5px; font-size:0.75rem; border-radius:50%; line-height:1;">?</button> button in the naming dialog to view standard conventions (e.g., "Stripe = Pin 1" for Tantalum).</li>
<li><strong>New Net:</strong> If no net is currently active, entering a name for a node will <strong>automatically start a new net</strong>.</li>
<li><strong>Export:</strong> In the Nets tab, you can export a <code>.net</code> file compatible with <strong>KiCad</strong> to help verify your reverse engineering in a professional EDA tool.</li>
<li><strong>Export:</strong> In the Nets tab, you can export a <code>.net</code> (KiCad) and <code>.cir</code> (SPICE) file to help verify your reverse engineering in a professional EDA tool.</li>
</ul>
<h3>Step 4: Schematic Generation (Schema ReTrace)</h3>
<p>Switch to the <strong>Schema</strong> tab to automatically generate a schematic diagram from your traced nets.</p>
<ul>
<li><strong>Auto Layout:</strong> ReTrace uses a built-in routing engine to organize components and draw wires intelligently.</li>
<li><strong>Interactive Editing:</strong> Drag components to rearrange them. Right-click (or long-press on mobile) to rotate or replace the symbol.</li>
<li><strong>KiCad Libraries:</strong> Click "Libraries" to import actual <code>.kicad_sym</code> libraries (or ZIP files) and assign standard symbols to your components.</li>
<li><strong>Standalone Mode:</strong> You can use this tool independently by visiting <code>schema.html</code> to visualize any standard KiCad <code>.net</code> or SPICE <code>.cir</code> netlist.</li>
</ul>
<h2>3. Computer Vision & Stitching</h2>
@ -160,7 +169,7 @@
<h3>Live Demo</h3>
<p>See this feature in action at the official <strong><a href="https://dev-lab.github.io/pcb-retrace-examples/" target="_blank">PCB ReTrace Examples Gallery</a></strong>. All projects there are hosted on GitHub Pages and load via Deep Links.</p>
<footer>
Copyright &copy; 2025 Taras Greben.
Copyright &copy; 2025—2026 Taras Greben.
</footer>
</div>

View file

@ -57,6 +57,16 @@
.feature-card { padding: 1.5rem; border: 1px solid #e2e8f0; border-radius: 1rem; transition: border-color 0.2s; }
.feature-card:hover { border-color: var(--primary); }
.feature-icon { font-size: 2rem; margin-bottom: 1rem; display: block; }
.feature-icon-svg {
display: inline-flex;
align-items: center;
justify-content: center;
}
.feature-icon-svg svg {
width: 2.5rem;
height: 2.5rem;
display: block;
}
.feature-card h3 { margin: 0 0 0.5rem; color: var(--text-main); }
.feature-card p { color: var(--text-sub); font-size: 0.95rem; margin: 0; }
@ -65,7 +75,7 @@
.tools-container { max-width: 800px; margin: 0 auto; }
.tools-title { text-align: center; margin-bottom: 2rem; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; font-size: 0.9rem; font-weight: 700; }
.tool-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; }
.tool-link {
background: white; border: 1px solid #e2e8f0; border-radius: 0.5rem;
padding: 1rem; text-align: center; text-decoration: none; color: inherit;
@ -97,7 +107,7 @@
<div class="hero">
<h1>PCB ReTrace</h1>
<p>Digitize, document, and reverse engineer printed circuit boards. Map components, trace nets, and inspect layers. Runs entirely in your browser.</p>
<p>Digitize, document, and reverse engineer printed circuit boards. Map components, trace nets, inspect layers, and generate schematics. Runs entirely in your browser.</p>
<a href="studio.html" class="cta-button">Launch Studio</a>
@ -134,14 +144,20 @@
<div class="feature-card">
<span class="feature-icon">🕸️</span>
<h3>Net Tracing</h3>
<p>Document connectivity across layers. Define nets, label nodes, and export a <code>.net</code> file for KiCad.</p>
<p>Document connectivity across layers. Define nets, label nodes, and export netlists (<code>.net</code>,<code>.cir</code> for KiCad, SPICE).</p>
</div>
<!-- 5. Integrated Tools (New - Highlights Calculators) -->
<!-- 5. Schematics (Workflow) -->
<div class="feature-card">
<span class="feature-icon">🧰</span>
<h3>Smart Decoders</h3>
<p>Integrated calculators for Resistor Color Bands, Inductors, and Air-core coils. Access them directly while editing.</p>
<span class="feature-icon feature-icon-svg">
<svg viewBox="0 0 24 24" aria-hidden="true">
<line x1="3" y1="16" x2="21" y2="16" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="3" x2="12" y2="16" stroke="currentColor" stroke-width="2"/>
<circle cx="12" cy="16" r="2.5" fill="currentColor"/>
</svg>
</span>
<h3>Schematics</h3>
<p>Generate interactive schematic diagrams, resolve placeholders into real components, and export to KiCad (<code>.kicad_sch</code>).</p>
</div>
<!-- 6. Deep Linking (New - Sharing) -->
@ -156,6 +172,10 @@
<div class="tools-container">
<div class="tools-title">Included Utilities</div>
<div class="tool-grid">
<a href="schema.html" class="tool-link">
<strong>Schema ReTrace</strong>
<span>Standalone Schematic Generator</span>
</a>
<a href="resistor.html" class="tool-link">
<strong>Resistor Decoder</strong>
<span>3-6 Bands & Reverse Lookup</span>
@ -174,7 +194,7 @@
<footer class="landing-footer">
<p>Dual Licensed: <strong>AGPL v3</strong> (Open Source) + <strong>Commercial</strong>.</p>
<p>Copyright © 2025 Taras Greben — <a href="https://pcb.etaras.com">pcb.eTaras.com</a></p>
<p>Copyright © 2025—2026 Taras Greben — <a href="https://pcb.etaras.com">pcb.eTaras.com</a></p>
</footer>
</body>

View file

@ -43,17 +43,17 @@
<script>
const colors = [
{ name: 'Black', val: 0, mult: 1, tol: 20, css: 'c-bk' },
{ name: 'Brown', val: 1, mult: 10, tol: 1, css: 'c-bn' },
{ name: 'Red', val: 2, mult: 100, tol: 2, css: 'c-rd' },
{ name: 'Black', val: 0, mult: 1, tol: 20, css: 'c-bk' },
{ name: 'Brown', val: 1, mult: 10, tol: 1, css: 'c-bn' },
{ name: 'Red', val: 2, mult: 100, tol: 2, css: 'c-rd' },
{ name: 'Orange', val: 3, mult: 1000, tol: 3, css: 'c-og' },
{ name: 'Yellow', val: 4, mult: 10000, tol: 4, css: 'c-ye' },
{ name: 'Green', val: 5, mult: null, tol: null, css: 'c-gn' },
{ name: 'Blue', val: 6, mult: null, tol: null, css: 'c-bu' },
{ name: 'Green', val: 5, mult: null, tol: null, css: 'c-gn' },
{ name: 'Blue', val: 6, mult: null, tol: null, css: 'c-bu' },
{ name: 'Violet', val: 7, mult: null, tol: null, css: 'c-vt' },
{ name: 'Gray', val: 8, mult: null, tol: null, css: 'c-gy' },
{ name: 'White', val: 9, mult: null, tol: null, css: 'c-wh' },
{ name: 'Gold', val: null, mult: 0.1, tol: 5, css: 'c-gd' },
{ name: 'Gray', val: 8, mult: null, tol: null, css: 'c-gy' },
{ name: 'White', val: 9, mult: null, tol: null, css: 'c-wh' },
{ name: 'Gold', val: null, mult: 0.1, tol: 5, css: 'c-gd' },
{ name: 'Silver', val: null, mult: 0.01, tol: 10, css: 'c-sv' }
];
let currentBands=3; let bandState=[1,0,0]; let activeBandIndex=-1; let isEmbed=false; let bridge=null;

13
docs/kicad.svg Normal file
View file

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" version="1">
<path opacity=".2" d="m2 28.6v1c0 0.775 0.6248 1.4 1.4004 1.4h25.2c0.775 0 1.4-0.625 1.4-1.4v-1c0 0.775-0.625 1.4-1.4 1.4h-25.2c-0.7752 0-1.4-0.625-1.4-1.4z"/>
<path style="fill:#44579d" d="M 3.4,2 C 2.6244,2 2,2.6244 2,3.4 V 28.6 C 2,29.3756 2.6244,30 3.4,30 H 28.6 C 29.3756,30 30,29.3756 30,28.6 V 9 A 7,7 0 0 0 23,2 Z"/>
<g opacity=".2" transform="translate(0,1)">
<path d="m5 6v21h4v-5l4 5h5l-7-10.5 7-10.5h-5l-4 5v-5z"/>
<path d="m22 13h4v14h-4z"/>
<circle cx="24" cy="9" r="3"/>
</g>
<path fill="#ffd676" d="m5 6v21h4v-5l4 5h5l-7-10.5 7-10.5h-5l-4 5v-5z"/>
<path fill="#ffd676" d="m22 13h4v14h-4z"/>
<circle fill="#f70" cx="24" cy="9" r="3"/>
<path style="fill:#ffffff;opacity:0.1" d="M 3.4003906 2 C 2.6247906 2 2 2.6247906 2 3.4003906 L 2 4.4003906 C 2 3.6247906 2.6247906 3 3.4003906 3 L 23 3 A 7 7 0 0 1 30 10 L 30 9 A 7 7 0 0 0 23 2 L 3.4003906 2 z"/>
</svg>

After

Width:  |  Height:  |  Size: 961 B

View file

@ -127,10 +127,10 @@ async render() {
// We only map types that are structurally unambiguous (2-pin passives, test points).
// Complex types (Q, U, J) are commented out to prevent incorrect symbol assignment.
const COMPONENT_LIBRARY_MAP = {
'R': { lib: "Device", part: "R", desc: "Resistor" },
'C': { lib: "Device", part: "C", desc: "Unpolarized capacitor" },
'L': { lib: "Device", part: "L", desc: "Inductor" },
'D': { lib: "Device", part: "D", desc: "Diode" },
'R': { lib: "Device", part: "R", desc: "Resistor" },
'C': { lib: "Device", part: "C", desc: "Unpolarized capacitor" },
'L': { lib: "Device", part: "L", desc: "Inductor" },
'D': { lib: "Device", part: "D", desc: "Diode" },
'TP': { lib: "Connector", part: "TestPoint", desc: "Test Point" },
// --- AMBIGUOUS TYPES (Disabled by default) ---
@ -142,7 +142,7 @@ async render() {
let out = "(export (version D)\n";
// 2. Export Components
out += " (components\n";
out += " (components\n";
components.forEach(c => {
const val = c.value ? c.value : "~";
const footprint = c.desc ? c.desc.replace(/"/g, '') : "";
@ -156,37 +156,37 @@ async render() {
const def = COMPONENT_LIBRARY_MAP[prefix];
out += ` (comp (ref "${c.label}")\n`;
out += ` (value "${val}")\n`;
out += ` (value "${val}")\n`;
if(footprint) out += ` (footprint "${footprint}")\n`;
// Inject Library Source if we have a safe definition
if(def) {
out += ` (libsource (lib "${def.lib}") (part "${def.part}") (description "${def.desc}"))\n`;
out += ` (property (name "Sheetname") (value "")) (property (name "Sheetfile") (value "${filename}.kicad_sch"))\n`;
out += ` (libsource (lib "${def.lib}") (part "${def.part}") (description "${def.desc}"))\n`;
out += ` (property (name "Sheetname") (value "")) (property (name "Sheetfile") (value "${filename}.kicad_sch"))\n`;
}
out += ` (tstamp "${tstamp}")\n`;
out += ` (tstamp "${tstamp}")\n`;
out += ` )\n`;
});
out += " )\n";
out += " )\n";
// 3. Export Nets
out += " (nets\n";
out += " (nets\n";
nets.forEach((net, i) => {
out += ` (net (code ${i+1}) (name "${net.name}")\n`;
net.nodes.forEach(node => {
const parts = node.label.split('.');
if(parts.length === 2) {
// Format: R1.2 (Ref R1, Pin 2)
out += ` (node (ref "${parts[0]}") (pin "${parts[1]}"))\n`;
out += ` (node (ref "${parts[0]}") (pin "${parts[1]}"))\n`;
} else {
// Fallback: TestPoints or direct names often use Pin 1
out += ` (node (ref "${node.label}") (pin "1"))\n`;
out += ` (node (ref "${node.label}") (pin "1"))\n`;
}
});
out += " )\n";
});
out += " )\n)\n";
out += " )\n)\n";
// 4. Download
const blob = new Blob([out], { type: 'text/plain' });
@ -196,4 +196,55 @@ async render() {
document.body.appendChild(a); a.click(); document.body.removeChild(a);
}
async exportSpice() {
let filename = "board_netlist";
if (typeof currentBomId !== 'undefined' && typeof bomList !== 'undefined') {
const meta = bomList.find(b => b.id === currentBomId);
if (meta && meta.name) {
filename = meta.name.replace(/[^a-z0-9_\-\.]/gi, '_');
}
}
const nets = await this.db.getNets();
const components = (typeof bomData !== 'undefined') ? bomData :[];
let out = "* SPICE Netlist generated by PCB ReTrace\n";
components.forEach(c => {
const val = c.value ? c.value.replace(/\s+/g, '_') : "1k";
const ref = c.label;
const pinToNet = {};
let maxPin = 0;
nets.forEach(net => {
net.nodes.forEach(node => {
const parts = node.label.split('.');
if (parts[0] === ref) {
const pinNum = parseInt(parts[1]) || 1;
pinToNet[pinNum] = net.name;
if (pinNum > maxPin) maxPin = pinNum;
} else if (node.label === ref) {
pinToNet[1] = net.name;
if (1 > maxPin) maxPin = 1;
}
});
});
let netString = "";
const pinCount = maxPin > 0 ? maxPin : 2;
for (let i = 1; i <= pinCount; i++) {
netString += (pinToNet[i] || `NC_${ref}_${i}`) + " ";
}
out += `${ref} ${netString}${val}\n`;
});
out += ".end\n";
const blob = new Blob([out], { type: 'text/plain' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${filename}.cir`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
}
}

View file

@ -61,17 +61,17 @@
<script>
const colors = [
{ name: 'Black', val: 0, mult: 1, tol: null, tcr: 250, css: 'c-bk' },
{ name: 'Brown', val: 1, mult: 10, tol: 1, tcr: 100, css: 'c-bn' },
{ name: 'Red', val: 2, mult: 100, tol: 2, tcr: 50, css: 'c-rd' },
{ name: 'Black', val: 0, mult: 1, tol: null, tcr: 250, css: 'c-bk' },
{ name: 'Brown', val: 1, mult: 10, tol: 1, tcr: 100, css: 'c-bn' },
{ name: 'Red', val: 2, mult: 100, tol: 2, tcr: 50, css: 'c-rd' },
{ name: 'Orange', val: 3, mult: 1000, tol: null, tcr: 15, css: 'c-og' },
{ name: 'Yellow', val: 4, mult: 10000, tol: null, tcr: 25, css: 'c-ye' },
{ name: 'Green', val: 5, mult: 100000, tol: 0.5, tcr: 20, css: 'c-gn' },
{ name: 'Blue', val: 6, mult: 1000000, tol: 0.25, tcr: 10, css: 'c-bu' },
{ name: 'Green', val: 5, mult: 100000, tol: 0.5, tcr: 20, css: 'c-gn' },
{ name: 'Blue', val: 6, mult: 1000000, tol: 0.25, tcr: 10, css: 'c-bu' },
{ name: 'Violet', val: 7, mult: 10000000, tol: 0.1, tcr: 5, css: 'c-vt' },
{ name: 'Gray', val: 8, mult: null, tol: 0.05, tcr: 1, css: 'c-gy' },
{ name: 'White', val: 9, mult: null, tol: null, tcr: null, css: 'c-wh' },
{ name: 'Gold', val: null, mult: 0.1, tol: 5, tcr: null, css: 'c-gd' },
{ name: 'Gray', val: 8, mult: null, tol: 0.05, tcr: 1, css: 'c-gy' },
{ name: 'White', val: 9, mult: null, tol: null, tcr: null, css: 'c-wh' },
{ name: 'Gold', val: null, mult: 0.1, tol: 5, tcr: null, css: 'c-gd' },
{ name: 'Silver', val: null, mult: 0.01, tol: 10, tcr: null, css: 'c-sv' }
];
const e12=[1.0,1.2,1.5,1.8,2.2,2.7,3.3,3.9,4.7,5.6,6.8,8.2];

404
docs/schema.html Normal file
View file

@ -0,0 +1,404 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("consent","default",{ad_storage:"granted",ad_user_data:"granted",ad_personalization:"granted",analytics_storage:"granted"});gtag("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied",region:"AT,BE,BG,HR,CY,CZ,DK,EE,FI,FR,DE,GR,HU,IE,IT,LV,LT,LU,MT,NL,PL,PT,RO,SK,SI,ES,SE,IS,LI,NO,GB,CH,UA".split(",")});</script>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-X1MKL6C4B0"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date());gtag("config","G-X1MKL6C4B0");</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Schematic ReTrace</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="db-core.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap');
:root {
--bg:#0d1117; --surface:#161b22; --surface2:#1c2230; --border:#30363d;
--accent:#00e5a0; --accent2:#00a8ff; --accent3:#ff6b35; --warn:#f0c040;
--text:#e6edf3; --text2:#8b949e;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg); color: var(--text);
font-family: 'Rajdhani', sans-serif; font-size: 14px;
height: 100dvh; display: flex; flex-direction: column; overflow: hidden;
}
/* ── Header ── */
#header {
background: var(--surface); border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 12px; padding: 6px 14px; flex-shrink: 0;
}
#header .logo { font-family: 'Share Tech Mono', monospace; font-size: 18px; color: var(--accent); letter-spacing: 2px; white-space: nowrap; }
#header .logo span { color: var(--accent2); }
.sep { width: 1px; height: 24px; background: var(--border); flex-shrink: 0; }
.spacer { flex: 1; }
/* ── Buttons ── */
.btn {
background: var(--surface2); border: 1px solid var(--border); color: var(--text);
padding: 4px 12px; border-radius: 4px; cursor: pointer;
font-family: 'Rajdhani', sans-serif; font-size: 13px; font-weight: 600;
transition: all .15s; white-space: nowrap;
display: inline-flex; align-items: center; justify-content: center;
}
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn.accent { border-color: var(--accent); color: var(--accent); }
.btn.accent:hover { background: var(--accent); color: var(--bg); }
.btn.warn { border-color: var(--warn); color: var(--warn); }
.btn-group { display: flex; gap: 6px; align-items: center; }
/* ── Status ── */
#status-msg { font-family: 'Space Mono', monospace; font-size: 11px; color: var(--text2); max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#status-msg.ok { color: var(--accent); }
#status-msg.err { color: var(--accent3); }
#status-msg.warn { color: var(--warn); }
/* ── Main layout ── */
#main { display: flex; flex: 1; overflow: hidden; }
/* ── Left panel ── */
#left-panel { width: 220px; background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; }
.panel-title { padding: 8px 12px; font-size: 11px; font-weight: 700; letter-spacing: 2px; color: var(--text2); border-bottom: 1px solid var(--border); text-transform: uppercase; font-family: 'Space Mono', monospace; }
#net-list { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 0; }
#net-list::-webkit-scrollbar { width: 4px; }
#net-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.net-item { padding: 5px 12px; cursor: pointer; display: flex; align-items: center; gap: 6px; font-family: 'Space Mono', monospace; font-size: 11px; transition: background .1s; }
.net-item:hover { background: var(--surface2); }
.net-item.selected { background: rgba(0,229,160,.08); color: var(--accent); }
.net-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.net-item .nc { margin-left: auto; color: var(--text2); font-size: 10px; }
.net-item.wip .net-dot { background: #f0c040; }
.net-item:not(.wip) .net-dot { background: var(--accent); }
#comp-list-section { border-top: 1px solid var(--border); flex: 1; min-height: 0; display: flex; flex-direction: column; }
#comp-list { overflow-y: auto; flex: 1; min-height: 0; padding: 4px 0; }
#comp-list::-webkit-scrollbar { width: 4px; }
#comp-list::-webkit-scrollbar-thumb { background: var(--border); }
.comp-item { padding: 4px 12px; cursor: pointer; font-family: 'Space Mono', monospace; font-size: 11px; display: flex; align-items: center; gap: 6px; transition: background .1s; }
.comp-item:hover { background: var(--surface2); }
.comp-item.selected { background: rgba(0,168,255,.08); color: var(--accent2); }
.cdot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
/* ── Canvas area ── */
#canvas-wrap { flex: 1; overflow: hidden; position: relative; background: var(--bg); }
#sch-canvas { display: block; width: 100%; height: 100%; }
#coord-readout { position: absolute; top: 8px; right: 12px; font-family: 'Space Mono', monospace; font-size: 10px; color: var(--text2); background: rgba(13,17,23,.7); padding: 3px 8px; border-radius: 4px; border: 1px solid var(--border); pointer-events: none; }
#zoom-ind { position: absolute; bottom: 60px; right: 12px; font-family: 'Space Mono', monospace; font-size: 10px; color: var(--text2); background: rgba(13,17,23,.7); padding: 3px 8px; border-radius: 4px; border: 1px solid var(--border); pointer-events: none; }
#canvas-toolbar { position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%); background: var(--surface); border: 1px solid var(--border); border-radius: 8px; display: flex; gap: 4px; padding: 6px 10px; z-index: 50; }
#canvas-toolbar .btn { padding: 3px 10px; font-size: 12px; }
#hint-bar { position: absolute; top: 8px; left: 50%; transform: translateX(-50%); font-family: 'Space Mono', monospace; font-size: 10px; color: var(--text2); background: rgba(13,17,23,.85); padding: 3px 10px; border-radius: 4px; border: 1px solid var(--border); pointer-events: none; opacity: 0; transition: opacity .25s; }
#hint-bar.show { opacity: 1; }
/* ── Right panel (properties) ── */
#right-panel { width: 220px; background: var(--surface); border-left: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; }
#prop-panel { padding: 10px 12px; flex: 1; overflow-y: auto; font-family: 'Space Mono', monospace; font-size: 11px; }
#prop-panel::-webkit-scrollbar { width: 4px; }
#prop-panel::-webkit-scrollbar-thumb { background: var(--border); }
.prop-row { margin-bottom: 8px; }
.prop-label { color: var(--text2); margin-bottom: 2px; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; }
.prop-val { color: var(--text); }
.prop-input { width: 100%; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 4px 6px; border-radius: 3px; font-family: 'Space Mono', monospace; font-size: 11px; margin-top: 2px; }
.prop-input:focus { outline: none; border-color: var(--accent2); }
/* ── Modals ── */
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 1000; align-items: center; justify-content: center; }
.modal-overlay.active { display: flex; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px; width: 480px; max-width: 95vw; max-height: 80vh; display: flex; flex-direction: column; gap: 14px; }
.modal h2 { font-family: 'Share Tech Mono', monospace; color: var(--accent); font-size: 16px; letter-spacing: 2px; border-bottom: 1px solid var(--border); padding-bottom: 10px; }
.modal textarea { background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 8px; border-radius: 4px; font-family: 'Space Mono', monospace; font-size: 11px; resize: vertical; min-height: 160px; flex: 1; }
.modal textarea:focus { outline: none; border-color: var(--accent2); }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
.modal p { font-size: 12px; color: var(--text2); line-height: 1.6; }
.modal .fmt { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 8px; font-family: 'Space Mono', monospace; font-size: 10px; color: var(--accent2); white-space: pre; overflow: auto; }
#export-content { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 10px; font-family: 'Space Mono', monospace; font-size: 10px; color: var(--accent); white-space: pre; overflow: auto; max-height: 300px; flex: 1; }
#db-list { max-height: 200px; overflow-y: auto; border: 1px solid var(--border); border-radius: 4px; }
.db-item { padding: 8px 12px; cursor: pointer; font-family: 'Space Mono', monospace; font-size: 11px; border-bottom: 1px solid var(--border); transition: background .1s; display: flex; justify-content: space-between; align-items: center; }
.db-item:last-child { border-bottom: none; }
.db-item:hover { background: var(--surface2); color: var(--accent); }
.db-item.selected { background: rgba(0,229,160,.08); color: var(--accent); }
.db-item .dm { color: var(--text2); font-size: 10px; }
/* ── Toast ── */
#toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(60px); background: var(--surface); border: 1px solid var(--accent); color: var(--accent); font-family: 'Space Mono', monospace; font-size: 12px; padding: 8px 20px; border-radius: 6px; z-index: 2000; transition: transform .3s; pointer-events: none; }
#toast.show { border-color: var(--accent); color: var(--accent); transform: translateX(-50%) translateY(0); }
#toast.err { border-color: var(--accent3); color: var(--accent3); transform: translateX(-50%) translateY(0); }
#toast.warn { border-color: var(--warn); color: var(--warn); transform: translateX(-50%) translateY(0); }
/* ── Context menu ── */
#ctx-menu { user-select: none; }
#ctx-menu div + div { border-top: 1px solid var(--border); }
/* ── Empty state ── */
#empty-state { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; pointer-events: none; }
#empty-state .et { font-family: 'Share Tech Mono', monospace; font-size: 24px; color: var(--border); letter-spacing: 4px; }
#empty-state .es { font-family: 'Space Mono', monospace; font-size: 12px; color: var(--text2); text-align: center; line-height: 1.8; max-width: 360px; }
#empty-state .eh { font-family: 'Rajdhani', sans-serif; font-size: 14px; color: var(--accent); pointer-events: all; cursor: pointer; border: 1px solid var(--accent); padding: 6px 20px; border-radius: 4px; transition: all .15s; }
#empty-state .eh:hover { background: var(--accent); color: var(--bg); }
.responsive-text::after { content: attr(data-long); white-space: nowrap; }
/* --- MOBILE LAYOUT --- */
.mobile-only { display: none !important; }
@media (max-width: 800px) {
.mobile-only { display: inline-flex !important; }
.hide-mobile { display: none !important; }
.responsive-text::after { content: attr(data-short); }
#header .btn { padding: 4px 8px; }
#header .sep { display: none; }
#header { overflow-x: auto; padding-bottom: 2px; }
#header::-webkit-scrollbar { height: 4px; }
#header::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
#main { position: relative; }
#left-panel {
position: absolute; left: -240px; top: 0; bottom: 0; z-index: 100;
transition: left 0.25s ease; box-shadow: 4px 0 15px rgba(0,0,0,0.6);
}
#left-panel.open { left: 0; }
#right-panel {
position: absolute; right: -240px; top: 0; bottom: 0; z-index: 100;
transition: right 0.25s ease; box-shadow: -4px 0 15px rgba(0,0,0,0.6);
}
#right-panel.open { right: 0; }
#canvas-toolbar { width: 90%; max-width: 380px; justify-content: center; flex-wrap: wrap; bottom: 10px; }
}
/* ── Copyright footer (standalone mode only) ── */
#copyright-footer {
display: none;
flex: 0 0 auto;
align-self: flex-start;
width: 100%;
text-align: center;
padding: 4px 14px;
font-family: 'Space Mono', monospace;
font-size: 10px;
color: var(--text2);
background: var(--surface);
border-top: 1px solid var(--border);
z-index: 20;
}
#copyright-footer a { color: var(--text2); text-decoration: none; }
#copyright-footer a:hover { color: var(--accent); }
/* Standalone detection: show footer whenever btn-load-db is not hidden.
Works regardless of what mechanism hides the button (url param, postMessage, etc.) */
body:not(:has(#btn-load-db[style*="display: none"])) #copyright-footer { display: block; }
@media (max-width: 800px) and (max-height: 500px) {
#copyright-footer {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
z-index: 200;
}
/* Hide labels and separators in the left panel to save height */
.panel-title { display: none; }
#comp-list-section { border-top: none; }
/* Change overall app layout to row so header goes to the left edge */
body { flex-direction: row; }
/* Transform top header into a vertical sidebar */
#header {
flex-direction: column; overflow-y: auto; overflow-x: hidden;
width: auto; padding: 8px 4px;
border-bottom: none; border-right: 1px solid var(--border);
}
#header .btn-group { flex-direction: column; }
#status-msg {
display: block; text-align: center; line-height: 1.6; padding-bottom: 4px;
max-width: 40px;
white-space: normal;
margin: 0 auto;
}
#status-msg.responsive-text::after { white-space: normal; }
/* Move canvas toolbar to the RIGHT, hide zoom buttons to save height */
#canvas-toolbar {
width: auto; height: auto; flex-direction: column; flex-wrap: nowrap;
top: 50%; bottom: auto; left: auto; right: 10px; transform: translateY(-50%);
background: transparent; border: none; padding: 0; gap: 6px;
}
#canvas-toolbar .sep { display: none; }
#canvas-toolbar .btn { box-shadow: 0 2px 8px rgba(0,0,0,0.6); }
#btn-zoom-in, #btn-zoom-out { display: none; }
}
</style>
</head>
<body>
<div id="header">
<div class="logo hide-mobile">SCHEMA<span>RETRACE</span></div>
<div class="sep hide-mobile"></div>
<div class="btn-group">
<button class="btn accent" id="btn-load-db" title="Switch to PCB ReTrace">⚡<span class="hide-mobile"> Switch to PCB ReTrace</span></button>
<button class="btn" id="btn-import-file" title="Import">📂<span class="hide-mobile"> Import</span></button>
<button class="btn" id="btn-lib-mgr" title="Libraries">📚<span class="hide-mobile"> Libraries</span></button>
</div>
<div class="sep"></div>
<div class="btn-group">
<button class="btn" id="btn-auto-place" title="Auto Layout">🔀<span class="hide-mobile"> Auto Layout</span></button>
<button class="btn" id="btn-zoom-fit" title="Fit View">⛶<span class="hide-mobile"> Fit</span></button>
</div>
<div class="sep"></div>
<div class="btn-group">
<label class="hide-mobile" style="font-size:12px;color:var(--text2);white-space:nowrap">Export:</label>
<button class="btn warn" id="btn-export-kicad" title="Export KiCad">
<img src="kicad.svg" alt="KiCad" style="width:14px; height:14px; border-radius:2px;">
<span class="hide-mobile" style="margin-left:5px;">KiCad .kicad_sch</span>
</button>
<!--<button class="btn warn" id="btn-export-spice">SPICE .cir</button>-->
</div>
<div class="spacer"></div>
<span id="status-msg">No netlist loaded</span>
</div>
<div id="main">
<div id="left-panel">
<div class="panel-title">Nets</div>
<div id="net-list"></div>
<div id="comp-list-section">
<div class="panel-title">Components</div>
<div id="comp-list"></div>
</div>
</div>
<div id="canvas-wrap">
<canvas id="sch-canvas"></canvas>
<div id="coord-readout">X: 0 Y: 0</div>
<div id="zoom-ind">100%</div>
<div id="hint-bar"></div>
<div id="canvas-toolbar">
<button class="btn mobile-only" id="btn-toggle-left">☰</button>
<button class="btn" id="btn-zoom-in">+</button>
<button class="btn" id="btn-zoom-out">−</button>
<button class="btn" id="btn-zoom-fit2">⛶</button>
<div class="sep hide-mobile" style="height:20px"></div>
<button class="btn" id="btn-undo">↩</button>
<button class="btn mobile-only" id="btn-toggle-right">🛈</button>
</div>
<div id="empty-state">
<div class="et">NO NETLIST</div>
<div class="es">Import a KiCad or PCB ReTrace netlist file<br>or open from PCB ReTrace Studio</div>
<div class="eh" id="es-load-btn">📂 Import Netlist</div>
</div>
</div>
<div id="right-panel">
<div class="panel-title">Properties</div>
<div id="prop-panel">
<div style="color:var(--text2);font-size:10px;text-align:center;margin-top:20px">
Select a component<br>or wire segment
</div>
</div>
</div>
</div>
<div id="toast"></div>
<footer id="copyright-footer">
Copyright &copy; 2026 Taras Greben &mdash; <a href="https://pcb.etaras.com" target="_blank">pcb.eTaras.com</a>
</footer>
<!-- Import modal -->
<div class="modal-overlay" id="modal-import">
<div class="modal">
<h2>📂 IMPORT NETLIST</h2>
<p>Paste or open a file. Accepted: PCB ReTrace JSON, KiCad XML netlist, KiCad S-expr .net.</p>
<div class="fmt">{ "nets": [ { "name": "NET1",
"nodes": [{"ref":"R1","pin":"1"},{"ref":"C1","pin":"1"}] } ],
"components": [ {"ref":"R1","value":"10k","type":"R"} ] }</div>
<textarea id="import-textarea" placeholder="Paste netlist here…"></textarea>
<div style="display:flex;gap:8px;align-items:center">
<button class="btn" id="btn-pick-file">📁 Open File</button>
<input type="file" id="file-input" accept=".json,.net,.cir,.txt" style="display:none">
<span id="file-name" style="font-family:'Space Mono',monospace;font-size:10px;color:var(--text2)">No file</span>
</div>
<div class="modal-actions">
<button class="btn" id="btn-import-cancel">Cancel</button>
<button class="btn accent" id="btn-import-ok">Import</button>
</div>
</div>
</div>
<!-- Replace Symbol modal -->
<div class="modal-overlay" id="modal-replace-symbol">
<div class="modal" style="width: 500px;">
<h2>✨ REPLACE SYMBOL</h2>
<div style="display:flex; flex-direction:column; gap: 4px; margin-bottom: 8px;">
<input type="text" id="sym-search" placeholder="Search symbols..." class="prop-input" style="width:100%;">
<div style="font-size:9px; color:var(--text2);">Advanced filters: <code>name:dio</code> <code>lib:device</code> <code>ref:D</code> <code>desc:zener</code></div>
</div>
<div id="sym-list" style="max-height: 300px; overflow-y: auto; border: 1px solid var(--border); border-radius: 4px;">
<!-- list populated via JS -->
</div>
<div class="modal-actions" style="margin-top: 14px;">
<button class="btn" id="btn-replace-cancel">Cancel</button>
</div>
</div>
</div>
<!-- Library Manager Modal -->
<div class="modal-overlay" id="modal-library-manager">
<div class="modal" style="width: 500px;">
<h2>📚 KICAD LIBRARY MANAGER</h2>
<div id="lib-default-ui" style="display:flex; flex-direction:column; gap:8px;">
<p>Import KiCad symbols from a local file (.zip, .kicad_sym) or a remote URL.</p>
<label style="font-size:10px; color:var(--text2); text-transform:uppercase;">Remote URL</label>
<div style="display:flex; gap:8px;">
<input type="text" id="lib-remote-url" value="https://pcb.etaras.com/assets/kicad-symbols-master.zip" placeholder="https://.../library.zip" class="prop-input" style="flex:1;">
<button class="btn accent" id="btn-lib-import-url">⬇ Fetch</button>
</div>
<div style="text-align:center; color:var(--text2); margin: 4px 0;">— OR —</div>
<label style="font-size:10px; color:var(--text2); text-transform:uppercase;">Local File</label>
<div style="display:flex; gap:8px; align-items:center;">
<button class="btn" id="btn-lib-pick-file">📁 Select File</button>
<input type="file" id="lib-file-input" accept=".zip,.kicad_sym" style="display:none">
<span id="lib-file-name" style="font-family:'Space Mono',monospace;font-size:10px;color:var(--text2)">No file</span>
</div>
</div>
<div id="lib-zip-explorer" style="display:none; flex-direction:column; gap:8px;">
<div style="font-size:11px; color:var(--text2);">Select libraries or symbols to import:</div>
<div style="display:flex; gap:6px;">
<button class="btn" id="btn-zip-sel-all">Select All</button>
<button class="btn" id="btn-zip-sel-none">Select None</button>
</div>
<div id="zip-tree" style="max-height: 240px; overflow-y: auto; border: 1px solid var(--border); padding: 6px; background: var(--bg); border-radius:4px; font-family:'Space Mono', monospace; font-size:11px;"></div>
<div style="display:flex; gap: 8px; margin-top:4px;">
<button class="btn accent" id="btn-zip-import">Import Selected</button>
<button class="btn" id="btn-zip-cancel">Cancel</button>
</div>
</div>
<div id="lib-import-log" style="margin-top:12px; max-height:100px; overflow-y:auto; font-size:10px; font-family:'Space Mono',monospace; color:var(--accent2); background:var(--bg); padding:6px; border:1px solid var(--border); border-radius:4px;"></div>
<div class="modal-actions" style="margin-top: 14px;">
<button class="btn" id="btn-lib-close">Close</button>
</div>
</div>
</div>
<!-- Export modal -->
<div class="modal-overlay" id="modal-export">
<div class="modal" style="width:560px">
<h2 id="export-title">EXPORT</h2>
<pre id="export-content"></pre>
<div class="modal-actions">
<button class="btn" id="btn-export-close">Close</button>
<button class="btn accent" id="btn-export-copy">📋 Copy</button>
<button class="btn warn" id="btn-export-download">⬇ Download</button>
</div>
</div>
</div>
<script type="module" src="schema/app.js"></script>
</body>
</html>

498
docs/schema/app.js Normal file
View file

@ -0,0 +1,498 @@
// ── 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') {
setStatus('Import a netlist or switch to PCB ReTrace', 'warn');
render();
} else {
render();
}
} catch (err) {
console.error('[schema] post-init error:', err);
}
});

870
docs/schema/components.js Normal file
View file

@ -0,0 +1,870 @@
// ═══════════════════════════════════════════════════════════════
// COMPONENT LIBRARY
//
// Each component type is a class that encapsulates everything
// specific to that type: geometry, pin layout, WireBender
// descriptor, and Canvas drawing. All external code talks only
// to the CompBase interface via createComp(stateObj).
//
// To add a new component type:
// 1. Subclass CompBase
// 2. Override color, label, _buildGeometry(), draw()
// 3. Register in ALL_TYPES at the bottom
//
// State object shape (S.components[i]):
// { id, ref, value, type, x, y, rotation, flipX, pins:[{name,net}], pcbX?, pcbY? }
// ═══════════════════════════════════════════════════════════════
import { MARGIN, getNetColor } from './state.js';
const PIN_PITCH = 20;
// ── WireBender PinDirection flags ─────────────────────────────
// Mirrors Module.PinDirection enum values.
export const PinDir = {
DirNone: 0,
DirUp: 1,
DirDown: 2,
DirLeft: 4,
DirRight: 8,
DirAll: 15,
};
// ── Base class ────────────────────────────────────────────────
export class CompBase {
constructor(state) {
// state is the plain object from S.components — we hold a reference,
// never copy, so mutations (x, y, rotation …) are always live.
this._s = state;
}
// ── Identity / appearance ─────────────────────────────────
get color() { return '#94a3b8'; } // override in subclass
get label() { return this._s.type; } // override in subclass
// ── Geometry ──────────────────────────────────────────────
// Returns { w, h, pins:[{name, side:'L'|'R'|'T'|'B', along}] }
// 'along' = signed offset from the face centre (px).
// Subclasses implement _buildGeometry(); consumers call geometry().
_buildGeometry() { throw new Error(`${this.constructor.name}._buildGeometry not implemented`); }
geometry() {
// Cache per instance — geometry only changes if the pin list changes,
// which only happens on netlist reload (new instance created).
if (!this._geo) this._geo = this._buildGeometry();
return this._geo;
}
// ── Pin local coordinates ─────────────────────────────────
// Returns pin positions in component-local space:
// origin = component centre, no rotation applied.
// Both pinPositions() and wbDescriptor() derive from this so
// they can never diverge.
//
// Returns [{gp, ldx, ldy, ex, ey}] where:
// ldx/ldy = offset from component centre
// ex/ey = unit outward direction of the stub
_pinLocalCoords() {
const geo = this.geometry();
return geo.pins.map(gp => {
let ldx, ldy, ex, ey;
switch (gp.side) {
case 'L': ldx = -geo.w / 2; ldy = gp.along; ex = -1; ey = 0; break;
case 'R': ldx = geo.w / 2; ldy = gp.along; ex = 1; ey = 0; break;
case 'T': ldx = gp.along; ldy = -geo.h / 2; ex = 0; ey = -1; break;
case 'B': ldx = gp.along; ldy = geo.h / 2; ex = 0; ey = 1; break;
default: ldx = 0; ldy = 0; ex = 0; ey = 0;
}
return { gp, ldx, ldy, ex, ey };
});
}
// ── Pin world-space positions ─────────────────────────────
// Returns the world-space position of each pin, derived purely
// from component geometry and rotation. This is the single source
// of truth for where pin dots and stubs are drawn.
//
// These positions match where WireBender places wire endpoints,
// because wbDescriptor() uses the same _pinLocalCoords() source.
// There is no need for a separate _wbX/_wbY back-annotation pass.
pinPositions() {
const s = this._s;
const rot = (s.rotation || 0) * Math.PI / 180;
const fx = s.flipX ? -1 : 1;
const cos = Math.cos(rot), sin = Math.sin(rot);
const rot2 = (dx, dy) => ({ x: dx * fx * cos - dy * sin, y: dx * fx * sin + dy * cos });
return this._pinLocalCoords().map(({ gp, ldx, ldy, ex, ey }, i) => {
const sp = s.pins[i];
const rp = rot2(ldx, ldy);
const re = rot2(ex, ey);
return {
name: gp.name,
pinName: sp?.name ?? gp.name,
x: s.x + rp.x,
y: s.y + rp.y,
ex: re.x,
ey: re.y,
net: sp?.net ?? null,
compId: s.id,
};
});
}
// ── Axis-aligned bounding box (with routing margin) ───────
bbox() {
const geo = this.geometry();
return {
x1: this._s.x - geo.w / 2 - MARGIN,
y1: this._s.y - geo.h / 2 - MARGIN,
x2: this._s.x + geo.w / 2 + MARGIN,
y2: this._s.y + geo.h / 2 + MARGIN,
};
}
// ── Pin stub length ───────────────────────────────────────
stubLen(_pinIdx) { return 16; } // matches WireBender padding default
// ── WireBender descriptor ─────────────────────────────────
// Returns the plain JS object consumed by wb.addComponent().
// NOTE: pins[] here is a plain array for our internal use;
// layout.js converts it to a VectorPinDescriptor before handing
// it to the WASM module.
//
wbDescriptor() {
const geo = this.geometry();
const s = this._s;
const lcs = this._pinLocalCoords();
const SIDE_DIR = {
L: PinDir.DirLeft,
R: PinDir.DirRight,
T: PinDir.DirUp,
B: PinDir.DirDown,
};
const pins = lcs.map(({ gp, ldx, ldy }, i) => {
const pinName = s.pins[i]?.name ?? gp.name;
return {
number: i + 1,
name: pinName,
x: ldx,
y: ldy,
directionFlags: SIDE_DIR[gp.side] ?? PinDir.DirAll,
};
});
return {
id: s.id,
width: geo.w,
height: geo.h,
padding: 16,
pins,
};
}
whTransformed() {
const geo = this.geometry();
const q = Math.round((this._s.rotation || 0) / 90) & 1;
return { w: q ? geo.h : geo.w, h: q ? geo.w : geo.h };
}
wbDescriptorTransformed() {
const geo = this.geometry();
const s = this._s;
const lcs = this._pinLocalCoords();
// Snap rotation to nearest 90° step (0, 1, 2, 3 quarter-turns)
const rot = s.rotation || 0;
const fx = s.flipX ? -1 : 1;
const steps = Math.round(rot / 90) & 3; // 0..3
const cosR = [1, 0, -1, 0][steps];
const sinR = [0, 1, 0, -1][steps];
// Rotated bounding box: w and h swap at 90°/270°
const rw = (steps & 1) ? geo.h : geo.w;
const rh = (steps & 1) ? geo.w : geo.h;
// Map pin side through rotation (CW on canvas, +Y down)
const ROTSIDE = { L: ['L','T','R','B'], T:['T','R','B','L'],
R: ['R','B','L','T'], B:['B','L','T','R'] };
const SIDE_DIR = {
L: PinDir.DirLeft,
R: PinDir.DirRight,
T: PinDir.DirUp,
B: PinDir.DirDown,
};
const pins = lcs.map(({ gp, ldx, ldy }, i) => {
let side = gp.side;
if (s.flipX) {
if (side === 'L') side = 'R';
else if (side === 'R') side = 'L';
}
// Rotate local coords by `steps` quarter-turns CW.
// Result is still centre-origin — exactly what WireBender v2 expects.
const px = cosR * (ldx * fx) - sinR * ldy;
const py = sinR * (ldx * fx) + cosR * ldy;
const rotatedSide = ROTSIDE[side]?.[steps] ?? side;
const pinName = s.pins[i]?.name ?? gp.name;
return {
number: i + 1,
name: pinName,
x: px,
y: py,
directionFlags: SIDE_DIR[rotatedSide] ?? PinDir.DirAll,
};
});
return {
id: s.id,
width: rw,
height: rh,
padding: 16,
pins,
};
}
// ── Drawing ───────────────────────────────────────────────
// labelHint — optional ComponentLabelHint from WireBender.
// When provided, pre-computed positions are used directly and the
// JS-side collision search is skipped entirely. Pass null/undefined
// to fall back to the self-contained JS placement logic.
draw(ctx, isSel, isHov, allComps, allWires, labelHint = null) {
const geo = this.geometry();
const col = this.color;
const s = this._s;
// ── Symbol body — rotated ─────────────────────────────
ctx.save();
ctx.translate(s.x, s.y);
ctx.rotate((s.rotation || 0) * Math.PI / 180);
if (s.flipX) ctx.scale(-1, 1);
if (isSel) {
ctx.shadowColor = col; ctx.shadowBlur = 14;
}
else if (isHov) { ctx.shadowColor = col; ctx.shadowBlur = 7; }
this._drawSymbol(ctx, geo, col, isSel);
ctx.shadowBlur = 0;
ctx.restore();
// ── Labels ────────────────────────────────────────────
this._drawLabels(ctx, geo, col, allComps, allWires, labelHint);
// ── Stubs and pin dots — world space ──────────────────
const STUB = 8;
const pins = this.pinPositions();
pins.forEach((pin) => {
const tipX = pin.x + pin.ex * STUB;
const tipY = pin.y + pin.ey * STUB;
// Stub: body edge → tip
ctx.strokeStyle = col + 'c0';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pin.x, pin.y);
ctx.lineTo(tipX, tipY);
ctx.stroke();
// Pin name label just outside body edge
ctx.fillStyle = col;
ctx.font = 'bold 8px Space Mono,monospace';
ctx.textBaseline = 'middle';
const lx = pin.x + pin.ex * 2, ly = pin.y + pin.ey * 2;
ctx.textAlign = pin.ex < -0.5 ? 'right' : pin.ex > 0.5 ? 'left' : 'center';
ctx.fillText(pin.pinName, lx, ly);
// Pin dot at stub tip (wire connection point)
ctx.beginPath();
ctx.arc(tipX, tipY, 3, 0, Math.PI * 2);
ctx.fillStyle = pin.net ? getNetColor(pin.net) : '#3d4552';
ctx.fill();
if (!pin.net) {
ctx.strokeStyle = '#3d4552'; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(tipX - 4, tipY - 4); ctx.lineTo(tipX + 4, tipY + 4);
ctx.moveTo(tipX + 4, tipY - 4); ctx.lineTo(tipX - 4, tipY + 4);
ctx.stroke();
}
});
}
// labelHint is a ComponentLabelHint returned by WireBender (may be null).
// When present the pre-computed positions are used directly, skipping the
// JS-side candidate search entirely. The fallback path (null hint) keeps
// the original behaviour so the method works before routing has run.
_drawLabels(ctx, geo, col, allComps, allWires, labelHint = null) {
const s = this._s;
// ── Fast path: use WireBender-supplied positions ──────────────────────
if (labelHint) {
ctx.font = `bold 10px 'Space Mono',monospace`;
ctx.fillStyle = col;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(s.ref, labelHint.refPosition.x, labelHint.refPosition.y);
if (s.value) {
ctx.font = `9px 'Space Mono',monospace`;
ctx.fillStyle = 'rgba(230,237,243,0.6)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(s.value, labelHint.valuePosition.x, labelHint.valuePosition.y);
}
return;
}
// ── Fallback path: self-contained JS collision search ─────────────────
// Used before routeAll() has been called (e.g. during initial load or
// while the WireBender module is still initialising).
const isColliding = (rect) => {
const BBOX_INFLATE = 4;
// 1. Check components
for (const other of allComps) {
const otherGeo = createComp(other).geometry();
const otherRot = (other.rotation || 0) * Math.PI / 180;
const otherCos = Math.abs(Math.cos(otherRot));
const otherSin = Math.abs(Math.sin(otherRot));
const otherW = otherGeo.w * otherCos + otherGeo.h * otherSin;
const otherH = otherGeo.w * otherSin + otherGeo.h * otherCos;
const otherRect = {
x1: other.x - otherW / 2 - BBOX_INFLATE, y1: other.y - otherH / 2 - BBOX_INFLATE,
x2: other.x + otherW / 2 + BBOX_INFLATE, y2: other.y + otherH / 2 + BBOX_INFLATE,
};
if (rect.x1 < otherRect.x2 && rect.x2 > otherRect.x1 &&
rect.y1 < otherRect.y2 && rect.y2 > otherRect.y1) {
return true;
}
}
// 2. Check wires
for (const w of allWires) {
if (!w.points) continue;
for (let i = 0; i < w.points.length - 1; i++) {
const p1 = w.points[i], p2 = w.points[i + 1];
const minX = Math.min(p1.x, p2.x) - 2;
const maxX = Math.max(p1.x, p2.x) + 2;
const minY = Math.min(p1.y, p2.y) - 2;
const maxY = Math.max(p1.y, p2.y) + 2;
if (rect.x1 <= maxX && rect.x2 >= minX && rect.y1 <= maxY && rect.y2 >= minY) {
return true;
}
}
}
return false;
};
const findPosition = (text, textW, textH, candidates, reservedRect = null) => {
for (const candidate of candidates) {
const labelRect = {
x1: candidate.x - textW / 2, y1: candidate.y - textH / 2,
x2: candidate.x + textW / 2, y2: candidate.y + textH / 2
};
if (isColliding(labelRect)) continue;
if (reservedRect &&
labelRect.x1 < reservedRect.x2 && labelRect.x2 > reservedRect.x1 &&
labelRect.y1 < reservedRect.y2 && labelRect.y2 > reservedRect.y1) {
continue;
}
return candidate;
}
return candidates[0];
};
const rot = (s.rotation || 0) * Math.PI / 180;
const cosR = Math.abs(Math.cos(rot)), sinR = Math.abs(Math.sin(rot));
const worldW = geo.w * cosR + geo.h * sinR;
const worldH = geo.w * sinR + geo.h * cosR;
const PADDING = 6;
// --- REF label ---
ctx.font = `bold 10px 'Space Mono',monospace`;
const refW = ctx.measureText(s.ref).width;
const refH = 12;
const refCandidates = [
{ x: s.x, y: s.y - worldH / 2 - refH / 2 - PADDING },
{ x: s.x, y: s.y + worldH / 2 + refH / 2 + PADDING },
{ x: s.x + worldW / 2 + refW / 2 + PADDING, y: s.y },
{ x: s.x - worldW / 2 - refW / 2 - PADDING, y: s.y },
{ x: s.x + worldW / 2 + refW / 2 + PADDING, y: s.y - worldH / 2 },
{ x: s.x - worldW / 2 - refW / 2 - PADDING, y: s.y - worldH / 2 }
];
const refPos = findPosition(s.ref, refW, refH, refCandidates);
const refRect = { x1: refPos.x - refW / 2, y1: refPos.y - refH / 2, x2: refPos.x + refW / 2, y2: refPos.y + refH / 2 };
ctx.fillStyle = col;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(s.ref, refPos.x, refPos.y);
// --- VALUE label ---
if (s.value) {
ctx.font = `9px 'Space Mono',monospace`;
const valW = ctx.measureText(s.value).width;
const valH = 11;
const valCandidates = [
{ x: s.x, y: s.y + worldH / 2 + valH / 2 + PADDING },
{ x: s.x, y: s.y - worldH / 2 - valH / 2 - PADDING },
{ x: s.x + worldW / 2 + valW / 2 + PADDING, y: s.y },
{ x: s.x - worldW / 2 - valW / 2 - PADDING, y: s.y },
{ x: s.x + worldW / 2 + valW / 2 + PADDING, y: s.y + worldH / 2 },
{ x: s.x - worldW / 2 - valW / 2 - PADDING, y: s.y + worldH / 2 }
];
const valPos = findPosition(s.value, valW, valH, valCandidates, refRect);
ctx.fillStyle = 'rgba(230,237,243,0.6)';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(s.value, valPos.x, valPos.y);
}
}
_drawSymbol(_ctx, _geo, _col, _isSel) {
throw new Error(`${this.constructor.name}._drawSymbol not implemented`);
}
// ── Hit-test ─────────────────────────────────────────────
hitTest(wx, wy) {
const geo = this.geometry();
const s = this._s;
const hw = geo.w / 2 + 8, hh = geo.h / 2 + 8;
const rot = -(s.rotation || 0) * Math.Pi / 180;
const dx = wx - s.x, dy = wy - s.y;
const lx = dx * Math.cos(rot) - dy * Math.sin(rot);
const ly = dx * Math.sin(rot) + dy * Math.cos(rot);
return Math.abs(lx) <= hw && Math.abs(ly) <= hh;
}
}
// ── Resistor ──────────────────────────────────────────────────
export class ResistorComp extends CompBase {
static prefixes = ['R'];
static typeKey = 'R';
get color() { return '#4ade80'; }
get label() { return 'RES'; }
_buildGeometry() {
const cp = this._s.pins;
return {
w: 60, h: 24,
pins: [
{ name: cp[0]?.name ?? '1', side: 'L', along: 0 },
{ name: cp[1]?.name ?? '2', side: 'R', along: 0 },
],
};
}
_drawSymbol(ctx, g, col, sel) {
const bw = 28, bh = 12;
ctx.strokeStyle = col; ctx.lineWidth = sel ? 2 : 1.5;
ctx.fillStyle = 'rgba(0,0,0,0.4)';
ctx.beginPath();
ctx.moveTo(-g.w / 2, 0); ctx.lineTo(-bw / 2, 0);
ctx.moveTo(bw / 2, 0); ctx.lineTo(g.w / 2, 0);
ctx.stroke();
ctx.beginPath(); ctx.rect(-bw / 2, -bh / 2, bw, bh);
ctx.fill(); ctx.stroke();
ctx.fillStyle = col + '60';
ctx.fillRect(-4, -bh / 2, 3, bh);
}
}
// ── Capacitor ─────────────────────────────────────────────────
export class CapacitorComp extends CompBase {
static prefixes = ['C'];
static typeKey = 'C';
get color() { return '#60a5fa'; }
get label() { return 'CAP'; }
_buildGeometry() {
const cp = this._s.pins;
return {
w: 28, h: 40,
pins: [
{ name: cp[0]?.name ?? '+', side: 'T', along: 0 },
{ name: cp[1]?.name ?? '-', side: 'B', along: 0 },
],
};
}
_drawSymbol(ctx, g, col, sel) {
const gap = 5, pw = 12;
ctx.strokeStyle = col; ctx.lineWidth = sel ? 2 : 1.5;
ctx.beginPath();
ctx.moveTo(0, -g.h / 2); ctx.lineTo(0, -gap);
ctx.moveTo(0, gap); ctx.lineTo(0, g.h / 2);
ctx.stroke();
ctx.lineWidth = sel ? 3 : 2;
ctx.beginPath();
ctx.moveTo(-pw, -gap); ctx.lineTo(pw, -gap);
ctx.moveTo(-pw, gap); ctx.lineTo(pw, gap);
ctx.stroke();
}
}
// ── Inductor ──────────────────────────────────────────────────
export class InductorComp extends CompBase {
static prefixes = ['L'];
static typeKey = 'L';
get color() { return '#f472b6'; }
get label() { return 'IND'; }
_buildGeometry() {
const cp = this._s.pins;
return {
w: 60, h: 20,
pins: [
{ name: cp[0]?.name ?? '1', side: 'L', along: 0 },
{ name: cp[1]?.name ?? '2', side: 'R', along: 0 },
],
};
}
_drawSymbol(ctx, g, col, sel) {
ctx.strokeStyle = col; ctx.lineWidth = sel ? 2 : 1.5;
ctx.beginPath();
ctx.moveTo(-g.w / 2, 0); ctx.lineTo(-22, 0);
ctx.moveTo(22, 0); ctx.lineTo(g.w / 2, 0);
ctx.stroke();
for (let i = 0; i < 4; i++) {
ctx.beginPath();
ctx.arc(-16 + i * 11, 0, 6, Math.PI, 0, false);
ctx.stroke();
}
}
}
// ── Diode (base, shared by DiodeComp and ZenerComp) ──────────
class _DiodeBase extends CompBase {
get color() { return '#fb923c'; }
_buildGeometry() {
const cp = this._s.pins;
return {
w: 44, h: 24,
pins: [
{ name: cp[0]?.name ?? 'A', side: 'L', along: 0 },
{ name: cp[1]?.name ?? 'K', side: 'R', along: 0 },
],
};
}
_drawBody(ctx, g, col, sel, isZener) {
ctx.strokeStyle = col; ctx.lineWidth = sel ? 2 : 1.5;
ctx.fillStyle = col + '40';
ctx.beginPath();
ctx.moveTo(-g.w / 2, 0); ctx.lineTo(-10, 0);
ctx.moveTo(10, 0); ctx.lineTo(g.w / 2, 0);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(-10, -10); ctx.lineTo(-10, 10); ctx.lineTo(10, 0);
ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.lineWidth = sel ? 2.5 : 2;
ctx.beginPath();
if (isZener) {
ctx.moveTo(10, -10); ctx.lineTo(10, 10);
ctx.moveTo(10, -10); ctx.lineTo(14, -14);
ctx.moveTo(10, 10); ctx.lineTo(6, 14);
} else {
ctx.moveTo(10, -10); ctx.lineTo(10, 10);
}
ctx.stroke();
}
}
export class DiodeComp extends _DiodeBase {
static prefixes = ['D', 'LED'];
static typeKey = 'D';
get label() { return 'DIO'; }
_drawSymbol(ctx, g, col, sel) { this._drawBody(ctx, g, col, sel, false); }
}
export class ZenerComp extends _DiodeBase {
static prefixes = ['Z'];
static typeKey = 'Z';
get label() { return 'ZEN'; }
_drawSymbol(ctx, g, col, sel) { this._drawBody(ctx, g, col, sel, true); }
}
// ── Generic IC box ────────────────────────────────────────────
export class ICComp extends CompBase {
static prefixes = []; // catch-all — matched when no other class claims the prefix
static typeKey = 'IC';
get color() { return '#a78bfa'; }
get label() { return 'IC'; }
_buildGeometry() {
const cp = this._s.pins;
const pc = cp.length;
const pL = Math.ceil(pc / 2), pR = Math.floor(pc / 2);
const rows = Math.max(pL, pR);
// Force odd slot count so centre slot has along=0
const slots = rows % 2 === 0 ? rows + 1 : rows;
const midSlot = Math.floor(slots / 2);
const alongOf = i => (i - midSlot) * PIN_PITCH;
const h = Math.max(60, (slots + 1) * PIN_PITCH);
const w = 80;
const pins = [];
cp.forEach((p, i) => {
if (i < pL) {
pins.push({ name: p.name, side: 'L', along: alongOf(i) });
} else {
const j = i - pL;
pins.push({ name: p.name, side: 'R', along: alongOf(pL - 1 - j) });
}
});
return { w, h, pins };
}
_drawSymbol(ctx, g, col, sel) {
ctx.strokeStyle = col;
ctx.fillStyle = 'rgba(167,139,250,0.07)';
ctx.lineWidth = sel ? 2 : 1.5;
ctx.beginPath(); ctx.rect(-g.w / 2, -g.h / 2, g.w, g.h);
ctx.fill(); ctx.stroke();
// Orientation notch
ctx.beginPath(); ctx.arc(0, -g.h / 2, 5, 0, Math.PI, false);
ctx.strokeStyle = col + '50'; ctx.stroke();
// Pin labels inside box
ctx.lineWidth = 1;
g.pins.forEach((gp, i) => {
const name = this._s.pins[i]?.name ?? gp.name;
if (gp.side === 'L') {
const px = -g.w / 2, py = gp.along;
ctx.fillStyle = col + '90'; ctx.font = '7px Space Mono,monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(name, px + 3, py);
} else {
const px = g.w / 2, py = gp.along;
ctx.fillStyle = col + '90'; ctx.font = '7px Space Mono,monospace';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(name, px - 3, py);
}
});
}
}
// ── KiCad Imported Symbol ─────────────────────────────────────
const KICAD_SCALE = 20 / 2.54;
export class KiCadComp extends CompBase {
static prefixes = [];
static typeKey = 'KICAD';
get color() { return '#f0c040'; }
get label() { return this._s.kicadData?.name || 'KICAD'; }
_buildGeometry() {
let minX = 0, minY = 0, maxX = 0, maxY = 0;
const data = this._s.kicadData;
if (data && data.pins) {
data.pins.forEach(p => {
minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y);
});
}
return { w: Math.max(40, maxX - minX + 20), h: Math.max(40, maxY - minY + 20), pins: [] };
}
_pinLocalCoords() {
if (!this._s.kicadData || !this._s.kicadData.pins) return [];
// Map over logical footprint pins, fetching the assigned physical KiCad pin geometry
return this._s.pins.map((sp, i) => {
const p = this._s.kicadData.pins.find(kp => kp.num === sp.name)
|| this._s.kicadData.pins.find(kp => kp.name === sp.name)
|| this._s.kicadData.pins[i]; // fallback
if (!p) return { gp: { name: sp.name, num: sp.name }, ldx: 0, ldy: 0, ex: 1, ey: 0 };
let ex = 0, ey = 0, a = p.angle || 0;
if (a === 0) { ex = -1; ey = 0; }
else if (a === 90) { ex = 0; ey = 1; }
else if (a === 180) { ex = 1; ey = 0; }
else if (a === 270) { ex = 0; ey = -1; }
return { gp: { name: p.name, num: p.num }, ldx: p.x, ldy: p.y, ex, ey };
});
}
wbDescriptor() {
const geo = this.geometry();
const s = this._s;
const lcs = this._pinLocalCoords();
const pins = lcs.map(({ gp, ldx, ldy, ex, ey }, i) => {
let dir = 15; // DirAll
if (ex < -0.5) dir = 4; else if (ex > 0.5) dir = 8;
else if (ey < -0.5) dir = 1; else if (ey > 0.5) dir = 2;
// Number must be 1-based index (i+1) to align perfectly with the footprint pins
return { number: i + 1, name: s.pins[i]?.name || gp.name || String(gp.num), x: ldx, y: ldy, directionFlags: dir };
});
return { id: s.id, width: geo.w, height: geo.h, padding: 16, pins };
}
wbDescriptorTransformed() {
const geo = this.geometry();
const s = this._s;
const lcs = this._pinLocalCoords();
const rot = s.rotation || 0;
const fx = s.flipX ? -1 : 1;
const steps = Math.round(rot / 90) & 3;
const cosR = [1, 0, -1, 0][steps];
const sinR = [0, 1, 0, -1][steps];
const rw = (steps & 1) ? geo.h : geo.w;
const rh = (steps & 1) ? geo.w : geo.h;
const pins = lcs.map(({ gp, ldx, ldy, ex, ey }, i) => {
const px = cosR * (ldx * fx) - sinR * ldy;
const py = sinR * (ldx * fx) + cosR * ldy;
const rex = cosR * (ex * fx) - sinR * ey;
const rey = sinR * (ex * fx) + cosR * ey;
let dir = 15; // DirAll
if (rex < -0.5) dir = 4; else if (rex > 0.5) dir = 8;
else if (rey < -0.5) dir = 1; else if (rey > 0.5) dir = 2;
// Number must be 1-based index (i+1) to align perfectly with the footprint pins
return { number: i + 1, name: s.pins[i]?.name || gp.name || String(gp.num), x: px, y: py, directionFlags: dir };
});
return { id: s.id, width: rw, height: rh, padding: 16, pins };
}
_drawSymbol(ctx, g, col, sel) {
const data = this._s.kicadData;
if (!data) return;
ctx.strokeStyle = col;
ctx.lineWidth = sel ? 2 : 1.5;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
(data.graphics || []).forEach(node => {
if (!Array.isArray(node)) return;
const tag = node[0];
const ptsNode = node.find(x => Array.isArray(x) && x[0] === 'pts');
const pts = [];
if (ptsNode) {
for (let i = 1; i < ptsNode.length; i++) {
if (ptsNode[i][0] === 'xy') pts.push({ x: parseFloat(ptsNode[i][1]) * KICAD_SCALE, y: -parseFloat(ptsNode[i][2]) * KICAD_SCALE });
}
}
ctx.beginPath();
if (tag === 'polyline' && pts.length > 0) {
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
} else if (tag === 'rectangle') {
const start = node.find(x => Array.isArray(x) && x[0] === 'start');
const end = node.find(x => Array.isArray(x) && x[0] === 'end');
if (start && end) {
const sx = parseFloat(start[1]) * KICAD_SCALE, sy = -parseFloat(start[2]) * KICAD_SCALE;
const ex = parseFloat(end[1]) * KICAD_SCALE, ey = -parseFloat(end[2]) * KICAD_SCALE;
ctx.rect(Math.min(sx, ex), Math.min(sy, ey), Math.abs(ex - sx), Math.abs(ey - sy));
ctx.stroke();
}
} else if (tag === 'circle') {
const center = node.find(x => Array.isArray(x) && x[0] === 'center');
const radius = node.find(x => Array.isArray(x) && x[0] === 'radius');
if (center && radius) {
ctx.arc(parseFloat(center[1]) * KICAD_SCALE, -parseFloat(center[2]) * KICAD_SCALE, parseFloat(radius[1]) * KICAD_SCALE, 0, Math.PI * 2);
ctx.stroke();
}
}
});
// KiCad pin lines
data.pins.forEach(p => {
let dx = 0, dy = 0;
if (p.angle === 0) dx = p.len;
else if (p.angle === 90) dy = -p.len;
else if (p.angle === 180) dx = -p.len;
else if (p.angle === 270) dy = p.len;
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x + dx, p.y + dy); ctx.stroke();
});
}
draw(ctx, isSel, isHov, allComps, allWires, labelHint = null) {
const geo = this.geometry(); const col = this.color; const s = this._s;
ctx.save(); ctx.translate(s.x, s.y); ctx.rotate((s.rotation || 0) * Math.PI / 180);
if (s.flipX) ctx.scale(-1, 1);
if (isSel) { ctx.shadowColor = col; ctx.shadowBlur = 14; }
else if (isHov) { ctx.shadowColor = col; ctx.shadowBlur = 7; }
this._drawSymbol(ctx, geo, col, isSel);
ctx.shadowBlur = 0; ctx.restore();
this._drawLabels(ctx, geo, col, allComps, allWires, labelHint);
this.pinPositions().forEach(pin => {
ctx.fillStyle = col; ctx.font = 'bold 8px Space Mono,monospace'; ctx.textBaseline = 'middle';
ctx.textAlign = pin.ex < -0.5 ? 'right' : pin.ex > 0.5 ? 'left' : 'center';
ctx.fillText(pin.pinName, pin.x + pin.ex * 4, pin.y + pin.ey * 4);
ctx.beginPath(); ctx.arc(pin.x, pin.y, 3, 0, Math.PI * 2);
ctx.fillStyle = pin.net ? getNetColor(pin.net) : '#3d4552'; ctx.fill();
if (!pin.net) {
ctx.strokeStyle = '#3d4552'; ctx.lineWidth = 1; ctx.beginPath();
ctx.moveTo(pin.x - 4, pin.y - 4); ctx.lineTo(pin.x + 4, pin.y + 4);
ctx.moveTo(pin.x + 4, pin.y - 4); ctx.lineTo(pin.x - 4, pin.y + 4); ctx.stroke();
}
});
}
}
// ── Registry & factory ────────────────────────────────────────
// ALL_TYPES is the single authoritative list of component classes.
// To add a new type: define the class above with static
// prefixes/typeKey, then append it here.
const ALL_TYPES = [
ResistorComp,
CapacitorComp,
InductorComp,
DiodeComp,
ZenerComp,
KiCadComp,
ICComp, // must be last — it is the catch-all (prefixes = [])
];
// typeKey → class (e.g. 'R' → ResistorComp)
const _byType = new Map(ALL_TYPES.map(Cls => [Cls.typeKey, Cls]));
// Sorted prefix list: longest first so 'LED' matches before 'L'.
const _byPrefix = ALL_TYPES
.flatMap(Cls => Cls.prefixes.map(p => ({ prefix: p, typeKey: Cls.typeKey })))
.sort((a, b) => b.prefix.length - a.prefix.length);
/**
* createComp(stateObj) → CompBase instance
* Wraps a plain state object with the correct component class.
*/
export function createComp(state) {
const Cls = _byType.get(state.type) ?? ICComp;
return new Cls(state);
}
/**
* classifyComp(ref) → typeKey string
* Derives a component type from its reference designator.
*/
export function classifyComp(ref) {
const u = ref.toUpperCase();
for (const { prefix, typeKey } of _byPrefix)
if (u.startsWith(prefix)) return typeKey;
return ICComp.typeKey;
}
export const resolvePin = (comp, ndPin) => {
const pins = createComp(comp).pinPositions();
const idx = comp.pins.findIndex(p => p.name === ndPin || p.name === String(ndPin));
return { pin: pins[idx >= 0 ? idx : 0], idx: idx >= 0 ? idx : 0 };
};
export const compColor = type => createComp({ type, pins: [] }).color;
export const compLabel = type => createComp({ type, pins: [] }).label;
export const compGeometry = comp => createComp(comp).geometry();
export const getPinPositions = comp => createComp(comp).pinPositions();
export const compBBox = comp => createComp(comp).bbox();
export const pinStubLen = (comp, idx) => createComp(comp).stubLen(idx);

469
docs/schema/db.js Normal file
View file

@ -0,0 +1,469 @@
// ═══════════════════════════════════════════════════════════════
// 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();

132
docs/schema/draw.js Normal file
View file

@ -0,0 +1,132 @@
import { S, canvas, ctx, w2s, getNetColor, GRID } from './state.js';
import { createComp } from './components.js';
// ── Component drawing ─────────────────────────────────────────
// New code:
export function drawComponent(comp) {
const isSel = S.selectedComp === comp.id;
const isHov = S.hoverComp === comp.id;
const labelHint = S.componentLabels ? S.componentLabels[comp.id] : null;
createComp(comp).draw(ctx, isSel, isHov, S.components, S.wires, labelHint);
}
// ── Wire drawing ──────────────────────────────────────────────
// Rendering order (from prototype):
// Pass 1 — white halos (strokeStyle=white, lineWidth=3.5)
// Pass 2 — net colours (lineWidth=1.5)
// Pass 3 — junction dots
//
// The white halo is critical for readability: it creates a visible
// separation border when wires from different nets run close together.
export function drawWires() {
// Pass 1: white halos for all wires
S.wires.forEach(wire => {
if (!wire.points || wire.points.length < 2) return;
ctx.strokeStyle = 'white';
ctx.lineWidth = 3.5;
ctx.lineJoin = 'round';
ctx.globalAlpha = 0.85;
ctx.beginPath();
ctx.moveTo(wire.points[0].x, wire.points[0].y);
wire.points.forEach((p, i) => { if (i > 0) ctx.lineTo(p.x, p.y); });
ctx.stroke();
ctx.globalAlpha = 1;
});
// Pass 2: net-coloured lines on top
S.wires.forEach(wire => {
if (!wire.points || wire.points.length < 2) return;
const isWIP = S.nets.find(n => n.name === wire.net)?.isWip;
const baseCol = isWIP ? '#f0c040' : getNetColor(wire.net);
const isSelNet = S.selectedNet === wire.net;
const isSelWire = S.selectedWire?.wireId === wire.id;
const drawCol = (isSelNet || isSelWire) ? '#ffffff' : baseCol;
ctx.strokeStyle = drawCol;
ctx.lineWidth = (isSelNet || isSelWire) ? 2.5 : 1.5;
ctx.lineJoin = 'round';
ctx.globalAlpha = 0.95;
ctx.beginPath();
ctx.moveTo(wire.points[0].x, wire.points[0].y);
wire.points.forEach((p, i) => { if (i > 0) ctx.lineTo(p.x, p.y); });
ctx.stroke();
ctx.globalAlpha = 1;
// Selected segment highlight
if (isSelWire) {
const si = S.selectedWire.segIdx;
if (si < wire.points.length - 1) {
ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 4; ctx.globalAlpha = 0.35;
ctx.beginPath();
ctx.moveTo(wire.points[si].x, wire.points[si].y);
ctx.lineTo(wire.points[si+1].x, wire.points[si+1].y);
ctx.stroke(); ctx.globalAlpha = 1;
}
}
// Hovered segment highlight
if (S.hoverWireSeg?.wireId === wire.id) {
const si = S.hoverWireSeg.segIdx;
if (si < wire.points.length - 1) {
ctx.strokeStyle = baseCol; ctx.lineWidth = 3; ctx.globalAlpha = 0.45;
ctx.beginPath();
ctx.moveTo(wire.points[si].x, wire.points[si].y);
ctx.lineTo(wire.points[si+1].x, wire.points[si+1].y);
ctx.stroke(); ctx.globalAlpha = 1;
}
}
});
// Pass 3: junction dots
_drawJunctions();
}
// ── Junction dots ─────────────────────────────────────────────
// WireBender computes junction positions explicitly and stores
// them in S.junctionPoints after each route.
function _drawJunctions() {
(S.junctionPoints || []).forEach(({ x, y, net }) => {
const col = getNetColor(net);
// Filled circle
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fillStyle = col;
ctx.fill();
// Dark outline for contrast (matches prototype)
ctx.strokeStyle = '#000';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.stroke();
});
}
// ── Background grid ───────────────────────────────────────────
export function drawGrid() {
const step = GRID * S.zoom;
if (step < 8) return;
const o = w2s(0, 0);
ctx.strokeStyle = '#1a2130'; ctx.lineWidth = 0.5;
for (let x = o.x % step; x < canvas.width; x += step) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
}
for (let y = o.y % step; y < canvas.height; y += step) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke();
}
}
// ── Main render ───────────────────────────────────────────────
export function render() {
// console.log('[schema] render canvas:', canvas?.width, canvas?.height, 'ctx:', !!ctx);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#0d1117'; ctx.fillRect(0, 0, canvas.width, canvas.height);
drawGrid();
ctx.save();
ctx.translate(canvas.width / 2 + S.viewX, canvas.height / 2 + S.viewY);
ctx.scale(S.zoom, S.zoom);
drawWires();
S.components.forEach(c => drawComponent(c));
ctx.restore();
document.getElementById('zoom-ind').textContent = Math.round(S.zoom * 100) + '%';
document.getElementById('empty-state').style.display = S.hasData ? 'none' : 'flex';
}

28
docs/schema/history.js Normal file
View file

@ -0,0 +1,28 @@
// ── Undo history ─────────────────────────────────────────────
// Kept separate to avoid circular imports between interaction.js and ui.js.
import { S } from './state.js';
import { routeWires } from './layout.js';
import { render } from './draw.js';
export function pushHistory() {
S.history.push(JSON.stringify(
S.components.map(c => ({ id: c.id, x: c.x, y: c.y, rotation: c.rotation, flipX: c.flipX }))
));
if (S.history.length > 60) S.history.shift();
}
export function undo() {
if (!S.history.length) {
import('./ui.js').then(({ toast }) => toast('Nothing to undo', 'warn'));
return;
}
JSON.parse(S.history.pop()).forEach(s => {
const c = S.components.find(x => x.id === s.id);
if (!c) return;
c.x = s.x; c.y = s.y; c.rotation = s.rotation; c.flipX = s.flipX;
});
// routeWires() fully syncs all geometry (including restored rotations)
// and positions into WireBender before calling routeAll().
routeWires();
import('./ui.js').then(({ toast }) => toast('Undone'));
}

551
docs/schema/interaction.js Normal file
View file

@ -0,0 +1,551 @@
// ── Interaction ───────────────────────────────────────────────
import { db } from './db.js';
import { S, canvas, s2w, snap } from './state.js';
import { compGeometry } from './components.js';
import { routeWires, fitView, moveComponentRoute } from './layout.js';
import { render } from './draw.js';
import { pushHistory, undo } from './history.js';
import {
showProperties, showNetProperties,
showDefaultProps, updateSidePanels, toast,
} from './ui.js';
// Autosave callbacks injected by app.js
let _saveComponentLayout = null;
let _saveViewport = null;
// ── Hit testing ───────────────────────────────────────────────
export function hitTestComp(wx, wy) {
for (const c of [...S.components].reverse()) {
const g = compGeometry(c);
const hw = g.w / 2 + 8, hh = g.h / 2 + 8;
const rot = -(c.rotation || 0) * Math.PI / 180;
const dx = wx - c.x, dy = wy - c.y;
const lx = dx * Math.cos(rot) - dy * Math.sin(rot);
const ly = dx * Math.sin(rot) + dy * Math.cos(rot);
if (Math.abs(lx) <= hw && Math.abs(ly) <= hh) return c;
}
return null;
}
export function hitTestWire(wx, wy) {
// Returns the net name of the first wire hit, or null.
const THRESH = 6 / S.zoom;
for (const wire of [...S.wires].reverse()) {
for (let i = 0; i < wire.points.length - 1; i++) {
const a = wire.points[i], b = wire.points[i + 1];
const isH = Math.abs(a.y - b.y) < 1;
const isV = Math.abs(a.x - b.x) < 1;
if (isH) {
const lo = Math.min(a.x, b.x), hi = Math.max(a.x, b.x);
if (Math.abs(wy - a.y) < THRESH && wx >= lo - THRESH && wx <= hi + THRESH)
return wire.net;
} else if (isV) {
const lo = Math.min(a.y, b.y), hi = Math.max(a.y, b.y);
if (Math.abs(wx - a.x) < THRESH && wy >= lo - THRESH && wy <= hi + THRESH)
return wire.net;
}
}
}
return null;
}
// ── Context menu ──────────────────────────────────────────────
// A lightweight DOM context menu that works on both desktop
// (right-click) and touch (long-press, handled below).
let _ctxMenu = null;
function _hideContextMenu() {
if (_ctxMenu) { _ctxMenu.remove(); _ctxMenu = null; }
}
function _showContextMenu(screenX, screenY, comp) {
_hideContextMenu();
const menu = document.createElement('div');
menu.id = 'ctx-menu';
menu.style.cssText = `
position:fixed; left:${screenX}px; top:${screenY}px;
background:var(--surface); border:1px solid var(--border);
border-radius:6px; padding:4px 0; z-index:500; min-width:160px;
font-family:'Space Mono',monospace; font-size:11px;
box-shadow:0 4px 20px rgba(0,0,0,.5);
`;
const items = [
{ label: '↺ Rotate −90°', action: () => _rotateComp(comp, { rotation: 270, flipX: false }) },
{ label: '↻ Rotate 90°', action: () => _rotateComp(comp, { rotation: 90, flipX: false }) },
{ label: '↕ Rotate 180°', action: () => _rotateComp(comp, { rotation: 180, flipX: false }) },
{ label: '↕ Mirror Vertically', action: () => _rotateComp(comp, { rotation: 180, flipX: true }) },
{ label: '↔ Mirror Horizontally', action: () => _rotateComp(comp, { rotation: 0, flipX: true }) },
{ label: '⟳ Reset rotation', action: () => _rotateComp(comp, d4delta({ rotation: comp.rotation, flipX: comp.flipX }, { rotation: 0, flipX: 0 })) },
{
label: '✨ Replace Symbol...', action: () => {
import('./ui.js').then(m => m.showReplaceSymbolModal(comp));
}
},
];
if (comp.type === 'KICAD') {
items.push({
label: '↺ Reset to Default Symbol', action: () => {
pushHistory();
import('./components.js').then(async ({ classifyComp }) => {
comp.type = classifyComp(comp.ref);
comp.componentTypeId = null;
comp.kicadData = null;
// Reset pin names to standard sequential numbers
comp.pins.forEach((p, i) => { p.name = String(i + 1); });
await routeWires();
if (_saveComponentLayout) _saveComponentLayout(comp.id);
import('./ui.js').then(m => {
m.toast('Reset to default symbol');
m.showProperties(comp);
m.updateSidePanels();
});
});
}
});
}
items.forEach(({ label, action }) => {
const item = document.createElement('div');
item.textContent = label;
item.style.cssText = `
padding:7px 14px; cursor:pointer; color:var(--text);
transition:background .1s;
`;
item.onmouseenter = () => { item.style.background = 'var(--surface2)'; item.style.color = 'var(--accent)'; };
item.onmouseleave = () => { item.style.background = ''; item.style.color = 'var(--text)'; };
item.onclick = () => { _hideContextMenu(); action(); };
menu.appendChild(item);
});
document.body.appendChild(menu);
_ctxMenu = menu;
// Clamp to viewport
const r = menu.getBoundingClientRect();
if (r.right > window.innerWidth) menu.style.left = (screenX - r.width) + 'px';
if (r.bottom > window.innerHeight) menu.style.top = (screenY - r.height) + 'px';
// Close on next click anywhere
setTimeout(() => document.addEventListener('click', _hideContextMenu, { once: true }), 0);
}
function d4inverse(t) {
const r = t.rotation || 0;
if (!t.flipX)
return { rotation: (360 - r) % 360, flipX: false };
else
return { rotation: r, flipX: true };
}
function d4compose(a, b) {
const rA = a.rotation || 0;
const rB = b.rotation || 0;
const fB = b.flipX || false;
if (!a.flipX)
return { rotation: (rA + rB) % 360, flipX: fB };
else
return { rotation: (rA + (360 - rB)) % 360, flipX: !fB };
}
function d4delta(current, target) {
// Delta to reach target is solved via extrinsic composition: Δ * C = T => Δ = T * C^-1
return d4compose(target, d4inverse(current));
}
function _rotateComp(comp, delta) {
pushHistory();
const prev = { rotation: comp.rotation || 0, flipX: comp.flipX || false };
// Swap compose order: Δ * prev applies transformations extrinsically (in world space)
const next = d4compose(delta, prev);
comp.rotation = next.rotation;
comp.flipX = next.flipX;
showProperties(comp);
updateSidePanels();
moveComponentRoute(comp.id).then(() => {
if (_saveComponentLayout) _saveComponentLayout(comp.id);
});
}
// ── Touch long-press for context menu ─────────────────────────
let _longPressTimer = null;
let _longPressComp = null;
function _cancelLongPress() {
if (_longPressTimer) { clearTimeout(_longPressTimer); _longPressTimer = null; }
}
// ── Mouse event listeners ─────────────────────────────────────
let _mouseDown = false;
let _didDrag = false; // distinguish click from drag on mouseup
export function initInteraction(saveComponentLayout, saveViewport) {
_saveComponentLayout = saveComponentLayout || null;
_saveViewport = saveViewport || null;
// ── mousedown ──────────────────────────────────────────────
canvas.addEventListener('mousedown', e => {
document.getElementById('left-panel')?.classList.remove('open');
// {
// const w = s2w(e.offsetX, e.offsetY);
// console.log('[schema] mousedown world:', w.x, w.y,
// 'components:', S.components.map(c => `${c.ref}(${Math.round(c.x)},${Math.round(c.y)})`).join(' '));
// }
_hideContextMenu();
_mouseDown = true;
_didDrag = false;
// Middle-button or alt+left → pan
if (e.button === 1 || e.button === 2 || e.altKey) {
S.panning = true;
S.panStart = { x: e.offsetX, y: e.offsetY, vx: S.viewX, vy: S.viewY };
return;
}
const w = s2w(e.offsetX, e.offsetY);
const hC = hitTestComp(w.x, w.y);
if (hC) {
S.selectedComp = hC.id; S.selectedNet = null; S.selectedWire = null;
pushHistory();
S.dragging = { compId: hC.id, ox: w.x - hC.x, oy: w.y - hC.y };
showProperties(hC); updateSidePanels(); render();
return;
}
// Click on wire → select whole net
const hitNet = hitTestWire(w.x, w.y);
if (hitNet) {
const net = S.nets.find(n => n.name === hitNet);
S.selectedNet = hitNet;
S.selectedComp = null; S.selectedWire = null;
if (net) showNetProperties(net);
updateSidePanels(); render();
return;
}
// Click on empty canvas → pan (left-button drag on empty space)
S.panning = true;
S.panStart = { x: e.offsetX, y: e.offsetY, vx: S.viewX, vy: S.viewY };
S.selectedComp = null; S.selectedNet = null; S.selectedWire = null;
showDefaultProps(); updateSidePanels(); render();
});
// ── mousemove ─────────────────────────────────────────────
canvas.addEventListener('mousemove', e => {
const w = s2w(e.offsetX, e.offsetY);
document.getElementById('coord-readout').textContent =
`X: ${Math.round(w.x)} Y: ${Math.round(w.y)}`;
if (S.panning && _mouseDown) {
const dx = e.offsetX - S.panStart.x;
const dy = e.offsetY - S.panStart.y;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) _didDrag = true;
S.viewX = S.panStart.vx + dx;
S.viewY = S.panStart.vy + dy;
canvas.style.cursor = 'grabbing';
render(); return;
}
if (S.dragging && _mouseDown) {
_didDrag = true;
const c = S.components.find(x => x.id === S.dragging.compId);
if (!c) return;
c.x = snap(w.x - S.dragging.ox);
c.y = snap(w.y - S.dragging.oy);
render(); return;
}
// Hover — cursor only, no hint bar for wires (they're not draggable)
const hC = hitTestComp(w.x, w.y);
const hW = hC ? null : hitTestWire(w.x, w.y);
const prevHoverComp = S.hoverComp;
S.hoverComp = hC ? hC.id : null;
S.hoverWireSeg = null; // no longer tracking individual segments
const newCursor = hC ? 'move' : hW ? 'pointer' : 'default';
if (canvas.style.cursor !== newCursor) canvas.style.cursor = newCursor;
if (S.hoverComp !== prevHoverComp) render();
});
// ── mouseup ───────────────────────────────────────────────
canvas.addEventListener('mouseup', e => {
_mouseDown = false;
if (S.dragging) {
const movedId = S.dragging.compId;
S.dragging = null;
moveComponentRoute(movedId).then(() => {
if (_saveComponentLayout) _saveComponentLayout(movedId);
});
return;
}
if (S.panning) {
canvas.style.cursor = 'default';
S.panning = false;
S.panStart = null;
if (_saveViewport) _saveViewport();
}
});
// ── mouseleave ────────────────────────────────────────────
canvas.addEventListener('mouseleave', () => {
if (S.dragging) {
const movedId = S.dragging.compId;
S.dragging = null;
moveComponentRoute(movedId).then(() => {
if (_saveComponentLayout) _saveComponentLayout(movedId);
});
}
if (S.panning) {
S.panning = false; S.panStart = null;
canvas.style.cursor = 'default';
if (_saveViewport) _saveViewport();
}
_mouseDown = false;
});
// ── Right-click context menu ──────────────────────────────
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
const w = s2w(e.offsetX, e.offsetY);
const hC = hitTestComp(w.x, w.y);
if (hC) {
S.selectedComp = hC.id;
showProperties(hC); updateSidePanels(); render();
_showContextMenu(e.clientX, e.clientY, hC);
}
});
// ── Touch interaction (Pan, Zoom, Tap, Drag) ─────────────────
let _touchDist = null;
let _touchStartPt = null;
let _touchDragType = null; // 'pan', 'comp', or 'zoom'
let _touchMoved = false;
function getPinchDist(e) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}
canvas.addEventListener('touchstart', e => {
e.preventDefault(); // Stop simulated mouse events & browser scrolling
document.getElementById('left-panel')?.classList.remove('open');
_hideContextMenu();
if (e.touches.length === 2) {
_cancelLongPress();
S.dragging = null;
_touchDist = getPinchDist(e);
_touchDragType = 'zoom';
return;
}
if (e.touches.length === 1) {
_touchMoved = false;
const t = e.touches[0];
const rect = canvas.getBoundingClientRect();
const cx = t.clientX - rect.left;
const cy = t.clientY - rect.top;
_touchStartPt = { cx, cy, vx: S.viewX, vy: S.viewY };
const w = s2w(cx, cy);
const hC = hitTestComp(w.x, w.y);
if (hC) {
_touchDragType = 'comp';
pushHistory();
S.dragging = { compId: hC.id, ox: w.x - hC.x, oy: w.y - hC.y };
_longPressComp = hC;
_longPressTimer = setTimeout(() => {
_longPressComp = null;
_touchDragType = null; // Cancel drag if long press activates
S.dragging = null;
S.selectedComp = hC.id;
showProperties(hC); updateSidePanels(); render();
_showContextMenu(t.clientX, t.clientY, hC);
}, 600);
} else {
_touchDragType = 'pan';
}
}
}, { passive: false });
canvas.addEventListener('touchmove', e => {
e.preventDefault();
if (e.touches.length === 2 && _touchDragType === 'zoom') {
const newDist = getPinchDist(e);
const factor = newDist / _touchDist;
_touchDist = newDist;
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - canvas.getBoundingClientRect().left;
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - canvas.getBoundingClientRect().top;
const w = s2w(cx, cy);
S.zoom = Math.max(0.1, Math.min(8, S.zoom * factor));
const ns = {
x: w.x * S.zoom + canvas.width / 2 + S.viewX,
y: w.y * S.zoom + canvas.height / 2 + S.viewY,
};
S.viewX += cx - ns.x;
S.viewY += cy - ns.y;
render();
return;
}
if (e.touches.length === 1 && _touchStartPt) {
const t = e.touches[0];
const rect = canvas.getBoundingClientRect();
const cx = t.clientX - rect.left;
const cy = t.clientY - rect.top;
const dx = cx - _touchStartPt.cx;
const dy = cy - _touchStartPt.cy;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
_touchMoved = true;
_cancelLongPress();
}
if (_touchDragType === 'pan') {
S.viewX = _touchStartPt.vx + dx;
S.viewY = _touchStartPt.vy + dy;
render();
} else if (_touchDragType === 'comp' && S.dragging) {
const w = s2w(cx, cy);
const c = S.components.find(x => x.id === S.dragging.compId);
if (c) {
c.x = snap(w.x - S.dragging.ox);
c.y = snap(w.y - S.dragging.oy);
render();
}
}
}
}, { passive: false });
canvas.addEventListener('touchend', e => {
e.preventDefault();
_cancelLongPress();
if (e.touches.length === 0) {
if (!_touchMoved && _touchDragType) {
// Finger was lifted without dragging -> Tap
const w = s2w(_touchStartPt.cx, _touchStartPt.cy);
const hC = hitTestComp(w.x, w.y);
if (hC) {
S.selectedComp = hC.id; S.selectedNet = null; S.selectedWire = null;
showProperties(hC); updateSidePanels(); render();
} else {
const hitNet = hitTestWire(w.x, w.y);
if (hitNet) {
const net = S.nets.find(n => n.name === hitNet);
S.selectedNet = hitNet;
S.selectedComp = null; S.selectedWire = null;
if (net) showNetProperties(net);
updateSidePanels(); render();
} else {
S.selectedComp = null; S.selectedNet = null; S.selectedWire = null;
showDefaultProps(); updateSidePanels(); render();
}
}
S.dragging = null; // Prevent backend routing on simple tap
} else {
// Drag finished
if (_touchDragType === 'comp' && S.dragging) {
const movedId = S.dragging.compId;
moveComponentRoute(movedId).then(() => {
if (_saveComponentLayout) _saveComponentLayout(movedId);
});
} else if ((_touchDragType === 'pan' || _touchDragType === 'zoom') && _saveViewport) {
_saveViewport();
}
}
S.dragging = null;
_touchDragType = null;
_touchStartPt = null;
_touchDist = null;
} else if (e.touches.length === 1) {
// Dropped from 2 fingers to 1 (finished pinching)
_touchDragType = null;
S.dragging = null;
if (_saveViewport) _saveViewport();
}
}, { passive: false });
canvas.addEventListener('touchcancel', e => {
e.preventDefault();
_cancelLongPress();
S.dragging = null;
_touchDragType = null;
_touchStartPt = null;
_touchDist = null;
}, { passive: false });
// ── Wheel — zoom toward pointer, pan with trackpad scroll ──
canvas.addEventListener('wheel', e => {
e.preventDefault();
// Two-finger trackpad pan: small deltas without ctrlKey = scroll, not pinch
if (!e.ctrlKey && e.deltaMode === 0 &&
(Math.abs(e.deltaX) > 2 || Math.abs(e.deltaY) < 50)) {
S.viewX -= e.deltaX;
S.viewY -= e.deltaY;
render();
if (_saveViewport) _saveViewport();
return;
}
// Zoom toward the pointer position (standard for diagram editors)
const factor = (e.deltaY < 0 || (e.ctrlKey && e.deltaY < 0)) ? 1.12 : 1 / 1.12;
const w = s2w(e.offsetX, e.offsetY);
S.zoom = Math.max(0.1, Math.min(8, S.zoom * factor));
const ns = {
x: w.x * S.zoom + canvas.width / 2 + S.viewX,
y: w.y * S.zoom + canvas.height / 2 + S.viewY,
};
S.viewX += e.offsetX - ns.x;
S.viewY += e.offsetY - ns.y;
render();
if (_saveViewport) _saveViewport();
}, { passive: false });
// ── Keyboard shortcuts ─────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
_hideContextMenu();
if ((e.key === 'z' || e.key === 'Z') && (e.ctrlKey || e.metaKey)) {
undo(); return;
}
if (e.key === 'f' || e.key === 'F') {
fitView(); return;
}
if ((e.key === 'r' || e.key === 'R') && S.selectedComp) {
const c = S.components.find(x => x.id === S.selectedComp);
if (c) _rotateComp(c, { rotation: 270, flipX: false });
return;
}
if ((e.key === 'y' || e.key === 'Y') && S.selectedComp) {
const c = S.components.find(x => x.id === S.selectedComp);
if (c) _rotateComp(c, { rotation: 180, flipX: true });
return;
}
if ((e.key === 'x' || e.key === 'X') && S.selectedComp) {
const c = S.components.find(x => x.id === S.selectedComp);
if (c) _rotateComp(c, { rotation: 0, flipX: true });
return;
}
if (e.key === 'Escape') {
_hideContextMenu();
S.selectedComp = null; S.selectedWire = null; S.selectedNet = null;
showDefaultProps(); updateSidePanels(); render();
}
});
}

404
docs/schema/io.js Normal file
View file

@ -0,0 +1,404 @@
// ── Exports ───────────────────────────────────────────────────
import { S } from './state.js';
import { createComp } from './components.js';
import { db, uuid } from './db.js';
function toSexpr(node) {
if (Array.isArray(node)) {
return '(' + node.map(toSexpr).join(' ') + ')';
}
if (typeof node === 'string') {
if (node === '' || /[^\w\-.]/.test(node)) return `"${node.replace(/"/g, '\\"')}"`;
return node;
}
return String(node);
}
export async function getExportFilename(ext) {
const bId = window._boardId || new URLSearchParams(window.location.search).get('boardId');
const dId = window._deviceId || new URLSearchParams(window.location.search).get('deviceId');
let dName = "Device";
let bName = "Board";
try {
if (bId) {
const board = await db.getBoard(bId);
if (board && board.name) bName = board.name.replace(/[^a-z0-9_\-]/gi, '_');
const fetchedDevId = dId || (board ? board.deviceId : null);
if (fetchedDevId) {
const device = await db.getDevice(fetchedDevId);
if (device && device.name) dName = device.name.replace(/[^a-z0-9_\-]/gi, '_');
}
}
} catch(e) { console.warn("Failed to fetch filename info", e); }
return `${dName}-${bName}.${ext}`;
}
// ── KiCad Schematic (.kicad_sch) ──────────────────────────────
export async function exportKiCad() {
const K_SCALE = 0.127; // 20px = 2.54mm -> 1px = 0.127mm
const K_OFFSET_X = 148.5; // Center of A4 paper horizontally
const K_OFFSET_Y = 105.0; // Center of A4 paper vertically
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
S.components.forEach(c => {
minX = Math.min(minX, c.x); maxX = Math.max(maxX, c.x);
minY = Math.min(minY, c.y); maxY = Math.max(maxY, c.y);
});
if (minX === Infinity) { minX = 0; maxX = 0; minY = 0; maxY = 0; }
const centerOffX = (minX + maxX) / 2;
const centerOffY = (minY + maxY) / 2;
const sheetUuid = uuid();
const bId = window._boardId || new URLSearchParams(window.location.search).get('boardId');
const dId = window._deviceId || new URLSearchParams(window.location.search).get('deviceId');
let title = "SchemaReTrace Export";
let comment1 = "Reverse-engineered using PCB ReTrace (pcb.etaras.com)";
try {
if (bId) {
const board = await db.getBoard(bId);
if (board && board.name) title = board.name;
const fetchedDevId = dId || (board ? board.deviceId : null);
if (fetchedDevId) {
const device = await db.getDevice(fetchedDevId);
if (device && device.name) title = device.name + " - " + board.name;
}
}
} catch(e) {}
const dateStr = new Date().toISOString().split('T')[0];
let o = `(kicad_sch (version 20231120) (generator SchemaReTrace)\n`;
o += ` (uuid "${sheetUuid}")\n (paper "A4")\n`;
o += ` (title_block\n`;
o += ` (title "${title}")\n`;
o += ` (date "${dateStr}")\n`;
o += ` (comment 1 "${comment1}")\n`;
o += ` )\n`;
o += ` (lib_symbols\n`;
const getKiCadLibData = async (c) => {
if (c.componentTypeId) {
const kSym = await db.getResolvedKicadDataForComponentType(c.componentTypeId);
if (kSym) {
const parsed = JSON.parse(kSym.parsedData);
return {
libId: `${kSym.library}:${kSym.symbol}`,
symName: kSym.symbol,
graphics: parsed.graphics,
pins: parsed.pins,
props: parsed.props || {},
footprint: kSym.footprint || "",
datasheet: kSym.datasheet || "~"
};
}
}
if (c.kicadData) {
return {
libId: `${c.kicadData.library || 'Device'}:${c.kicadData.name}`,
symName: c.kicadData.name,
graphics: c.kicadData.graphics,
pins: c.kicadData.pins,
props: c.kicadData.props || {},
footprint: c.kicadData.footprint || "",
datasheet: c.kicadData.datasheet || "~"
};
}
let symName = 'Unknown';
let libId = 'Device:Unknown';
switch(c.type) {
case 'R': libId = 'Device:R'; symName = 'R'; break;
case 'C': libId = 'Device:C'; symName = 'C'; break;
case 'L': libId = 'Device:L'; symName = 'L'; break;
case 'D': libId = 'Device:D'; symName = 'D'; break;
case 'Z': libId = 'Device:D_Zener'; symName = 'D_Zener'; break;
default:
if (c.ref.startsWith('Q')) { libId = 'Device:Q_NPN_BCE'; symName = 'Q_NPN_BCE'; }
else if (c.ref.startsWith('J')) { libId = 'Device:Q_NJFET_DGS'; symName = 'Q_NJFET_DGS'; }
else { symName = createComp(c).label || 'Unknown'; libId = `Device:${symName}`; }
}
return { libId, symName, graphics: null, pins: null, props: {}, footprint: "", datasheet: "~" };
};
const usedLibs = new Map();
const compLibMap = new Map();
for (const c of S.components) {
const libData = await getKiCadLibData(c);
compLibMap.set(c.id, libData);
if (usedLibs.has(libData.libId)) continue;
let refPrefix = c.ref.match(/^[A-Za-z]+/)?.[0] || 'U';
const pfx = refPrefix.toUpperCase();
const isPassiveOrTransistor =['R', 'C', 'L', 'D', 'Z', 'Q', 'J', 'LED'].includes(pfx) ||['R', 'C', 'L', 'D', 'Z'].includes(pfx.charAt(0));
const hidePinsStr = isPassiveOrTransistor ? ` (pin_numbers hide) (pin_names hide)` : '';
let symDef = ` (symbol "${libData.libId}" (in_bom yes) (on_board yes)${hidePinsStr}\n`;
// Map dynamically extracted library properties
let propId = 0;
const symProps = {
"Reference": refPrefix,
"Value": libData.symName,
"Footprint": libData.footprint || "",
"Datasheet": libData.datasheet || "~",
...(libData.props || {})
};['Reference', 'Value', 'Footprint', 'Datasheet'].forEach(k => {
let hideStr = (k === 'Reference' || k === 'Value') ? '' : ' hide';
symDef += ` (property "${k}" "${String(symProps[k] || '').replace(/"/g, '\\"')}" (id ${propId++}) (at 0 0 0) (effects (font (size 1.27 1.27))${hideStr}))\n`;
delete symProps[k];
});
for (const [k, v] of Object.entries(symProps)) {
symDef += ` (property "${k}" "${String(v).replace(/"/g, '\\"')}" (id ${propId++}) (at 0 0 0) (effects (font (size 1.27 1.27)) hide))\n`;
}
if (libData.graphics && libData.pins) {
symDef += ` (symbol "${libData.symName}_0_1"\n`;
libData.graphics.forEach(g => {
symDef += ` ${toSexpr(g)}\n`;
});
symDef += ` )\n`;
symDef += ` (symbol "${libData.symName}_1_1"\n`;
libData.pins.forEach(p => {
const px = p.x * K_SCALE;
const py = -p.y * K_SCALE;
const plen = (p.len || 20) * K_SCALE;
// Output the exact electrical type and style parsed from the DB
const eType = p.electrical_type || 'passive';
const gStyle = p.graphical_style || 'line';
symDef += ` (pin ${eType} ${gStyle} (at ${px.toFixed(3)} ${py.toFixed(3)} ${p.angle || 0}) (length ${plen.toFixed(3)})\n`;
symDef += ` (name "${p.name}" (effects (font (size 1.27 1.27))))\n`;
symDef += ` (number "${p.num}" (effects (font (size 1.27 1.27))))\n`;
symDef += ` )\n`;
});
symDef += ` )\n`;
} else {
const compInst = createComp(c);
const geo = compInst.geometry();
const hw = (geo.w / 2) * K_SCALE;
const hh = (geo.h / 2) * K_SCALE;
symDef += ` (symbol "${libData.symName}_0_1"\n`;
symDef += ` (rectangle (start -${hw.toFixed(3)} -${hh.toFixed(3)}) (end ${hw.toFixed(3)} ${hh.toFixed(3)})\n`;
symDef += ` (stroke (width 0.254) (type default)) (fill (type background))\n )\n )\n`;
symDef += ` (symbol "${libData.symName}_1_1"\n`;
compInst._pinLocalCoords().forEach((pinCoord, i) => {
const px = pinCoord.ldx * K_SCALE;
const py = -pinCoord.ldy * K_SCALE;
const stubLen = compInst.stubLen(i) * K_SCALE;
const connX = px + (pinCoord.ex * stubLen);
const connY = py + (-pinCoord.ey * stubLen);
let kAngle = 0;
if (pinCoord.ex > 0.5) kAngle = 180;
else if (pinCoord.ex < -0.5) kAngle = 0;
else if (pinCoord.ey < -0.5) kAngle = 270;
else if (pinCoord.ey > 0.5) kAngle = 90;
const num = pinCoord.gp.name || String(i + 1);
symDef += ` (pin passive line (at ${connX.toFixed(3)} ${connY.toFixed(3)} ${kAngle}) (length ${stubLen.toFixed(3)})\n`;
symDef += ` (name "${num}" (effects (font (size 1.27 1.27))))\n`;
symDef += ` (number "${num}" (effects (font (size 1.27 1.27))))\n )\n`;
});
symDef += ` )\n`;
}
symDef += ` )\n`;
usedLibs.set(libData.libId, symDef);
}
usedLibs.forEach(def => { o += def; });
o += ` )\n`;
// Component Instantiation on the Paper
S.components.forEach(c => {
const libData = compLibMap.get(c.id);
const cx = (c.x - centerOffX) * K_SCALE + K_OFFSET_X;
const cy = (c.y - centerOffY) * K_SCALE + K_OFFSET_Y;
let rot = (-c.rotation || 0) % 360;
if (rot < 0) rot += 360;
let mirrorStr = c.flipX ? " (mirror y)" : "";
let textAngle = (rot === 90 || rot === 270) ? 90 : 0;
const hasValue = !!c.value;
const val = c.value || libData.symName;
const valHide = hasValue ? "" : " hide";
const compUuid = uuid();
let refX = cx + 2, refY = cy - 2;
let valX = cx + 2, valY = cy + 2;
if (S.componentLabels && S.componentLabels[c.id]) {
const hint = S.componentLabels[c.id];
refX = (hint.refPosition.x - centerOffX) * K_SCALE + K_OFFSET_X;
refY = (hint.refPosition.y - centerOffY) * K_SCALE + K_OFFSET_Y;
valX = (hint.valuePosition.x - centerOffX) * K_SCALE + K_OFFSET_X;
valY = (hint.valuePosition.y - centerOffY) * K_SCALE + K_OFFSET_Y;
}
o += ` (symbol (lib_id "${libData.libId}") (at ${cx.toFixed(3)} ${cy.toFixed(3)} ${rot})${mirrorStr} (unit 1)\n`;
o += ` (in_bom yes) (on_board yes) (dnp no) (fields_autoplaced no) (uuid "${compUuid}")\n`;
// Write all dynamic instance properties
let propId = 0;
const instProps = {
"Reference": c.ref,
"Value": val,
"Footprint": libData.footprint || "",
"Datasheet": libData.datasheet || "~",
...(libData.props || {})
};['Reference', 'Value', 'Footprint', 'Datasheet'].forEach(k => {
let pX = cx, pY = cy;
if (k === 'Reference') { pX = refX; pY = refY; }
else if (k === 'Value') { pX = valX; pY = valY; }
let hideStr = (k === 'Footprint' || k === 'Datasheet' || (k === 'Value' && valHide)) ? ' hide' : '';
o += ` (property "${k}" "${String(instProps[k] || '').replace(/"/g, '\\"')}" (id ${propId++}) (at ${pX.toFixed(3)} ${pY.toFixed(3)} ${textAngle})\n`;
o += ` (effects (font (size 1.27 1.27))${hideStr})\n )\n`;
delete instProps[k];
});
for (const [k, v] of Object.entries(instProps)) {
if (!k.startsWith('ki_')) {
o += ` (property "${k}" "${String(v).replace(/"/g, '\\"')}" (id ${propId++}) (at ${cx.toFixed(3)} ${cy.toFixed(3)} ${textAngle})\n`;
o += ` (effects (font (size 1.27 1.27)) hide)\n )\n`;
}
}
o += ` (instances\n`;
o += ` (project ""\n`;
o += ` (path "/${sheetUuid}"\n`;
o += ` (reference "${c.ref}") (unit 1)\n`;
o += ` )\n`;
o += ` )\n`;
o += ` )\n`;
o += ` )\n`;
});
S.wires.forEach(w => {
if (!w.points || w.points.length < 2) return;
for (let i = 0; i < w.points.length - 1; i++) {
const p1 = w.points[i];
const p2 = w.points[i+1];
const x1 = (p1.x - centerOffX) * K_SCALE + K_OFFSET_X;
const y1 = (p1.y - centerOffY) * K_SCALE + K_OFFSET_Y;
const x2 = (p2.x - centerOffX) * K_SCALE + K_OFFSET_X;
const y2 = (p2.y - centerOffY) * K_SCALE + K_OFFSET_Y;
o += ` (wire (pts (xy ${x1.toFixed(3)} ${y1.toFixed(3)}) (xy ${x2.toFixed(3)} ${y2.toFixed(3)}))\n`;
o += ` (stroke (width 0) (type default)) (uuid "${uuid()}")\n )\n`;
}
});
if (S.junctionPoints) {
S.junctionPoints.forEach(j => {
const jx = (j.x - centerOffX) * K_SCALE + K_OFFSET_X;
const jy = (j.y - centerOffY) * K_SCALE + K_OFFSET_Y;
o += ` (junction (at ${jx.toFixed(3)} ${jy.toFixed(3)}) (diameter 0) (color 0 0 0 0) (uuid "${uuid()}"))\n`;
});
}
o += `)\n`;
return o;
}
// ── MicroCAP Schematic (.cir) ─────────────────────────────────
export function exportSpice() {
const M_SCALE = 8 / 20; // Scale our 20px grid to an 8-unit MicroCap schematic grid
const M_OFFSET = 200; // Center offset for MicroCap
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
S.components.forEach(c => {
minX = Math.min(minX, c.x); maxX = Math.max(maxX, c.x);
minY = Math.min(minY, c.y); maxY = Math.max(maxY, c.y);
});
if (minX === Infinity) { minX = 0; maxX = 0; minY = 0; maxY = 0; }
const centerOffX = (minX + maxX) / 2;
const centerOffY = (minY + maxY) / 2;
let o = `[Main]\nFileType=CIR\nVersion=12.00\nProgram=Micro-Cap\n\n`;
const getMcName = (type) => {
switch(type) {
case 'R': return 'Resistor';
case 'C': return 'Capacitor';
case 'L': return 'Inductor';
case 'D': return 'Diode';
case 'Z': return 'Zener';
default: return 'Macro';
}
};
S.components.forEach(c => {
const mcName = getMcName(c.type);
const cx = Math.round((c.x - centerOffX) * M_SCALE) + M_OFFSET;
const cy = Math.round((c.y - centerOffY) * M_SCALE) + M_OFFSET;
let rot = Math.round((c.rotation || 0) / 90) % 4;
if (rot < 0) rot += 4;
if (c.flipX) rot += 4;
o += `[Comp]\nName=${mcName}\nPx=${cx},${cy}\nRot=${rot}\n`;
o += `[Attr]\nON=10,-10,PART\nV=${c.ref}\n`;
o += `[Attr]\nON=10,10,VALUE\nV=${c.value || '1k'}\n\n`;
});
S.wires.forEach(w => {
if (!w.points || w.points.length < 2) return;
for (let i = 0; i < w.points.length - 1; i++) {
const p1 = w.points[i];
const p2 = w.points[i+1];
const x1 = Math.round((p1.x - centerOffX) * M_SCALE) + M_OFFSET;
const y1 = Math.round((p1.y - centerOffY) * M_SCALE) + M_OFFSET;
const x2 = Math.round((p2.x - centerOffX) * M_SCALE) + M_OFFSET;
const y2 = Math.round((p2.y - centerOffY) * M_SCALE) + M_OFFSET;
o += `[Wire]\nPxs=${x1},${y1},${x2},${y2}\n\n`;
}
});
return o;
}
// ── postMessage listener ──────────────────────────────────────
let _loadBoard = null;
export function setIOCallbacks(loadBoard) {
_loadBoard = loadBoard;
}
export function initPostMessageListener() {
window.addEventListener('message', e => {
// console.log('[schema/io] message received:', e.data?.type, e.origin);
const d = e.data;
if (!d || typeof d !== 'object') return;
if (d.type === 'SCHEMA_INIT') {
window._embeddedMode = true;
// Safely cache data for title block exports
window._boardId = d.boardId || null;
window._deviceId = d.deviceId || null;
const elementsToHide =['btn-load-db', 'btn-import-file', 'es-load-btn'];
elementsToHide.forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
window.parent.postMessage({ type: 'SCHEMA_READY' }, '*');
if (_loadBoard) _loadBoard(d.boardId || null, d.deviceId || null);
} else if (d.type === 'SCHEMA_RESIZE') {
import('./app.js').then(({ triggerResize }) => triggerResize());
}
});
}

201
docs/schema/kicad.js Normal file
View file

@ -0,0 +1,201 @@
import { db, uuid } from './db.js';
const SCALE = 20 / 2.54;
//const ZIP_URL = 'https://gitlab.com/kicad/libraries/kicad-symbols/-/archive/master/kicad-symbols-master.zip'; // CORS issue
const ZIP_URL = './assets/kicad-symbols-master.zip';
// ── S-Expression Parser ─────────────────────────────────────────
function parseSexpr(str) {
const tokens =[]; let i = 0;
while (i < str.length) {
const c = str[i];
if (c === '(' || c === ')') { tokens.push(c); i++; }
else if (/\s/.test(c)) { i++; }
else if (c === '"') {
let s = '"'; i++;
while (i < str.length) {
if (str[i] === '\\') { s += str[i] + str[i+1]; i += 2; continue; }
if (str[i] === '"') { s += '"'; i++; break; }
s += str[i]; i++;
}
tokens.push(s);
} else {
let s = '';
while (i < str.length && !/[\s()]/.test(str[i]) && str[i] !== '"') { s += str[i]; i++; }
tokens.push(s);
}
}
let ti = 0;
function walk() {
if (ti >= tokens.length) return null;
let t = tokens[ti++];
if (t === '(') {
let list = [];
while (ti < tokens.length && tokens[ti] !== ')') list.push(walk());
ti++; return list;
}
if (t.startsWith('"')) return t.slice(1, -1).replace(/\\"/g, '"');
return t;
}
const ast =[];
while (ti < tokens.length) ast.push(walk());
return ast;
}
// ── Extractors ──────────────────────────────────────────────────
function extractSymbolData(node) {
const rawName = node[1] || '';
const name = rawName.includes(':') ? rawName.split(':').pop() : rawName;
const props = {}, pins =[], graphics =[];
let extendsName = null;
for (let i = 2; i < node.length; i++) {
const child = node[i];
if (!Array.isArray(child)) continue;
const tag = child[0];
if (tag === 'extends') {
extendsName = child[1];
} else if (tag === 'property') {
props[child[1]] = child[2] || '';
} else if (tag === 'pin') {
const at = child.find(x => Array.isArray(x) && x[0] === 'at');
const lengthNode = child.find(x => Array.isArray(x) && x[0] === 'length');
const nameNode = child.find(x => Array.isArray(x) && x[0] === 'name');
const numNode = child.find(x => Array.isArray(x) && x[0] === 'number');
if (at && numNode) {
pins.push({
num: numNode[1],
name: nameNode ? nameNode[1] : '',
x: parseFloat(at[1]) * SCALE,
y: -parseFloat(at[2]) * SCALE,
angle: parseFloat(at[3] || 0),
len: lengthNode ? parseFloat(lengthNode[1]) * SCALE : 20,
electrical_type: typeof child[1] === 'string' ? child[1] : 'passive',
graphical_style: typeof child[2] === 'string' ? child[2] : 'line'
});
}
} else if (tag === 'symbol') {
const nested = extractSymbolData(child);
graphics.push(...nested.graphics);
pins.push(...nested.pins);
} else if (['polyline', 'rectangle', 'circle', 'arc'].includes(tag)) {
graphics.push(child);
}
}
return { name, extendsName, props, pins, graphics };
}
function extractTopLevelSymbols(ast, parsedSymbols) {
const findSymbols = (node) => {
if (!Array.isArray(node)) return;
if (node[0] === 'symbol') {
const data = extractSymbolData(node);
parsedSymbols[data.name] = data;
} else {
for (let i = 0; i < node.length; i++) findSymbols(node[i]);
}
};
findSymbols(ast);
}
function resolveInheritance(parsedSymbols) {
const resolve = (sym) => {
if (!sym.extendsName) return;
const parentName = sym.extendsName.includes(':') ? sym.extendsName.split(':').pop() : sym.extendsName;
const parent = parsedSymbols[parentName];
if (parent) {
if (parent.extendsName) resolve(parent);
sym.pins =[...parent.pins, ...sym.pins];
sym.graphics = [...parent.graphics, ...sym.graphics];
sym.props = { ...parent.props, ...sym.props };
}
sym.extendsName = null;
};
Object.values(parsedSymbols).forEach(sym => resolve(sym));
}
async function upsertParsedSymbols(parsedSymbols, libName) {
const existingLib = await db.getKicadSymbols(libName) ||[];
const existingMap = {};
existingLib.forEach(s => existingMap[s.symbol] = s);
let inserted = 0, updated = 0;
const dbRecords = Object.values(parsedSymbols).map(sym => {
const existing = existingMap[sym.name];
if (existing) updated++; else inserted++;
return {
id: existing ? existing.id : uuid(), // Keep original ID if it exists
library: libName,
symbol: sym.name,
reference: sym.props['Reference'] || '',
description: sym.props['Description'] || '',
datasheet: sym.props['Datasheet'] || '',
keywords: sym.props['ki_keywords'] || '',
fp_filters: sym.props['ki_fp_filters'] || '',
footprint: sym.props['Footprint'] || '',
pinCount: sym.pins.length,
parsedData: JSON.stringify({ pins: sym.pins, graphics: sym.graphics, props: sym.props })
};
});
if (dbRecords.length > 0) await db.saveKicadSymbolsBatch(dbRecords);
return { inserted, updated };
}
// ── Public Importers ──────────────────────────────────────────────
export async function importSelectedFromZip(zip, filenames) {
let stats = { inserted: 0, updated: 0 };
const filesByLib = {};
for (const filename of filenames) {
const match = filename.match(/([^\/]+)\.kicad_symdir\//);
const libName = match ? match[1] : 'Imported';
if (!filesByLib[libName]) filesByLib[libName] = [];
filesByLib[libName].push(filename);
}
for (const [libName, files] of Object.entries(filesByLib)) {
const parsedSymbols = {};
for (const filename of files) {
const text = await zip.files[filename].async("text");
extractTopLevelSymbols(parseSexpr(text), parsedSymbols);
}
resolveInheritance(parsedSymbols);
const res = await upsertParsedSymbols(parsedSymbols, libName);
stats.inserted += res.inserted;
stats.updated += res.updated;
}
return stats;
}
export async function importSymbolsFromText(text, libName = 'Imported') {
const parsedSymbols = {};
extractTopLevelSymbols(parseSexpr(text), parsedSymbols);
resolveInheritance(parsedSymbols);
return await upsertParsedSymbols(parsedSymbols, libName);
}
// ── Auto-Importer for initial boot ─────────────────────────────────
export async function autoImportDeviceLib() {
try {
const existing = await db.getKicadSymbols('Device');
if (existing && existing.length > 0) return;
console.log('[kicad] Auto-importing default Device library...');
const res = await fetch(ZIP_URL);
if (!res.ok) throw new Error('Failed to fetch zip');
const buffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(buffer);
const deviceFiles = Object.keys(zip.files).filter(name => name.includes('/Device.kicad_symdir/') && name.endsWith('.kicad_sym'));
await importSelectedFromZip(zip, deviceFiles);
console.log('[kicad] Default library import complete!');
} catch (err) {
console.error('[kicad] Auto-import failed:', err);
}
}

459
docs/schema/layout.js Normal file
View file

@ -0,0 +1,459 @@
// ═══════════════════════════════════════════════════════════════
// LAYOUT — WireBender placement + routing
//
// Replaces the previous ELK-based layout.js entirely.
//
// Workflow (mirrors the prototype in wirebender-test.html):
// 1. Load WASM module once at startup (initWireBender)
// 2. On netlist load / auto-layout:
// buildAndRoute() → clears wb, re-adds everything,
// classify → computePlacement → routeAll
// 3. On component drag drop:
// routeWires() → setComponentPlacement → routeAll
// (visual-only drag happens in interaction.js; no routing
// during the drag, only on drop)
//
// Persistent instance pattern (from prototype):
// A single WireBender instance (_wb) is created once and kept
// alive for the session. It is destroyed + recreated only when
// buildAndRoute() is called (full re-layout). This is required
// because setComponentPlacement / routeAll operate on libavoid's
// live router state.
// ═══════════════════════════════════════════════════════════════
import { S } from './state.js';
import { createComp } from './components.js';
import { render } from './draw.js';
// ── Module loading ────────────────────────────────────────────
let _WB = null; // WireBender WASM module (Module object)
let _wb = null; // persistent WireBender instance
let _modulePromise = null;
/**
* Loads the WASM module once. Subsequent calls return the same promise.
* Must be called (and awaited) before any routing.
*
* Expects WireBender.js and WireBender.wasm to be at the same URL path
* as the HTML page (i.e. served from the project root alongside index.html).
* @returns Promise
*/
export function initWireBender() {
if (_modulePromise) return _modulePromise;
_modulePromise = import('https://dev-lab.github.io/WireBender/latest/WireBender.js')
.then(m => m.default({
locateFile: f => f === 'WireBender.wasm' ? 'https://dev-lab.github.io/WireBender/latest/WireBender.wasm' : f,
}))
.then(module => {
_WB = module;
console.log('[WireBender] WASM module loaded');
})
.catch(err => {
console.error('[WireBender] Failed to load WASM module:', err);
// Surface to UI via lazy import to avoid circular dep
import('./ui.js').then(({ toast }) =>
toast('WireBender WASM failed to load — check console', 'err'));
});
return _modulePromise;
}
/**
* Destroy persistent WireBender instance.
*/
function _destroyInstance() {
if (_wb) {
try { _wb.delete(); } catch (_) { /* ignore */ }
_wb = null;
}
}
/**
* Build WireBender netlist from S
* Feeds S.components and S.nets into a freshly-created WireBender
* instance.
*
* Call after _wb is (re)created.
*/
function _populateWb() {
// Step 1: add components
S.components.forEach(comp => {
const desc = createComp(comp).wbDescriptor();
const pinsVec = new _WB.VectorPinDescriptor();
desc.pins.forEach(p => pinsVec.push_back({
number: p.number,
name: p.name,
x: p.x,
y: p.y,
directionFlags: p.directionFlags,
}));
_wb.addComponent({
id: desc.id,
width: desc.width,
height: desc.height,
padding: desc.padding,
pins: pinsVec,
});
pinsVec.delete();
});
// Step 2: add nets (only those with ≥ 2 nodes)
S.nets.forEach(net => {
const nodes = (net.nodes || []).filter(nd =>
S.components.find(c => c.ref === nd.ref)
);
if (nodes.length < 2) return;
const pinsVec = new _WB.VectorPinRef();
nodes.forEach(nd => {
const comp = S.components.find(c => c.ref === nd.ref);
if (!comp) return;
// Find pin index (0-based) → WireBender pin number (1-based)
const pinIdx = comp.pins.findIndex(
p => p.originalName === nd.pin || p.originalName === String(nd.pin) ||
p.name === nd.pin || p.name === String(nd.pin)
);
pinsVec.push_back({
componentId: comp.id,
pinNumber: pinIdx >= 0 ? pinIdx + 1 : 1,
});
});
_wb.addNet({ name: net.name, pins: pinsVec });
pinsVec.delete();
});
}
/**
* Apply a SchematicRouteResult into S.wires / S.junctionPoints.
*
* Converts Emscripten vectors to plain JS arrays.
* @param result routing result (wires, junctions)
* @param affectedNets: if given, replaces only those nets; otherwise full rebuild.
*/
function _applyRouteResult(result, affectedNets = null) {
// Build map: netName → {wires:[[pts]], junctions:[{x,y}]}
const byNet = {};
for (let i = 0; i < result.wires.size(); i++) {
const wire = result.wires.get(i);
const net = wire.net;
if (!byNet[net]) byNet[net] = { wires: [], junctions: [] };
const pts = [];
for (let j = 0; j < wire.points.size(); j++) {
const p = wire.points.get(j);
pts.push({ x: p.x, y: p.y });
}
if (pts.length >= 2) byNet[net].wires.push(pts);
}
for (let i = 0; i < result.junctions.size(); i++) {
const jd = result.junctions.get(i);
const net = jd.net;
if (!byNet[net]) byNet[net] = { wires: [], junctions: [] };
byNet[net].junctions.push({ x: jd.position.x, y: jd.position.y });
}
// Helper: write wire data for one net into S
const applyNet = netName => {
const nd = byNet[netName];
if (!nd) return;
// Remove existing wires for this net
S.wires = S.wires.filter(w => w.net !== netName);
// Remove existing junctions for this net
S.junctionPoints = (S.junctionPoints || []).filter(jp => jp.net !== netName);
let wireSeq = 0;
nd.wires.forEach(pts => {
S.wires.push({
id: `wb_${netName}_${wireSeq++}`,
net: netName,
points: pts,
pinA: null, // WireBender owns routing — we don't track pin refs per wire
pinB: null,
});
});
nd.junctions.forEach(j => {
S.junctionPoints.push({ x: j.x, y: j.y, net: netName });
});
};
if (affectedNets) {
// Incremental update
affectedNets.forEach(applyNet);
} else {
// Full rebuild
S.wires = [];
S.junctionPoints = [];
Object.keys(byNet).forEach(applyNet);
}
// Generate WIP stubs for single-node nets
_applyWipStubs();
// Extract component label hints if the WASM module provided them
S.componentLabels = {};
if (result.componentLabels) {
for (let i = 0; i < result.componentLabels.size(); i++) {
const hint = result.componentLabels.get(i);
S.componentLabels[hint.componentId] = {
refPosition: { x: hint.refPosition.x, y: hint.refPosition.y },
refIsVertical: hint.refIsVertical,
valuePosition: { x: hint.valuePosition.x, y: hint.valuePosition.y },
valueIsVertical: hint.valueIsVertical
};
}
}
// Generate WIP stubs for single-node nets
_applyWipStubs();
}
/**
* WIP stubs (single-node nets).
*
* For nets with only one connected pin, draw a short dangling stub
* so the connection is visible. WireBender only routes nets with
* ≥ 2 nodes; stubs are synthesised here.
*/
function _applyWipStubs() {
S.nets.forEach(net => {
if ((net.nodes || []).length !== 1) return;
const nd = net.nodes[0];
const comp = S.components.find(c => c.ref === nd.ref);
if (!comp) return;
const pins = createComp(comp).pinPositions();
const pinIdx = comp.pins.findIndex(
p => p.originalName === nd.pin || p.originalName === String(nd.pin) ||
p.name === nd.pin || p.name === String(nd.pin)
);
const p = pins[pinIdx >= 0 ? pinIdx : 0];
if (!p) return;
// Remove any previous stub for this net
S.wires = S.wires.filter(w => w.id !== `wip_${net.name}`);
S.wires.push({
id: `wip_${net.name}`,
net: net.name,
points: [
{ x: p.x, y: p.y },
{ x: p.x + p.ex * 32, y: p.y + p.ey * 32 },
],
pinA: { compId: comp.id, pinName: p.pinName },
pinB: null,
});
});
}
/**
* Write component placements from WireBender into S.
* @param placements component placements
*/
function _applyPlacements(placements) {
for (const id in placements) {
const placement = placements[id];
const comp = S.components.find(c => c.id === id);
if (!comp) continue;
comp.x = placement.position.x;
comp.y = placement.position.y;
comp.rotation = placement.transform.rotation;
comp.flipX = placement.transform.flipX;
}
}
/**
* Fit viewport.
*
* Always calls render() so every caller - buttons, keyboard, post-route -
* doesn't have to remember to chain it.
*/
export function fitView() {
if (!S.components.length) return;
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
S.components.forEach(c => {
const g = createComp(c).whTransformed();
x1 = Math.min(x1, c.x - g.w / 2 - 60); y1 = Math.min(y1, c.y - g.h / 2 - 60);
x2 = Math.max(x2, c.x + g.w / 2 + 60); y2 = Math.max(y2, c.y + g.h / 2 + 60);
});
const cv = document.getElementById('sch-canvas');
S.zoom = Math.max(0.1, Math.min(0.95, Math.min(cv.width / (x2 - x1), cv.height / (y2 - y1))));
S.viewX = -((x1 + x2) / 2) * S.zoom;
S.viewY = -((y1 + y2) / 2) * S.zoom;
render();
}
/**
* Full build + layout.
*
* Destroys the old WireBender instance, rebuilds the full netlist.
* If runPlacement is true, runs classify → computePlacement (respecting locks) → routeAll.
* If false, bypasses placement entirely and uses exact coordinates from S.components.
*
* lockedPlacements: optional map { [compId]: {{x, y}, {rotation, flipX}} } of components
* that should not be moved during auto-placement (e.g. previously
* user-positioned components loaded from schemaComponents in DB).
* Coordinates are world-space centre, same as S.components.x/y.
*
* Returns the placement result map { [compId]: {{x, y}, {rotation, flipX}} } so the
* caller can persist newly-placed components to the DB.
*/
export async function buildAndRoute(lockedPlacements = {}, runPlacement = true) {
if (!S.components.length) {
render(); // Force clear canvas and show empty state
return {};
}
await initWireBender();
if (!_WB) return {};
_destroyInstance();
_wb = new _WB.WireBender();
_populateWb();
// Classify (bus detection)
const cls = _wb.classify();
_wb.applyClassification(cls);
cls.delete();
let placements = {};
if (runPlacement) {
// Apply locked positions before placement
if (Object.keys(lockedPlacements).length > 0) {
const locks = new _WB.ComponentPlacements();
locks.fromObject(lockedPlacements);
_wb.setLockedPlacements(locks);
locks.delete();
}
placements = _wb.computePlacements().toObject();
_applyPlacements(placements);
} else {
// No placement requested: push exact saved placements directly into WB
S.components.forEach(comp => {
_wb.setComponentPlacement(comp.id, { position: { x: comp.x, y: comp.y }, transform: { rotation: comp.rotation || 0, flipX: comp.flipX || false } });
});
}
// Route
const result = _wb.routeAll();
_applyRouteResult(result);
if (runPlacement) fitView();
else render();
return placements;
}
/**
* Incremental Move or Transform (Rotate, FlipX) Component.
* @param compId component ID
*/
export async function moveComponentRoute(compId) {
if (!_wb) { await buildAndRoute(); return; }
const comp = S.components.find(c => c.id === compId);
if (!comp) return;
const incResult = _wb.moveComponent(compId, { position: { x: comp.x, y: comp.y }, transform: { rotation: comp.rotation || 0, flipX: comp.flipX || false } });
// Extract affected nets into a standard JS array
const affectedNets = [];
for (let i = 0; i < incResult.affectedNets.size(); i++) {
affectedNets.push(incResult.affectedNets.get(i));
}
// Apply only to the affected nets
_applyRouteResult(incResult.routes, affectedNets);
render();
}
/**
* Replace Component (Symbol change).
* @param comp component to replace
* @param oldToNewMap pin mapping
*/
export async function replaceComponentRoute(comp, oldToNewMap) {
if (!_wb) { await buildAndRoute(); return; }
const desc = createComp(comp).wbDescriptor();
const pinsVec = new _WB.VectorPinDescriptor();
desc.pins.forEach(p => pinsVec.push_back({
number: p.number,
name: p.name,
x: p.x,
y: p.y,
directionFlags: p.directionFlags,
}));
const mapObj = new _WB.PinMap();
mapObj.fromObject(oldToNewMap);
const replacement = {
componentId: comp.id,
newDescriptor: {
id: desc.id,
width: desc.width,
height: desc.height,
padding: desc.padding,
pins: pinsVec
},
pinMapping: mapObj
};
const result = _wb.replaceComponent(replacement);
_applyRouteResult(result);
render();
pinsVec.delete();
mapObj.delete();
}
/**
* Re-route after a manual drag or undo.
* Fully syncs all component geometry AND positions into WireBender
* before calling routeAll(). This handles any combination of:
* - position changes (drag)
* - transform changes (undo restoring a previous rotation/flipX)
*
* TODO: with changes to placement instead of position, not sure
* that the fix described below needed.
* The critical fix: routeWires() must call addComponent() (not just
* setComponentPlacement()) for every component, because addComponent
* replaces the geometry registration. Using only setComponentPosition
* leaves WireBender with stale pin geometry after rotation/undo, which
* produces diagonal wire artifacts and wrong wire endpoints.
*/
export async function routeWires() {
if (!S.components.length) return;
if (!_wb) { await buildAndRoute(); return; }
// Sync every component's current geometry and position.
// addComponent() replaces the existing registration when called with
// the same id — so this is always safe to call unconditionally.
S.components.forEach(comp => {
const desc = createComp(comp).wbDescriptor();
const pinsVec = new _WB.VectorPinDescriptor();
desc.pins.forEach(p => pinsVec.push_back({
number: p.number,
name: p.name,
x: p.x,
y: p.y,
directionFlags: p.directionFlags,
}));
// TODO: check if needed with new setPlacement, find ui undo method and sync wirebender instead
_wb.addComponent({
id: desc.id,
width: desc.width,
height: desc.height,
padding: desc.padding,
pins: pinsVec,
});
pinsVec.delete();
_wb.setComponentPlacement(comp.id, { position: { x: comp.x, y: comp.y }, transform: { rotation: comp.rotation || 0, flipX: comp.flipX || false } });
});
const result = _wb.routeAll();
_applyRouteResult(result);
render();
}

139
docs/schema/parsers.js Normal file
View file

@ -0,0 +1,139 @@
// ── Parsers ───────────────────────────────────────────────────
import { classifyComp } from './components.js';
// ── Studio DB → internal format ───────────────────────────────
// Translates Studio's component and net records into the internal
// parsed format that loadNetlist() consumes.
//
// Studio component shape: { id, boardId, label, value, desc, x?, y?, imgId? }
// Studio net node shape: { label: "R1.2" } (ref.pin) or { label: "TP1" } (ref only)
//
// Internal format:
// components: [{ ref, value, type, studioId }]
// nets: [{ name, nodes:[{ ref, pin }] }]
export function parseFromStudioDB(studioComponents, studioNets) {
const components = studioComponents.map(c => ({
ref: c.label,
value: c.value || '',
type: classifyComp(c.label),
studioId: c.id, // preserved so app.js can link back to schemaComponents
}));
const nets = studioNets.map(n => ({
name: n.name,
nodes: (n.nodes || []).map(nd => {
const parts = (nd.label || '').split('.');
if (parts.length >= 2) {
return { ref: parts[0], pin: parts[1] };
}
// Single-label node (e.g. test point) — default to pin 1
return { ref: nd.label || '', pin: '1' };
}).filter(nd => nd.ref),
}));
return { components, nets };
}
// ── PCB ReTrace JSON ──────────────────────────────────────────
export function parseJsonNetlist(json) {
if (json.nets && json.components) {
return {
components: json.components.map(c => ({
ref: c.ref, value: c.value || '',
type: c.type || classifyComp(c.ref),
})),
nets: json.nets.map(n => ({ name: n.name, nodes: n.nodes || [] })),
};
}
if (Array.isArray(json)) {
const refs = new Set();
json.forEach(n => (n.nodes || []).forEach(nd => refs.add(nd.ref)));
return {
components: [...refs].map(r => ({ ref: r, value: '', type: classifyComp(r) })),
nets: json,
};
}
throw new Error('Unknown JSON format');
}
// ── KiCad XML netlist ─────────────────────────────────────────
export function parseKiCadXml(text) {
const doc = new DOMParser().parseFromString(text, 'text/xml');
const comps = [];
doc.querySelectorAll('comp').forEach(el =>
comps.push({
ref: el.getAttribute('ref'),
value: el.querySelector('value')?.textContent || '',
type: classifyComp(el.getAttribute('ref')),
})
);
const nets = [];
doc.querySelectorAll('net').forEach(el => {
const nodes = [];
el.querySelectorAll('node').forEach(n =>
nodes.push({ ref: n.getAttribute('ref'), pin: n.getAttribute('pin') || '1' })
);
nets.push({ name: el.getAttribute('name') || 'NET', nodes });
});
return { components: comps, nets };
}
// ── KiCad S-expression netlist ────────────────────────────────
export function parseKiCadSexpr(text) {
const comps = [], nets =[];
let m;
const cR = /\(comp\s+\(ref\s+"?([^"\s)]+)"?\)[\s\S]*?\(value\s+"?([^"\s)]*)"?\)/g;
while ((m = cR.exec(text)) !== null)
comps.push({ ref: m[1], value: m[2], type: classifyComp(m[1]) });
// Split text by "(net " to robustly isolate each net block regardless of nesting
const netBlocks = text.split(/\(\s*net\s+/).slice(1);
netBlocks.forEach(block => {
const nameMatch = block.match(/\(name\s+"?([^"\s)]*)"?\)/);
if (!nameMatch) return;
const nodes = [];
const ndR = /\(node\s+\(ref\s+"?([^"\s)]+)"?\)\s+\(pin\s+"?([^"\s)]+)"?/g;
let nm;
while ((nm = ndR.exec(block)) !== null) nodes.push({ ref: nm[1], pin: nm[2] });
nets.push({ name: nameMatch[1], nodes });
});
return { components: comps, nets };
}
// ── SPICE netlist (.cir) ──────────────────────────────────────
export function parseSpice(text) {
const comps =[], netsMap = {};
const lines = text.split('\n');
lines.forEach(line => {
let l = line.trim();
if (!l || l.startsWith('.') || l.startsWith('*') || l.startsWith('+')) return;
const tokens = l.split(/\s+/);
if (tokens.length < 3) return;
const ref = tokens[0];
const value = tokens[tokens.length - 1];
comps.push({ ref, value, type: classifyComp(ref) });
// Pins are ordered sequentially between Ref and Value
for (let i = 1; i < tokens.length - 1; i++) {
const netName = tokens[i];
if (!netsMap[netName]) netsMap[netName] = [];
netsMap[netName].push({ ref, pin: String(i) });
}
});
const nets = Object.keys(netsMap).map(name => ({ name, nodes: netsMap[name] }));
return { components: comps, nets };
}
// ── Detect and dispatch file content ─────────────────────────
export function parseFileContent(text) {
const t = text.trim();
if (t.startsWith('{') || t.startsWith('['))
return parseJsonNetlist(JSON.parse(t));
if (t.startsWith('*') || t.startsWith('.title') || t.includes('.end'))
return parseSpice(t);
if (t.includes('(export') || t.includes('(net '))
return parseKiCadSexpr(t);
return parseKiCadXml(t);
}

75
docs/schema/state.js Normal file
View file

@ -0,0 +1,75 @@
// ── State ─────────────────────────────────────────────────────
// Single mutable app state object. All modules share this reference.
export const S = {
components: [], // [{id, ref, value, type, x, y, rotation, flipX, pins:[{name,net}], pcbX, pcbY}]
nets: [], // [{name, nodes:[{ref,pin}], color, isWip}]
wires: [], // [{id, net, points:[{x,y}], pinA, pinB}]
// Viewport
viewX: 0, viewY: 0, zoom: 1,
// Interaction
dragging: null, // {compId, ox, oy}
wireDrag: null, // unused after WireBender migration, kept for compat
panning: false,
panStart: null, // {x, y, vx, vy}
// Selection
selectedComp: null,
selectedWire: null, // {wireId, segIdx, axis}
selectedNet: null,
// Hover
hoverComp: null,
hoverWireSeg: null, // {wireId, segIdx, axis}
// History (undo)
history: [],
// Junction dots — written by layout.js after each route
junctionPoints: [], // [{x, y, net}]
// Component label hints from WireBender
componentLabels: {}, // { [compId]: ComponentLabelHint }
hasData: false,
};
// ── Global constants ─────────────────────────────────────────
export const GRID = 20; // snap grid, px
export const MARGIN = 10; // routing clearance around component bodies, px
export const NET_COLORS = [
'#00e5a0', '#00a8ff', '#ff6b35', '#f0c040', '#a78bfa',
'#f472b6', '#34d399', '#fb923c', '#60a5fa', '#4ade80',
'#e879f9', '#2dd4bf', '#fbbf24', '#f87171', '#818cf8',
];
// ── Canvas context (set once by app.js after DOM ready) ───────
export let canvas, ctx;
export function initCanvas(canvasEl) {
canvas = canvasEl;
ctx = canvasEl.getContext('2d');
// console.log('[schema] initCanvas:', canvasEl, 'ctx:', !!ctx);
}
// ── Coordinate helpers ────────────────────────────────────────
export const s2w = (sx, sy) => ({
x: (sx - canvas.width / 2 - S.viewX) / S.zoom,
y: (sy - canvas.height / 2 - S.viewY) / S.zoom,
});
export const w2s = (wx, wy) => ({
x: wx * S.zoom + canvas.width / 2 + S.viewX,
y: wy * S.zoom + canvas.height / 2 + S.viewY,
});
export const snap = v => Math.round(v / GRID) * GRID;
// ── Net color lookup ──────────────────────────────────────────
export function getNetColor(name) {
const net = S.nets.find(x => x.name === name);
if (net?.color) return net.color;
// Fallback: stable hash so same name always gets same color
let h = 0;
for (const c of (name || '')) h = (h * 31 + c.charCodeAt(0)) & 0xffff;
return NET_COLORS[h % NET_COLORS.length];
}

786
docs/schema/ui.js Normal file
View file

@ -0,0 +1,786 @@
// ── UI helpers ────────────────────────────────────────────────
import { db, uuid } from './db.js';
import { S, getNetColor } from './state.js';
import { compColor, compLabel } from './components.js';
import { render } from './draw.js';
import { pushHistory, undo } from './history.js';
import { fitView, routeWires } from './layout.js';
import { importSelectedFromZip, importSymbolsFromText } from './kicad.js';
// ── Toast notification ────────────────────────────────────────
let _toastTimer;
export function toast(msg, type = '') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'show' + (type ? ' ' + type : '');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => { el.className = type || ''; }, 2600);
}
// ── Status bar ────────────────────────────────────────────────
export function setStatus(msg, type = '', shortMsg = null) {
const el = document.getElementById('status-msg');
el.className = type;
if (shortMsg) {
el.setAttribute('data-long', msg);
el.setAttribute('data-short', shortMsg);
el.classList.add('responsive-text');
el.textContent = '';
} else {
el.removeAttribute('data-long');
el.removeAttribute('data-short');
el.classList.remove('responsive-text');
el.textContent = msg;
}
}
// ── Modal helpers ─────────────────────────────────────────────
export function showModal(id) { document.getElementById(id).classList.add('active'); }
export function hideModal(id) { document.getElementById(id).classList.remove('active'); }
// ── Properties panel ─────────────────────────────────────────
export function showProperties(comp) {
document.getElementById('right-panel')?.classList.add('open');
const col = compColor(comp.type);
let extraProps = '';
if (comp.type !== 'KICAD') {
extraProps = `
<div class="prop-row" style="margin-top:6px; border-top:1px solid var(--border); padding-top:6px;">
<div class="prop-label">Pin Count</div>
<input type="number" class="prop-input" id="ppc" value="${comp.pins.length}" min="1">
</div>
`;
}
const isKicad = comp.type === 'KICAD' && comp.kicadData?.pins;
const kPins = isKicad ? comp.kicadData.pins : new Array();
let shiftUi = `<div class="prop-label">Pins (${comp.pins.length})</div>`;
if (isKicad && kPins.length > 0) {
let shiftVal = '0';
if (comp.overrides?.type === 'shift') shiftVal = comp.overrides.offset.toString();
else if (comp.overrides?.type === 'remap') shiftVal = 'custom';
let shiftOptions = `<option value="custom">Custom</option>`;
for (let i = 0; i < kPins.length; i++) {
shiftOptions += `<option value="${i}" ${shiftVal === i.toString() ? 'selected' : ''}>${i}</option>`;
}
shiftUi = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<div class="prop-label" style="margin:0;">Pins (${comp.pins.length})</div>
<div style="display:flex;align-items:center;gap:6px">
<span class="prop-label" style="margin:0;">Shift</span>
<select id="kicad-pin-shift" class="prop-input" style="width:64px;padding:0 2px;margin:0;height:18px;font-size:10px">
${shiftOptions}
</select>
</div>
</div>`;
}
document.getElementById('prop-panel').innerHTML = `
<div class="prop-row">
<div class="prop-label">Ref</div>
<div class="prop-val">${comp.ref}</div>
</div>
<div class="prop-row">
<div class="prop-label">Type</div>
<div class="prop-val" style="color:${col}">${compLabel(comp.type)}</div>
</div>
<div class="prop-row">
<div class="prop-label">Value</div>
<input class="prop-input" id="pv" value="${comp.value}" placeholder="e.g. 10k">
</div>
${extraProps}
<div id="kicad-extra-props"></div>
<div class="prop-row" style="margin-top:6px; border-top:1px solid var(--border); padding-top:6px;">
${shiftUi}
${comp.pins.map((p, i) => {
let mappingUi = '';
if (isKicad) {
const options = kPins.map(kp =>
`<option value="${kp.num}" ${kp.num === p.name ? 'selected' : ''}>${kp.num}</option>`
).join('');
mappingUi = `<div style="display:flex;align-items:center;gap:6px">
<span style="color:var(--text2);width:16px">${p.originalName || p.name}</span>
<span style="color:var(--border)">→</span>
<select class="prop-input kicad-pin-map" data-orig="${p.originalName}" style="width:48px;padding:0 2px;margin:0;height:18px;font-size:10px">${options}</select>
</div>`;
} else {
const mappingStr = p.name !== (p.originalName || p.name) ? ` <span style="color:var(--warn)">→${p.name}</span>` : '';
mappingUi = `<span style="color:var(--text2)">${p.originalName || p.name}${mappingStr}</span>`;
}
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:2px 0;border-bottom:1px solid var(--border)">
${mappingUi}
<span style="color:${p.net ? getNetColor(p.net) : 'var(--border)'}">${p.net || '—'}</span>
</div>`;
}).join('')}
</div>
<div style="margin-top:8px">
<button class="btn accent" id="pa" style="width:100%">Apply</button>
</div>
<div style="margin-top:8px;color:var(--text2);font-size:10px;line-height:1.6">
Right-click / long-press to rotate or replace.
</div>`;
// ── Sync Shift Dropdown and Individual Pin Selects ──
if (isKicad && kPins.length > 0) {
const shiftSelect = document.getElementById('kicad-pin-shift');
const pinSelects = Array.from(document.querySelectorAll('.kicad-pin-map'));
const checkShiftState = () => {
let detectedOffset = -1;
for (let offset = 0; offset < kPins.length; offset++) {
let isMatch = true;
for (let i = 0; i < comp.pins.length; i++) {
const mappedNum = pinSelects[i].value;
const expectedNum = kPins[(i + offset) % kPins.length]?.num;
if (mappedNum !== expectedNum) {
isMatch = false; break;
}
}
if (isMatch) { detectedOffset = offset; break; }
}
shiftSelect.value = detectedOffset >= 0 ? detectedOffset.toString() : 'custom';
};
shiftSelect.addEventListener('change', () => {
if (shiftSelect.value === 'custom') return;
const offset = parseInt(shiftSelect.value);
pinSelects.forEach((sel, i) => {
const expectedNum = kPins[(i + offset) % kPins.length]?.num;
sel.value = expectedNum;
});
});
pinSelects.forEach(sel => sel.addEventListener('change', checkShiftState));
}
// Dynamically fetch and render extra DB properties for KiCad symbols
if (comp.componentTypeId) {
db.getResolvedKicadDataForComponentType(comp.componentTypeId).then(kSym => {
if (!kSym) return;
const container = document.getElementById('kicad-extra-props');
if (!container) return; // Panel might have been closed/switched
let html = '';
if (kSym.description) {
html += `
<div class="prop-row" style="margin-top:6px;">
<div class="prop-val" style="font-size:10px;line-height:1.3;color:var(--text2);word-wrap:break-word;">${kSym.description}</div>
</div>`;
}
const footprint = kSym.footprint || kSym.fp_filters;
if (footprint) {
html += `
<div class="prop-row">
<div class="prop-val" style="font-size:10px;line-height:1.3;color:var(--text2);word-wrap:break-word;">FP: ${footprint}</div>
</div>`;
}
if (kSym.datasheet && kSym.datasheet !== '~') {
html += `
<div class="prop-row" style="margin-top:6px;">
<a href="${kSym.datasheet}" target="_blank" style="color:var(--accent2);text-decoration:underline;font-size:11px;">View Datasheet</a>
</div>`;
}
container.innerHTML = html;
});
}
document.getElementById('pa').onclick = async () => {
comp.value = document.getElementById('pv').value;
if (comp.type === 'KICAD') {
const selects = document.querySelectorAll('.kicad-pin-map');
if (selects.length > 0) {
const newMap = {};
selects.forEach(sel => { newMap[sel.getAttribute('data-orig')] = sel.value; });
let detectedOffset = -1;
// Detect if the mapping is a uniform CCW shift
for (let offset = 0; offset < kPins.length; offset++) {
let isMatch = true;
for (let i = 0; i < comp.pins.length; i++) {
const orig = comp.pins[i].originalName;
const mappedNum = newMap[orig];
const expectedNum = kPins[(i + offset) % kPins.length]?.num;
if (mappedNum !== expectedNum) {
isMatch = false;
break;
}
}
if (isMatch) {
detectedOffset = offset;
break;
}
}
if (detectedOffset === 0) {
comp.overrides = null; // Identity mapping
} else if (detectedOffset > 0) {
comp.overrides = { type: 'shift', offset: detectedOffset };
} else {
// Fallback identity check just in case
let isIdentity = true;
for (let i = 0; i < comp.pins.length; i++) {
if (newMap[comp.pins[i].originalName] !== kPins[i]?.num) {
isIdentity = false; break;
}
}
if (isIdentity) comp.overrides = null;
else comp.overrides = { type: 'remap', map: newMap };
}
}
const m = await import('./app.js');
m.applyPinOverrides(comp);
const l = await import('./layout.js');
await l.buildAndRoute({}, false);
m.saveComponentLayout(comp.id);
} else {
const newCount = parseInt(document.getElementById('ppc').value) || comp.pins.length;
const minCount = Math.max(1, comp.pins.filter(p => p.net).length);
const safeCount = Math.max(minCount, newCount);
if (safeCount > comp.pins.length) {
let nextPin = 1;
while (comp.pins.length < safeCount) {
if (!comp.pins.find(p => p.originalName === String(nextPin))) {
comp.pins.push({ name: String(nextPin), originalName: String(nextPin), net: null });
}
nextPin++;
}
} else if (safeCount < comp.pins.length) {
const keptPins = new Array();
comp.pins.forEach(p => { if (p.net || keptPins.length < safeCount) keptPins.push(p); });
comp.pins = keptPins;
}
const l = await import('./layout.js');
await l.buildAndRoute({}, false);
const m = await import('./app.js');
m.saveComponentLayout(comp.id);
}
updateSidePanels();
showProperties(comp);
toast('Updated ' + comp.ref);
};
}
export function showNetProperties(net) {
document.getElementById('right-panel')?.classList.add('open');
const col = net.color || getNetColor(net.name);
const rows = (net.nodes || new Array()).map(nd => {
const comp = S.components.find(c => c.ref === nd.ref);
const cCol = comp ? compColor(comp.type) : '#94a3b8';
return `<div style="display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px solid var(--border)">
<span style="color:${cCol}">${nd.ref}</span>
<span style="color:${col};font-size:10px">pin ${nd.pin}</span>
</div>`;
}).join('');
document.getElementById('prop-panel').innerHTML = `
<div class="prop-row">
<div class="prop-label">Net</div>
<div class="prop-val" style="color:${col}">${net.name}</div>
</div>
<div class="prop-row">
<div class="prop-label">Connections (${(net.nodes || new Array()).length})</div>
${rows || '<div style="color:var(--text2);font-size:10px">No connections</div>'}
</div>`;
}
export function showDefaultProps() {
document.getElementById('right-panel')?.classList.remove('open');
document.getElementById('prop-panel').innerHTML =
'<div style="color:var(--text2);font-size:10px;text-align:center;margin-top:20px">' +
'Click a component or wire to select.<br><br>' +
'Drag component to move.<br>Right-click for rotation.</div>';
}
// ── Side panels ───────────────────────────────────────────────
export function updateSidePanels() {
// Net list
const nl = document.getElementById('net-list');
nl.innerHTML = '';
S.nets.forEach(net => {
const d = document.createElement('div');
d.className = 'net-item' + (net.isWip ? ' wip' : '') +
(S.selectedNet === net.name ? ' selected' : '');
d.innerHTML = `<span class="net-dot" style="background:${net.color}"></span>
<span>${net.name}</span>
<span class="nc">${net.nodes.length}⬥</span>`;
d.onclick = () => {
if (window.innerWidth <= 800) document.getElementById('left-panel')?.classList.remove('open');
S.selectedNet = S.selectedNet === net.name ? null : net.name;
S.selectedComp = null; S.selectedWire = null;
if (S.selectedNet) showNetProperties(net); else showDefaultProps();
updateSidePanels(); render();
};
nl.appendChild(d);
});
// Component list
const cl = document.getElementById('comp-list');
cl.innerHTML = '';
S.components.forEach(c => {
const d = document.createElement('div');
d.className = 'comp-item' + (S.selectedComp === c.id ? ' selected' : '');
d.innerHTML = `<span class="cdot" style="background:${compColor(c.type)}"></span>
<span>${c.ref}</span>
<span style="margin-left:auto;color:var(--text2);font-size:10px">${c.value}</span>`;
d.onclick = () => {
if (window.innerWidth <= 800) document.getElementById('left-panel')?.classList.remove('open');
S.selectedComp = c.id; S.selectedWire = null; S.selectedNet = null;
showProperties(c); updateSidePanels(); render();
};
cl.appendChild(d);
});
}
// ── Export modal ───────────────────────────────────────────────
let _expFile = '', _expContent = '';
export function showExport(title, content, fname) {
_expFile = fname;
_expContent = content;
document.getElementById('export-title').textContent = title;
document.getElementById('export-content').textContent = content;
showModal('modal-export');
}
// ── Button wiring (called once from app.js) ───────────────────
export function initUI(mode, importNetlistStandalone, resetLayout, hasLayoutData) {
const isEmbedded = mode === 'embedded';
const btnLeft = document.getElementById('btn-toggle-left');
if (btnLeft) btnLeft.onclick = () => document.getElementById('left-panel').classList.toggle('open');
const btnRight = document.getElementById('btn-toggle-right');
if (btnRight) btnRight.onclick = () => document.getElementById('right-panel').classList.toggle('open');
const hideEl = id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; };
// In embedded mode, hide standalone-only controls
if (isEmbedded) {
hideEl('btn-load-db');
hideEl('btn-import-file');
hideEl('es-load-btn');
}
initLibraryManager();
// Load-from-DB button — repurposed as a hint in standalone mode
const loadBtn = document.getElementById('btn-load-db');
if (loadBtn && !isEmbedded) {
loadBtn.onclick = () => {
localStorage.setItem('pcb_startup_tab', 'schema');
window.location.href = 'studio.html';
}
}
// Empty-state button — opens import modal in standalone mode
const esBtn = document.getElementById('es-load-btn');
if (esBtn && !isEmbedded) {
esBtn.onclick = () => {
document.getElementById('import-textarea').value = '';
document.getElementById('file-name').textContent = 'No file';
showModal('modal-import');
};
}
// Import button in header
const importBtn = document.getElementById('btn-import-file');
if (importBtn && !isEmbedded) {
importBtn.onclick = () => {
document.getElementById('import-textarea').value = '';
document.getElementById('file-name').textContent = 'No file';
showModal('modal-import');
};
}
document.getElementById('btn-auto-place').onclick = async () => {
if (!S.hasData) { toast('No netlist', 'warn'); return; }
if (hasLayoutData && hasLayoutData()) {
// Use Studio's confirmAction if available (embedded), else use confirm()
const message = 'Reset layout? All manually placed positions will be lost.';
const ok = window.parent?.confirmAction
? await window.parent.confirmAction(message, 'Reset Layout')
: window.confirm(message);
if (!ok) return;
}
pushHistory();
if (resetLayout) await resetLayout();
toast('Auto layout applied');
};
// fitView already calls render() internally
document.getElementById('btn-zoom-fit').onclick = fitView;
document.getElementById('btn-zoom-fit2').onclick = fitView;
document.getElementById('btn-zoom-in').onclick = () => { S.zoom = Math.min(8, S.zoom * 1.25); render(); };
document.getElementById('btn-zoom-out').onclick = () => { S.zoom = Math.max(0.1, S.zoom / 1.25); render(); };
document.getElementById('btn-undo').onclick = undo;
// Import modal — file picker and OK
const pickBtn = document.getElementById('btn-pick-file');
if (pickBtn) pickBtn.onclick = () => document.getElementById('file-input').click();
document.getElementById('file-input').onchange = e => {
const f = e.target.files[0]; if (!f) return;
document.getElementById('file-name').textContent = f.name;
const r = new FileReader();
r.onload = ev => document.getElementById('import-textarea').value = ev.target.result;
r.readAsText(f);
};
document.getElementById('btn-import-cancel').onclick = () => hideModal('modal-import');
document.getElementById('btn-import-ok').onclick = () => {
const text = document.getElementById('import-textarea').value.trim();
const fname = document.getElementById('file-name').textContent;
if (!text) { toast('Nothing to import', 'err'); return; }
hideModal('modal-import');
if (importNetlistStandalone) {
importNetlistStandalone(text, fname !== 'No file' ? fname : 'Imported Netlist');
}
};
// Export buttons
document.getElementById('btn-export-kicad').onclick = () => {
if (!S.hasData) { toast('No schematic', 'warn'); return; }
import('./io.js').then(async m => showExport('KiCad Schematic (.kicad_sch)', await m.exportKiCad(), await m.getExportFilename('kicad_sch')));
};
/*
document.getElementById('btn-export-spice').onclick = () => {
if (!S.hasData) { toast('No schematic', 'warn'); return; }
import('./io.js').then(async m => showExport('SPICE (.cir) — LTspice/ngspice/KiCad Sim/MicroCAP', m.exportSpice(), await m.getExportFilename('cir')));
};
*/
// Export modal actions
document.getElementById('btn-export-close').onclick = () => hideModal('modal-export');
document.getElementById('btn-export-copy').onclick = () =>
navigator.clipboard.writeText(_expContent).then(() => toast('Copied'));
document.getElementById('btn-export-download').onclick = () => {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([_expContent], { type: 'text/plain' }));
a.download = _expFile;
a.click();
toast('Downloaded ' + _expFile);
};
// Close modal on backdrop click
document.querySelectorAll('.modal-overlay').forEach(o =>
o.addEventListener('click', e => { if (e.target === o) o.classList.remove('active'); })
);
}
// ── Library Manager Modal ──────────────────────────────────────
function initLibraryManager() {
const modal = document.getElementById('modal-library-manager');
const btnOpen = document.getElementById('btn-lib-mgr');
const defaultUi = document.getElementById('lib-default-ui');
const zipUi = document.getElementById('lib-zip-explorer');
const treeEl = document.getElementById('zip-tree');
const logEl = document.getElementById('lib-import-log');
let currentZip = null;
let libsData = {};
const log = (msg) => { logEl.innerHTML += `<div>${msg}</div>`; logEl.scrollTop = logEl.scrollHeight; };
const resetZipUi = () => { defaultUi.style.display = 'flex'; zipUi.style.display = 'none'; currentZip = null; libsData = {}; };
if (btnOpen) btnOpen.onclick = () => { modal.classList.add('active'); resetZipUi(); };
document.getElementById('btn-lib-close').onclick = () => { modal.classList.remove('active'); logEl.innerHTML = ''; resetZipUi(); };
document.getElementById('btn-zip-cancel').onclick = resetZipUi;
document.getElementById('btn-zip-sel-all').onclick = () => document.querySelectorAll('.cb-lib').forEach(cb => { cb.checked = true; cb.dispatchEvent(new Event('change')); });
document.getElementById('btn-zip-sel-none').onclick = () => document.querySelectorAll('.cb-lib').forEach(cb => { cb.checked = false; cb.dispatchEvent(new Event('change')); });
async function processZipBuffer(buffer) {
log('Parsing ZIP directory structure...');
currentZip = await JSZip.loadAsync(buffer);
const files = Object.keys(currentZip.files).filter(n => n.endsWith('.kicad_sym'));
libsData = {};
files.forEach(f => {
const match = f.match(/([^\/]+)\.kicad_symdir\//);
const libName = match ? match[1] : 'Imported';
if (!libsData[libName]) libsData[libName] = new Array();
libsData[libName].push({ path: f, name: f.split('/').pop().replace('.kicad_sym','') });
});
treeEl.innerHTML = '';
Object.keys(libsData).sort().forEach(libName => {
const libDiv = document.createElement('div');
libDiv.style.marginBottom = '6px';
const head = document.createElement('div');
head.style.display = 'flex'; head.style.alignItems = 'center'; head.style.gap = '6px';
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.className = 'cb-lib'; cb.dataset.lib = libName;
const lbl = document.createElement('span');
lbl.textContent = `${libName} (${libsData[libName].length} symbols)`;
lbl.style.flex = '1'; lbl.style.cursor = 'pointer';
lbl.onclick = () => { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); };
const btnExp = document.createElement('button');
btnExp.textContent = '▼';
btnExp.style.background = 'none'; btnExp.style.border = 'none'; btnExp.style.color = 'var(--text2)'; btnExp.style.cursor = 'pointer';
head.appendChild(cb); head.appendChild(lbl); head.appendChild(btnExp);
libDiv.appendChild(head);
const symsDiv = document.createElement('div');
symsDiv.style.display = 'none'; symsDiv.style.paddingLeft = '20px'; symsDiv.style.flexDirection = 'column'; symsDiv.style.marginTop = '4px';
let rendered = false;
btnExp.onclick = () => {
if (symsDiv.style.display === 'none') {
symsDiv.style.display = 'flex'; btnExp.textContent = '▲';
if (!rendered) {
libsData[libName].forEach(sym => {
const sRow = document.createElement('label');
sRow.style.display = 'flex'; sRow.style.gap = '6px'; sRow.style.fontSize = '10px'; sRow.style.color = 'var(--text)';
const sCb = document.createElement('input');
sCb.type = 'checkbox'; sCb.className = `cb-sym cb-sym-${libName}`; sCb.dataset.path = sym.path;
sCb.checked = cb.checked;
sCb.onchange = () => {
const allSyms = Array.from(document.querySelectorAll(`.cb-sym-${libName}`));
const checkedCount = allSyms.filter(x => x.checked).length;
cb.checked = checkedCount === allSyms.length;
cb.indeterminate = checkedCount > 0 && checkedCount < allSyms.length;
};
sRow.appendChild(sCb); sRow.appendChild(document.createTextNode(sym.name));
symsDiv.appendChild(sRow);
});
rendered = true;
}
} else {
symsDiv.style.display = 'none'; btnExp.textContent = '▼';
}
};
cb.onchange = () => {
cb.indeterminate = false;
if (rendered) document.querySelectorAll(`.cb-sym-${libName}`).forEach(x => x.checked = cb.checked);
};
libDiv.appendChild(symsDiv);
treeEl.appendChild(libDiv);
});
defaultUi.style.display = 'none';
zipUi.style.display = 'flex';
}
document.getElementById('btn-zip-import').onclick = async () => {
const selectedPaths = new Array();
document.querySelectorAll('.cb-lib').forEach(cbLib => {
const libName = cbLib.dataset.lib;
const symsDiv = cbLib.parentElement.nextElementSibling;
const rendered = symsDiv.children.length > 0;
if (rendered) {
document.querySelectorAll(`.cb-sym-${libName}:checked`).forEach(sCb => selectedPaths.push(sCb.dataset.path));
} else if (cbLib.checked) {
libsData[libName].forEach(sym => selectedPaths.push(sym.path));
}
});
if (selectedPaths.length === 0) return toast('No files selected', 'warn');
// Capture the zip reference BEFORE resetting the UI state!
const zipRef = currentZip;
resetZipUi();
log(`Extracting and parsing ${selectedPaths.length} files...`);
try {
const stats = await importSelectedFromZip(zipRef, selectedPaths);
log(`Success: <span style="color:#4ade80">${stats.inserted} inserted</span>, <span style="color:#f0c040">${stats.updated} updated</span>.`);
toast('Import Complete', 'ok');
} catch (err) { log(`<span style="color:var(--accent3)">Error: ${err.message}</span>`); }
};
document.getElementById('btn-lib-import-url').onclick = async () => {
const url = document.getElementById('lib-remote-url').value.trim();
if (!url) return toast('Please enter a URL', 'warn');
log(`Fetching ${url}...`);
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (url.endsWith('.zip')) {
const buf = await res.arrayBuffer();
await processZipBuffer(buf);
} else {
const text = await res.text();
const libName = url.split('/').pop().replace('.kicad_sym', '');
const stats = await importSymbolsFromText(text, libName);
log(`Success: <span style="color:#4ade80">${stats.inserted} inserted</span>, <span style="color:#f0c040">${stats.updated} updated</span> into '${libName}'.`);
toast('Library Imported Successfully', 'ok');
}
} catch (err) { log(`<span style="color:var(--accent3)">Error: ${err.message}</span>`); }
};
const fileInput = document.getElementById('lib-file-input');
document.getElementById('btn-lib-pick-file').onclick = () => fileInput.click();
fileInput.onchange = async (e) => {
const f = e.target.files[0];
if (!f) return;
document.getElementById('lib-file-name').textContent = f.name;
log(`Reading local file: ${f.name}...`);
try {
if (f.name.endsWith('.zip')) {
const buf = await f.arrayBuffer();
await processZipBuffer(buf);
} else {
const text = await f.text();
const libName = f.name.replace('.kicad_sym', '');
const stats = await importSymbolsFromText(text, libName);
log(`Success: <span style="color:#4ade80">${stats.inserted} inserted</span>, <span style="color:#f0c040">${stats.updated} updated</span> into '${libName}'.`);
toast('Library Imported Successfully', 'ok');
}
} catch (err) { log(`<span style="color:var(--accent3)">Error: ${err.message}</span>`); }
fileInput.value = '';
};
}
// ── Replace Symbol Modal ──────────────────────────────────────
export async function showReplaceSymbolModal(comp) {
const modal = document.getElementById('modal-replace-symbol');
const listEl = document.getElementById('sym-list');
const searchEl = document.getElementById('sym-search');
listEl.innerHTML = '<div style="padding:10px;text-align:center;color:var(--text2);">Loading Library...</div>';
modal.classList.add('active');
const t0 = performance.now();
const targetPinCount = comp.pins.length;
// Instantly fetch ONLY the components that match the required pin count using the DB index
const matchingSyms = await db.getKicadSymbolsByPinCount(targetPinCount);
const t1 = performance.now();
// console.log(`[Perf] DB Fetch/Cache: ${matchingSyms.length} records in ${(t1 - t0).toFixed(1)}ms`);
// Parse JSON only for the few thousand that actually matched pin count
const validSyms = matchingSyms.map(s => ({ ...s, parsed: JSON.parse(s.parsedData) }));
const t2 = performance.now();
// console.log(`[Perf] Fast Filter & Selective Parse: left ${validSyms.length} records in ${(t2 - t1).toFixed(1)}ms`);
const renderList = (filterText = '') => {
listEl.innerHTML = '';
// 1. Parse the search string
const terms = filterText.toLowerCase().split(/\s+/).filter(Boolean);
const filters = { name: null, lib: null, ref: null, desc: null, pins: null, text: new Array() };
terms.forEach(term => {
if (term.startsWith('name:')) filters.name = term.slice(5);
else if (term.startsWith('lib:')) filters.lib = term.slice(4);
else if (term.startsWith('ref:')) filters.ref = term.slice(4);
else if (term.startsWith('desc:')) filters.desc = term.slice(5);
else filters.text.push(term);
});
// 2. Filter the symbols
const filtered = validSyms.filter(s => {
if (filters.name && !s.symbol.toLowerCase().includes(filters.name)) return false;
if (filters.lib && !s.library.toLowerCase().includes(filters.lib)) return false;
if (filters.ref && !(s.reference || '').toLowerCase().includes(filters.ref)) return false;
if (filters.desc && !(s.description || '').toLowerCase().includes(filters.desc)) return false;
if (filters.text.length > 0) {
const combinedText =[
s.symbol,
s.library,
s.description || '',
s.keywords || '',
s.fp_filters || ''
].join(' ').toLowerCase();
// All general text terms must match somewhere in the combined text
if (!filters.text.every(t => combinedText.includes(t))) return false;
}
return true;
});
// 3. Render the list
if (filtered.length === 0) {
listEl.innerHTML = '<div style="padding:10px;text-align:center;color:var(--text2);">No matching symbols found.</div>';
return;
}
filtered.forEach(s => {
const item = document.createElement('div');
item.className = 'db-item';
const symNameHtml = s.datasheet && s.datasheet !== '~'
? `<a href="${s.datasheet}" target="_blank" style="font-weight:bold; color:var(--accent2); text-decoration:underline;" onclick="event.stopPropagation()">${s.symbol}</a>`
: `<span style="font-weight:bold; color:var(--accent);">${s.symbol}</span>`;
item.innerHTML = `
<div style="display:flex; flex-direction:column; max-width:70%;">
<div style="display:flex; align-items:center; gap:6px;">
<span style="font-size:9px; background:var(--surface2); padding:2px 4px; border-radius:3px; color:var(--text2);">${s.library}</span>
${symNameHtml}
</div>
<span style="font-size:10px; color:var(--text2); margin-top:4px;">${s.description || 'No description'}</span>
</div>
<div style="text-align:right; font-size:10px; color:var(--text2); line-height:1.4;">
<span style="color:var(--accent)">Ref: ${s.reference || '?'}</span><br>
${s.fp_filters ? `FP: ${s.fp_filters.substring(0, 15)}...<br>` : ''}
${s.parsed.pins.length} pins
</div>
`;
item.onclick = async () => {
modal.classList.remove('active');
await applyKiCadSymbolReplacement(comp, s);
};
listEl.appendChild(item);
});
};
// Setup smart default search string
let defaultSearch = '';
if (['R','C','L','D','Z'].includes(comp.type)) {
defaultSearch = `lib:device ref:${comp.type}`;
} else {
const refMatch = comp.ref.match(/^[A-Za-z]+/);
defaultSearch = comp.value ? comp.value : (refMatch ? `ref:${refMatch[0]}` : '');
}
searchEl.value = defaultSearch;
renderList(defaultSearch);
searchEl.oninput = (e) => renderList(e.target.value);
document.getElementById('btn-replace-cancel').onclick = () => modal.classList.remove('active');
}
async function applyKiCadSymbolReplacement(comp, kicadSym) {
let ct = await db.getComponentTypeForKicadSymbol(kicadSym.id);
if (!ct) {
const newCtId = uuid();
await db.saveComponentType({ id: newCtId, symbolTypeId: null, name: kicadSym.symbol, pinCount: kicadSym.parsed.pins.length, created: Date.now(), lastModified: Date.now() });
await db.saveComponentTypeKicadSymbol({ id: uuid(), componentTypeId: newCtId, kicadSymbolId: kicadSym.id, isPrimary: 1 });
ct = { id: newCtId };
}
const pinMap = {};
comp.pins.forEach((p, i) => {
if (kicadSym.parsed.pins[i]) p.name = kicadSym.parsed.pins[i].num;
pinMap[i + 1] = i + 1; // 1:1 mapping mapping for existing pins based on count
});
comp.type = 'KICAD';
comp.componentTypeId = ct.id;
comp.kicadData = { name: kicadSym.symbol, ...kicadSym.parsed };
// Because we replaced the symbol, use the optimized replacement route
import('./layout.js').then(l => l.replaceComponentRoute(comp, pinMap)).then(() => {
import('./app.js').then(({ saveComponentLayout }) => saveComponentLayout(comp.id));
});
toast(`Replaced with: ${kicadSym.symbol}`);
}

View file

@ -16,6 +16,7 @@
<script src="spyglass.js"></script>
<!-- CORE LOGIC -->
<script src="db-core.js"></script>
<script src="cv-core.js"></script>
<script src="stitch-editor.js"></script>
<script src="studio.js" defer></script>
@ -119,7 +120,7 @@
}
/* FORM */
.add-form-container {
.add-form-container {
display: flex;
gap: 0.4rem;
background: #fff;
@ -323,9 +324,9 @@
/* MODALS */
/* MODAL STACKING ORDER */
#image-settings-modal { z-index: 1000; } /* Base Level */
#connections-modal { z-index: 1050; } /* Level 2 */
#stitch-modal { z-index: 1060; } /* Level 3 */
#confirmation-modal { z-index: 1100; } /* Top Level (Alerts) */
#connections-modal { z-index: 1050; } /* Level 2 */
#stitch-modal { z-index: 1060; } /* Level 3 */
#confirmation-modal { z-index: 1100; } /* Top Level (Alerts) */
.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 1000; }
.modal-content { width: 95%; max-width: 900px; height: 90vh; background: #f4f4f9; border-radius: 0.5rem; position: relative; overflow: hidden; display: flex; flex-direction: column; }
.modal-header { padding: 0.5rem 1rem; background: #fff; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); }
@ -728,9 +729,17 @@
<div class="tabs">
<div id="tab-list" class="tab-btn active" onclick="switchView('list')">📋 BOM</div>
<div id="tab-map" class="tab-btn" onclick="switchView('map')">🖼️ Images</div>
<div id="tab-map" class="tab-btn" onclick="switchView('map')">🖼️ Images</div>
<div id="tab-nets" class="tab-btn" onclick="switchView('nets')">🕸️ Nets</div>
<div id="tab-inspect" class="tab-btn" onclick="switchView('inspect')">🔍 Inspect</div>
<div id="tab-schema" class="tab-btn" onclick="switchView('schema')">
<svg width="16" height="16" viewBox="0 0 16 16" style="vertical-align: middle; margin-right: 4px;">
<line x1="2" y1="11" x2="14" y2="11" stroke="currentColor" stroke-width="1.5"/>
<line x1="8" y1="1" x2="8" y2="11" stroke="currentColor" stroke-width="1.5"/>
<circle cx="8" cy="11" r="2" fill="currentColor"/>
</svg>
Schema
</div>
</div>
</header>
@ -841,6 +850,7 @@
<div class="list-fixed-top">
<div class="actions-bar">
<button class="secondary" onclick="exportKiCad()" title="Export KiCad">💾 KiCad</button>
<button class="secondary" onclick="exportSpice()" title="Export SPICE">💾 SPICE</button>
</div>
</div>
<div class="list-scroll-area">
@ -882,9 +892,18 @@
</div>
</div>
<!-- 5. SCHEMA VIEW — Schematic ReTrace (lazy-loaded iframe) -->
<div id="view-schema" class="view-section" style="padding:0; overflow:hidden; height:100%;">
<iframe
id="schema-frame"
style="width:100%; height:100%; border:none; display:block;"
src=""
></iframe>
</div>
</main>
<footer class="app-footer">Copyright © 2025 Taras Greben — <a href="https://pcb.etaras.com">pcb.eTaras.com</a></footer>
<footer class="app-footer">Copyright © 2025—2026 Taras Greben — <a href="https://pcb.etaras.com">pcb.eTaras.com</a></footer>
<!-- COMPONENT TOOL MODAL -->
<div style="display:none" id="tool-modal" class="modal-overlay"><div class="modal-content"><div class="modal-header"><h3 style="margin:0" id="tool-title">Component Tool</h3><button class="close-btn" onclick="closeTool()">×</button></div><div id="tool-selector"><h4 style="color:#64748b; margin-top:0">Choose:</h4><div class="selector-grid" id="selector-grid"></div></div><iframe id="tool-frame" src=""></iframe></div></div>

File diff suppressed because it is too large Load diff