From ca9fe52db5b863b49e09ae0ab7eb9cc839abf94a Mon Sep 17 00:00:00 2001 From: Taras Greben Date: Sun, 21 Dec 2025 18:45:40 +0200 Subject: [PATCH] Fixed manual image stitching and Inspector view for mobiles, other minor fixes/improvements. --- docs/canvas-ui.js | 201 +++++++++++------- docs/guide.html | 26 +-- docs/index.html | 24 +-- docs/inductor.html | 2 +- docs/inspector.js | 483 ++++++++++++++++++++---------------------- docs/nets.js | 170 ++++++++------- docs/resistor.html | 2 +- docs/stitch-editor.js | 48 ++--- docs/studio.html | 100 +++++---- docs/studio.js | 315 +++++++++++++++++++-------- 10 files changed, 778 insertions(+), 593 deletions(-) diff --git a/docs/canvas-ui.js b/docs/canvas-ui.js index c824718..3ae4cd3 100644 --- a/docs/canvas-ui.js +++ b/docs/canvas-ui.js @@ -1,4 +1,4 @@ -/* canvas-ui.js - Shared Canvas Logic */ +/* canvas-ui.js - Shared Canvas Logic (Mobile/Touch Ready) */ class PanZoomCanvas { constructor(id, onDraw, onClick, onDragPt) { @@ -6,18 +6,23 @@ class PanZoomCanvas { 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.t = {x:0, y:0, k:1}; + this.lm = {x:0, y:0}; this.activePtIdx = -1; this.isMirrored = false; - - this.onDraw = onDraw; - this.onClick = onClick; + + this.onDraw = onDraw; + this.onClick = onClick; this.onDragPt = onDragPt; - this.onMouseMove = null; + this.onMouseMove = null; + this.onPointerDown = null; // New Hook + + this.evCache = []; + this.prevDiff = -1; + this.isDragging = false; + this.totalDragDist = 0; if(this.container) { new ResizeObserver(() => { @@ -32,71 +37,125 @@ class PanZoomCanvas { } initEvents() { - this.canvas.onwheel = e => { + this.canvas.style.touchAction = 'none'; + + this.canvas.addEventListener('wheel', 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.zoomAt(e.clientX, e.clientY, f); + }, { passive: false }); - 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.addEventListener('pointerdown', e => this.handlePointerDown(e)); + this.canvas.addEventListener('pointermove', e => this.handlePointerMove(e)); + this.canvas.addEventListener('pointerup', e => this.handlePointerUp(e)); + this.canvas.addEventListener('pointercancel', e => this.handlePointerUp(e)); + this.canvas.addEventListener('pointerout', e => this.handlePointerUp(e)); + this.canvas.addEventListener('pointerleave', e => this.handlePointerUp(e)); - this.canvas.oncontextmenu = e => { - e.preventDefault(); + this.canvas.addEventListener('contextmenu', e => { 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(); + zoomAt(clientX, clientY, factor) { + const r = this.canvas.getBoundingClientRect(); + const mx = clientX - r.left; + const my = clientY - r.top; + const wx = (mx - this.t.x) / this.t.k; + const wy = (my - this.t.y) / this.t.k; + this.t.k = Math.max(0.01, Math.min(20, this.t.k * factor)); + this.t.x = mx - wx * this.t.k; + this.t.y = my - wy * this.t.k; + this.draw(); + } + + handlePointerDown(e) { + this.canvas.setPointerCapture(e.pointerId); + this.evCache.push(e); + this.totalDragDist = 0; + + // Trigger Hook if defined (Before internal logic) + if (this.onPointerDown) this.onPointerDown(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.isDragging = true; + this.lm = { x: e.clientX, y: e.clientY }; + return; + } + } + + this.isDragging = true; + this.lm = { x: e.clientX, y: e.clientY }; + this.activePtIdx = -1; + } + + handlePointerMove(e) { + const index = this.evCache.findIndex(cached => cached.pointerId === e.pointerId); + if (index > -1) this.evCache[index] = e; + + if (this.evCache.length === 2) { + // Pinch Zoom + 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 factor = 1 + ((curDiff - this.prevDiff) * 0.01); + const cx = (this.evCache[0].clientX + this.evCache[1].clientX) / 2; + const cy = (this.evCache[0].clientY + this.evCache[1].clientY) / 2; + this.zoomAt(cx, cy, factor); + } + this.prevDiff = curDiff; + this.totalDragDist += 20; // Ensure pinch doesn't trigger click + return; + } + + if (this.isDragging && this.evCache.length === 1) { + const dx = e.clientX - this.lm.x; + const dy = e.clientY - this.lm.y; + + this.totalDragDist += Math.hypot(dx, dy); + + if (this.activePtIdx !== -1) { + const curr = this.getImgCoords(e.clientX, e.clientY); + const prev = this.getImgCoords(this.lm.x, this.lm.y); + this.onDragPt(curr.x - prev.x, curr.y - prev.y, 'move', this.activePtIdx); + } else { + this.t.x += dx; + this.t.y += dy; + this.draw(); + } + this.lm = { x: e.clientX, y: e.clientY }; + } + + if(this.onMouseMove) { + const coords = this.getImgCoords(e.clientX, e.clientY); + this.onMouseMove(coords.x, coords.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) { + // Threshold increased to 20px for mobile tap tolerance + if (this.totalDragDist < 20) { + if (this.activePtIdx === -1 && this.onClick) { + const coords = this.getImgCoords(e.clientX, e.clientY); + this.onClick(coords.x, coords.y, e); } } - 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.isDragging = false; this.activePtIdx = -1; - }); + } } getImgCoords(screenX, screenY) { @@ -109,40 +168,28 @@ class PanZoomCanvas { 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"; + this.canvas.style.filter = isDimmed ? "brightness(0.4) grayscale(100%)" : "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) { diff --git a/docs/guide.html b/docs/guide.html index b1b6b7b..4457e0d 100644 --- a/docs/guide.html +++ b/docs/guide.html @@ -20,35 +20,35 @@ 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); + .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; } diff --git a/docs/index.html b/docs/index.html index c244f8d..340f82d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -15,7 +15,7 @@ html, body { height: auto !important; overflow-y: auto !important; /* Force scrollbar */ - display: block !important; /* Disable app flex layout */ + display: block !important; /* Disable app flex layout */ margin: 0; padding: 0; background: white; @@ -23,7 +23,7 @@ } :root { --hero-bg: #f8fafc; --text-main: #0f172a; --text-sub: #475569; --primary: #2563eb; } - + /* HERO SECTION */ .hero { padding: 4rem 1rem 3rem; @@ -33,7 +33,7 @@ } .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; @@ -63,10 +63,10 @@ .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; + .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; } @@ -75,10 +75,10 @@ .tool-link span { font-size: 0.8rem; color: #64748b; } footer.landing-footer { - margin-top: 0; - padding: 2rem 1rem; - text-align: center; - background: white; + margin-top: 0; + padding: 2rem 1rem; + text-align: center; + background: white; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 0.8rem; @@ -97,9 +97,9 @@

PCB ReTrace

Digitize, document, and reverse engineer printed circuit boards. Map components, trace nets, and inspect layers. Runs entirely in your browser.

- + Launch Studio - +
- +
diff --git a/docs/inspector.js b/docs/inspector.js index 7d200bd..5dad75b 100644 --- a/docs/inspector.js +++ b/docs/inspector.js @@ -7,21 +7,21 @@ class Inspector { 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.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'; + + // 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(); @@ -35,17 +35,17 @@ class Inspector { 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); @@ -71,6 +71,15 @@ class Inspector { } async renderGrid() { + // --- 1. Snapshot State (Zoom/Pan) --- + const savedStates = {}; + if (this.viewers) { + Object.entries(this.viewers).forEach(([id, v]) => { + // We clone the object to ensure safety, though not strictly required + if (v.t) savedStates[id] = { ...v.t }; + }); + } + this.grid.innerHTML = ''; this.viewers = {}; @@ -79,7 +88,7 @@ class Inspector { return; } - // [FIX] Force Grid Layout Styles via JS to ensure they apply + // Grid Layout this.grid.style.display = 'grid'; this.grid.style.width = '100%'; this.grid.style.height = '100%'; @@ -88,8 +97,15 @@ class Inspector { 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'; + const isPortrait = window.innerHeight > window.innerWidth; + + if (isPortrait) { + this.grid.style.gridTemplateColumns = '1fr'; + this.grid.style.gridTemplateRows = `repeat(${count}, 1fr)`; + } else { + 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; @@ -101,7 +117,7 @@ class Inspector { 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'; @@ -114,89 +130,68 @@ class Inspector { this.grid.appendChild(cell); + let wasActiveBeforeDown = false; + const viewer = new PanZoomCanvas(cvs.id, (ctx, k) => this.drawOverlay(id, ctx, k), - (x, y) => this.handleNodeClick(id, x, y), + + async (x, y, e) => { + if (e.button !== 0) return; + + if (wasActiveBeforeDown) { + const hit = await this.handleNodeClick(id, x, y); + if (!hit) { + await this.handleAddNode(id, x, y); + } + } + }, (dx, dy, mode) => { - // Hit Test: Return -1 to allow Panning - if(mode === 'check') return -1; + if(mode === 'check') return -1; } ); - + + viewer.onPointerDown = (e) => { + if (e.isPrimary || e.button === 0) { + wasActiveBeforeDown = (this.masterId === id); + + if (this.masterId !== id) { + this.masterId = id; + } + const pt = viewer.getImgCoords(e.clientX, e.clientY); + this.syncCursors(id, pt.x, pt.y); + } + }; + 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); - } - } - } - }); + cvs.addEventListener('contextmenu', (e) => { + e.preventDefault(); e.stopPropagation(); + this.masterId = null; + this.cursorState = null; + Object.values(this.viewers).forEach(v => { + v.cursorPos = null; v.setDimmed(false); v.draw(); + }); + }); 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(); + + // --- 2. Restore State or Fit --- + if (savedStates[id]) { + viewer.t = savedStates[id]; + viewer.draw(); // Force redraw with restored transform + } else { + viewer.fit(); + } if(imgRec.name.toLowerCase().includes('bot') && !imgRec.name.toLowerCase().includes('top')) { viewer.setMirror(true); @@ -206,38 +201,38 @@ class Inspector { this.updateNetNodeCache(); } - handleNodeClick(imgId, x, y) { - if (!this.activeNet || !this.netNodeCache[imgId]) return false; - const viewer = this.viewers[imgId]; - if (!viewer) return false; + async 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_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; + }); - 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) { + // Use generic input with a special Delete button + const res = await requestInput("Edit Node", "Node Name", hit.label, { + extraBtn: { label: 'Delete', value: '__DELETE__', class: 'danger' } + }); - 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 (res === '__DELETE__') { + const idx = this.activeNet.nodes.indexOf(hit.origNode); + if (idx > -1) this.activeNet.nodes.splice(idx, 1); + } else if (res) { + hit.origNode.label = res; + } - 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 - } + if (res) { + this.updateNetUI(); + Object.values(this.viewers).forEach(v => v.draw()); + } + return true; + } + return false; + } toggleLayer(id, isVisible) { if(isVisible) this.visibleIds.add(id); @@ -245,22 +240,22 @@ class Inspector { 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()); - } - } + // 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 = {}; @@ -274,21 +269,21 @@ class Inspector { // 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 + 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, + x: proj.x, y: proj.y, label: node.label, color: '#4ade80', isSource: false, origNode: node }); } @@ -301,7 +296,7 @@ class Inspector { 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); @@ -316,18 +311,18 @@ class Inspector { 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; @@ -362,90 +357,90 @@ class Inspector { } } - drawOverlay(id, ctx, k) { - const viewer = this.viewers[id]; - if (!viewer) return; + 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; + const ik = 1/k; // Inverse zoom factor - ctx.save(); - ctx.translate(drawX, n.y); - ctx.scale(ik, ik); // Keep pin constant size on screen + // 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; - // 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); + ctx.save(); + ctx.translate(drawX, n.y); + ctx.scale(ik, ik); // Keep pin constant size on screen - // 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) + // 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); - ctx.beginPath(); - ctx.moveTo(0, 0); // Tip starts exactly on the node coordinate + // 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. - // Left Edge (going 'Up' in local coords) -> Top-Left Corner - ctx.lineTo(0, -s + r); - ctx.arcTo(0, -s, s, -s, r); + const s = 20; // Size of the square side + const r = 10; // Radius (50% of size) - // Top Edge -> Top-Right Corner - ctx.lineTo(s - r, -s); - ctx.arcTo(s, -s, s, 0, r); + ctx.beginPath(); + ctx.moveTo(0, 0); // Tip starts exactly on the node coordinate - // Right Edge -> Bottom-Right Corner - ctx.lineTo(s, -r); - ctx.arcTo(s, 0, 0, 0, r); + // Left Edge (going 'Up' in local coords) -> Top-Left Corner + ctx.lineTo(0, -s + r); + ctx.arcTo(0, -s, s, -s, 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(); + // Top Edge -> Top-Right Corner + ctx.lineTo(s - r, -s); + ctx.arcTo(s, -s, s, 0, r); - // 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); + // Right Edge -> Bottom-Right Corner + ctx.lineTo(s, -r); + ctx.arcTo(s, 0, 0, 0, r); - ctx.fillStyle = 'white'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.font = 'bold 9px sans-serif'; - ctx.fillText(n.label, 0, 0); - - ctx.restore(); - }); - } + // 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'; + color = '#facc15'; } if(cx !== undefined) { @@ -464,76 +459,62 @@ class Inspector { this.updateNetUI(); } - handleAddNode(imgId, x, y) { - // Determine default label (start at P1 if no net exists) + async handleAddNode(imgId, x, y) { 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(); - } + const label = await requestInput("Add Node", "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 + 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 - } - } + async saveNet() { + if(!this.activeNet) return; - this.activeNet.projectId = currentBomId; - await this.db.addNet(this.activeNet); - - this.activeNet = null; - this.updateNetUI(); - - if(window.netManager) window.netManager.render(); + // Only prompt for name if it's a new net + if (this.activeNet.isNew) { + const name = await requestInput("Save Net", "Net Name", this.activeNet.name); + if(name) { + this.activeNet.name = name; + delete this.activeNet.isNew; + } else { + return; + } + } + 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(); + } - 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;"; - 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 = ` - ${this.activeNet.name} - ${this.activeNet.nodes.length} - - - `; - } - this.updateNetNodeCache(); - } + this.activeNetEl.innerHTML = ` + ${this.activeNet.name} + ${this.activeNet.nodes.length} + + + `; + } + this.updateNetNodeCache(); + } } diff --git a/docs/nets.js b/docs/nets.js index 57dadfa..67d7dc5 100644 --- a/docs/nets.js +++ b/docs/nets.js @@ -5,62 +5,71 @@ class NetManager { this.db = db; } - async render() { - const tbody = document.getElementById('nets-body'); - if(!tbody) return; +async render() { + const tbody = document.getElementById('nets-body'); + if(!tbody) return; - tbody.innerHTML = 'Loading...'; - - const nets = await this.db.getNets(); - tbody.innerHTML = ''; - - if(nets.length === 0) { - tbody.innerHTML = 'No nets defined. Go to "Inspect" to create one.'; - return; - } + // Safety check: If no board is loaded, clear the table + if (typeof currentBomId === 'undefined' || !currentBomId) { + tbody.innerHTML = ''; + return; + } - nets.forEach(net => { - const tr = document.createElement('tr'); - - tr.style.height = 'auto'; - tr.style.minHeight = '2.2rem'; + tbody.innerHTML = 'Loading...'; - // Target Icon for Editing - const targetIcon = `🎯`; + const allNets = await this.db.getNets(); - let nodesHtml = ''; - net.nodes.forEach((n, idx) => { - nodesHtml += `${n.label}`; - }); - - tr.innerHTML = ` - - ${targetIcon} - - - - - -
${nodesHtml}
- - - - - - `; - tbody.appendChild(tr); - }); - } + // --- FIX: Filter by Current Board ID --- + const nets = allNets.filter(n => n.projectId === currentBomId); - // 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); - } - } + tbody.innerHTML = ''; + + if(nets.length === 0) { + tbody.innerHTML = 'No nets defined. Go to "Inspect" to create one.'; + return; + } + + nets.forEach(net => { + const tr = document.createElement('tr'); + + tr.style.height = 'auto'; + tr.style.minHeight = '2.2rem'; + + // Target Icon for Editing + const targetIcon = `🎯`; + + let nodesHtml = ''; + net.nodes.forEach((n, idx) => { + nodesHtml += `${n.label}`; + }); + + tr.innerHTML = ` + + ${targetIcon} + + + + +
${nodesHtml}
+ + + + + + `; + 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) { @@ -68,20 +77,17 @@ class NetManager { 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(); + const res = await requestInput("Edit Node", "Node Name", node.label, { + extraBtn: { label: 'Delete', value: '__DELETE__', class: 'danger' } + }); + + if (res === '__DELETE__') { + net.nodes.splice(nodeIdx, 1); + await this.db.addNet(net); + this.render(); + } else if (res) { + net.nodes[nodeIdx].label = res; await this.db.addNet(net); this.render(); } @@ -125,10 +131,10 @@ class NetManager { '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 + // '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 }; @@ -140,44 +146,44 @@ class NetManager { 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`; - + 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 += ` (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 += ` (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`; + out += ` (net (code ${i+1}) (name "${net.name}")\n`; net.nodes.forEach(node => { const parts = node.label.split('.'); if(parts.length === 2) { // Format: R1.2 (Ref R1, Pin 2) - out += ` (node (ref "${parts[0]}") (pin "${parts[1]}"))\n`; + out += ` (node (ref "${parts[0]}") (pin "${parts[1]}"))\n`; } else { // Fallback: TestPoints or direct names often use Pin 1 - out += ` (node (ref "${node.label}") (pin "1"))\n`; + out += ` (node (ref "${node.label}") (pin "1"))\n`; } }); - out += " )\n"; + out += " )\n"; }); out += " )\n)\n"; diff --git a/docs/resistor.html b/docs/resistor.html index 9b6c8db..b43a002 100644 --- a/docs/resistor.html +++ b/docs/resistor.html @@ -53,7 +53,7 @@
- + diff --git a/docs/stitch-editor.js b/docs/stitch-editor.js index fa0d5b9..83e7723 100644 --- a/docs/stitch-editor.js +++ b/docs/stitch-editor.js @@ -1,11 +1,7 @@ /* 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'); @@ -13,26 +9,31 @@ class StitchEditor { this.points = []; this.colors = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#00ffff', '#ff00ff', '#ffffff', '#ff8800', '#88ff00']; + // FIX: We pass 'null' as the 3rd argument (onClick) to prevent adding new points. + // Points are only generated via setGrid() and moved via dragging. + this.viewSrc = new PanZoomCanvas('stitch-canvas-src', (c, k) => this.drawPts(c, k, 's'), - (x, y) => this.addPt(x, y, 's'), + null, (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'), + null, (x, y, m, i) => this.hit(x, y, m, i, 'd') ); + this.injectFlipControls(); } - - 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'; + container.style.marginRight = 'auto'; const btnFlip = document.createElement('button'); btnFlip.id = 'btn-stitch-flip'; btnFlip.className = 'secondary'; @@ -43,7 +44,7 @@ class StitchEditor { else toolbar.appendChild(container); } } - + toggleFlip(btn) { const newVal = !this.viewDst.isMirrored; this.viewDst.setMirror(newVal); @@ -96,7 +97,7 @@ class StitchEditor { if (H && invH) { const rect = this.getOverlapRect( this.viewSrc.bmp.width, this.viewSrc.bmp.height, - this.viewDst.bmp.width, this.viewDst.bmp.height, + this.viewDst.bmp.width, this.viewDst.bmp.height, H ); if (rect) { @@ -148,12 +149,22 @@ class StitchEditor { const bS = await createImageBitmap(i1.blob); const bD = await createImageBitmap(i2.blob); + this.modal.style.display = 'flex'; // Trigger Layout + this.viewSrc.setImage(bS); this.viewDst.setImage(bD); + // Reset Zoom (Delay to ensure Canvas dimensions are updated by Observer) + requestAnimationFrame(() => { + requestAnimationFrame(() => { + this.viewSrc.fit(); + this.viewDst.fit(); + }); + }); + this.points = []; const existing = await this.db.getOverlapsForPair(srcImgId, dstImgId); - + let shouldFlip = false; if (existing) { @@ -194,7 +205,6 @@ class StitchEditor { } this.refresh(); - this.modal.style.display = 'flex'; } refresh() { this.viewSrc.draw(); this.viewDst.draw(); } @@ -228,14 +238,6 @@ class StitchEditor { }); } - 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--) { @@ -246,9 +248,6 @@ class StitchEditor { } 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(); } } } @@ -275,4 +274,3 @@ class StitchEditor { window.history.back(); } } - diff --git a/docs/studio.html b/docs/studio.html index 8ab4410..b8b7e80 100644 --- a/docs/studio.html +++ b/docs/studio.html @@ -18,9 +18,9 @@ - - - + + +