PCB ReTrace initial

This commit is contained in:
Taras Greben 2025-12-21 00:31:26 +02:00
commit f06b8a68b8
20 changed files with 7089 additions and 0 deletions

38
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 &copy; 2025 Taras Greben.
</footer>
</div>
</body>
</html>

153
docs/index.html Normal file
View 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
View 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
View 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
View 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
View 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)+' '; 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
View 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
View 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

File diff suppressed because it is too large Load diff

2349
docs/studio.js Normal file

File diff suppressed because it is too large Load diff

163
docs/tool-bridge.js Normal file
View 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
View 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; }
}