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

30
.github/workflows/cla.yml vendored Normal file
View file

@ -0,0 +1,30 @@
name: "CLA Assistant"
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened,closed,synchronize]
permissions:
actions: write
contents: write
pull-requests: write
statuses: write
jobs:
CLAAssistant:
runs-on: ubuntu-latest
steps:
- name: "CLA Assistant"
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_request.pull_request
uses: cla-assistant/github-action@v2.5.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_SIGNATURE_PAT }}
with:
path-to-signatures: 'pcb-retrace-signatures.json'
path-to-cla: 'https://github.com/dev-lab/pcb-retrace/blob/main/CLA.md'
remote-organization-name: 'dev-lab'
remote-repository-name: 'dev-lab-cla-signatures'
branch: 'main'
allowlist: 'bot*,'

9
CLA.md Normal file
View file

@ -0,0 +1,9 @@
# Contributor License Agreement (CLA)
By commenting "I have read the CLA Document and I hereby sign the CLA" on a Pull Request, you agree to the following:
1. **Grant of Rights:** You grant to **Taras Greben** a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, and **sublicense** your contributions.
2. **Dual-Licensing Acknowledgment:** You understand that the Project is dual-licensed under AGPLv3 and a Commercial License. You agree that your contribution may be distributed under either or both of these licenses.
3. **Representation:** You represent that you are legally entitled to grant this license and that the contribution is your original creation.
4. **No Warranties:** Unless required by law, your contribution is provided on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND.

View file

