Schema ReTrace added (create schematic diagrams from netlists).
This commit is contained in:
parent
33d9820785
commit
d7b4a6c499
27 changed files with 5985 additions and 385 deletions
30
.github/workflows/cla.yml
vendored
Normal file
30
.github/workflows/cla.yml
vendored
Normal 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
9
CLA.md
Normal 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.
|
||||
|
||||
34
README.md
34
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
84
docs/db-core.js
Normal 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});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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 © 2025 Taras Greben.
|
||||
Copyright © 2025—2026 Taras Greben.
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
13
docs/kicad.svg
Normal 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 |
51
docs/nets.js
51
docs/nets.js
|
|
@ -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
404
docs/schema.html
Normal 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 © 2026 Taras Greben — <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
498
docs/schema/app.js
Normal 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
870
docs/schema/components.js
Normal 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
469
docs/schema/db.js
Normal 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
132
docs/schema/draw.js
Normal 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
28
docs/schema/history.js
Normal 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
551
docs/schema/interaction.js
Normal 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
404
docs/schema/io.js
Normal 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
201
docs/schema/kicad.js
Normal 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
459
docs/schema/layout.js
Normal 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
139
docs/schema/parsers.js
Normal 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
75
docs/schema/state.js
Normal 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
786
docs/schema/ui.js
Normal 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}`);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
374
docs/studio.js
374
docs/studio.js
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue