PCB ReTrace initial
This commit is contained in:
commit
f06b8a68b8
20 changed files with 7089 additions and 0 deletions
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# --- Operating System Files ---
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
._*
|
||||
|
||||
# --- Editor & IDE Configs ---
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# --- Exported Data & Backups ---
|
||||
# Prevent committing personal board data, exports, or test images
|
||||
*.zip
|
||||
*.csv
|
||||
*.net
|
||||
*.kicad_sch
|
||||
*.kicad_pcb
|
||||
|
||||
# Specific JSON exports (extracted from ZIPs)
|
||||
# Note: We allow *.json generally for configs, but ignore these specific export names
|
||||
device.json
|
||||
bom.json
|
||||
|
||||
# --- Temporary / Test Files ---
|
||||
tmp/
|
||||
temp/
|
||||
*.log
|
||||
33
LICENSE
Normal file
33
LICENSE
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
PCB ReTrace - Dual Licensing Notice
|
||||
========================================================================
|
||||
|
||||
This software is available under two different licenses:
|
||||
|
||||
1. GNU Affero General Public License v3.0 (AGPLv3)
|
||||
This is the default license. It allows you to use, modify, and distribute
|
||||
this software for free, provided that you disclose your source code and
|
||||
license modified versions under the same AGPLv3 terms (including network use).
|
||||
|
||||
2. Commercial License
|
||||
If you wish to use this software in a proprietary setting, without the
|
||||
obligations of the AGPLv3 (such as disclosing your modifications), you
|
||||
must acquire a commercial license.
|
||||
|
||||
Please contact [Taras Greben <taras.greben@gmail.com>] for pricing and terms.
|
||||
|
||||
========================================================================
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2025 Taras Greben
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/agpl-3.0.txt>.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
73
README.md
Normal file
73
README.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# PCB ReTrace
|
||||
|
||||
**Digitize, document, and reverse engineer printed circuit boards entirely in your browser.**
|
||||
|
||||
[**Launch PCB ReTrace**](https://pcb.etaras.com/studio.html)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 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.
|
||||
|
||||
It runs 100% client-side using HTML5, Vanilla JS, and WebAssembly. **No data is ever uploaded to a server.**
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
* **BOM Management:** Map components on board photos to a digital Bill of Materials.
|
||||
* **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`).
|
||||
* **Integrated Tools:**
|
||||
* Resistor Color Code Decoder (3-6 bands)
|
||||
* Inductor Color Code Decoder
|
||||
* Air-core Coil Calculator
|
||||
* **Privacy First:** All data is stored in your browser's IndexedDB.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Online
|
||||
Simply visit the [GitHub Pages deployment](https://dev-lab.github.io/pcb-retrace/).
|
||||
|
||||
### Offline / Local Development
|
||||
Since the project uses no build tools, you can run it directly from the source:
|
||||
|
||||
1. Clone this repository:
|
||||
```bash
|
||||
git clone https://github.com/dev-lab/pcb-retrace.git
|
||||
```
|
||||
2. Navigate to the `docs` folder (where the source code lives):
|
||||
```bash
|
||||
cd pcb-retrace/docs
|
||||
```
|
||||
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`.*
|
||||
|
||||
## 📂 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.
|
||||
|
||||
## ⚖️ License & Commercial Use
|
||||
|
||||
This project is **Dual Licensed**:
|
||||
|
||||
1. **Open Source (AGPLv3):** Ideal for hobbyists, educational use, and open-source projects. You are free to use and modify the software, provided that any modifications are also made open source under the same terms.
|
||||
2. **Commercial License:** For proprietary use, internal corporate deployment without copyleft restrictions, or integration into closed-source workflows, a commercial license is available.
|
||||
|
||||
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)
|
||||
|
||||
See [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) for full text.
|
||||
54
THIRD_PARTY_LICENSES.md
Normal file
54
THIRD_PARTY_LICENSES.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Third-Party Licenses
|
||||
|
||||
This software uses the following open-source packages:
|
||||
|
||||
---
|
||||
|
||||
## OpenCV.js
|
||||
**License:** Apache License 2.0
|
||||
**Copyright:** (C) 2000-2022, Intel Corporation, all rights reserved.
|
||||
**Copyright:** (C) 2009-2011, Willow Garage Inc., all rights reserved.
|
||||
**Copyright:** (C) 2009-2016, NVIDIA Corporation, all rights reserved.
|
||||
**Copyright:** (C) 2010-2013, Advanced Micro Devices, Inc., all rights reserved.
|
||||
**Copyright:** (C) 2015-2023, OpenCV Foundation, all rights reserved.
|
||||
**Copyright:** (C) 2008-2016, Itseez Inc., all rights reserved.
|
||||
**Copyright:** (C) 2019-2023, Xperience AI, all rights reserved.
|
||||
**Copyright:** (C) 2019-2022, Shenzhen Institute of Artificial Intelligence and Robotics for Society, all rights reserved.
|
||||
**Copyright:** (C) 2022-2023, Southern University of Science And Technology, all rights reserved.
|
||||
**Copyright:** (C) 2023-2025, OpenCV AI, all rights reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
---
|
||||
|
||||
## JSZip
|
||||
**License:** MIT License
|
||||
**Copyright:** (c) 2009-2016 Stuart Knightley, David Duponchel, Franz Buchinger, António Afonso
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
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.
|
||||
158
docs/canvas-ui.js
Normal file
158
docs/canvas-ui.js
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/* canvas-ui.js - Shared Canvas Logic */
|
||||
|
||||
class PanZoomCanvas {
|
||||
constructor(id, onDraw, onClick, onDragPt) {
|
||||
this.canvas = document.getElementById(id);
|
||||
this.container = this.canvas ? this.canvas.parentElement : null;
|
||||
this.ctx = this.canvas ? this.canvas.getContext('2d') : null;
|
||||
this.bmp = null;
|
||||
|
||||
this.t = {x:0, y:0, k:1}; // Changed default k to 1, but fit() will override
|
||||
|
||||
this.drag = false;
|
||||
this.lm = {x:0, y:0};
|
||||
this.activePtIdx = -1;
|
||||
this.isMirrored = false;
|
||||
|
||||
this.onDraw = onDraw;
|
||||
this.onClick = onClick;
|
||||
this.onDragPt = onDragPt;
|
||||
this.onMouseMove = null;
|
||||
|
||||
if(this.container) {
|
||||
new ResizeObserver(() => {
|
||||
if(this.container && this.container.clientWidth > 0) {
|
||||
this.canvas.width = this.container.clientWidth;
|
||||
this.canvas.height = this.container.clientHeight;
|
||||
this.draw();
|
||||
}
|
||||
}).observe(this.container);
|
||||
this.initEvents();
|
||||
}
|
||||
}
|
||||
|
||||
initEvents() {
|
||||
this.canvas.onwheel = e => {
|
||||
e.preventDefault();
|
||||
const f = Math.exp(-e.deltaY * 0.001);
|
||||
const r = this.canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - r.left, my = e.clientY - r.top;
|
||||
const wx = (mx - this.t.x) / this.t.k, wy = (my - this.t.y) / this.t.k;
|
||||
this.t.k = Math.max(0.01, Math.min(20, this.t.k * f));
|
||||
this.t.x = mx - wx * this.t.k;
|
||||
this.t.y = my - wy * this.t.k;
|
||||
this.draw();
|
||||
};
|
||||
|
||||
this.canvas.onmousedown = e => {
|
||||
const coords = this.getImgCoords(e.clientX, e.clientY);
|
||||
if (this.onDragPt) {
|
||||
const idx = this.onDragPt(coords.x, coords.y, 'check');
|
||||
if (idx !== -1) {
|
||||
this.activePtIdx = idx;
|
||||
this.drag = true;
|
||||
this.lm = { x: e.clientX, y: e.clientY };
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.drag = true;
|
||||
this.lm = { x: e.clientX, y: e.clientY };
|
||||
this.activePtIdx = -1;
|
||||
};
|
||||
|
||||
this.canvas.oncontextmenu = e => {
|
||||
e.preventDefault();
|
||||
const coords = this.getImgCoords(e.clientX, e.clientY);
|
||||
if (this.onDragPt) this.onDragPt(coords.x, coords.y, 'delete');
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', e => {
|
||||
if (this.drag) {
|
||||
if (this.activePtIdx !== -1) {
|
||||
const r = this.canvas.getBoundingClientRect();
|
||||
const curr = this.getImgCoords(e.clientX, e.clientY);
|
||||
const prev = this.getImgCoords(this.lm.x, this.lm.y);
|
||||
const dx = curr.x - prev.x;
|
||||
const dy = curr.y - prev.y;
|
||||
this.onDragPt(dx, dy, 'move', this.activePtIdx);
|
||||
this.lm = { x: e.clientX, y: e.clientY };
|
||||
} else {
|
||||
this.t.x += e.clientX - this.lm.x;
|
||||
this.t.y += e.clientY - this.lm.y;
|
||||
this.lm = { x: e.clientX, y: e.clientY };
|
||||
this.draw();
|
||||
}
|
||||
}
|
||||
if(this.onMouseMove && e.target === this.canvas) {
|
||||
const coords = this.getImgCoords(e.clientX, e.clientY);
|
||||
this.onMouseMove(coords.x, coords.y);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', e => {
|
||||
if (this.drag && this.activePtIdx === -1 && e.shiftKey && this.onClick) {
|
||||
const coords = this.getImgCoords(e.clientX, e.clientY);
|
||||
this.onClick(coords.x, coords.y);
|
||||
}
|
||||
this.drag = false;
|
||||
this.activePtIdx = -1;
|
||||
});
|
||||
}
|
||||
|
||||
getImgCoords(screenX, screenY) {
|
||||
const r = this.canvas.getBoundingClientRect();
|
||||
const mx = (screenX - r.left - this.t.x) / this.t.k;
|
||||
const my = (screenY - r.top - this.t.y) / this.t.k;
|
||||
if (this.isMirrored && this.bmp) { return { x: this.bmp.width - mx, y: my }; }
|
||||
return { x: mx, y: my };
|
||||
}
|
||||
|
||||
setMirror(val) { this.isMirrored = val; this.draw(); }
|
||||
setImage(b) { this.bmp = b; this.draw(); }
|
||||
|
||||
setDimmed(isDimmed) {
|
||||
if(isDimmed) this.canvas.style.filter = "brightness(0.4) grayscale(100%)";
|
||||
else this.canvas.style.filter = "none";
|
||||
}
|
||||
|
||||
// [NEW] Fit Image to Container
|
||||
fit() {
|
||||
if(!this.bmp || !this.canvas) return;
|
||||
const vw = this.canvas.width;
|
||||
const vh = this.canvas.height;
|
||||
if (vw === 0 || vh === 0) return;
|
||||
|
||||
const iw = this.bmp.width;
|
||||
const ih = this.bmp.height;
|
||||
|
||||
// Scale to fit
|
||||
const scale = Math.min(vw / iw, vh / ih);
|
||||
|
||||
// Center
|
||||
const cx = (vw - iw * scale) / 2;
|
||||
const cy = (vh - ih * scale) / 2;
|
||||
|
||||
this.t = { x: cx, y: cy, k: scale };
|
||||
this.draw();
|
||||
}
|
||||
|
||||
draw() {
|
||||
if(!this.ctx) return;
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.ctx.save();
|
||||
this.ctx.translate(this.t.x, this.t.y);
|
||||
this.ctx.scale(this.t.k, this.t.k);
|
||||
|
||||
if (this.bmp) {
|
||||
this.ctx.save();
|
||||
if (this.isMirrored) {
|
||||
this.ctx.translate(this.bmp.width, 0);
|
||||
this.ctx.scale(-1, 1);
|
||||
}
|
||||
this.ctx.drawImage(this.bmp, 0, 0);
|
||||
this.ctx.restore();
|
||||
}
|
||||
if (this.onDraw) this.onDraw(this.ctx, this.t.k);
|
||||
this.ctx.restore();
|
||||
}
|
||||
}
|
||||
238
docs/coil.html
Normal file
238
docs/coil.html
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
<!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, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Air Core Coil</title>
|
||||
<script src="tool-bridge.js"></script>
|
||||
<script src="spyglass.js"></script>
|
||||
<link rel="stylesheet" href="tool-style.css">
|
||||
<style>
|
||||
:root {
|
||||
/* Copper Theme */
|
||||
--accent: #ea580c;
|
||||
--copper-dark: #9a3412;
|
||||
--bg: #fff7ed;
|
||||
--border: #fed7aa;
|
||||
--focus-ring: #ffedd5;
|
||||
}
|
||||
|
||||
/* Layout overrides */
|
||||
.coil-viz {
|
||||
width: 100%; height: 120px; display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(to bottom, #fff7ed, #fff);
|
||||
border: 1px solid #fed7aa; border-radius: 0.5rem;
|
||||
overflow: hidden; position: relative; flex-shrink: 0; margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.coil-viz svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
svg path {
|
||||
fill: none; stroke: url(#copperGradient); stroke-width: 4; stroke-linecap: round;
|
||||
filter: drop-shadow(0px 2px 2px rgba(0,0,0,0.2));
|
||||
}
|
||||
|
||||
/* Specific Label Color from original file */
|
||||
label { color: #78350f !important; opacity: 1 !important; }
|
||||
|
||||
/* MOBILE OVERRIDES */
|
||||
@media (max-width: 600px) {
|
||||
.result-val { font-size: 1.5rem; }
|
||||
|
||||
/* NEW: Smaller Coil Graphic on Mobile */
|
||||
.coil-viz {
|
||||
height: 80px; /* Reduced from 120px */
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<h1 id="app-title">Air Core Calc</h1>
|
||||
|
||||
<div class="top-section">
|
||||
<div id="spyglass-col" class="col-spyglass">
|
||||
<div class="spyglass-wrapper">
|
||||
<canvas id="tool-canvas" class="spyglass-canvas" width="600" height="600"></canvas>
|
||||
<div id="tool-zoom" class="zoom-badge">2x</div>
|
||||
</div>
|
||||
<div class="spyglass-hint">Scroll to zoom</div>
|
||||
<div id="view-selector"></div>
|
||||
</div>
|
||||
|
||||
<div class="col-controls">
|
||||
<div class="input-grid">
|
||||
<div class="form-group">
|
||||
<label>Turns (N)</label>
|
||||
<div class="input-wrapper">
|
||||
<input type="number" id="inp-turns" value="10" min="1" step="1" oninput="updateLength()">
|
||||
<span class="unit">T</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Diameter (D)</label>
|
||||
<div class="input-wrapper">
|
||||
<input type="number" id="inp-dia" value="6" min="0.1" step="0.1" oninput="calculate()">
|
||||
<span class="unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Wire Ø (d)</label>
|
||||
<div class="input-wrapper">
|
||||
<input type="number" id="inp-wire" value="0.5" min="0.05" step="0.05" oninput="updateLength()">
|
||||
<span class="unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Length (L)</label>
|
||||
<div class="input-wrapper">
|
||||
<input type="number" id="inp-len" value="5" min="0.1" step="0.1" oninput="calculate()" style="background:#f9fafb; color:#666">
|
||||
<span class="unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="coil-viz">
|
||||
<svg width="300" height="100" viewBox="0 0 300 100" id="coil-svg">
|
||||
<defs>
|
||||
<linearGradient id="copperGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#fdba74;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#ea580c;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#7c2d12;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path id="coil-path" d="" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="result-box">
|
||||
<div class="result-row" style="background:#fff7ed; border:1px solid #fed7aa;">
|
||||
<div class="val-group">
|
||||
<span class="val-text" id="res-val" style="color:#9a3412">---</span>
|
||||
<span class="meta-text" id="res-desc" style="color:#c2410c">Adjust parameters above</span>
|
||||
</div>
|
||||
<button id="btn-select" class="btn-select" style="display:none;" onclick="sendResult()">Select</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-footer">Copyright © 2025 Taras Greben — <a href="https://pcb.etaras.com">pcb.eTaras.com</a></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let isEmbed = false;
|
||||
let calculatedValues = { val: '', desc: '' };
|
||||
let bridge = null;
|
||||
|
||||
function init() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has('embed') || window.self !== window.top) {
|
||||
isEmbed = true;
|
||||
document.getElementById('btn-select').style.display = 'block';
|
||||
document.getElementById('app-title').style.display = 'none';
|
||||
}
|
||||
calculate();
|
||||
}
|
||||
|
||||
function parseDescription(desc) {
|
||||
try {
|
||||
const regex = /([\d\.]+)T,\s*([\d\.]+)mm ID,\s*([\d\.]+)mm Cu/;
|
||||
const match = desc.match(regex);
|
||||
if (match) {
|
||||
document.getElementById('inp-turns').value = match[1];
|
||||
document.getElementById('inp-dia').value = match[2];
|
||||
document.getElementById('inp-wire').value = match[3];
|
||||
updateLength();
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function updateLength() {
|
||||
const N = parseFloat(document.getElementById('inp-turns').value) || 0;
|
||||
const d_wire = parseFloat(document.getElementById('inp-wire').value) || 0;
|
||||
const len = (N * d_wire * 1.05).toFixed(2);
|
||||
document.getElementById('inp-len').value = len;
|
||||
calculate();
|
||||
}
|
||||
|
||||
function calculate() {
|
||||
const N = parseFloat(document.getElementById('inp-turns').value);
|
||||
const D_mm = parseFloat(document.getElementById('inp-dia').value);
|
||||
const l_mm = parseFloat(document.getElementById('inp-len').value);
|
||||
const w_mm = parseFloat(document.getElementById('inp-wire').value);
|
||||
|
||||
if (!N || !D_mm || !l_mm) return;
|
||||
|
||||
const d_in = D_mm / 25.4;
|
||||
const l_in = l_mm / 25.4;
|
||||
const num = Math.pow(d_in, 2) * Math.pow(N, 2);
|
||||
const den = (18 * d_in) + (40 * l_in);
|
||||
let uH = num / den;
|
||||
|
||||
let displayVal = "", cleanVal = "";
|
||||
if (uH < 1) {
|
||||
let nH = uH * 1000;
|
||||
displayVal = nH.toFixed(0) + " nH";
|
||||
cleanVal = nH.toFixed(0) + "nH";
|
||||
} else {
|
||||
displayVal = uH.toFixed(2) + " µH";
|
||||
cleanVal = uH.toFixed(2) + "uH";
|
||||
}
|
||||
|
||||
const desc = `Air Core: ${N}T, ${D_mm}mm ID, ${w_mm}mm Cu`;
|
||||
|
||||
document.getElementById('res-val').innerText = displayVal;
|
||||
document.getElementById('res-desc').innerText = desc;
|
||||
calculatedValues = { val: cleanVal, desc: desc };
|
||||
drawCoil(N);
|
||||
}
|
||||
|
||||
function drawCoil(turns) {
|
||||
const path = document.getElementById('coil-path');
|
||||
const width = 300;
|
||||
const height = 100;
|
||||
const padding = 20;
|
||||
|
||||
const visualTurns = Math.min(Math.max(turns, 1), 20);
|
||||
const pitch = (width - padding*2) / visualTurns;
|
||||
|
||||
const radius = 24;
|
||||
const midY = 60;
|
||||
|
||||
let d = `M ${padding} ${midY + radius}`;
|
||||
for (let i = 0; i < visualTurns; i++) {
|
||||
const x = padding + (i * pitch);
|
||||
// The magic number 2.5 defines the 'height' of the back loop perspective
|
||||
d += ` C ${x + pitch*0.2} ${midY - radius*2.5}, ${x + pitch*0.8} ${midY - radius*2.5}, ${x + pitch} ${midY + radius}`;
|
||||
}
|
||||
path.setAttribute('d', d);
|
||||
}
|
||||
|
||||
function sendResult() {
|
||||
if(bridge) bridge.sendResult();
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
bridge = new ToolBridge({
|
||||
onData: (d) => {
|
||||
if(d.description) parseDescription(d.description);
|
||||
calculate();
|
||||
},
|
||||
onNominal: () => calculatedValues.val,
|
||||
onDescription: () => calculatedValues.desc
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
43
docs/common.css
Normal file
43
docs/common.css
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
:root {
|
||||
--border: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--bg-default: #f1f5f9;
|
||||
--text-default: #1e293b;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
/* MOBILE VIEWPORT FIX */
|
||||
height: 100vh; /* Fallback */
|
||||
height: 100dvh; /* Dynamic height handles mobile toolbars */
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden; /* Prevent body scroll, let containers scroll */
|
||||
}
|
||||
|
||||
/* COMPACT FOOTER */
|
||||
.app-footer {
|
||||
padding: 0.2rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.9); /* Slight transparency */
|
||||
flex-shrink: 0;
|
||||
z-index: 20;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-footer a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--border);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
100% { -webkit-transform: rotate(360deg); transform:rotate(360deg); }
|
||||
}
|
||||
172
docs/cv-core.js
Normal file
172
docs/cv-core.js
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/* cv-core.js - Computer Vision Logic for PCB ReTrace Suite */
|
||||
class CVManager {
|
||||
constructor() {
|
||||
this.ready = false;
|
||||
this.initAttempts = 0;
|
||||
this.detector = null;
|
||||
this.matcher = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.ready) return;
|
||||
if (typeof cv !== 'undefined' && cv.Mat) {
|
||||
try {
|
||||
// Tuned Settings: ORB 5000, Hamming
|
||||
this.detector = new cv.ORB(5000);
|
||||
this.matcher = new cv.BFMatcher(cv.NORM_HAMMING, false);
|
||||
this.ready = true;
|
||||
console.log("CV Core Initialized");
|
||||
} catch (e) {
|
||||
console.error("CV Init Error:", e);
|
||||
}
|
||||
} else {
|
||||
this.initAttempts++;
|
||||
if (this.initAttempts < 50) setTimeout(() => this.init(), 500);
|
||||
else console.error("CV Failed to load OpenCV.js");
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Project (x,y) using Homography Matrix
|
||||
projectPoint(x, y, h) {
|
||||
if (!h || h.length < 9) return null;
|
||||
const Z = h[6] * x + h[7] * y + h[8];
|
||||
if (Math.abs(Z) < 0.0001) return null;
|
||||
return {
|
||||
x: (h[0] * x + h[1] * y + h[2]) / Z,
|
||||
y: (h[3] * x + h[4] * y + h[5]) / Z
|
||||
};
|
||||
}
|
||||
|
||||
// Helper: Resize for processing (Speed optimization)
|
||||
async createSmallMat(blob) {
|
||||
const maxWidth = 2000; // Tuned for optimal accuracy/speed
|
||||
const bmp = await createImageBitmap(blob);
|
||||
const canvas = document.createElement('canvas');
|
||||
const scale = Math.min(1, maxWidth / bmp.width);
|
||||
|
||||
canvas.width = bmp.width * scale;
|
||||
canvas.height = bmp.height * scale;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(bmp, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const mat = cv.imread(canvas);
|
||||
const gray = new cv.Mat();
|
||||
cv.cvtColor(mat, gray, cv.COLOR_RGBA2GRAY);
|
||||
mat.delete();
|
||||
|
||||
// Cleanup canvas to free memory
|
||||
canvas.width = 0; canvas.height = 0;
|
||||
|
||||
return { mat: gray, scale: scale, width: bmp.width, height: bmp.height };
|
||||
}
|
||||
|
||||
// Solve Manual Stitch (Least Squares)
|
||||
solveManual(pairs) {
|
||||
if (pairs.length < 4) return null;
|
||||
const srcMat = new cv.Mat(pairs.length, 1, cv.CV_32FC2);
|
||||
const dstMat = new cv.Mat(pairs.length, 1, cv.CV_32FC2);
|
||||
|
||||
for (let i = 0; i < pairs.length; i++) {
|
||||
srcMat.data32F[i * 2] = pairs[i].s.x;
|
||||
srcMat.data32F[i * 2 + 1] = pairs[i].s.y;
|
||||
dstMat.data32F[i * 2] = pairs[i].d.x;
|
||||
dstMat.data32F[i * 2 + 1] = pairs[i].d.y;
|
||||
}
|
||||
|
||||
// Method 0 = Least Squares (Best for manual precise points)
|
||||
const H = cv.findHomography(srcMat, dstMat, 0);
|
||||
|
||||
if (H.empty()) {
|
||||
srcMat.delete(); dstMat.delete(); H.delete();
|
||||
return null;
|
||||
}
|
||||
|
||||
const hd = [];
|
||||
const Hi = H.inv(cv.DECOMP_LU);
|
||||
const ihd = [];
|
||||
|
||||
for (let i = 0; i < 9; i++) hd.push(H.data64F[i]);
|
||||
for (let i = 0; i < 9; i++) ihd.push(Hi.data64F[i]);
|
||||
|
||||
srcMat.delete(); dstMat.delete(); H.delete(); Hi.delete();
|
||||
return { hData: hd, invHData: ihd };
|
||||
}
|
||||
|
||||
// Extract Features
|
||||
async feats(blob) {
|
||||
if (!this.ready) return null;
|
||||
const { mat, scale, width, height } = await this.createSmallMat(blob);
|
||||
const kp = new cv.KeyPointVector();
|
||||
const des = new cv.Mat();
|
||||
this.detector.detectAndCompute(mat, new cv.Mat(), kp, des);
|
||||
mat.delete();
|
||||
return { kp, des, scale, w: width, h: height };
|
||||
}
|
||||
|
||||
// Chain two homographies: H_total = H2 * H1
|
||||
multiplyH(H2, H1) {
|
||||
// H1 and H2 are flat arrays [0..8] representing 3x3 matrices
|
||||
// Standard Matrix Multiplication
|
||||
const res = new Array(9);
|
||||
for (let r = 0; r < 3; r++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
let sum = 0;
|
||||
for (let k = 0; k < 3; k++) {
|
||||
sum += H2[r*3 + k] * H1[k*3 + c];
|
||||
}
|
||||
res[r*3 + c] = sum;
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// Find Homography between two feature sets
|
||||
findH(s, d) {
|
||||
if (s.des.rows === 0 || d.des.rows === 0) return null;
|
||||
|
||||
const mv = new cv.DMatchVectorVector();
|
||||
this.matcher.knnMatch(s.des, d.des, mv, 2);
|
||||
|
||||
const good = [];
|
||||
for (let i = 0; i < mv.size(); i++) {
|
||||
const m = mv.get(i).get(0), n = mv.get(i).get(1);
|
||||
if (m.distance < 0.70 * n.distance) good.push(m); // Ratio Test 0.70
|
||||
}
|
||||
mv.delete();
|
||||
|
||||
if (good.length > 8) {
|
||||
const sp = new cv.Mat(good.length, 1, cv.CV_32FC2);
|
||||
const dp = new cv.Mat(good.length, 1, cv.CV_32FC2);
|
||||
|
||||
for (let i = 0; i < good.length; i++) {
|
||||
const k1 = s.kp.get(good[i].queryIdx);
|
||||
const k2 = d.kp.get(good[i].trainIdx);
|
||||
sp.data32F[i * 2] = k1.pt.x / s.scale;
|
||||
sp.data32F[i * 2 + 1] = k1.pt.y / s.scale;
|
||||
dp.data32F[i * 2] = k2.pt.x / d.scale;
|
||||
dp.data32F[i * 2 + 1] = k2.pt.y / d.scale;
|
||||
}
|
||||
|
||||
const mask = new cv.Mat();
|
||||
// RANSAC 8.0 (Tuned for 2000px resolution)
|
||||
const H = cv.findHomography(sp, dp, cv.RANSAC, 8.0, mask);
|
||||
|
||||
if (H.empty()) {
|
||||
sp.delete(); dp.delete(); mask.delete(); H.delete();
|
||||
return null;
|
||||
}
|
||||
|
||||
const hd = [];
|
||||
const Hi = H.inv(cv.DECOMP_LU);
|
||||
const ihd = [];
|
||||
|
||||
for (let i = 0; i < 9; i++) hd.push(H.data64F[i]);
|
||||
for (let i = 0; i < 9; i++) ihd.push(Hi.data64F[i]);
|
||||
|
||||
sp.delete(); dp.delete(); mask.delete(); H.delete(); Hi.delete();
|
||||
return { hData: hd, invHData: ihd, matches: good.length };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
143
docs/guide.html
Normal file
143
docs/guide.html
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>User Guide - PCB ReTrace</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
<style>
|
||||
/* Override common.css app locks for document flow */
|
||||
html, body {
|
||||
height: auto !important;
|
||||
overflow-y: auto !important;
|
||||
display: block !important;
|
||||
margin: 0; padding: 0;
|
||||
}
|
||||
|
||||
body { background: #f8fafc; color: #334155; line-height: 1.6; }
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
background: white;
|
||||
min-height: 100vh;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.05);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1 { color: #0f172a; margin-bottom: 0.5rem; border-bottom: 1px solid #e2e8f0; padding-bottom: 1rem; }
|
||||
h2 { color: #1e293b; margin-top: 2.5rem; margin-bottom: 1rem; font-size: 1.5rem; }
|
||||
h3 { color: #2563eb; margin-top: 1.5rem; font-size: 1.1rem; }
|
||||
|
||||
.badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.75rem; font-weight: bold; background: #e2e8f0; color: #475569; }
|
||||
.badge.pro { background: #dcfce7; color: #166534; }
|
||||
|
||||
ul, ol { padding-left: 1.5rem; margin-bottom: 1rem; }
|
||||
li { margin-bottom: 0.5rem; }
|
||||
|
||||
.tip-box { background: #eff6ff; border-left: 4px solid #2563eb; padding: 1rem; margin: 1.5rem 0; border-radius: 0 4px 4px 0; font-size: 0.95rem; }
|
||||
.warn-box { background: #fff1f2; border-left: 4px solid #f43f5e; padding: 1rem; margin: 1.5rem 0; border-radius: 0 4px 4px 0; font-size: 0.95rem; }
|
||||
|
||||
code { background: #f1f5f9; padding: 0.2rem 0.4rem; border-radius: 3px; font-family: monospace; font-size: 0.9em; color: #0f172a; border: 1px solid #e2e8f0; }
|
||||
|
||||
.nav-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; padding-bottom: 0.5rem; border-bottom: 1px solid #e2e8f0; }
|
||||
.back-link { text-decoration: none; color: #64748b; font-weight: 600; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.back-link:hover { color: #2563eb; }
|
||||
|
||||
footer { margin-top: 4rem; padding-top: 2rem; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 0.8rem; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<nav class="nav-header">
|
||||
<a href="index.html" class="back-link">← Back to Home</a>
|
||||
<span style="color:#94a3b8; font-size:0.9rem;">Documentation v5.3</span>
|
||||
</nav>
|
||||
|
||||
<h1>PCB ReTrace Guide</h1>
|
||||
<p class="lead">A local-first browser tool for reverse engineering, documenting, and inspecting printed circuit boards.</p>
|
||||
|
||||
<div class="tip-box">
|
||||
<strong>Privacy Note:</strong> This application runs 100% in your browser. All images and data are stored in your browser's local database (IndexedDB). No data is uploaded to any server.
|
||||
</div>
|
||||
|
||||
<h2>1. Concept & Hierarchy</h2>
|
||||
<p>ReTrace organizes data to match physical hardware:</p>
|
||||
<ol>
|
||||
<li><strong>Device:</strong> The physical unit (e.g., "Multimeter Model X").</li>
|
||||
<li><strong>Board:</strong> A specific PCB inside the device (e.g., "Mainboard", "Power Supply").</li>
|
||||
<li><strong>Images:</strong> Photos of the board (Top, Bottom, Angled shots).</li>
|
||||
</ol>
|
||||
|
||||
<h2>2. The Workflow</h2>
|
||||
|
||||
<h3>Step 1: Setup</h3>
|
||||
<p>Create a <strong>New Device</strong> and a <strong>New Board</strong> using the top menu. Then, upload your images. High-resolution, flat photos work best.</p>
|
||||
|
||||
<h3>Step 2: Mapping Components (BOM)</h3>
|
||||
<p>Navigate to the <strong>Images</strong> tab. Click on a component in the photo to mark it.</p>
|
||||
<ul>
|
||||
<li>The app switches to the <strong>BOM</strong> list.</li>
|
||||
<li>Enter the Reference (e.g., <code>R1</code>).</li>
|
||||
<li>If identifying the value requires calculation, click the tool icon to open the built-in decoders (Resistor Bands, Inductors).</li>
|
||||
</ul>
|
||||
|
||||
<h3>Step 3: Tracing Nets</h3>
|
||||
<p>Use the <strong>Inspect</strong> and <strong>Nets</strong> tabs to document connectivity.</p>
|
||||
<ul>
|
||||
<li><strong>New Net:</strong> In the Inspect tab, click anywhere on the image to create a node. You will be prompted to name the Pin/Pad. If no net is currently active, entering a name will <strong>automatically start a new net</strong>.</li>
|
||||
<li><strong>Add Nodes:</strong> Click on other pins that belong to the same electrical connection.</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>
|
||||
</ul>
|
||||
|
||||
<h2>3. Computer Vision & Stitching</h2>
|
||||
<p>ReTrace uses Computer Vision (CV) for <strong>Spatial Alignment</strong>. It allows you to synchronize the view across different images.</p>
|
||||
|
||||
<div class="warn-box">
|
||||
<strong>Important:</strong> The CV engine does <em>not</em> automatically recognize components (it won't read "10k" off a resistor). Its purpose is to mathematically align coordinates between images so you can see through the board.
|
||||
</div>
|
||||
|
||||
<h3>Auto-Stitch (Same Side)</h3>
|
||||
<p>When you upload a new image, ReTrace <strong>automatically attempts to stitch</strong> it against existing images on the board in the background. If features match (e.g., a close-up vs. a wide shot), connections are created automatically.</p>
|
||||
<p>You can also run this manually:</p>
|
||||
<ol>
|
||||
<li>Open <strong>Image Settings</strong> on your Main image.</li>
|
||||
<li>Select <strong>Stitch / Connections</strong> -> <strong>Run Auto-CV</strong>.</li>
|
||||
</ol>
|
||||
|
||||
<h3>Manual Stitch (Top vs. Bottom)</h3>
|
||||
<p>To create an "X-Ray" view where clicking a pin on the Top automatically shows the corresponding pin on the Bottom:</p>
|
||||
<ol>
|
||||
<li>Select your <strong>Top Image</strong>.</li>
|
||||
<li>Go to <strong>Image Settings</strong> -> <strong>Connections</strong>.</li>
|
||||
<li>Find your <strong>Bottom Image</strong> and click <strong>"Edit Stitch"</strong>.</li>
|
||||
<li><strong>Flip Target:</strong> Click the <button style="padding:0 4px; font-size:0.8em;">↔ Flip Target</button> button. This mirrors the bottom image horizontally.</li>
|
||||
<li><strong>Match Points:</strong> Click 4 common features (Vias, Holes, Corners) visible on both images.</li>
|
||||
<li>Click <strong>Save Match</strong>.</li>
|
||||
</ol>
|
||||
|
||||
<h2>4. Tools</h2>
|
||||
<p>ReTrace includes standalone calculators accessible from the BOM editor:</p>
|
||||
<ul>
|
||||
<li><strong>Resistor:</strong> 3-6 Band decoder with reverse lookup.</li>
|
||||
<li><strong>Inductor:</strong> Standard and Military codes.</li>
|
||||
<li><strong>Coil:</strong> Calculator for hand-wound air-core inductors.</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Data Management</h2>
|
||||
<ul>
|
||||
<li><strong>Device Export (ZIP):</strong> Full backup including all boards and images.</li>
|
||||
<li><strong>Board Export (ZIP):</strong> Portable backup of a single board.</li>
|
||||
<li><strong>CSV Export:</strong> Standard spreadsheet export for BOM ordering.</li>
|
||||
<li><strong>KiCad Export (.net):</strong> Exports identified components and traced nets for use in PCB design software.</li>
|
||||
</ul>
|
||||
|
||||
<footer>
|
||||
Copyright © 2025 Taras Greben.
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
153
docs/index.html
Normal file
153
docs/index.html
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<!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>PCB ReTrace</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
<style>
|
||||
/* OVERRIDE COMMON.CSS APP LOCKS */
|
||||
/* This ensures the landing page scrolls normally and footer is visible */
|
||||
html, body {
|
||||
height: auto !important;
|
||||
overflow-y: auto !important; /* Force scrollbar */
|
||||
display: block !important; /* Disable app flex layout */
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: white;
|
||||
color: var(--text-default);
|
||||
}
|
||||
|
||||
:root { --hero-bg: #f8fafc; --text-main: #0f172a; --text-sub: #475569; --primary: #2563eb; }
|
||||
|
||||
/* HERO SECTION */
|
||||
.hero {
|
||||
padding: 4rem 1rem 3rem;
|
||||
text-align: center;
|
||||
background: radial-gradient(circle at 50% 50%, #f1f5f9 0%, #fff 70%);
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.hero h1 { font-size: 2.5rem; margin-bottom: 1rem; letter-spacing: -0.05em; color: var(--text-main); }
|
||||
.hero p { font-size: 1.2rem; color: var(--text-sub); max-width: 600px; margin: 0 auto 2rem; }
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background: var(--primary); color: white;
|
||||
padding: 0.8rem 2rem; border-radius: 2rem;
|
||||
font-weight: 700; text-decoration: none; font-size: 1.1rem;
|
||||
box-shadow: 0 4px 6px rgba(37, 99, 235, 0.2);
|
||||
transition: transform 0.1s, box-shadow 0.1s;
|
||||
}
|
||||
.cta-button:hover { transform: translateY(-2px); box-shadow: 0 6px 12px rgba(37, 99, 235, 0.3); }
|
||||
.cta-button:active { transform: translateY(0); }
|
||||
|
||||
.secondary-links { margin-top: 1.5rem; display: flex; gap: 1rem; justify-content: center; font-size: 0.9rem; }
|
||||
.secondary-links a { color: var(--text-sub); text-decoration: none; border-bottom: 1px dotted #ccc; }
|
||||
|
||||
/* FEATURES GRID */
|
||||
.features {
|
||||
max-width: 1000px; margin: 3rem auto; padding: 0 1rem;
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 2rem;
|
||||
}
|
||||
.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-card h3 { margin: 0 0 0.5rem; color: var(--text-main); }
|
||||
.feature-card p { color: var(--text-sub); font-size: 0.95rem; margin: 0; }
|
||||
|
||||
/* TOOLS LIST */
|
||||
.tools-section { background: #f8fafc; padding: 3rem 1rem; border-top: 1px solid #e2e8f0; }
|
||||
.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-link {
|
||||
background: white; border: 1px solid #e2e8f0; border-radius: 0.5rem;
|
||||
padding: 1rem; text-align: center; text-decoration: none; color: inherit;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.tool-link:hover { transform: translateY(-3px); border-color: var(--primary); }
|
||||
.tool-link strong { display: block; color: var(--primary); margin-bottom: 0.2rem; }
|
||||
.tool-link span { font-size: 0.8rem; color: #64748b; }
|
||||
|
||||
footer.landing-footer {
|
||||
margin-top: 0;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
background: white;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
footer.landing-footer a { color: inherit; text-decoration: underline; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.hero { padding: 3rem 1rem; }
|
||||
.hero h1 { font-size: 2rem; }
|
||||
.tool-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<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>
|
||||
|
||||
<a href="studio.html" class="cta-button">Launch Studio</a>
|
||||
|
||||
<div class="secondary-links">
|
||||
<a href="guide.html">📖 User Guide & Documentation</a>
|
||||
<a href="https://github.com/dev-lab/pcb-retrace">GitHub Repo</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🔒</span>
|
||||
<h3>Privacy First</h3>
|
||||
<p>Zero cloud upload. All images and data are stored in your browser's local database. Works 100% offline.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🕸️</span>
|
||||
<h3>Netlist Tracing</h3>
|
||||
<p>Click-to-trace connectivity across your board. Define nets, label nodes, and export directly to <strong>KiCad</strong>.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">👁️</span>
|
||||
<h3>Auto-Stitch & Inspect</h3>
|
||||
<p>Align close-up shots with wide-angle views using Computer Vision. Stitch Top vs. Bottom layers for X-Ray inspection.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tools-section">
|
||||
<div class="tools-container">
|
||||
<div class="tools-title">Included Utilities</div>
|
||||
<div class="tool-grid">
|
||||
<a href="resistor.html" class="tool-link">
|
||||
<strong>Resistor Decoder</strong>
|
||||
<span>3-6 Bands & Reverse Lookup</span>
|
||||
</a>
|
||||
<a href="inductor.html" class="tool-link">
|
||||
<strong>Inductor Decoder</strong>
|
||||
<span>Standard & Military Codes</span>
|
||||
</a>
|
||||
<a href="coil.html" class="tool-link">
|
||||
<strong>Coil Calculator</strong>
|
||||
<span>Air-core Inductance</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
141
docs/inductor.html
Normal file
141
docs/inductor.html
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<!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, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Inductor Decoder</title>
|
||||
<script src="tool-bridge.js"></script>
|
||||
<script src="spyglass.js"></script>
|
||||
<link rel="stylesheet" href="tool-style.css">
|
||||
<style>
|
||||
:root { --accent: #16a34a; --resistor-body: #86efac; --resistor-shadow: #4ade80; --bg: #f0fdf4; }
|
||||
.controls-grid { grid-template-columns: 1fr 1fr 1fr; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<h1 id="app-title">Inductor Decoder</h1>
|
||||
<div class="top-section">
|
||||
<div id="spyglass-col" class="col-spyglass">
|
||||
<div class="spyglass-wrapper"><canvas id="tool-canvas" class="spyglass-canvas" width="600" height="600"></canvas><div id="tool-zoom" class="zoom-badge">2x</div></div>
|
||||
<div id="view-selector"></div>
|
||||
</div>
|
||||
<div class="col-controls">
|
||||
<div class="reverse-input-group"><div class="input-row"><input type="text" id="nominal-input" class="inp-nominal" placeholder="e.g. 100n, 4u7"><button class="btn-action" onclick="applyNominal()">Set</button></div></div>
|
||||
<div class="controls-grid">
|
||||
<button class="btn-mode active" onclick="setBands(3)">3 Band</button>
|
||||
<button class="btn-mode" onclick="setBands(4)">4 Band</button>
|
||||
<button class="btn-mode" onclick="setBands(5)">5 Band (Mil)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resistor-wrapper-outer"><div class="resistor-dumbbell-shape"><div class="bands-container" id="picker-area"></div></div></div>
|
||||
<div id="results" class="result-box"></div>
|
||||
<div class="app-footer">Copyright © 2025 Taras Greben — <a href="https://pcb.etaras.com">pcb.eTaras.com</a></div>
|
||||
</div>
|
||||
<div id="global-dropdown"></div>
|
||||
|
||||
<script>
|
||||
const colors = [
|
||||
{ name: 'Black', val: 0, mult: 1, tol: 20, css: 'c-bk' },
|
||||
{ name: 'Brown', val: 1, mult: 10, tol: 1, css: 'c-bn' },
|
||||
{ name: 'Red', val: 2, mult: 100, tol: 2, css: 'c-rd' },
|
||||
{ name: 'Orange', val: 3, mult: 1000, tol: 3, css: 'c-og' },
|
||||
{ name: 'Yellow', val: 4, mult: 10000, tol: 4, css: 'c-ye' },
|
||||
{ name: 'Green', val: 5, mult: null, tol: null, css: 'c-gn' },
|
||||
{ name: 'Blue', val: 6, mult: null, tol: null, css: 'c-bu' },
|
||||
{ name: 'Violet', val: 7, mult: null, tol: null, css: 'c-vt' },
|
||||
{ name: 'Gray', val: 8, mult: null, tol: null, css: 'c-gy' },
|
||||
{ name: 'White', val: 9, mult: null, tol: null, css: 'c-wh' },
|
||||
{ name: 'Gold', val: null, mult: 0.1, tol: 5, css: 'c-gd' },
|
||||
{ name: 'Silver', val: null, mult: 0.01, tol: 10, css: 'c-sv' }
|
||||
];
|
||||
let currentBands=3; let bandState=[1,0,0]; let activeBandIndex=-1; let isEmbed=false; let bridge=null;
|
||||
const globalDropdown = document.getElementById('global-dropdown');
|
||||
|
||||
function init() {
|
||||
initGlobalDropdown();
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has('embed') || window.self !== window.top) { isEmbed = true; document.getElementById('app-title').style.display = 'none'; }
|
||||
setBands(3);
|
||||
}
|
||||
|
||||
function initGlobalDropdown(){ globalDropdown.innerHTML=''; colors.forEach((c,i)=>{ const d=document.createElement('div'); d.className=`custom-option ${c.css}`; d.innerText=c.name; d.onclick=e=>{e.stopPropagation();if(activeBandIndex>-1){bandState[activeBandIndex]=i;renderPickers();calculate();closeDropdown();}}; globalDropdown.appendChild(d); }); }
|
||||
|
||||
function openGlobalDropdown(triggerElement) {
|
||||
globalDropdown.style.display = 'flex'; globalDropdown.style.top = '0px'; globalDropdown.style.left = '0px';
|
||||
globalDropdown.classList.remove('condensed', 'super-condensed');
|
||||
globalDropdown.style.height = 'auto'; globalDropdown.style.overflowY = 'hidden';
|
||||
const triggerRect = triggerElement.getBoundingClientRect();
|
||||
const menuRect = globalDropdown.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
let top = triggerRect.bottom + 5;
|
||||
let left = triggerRect.left + (triggerRect.width / 2);
|
||||
let projectedBottom = top + menuRect.height;
|
||||
if (projectedBottom > viewportHeight - 10) { globalDropdown.classList.add('condensed'); projectedBottom = top + globalDropdown.getBoundingClientRect().height; }
|
||||
if (projectedBottom > viewportHeight - 10) { globalDropdown.classList.add('super-condensed'); projectedBottom = top + globalDropdown.getBoundingClientRect().height; }
|
||||
if (projectedBottom > viewportHeight - 10) { const height = globalDropdown.getBoundingClientRect().height; top = viewportHeight - height - 10; if (top < 10) { top = 10; globalDropdown.style.height = (viewportHeight - 20) + 'px'; globalDropdown.style.overflowY = 'auto'; } }
|
||||
globalDropdown.style.top = top + 'px'; globalDropdown.style.left = left + 'px'; globalDropdown.style.transform = 'translateX(-50%)';
|
||||
}
|
||||
|
||||
window.onclick=closeDropdown; function closeDropdown(){globalDropdown.style.display='none';activeBandIndex=-1;}
|
||||
function setBands(n){ currentBands=n; document.querySelectorAll('.btn-mode').forEach((b,i)=>b.classList.toggle('active',(i+3)===n)); if(n===3)bandState=[1,0,0];else if(n===4)bandState=[1,0,0,11];else bandState=[11,1,0,0,10]; renderPickers(); calculate(); }
|
||||
function renderPickers(){ const a=document.getElementById('picker-area'); a.innerHTML=''; for(let i=0;i<currentBands;i++){ const w=document.createElement('div'); w.className='custom-select-wrapper'; const t=document.createElement('div'); t.className=`custom-select__trigger ${colors[bandState[i]].css}`; t.innerHTML=`<span>${colors[bandState[i]].name}</span>`; t.onclick=e=>{e.stopPropagation(); if(activeBandIndex===i&&globalDropdown.style.display==='flex')closeDropdown(); else{activeBandIndex=i;openGlobalDropdown(t);}}; w.appendChild(t); a.appendChild(w); } }
|
||||
|
||||
function parseInputStr(str) { if(!str)return null; str=str.toLowerCase().trim(); let m=1; if(str.includes('m')){m=1e3;str=str.replace('m','.');}else if(str.includes('u')){m=1;str=str.replace('u','.');}else if(str.includes('n')){m=1e-3;str=str.replace('n','.');} const v=parseFloat(str); return isNaN(v)?null:v*m; }
|
||||
function applyNominal() { const raw=document.getElementById('nominal-input').value; const uH=parseInputStr(raw); if(uH===null)return; let val=uH; let exp=0; if(val<10){let s=(Math.round(val*1000)/1000).toString(); if(s.includes('.')){const sh=s.split('.')[1].length;val=Math.round(val*Math.pow(10,sh));exp=-sh;}} let s=val.toString(); const d1=parseInt(s[0]); const d2=parseInt(s.length>1?s[1]:0); const te=s.length-2+exp; let mi=-1; if(te>=0&&te<=4)mi=te;else if(te===-1)mi=10;else if(te===-2)mi=11; if(mi!==-1){ if(currentBands===3)bandState=[d1,d2,mi];else if(currentBands===4)bandState=[d1,d2,mi,11];else bandState=[11,d1,d2,mi,10]; renderPickers(); calculate(); } }
|
||||
|
||||
function decode(idxs) {
|
||||
if (currentBands !== 5 && colors[idxs[0]].name === 'Silver') return { valid: false };
|
||||
let v=0, m=0, t=0;
|
||||
if (currentBands === 3) {
|
||||
v = colors[idxs[0]].val * 10 + colors[idxs[1]].val; m = colors[idxs[2]].mult; t = 20;
|
||||
} else if (currentBands === 4) {
|
||||
v = colors[idxs[0]].val * 10 + colors[idxs[1]].val; m = colors[idxs[2]].mult; t = colors[idxs[3]].tol;
|
||||
} else {
|
||||
if (colors[idxs[0]].name !== 'Silver') return { valid: false };
|
||||
v = colors[idxs[1]].val * 10 + colors[idxs[2]].val; m = colors[idxs[3]].mult; t = colors[idxs[4]].tol;
|
||||
}
|
||||
if (isNaN(v) || m === null || t === null) return { valid: false };
|
||||
return { uH: v * m, tol: t, valid: true };
|
||||
}
|
||||
|
||||
function format(uH){ if(uH>=1000)return(uH/1000).toFixed(2)+'mH'; if(uH<1)return(uH*1000).toFixed(0)+'nH'; return uH.toFixed(2)+'uH'; }
|
||||
|
||||
function calculate(){
|
||||
const f=decode(bandState.slice(0,currentBands)); const b=decode([...bandState.slice(0,currentBands)].reverse());
|
||||
|
||||
const arrowSvg = (isF) => isF ? `<svg viewBox="0 0 24 24"><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"></path></svg>` : `<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"></path></svg>`;
|
||||
|
||||
const h=(d,fwd)=>{
|
||||
const btn = isEmbed ? `<button class="btn-select" onclick="bridge.sendResult('${format(d.uH)}')">Select</button>` : '';
|
||||
const badge = fwd ? `<span class="badge-success" style="margin-left:0.5rem; padding:0.1rem 0.5rem; font-size:0.7rem; vertical-align:middle;">Likely</span>` : '';
|
||||
|
||||
return `<div class="result-row"><div class="${fwd?'direction-icon':'direction-icon reverse'}">${arrowSvg(fwd)}</div><div class="val-group"><span class="val-text">${format(d.uH)}</span><span class="meta-text">±${d.tol}%${badge}</span></div>${btn}</div>`;
|
||||
};
|
||||
|
||||
let res=''; if(f.valid)res+=h(f,true); document.getElementById('results').innerHTML=res||'<div style="text-align:center;color:red;padding:1rem">Invalid</div>';
|
||||
}
|
||||
|
||||
function parseInductorDescription(desc) {
|
||||
let n=3, t=-1; if(desc.includes("4-Band"))n=4; else if(desc.includes("5-Band"))n=5;
|
||||
const tm=desc.match(/±\s*([\d.]+)%/); if(tm){const v=parseFloat(tm[1]);const c=colors.findIndex(x=>x.tol===v);if(c!==-1)t=c;}
|
||||
setBands(n); if(t!==-1){ if(n===4)bandState[3]=t; if(n===5)bandState[4]=t; }
|
||||
}
|
||||
|
||||
init();
|
||||
bridge = new ToolBridge({
|
||||
onData: (d) => {
|
||||
if(d.value) document.getElementById('nominal-input').value = d.value;
|
||||
if(d.description) parseInductorDescription(d.description);
|
||||
applyNominal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
539
docs/inspector.js
Normal file
539
docs/inspector.js
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
/* inspector.js - Visual Trace Tracking (v6) */
|
||||
|
||||
class Inspector {
|
||||
constructor(db, cv) {
|
||||
this.db = db;
|
||||
this.cv = cv;
|
||||
this.grid = document.getElementById('inspect-grid');
|
||||
this.sidebarList = document.getElementById('inspect-layers');
|
||||
this.activeNetEl = document.getElementById('inspect-active-net');
|
||||
|
||||
this.viewers = {};
|
||||
this.visibleIds = new Set();
|
||||
this.activeNet = null;
|
||||
this.masterId = null;
|
||||
this.netNodeCache = {}; // Cache for calculated node positions
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.sidebarList.innerHTML = '';
|
||||
|
||||
// Hide the "+ New Net" button for now
|
||||
// We find it by the onclick attribute since it doesn't have an ID in the HTML
|
||||
const newNetBtn = document.querySelector('button[onclick="inspector.startNewNet()"]');
|
||||
if(newNetBtn) newNetBtn.style.display = 'none';
|
||||
|
||||
const imgs = [...bomImages].sort((a,b) => {
|
||||
const nA = a.name.toLowerCase(), nB = b.name.toLowerCase();
|
||||
if(nA.includes('top')) return -1;
|
||||
if(nB.includes('top')) return 1;
|
||||
return nA.localeCompare(nB);
|
||||
});
|
||||
|
||||
// Checkbox Layout
|
||||
imgs.forEach(img => {
|
||||
const row = document.createElement('div');
|
||||
// Force Grid layout for strict alignment of checkbox vs label
|
||||
row.style.cssText = "display:grid; grid-template-columns: 20px 1fr; align-items:center; gap:5px; color:#334155; font-size:0.85rem; border-bottom:1px solid #f1f5f9; padding-bottom:4px;";
|
||||
|
||||
const chk = document.createElement('input');
|
||||
chk.type = 'checkbox';
|
||||
chk.checked = this.visibleIds.has(img.id);
|
||||
chk.onchange = () => this.toggleLayer(img.id, chk.checked);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.innerText = img.name;
|
||||
label.style.cssText = "white-space:nowrap; overflow:hidden; text-overflow:ellipsis;";
|
||||
label.title = img.name;
|
||||
|
||||
row.appendChild(chk);
|
||||
row.appendChild(label);
|
||||
this.sidebarList.appendChild(row);
|
||||
});
|
||||
|
||||
// Smart Default Selection
|
||||
if(this.visibleIds.size === 0) {
|
||||
const isDesktop = window.matchMedia("(min-width: 800px)").matches;
|
||||
if (isDesktop) {
|
||||
imgs.forEach(img => this.visibleIds.add(img.id));
|
||||
} else {
|
||||
if (imgs.length > 0) this.visibleIds.add(imgs[0].id);
|
||||
if (imgs.length > 1) this.visibleIds.add(imgs[1].id);
|
||||
}
|
||||
// Sync UI
|
||||
Array.from(this.sidebarList.querySelectorAll('input')).forEach((chk, i) => {
|
||||
chk.checked = this.visibleIds.has(imgs[i].id);
|
||||
});
|
||||
}
|
||||
|
||||
this.updateNetUI();
|
||||
this.renderGrid();
|
||||
}
|
||||
|
||||
async renderGrid() {
|
||||
this.grid.innerHTML = '';
|
||||
this.viewers = {};
|
||||
|
||||
if(this.visibleIds.size === 0) {
|
||||
this.grid.innerHTML = '<div style="display:flex; align-items:center; justify-content:center; color:#64748b; height:100%;">Select layers to inspect</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// [FIX] Force Grid Layout Styles via JS to ensure they apply
|
||||
this.grid.style.display = 'grid';
|
||||
this.grid.style.width = '100%';
|
||||
this.grid.style.height = '100%';
|
||||
this.grid.style.boxSizing = 'border-box';
|
||||
this.grid.style.gap = '2px';
|
||||
this.grid.style.background = '#000';
|
||||
|
||||
const count = this.visibleIds.size;
|
||||
this.grid.style.gridTemplateColumns = (count > 1) ? '1fr 1fr' : '1fr';
|
||||
this.grid.style.gridTemplateRows = (count > 2) ? '1fr 1fr' : '1fr';
|
||||
|
||||
if(!this.masterId || !this.visibleIds.has(this.masterId)) {
|
||||
this.masterId = this.visibleIds.values().next().value;
|
||||
}
|
||||
|
||||
for(const id of this.visibleIds) {
|
||||
const imgRec = bomImages.find(i => i.id === id);
|
||||
if(!imgRec) continue;
|
||||
|
||||
const cell = document.createElement('div');
|
||||
cell.style.cssText = "position:relative; overflow:hidden; border:1px solid #334155; background:#000; width:100%; height:100%;";
|
||||
|
||||
const cvs = document.createElement('canvas');
|
||||
cvs.id = `inspect-cvs-${id}`;
|
||||
cvs.style.display = 'block';
|
||||
cell.appendChild(cvs);
|
||||
|
||||
const lbl = document.createElement('div');
|
||||
lbl.innerText = imgRec.name;
|
||||
lbl.style.cssText = "position:absolute; top:5px; left:5px; background:rgba(0,0,0,0.7); padding:2px 6px; font-size:0.7rem; pointer-events:none; border-radius:3px; color:white;";
|
||||
cell.appendChild(lbl);
|
||||
|
||||
this.grid.appendChild(cell);
|
||||
|
||||
const viewer = new PanZoomCanvas(cvs.id,
|
||||
(ctx, k) => this.drawOverlay(id, ctx, k),
|
||||
(x, y) => this.handleNodeClick(id, x, y),
|
||||
(dx, dy, mode) => {
|
||||
// Hit Test: Return -1 to allow Panning
|
||||
if(mode === 'check') return -1;
|
||||
}
|
||||
);
|
||||
|
||||
viewer.onMouseMove = (x, y) => {
|
||||
if(this.masterId === id) this.syncCursors(id, x, y);
|
||||
};
|
||||
|
||||
// Activation & Click Handling
|
||||
let downX = 0, downY = 0;
|
||||
let wasActiveBeforeDown = false; // Track state before click
|
||||
|
||||
cvs.addEventListener('mousedown', (e) => {
|
||||
if (e.button === 0) { // Left Click
|
||||
downX = e.clientX;
|
||||
downY = e.clientY;
|
||||
|
||||
// Check if we are clicking an already active window
|
||||
wasActiveBeforeDown = (this.masterId === id);
|
||||
|
||||
// Activate immediately to allow dragging/tracking
|
||||
if(this.masterId !== id) {
|
||||
this.masterId = id;
|
||||
}
|
||||
|
||||
const pt = viewer.getImgCoords(e.clientX, e.clientY);
|
||||
this.syncCursors(id, pt.x, pt.y);
|
||||
}
|
||||
});
|
||||
|
||||
// Right Click: Inactivate (Stop Tracking)
|
||||
cvs.oncontextmenu = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.masterId = null;
|
||||
this.cursorState = null;
|
||||
|
||||
// Clear cursors on all views
|
||||
Object.values(this.viewers).forEach(v => {
|
||||
v.cursorPos = null;
|
||||
v.setDimmed(false);
|
||||
v.draw();
|
||||
});
|
||||
};
|
||||
|
||||
cvs.addEventListener('mouseup', (e) => {
|
||||
if (e.button !== 0) return; // Left click only
|
||||
|
||||
const dist = Math.hypot(e.clientX - downX, e.clientY - downY);
|
||||
if (dist < 10) { // Click vs Drag
|
||||
// Only Add/Edit node if the window was ALREADY active.
|
||||
// If it wasn't, we just activated it in mousedown, so do nothing else.
|
||||
if (wasActiveBeforeDown) {
|
||||
const pt = viewer.getImgCoords(e.clientX, e.clientY);
|
||||
|
||||
// 1. Try to edit an existing node
|
||||
const hit = this.handleNodeClick(id, pt.x, pt.y);
|
||||
|
||||
// 2. If no node hit, try to add a new one
|
||||
if (!hit) {
|
||||
this.handleAddNode(id, pt.x, pt.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.viewers[id] = viewer;
|
||||
|
||||
try {
|
||||
const bmp = await createImageBitmap(imgRec.blob);
|
||||
viewer.setImage(bmp);
|
||||
// Force sync canvas resolution to container size before fitting
|
||||
if (cell.clientWidth && cell.clientHeight) {
|
||||
viewer.canvas.width = cell.clientWidth;
|
||||
viewer.canvas.height = cell.clientHeight;
|
||||
}
|
||||
viewer.fit();
|
||||
|
||||
if(imgRec.name.toLowerCase().includes('bot') && !imgRec.name.toLowerCase().includes('top')) {
|
||||
viewer.setMirror(true);
|
||||
}
|
||||
} catch(e) { console.error("Inspector img load error", e); }
|
||||
}
|
||||
this.updateNetNodeCache();
|
||||
}
|
||||
|
||||
handleNodeClick(imgId, x, y) {
|
||||
if (!this.activeNet || !this.netNodeCache[imgId]) return false;
|
||||
const viewer = this.viewers[imgId];
|
||||
if (!viewer) return false;
|
||||
|
||||
// Hit test in Screen Space
|
||||
const HIT_RADIUS = 20;
|
||||
|
||||
const hit = this.netNodeCache[imgId].find(n => {
|
||||
const dist = Math.hypot(n.x - x, n.y - y);
|
||||
return (dist * viewer.t.k) < HIT_RADIUS;
|
||||
});
|
||||
|
||||
if (hit) {
|
||||
const oldLabel = hit.label;
|
||||
const newLabel = prompt(`Edit Node "${oldLabel}"\n\nEnter new name to Rename.\nClear text and click OK to Delete.`, oldLabel);
|
||||
|
||||
if (newLabel !== null) {
|
||||
const nodeIndex = this.activeNet.nodes.indexOf(hit.origNode);
|
||||
if (nodeIndex === -1) return true;
|
||||
|
||||
if (newLabel.trim() === "") {
|
||||
this.activeNet.nodes.splice(nodeIndex, 1); // Delete
|
||||
} else {
|
||||
this.activeNet.nodes[nodeIndex].label = newLabel.trim(); // Rename
|
||||
}
|
||||
this.updateNetUI();
|
||||
}
|
||||
return true; // Handled
|
||||
}
|
||||
return false; // Not handled
|
||||
}
|
||||
|
||||
toggleLayer(id, isVisible) {
|
||||
if(isVisible) this.visibleIds.add(id);
|
||||
else this.visibleIds.delete(id);
|
||||
this.renderGrid();
|
||||
}
|
||||
|
||||
// Load an existing net for editing
|
||||
loadNet(net) {
|
||||
// Deep copy to ensure "Cancel" works (discarding changes)
|
||||
this.activeNet = JSON.parse(JSON.stringify(net));
|
||||
this.updateNetUI();
|
||||
|
||||
// Ensure pins are visible immediately
|
||||
// We might need to ensure grid is rendered if not already
|
||||
if(Object.keys(this.viewers).length === 0) {
|
||||
this.renderGrid().then(() => {
|
||||
Object.values(this.viewers).forEach(v => v.draw());
|
||||
});
|
||||
} else {
|
||||
Object.values(this.viewers).forEach(v => v.draw());
|
||||
}
|
||||
}
|
||||
|
||||
async updateNetNodeCache() {
|
||||
this.netNodeCache = {};
|
||||
if (!this.activeNet || !this.activeNet.nodes) return;
|
||||
|
||||
// Initialize cache arrays for visible viewers
|
||||
for (const vid of this.visibleIds) this.netNodeCache[vid] = [];
|
||||
|
||||
// Process each node in the net
|
||||
for (const node of this.activeNet.nodes) {
|
||||
// 1. Add directly to the source image (Blue)
|
||||
if (this.visibleIds.has(node.imgId)) {
|
||||
this.netNodeCache[node.imgId].push({
|
||||
x: node.x, y: node.y, label: node.label,
|
||||
color: '#2563eb', isSource: true, origNode: node
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Project to other visible images (Green)
|
||||
// We need paths from the node's image to all other visible images
|
||||
const paths = await ImageGraph.solvePaths(node.imgId, this.cv, this.db);
|
||||
|
||||
for (const p of paths) {
|
||||
if (this.visibleIds.has(p.id)) {
|
||||
const proj = this.cv.projectPoint(node.x, node.y, p.H);
|
||||
if (proj) {
|
||||
this.netNodeCache[p.id].push({
|
||||
x: proj.x, y: proj.y, label: node.label,
|
||||
color: '#4ade80', isSource: false, origNode: node
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Redraw all to show changes
|
||||
Object.values(this.viewers).forEach(v => v.draw());
|
||||
}
|
||||
|
||||
async syncCursors(masterId, mx, my) {
|
||||
this.cursorState = { masterId, mx, my };
|
||||
|
||||
for(const [id, viewer] of Object.entries(this.viewers)) {
|
||||
if(id === masterId) {
|
||||
viewer.setDimmed(false);
|
||||
viewer.draw();
|
||||
continue;
|
||||
}
|
||||
|
||||
const path = await ImageGraph.solvePaths(masterId, this.cv, this.db);
|
||||
const targetPath = path.find(p => p.id === id);
|
||||
|
||||
if(targetPath) {
|
||||
const pt = this.cv.projectPoint(mx, my, targetPath.H);
|
||||
if(pt) {
|
||||
viewer.cursorPos = pt;
|
||||
|
||||
const w = viewer.bmp ? viewer.bmp.width : 1000;
|
||||
const h = viewer.bmp ? viewer.bmp.height : 1000;
|
||||
const inside = (pt.x >= 0 && pt.y >= 0 && pt.x <= w && pt.y <= h);
|
||||
|
||||
viewer.setDimmed(!inside);
|
||||
|
||||
if (inside && viewer.bmp) {
|
||||
const k = viewer.t.k;
|
||||
const tx = viewer.t.x;
|
||||
const ty = viewer.t.y;
|
||||
|
||||
const imgX = viewer.isMirrored ? (w - pt.x) : pt.x;
|
||||
const screenX = imgX * k + tx;
|
||||
const screenY = pt.y * k + ty;
|
||||
|
||||
const cvsW = viewer.canvas.width;
|
||||
const cvsH = viewer.canvas.height;
|
||||
const padX = cvsW * 0.25;
|
||||
const padY = cvsH * 0.25;
|
||||
|
||||
let dx = 0; let dy = 0;
|
||||
|
||||
if (screenX > cvsW - padX) dx = (cvsW - padX) - screenX;
|
||||
else if (screenX < padX) dx = padX - screenX;
|
||||
|
||||
if (screenY > cvsH - padY) dy = (cvsH - padY) - screenY;
|
||||
else if (screenY < padY) dy = padY - screenY;
|
||||
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
viewer.t.x += dx;
|
||||
viewer.t.y += dy;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
viewer.cursorPos = null;
|
||||
viewer.setDimmed(true);
|
||||
}
|
||||
} else {
|
||||
viewer.cursorPos = null;
|
||||
viewer.setDimmed(true);
|
||||
}
|
||||
viewer.draw();
|
||||
}
|
||||
}
|
||||
|
||||
drawOverlay(id, ctx, k) {
|
||||
const viewer = this.viewers[id];
|
||||
if (!viewer) return;
|
||||
|
||||
const ik = 1/k; // Inverse zoom factor
|
||||
|
||||
// 1. Draw Cached Nodes as Pins
|
||||
if (this.netNodeCache[id]) {
|
||||
this.netNodeCache[id].forEach(n => {
|
||||
let drawX = n.x;
|
||||
if (viewer.isMirrored && viewer.bmp) drawX = viewer.bmp.width - n.x;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(drawX, n.y);
|
||||
ctx.scale(ik, ik); // Keep pin constant size on screen
|
||||
|
||||
// ROTATION: -45 degrees (Counter-Clockwise)
|
||||
// This aligns the "Bottom-Left" corner of our square to point straight down if the square is in the Top-Right quadrant.
|
||||
ctx.rotate(-Math.PI / 4);
|
||||
|
||||
// DRAW PIN SHAPE
|
||||
// We draw a square relative to the origin (0,0).
|
||||
// The Origin (0,0) is the Sharp Tip.
|
||||
// The square extends into x>0, y<0 (Visual Top-Right relative to rotation axis)
|
||||
// so that when rotated -45deg, it stands "Up" above the point.
|
||||
|
||||
const s = 20; // Size of the square side
|
||||
const r = 10; // Radius (50% of size)
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0); // Tip starts exactly on the node coordinate
|
||||
|
||||
// Left Edge (going 'Up' in local coords) -> Top-Left Corner
|
||||
ctx.lineTo(0, -s + r);
|
||||
ctx.arcTo(0, -s, s, -s, r);
|
||||
|
||||
// Top Edge -> Top-Right Corner
|
||||
ctx.lineTo(s - r, -s);
|
||||
ctx.arcTo(s, -s, s, 0, r);
|
||||
|
||||
// Right Edge -> Bottom-Right Corner
|
||||
ctx.lineTo(s, -r);
|
||||
ctx.arcTo(s, 0, 0, 0, r);
|
||||
|
||||
// Return to Tip
|
||||
ctx.lineTo(0, 0);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fillStyle = n.color; // Blue (#2563eb) or Green (#4ade80)
|
||||
ctx.fill();
|
||||
|
||||
// White Border
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeStyle = 'white';
|
||||
ctx.stroke();
|
||||
|
||||
// LABEL
|
||||
// We want the text in the center of the "bulb".
|
||||
// The center of our square is at (s/2, -s/2).
|
||||
ctx.translate(s/2, -s/2);
|
||||
|
||||
// Rotate text back +45deg so it appears horizontal
|
||||
ctx.rotate(Math.PI / 4);
|
||||
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.fillText(n.label, 0, 0);
|
||||
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Cursor Crosshair (Unchanged)
|
||||
let cx, cy, color = '#ff0000';
|
||||
|
||||
if(this.cursorState && this.cursorState.masterId === id) {
|
||||
cx = this.cursorState.mx; cy = this.cursorState.my;
|
||||
if(viewer.isMirrored && viewer.bmp) cx = viewer.bmp.width - cx;
|
||||
} else if (viewer.cursorPos) {
|
||||
cx = viewer.cursorPos.x; cy = viewer.cursorPos.y;
|
||||
if(viewer.isMirrored && viewer.bmp) cx = viewer.bmp.width - cx;
|
||||
color = '#facc15';
|
||||
}
|
||||
|
||||
if(cx !== undefined) {
|
||||
const len = 100000;
|
||||
ctx.lineWidth = 1 * ik;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - len, cy); ctx.lineTo(cx + len, cy);
|
||||
ctx.moveTo(cx, cy - len); ctx.lineTo(cx, cy + len);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
startNewNet() {
|
||||
this.activeNet = { id: uuid(), name: "New Net", nodes: [], isNew: true };
|
||||
this.updateNetUI();
|
||||
}
|
||||
|
||||
handleAddNode(imgId, x, y) {
|
||||
// Determine default label (start at P1 if no net exists)
|
||||
const nextIdx = this.activeNet ? this.activeNet.nodes.length + 1 : 1;
|
||||
const defaultLabel = `P${nextIdx}`;
|
||||
|
||||
const label = prompt("Pad/Pin Name:", defaultLabel);
|
||||
|
||||
if(label) {
|
||||
if(!this.activeNet) {
|
||||
this.startNewNet();
|
||||
}
|
||||
|
||||
this.activeNet.nodes.push({
|
||||
id: uuid(),
|
||||
imgId: imgId,
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
label: label
|
||||
});
|
||||
this.updateNetUI();
|
||||
Object.values(this.viewers).forEach(v => v.draw());
|
||||
}
|
||||
}
|
||||
|
||||
async saveNet() {
|
||||
if(!this.activeNet) return;
|
||||
|
||||
// Only prompt for name if it's a new net
|
||||
if (this.activeNet.isNew) {
|
||||
const name = prompt("Net Name:", this.activeNet.name);
|
||||
if(name) {
|
||||
this.activeNet.name = name;
|
||||
delete this.activeNet.isNew; // Remove flag before saving
|
||||
} else {
|
||||
return; // Cancelled by user in prompt
|
||||
}
|
||||
}
|
||||
|
||||
this.activeNet.projectId = currentBomId;
|
||||
await this.db.addNet(this.activeNet);
|
||||
|
||||
this.activeNet = null;
|
||||
this.updateNetUI();
|
||||
|
||||
if(window.netManager) window.netManager.render();
|
||||
|
||||
// Return to Nets list
|
||||
history.back();
|
||||
}
|
||||
|
||||
cancelNet() {
|
||||
this.activeNet = null;
|
||||
this.updateNetUI();
|
||||
history.back();
|
||||
}
|
||||
|
||||
updateNetUI() {
|
||||
if(!this.activeNet) {
|
||||
this.activeNetEl.style.display = 'none';
|
||||
} else {
|
||||
this.activeNetEl.style.cssText = "pointer-events:auto; background:rgba(15, 23, 42, 0.9); padding:4px 10px; border-radius:20px; border:1px solid #334155; display:flex; color:white; align-items:center; gap:8px; box-shadow:0 4px 6px rgba(0,0,0,0.2); backdrop-filter:blur(4px); font-size:0.85rem; height:auto;";
|
||||
|
||||
this.activeNetEl.innerHTML = `
|
||||
<span style="font-weight:600; color:#4ade80; max-width:100px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${this.activeNet.name}</span>
|
||||
<span style="color:#94a3b8; border-left:1px solid #475569; padding-left:8px; font-size:0.8rem;">${this.activeNet.nodes.length}</span>
|
||||
<button class="primary sm-btn" style="padding:1px 8px; font-size:0.75rem; height:24px; min-height:0; line-height:1;" onclick="inspector.saveNet()">Save</button>
|
||||
<button class="danger sm-btn" style="padding:0; width:20px; height:20px; min-height:0; border-radius:50%; line-height:1; display:flex; align-items:center; justify-content:center;" onclick="inspector.cancelNet()">×</button>
|
||||
`;
|
||||
}
|
||||
this.updateNetNodeCache();
|
||||
}
|
||||
|
||||
}
|
||||
192
docs/nets.js
Normal file
192
docs/nets.js
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/* nets.js - Netlist Management (v2) */
|
||||
|
||||
class NetManager {
|
||||
constructor(db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async render() {
|
||||
const tbody = document.getElementById('nets-body');
|
||||
if(!tbody) return;
|
||||
|
||||
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center; color:#888;">Loading...</td></tr>';
|
||||
|
||||
const nets = await this.db.getNets();
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if(nets.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center; padding:2rem; color:#94a3b8;">No nets defined. Go to "Inspect" to create one.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
nets.forEach(net => {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
tr.style.height = 'auto';
|
||||
tr.style.minHeight = '2.2rem';
|
||||
|
||||
// Target Icon for Editing
|
||||
const targetIcon = `<span style="cursor:pointer; margin-right:0.5rem;" onclick="netManager.editNet('${net.id}')" title="Edit Net on Board">🎯</span>`;
|
||||
|
||||
let nodesHtml = '';
|
||||
net.nodes.forEach((n, idx) => {
|
||||
nodesHtml += `<span class="net-chip" onclick="netManager.editNode('${net.id}', ${idx})" title="Edit Node">${n.label}</span>`;
|
||||
});
|
||||
|
||||
tr.innerHTML = `
|
||||
<td style="display:flex; align-items:center; vertical-align:top; height:auto;">
|
||||
${targetIcon}
|
||||
<input type="text" value="${net.name}" onchange="netManager.rename('${net.id}', this.value)" style="border:none; background:transparent; font-weight:bold; flex:1; min-width:0;">
|
||||
</td>
|
||||
|
||||
<!-- [FIX] Allow cell to expand freely -->
|
||||
<td style="white-space:normal; height:auto; overflow:visible;">
|
||||
<div style="display:flex; flex-wrap:wrap; gap:4px; padding:2px 0;">${nodesHtml}</div>
|
||||
</td>
|
||||
|
||||
<td style="text-align:right; vertical-align:top; height:auto;">
|
||||
<button class="danger sm-btn" onclick="netManager.delete('${net.id}')" title="Delete Net">🗑️</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
// Edit Net in Inspector
|
||||
async editNet(id) {
|
||||
const net = await this.db._tx('nets', 'readonly', s => s.get(id));
|
||||
if(net) {
|
||||
switchView('inspect');
|
||||
// We use the global inspector instance
|
||||
if(window.inspector) window.inspector.loadNet(net);
|
||||
}
|
||||
}
|
||||
|
||||
// Edit individual node
|
||||
async editNode(netId, nodeIdx) {
|
||||
const net = await this.db._tx('nets', 'readonly', s => s.get(netId));
|
||||
if(!net || !net.nodes[nodeIdx]) return;
|
||||
|
||||
const node = net.nodes[nodeIdx];
|
||||
// Simple action menu via Prompt/Confirm
|
||||
// 1. Ask for new name (Empty = Delete)
|
||||
const newName = prompt(`Edit node "${node.label}"\n\nEnter new name to Rename.\nClear text and press OK to Delete.`, node.label);
|
||||
|
||||
if(newName === null) return; // Cancelled
|
||||
|
||||
if(newName.trim() === '') {
|
||||
if(confirm("Delete this node?")) {
|
||||
net.nodes.splice(nodeIdx, 1);
|
||||
await this.db.addNet(net);
|
||||
this.render();
|
||||
}
|
||||
} else {
|
||||
net.nodes[nodeIdx].label = newName.trim();
|
||||
await this.db.addNet(net);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
async rename(id, newName) {
|
||||
if(!newName.trim()) return;
|
||||
const net = await this.db._tx('nets', 'readonly', s => s.get(id));
|
||||
if(net) {
|
||||
net.name = newName.trim();
|
||||
await this.db.addNet(net);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id) {
|
||||
if(await confirmAction("Delete this net?", "Delete")) {
|
||||
await this.db.deleteNet(id);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
async exportKiCad() {
|
||||
// 1. Determine Filename
|
||||
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 : [];
|
||||
|
||||
// --- CONFIGURATION: Component Type Mapping ---
|
||||
// We only map types that are structurally unambiguous (2-pin passives, test points).
|
||||
// Complex types (Q, U, J) are commented out to prevent incorrect symbol assignment.
|
||||
const COMPONENT_LIBRARY_MAP = {
|
||||
'R': { lib: "Device", part: "R", desc: "Resistor" },
|
||||
'C': { lib: "Device", part: "C", desc: "Unpolarized capacitor" },
|
||||
'L': { lib: "Device", part: "L", desc: "Inductor" },
|
||||
'D': { lib: "Device", part: "D", desc: "Diode" },
|
||||
'TP': { lib: "Connector", part: "TestPoint", desc: "Test Point" },
|
||||
|
||||
// --- AMBIGUOUS TYPES (Disabled by default) ---
|
||||
// 'Q': { lib: "Device", part: "Q_NPN_BEC", desc: "Transistor NPN" }, // Risk: Could be PNP, MOSFET, IGBT
|
||||
// 'J': { lib: "Connector", part: "Conn_01x02_Male", desc: "Connector" }, // Risk: Pin count unknown
|
||||
// 'CN': { lib: "Connector", part: "Conn_01x02_Male", desc: "Connector" }, // Risk: Pin count unknown
|
||||
};
|
||||
|
||||
let out = "(export (version D)\n";
|
||||
|
||||
// 2. Export Components
|
||||
out += " (components\n";
|
||||
components.forEach(c => {
|
||||
const val = c.value ? c.value : "~";
|
||||
const footprint = c.desc ? c.desc.replace(/"/g, '') : "";
|
||||
const tstamp = c.id ? c.id.substring(0, 8) : Math.floor(Math.random()*10000000).toString(16);
|
||||
|
||||
// Detect Type from Prefix
|
||||
const prefix = (c.label.match(/^[A-Z]+/) || [""])[0].toUpperCase();
|
||||
|
||||
// Lookup Library definition
|
||||
// Future TODO: Add logic here to check c.desc for keywords like "NPN", "MOSFET", etc.
|
||||
const def = COMPONENT_LIBRARY_MAP[prefix];
|
||||
|
||||
out += ` (comp (ref "${c.label}")\n`;
|
||||
out += ` (value "${val}")\n`;
|
||||
if(footprint) out += ` (footprint "${footprint}")\n`;
|
||||
|
||||
// Inject Library Source if we have a safe definition
|
||||
if(def) {
|
||||
out += ` (libsource (lib "${def.lib}") (part "${def.part}") (description "${def.desc}"))\n`;
|
||||
out += ` (property (name "Sheetname") (value "")) (property (name "Sheetfile") (value "${filename}.kicad_sch"))\n`;
|
||||
}
|
||||
|
||||
out += ` (tstamp "${tstamp}")\n`;
|
||||
out += ` )\n`;
|
||||
});
|
||||
out += " )\n";
|
||||
|
||||
// 3. Export Nets
|
||||
out += " (nets\n";
|
||||
nets.forEach((net, i) => {
|
||||
out += ` (net (code ${i+1}) (name "${net.name}")\n`;
|
||||
net.nodes.forEach(node => {
|
||||
const parts = node.label.split('.');
|
||||
if(parts.length === 2) {
|
||||
// Format: R1.2 (Ref R1, Pin 2)
|
||||
out += ` (node (ref "${parts[0]}") (pin "${parts[1]}"))\n`;
|
||||
} else {
|
||||
// Fallback: TestPoints or direct names often use Pin 1
|
||||
out += ` (node (ref "${node.label}") (pin "1"))\n`;
|
||||
}
|
||||
});
|
||||
out += " )\n";
|
||||
});
|
||||
out += " )\n)\n";
|
||||
|
||||
// 4. Download
|
||||
const blob = new Blob([out], { type: 'text/plain' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${filename}.net`;
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
}
|
||||
|
||||
}
|
||||
227
docs/resistor.html
Normal file
227
docs/resistor.html
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
<!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, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Resistor Color Decoder</title>
|
||||
<script src="tool-bridge.js"></script>
|
||||
<script src="spyglass.js"></script>
|
||||
<link rel="stylesheet" href="tool-style.css">
|
||||
<style>
|
||||
:root { --accent: #2563eb; --resistor-body: #82cae0; --resistor-shadow: #5ea8bd; }
|
||||
.controls-grid { grid-template-columns: 1fr 1fr; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<h1 id="app-title">Resistor Color Decoder</h1>
|
||||
|
||||
<div class="top-section">
|
||||
<div id="spyglass-col" class="col-spyglass">
|
||||
<div class="spyglass-wrapper">
|
||||
<canvas id="tool-canvas" class="spyglass-canvas" width="600" height="600"></canvas>
|
||||
<div id="tool-zoom" class="zoom-badge">2x</div>
|
||||
</div>
|
||||
<div id="view-selector"></div>
|
||||
</div>
|
||||
|
||||
<div class="col-controls">
|
||||
<div class="reverse-input-group">
|
||||
<div class="input-row">
|
||||
<input type="text" id="nominal-input" class="inp-nominal" placeholder="e.g. 4k7, 100">
|
||||
<button class="btn-action" onclick="applyNominal()">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls-grid">
|
||||
<button class="btn-mode" onclick="setBands(3)">3 Band</button>
|
||||
<button class="btn-mode" onclick="setBands(4)">4 Band</button>
|
||||
<button class="btn-mode" onclick="setBands(5)">5 Band</button>
|
||||
<button class="btn-mode active" onclick="setBands(6)">6 Band</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="resistor-wrapper-outer">
|
||||
<div class="resistor-dumbbell-shape">
|
||||
<div class="bands-container" id="picker-area"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="results" class="result-box"></div>
|
||||
<div class="app-footer">Copyright © 2025 Taras Greben — <a href="https://pcb.etaras.com">pcb.eTaras.com</a></div>
|
||||
</div>
|
||||
|
||||
<div id="global-dropdown"></div>
|
||||
|
||||
<script>
|
||||
const colors = [
|
||||
{ name: 'Black', val: 0, mult: 1, tol: null, tcr: 250, css: 'c-bk' },
|
||||
{ name: 'Brown', val: 1, mult: 10, tol: 1, tcr: 100, css: 'c-bn' },
|
||||
{ name: 'Red', val: 2, mult: 100, tol: 2, tcr: 50, css: 'c-rd' },
|
||||
{ name: 'Orange', val: 3, mult: 1000, tol: null, tcr: 15, css: 'c-og' },
|
||||
{ name: 'Yellow', val: 4, mult: 10000, tol: null, tcr: 25, css: 'c-ye' },
|
||||
{ name: 'Green', val: 5, mult: 100000, tol: 0.5, tcr: 20, css: 'c-gn' },
|
||||
{ name: 'Blue', val: 6, mult: 1000000, tol: 0.25, tcr: 10, css: 'c-bu' },
|
||||
{ name: 'Violet', val: 7, mult: 10000000, tol: 0.1, tcr: 5, css: 'c-vt' },
|
||||
{ name: 'Gray', val: 8, mult: null, tol: 0.05, tcr: 1, css: 'c-gy' },
|
||||
{ name: 'White', val: 9, mult: null, tol: null, tcr: null, css: 'c-wh' },
|
||||
{ name: 'Gold', val: null, mult: 0.1, tol: 5, tcr: null, css: 'c-gd' },
|
||||
{ name: 'Silver', val: null, mult: 0.01, tol: 10, tcr: null, css: 'c-sv' }
|
||||
];
|
||||
const e12=[1.0,1.2,1.5,1.8,2.2,2.7,3.3,3.9,4.7,5.6,6.8,8.2];
|
||||
const e24=[1.0,1.1,1.2,1.3,1.5,1.6,1.8,2.0,2.2,2.4,2.7,3.0,3.3,3.6,3.9,4.3,4.7,5.1,5.6,6.2,6.8,7.5,8.2,9.1];
|
||||
|
||||
let currentBands=4; let bandState=[1,2,2,10,1,2]; let activeBandIndex=-1; let isEmbed=false; let bridge=null;
|
||||
const globalDropdown = document.getElementById('global-dropdown');
|
||||
|
||||
function init() {
|
||||
initGlobalDropdown();
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has('embed') || window.self !== window.top) {
|
||||
isEmbed = true;
|
||||
document.getElementById('app-title').style.display = 'none';
|
||||
}
|
||||
setBands(4);
|
||||
}
|
||||
|
||||
function initGlobalDropdown(){
|
||||
globalDropdown.innerHTML='';
|
||||
colors.forEach((c,i)=>{
|
||||
const o=document.createElement('div');
|
||||
o.className=`custom-option ${c.css}`;
|
||||
o.innerText=c.name;
|
||||
o.onclick=e=>{e.stopPropagation(); if(activeBandIndex>-1){bandState[activeBandIndex]=i;renderPickers();calculate();closeDropdown();}};
|
||||
globalDropdown.appendChild(o);
|
||||
});
|
||||
}
|
||||
|
||||
function openGlobalDropdown(triggerElement) {
|
||||
globalDropdown.style.display = 'flex'; globalDropdown.style.top = '0px'; globalDropdown.style.left = '0px';
|
||||
globalDropdown.classList.remove('condensed', 'super-condensed');
|
||||
globalDropdown.style.height = 'auto'; globalDropdown.style.overflowY = 'hidden';
|
||||
|
||||
const triggerRect = triggerElement.getBoundingClientRect();
|
||||
const menuRect = globalDropdown.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let top = triggerRect.bottom + 5;
|
||||
let left = triggerRect.left + (triggerRect.width / 2);
|
||||
let projectedBottom = top + menuRect.height;
|
||||
|
||||
if (projectedBottom > viewportHeight - 10) {
|
||||
globalDropdown.classList.add('condensed');
|
||||
projectedBottom = top + globalDropdown.getBoundingClientRect().height;
|
||||
}
|
||||
if (projectedBottom > viewportHeight - 10) {
|
||||
globalDropdown.classList.add('super-condensed');
|
||||
projectedBottom = top + globalDropdown.getBoundingClientRect().height;
|
||||
}
|
||||
if (projectedBottom > viewportHeight - 10) {
|
||||
const height = globalDropdown.getBoundingClientRect().height;
|
||||
top = viewportHeight - height - 10;
|
||||
if (top < 10) {
|
||||
top = 10;
|
||||
globalDropdown.style.height = (viewportHeight - 20) + 'px';
|
||||
globalDropdown.style.overflowY = 'auto';
|
||||
}
|
||||
}
|
||||
globalDropdown.style.top = top + 'px';
|
||||
globalDropdown.style.left = left + 'px';
|
||||
globalDropdown.style.transform = 'translateX(-50%)';
|
||||
}
|
||||
|
||||
function closeDropdown(){ globalDropdown.style.display='none'; activeBandIndex=-1; }
|
||||
window.addEventListener('click', closeDropdown);
|
||||
window.addEventListener('resize', closeDropdown);
|
||||
window.addEventListener('scroll', closeDropdown, true);
|
||||
|
||||
function setBands(n){ currentBands=n; document.querySelectorAll('.btn-mode').forEach((b,i)=>b.classList.toggle('active',(i+3)===n)); while(bandState.length<n)bandState.push(0); renderPickers(); calculate(); }
|
||||
|
||||
function renderPickers(){
|
||||
const a=document.getElementById('picker-area'); a.innerHTML='';
|
||||
for(let i=0;i<currentBands;i++){
|
||||
const w=document.createElement('div'); w.className='custom-select-wrapper';
|
||||
const t=document.createElement('div'); t.className=`custom-select__trigger ${colors[bandState[i]].css}`;
|
||||
t.innerHTML=`<span>${colors[bandState[i]].name}</span>`;
|
||||
t.onclick=e=>{e.stopPropagation(); if(activeBandIndex===i&&globalDropdown.style.display==='flex')closeDropdown();else{activeBandIndex=i;openGlobalDropdown(t);}};
|
||||
w.appendChild(t); a.appendChild(w);
|
||||
}
|
||||
}
|
||||
|
||||
function parseInputStr(str) { if(!str)return null; let m=1; if(str.includes('M')){m=1e6;str=str.replace('M','.');}else if(str.includes('m')){m=1e-3;str=str.replace('m','.');}else if(str.toLowerCase().includes('k')){m=1e3;str=str.replace(/k/i,'.');}else if(str.toLowerCase().includes('r')){m=1;str=str.replace(/r/i,'.');} const v=parseFloat(str); return isNaN(v)?null:v*m; }
|
||||
function applyNominal(){ const raw=document.getElementById('nominal-input').value; const ohms=parseInputStr(raw); if(ohms===null)return; const nD=(currentBands>=5)?3:2; let val=ohms; const mag=Math.floor(Math.log10(val)); const shift=(nD-1)-mag; let norm=Math.round(val*Math.pow(10,shift)); let s=norm.toString(); if(s.length>nD){norm=Math.round(norm/10);s=norm.toString();} const d=[]; for(let i=0;i<nD;i++)d.push(parseInt(s[i])); const exp=Math.round(Math.log10(ohms/norm)); let mIdx=-1; if(exp>=0&&exp<=9)mIdx=exp;else if(exp===-1)mIdx=10;else if(exp===-2)mIdx=11; if(mIdx!==-1){ bandState[0]=d[0];bandState[1]=d[1]; if(nD===2){bandState[2]=mIdx;if(currentBands===4)bandState[3]=10;}else{bandState[2]=d[2];bandState[3]=mIdx;bandState[4]=1;if(currentBands===6)bandState[5]=1;} renderPickers(); calculate(); } }
|
||||
|
||||
function formatOhmsUI(v){ if(v>=1e6)return(v/1e6).toFixed(2)+' MΩ'; if(v>=1e3)return(v/1e3).toFixed(2)+' kΩ'; if(v<1)return(v*1000).toFixed(0)+' mΩ'; return v.toFixed(2)+' Ω'; }
|
||||
function formatOhmsBOM(v){ if(v>=1e6)return(v/1e6).toFixed(2)+'M'; if(v>=1e3)return(v/1e3).toFixed(2)+'k'; if(v<1)return(v*1000).toFixed(0)+'m'; return v.toFixed(2)+'R'; }
|
||||
function checkSeries(v){ let n=v; while(n>=10)n/=10; while(n<1&&n>0)n*=10; n=Math.round(n*100)/100; if(e12.includes(n))return "E12"; if(e24.includes(n))return "E24"; return ""; }
|
||||
|
||||
function decode(idx){ if(colors[idx[0]].name==='Black'||colors[idx[0]].name==='Gold'||colors[idx[0]].name==='Silver')return{valid:false}; let v=0,m=0,t=0,tcr=0; if(currentBands===3){v=colors[idx[0]].val*10+colors[idx[1]].val;m=colors[idx[2]].mult;t=20;}else if(currentBands===4){v=colors[idx[0]].val*10+colors[idx[1]].val;m=colors[idx[2]].mult;t=colors[idx[3]].tol;}else{v=colors[idx[0]].val*100+colors[idx[1]].val*10+colors[idx[2]].val;m=colors[idx[3]].mult;t=colors[idx[4]].tol;if(currentBands===6)tcr=colors[idx[5]].tcr;} if(m===null||(currentBands>3&&t===null))return{valid:false}; const o=v*m; return{ohms:o,tol:t,tcr,valid:true,seriesInfo:checkSeries(o)}; }
|
||||
|
||||
function calculate(){
|
||||
const f=decode(bandState.slice(0,currentBands)); const b=decode([...bandState.slice(0,currentBands)].reverse());
|
||||
let fb=true; if(f.valid&&!b.valid)fb=true;else if(!f.valid&&b.valid)fb=false;else if(f.valid&&b.valid){fb=!(b.seriesInfo!==""&&f.seriesInfo==="");}
|
||||
|
||||
let h='';
|
||||
const row=(d,isF,best)=>{
|
||||
if(!d.valid&&!best)return'';
|
||||
const cls=best?'result-row best-match':'result-row';
|
||||
|
||||
const arrowSvg = isF ? `<svg viewBox="0 0 24 24"><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"></path></svg>` : `<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"></path></svg>`;
|
||||
const ic = isF ? "direction-icon" : "direction-icon reverse";
|
||||
|
||||
const btn = isEmbed ? `<button class="btn-select" onclick="selectResult('${formatOhmsBOM(d.ohms)}')">Select</button>` : '';
|
||||
const badge = best ? `<span class="badge-success" style="margin-left:0.5rem; padding:0.1rem 0.5rem; font-size:0.7rem; vertical-align:middle;">Likely</span>` : '';
|
||||
|
||||
const ser=d.seriesInfo?` <span class="match-badge">${d.seriesInfo}</span>`:'';
|
||||
|
||||
return `<div class="${cls}">
|
||||
<div class="${ic}">${arrowSvg}</div>
|
||||
<div class="val-group">
|
||||
<span class="val-text">${formatOhmsUI(d.ohms)}${ser}</span>
|
||||
<span class="meta-text">±${d.tol}%${d.tcr?` | ${d.tcr}ppm`:''}${badge}</span>
|
||||
</div>
|
||||
${btn}
|
||||
</div>`;
|
||||
};
|
||||
|
||||
if(!f.valid&&!b.valid) h='<div style="text-align:center;color:red;padding:1rem">Invalid</div>';
|
||||
else{
|
||||
if(fb){h+=row(f,true,true);h+=row(b,false,false);}
|
||||
else{h+=row(b,false,true);h+=row(f,true,false);}
|
||||
}
|
||||
document.getElementById('results').innerHTML=h;
|
||||
}
|
||||
|
||||
function selectResult(vStr){
|
||||
let d=`Resistor: ${currentBands}-Band, ±${colors[bandState[currentBands===3?null:(currentBands>=5?4:3)]]?.tol||20}%`;
|
||||
if(bridge) bridge.sendResult(vStr, d);
|
||||
}
|
||||
|
||||
function parseResistorMeta(d){
|
||||
let r={bands:4,tolIdx:-1,tcrIdx:-1}; if(d.includes("3-Band"))r.bands=3;else if(d.includes("5-Band"))r.bands=5;else if(d.includes("6-Band"))r.bands=6;
|
||||
const tm=d.match(/±\s*([\d.]+)%/); if(tm){const t=parseFloat(tm[1]);const c=colors.findIndex(x=>x.tol===t);if(c!==-1)r.tolIdx=c;}
|
||||
const tc=d.match(/(\d+)\s*ppm/i); if(tc){const p=parseInt(tc[1]);const c=colors.findIndex(x=>x.tcr===p);if(c!==-1)r.tcrIdx=c;}
|
||||
return r;
|
||||
}
|
||||
|
||||
init();
|
||||
bridge = new ToolBridge({
|
||||
onData: (d) => {
|
||||
if(d.value) document.getElementById('nominal-input').value = d.value;
|
||||
let meta = {bands:4,tolIdx:-1,tcrIdx:-1};
|
||||
if(d.description) meta = parseResistorMeta(d.description);
|
||||
setBands(meta.bands);
|
||||
applyNominal();
|
||||
if(meta.tolIdx!==-1){ if(meta.bands===4)bandState[3]=meta.tolIdx; else if(meta.bands>=5)bandState[4]=meta.tolIdx; }
|
||||
if(meta.tcrIdx!==-1 && meta.bands===6) bandState[5]=meta.tcrIdx;
|
||||
renderPickers(); calculate();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
229
docs/spyglass.js
Normal file
229
docs/spyglass.js
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* spyglass.js
|
||||
* Handles zooming, panning, and coordinate adjustment for PCB previews.
|
||||
* v2.1 - Added Pointer Events for Mobile Support (Pinch & Touch-Drag)
|
||||
*/
|
||||
class PcbSpyglass {
|
||||
/**
|
||||
* @param {string} canvasId
|
||||
* @param {string} overlayId
|
||||
* @param {function} onUpdateCallback - Optional. Called with (x, y) when user drags.
|
||||
*/
|
||||
constructor(canvasId, overlayId, onUpdateCallback = null) {
|
||||
this.canvas = document.getElementById(canvasId);
|
||||
this.overlay = document.getElementById(overlayId);
|
||||
this.container = this.canvas ? this.canvas.parentElement : null;
|
||||
this.onUpdate = onUpdateCallback;
|
||||
|
||||
if (!this.canvas) {
|
||||
console.error("Spyglass: Canvas element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.zoom = 2;
|
||||
this.img = null;
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this.enabled = false;
|
||||
|
||||
// Pointer State
|
||||
this.isDragging = false;
|
||||
this.lastMouseX = 0;
|
||||
this.lastMouseY = 0;
|
||||
this.evCache = []; // Cache for multi-touch
|
||||
this.prevDiff = -1; // For pinch zoom
|
||||
|
||||
// Bind Events
|
||||
this.canvas.addEventListener('wheel', this.handleWheel.bind(this), { passive: false });
|
||||
|
||||
// Unified Pointer Events (Mouse + Touch)
|
||||
this.canvas.addEventListener('pointerdown', this.handlePointerDown.bind(this));
|
||||
this.canvas.addEventListener('pointermove', this.handlePointerMove.bind(this));
|
||||
this.canvas.addEventListener('pointerup', this.handlePointerUp.bind(this));
|
||||
this.canvas.addEventListener('pointercancel', this.handlePointerUp.bind(this));
|
||||
this.canvas.addEventListener('pointerout', this.handlePointerUp.bind(this));
|
||||
this.canvas.addEventListener('pointerleave', this.handlePointerUp.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the image and coordinates.
|
||||
* @param {ImageBitmap|HTMLImageElement} imgSource
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {boolean} autoCloseOld - If true, .close() is called on the old bitmap (Use false for galleries)
|
||||
*/
|
||||
setTarget(imgSource, x, y, autoCloseOld = true) {
|
||||
if (!imgSource) return;
|
||||
|
||||
// MEMORY CLEANUP: Only if requested
|
||||
if (autoCloseOld && this.img && this.img !== imgSource && typeof this.img.close === 'function') {
|
||||
try { this.img.close(); } catch(e) {}
|
||||
}
|
||||
|
||||
this.img = imgSource;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.enabled = true;
|
||||
|
||||
if (this.container) {
|
||||
this.container.classList.remove('empty');
|
||||
this.container.classList.add('active');
|
||||
}
|
||||
|
||||
this.render();
|
||||
this.updateOverlay();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.enabled = false;
|
||||
this.img = null;
|
||||
|
||||
if (this.container) {
|
||||
this.container.classList.remove('active');
|
||||
this.container.classList.add('empty');
|
||||
}
|
||||
|
||||
this.ctx.fillStyle = '#ffffff';
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
if (this.overlay) this.overlay.style.display = 'none';
|
||||
}
|
||||
|
||||
handleWheel(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const d = e.deltaY > 0 ? -0.5 : 0.5;
|
||||
this.zoom = Math.max(0.5, Math.min(10, this.zoom + d));
|
||||
this.render();
|
||||
this.updateOverlay();
|
||||
}
|
||||
|
||||
// --- POINTER HANDLING (Desktop + Mobile) ---
|
||||
|
||||
handlePointerDown(e) {
|
||||
if (!this.enabled) return;
|
||||
this.canvas.setPointerCapture(e.pointerId);
|
||||
this.evCache.push(e);
|
||||
|
||||
if (this.evCache.length === 1) {
|
||||
this.isDragging = true;
|
||||
this.lastMouseX = e.clientX;
|
||||
this.lastMouseY = e.clientY;
|
||||
this.canvas.style.cursor = 'grabbing';
|
||||
}
|
||||
}
|
||||
|
||||
handlePointerMove(e) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
// Update cache
|
||||
const index = this.evCache.findIndex(cached => cached.pointerId === e.pointerId);
|
||||
if (index > -1) this.evCache[index] = e;
|
||||
|
||||
// 1. PINCH ZOOM (2 Fingers)
|
||||
if (this.evCache.length === 2) {
|
||||
e.preventDefault();
|
||||
const dx = this.evCache[0].clientX - this.evCache[1].clientX;
|
||||
const dy = this.evCache[0].clientY - this.evCache[1].clientY;
|
||||
const curDiff = Math.hypot(dx, dy);
|
||||
|
||||
if (this.prevDiff > 0) {
|
||||
const diff = curDiff - this.prevDiff;
|
||||
// PATCH 1: Increased Zoom Speed (0.01 -> 0.1)
|
||||
this.zoom = Math.max(0.5, Math.min(10, this.zoom + (diff * 0.1)));
|
||||
this.render();
|
||||
this.updateOverlay();
|
||||
}
|
||||
this.prevDiff = curDiff;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. PANNING (1 Finger / Mouse)
|
||||
if (this.isDragging && this.evCache.length === 1) {
|
||||
e.preventDefault();
|
||||
const dx = e.clientX - this.lastMouseX;
|
||||
const dy = e.clientY - this.lastMouseY;
|
||||
|
||||
this.lastMouseX = e.clientX;
|
||||
this.lastMouseY = e.clientY;
|
||||
|
||||
// PATCH 2: Speed Multiplier for Touch
|
||||
// Mouse stays 1:1 for precision, Touch gets 5x speed to traverse faster
|
||||
const speed = (e.pointerType === 'touch') ? 5 : 1.0;
|
||||
|
||||
this.x -= (dx * speed) / this.zoom;
|
||||
this.y -= (dy * speed) / this.zoom;
|
||||
|
||||
this.render();
|
||||
|
||||
if (this.onUpdate) {
|
||||
this.onUpdate(Math.round(this.x), Math.round(this.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlePointerUp(e) {
|
||||
const index = this.evCache.findIndex(cached => cached.pointerId === e.pointerId);
|
||||
if (index > -1) this.evCache.splice(index, 1);
|
||||
|
||||
if (this.evCache.length < 2) this.prevDiff = -1;
|
||||
|
||||
if (this.evCache.length === 0) {
|
||||
this.isDragging = false;
|
||||
if(this.canvas) this.canvas.style.cursor = 'crosshair';
|
||||
} else if (this.evCache.length === 1) {
|
||||
// Reset reference point when switching from pinch to pan
|
||||
this.lastMouseX = this.evCache[0].clientX;
|
||||
this.lastMouseY = this.evCache[0].clientY;
|
||||
}
|
||||
}
|
||||
|
||||
updateOverlay() {
|
||||
if (this.overlay) {
|
||||
this.overlay.innerText = this.zoom.toFixed(1) + 'x';
|
||||
this.overlay.style.display = this.enabled ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.enabled || !this.img) return;
|
||||
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
|
||||
const srcW = w / this.zoom;
|
||||
const srcH = h / this.zoom;
|
||||
|
||||
this.ctx.fillStyle = '#ffffff';
|
||||
this.ctx.fillRect(0, 0, w, h);
|
||||
|
||||
try {
|
||||
this.ctx.drawImage(
|
||||
this.img,
|
||||
this.x - srcW / 2,
|
||||
this.y - srcH / 2,
|
||||
srcW, srcH,
|
||||
0, 0, w, h
|
||||
);
|
||||
|
||||
// Crosshair (Red)
|
||||
const cx = Math.floor(w / 2) + 0.5;
|
||||
const cy = Math.floor(h / 2) + 0.5;
|
||||
|
||||
this.ctx.strokeStyle = '#ff0000';
|
||||
this.ctx.lineWidth = 2;
|
||||
this.ctx.lineCap = 'butt';
|
||||
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(0, cy); this.ctx.lineTo(w, cy);
|
||||
this.ctx.moveTo(cx, 0); this.ctx.lineTo(cx, h);
|
||||
this.ctx.stroke();
|
||||
|
||||
} catch (e) {
|
||||
if (e.name !== 'InvalidStateError' && e.name !== 'DOMException') {
|
||||
console.error("Spyglass Render Error:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
278
docs/stitch-editor.js
Normal file
278
docs/stitch-editor.js
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
/* stitch-editor.js */
|
||||
|
||||
// PanZoomCanvas class removed (moved to canvas-ui.js)
|
||||
|
||||
class StitchEditor {
|
||||
constructor(dbInstance, cvInstance) {
|
||||
// ... (Constructor remains the same) ...
|
||||
// It will now use the global PanZoomCanvas class
|
||||
this.db = dbInstance;
|
||||
this.cv = cvInstance;
|
||||
this.modal = document.getElementById('stitch-modal');
|
||||
this.srcId = null; this.dstId = null;
|
||||
this.points = [];
|
||||
this.colors = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#00ffff', '#ff00ff', '#ffffff', '#ff8800', '#88ff00'];
|
||||
|
||||
this.viewSrc = new PanZoomCanvas('stitch-canvas-src',
|
||||
(c, k) => this.drawPts(c, k, 's'),
|
||||
(x, y) => this.addPt(x, y, 's'),
|
||||
(x, y, m, i) => this.hit(x, y, m, i, 's')
|
||||
);
|
||||
this.viewDst = new PanZoomCanvas('stitch-canvas-dst',
|
||||
(c, k) => this.drawPts(c, k, 'd'),
|
||||
(x, y) => this.addPt(x, y, 'd'),
|
||||
(x, y, m, i) => this.hit(x, y, m, i, 'd')
|
||||
);
|
||||
this.injectFlipControls();
|
||||
}
|
||||
|
||||
injectFlipControls() {
|
||||
const toolbar = document.querySelector('.stitch-toolbar');
|
||||
if(toolbar && !document.getElementById('btn-stitch-flip')) {
|
||||
const container = document.createElement('div');
|
||||
container.style.display = 'flex';
|
||||
container.style.gap = '5px';
|
||||
container.style.marginRight = 'auto';
|
||||
const btnFlip = document.createElement('button');
|
||||
btnFlip.id = 'btn-stitch-flip';
|
||||
btnFlip.className = 'secondary';
|
||||
btnFlip.innerText = '↔ Flip Target';
|
||||
btnFlip.onclick = () => this.toggleFlip(btnFlip);
|
||||
container.appendChild(btnFlip);
|
||||
if(toolbar.firstChild) toolbar.insertBefore(container, toolbar.firstChild);
|
||||
else toolbar.appendChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
toggleFlip(btn) {
|
||||
const newVal = !this.viewDst.isMirrored;
|
||||
this.viewDst.setMirror(newVal);
|
||||
btn.style.background = newVal ? '#e0f2fe' : '';
|
||||
btn.style.borderColor = newVal ? '#2563eb' : '';
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
getGridCoords(w, h, n) {
|
||||
const coords = [];
|
||||
const factors = (n === 2) ? [0.25, 0.75] : [0.15, 0.5, 0.85];
|
||||
for(let fy of factors) {
|
||||
for(let fx of factors) {
|
||||
coords.push({ x: w * fx, y: h * fy });
|
||||
}
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
getOverlapRect(wSrc, hSrc, wDst, hDst, H) {
|
||||
const corners = [
|
||||
{x:0, y:0}, {x:wSrc, y:0}, {x:wSrc, y:hSrc}, {x:0, y:hSrc}
|
||||
].map(p => this.cv.projectPoint(p.x, p.y, H)).filter(p => p !== null);
|
||||
|
||||
if(corners.length < 4) return null;
|
||||
|
||||
const minPx = Math.min(...corners.map(p => p.x));
|
||||
const maxPx = Math.max(...corners.map(p => p.x));
|
||||
const minPy = Math.min(...corners.map(p => p.y));
|
||||
const maxPy = Math.max(...corners.map(p => p.y));
|
||||
|
||||
const iMinX = Math.max(0, minPx);
|
||||
const iMaxX = Math.min(wDst, maxPx);
|
||||
const iMinY = Math.max(0, minPy);
|
||||
const iMaxY = Math.min(hDst, maxPy);
|
||||
|
||||
if (iMinX >= iMaxX || iMinY >= iMaxY) return null;
|
||||
return { x: iMinX, y: iMinY, w: iMaxX - iMinX, h: iMaxY - iMinY };
|
||||
}
|
||||
|
||||
setGrid(n, explicitH = null, explicitInvH = null) {
|
||||
if(!this.viewSrc.bmp || !this.viewDst.bmp) return;
|
||||
let H = explicitH;
|
||||
let invH = explicitInvH;
|
||||
if (!H && this.points.length >= 4) {
|
||||
const res = this.cv.solveManual(this.points);
|
||||
if(res) { H = res.hData; invH = res.invHData; }
|
||||
}
|
||||
this.points = [];
|
||||
if (H && invH) {
|
||||
const rect = this.getOverlapRect(
|
||||
this.viewSrc.bmp.width, this.viewSrc.bmp.height,
|
||||
this.viewDst.bmp.width, this.viewDst.bmp.height,
|
||||
H
|
||||
);
|
||||
if (rect) {
|
||||
const dstGrid = this.getGridCoords(rect.w, rect.h, n).map(p => ({
|
||||
x: rect.x + p.x,
|
||||
y: rect.y + p.y
|
||||
}));
|
||||
dstGrid.forEach((dPt, i) => {
|
||||
const sPt = this.cv.projectPoint(dPt.x, dPt.y, invH);
|
||||
if (sPt && sPt.x >= 0 && sPt.y >= 0 && sPt.x <= this.viewSrc.bmp.width && sPt.y <= this.viewSrc.bmp.height) {
|
||||
this.points.push({ s: sPt, d: dPt, color: this.colors[i % this.colors.length] });
|
||||
}
|
||||
});
|
||||
if (this.points.length > 0) {
|
||||
this.refresh();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const sGrid = this.getGridCoords(this.viewSrc.bmp.width, this.viewSrc.bmp.height, n);
|
||||
const dGrid = this.getGridCoords(this.viewDst.bmp.width, this.viewDst.bmp.height, n);
|
||||
for(let i=0; i<sGrid.length; i++) {
|
||||
this.points.push({ s: sGrid[i], d: dGrid[i], color: this.colors[i % this.colors.length] });
|
||||
}
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
clearPoints() {
|
||||
this.points = [];
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
isReflection(H) {
|
||||
if (!H || H.length < 5) return false;
|
||||
const det = (H[0] * H[4]) - (H[1] * H[3]);
|
||||
return det < 0;
|
||||
}
|
||||
|
||||
async open(srcImgId, dstImgId) {
|
||||
this.srcId = srcImgId;
|
||||
this.dstId = dstImgId;
|
||||
|
||||
const i1 = await this.db.getImage(srcImgId);
|
||||
const i2 = await this.db.getImage(dstImgId);
|
||||
if(!i1 || !i2) return alert("Images not found");
|
||||
|
||||
document.getElementById('stitch-title').innerText = `Stitch: ${i1.name} ↔ ${i2.name}`;
|
||||
|
||||
const bS = await createImageBitmap(i1.blob);
|
||||
const bD = await createImageBitmap(i2.blob);
|
||||
|
||||
this.viewSrc.setImage(bS);
|
||||
this.viewDst.setImage(bD);
|
||||
|
||||
this.points = [];
|
||||
const existing = await this.db.getOverlapsForPair(srcImgId, dstImgId);
|
||||
|
||||
let shouldFlip = false;
|
||||
|
||||
if (existing) {
|
||||
if (existing.isManual && existing.manualPoints) {
|
||||
existing.manualPoints.forEach((p, i) => {
|
||||
let ptS, ptD;
|
||||
if(existing.fromImageId === this.srcId) { ptS=p.s; ptD=p.d; }
|
||||
else { ptS=p.d; ptD=p.s; }
|
||||
this.points.push({ s: {x:ptS.x, y:ptS.y}, d: {x:ptD.x, y:ptD.y}, color: this.colors[i % this.colors.length] });
|
||||
});
|
||||
if (this.points.length >= 3) {
|
||||
const tempRes = this.cv.solveManual(this.points);
|
||||
if (tempRes && this.isReflection(tempRes.hData)) {
|
||||
shouldFlip = true;
|
||||
}
|
||||
}
|
||||
} else if (!existing.isManual && existing.homography) {
|
||||
let hToUse = existing.homography;
|
||||
let invHToUse = existing.inverseHomography;
|
||||
if (existing.fromImageId !== this.srcId) {
|
||||
hToUse = existing.inverseHomography;
|
||||
invHToUse = existing.homography;
|
||||
}
|
||||
if (this.isReflection(hToUse)) {
|
||||
shouldFlip = true;
|
||||
}
|
||||
this.setGrid(3, hToUse, invHToUse);
|
||||
}
|
||||
} else {
|
||||
this.setGrid(3);
|
||||
}
|
||||
|
||||
this.viewDst.setMirror(shouldFlip);
|
||||
const flipBtn = document.getElementById('btn-stitch-flip');
|
||||
if(flipBtn) {
|
||||
flipBtn.style.background = shouldFlip ? '#e0f2fe' : '';
|
||||
flipBtn.style.borderColor = shouldFlip ? '#2563eb' : '';
|
||||
}
|
||||
|
||||
this.refresh();
|
||||
this.modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
refresh() { this.viewSrc.draw(); this.viewDst.draw(); }
|
||||
|
||||
drawPts(ctx, k, side) {
|
||||
const ik = 1/k;
|
||||
const isMirrored = (side === 'd' && this.viewDst.isMirrored);
|
||||
const width = (side === 'd' && this.viewDst.bmp) ? this.viewDst.bmp.width : 0;
|
||||
|
||||
this.points.forEach((p, idx) => {
|
||||
const pt = (side==='s') ? p.s : p.d;
|
||||
const label = (idx + 1).toString();
|
||||
let drawX = pt.x;
|
||||
if (isMirrored) drawX = width - pt.x;
|
||||
|
||||
const r=10*ik, len=20*ik, gap=2*ik;
|
||||
const path = (c) => {
|
||||
c.beginPath(); c.arc(drawX, pt.y, r, 0, Math.PI*2);
|
||||
c.moveTo(drawX-len, pt.y); c.lineTo(drawX-gap, pt.y);
|
||||
c.moveTo(drawX+gap, pt.y); c.lineTo(drawX+len, pt.y);
|
||||
c.moveTo(drawX, pt.y-len); c.lineTo(drawX, pt.y-gap);
|
||||
c.moveTo(drawX, pt.y+gap); c.lineTo(drawX, pt.y+len);
|
||||
};
|
||||
|
||||
ctx.strokeStyle='black'; ctx.lineWidth=3*ik; path(ctx); ctx.stroke();
|
||||
ctx.strokeStyle=p.color; ctx.lineWidth=1.5*ik; path(ctx); ctx.stroke();
|
||||
|
||||
ctx.font = `bold ${14*ik}px sans-serif`;
|
||||
ctx.lineWidth = 3*ik; ctx.strokeStyle='black'; ctx.strokeText(label, drawX+8*ik, pt.y-8*ik);
|
||||
ctx.fillStyle = 'white'; ctx.fillText(label, drawX+8*ik, pt.y-8*ik);
|
||||
});
|
||||
}
|
||||
|
||||
addPt(x, y, side) {
|
||||
const p = { s:{x:0,y:0}, d:{x:0,y:0}, color: this.colors[this.points.length % this.colors.length] };
|
||||
if(side==='s') { p.s={x,y}; p.d={x:this.viewDst.bmp.width/2, y:this.viewDst.bmp.height/2}; }
|
||||
else { p.d={x,y}; p.s={x:this.viewSrc.bmp.width/2, y:this.viewSrc.bmp.height/2}; }
|
||||
this.points.push(p);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
hit(x, y, mode, idx, side) {
|
||||
if(mode==='check') {
|
||||
for(let i=this.points.length-1; i>=0; i--) {
|
||||
const pt = (side==='s')?this.points[i].s:this.points[i].d;
|
||||
if(Math.hypot(x-pt.x, y-pt.y) < 20) return i;
|
||||
}
|
||||
return -1;
|
||||
} else if(mode==='move') {
|
||||
const pt = (side==='s')?this.points[idx].s:this.points[idx].d;
|
||||
pt.x+=x; pt.y+=y; this.refresh();
|
||||
} else if(mode==='delete') {
|
||||
const i=this.hit(x,y,'check',-1,side);
|
||||
if(i!==-1) { this.points.splice(i,1); this.refresh(); }
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
if(this.points.length < 4) return alert("Need at least 4 points to solve.");
|
||||
const res = this.cv.solveManual(this.points);
|
||||
if(!res) return alert("Solve failed. Points might be collinear or overlapping.");
|
||||
|
||||
await this.db.deleteOverlapsForPair(this.srcId, this.dstId);
|
||||
|
||||
await this.db.addOverlap({
|
||||
id: uuid(),
|
||||
fromImageId: this.srcId,
|
||||
toImageId: this.dstId,
|
||||
homography: res.hData,
|
||||
inverseHomography: res.invHData,
|
||||
matchCount: this.points.length,
|
||||
isManual: true,
|
||||
manualPoints: this.points.map(p => ({s:p.s, d:p.d}))
|
||||
});
|
||||
|
||||
if(typeof renderList === 'function') renderList();
|
||||
if(typeof renderConnectionsList === 'function') renderConnectionsList();
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
|
||||
1530
docs/studio.html
Normal file
1530
docs/studio.html
Normal file
File diff suppressed because it is too large
Load diff
2349
docs/studio.js
Normal file
2349
docs/studio.js
Normal file
File diff suppressed because it is too large
Load diff
163
docs/tool-bridge.js
Normal file
163
docs/tool-bridge.js
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* tool-bridge.js
|
||||
* Common logic for connecting Component Tools (iframe) to the main Application (studio.js).
|
||||
* Handles: PostMessage, Spyglass Views, Promotion, and Dragging.
|
||||
*/
|
||||
class ToolBridge {
|
||||
constructor(config) {
|
||||
this.cfg = config;
|
||||
this.availableViews = [];
|
||||
this.currentViewIdx = 0;
|
||||
this.spyglass = null;
|
||||
this.pendingViewInit = null; // Buffer for race condition fix
|
||||
|
||||
// Initialize Spyglass
|
||||
const initSpyglass = () => {
|
||||
const canvas = document.getElementById('tool-canvas');
|
||||
if (typeof PcbSpyglass !== 'undefined' && canvas) {
|
||||
this.spyglass = new PcbSpyglass('tool-canvas', 'tool-zoom', (x, y) => this.handleDrag(x, y));
|
||||
|
||||
// If we received data before spyglass was ready, apply it now
|
||||
if (this.pendingViewInit !== null) {
|
||||
this.selectView(this.pendingViewInit);
|
||||
this.pendingViewInit = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initSpyglass);
|
||||
} else {
|
||||
// If already loaded, init immediately (microtask)
|
||||
setTimeout(initSpyglass, 0);
|
||||
}
|
||||
|
||||
window.addEventListener('message', this.handleMessage.bind(this));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
window.parent.postMessage({ type: 'ESC_PRESSED' }, '*');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleMessage(event) {
|
||||
if (!event.data || event.data.type !== 'INIT_TOOL') return;
|
||||
const d = event.data;
|
||||
|
||||
// 1. Pass Data to Tool
|
||||
if (this.cfg.onData) {
|
||||
this.cfg.onData({ value: d.value, description: d.description });
|
||||
}
|
||||
|
||||
// 2. Handle Views
|
||||
if (d.views && d.views.length > 0) {
|
||||
this.availableViews = d.views;
|
||||
this.renderViewSelector();
|
||||
|
||||
const mainIdx = this.availableViews.findIndex(v => v.isMain);
|
||||
const targetIdx = mainIdx !== -1 ? mainIdx : 0;
|
||||
|
||||
this.toggleSpyglass(true);
|
||||
|
||||
// Check if spyglass is ready
|
||||
if (this.spyglass) {
|
||||
this.selectView(targetIdx);
|
||||
} else {
|
||||
// Buffer it for later
|
||||
this.pendingViewInit = targetIdx;
|
||||
}
|
||||
} else if (d.bitmap) {
|
||||
// Legacy support
|
||||
this.availableViews = [{ bitmap: d.bitmap, x: d.x, y: d.y, name: 'Main', isMain: true }];
|
||||
this.renderViewSelector();
|
||||
this.toggleSpyglass(true);
|
||||
|
||||
if (this.spyglass) this.selectView(0);
|
||||
else this.pendingViewInit = 0;
|
||||
} else {
|
||||
this.toggleSpyglass(false);
|
||||
}
|
||||
}
|
||||
|
||||
toggleSpyglass(show) {
|
||||
const el = document.getElementById('spyglass-col');
|
||||
if (el) {
|
||||
if (show) el.classList.add('active');
|
||||
else {
|
||||
el.classList.remove('active');
|
||||
if (this.spyglass) this.spyglass.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderViewSelector() {
|
||||
const container = document.getElementById('view-selector');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
if (this.availableViews.length <= 1) return;
|
||||
|
||||
this.availableViews.forEach((v, idx) => {
|
||||
const thumb = document.createElement('div');
|
||||
thumb.className = 'view-thumb';
|
||||
if (v.isMain) thumb.style.borderColor = 'var(--accent, #2563eb)';
|
||||
|
||||
const cvs = document.createElement('canvas');
|
||||
cvs.width = 100; cvs.height = 100;
|
||||
const ctx = cvs.getContext('2d');
|
||||
try {
|
||||
const sSize = 200;
|
||||
const sx = Math.max(0, v.x - sSize/2);
|
||||
const sy = Math.max(0, v.y - sSize/2);
|
||||
ctx.drawImage(v.bitmap, sx, sy, sSize, sSize, 0, 0, 100, 100);
|
||||
} catch(e) {}
|
||||
|
||||
thumb.innerHTML = `<span>${v.name}</span>`;
|
||||
thumb.appendChild(cvs);
|
||||
|
||||
if (!v.isMain) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'promote-btn';
|
||||
btn.innerText = '★';
|
||||
btn.onclick = (e) => { e.stopPropagation(); this.promoteView(idx); };
|
||||
thumb.appendChild(btn);
|
||||
}
|
||||
|
||||
thumb.onclick = () => {
|
||||
const all = container.querySelectorAll('.view-thumb');
|
||||
all.forEach(t => t.style.borderColor = '#ccc');
|
||||
thumb.style.borderColor = 'var(--accent, #2563eb)';
|
||||
this.selectView(idx);
|
||||
};
|
||||
container.appendChild(thumb);
|
||||
});
|
||||
}
|
||||
|
||||
selectView(idx) {
|
||||
this.currentViewIdx = idx;
|
||||
const v = this.availableViews[idx];
|
||||
if (this.spyglass && v && v.bitmap) {
|
||||
this.spyglass.setTarget(v.bitmap, v.x, v.y, false);
|
||||
}
|
||||
}
|
||||
|
||||
promoteView(idx) {
|
||||
const v = this.availableViews[idx];
|
||||
window.parent.postMessage({ type: 'PROMOTE_VIEW', x: v.x, y: v.y, imgId: v.imgId }, '*');
|
||||
}
|
||||
|
||||
handleDrag(x, y) {
|
||||
if (this.availableViews[this.currentViewIdx]) {
|
||||
this.availableViews[this.currentViewIdx].x = x;
|
||||
this.availableViews[this.currentViewIdx].y = y;
|
||||
}
|
||||
if (this.availableViews[this.currentViewIdx].isMain) {
|
||||
window.parent.postMessage({ type: 'UPDATE_POS', x: x, y: y }, '*');
|
||||
}
|
||||
}
|
||||
|
||||
sendResult(valueOverride = null, descOverride = null) {
|
||||
const val = valueOverride || (this.cfg.onNominal ? this.cfg.onNominal() : null);
|
||||
const desc = descOverride || (this.cfg.onDescription ? this.cfg.onDescription() : "");
|
||||
if (val) window.parent.postMessage({ type: 'COMPONENT_UPDATE', value: val, description: desc }, '*');
|
||||
}
|
||||
}
|
||||
336
docs/tool-style.css
Normal file
336
docs/tool-style.css
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
/* tool-style.css - Shared styles for Component Editors */
|
||||
@import url('common.css');
|
||||
|
||||
:root {
|
||||
--accent: #2563eb;
|
||||
--text: #333;
|
||||
--bg: #f4f4f9;
|
||||
--card: #ffffff;
|
||||
--border: #ccc;
|
||||
--focus-ring: rgba(37, 99, 235, 0.2);
|
||||
--resistor-body: #82cae0;
|
||||
--resistor-shadow: #5ea8bd;
|
||||
}
|
||||
|
||||
/* --- CORE LAYOUT --- */
|
||||
html { font-size: 16px; box-sizing: border-box; height: 100%; overflow: hidden; }
|
||||
*, *:before, *:after { box-sizing: inherit; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
margin: 0; padding: 0;
|
||||
height: 100%; width: 100%;
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: var(--card); padding: 1.5rem; border-radius: 1rem;
|
||||
box-shadow: 0 0.5rem 2rem rgba(0,0,0,0.05);
|
||||
width: 95%; max-width: 1000px; height: 95%; max-height: 95%;
|
||||
margin: 1rem; display: flex; flex-direction: column;
|
||||
overflow-y: auto; scrollbar-width: thin; gap: 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
h1 { margin: 0; font-size: 1.5rem; text-align: center; flex-shrink: 0; }
|
||||
|
||||
/* --- TOP SECTION --- */
|
||||
.top-section {
|
||||
display: flex; gap: 1rem; width: 100%;
|
||||
align-items: stretch; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Spyglass */
|
||||
.col-spyglass {
|
||||
flex: 0 0 auto;
|
||||
display: none;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.col-spyglass.active { display: flex; }
|
||||
|
||||
.spyglass-wrapper {
|
||||
width: 200px; height: 200px;
|
||||
border-radius: 0.5rem; border: 1px solid var(--border);
|
||||
padding: 0.5rem; position: relative; background: rgba(0,0,0,0.02);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.spyglass-canvas {
|
||||
width: 100%; height: 100%; object-fit: contain;
|
||||
cursor: crosshair; display: block; background: #fff; border-radius: 0.25rem;
|
||||
}
|
||||
.spyglass-hint { display: none; }
|
||||
.zoom-badge {
|
||||
position: absolute; bottom: 0.5rem; right: 0.5rem;
|
||||
background: rgba(0,0,0,0.6); color: white;
|
||||
font-size: 0.7rem; padding: 2px 6px;
|
||||
border-radius: 4px; pointer-events: none;
|
||||
}
|
||||
|
||||
/* Thumbnails */
|
||||
#view-selector {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
width: 72px; height: 200px;
|
||||
overflow-y: auto; overflow-x: hidden;
|
||||
padding-bottom: 0; margin-top: 0;
|
||||
border-left: 1px solid var(--border); padding-left: 4px;
|
||||
}
|
||||
.view-thumb { width: 60px; height: 60px; border: 2px solid #ccc; border-radius: 4px; cursor: pointer; overflow: hidden; position: relative; flex-shrink: 0; }
|
||||
.view-thumb.active { border-color: var(--accent); box-shadow: 0 0 0 2px var(--focus-ring); }
|
||||
.view-thumb canvas { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
|
||||
.view-thumb span { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.6); color: white; font-size: 8px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.view-thumb .promote-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
position: absolute; top: 0; right: 0;
|
||||
background: #f59e0b; color: white; border: none;
|
||||
width: 18px; height: 18px; font-size: 12px; line-height: 1;
|
||||
cursor: pointer; z-index: 10;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.col-controls {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
justify-content: center; gap: 1rem;
|
||||
max-width: 600px; margin: 0 auto; width: 100%;
|
||||
}
|
||||
.top-section:has(.col-spyglass.active) .col-controls { margin: 0; }
|
||||
|
||||
/* INPUTS */
|
||||
.reverse-input-group { display: flex; flex-direction: column; gap: 0.5rem; width: 100%; }
|
||||
.input-row { display: flex; gap: 0.5rem; width: 100%; }
|
||||
.inp-nominal { padding: 0.6rem 0.8rem; border: 1px solid var(--border); border-radius: 0.5rem; font-size: 1rem; flex: 1; min-width: 0; }
|
||||
.btn-action { padding: 0.6rem 1rem; background: var(--accent); color: white; border: none; border-radius: 0.5rem; font-weight: 600; cursor: pointer; white-space: nowrap; flex-shrink: 0; }
|
||||
|
||||
.input-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; width: 100%; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
|
||||
.input-wrapper { position: relative; width: 100%; }
|
||||
.input-wrapper input { padding-right: 2.5rem !important; }
|
||||
.unit { position: absolute; right: 0.8rem; top: 50%; transform: translateY(-50%); color: #9ca3af; font-size: 0.9rem; pointer-events: none; }
|
||||
|
||||
label { font-size: 0.8rem; font-weight: 700; text-transform: uppercase; opacity: 0.8; }
|
||||
input {
|
||||
width: 100%; padding: 0.8rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1.1rem; font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
background: #fff; color: inherit; box-sizing: border-box;
|
||||
}
|
||||
input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus-ring); }
|
||||
|
||||
.controls-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); gap: 0.5rem; width: 100%; }
|
||||
.btn-mode { padding: 0.6rem; border: 1px solid #e0e0e0; background: #fff; cursor: pointer; border-radius: 0.5rem; font-weight: 600; font-size: 0.9rem; color: #555; text-align: center; white-space: nowrap; }
|
||||
.btn-mode.active { background: var(--accent); color: white; border-color: var(--accent); }
|
||||
|
||||
/* --- COMPONENT GRAPHIC --- */
|
||||
.resistor-wrapper-outer {
|
||||
position: relative; width: 100%; height: 9rem; flex-shrink: 0;
|
||||
display: flex; justify-content: center; align-items: center; z-index: 0; margin: 0;
|
||||
}
|
||||
.resistor-wrapper-outer::before {
|
||||
content: ''; position: absolute; top: 50%; left: 0; width: 100%; height: 0.6rem;
|
||||
transform: translateY(-50%); background: #9da5b4;
|
||||
background-image: linear-gradient(to bottom, #d0d6e0, #8a92a0, #d0d6e0); z-index: -1;
|
||||
}
|
||||
.resistor-dumbbell-shape {
|
||||
display: inline-flex; align-items: center; justify-content: center; position: relative; z-index: 10;
|
||||
transform: scale(0.9); transform-origin: center;
|
||||
}
|
||||
.resistor-dumbbell-shape::before, .resistor-dumbbell-shape::after {
|
||||
content: ''; display: block; width: 3.5rem; height: 8rem;
|
||||
background-color: var(--resistor-body); border-radius: 1.5rem; flex-shrink: 0;
|
||||
background-image: linear-gradient(to right, rgba(255,255,255,0.3) 0%, rgba(255,255,255,0) 20%, rgba(0,0,0,0) 80%, rgba(0,0,0,0.1) 100%), linear-gradient(to bottom, rgba(255,255,255,0.5) 0%, rgba(0,0,0,0) 40%, rgba(0,0,0,0.3) 100%);
|
||||
box-shadow: inset -2px 0 5px rgba(0,0,0,0.1), 0 0.8rem 1.2rem rgba(0,0,0,0.15); border: 1px solid var(--resistor-shadow);
|
||||
}
|
||||
.resistor-dumbbell-shape::before { border-top-right-radius: 0.6rem; border-bottom-right-radius: 0.6rem; margin-right: -0.4rem; }
|
||||
.resistor-dumbbell-shape::after { border-top-left-radius: 0.6rem; border-bottom-left-radius: 0.6rem; margin-left: -0.4rem; }
|
||||
|
||||
.bands-container {
|
||||
display: inline-flex; align-items: stretch; justify-content: center; height: 6rem;
|
||||
background-color: var(--resistor-body);
|
||||
box-shadow: inset 0 0.8rem 0.8rem -0.2rem rgba(0,0,0,0.15), inset 0 -0.8rem 0.8rem -0.2rem rgba(0,0,0,0.2);
|
||||
padding: 0 0.5rem; gap: 0.5rem; z-index: 5;
|
||||
}
|
||||
|
||||
/* --- COLOR CLASSES (Shared) --- */
|
||||
.c-bk { background-color: #000; color: #fff !important; }
|
||||
.c-bn { background-color: #5d4037; color: #fff !important; }
|
||||
.c-rd { background-color: #d32f2f; color: #fff !important; }
|
||||
.c-og { background-color: #f57c00; color: #000 !important; }
|
||||
.c-ye { background-color: #ffeb3b; color: #000 !important; }
|
||||
.c-gn { background-color: #2e7d32; color: #fff !important; }
|
||||
.c-bu { background-color: #1565c0; color: #fff !important; }
|
||||
.c-vt { background-color: #7b1fa2; color: #fff !important; }
|
||||
.c-gy { background-color: #9e9e9e; color: #000 !important; }
|
||||
.c-wh { background-color: #fff; border: 1px solid #ddd; color: #000 !important; }
|
||||
|
||||
/* Metallic (Horizontal Gradient for Dropdowns) */
|
||||
.c-gd { background: linear-gradient(to right, #fbf0b5 0%, #c49a00 20%, #fbf0b5 40%, #b68f00 60%, #fbf0b5 80%); color: #000 !important; }
|
||||
.c-sv { background: linear-gradient(to right, #fff 0%, #9e9e9e 20%, #fff 40%, #808080 60%, #e8e8e8 80%); color: #000 !important; }
|
||||
|
||||
/* Metallic Overrides (Vertical/Top Gradient for Component Bands) */
|
||||
.custom-select__trigger.c-gd { background-image: linear-gradient(to top, #fbf0b5 0%, #c49a00 20%, #fbf0b5 40%, #b68f00 60%, #fbf0b5 80%) !important; }
|
||||
.custom-select__trigger.c-sv { background-image: linear-gradient(to top, #ffffff 0%, #9e9e9e 20%, #ffffff 40%, #808080 60%, #e8e8e8 80%) !important; }
|
||||
|
||||
/* Color Text Contrast Helper */
|
||||
.c-ye span, .c-wh span, .c-gd span, .c-sv span, .c-og span, .c-gy span { color: #222 !important; text-shadow: 0 1px 1px rgba(255,255,255,0.5); }
|
||||
|
||||
/* --- DROPDOWN --- */
|
||||
.custom-select-wrapper { position: relative; width: 2.8rem; height: 100%; }
|
||||
.custom-select__trigger {
|
||||
position: relative; display: flex; align-items: center; justify-content: center; height: 100%;
|
||||
cursor: pointer; transition: transform 0.1s;
|
||||
background-image: linear-gradient(to bottom, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0) 40%, rgba(0,0,0,0.2) 100%);
|
||||
box-shadow: inset 1px 0 2px rgba(0,0,0,0.2), inset -1px 0 2px rgba(0,0,0,0.2);
|
||||
}
|
||||
.custom-select__trigger:active { transform: scale(0.95); }
|
||||
.custom-select__trigger span {
|
||||
writing-mode: vertical-lr; text-orientation: mixed; transform: rotate(180deg); color: #fff;
|
||||
font-weight: 700; font-size: 0.9rem; text-shadow: 0 1px 2px rgba(0,0,0,0.5); white-space: nowrap; pointer-events: none;
|
||||
}
|
||||
|
||||
#global-dropdown {
|
||||
position: fixed; top: 0; left: 0; width: 12rem; background: #fff;
|
||||
border: 1px solid #ccc; border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.4);
|
||||
z-index: 99999; display: none; flex-direction: column;
|
||||
max-height: 90vh; overflow-y: auto;
|
||||
}
|
||||
.custom-option { padding: 0.8rem 1rem; font-size: 1rem; font-weight: 600; cursor: pointer; text-align: center; border-bottom: 1px solid rgba(0,0,0,0.05); }
|
||||
.custom-option:hover { filter: brightness(0.95); }
|
||||
|
||||
#global-dropdown.condensed .custom-option { padding: 0.5rem 0.5rem; font-size: 0.9rem; }
|
||||
#global-dropdown.super-condensed .custom-option { padding: 0.25rem 0.2rem; font-size: 0.85rem; }
|
||||
|
||||
/* --- RESULTS --- */
|
||||
.result-box { width: 100%; display: flex; flex-direction: column; gap: 0.5rem; flex-shrink: 0; padding-bottom: 0.5rem; }
|
||||
.result-row { display: flex; align-items: center; padding: 0.8rem; background: #fff; border: 1px solid #eee; border-radius: 0.8rem; box-shadow: 0 2px 5px rgba(0,0,0,0.03); gap: 1rem; }
|
||||
.best-match { background: #f8fbff; border: 1px solid #cce5ff; border-left: 0.4rem solid var(--accent); }
|
||||
.val-group { display: flex; flex-direction: column; gap: 0.2rem; flex: 1; }
|
||||
.val-text { font-size: 1.4rem; font-weight: 800; color: #222; }
|
||||
.meta-text { color: #666; font-size: 0.9rem; font-weight: 500;}
|
||||
.match-badge { background: #e0f2fe; color: var(--accent); padding: 0.4rem 1rem; border-radius: 2rem; font-size: 0.8rem; font-weight: 700; white-space: nowrap;}
|
||||
.badge-success { background: #dcfce7; color: #166534; padding: 0.4rem 1rem; border-radius: 2rem; font-size: 0.8rem; font-weight: 700; white-space: nowrap;}
|
||||
|
||||
.direction-icon { width: 2.2rem; height: 2.2rem; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; background: #eee; color: #666; }
|
||||
.direction-icon svg { width: 1rem; height: 1rem; fill: currentColor; }
|
||||
.best-match .direction-icon { background: var(--accent); color: #fff; }
|
||||
.direction-icon.reverse { background: #f59e0b; color: #fff; }
|
||||
|
||||
.btn-select { background: #059669; color: white; border: none; padding: 0.5rem 1rem; border-radius: 0.4rem; cursor: pointer; font-weight: 700; white-space: nowrap; margin-left: auto; }
|
||||
.btn-select:hover { background: #047857; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
/* 1. Global Setup */
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0; padding: 0;
|
||||
}
|
||||
|
||||
/* 2. Container: Fill the parent iframe completely */
|
||||
.container {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0; /* Parent handles radius */
|
||||
box-shadow: none; /* Parent handles shadow */
|
||||
border: none;
|
||||
|
||||
padding: 0.25rem;
|
||||
gap: 0.25rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 3. Header: Hidden inside tool */
|
||||
h1 { display: none; }
|
||||
|
||||
/* 4. Top Section */
|
||||
.top-section {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.col-controls {
|
||||
width: 100%;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* 5. Inputs & Buttons */
|
||||
input {
|
||||
padding: 0 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
height: 2rem;
|
||||
}
|
||||
.unit { font-size: 0.7rem; right: 0.25rem; }
|
||||
|
||||
.btn-action, .btn-select {
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
height: 2rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* BAND BUTTONS: Single Line Forced */
|
||||
.controls-grid {
|
||||
display: flex !important;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.btn-mode {
|
||||
flex: 1;
|
||||
padding: 0 !important;
|
||||
font-size: 0.75rem;
|
||||
height: 2rem;
|
||||
line-height: 2rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.input-grid { gap: 0.25rem; }
|
||||
.form-group { gap: 0; margin-bottom: 0; }
|
||||
label { font-size: 0.7rem; margin: 0; line-height: 1.2; }
|
||||
|
||||
/* 6. Graphic */
|
||||
.resistor-wrapper-outer {
|
||||
height: 5rem;
|
||||
min-height: 5rem;
|
||||
margin: 0.125rem 0;
|
||||
overflow: visible;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.resistor-dumbbell-shape {
|
||||
transform: scale(0.65);
|
||||
transform-origin: center center;
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
}
|
||||
.bands-container { gap: 0.125rem; }
|
||||
|
||||
/* 7. Results */
|
||||
.result-row {
|
||||
padding: 0.25rem 0.5rem;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
.val-text { font-size: 1.1rem; }
|
||||
.meta-text { font-size: 0.75rem; }
|
||||
|
||||
/* 8. Dropdown Fix */
|
||||
.col-controls .custom-select-wrapper {
|
||||
height: 2rem;
|
||||
}
|
||||
.bands-container .custom-select-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
.custom-select__trigger span { font-size: 0.75rem; }
|
||||
}
|
||||
Loading…
Reference in a new issue