@ -1,13 +1,13 @@
# PCB ReTrace
**Digitize, document, and reverse engineer printed circuit boards entirely in your browser.**
**Digitize, document, reverse engineer, and generate schematics for printed circuit boards entirely in your browser.**
[**Launch PCB ReTrace**](https://pcb.etaras.com/studio.html)
[**Launch PCB ReTrace**](https://pcb.etaras.com/studio.html) | [**Examples Gallery**](https://dev-lab.github.io/pcb-retrace-examples/)
---
## 🔍 Overview
**PCB ReTrace** is a local-first web application designed for electronics engineers, repair technicians, and hardware hackers. It helps you move from a physical PCB to a digital representation (BOM & Netlist) using only photographs.
**PCB ReTrace** is a local-first web application designed for electronics engineers, repair technicians, and hardware hackers. It helps you move from a physical PCB to a digital representation using only photographs.
It runs 100% client-side using HTML5, Vanilla JS, and WebAssembly. **No data is ever uploaded to a server.**
@ -17,7 +17,11 @@ It runs 100% client-side using HTML5, Vanilla JS, and WebAssembly. **No data is
* **Visual Inspection:** View multiple board layers (Top/Bottom) simultaneously.
* **Computer Vision Stitching:** Automatically align close-up macro shots with wide-angle reference photos using OpenCV.
* **X-Ray View:** Manually stitch Top and Bottom images to trace vias and through-holes across layers.
* **Netlist Tracing:** click-to-trace connectivity and export to **KiCad** compatible netlists (`.net`).
* **Netlist Tracing:** Click-to-trace connectivity and export to **KiCad** compatible netlists (`.net`) and SPICE netlists (`.cir`).
* **Schematic Generation (Schema ReTrace):** Generate and route schematic diagrams from traced nets.
* **Interactive Editing:** Drag components, rotate them, and replace symbols.
* **KiCad Libraries:** Import `.kicad_sym` libraries or ZIP archives to assign standard symbols to components.
* **KiCad Schematic Export:** Export a generated schematic diagram to a **KiCad** compatible format (`.kicad_sch`).
* **Integrated Tools:**
* Resistor Color Code Decoder (3-6 bands)
* Inductor Color Code Decoder
@ -43,15 +47,25 @@ Since the project uses no build tools, you can run it directly from the source:
3. Open `index.html` in your browser.
* *Note: Some features (like Camera access) require a secure context (HTTPS) or `localhost`. For best results, use a simple local server like Python's `http.server`.*
## 🔗 Sharing & Deep Links
You can easily share a reverse-engineered device setup by hosting an exported ZIP file on a public server (like GitHub Pages) and creating a deep link.
Append `?import={URL}` to the studio address:
`https://pcb.etaras.com/studio.html?import=https://example.com/my-device.zip`
*(Note: The hosting server must support CORS by sending the `Access-Control-Allow-Origin: *` header).*
## 📂 Project Structure
This project follows a "Build-Free" architecture. All source code is located in the `/docs` folder to support GitHub Pages directly.
* `docs/studio.html`: The main application entry point.
* `docs/studio.js`: Core controller and logic.
* `docs/cv-core.js`: Computer Vision & Homography logic (OpenCV wrapper).
* `docs/inspector.js`: Visual trace tracking logic.
* `docs/nets.js`: Netlist management and KiCad export.
* `docs/schema.html`: Standalone schematic generation and netlist visualization.
* `docs/guide.html`: PCB ReTrace User Guide.
* `docs/resistor.html`: Resistor Color Code Decoder.
* `docs/inductor.html`: Inductor Color Code Decoder.
* `docs/coil.html`: Air-core Coil Calculator.
## ⚖️ License & Commercial Use
@ -64,10 +78,12 @@ Please see [LICENSE](LICENSE) for details.
For commercial licensing inquiries, please contact: Taras Greben <taras.greben@gmail.com>.
## 📦 Third-Party Libraries
* **OpenCV.js:** Computer Vision (Apache 2.0 License)
* **JSZip:** File compression/decompression (MIT License)
* **WireBender:** Schematic generation/routing library (AGPLv3 or Commercial)
* **Adaptagrams:** Used by WireBender via `libavoid`, `libcola`, and `libvpsc` (LGPL 2.1 or later)
* **KiCad symbol libraries:** Redistributed symbol data bundled as `assets/kicad-symbols-master.zip`
See [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) for full text.

View file

@ -52,3 +52,38 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---
## WireBender
**License:** GNU Affero General Public License v3.0 (AGPL-3.0) + Commercial License Option
**URL:** https://github.com/dev-lab/WireBender
WireBender is my own library. It includes third-party code from Adaptagrams:
- libavoid
- libcola
- libvpsc
See the Adaptagrams section below for the third-party license terms.
---
## Adaptagrams (libavoid, libcola, libvpsc)
**License:** GNU Lesser General Public License v2.1 (LGPL-2.1)
**Copyright:** (c) The Adaptagrams Authors and Contributors
This software uses libavoid, libcola, and libvpsc from the Adaptagrams project, which are licensed under the LGPL v2.1.
You may obtain a copy of the License at:
https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
---
## KiCad Symbols Library
**License:** Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) with KiCad Library Exception
**Copyright:** (c) The KiCad Developers
To prevent cross-origin (CORS) errors, this application hosts a redistributable copy of `kicad-symbols-master.zip`.
The library is licensed under CC BY-SA 4.0 with a special exception that allows you to use these symbols in your schematic designs without the design itself becoming subject to the CC BY-SA 4.0 license.
Full license text and exception details can be found at:
https://gitlab.com/kicad/libraries/kicad-symbols/-/blob/master/LICENSE.md

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>

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

@ -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);
}
}

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>
@ -731,6 +732,14 @@
<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>

View file

@ -1,7 +1,5 @@
/* studio.js - Main Application Logic */
const DB_NAME = 'PcbReTrace'; const DB_VER = 1;
const ICONS = { RESISTOR: `<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="6" width="16" height="12" rx="3" fill="#bae6fd" stroke="#0ea5e9" stroke-width="1"/><rect x="7" y="6" width="2" height="12" fill="#ef4444"/><rect x="11" y="6" width="2" height="12" fill="#000000"/><rect x="15" y="6" width="2" height="12" fill="#ef4444"/><line x1="1" y1="12" x2="4" y2="12" stroke="#94a3b8" stroke-width="2"/><line x1="20" y1="12" x2="23" y2="12" stroke="#94a3b8" stroke-width="2"/></svg>`, INDUCTOR: `<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="5" width="16" height="14" rx="4" fill="#bbf7d0" stroke="#22c55e" stroke-width="1"/><rect x="7" y="5" width="2" height="14" fill="#cbd5e1"/><rect x="11" y="5" width="2" height="14" fill="#ef4444"/><rect x="15" y="5" width="2" height="14" fill="#ef4444"/><line x1="1" y1="12" x2="4" y2="12" stroke="#94a3b8" stroke-width="2"/><line x1="20" y1="12" x2="23" y2="12" stroke="#94a3b8" stroke-width="2"/></svg>`, COIL: `<svg viewBox="0 0 24 24" fill="none" stroke="#ea580c" stroke-width="2" stroke-linecap="round"><line x1="1" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="23" y2="12"/><path d="M5 12 C5 4 9 4 9 12"/><path d="M9 12 C9 19 10 19 10 12" stroke-opacity="0.5"/><path d="M10 12 C10 4 14 4 14 12"/><path d="M14 12 C14 19 15 19 15 12" stroke-opacity="0.5"/><path d="M15 12 C15 4 19 4 19 12"/></svg>` };
const TOOL_REGISTRY = { 'R': [{ url: 'resistor.html', icon: ICONS.RESISTOR, title: 'Resistor' }], 'L': [{ url: 'inductor.html', icon: ICONS.INDUCTOR, title: 'Inductor' }, { url: 'coil.html', icon: ICONS.COIL, title: 'Coil' }] };
const MAX_DIRECT_TOOLS = 3;
@ -31,36 +29,18 @@ let currentOverlaps = [];
let netManager = null;
let inspector = null;
// Utility: Global UUID generator (used by StitchEditor too)
window.uuid = function(){ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c=>{var r=Math.random()*16|0,v=c=='x'?r:(r&0x3|0x8);return v.toString(16);}); }
class PcbDatabase {
constructor() { this.name=DB_NAME; this.ver=DB_VER; }
async init() { return new Promise((r,j)=>{
constructor() { this.name = window.PcbDbCore.DB_NAME; this.ver = window.PcbDbCore.DB_VER; }
async init() {
return new Promise((r, j) => {
const q = indexedDB.open(this.name, this.ver);
q.onupgradeneeded = e => {
const d=e.target.result;
if(d.objectStoreNames.contains('projects')) d.deleteObjectStore('projects');
if(!d.objectStoreNames.contains('devices')) d.createObjectStore('devices',{keyPath:'id'});
if(!d.objectStoreNames.contains('boards')) { const bs=d.createObjectStore('boards',{keyPath:'id'}); bs.createIndex('deviceId','deviceId',{unique:false}); }
if(!d.objectStoreNames.contains('components')) { const cs=d.createObjectStore('components',{keyPath:'id'}); cs.createIndex('boardId','boardId',{unique:false}); }
if(!d.objectStoreNames.contains('images')) { const is=d.createObjectStore('images',{keyPath:'id'}); is.createIndex('boardId','boardId',{unique:false}); }
// v6: POI Support
if(!d.objectStoreNames.contains('overlappedImages')) {
const os=d.createObjectStore('overlappedImages',{keyPath:'id'});
os.createIndex('fromImageId','fromImageId');
}
// v7: Nets Store
if(!d.objectStoreNames.contains('nets')) {
const ns=d.createObjectStore('nets',{keyPath:'id'});
// No index needed yet as we usually load all nets for a board via manual filter or ID list
}
window.PcbDbCore.setupDatabase(e.target.result, e.target.transaction);
};
q.onsuccess = e => { this.db = e.target.result; r() };
q.onerror = e => { console.error("DB Open Error:", e); j(e); };
}); }
});
}
// Helpers
async _tx(s, m, cb) { return new Promise((r, j) => { const t = this.db.transaction(s, m); const q = cb(t.objectStore(s)); q.onsuccess = () => r(q.result); q.onerror = () => j(q.error); }); }
@ -110,6 +90,28 @@ class PcbDatabase {
}
});
}
async getSchemas() { return this._tx('schemas', 'readonly', s => s.getAll()).catch(() => []); }
async addSchema(s) { return this._tx('schemas', 'readwrite', st => st.put(s)); }
async deleteSchema(id) { return this._tx('schemas', 'readwrite', st => st.delete(id)); }
async getSchemaComponents() { return this._tx('schemaComponents', 'readonly', s => s.getAll()).catch(() => []); }
async addSchemaComponent(sc) { return this._tx('schemaComponents', 'readwrite', s => s.put(sc)); }
async deleteSchemaComponent(id) { return this._tx('schemaComponents', 'readwrite', s => s.delete(id)); }
async getKicadSymbols() { return this._tx('kicadSymbols', 'readonly', s => s.getAll()).catch(() => []); }
async addKicadSymbol(s) { return this._tx('kicadSymbols', 'readwrite', st => st.put(s)); }
async getComponentTypes() { return this._tx('componentTypes', 'readonly', s => s.getAll()).catch(() => []); }
async addComponentType(ct) { return this._tx('componentTypes', 'readwrite', s => s.put(ct)); }
async getComponentTypeKicadSymbols() { return this._tx('componentTypeKicadSymbols', 'readonly', s => s.getAll()).catch(() => []); }
async addComponentTypeKicadSymbol(ctks) { return this._tx('componentTypeKicadSymbols', 'readwrite', s => s.put(ctks)); }
async getComponentTypePins() { return this._tx('componentTypePins', 'readonly', s => s.getAll()).catch(() => []); }
async addComponentTypePin(ctp) { return this._tx('componentTypePins', 'readwrite', s => s.put(ctp)); }
async getSymbolTypes() { return this._tx('symbolTypes', 'readonly', s => s.getAll()).catch(() => []); }
async addSymbolType(st) { return this._tx('symbolTypes', 'readwrite', s => s.put(st)); }
}
// MAIN INIT
@ -143,7 +145,7 @@ async function init() {
await db.addProject({ id: bid, deviceId: defDev.id, name: 'Main Board', sortMode: 'none' });
}
currentDeviceId = localStorage.getItem('pcb_dev_id') || deviceList[0].id;
currentDeviceId = localStorage.getItem('pcb_device_id') || deviceList[0].id;
if (!deviceList.find(d => d.id === currentDeviceId)) currentDeviceId = deviceList[0].id;
updateDeviceDropdown();
@ -177,6 +179,11 @@ async function init() {
setupDragDrop();
NavManager.init();
const startupTab = localStorage.getItem('pcb_startup_tab');
if (startupTab) {
localStorage.removeItem('pcb_startup_tab');
setTimeout(() => switchView(startupTab), 100);
}
}
// Helper: Find all transitive connections for a given image
@ -206,7 +213,7 @@ function updateDeviceDropdown() {
async function switchDevice() {
resetStickyEditor();
currentDeviceId = document.getElementById('device-select').value;
localStorage.setItem('pcb_dev_id', currentDeviceId);
localStorage.setItem('pcb_device_id', currentDeviceId);
await loadDeviceBoms();
}
@ -231,7 +238,7 @@ async function loadDeviceBoms() {
});
s.appendChild(grp);
}
const lastBom = localStorage.getItem('pcb_bom_id');
const lastBom = localStorage.getItem('pcb_board_id');
currentBomId = (lastBom && bomList.find(b => b.id === lastBom)) ? lastBom : bomList[0].id;
s.value = currentBomId;
}
@ -241,7 +248,7 @@ async function loadDeviceBoms() {
async function switchBom() {
resetStickyEditor();
currentBomId = document.getElementById('bom-select').value;
localStorage.setItem('pcb_bom_id', currentBomId);
localStorage.setItem('pcb_board_id', currentBomId);
currentImgId = null;
await loadProjectData();
}
@ -256,6 +263,10 @@ async function loadProjectData() {
window.inspector.activeNet = null; // Reset active net
await window.inspector.init(); // Rebuild sidebar & grid
}
const schemaView = document.getElementById('view-schema');
if (schemaView && schemaView.classList.contains('active')) {
switchView('schema');
}
};
if (!currentBomId) {
@ -425,7 +436,7 @@ async function deleteCurrentDevice() {
}
currentDeviceId = deviceList[0].id;
localStorage.setItem('pcb_dev_id', currentDeviceId);
localStorage.setItem('pcb_device_id', currentDeviceId);
updateDeviceDropdown();
await loadDeviceBoms();
@ -487,6 +498,103 @@ async function deleteCurrentPart() {
}
// --- IMPORT / EXPORT ---
// --- SCHEMA DEPENDENCY RESOLVERS ---
async function gatherSchemaDependencies(schemaComps) {
const usedCTIds = new Set(schemaComps.map(sc => sc.componentTypeId).filter(Boolean));
const componentTypes = (await db.getComponentTypes() || []).filter(ct => usedCTIds.has(ct.id));
const usedSymbolTypeIds = new Set(componentTypes.map(ct => ct.symbolTypeId).filter(Boolean));
const allSymbolTypes = await db.getSymbolTypes() || [];
const symbolTypes = [];
const addSymType = (id) => {
if (!id) return;
const st = allSymbolTypes.find(s => s.id === id);
if (st && !symbolTypes.some(s => s.id === id)) {
symbolTypes.push(st);
addSymType(st.parentId);
}
};
usedSymbolTypeIds.forEach(addSymType);
const componentTypePins = (await db.getComponentTypePins() || []).filter(p => usedCTIds.has(p.componentTypeId));
const componentTypeKicadSymbols = (await db.getComponentTypeKicadSymbols() || []).filter(ck => usedCTIds.has(ck.componentTypeId));
const usedKSIds = new Set(componentTypeKicadSymbols.map(ck => ck.kicadSymbolId).filter(Boolean));
const kicadSymbols = (await db.getKicadSymbols() || []).filter(ks => usedKSIds.has(ks.id));
return { componentTypes, symbolTypes, componentTypePins, componentTypeKicadSymbols, kicadSymbols };
}
async function importSchemaDependencies(schemaDeps, idMap) {
if (!schemaDeps) return;
const existingSTs = await db.getSymbolTypes() || [];
for (const st of schemaDeps.symbolTypes || []) {
const existing = existingSTs.find(e => e.name === st.name);
if (existing) { idMap.set(st.id, existing.id); } else {
const newSt = { ...st, id: uuid() };
idMap.set(st.id, newSt.id);
await db.addSymbolType(newSt);
existingSTs.push(newSt);
}
}
for (const st of schemaDeps.symbolTypes || []) {
if (idMap.has(st.id) && idMap.get(st.id) !== st.id && st.parentId && idMap.has(st.parentId)) {
const actualSt = existingSTs.find(e => e.id === idMap.get(st.id));
if (actualSt && actualSt.parentId !== idMap.get(st.parentId)) {
actualSt.parentId = idMap.get(st.parentId);
await db.addSymbolType(actualSt);
}
}
}
const existingKSs = await db.getKicadSymbols() || [];
for (const ks of schemaDeps.kicadSymbols || []) {
const existing = existingKSs.find(e => e.library === ks.library && e.symbol === ks.symbol);
if (existing) { idMap.set(ks.id, existing.id); } else {
const newKs = { ...ks, id: uuid() };
idMap.set(ks.id, newKs.id);
await db.addKicadSymbol(newKs);
existingKSs.push(newKs);
}
}
const existingCTs = await db.getComponentTypes() || [];
for (const ct of schemaDeps.componentTypes || []) {
const existing = existingCTs.find(e => e.name === ct.name);
if (existing) { idMap.set(ct.id, existing.id); } else {
const newCt = { ...ct, id: uuid(), symbolTypeId: idMap.get(ct.symbolTypeId) || ct.symbolTypeId };
idMap.set(ct.id, newCt.id);
await db.addComponentType(newCt);
existingCTs.push(newCt);
}
}
const existingCTPs = await db.getComponentTypePins() || [];
for (const ctp of schemaDeps.componentTypePins || []) {
const mappedCtId = idMap.get(ctp.componentTypeId) || ctp.componentTypeId;
const existing = existingCTPs.find(e => e.componentTypeId === mappedCtId && e.name === ctp.name);
if (existing) { idMap.set(ctp.id, existing.id); } else {
const newCtp = { ...ctp, id: uuid(), componentTypeId: mappedCtId };
idMap.set(ctp.id, newCtp.id);
await db.addComponentTypePin(newCtp);
existingCTPs.push(newCtp);
}
}
const existingCTKSs = await db.getComponentTypeKicadSymbols() || [];
for (const ctks of schemaDeps.componentTypeKicadSymbols || []) {
const mappedCtId = idMap.get(ctks.componentTypeId) || ctks.componentTypeId;
const mappedKsId = idMap.get(ctks.kicadSymbolId) || ctks.kicadSymbolId;
const existing = existingCTKSs.find(e => e.componentTypeId === mappedCtId && e.kicadSymbolId === mappedKsId);
if (existing) { idMap.set(ctks.id, existing.id); } else {
const newCtks = { ...ctks, id: uuid(), componentTypeId: mappedCtId, kicadSymbolId: mappedKsId };
idMap.set(ctks.id, newCtks.id);
await db.addComponentTypeKicadSymbol(newCtks);
existingCTKSs.push(newCtks);
}
}
}
// Device Export
async function exportDeviceZIP() {
if (!window.JSZip) return alert("JSZip required.");
@ -498,6 +606,8 @@ async function exportDeviceZIP() {
// Fetch ALL nets once (since we don't have an index, we filter in JS)
const allNets = await db.getNets();
const allSchemas = await db.getSchemas();
const allSchemaComps = await db.getSchemaComponents();
// 1. Generate README
const readmeContent = `PCB ReTrace Data Export
@ -527,6 +637,9 @@ HOW TO USE:
// Filter nets for this specific board
const boardNets = allNets.filter(n => n.projectId === bom.id);
const boardSchemas = allSchemas.filter(s => s.boardId === bom.id);
const boardSchemaIds = new Set(boardSchemas.map(s => s.id));
const boardSchemaComps = allSchemaComps.filter(sc => boardSchemaIds.has(sc.schemaId));
const overlapsMap = new Map();
for (const img of imgs) {
@ -542,7 +655,9 @@ HOW TO USE:
components: cleanComps,
images: imgMeta,
overlaps: Array.from(overlapsMap.values()),
nets: boardNets // Add Nets to manifest
nets: boardNets,
schemas: boardSchemas,
schemaComponents: boardSchemaComps
});
// Image Loop
@ -561,6 +676,15 @@ HOW TO USE:
}
}
manifest.deviceSchemas = allSchemas.filter(s => s.deviceId === dev.id && !s.boardId);
const devSchemaIds = new Set(manifest.deviceSchemas.map(s => s.id));
manifest.deviceSchemaComponents = allSchemaComps.filter(sc => devSchemaIds.has(sc.schemaId));
const exportedSchemaComps = manifest.deviceSchemaComponents.concat(
manifest.boards.flatMap(b => b.schemaComponents || [])
);
manifest.schemaDependencies = await gatherSchemaDependencies(exportedSchemaComps);
zip.file("device.json", JSON.stringify(manifest, null, 2));
zip.generateAsync({ type: "blob" }).then(c => dl(c, `${dev.name}_Backup.zip`, 'application/zip'));
}
@ -797,6 +921,10 @@ async function restoreDevice(manifest, zip) {
if (!existingDev) {
await db.addDevice(manifest.device);
}
const idMap = new Map();
if (manifest.schemaDependencies) {
await importSchemaDependencies(manifest.schemaDependencies, idMap);
}
// --- PRUNING PHASE 1: BOARDS ---
const localBoards = await db.getProjectsByDevice(devId);
@ -859,7 +987,7 @@ async function restoreDevice(manifest, zip) {
});
}
// 4. Prune Nets [NEW]
// 4. Prune Nets
const allNets = await db.getNets();
const localNets = allNets.filter(n => n.projectId === boardId);
const importNetIds = new Set((boardData.nets || []).map(n => n.id));
@ -867,6 +995,14 @@ async function restoreDevice(manifest, zip) {
if (!importNetIds.has(ln.id)) await db.deleteNet(ln.id);
}
// 5. Prune Schemas
const allSchemas = await db.getSchemas();
const localSchemas = allSchemas.filter(s => s.boardId === boardId);
const importSchemaIds = new Set((boardData.schemas || []).map(s => s.id));
for (const ls of localSchemas) {
if (!importSchemaIds.has(ls.id)) await db.deleteSchema(ls.id);
}
} else {
mergedBoards++;
}
@ -921,7 +1057,7 @@ async function restoreDevice(manifest, zip) {
}
}
// Nets [NEW]
// Nets
if (boardData.nets) {
for (const net of boardData.nets) {
// Ensure correct association
@ -934,6 +1070,54 @@ async function restoreDevice(manifest, zip) {
}
}
}
// Schemas
if (boardData.schemas) {
for (const schema of boardData.schemas) {
schema.boardId = boardId;
schema.deviceId = devId;
if (isNewer) {
await db.addSchema(schema);
} else {
const existing = await db._tx('schemas', 'readonly', s => s.get(schema.id));
if (!existing) await db.addSchema(schema);
}
}
}
// SchemaComponents
if (boardData.schemaComponents) {
for (const sc of boardData.schemaComponents) {
if (sc.componentTypeId && idMap.has(sc.componentTypeId)) {
sc.componentTypeId = idMap.get(sc.componentTypeId);
}
if (isNewer) {
await db.addSchemaComponent(sc);
} else {
const existing = await db._tx('schemaComponents', 'readonly', s => s.get(sc.id));
if (!existing) await db.addSchemaComponent(sc);
}
}
}
}
// Upsert device-level schemas
if (manifest.deviceSchemas) {
for (const schema of manifest.deviceSchemas) {
schema.deviceId = devId;
schema.boardId = null;
const existing = await db._tx('schemas', 'readonly', s => s.get(schema.id));
if (!existing) await db.addSchema(schema);
}
}
if (manifest.deviceSchemaComponents) {
for (const sc of manifest.deviceSchemaComponents) {
if (sc.componentTypeId && idMap.has(sc.componentTypeId)) {
sc.componentTypeId = idMap.get(sc.componentTypeId);
}
const existing = await db._tx('schemaComponents', 'readonly', s => s.get(sc.id));
if (!existing) await db.addSchemaComponent(sc);
}
}
deviceList = await db.getDevices();
@ -943,6 +1127,10 @@ async function restoreDevice(manifest, zip) {
}
async function processImportData(data, zipObj) {
const idMap = new Map();
if (data.schemaDependencies) {
await importSchemaDependencies(data.schemaDependencies, idMap);
}
const boardId = data.meta.id;
const localBoard = await db.getProject(boardId);
@ -986,7 +1174,7 @@ async function processImportData(data, zipObj) {
});
}
// 4. Nets [NEW]
// 4. Nets
const allNets = await db.getNets();
const localNets = allNets.filter(n => n.projectId === boardId);
const importNetIds = new Set((data.nets || []).map(n => n.id));
@ -994,6 +1182,15 @@ async function processImportData(data, zipObj) {
if (!importNetIds.has(ln.id)) await db.deleteNet(ln.id);
}
// 5. Schemas
const allSchemas = await db.getSchemas();
const localSchemas = allSchemas.filter(s => s.boardId === boardId);
const safeSchemas = data.schemas || new Array();
const importSchemaIds = new Set(safeSchemas.map(s => s.id));
for (const ls of localSchemas) {
if (!importSchemaIds.has(ls.id)) await db.deleteSchema(ls.id);
}
} else {
console.log("Import is older/same. Merging missing items only.");
}
@ -1052,7 +1249,7 @@ async function processImportData(data, zipObj) {
}
}
// Nets [NEW]
// Nets
if (data.nets) {
for (const net of data.nets) {
net.projectId = boardId;
@ -1065,6 +1262,36 @@ async function processImportData(data, zipObj) {
}
}
// Schemas
if (data.schemas) {
for (const schema of data.schemas) {
schema.boardId = boardId;
if (isNewer) {
await db.addSchema(schema);
} else {
const existing = await db._tx('schemas', 'readonly', s => s.get(schema.id));
if (!existing) await db.addSchema(schema);
}
}
}
// SchemaComponents
if (data.schemaComponents) {
for (const sc of data.schemaComponents) {
// Map the componentTypeId to the newly generated one (if it was created during import)
if (sc.componentTypeId && idMap.has(sc.componentTypeId)) {
sc.componentTypeId = idMap.get(sc.componentTypeId);
}
if (isNewer) {
await db.addSchemaComponent(sc);
} else {
const existing = await db._tx('schemaComponents', 'readonly', s => s.get(sc.id));
if (!existing) await db.addSchemaComponent(sc);
}
}
}
await loadDeviceBoms();
}
@ -1095,6 +1322,13 @@ async function exportZIP() {
const imgMeta = images.map(i => ({ id: i.id, name: i.name, type: 'image/jpeg' }));
const cleanComps = bomData.map(c => { const { boardId, ...rest } = c; return rest; });
const allSchemas = await db.getSchemas();
const boardSchemas = allSchemas.filter(s => s.boardId === currentBomId);
const boardSchemaIds = new Set(boardSchemas.map(s => s.id));
const allSchemaComps = await db.getSchemaComponents();
const boardSchemaComps = allSchemaComps.filter(sc => boardSchemaIds.has(sc.schemaId));
const schemaDeps = await gatherSchemaDependencies(boardSchemaComps);
// 1. Generate README
const readmeContent = `PCB ReTrace Data Export
Type: Single Board (${meta.name})
@ -1115,6 +1349,9 @@ HOW TO USE:
images: imgMeta,
overlaps: overlaps,
nets: boardNets, // Add Nets
schemas: boardSchemas,
schemaComponents: boardSchemaComps,
schemaDependencies: schemaDeps,
version: DB_VER,
source: "pcb.etaras.com"
};
@ -1192,6 +1429,13 @@ function switchView(v) {
// View Active State
const view = document.getElementById(`view-${v}`);
if (view) view.classList.add('active');
// const schemaDiv = document.getElementById('view-schema');
// if (schemaDiv) {
// console.log('[studio] view-schema computed display:',
// window.getComputedStyle(schemaDiv).display,
// 'clientWidth:', schemaDiv.clientWidth,
// 'clientHeight:', schemaDiv.clientHeight);
// }
// View Specific Initialization
if (v === 'map') {
@ -1206,7 +1450,45 @@ function switchView(v) {
else if (v === 'inspect') {
if (window.inspector) window.inspector.init();
}
// 'list' needs no specific init
else if (v === 'schema') {
const frame = document.getElementById('schema-frame');
if (!frame) return;
const mc = document.querySelector('.main-content');
const msg = {
type: 'SCHEMA_INIT',
boardId: currentBomId,
deviceId: currentDeviceId,
width: mc ? mc.clientWidth : window.innerWidth,
height: mc ? mc.clientHeight : window.innerHeight
};
if (!frame._schemaReady) {
// First visit — load the page, send message after load
// console.log('[studio] setting frame.src, _schemaReady:', frame._schemaReady,
// 'boardId:', currentBomId);
frame.onload = () => {
// console.log('[studio] frame.onload fired, frame clientWidth:', frame.clientWidth,
// 'clientHeight:', frame.clientHeight);
// console.log('[studio] frame computed style width:',
// window.getComputedStyle(frame).width,
// 'height:', window.getComputedStyle(frame).height,
// 'display:', window.getComputedStyle(frame).display,
// 'position:', window.getComputedStyle(frame).position);
frame._schemaReady = true;
// console.log('[studio] about to postMessage, contentWindow:',
// !!frame.contentWindow,
// 'contentWindow.location:', frame.contentWindow?.location?.href);
frame.contentWindow.postMessage(msg, '*');
};
frame.src = 'schema.html';
// console.log('[studio] frame clientWidth:', frame.clientWidth,
// 'clientHeight:', frame.clientHeight,
// 'offsetWidth:', frame.offsetWidth,
// 'offsetHeight:', frame.offsetHeight);
} else {
// Already loaded — send directly
frame.contentWindow.postMessage(msg, '*');
}
}
}
function resetStickyEditor() {
@ -1415,6 +1697,12 @@ window.addEventListener('message', async e=>{
// Note: We don't reloadProjectData() here to keep the UI smooth while dragging
}
}
else if (e.data?.type === 'SCHEMA_READY') {
const frame = document.getElementById('schema-frame');
if (frame) clearInterval(frame._schemaTimer);
}
});
// --- SPYGLASS & MAP (ENHANCED) ---
@ -2565,13 +2853,23 @@ async function processUrlImport(url) {
}
}
new ResizeObserver(() => {
const frame = document.getElementById('schema-frame');
const mc = document.querySelector('.main-content');
if (frame?._schemaReady && frame.contentWindow && mc) {
frame.contentWindow.postMessage({ type: 'SCHEMA_RESIZE' }, '*');
}
}).observe(document.querySelector('.main-content'));
// Start
window.onload = init;
window.exportKiCad = () => {
if (window.netManager) window.netManager.exportKiCad();
};
window.exportSpice = () => {
if (window.netManager) window.netManager.exportSpice();
};
window.startNewNet = () => {
if (window.inspector) window.inspector.startNewNet();
};