by adom
0402 Thick Film Resistor · 4.7 kΩ ±1% · 1/16 W
← Commit history
Footprint viewer regenerated with adom-footprint: 300 um solder balls hidden by default, same positions as the hero
1 file changed
+204−799
CR0402-FX-4701GLF-fpview.html+204−799@@ -1,805 +1,210 @@-<!DOCTYPE html>-<html lang="en">-<head>-<meta charset="UTF-8">-<meta name="viewport" content="width=device-width, initial-scale=1.0">-<title>CR0402-FX-4701GLF</title>+<!doctype html><html><head><meta charset="utf-8">+<title>CR0402-FX-4701GLF - adom-footprint</title>+<meta name="viewport" content="width=device-width,initial-scale=1"> <style>- * { margin: 0; padding: 0; box-sizing: border-box; }- body {- background: #0d1117;- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;- overflow: hidden;- height: 100vh;- display: flex;- flex-direction: column;- color: #e6edf3;- }-- .toolbar {- display: flex;- align-items: center;- gap: 6px;- padding: 8px 14px;- background: #161b22;- border-bottom: 1px solid #21262d;- flex-shrink: 0;- font-size: 13px;- }- .toolbar .title {- font-weight: 600;- color: #00b8b0;- font-size: 15px;- letter-spacing: 0.02em;- overflow: hidden;- text-overflow: ellipsis;- white-space: nowrap;- min-width: 0;- }- .toolbar .badge {- font-size: 10px;- padding: 2px 6px;- border-radius: 4px;- background: rgba(0, 184, 176, 0.12);- color: #00b8b0;- border: 1px solid rgba(0, 184, 176, 0.25);- margin-left: 6px;- }- .toolbar .spacer { flex: 1; }- .toolbar .sep {- width: 1px;- height: 22px;- background: #21262d;- margin: 0 4px;- flex-shrink: 0;- }- .toolbar .btn-group {- display: flex;- align-items: center;- gap: 2px;- flex-shrink: 0;- }- .toolbar button {- background: rgba(0, 184, 176, 0.12);- border: 1px solid rgba(0, 184, 176, 0.25);- border-radius: 4px;- padding: 4px 10px;- cursor: pointer;- font-size: 12px;- color: #8b949e;- transition: all 0.15s;- }- .toolbar button:hover { background: rgba(0,184,176,0.18); color: #e6edf3; border-color: rgba(0,184,176,0.4); }- .toolbar button:active { background: rgba(0,184,176,0.28); }- .toolbar button.active { background: rgba(0,184,176,0.2); color: #00b8b0; border-color: rgba(0,184,176,0.4); }- .toolbar .zoom-level {- font-size: 11px;- color: #8b949e;- min-width: 44px;- text-align: center;- font-variant-numeric: tabular-nums;- }-- .canvas-wrap {- flex: 1;- overflow: hidden;- position: relative;- cursor: grab;- background-color: #0d1117;- background-image:- linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),- linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);- background-size: 20px 20px;- }- .canvas-wrap.dragging { cursor: grabbing; }- .canvas-wrap.no-grid { background-image: none; }-- .canvas {- position: absolute;- transform-origin: 0 0;- }- .canvas svg { display: block; }-- .tooltip {- display: none;- position: fixed;- background: #21262d;- backdrop-filter: blur(12px);- color: #e6edf3;- padding: 10px 14px;- border-radius: 8px;- border: 1px solid rgba(0, 184, 176, 0.25);- font-size: 12px;- line-height: 1.6;- pointer-events: none;- z-index: 100;- max-width: 280px;- box-shadow: 0 8px 24px rgba(0,0,0,0.4);- }- .tooltip.visible { display: block; }- .tooltip .tt-pad { font-weight: 700; font-size: 15px; color: #00e6dc; }- .tooltip .tt-signal { color: #f0c040; font-size: 13px; margin-left: 6px; font-weight: 600; }- .tooltip .tt-dims {- display: inline-block;- margin-top: 4px;- padding: 2px 8px;- border-radius: 4px;- font-size: 11px;- font-weight: 500;- background: rgba(200,52,52,0.15);- color: #C83434;- border: 1px solid rgba(200,52,52,0.3);- }- .tooltip .tt-desc {- margin-top: 6px;- padding-top: 6px;- border-top: 1px solid #21262d;- color: #8b949e;- font-size: 11px;- line-height: 1.5;- }- .tooltip.sticky {- pointer-events: auto;- user-select: text;- border-color: #00e6dc;- }- .tooltip .tt-hint {- margin-top: 6px;- font-size: 9px;- color: #484f58;- text-align: center;- }- .tooltip .tt-source {- margin-top: 6px;- padding-top: 4px;- border-top: 1px solid #21262d;- font-size: 9px;- }- .tooltip .tt-source-label { color: #8C6BF7; }- .tooltip .tt-source a { color: #58a6ff; text-decoration: none; }- .tooltip .tt-source a:hover { text-decoration: underline; }-- .edit-btn {- position: absolute; top: 10px; right: 10px; z-index: 100;- display: flex; align-items: center; gap: 6px;- padding: 5px 10px;- background: rgba(22,27,34,0.75);- backdrop-filter: blur(8px);- border: 1px solid rgba(140,107,247,0.2);- border-radius: 6px;- color: #8b949e; font-size: 11px;- cursor: pointer; text-decoration: none;- transition: all 0.2s; opacity: 0.7;- }- .edit-btn:hover {- opacity: 1;- background: rgba(140,107,247,0.12);- border-color: rgba(140,107,247,0.4);- color: #C5B3FF;- }- .edit-btn svg { width: 12px; height: 12px; }-- /* Regen prompt modal */- .regen-overlay {- display: none;- position: fixed; inset: 0;- background: rgba(0,0,0,0.6);- z-index: 2000;- align-items: center; justify-content: center;- }- .regen-overlay.visible { display: flex; }- .regen-modal {- background: #161b22;- border: 1px solid #21262d;- border-radius: 10px;- padding: 20px 24px;- max-width: 520px;- width: 90%;- box-shadow: 0 12px 40px rgba(0,0,0,0.5);- }- .regen-modal h3 {- margin: 0 0 12px;- font-size: 14px;- font-weight: 600;- color: #00b8b0;- }- .regen-modal .prompt-text {- background: undefined;- border: 1px solid #21262d;- border-radius: 6px;- padding: 12px 14px;- font-size: 13px;- line-height: 1.5;- color: undefined;- white-space: pre-wrap;- word-break: break-word;- user-select: all;- }- .regen-modal .modal-footer {- display: flex;- align-items: center;- justify-content: space-between;- margin-top: 14px;- }- .regen-modal .copy-status {- font-size: 11px;- color: #484f58;- }- .regen-modal .copy-status.copied { color: #00b8b0; }- .regen-modal .modal-btns { display: flex; gap: 8px; }- .regen-modal .modal-btns button {- padding: 6px 14px;- border-radius: 6px;- border: 1px solid #21262d;- background: #161b22;- color: #8b949e;- font-size: 12px;- cursor: pointer;- }- .regen-modal .modal-btns button:hover { background: rgba(255,255,255,0.06); }- .regen-modal .modal-btns .copy-btn {- background: rgba(0,184,176,0.12);- border-color: rgba(0,184,176,0.3);- color: #00b8b0;- }- .regen-modal .modal-btns .copy-btn:hover { background: rgba(0,184,176,0.22); }-- /* Pad highlight */- .pad-highlight {- stroke: #00b8b0 !important;- stroke-width: 0.15 !important;- fill-opacity: 0.6 !important;- }-- .bottom-panel {- background: #161b22;- border-top: 1px solid #21262d;- flex-shrink: 0;- }- .info-row {- display: flex;- align-items: baseline;- flex-wrap: wrap;- gap: 1px 0;- padding: 5px 8px;- font-size: 10px;- }- .info-item {- display: inline-flex;- align-items: baseline;- gap: 5px;- padding: 1px 0;- margin-right: 8px;- }- .info-item::after {- content: '';- display: inline-block;- width: 1px;- height: 10px;- background: #21262d;- margin-left: 8px;- vertical-align: middle;- }- .info-item:last-child::after { display: none; }- .info-item:last-child { margin-right: 0; }- .info-label {- color: #484f58;- text-transform: uppercase;- letter-spacing: 0.04em;- font-size: 9px;- }- .info-value { color: #8b949e; }- .info-value a { color: #00b8b0; text-decoration: none; }- .info-value a:hover { text-decoration: underline; }- .info-divider {- height: 1px;- background: #21262d;- margin: 0 8px;- }- .coord-row {- display: flex;- align-items: center;- justify-content: space-between;- padding: 4px 8px;- font-size: 10px;- color: #8b949e;- font-variant-numeric: tabular-nums;- }-</style>-</head>+/* ── adom-footprint shared viewer core (CSS) ───────────────────────────────+ Included VERBATIM by both the live app (app.html) and the static embed+ (embed.html) so the canvas, zoom toolbar and layer-panel rows look identical+ and cannot drift. Self-contained literal colours (no dependency on either+ file's CSS vars). Class prefix: av- */++.av-vp{position:absolute;inset:0;overflow:hidden;cursor:grab;background:radial-gradient(circle at 50% 42%,#121a26 0%,#0d1117 72%)}+.av-vp.grab{cursor:grabbing}+/* DIRECT child only — the zoom-toolbar button icons (.av-tools svg) also live+ inside .av-vp; without '>' they'd inherit position:absolute and stack at 0,0. */+.av-vp > svg{position:absolute;left:0;top:0;width:100%;height:100%;transform-origin:0 0;display:block}++/* zoom toolbar */+/* Grouped translucent toolbar (matches adom-symbol's .tools): one container, borderless+ buttons that highlight on hover — instead of individually-boxed buttons. */+.av-tools{position:absolute;left:10px;top:10px;display:flex;gap:3px;padding:3px;z-index:5;background:rgba(22,27,34,.82);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid #222b38;border-radius:9px}+.av-tools button{width:30px;height:30px;border-radius:6px;border:none;background:transparent;color:#cfd8e2;cursor:pointer;display:grid;place-items:center;transition:background .15s ease,color .15s ease}+.av-tools button svg{width:16px;height:16px;display:block}+.av-tools button:hover{background:#222b38;color:#37c8a6}+.av-tools button:focus,.av-tools button:focus-visible{outline:none}+.av-panhint{position:absolute;right:10px;bottom:9px;color:#8b949e;font-size:11px;z-index:5;pointer-events:none;user-select:none;-webkit-user-select:none}++/* layer panel rows */+.av-lrow{display:flex;align-items:center;gap:9px;padding:8px 9px;border-radius:8px;cursor:pointer}+.av-lrow:hover{background:#161c27}+.av-lrow .sw{width:13px;height:13px;border-radius:3px;border:1px solid rgba(255,255,255,.15);flex:0 0 13px}+.av-lrow .sw.ring{background:transparent!important}+.av-lrow .ln{flex:1;font-size:13px}+.av-lrow .meta{font-size:10px;color:#8b949e}+.av-lrow .chk{width:16px;height:16px;border-radius:4px;border:1.5px solid #3a4757;display:grid;place-items:center;color:#0b0f17;font-size:11px}+.av-lrow.on .chk{background:#37c8a6;border-color:#37c8a6}+.av-lrow.off .ln,.av-lrow.off .sw{opacity:.4}+.av-lrow.special{background:linear-gradient(100deg,rgba(55,200,166,.10),transparent);border:1px solid rgba(55,200,166,.25);margin-top:4px}+.av-lrow.special .ln{color:#7ef0d2;font-weight:600}++:root{--bg:#0d1117;--panel:#11161f;--border:#222b38;--text:#e6edf3;--muted:#8b949e;--accent:#37c8a6}+*{box-sizing:border-box;margin:0;padding:0}html,body{height:100%}+body{background:var(--bg);color:var(--text);font-family:'Satoshi','Segoe UI',system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden}+header{display:flex;align-items:center;gap:9px;padding:9px 14px;border-bottom:1px solid var(--border);flex:0 0 auto}+header .dot{width:9px;height:9px;border-radius:50%;background:var(--accent);box-shadow:0 0 10px var(--accent)}+header .b{font-weight:700;font-size:14px}header .b span{color:var(--muted);font-weight:400}+header .nm{margin-left:auto;font-weight:600;font-size:13px;color:#cfd8e2}+main{flex:1;display:flex;min-height:0}+.vpwrap{flex:1;position:relative;min-width:0}+aside{flex:0 0 248px;border-left:1px solid var(--border);background:var(--panel);display:flex;flex-direction:column;min-height:0}+aside .ah{padding:9px 12px;border-bottom:1px solid var(--border);font-size:12px;color:var(--muted)}+.layers{padding:6px}+.note{font-size:11px;color:var(--muted);line-height:1.45;padding:8px 11px}+.note b{color:#7ef0d2}+</style></head> <body>--<div class="toolbar">- <span class="title">CR0402-FX-4701GLF</span>- <span class="badge">Footprint</span>- - <div class="spacer"></div>- <div class="btn-group">- <button onclick="zoomOut()">−</button>- <span class="zoom-level" id="zoom-level">100%</span>- <button onclick="zoomIn()">+</button>- </div>- <div class="sep"></div>- <button onclick="zoomFit()">Fit</button>- <button onclick="zoomReset()">1:1</button>- <button onclick="toggleGrid()" id="grid-btn" class="active">Grid</button>- <button onclick="toggleSolder()" id="solder-btn" class="active" title="250µm solder jet blobs for Adom InstaPCB paste jetting. Adom uses this placement data for its jetting sequence. Blob count, position, and size (250–600µm) are adjustable. Placement accuracy: ±50µm.">Solder</button>- <button onclick="toggleLabels()" id="labels-btn">Labels</button>- <div class="sep"></div>- <button onclick="showRegenPrompt()" id="regen-btn" title="Show regeneration prompt to paste into Claude Code">Regen</button>-</div>--<div class="regen-overlay" id="regen-overlay" onclick="if(event.target===this)closeRegenPrompt()">- <div class="regen-modal">- <h3>Regeneration Prompt</h3>- <div class="prompt-text" id="regen-prompt-text"></div>- <div class="modal-footer">- <span class="copy-status" id="regen-copy-status">Copied to clipboard</span>- <div class="modal-btns">- <button onclick="closeRegenPrompt()">Close</button>- <button class="copy-btn" onclick="copyRegenText()">Copy</button>+ <header><span class="dot"></span><span class="b">adom-footprint <span>· interactive</span></span><span class="nm">CR0402-FX-4701GLF</span></header>+ <main>+ <div class="vpwrap">+ <div class="av-vp" id="vp">+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="-2.000 -1.500 4.000 3.000" preserveAspectRatio="xMidYMid meet"><g class="adom-layer-courtyard"><rect x="-1.000" y="-0.500" width="2.000" height="1.000" fill="none" stroke="#FF26E2" stroke-width="0.05" stroke-dasharray="0.2,0.1"/></g><g class="adom-layer-fab"><rect x="-0.500" y="-0.250" width="1.000" height="0.500" fill="none" stroke="#6e7681" stroke-width="0.1"/></g><g class="adom-layer-copper"><g data-pad="1" data-shape="rect" data-size="0.500x0.500" data-mount="smd"><rect x="-0.750" y="-0.250" width="0.500" height="0.500" fill="#C83434" stroke="#ef5350" stroke-width="0.04"/></g><g data-pad="2" data-shape="rect" data-size="0.500x0.500" data-mount="smd"><rect x="0.250" y="-0.250" width="0.500" height="0.500" fill="#C83434" stroke="#ef5350" stroke-width="0.04"/></g></g><g class="adom-layer-heatsink"></g><g class="adom-layer-drill"></g><g class="adom-layer-silk"><line x1="-0.165" y1="-0.360" x2="0.165" y2="-0.360" stroke="#00cccc" stroke-width="0.12" stroke-linecap="round"/><line x1="-0.165" y1="0.360" x2="0.165" y2="0.360" stroke="#00cccc" stroke-width="0.12" stroke-linecap="round"/></g><g class="adom-layer-padnum"><text x="-0.500" y="0.000" text-anchor="middle" dominant-baseline="central" font-size="0.210" fill="#fff" font-family="sans-serif" style="pointer-events:none">1</text><text x="0.500" y="0.000" text-anchor="middle" dominant-baseline="central" font-size="0.210" fill="#fff" font-family="sans-serif" style="pointer-events:none">2</text></g><g class="adom-layer-paste-apertures"><rect x="-0.750" y="-0.250" width="0.500" height="0.500" rx="0.05" transform="rotate(0.00 -0.500 0.000)" fill="none" stroke="#8aa0b8" stroke-width="0.03" stroke-dasharray="0.12 0.08" opacity="0.7"/><rect x="0.250" y="-0.250" width="0.500" height="0.500" rx="0.05" transform="rotate(0.00 0.500 0.000)" fill="none" stroke="#8aa0b8" stroke-width="0.03" stroke-dasharray="0.12 0.08" opacity="0.7"/></g><g class="adom-layer-paste-dots"><circle cx="-0.500" cy="0.000" r="0.150" fill="#37c8a6" opacity="0.92"/><circle cx="0.500" cy="0.000" r="0.150" fill="#37c8a6" opacity="0.92"/></g></svg> </div> </div>- </div>-</div>--<div class="canvas-wrap" id="canvas-wrap">- <div class="canvas" id="canvas">- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 3">-<rect x="0" y="0" width="4" height="3" fill="#0d1117"/>-<rect x="1" y="1" width="2" height="1" fill="none" stroke="#FF26E2" stroke-width="0.05" stroke-dasharray="0.2,0.1"/>-<rect x="1.5" y="1.25" width="1" height="0.5" fill="none" stroke="#6e7681" stroke-width="0.1"/>-<line x1="1.835" y1="1.14" x2="2.165" y2="1.14" stroke="#00cccc" stroke-width="0.12" stroke-linecap="round"/><line x1="1.835" y1="1.86" x2="2.165" y2="1.86" stroke="#00cccc" stroke-width="0.12" stroke-linecap="round"/>-<rect x="1.25" y="1.25" width="0.5" height="0.5" rx="0" ry="0" fill="#C83434" stroke="#ef5350" stroke-width="0.04"/>-<text x="1.5" y="1.5" text-anchor="middle" dominant-baseline="central" font-size="0.225" fill="#fff" font-family="sans-serif">1</text>-<rect x="2.25" y="1.25" width="0.5" height="0.5" rx="0" ry="0" fill="#C83434" stroke="#ef5350" stroke-width="0.04"/>-<text x="2.5" y="1.5" text-anchor="middle" dominant-baseline="central" font-size="0.225" fill="#fff" font-family="sans-serif">2</text>-</svg>- </div>- <a class="edit-btn" href="https://coder.john-service-wiki-a054078430ce38e4.containers.adom.inc/proxy/8785/wiki/components/cr0402-fx-4701glf/edit?field=content" target="_blank" title="Suggest corrections on Adom Wiki">- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">- <path d="M11.5 1.5l3 3L5 14H2v-3L11.5 1.5z"/>- <path d="M9.5 3.5l3 3"/>- </svg>- Suggest edit- </a>-</div>--<div class="tooltip" id="tooltip"></div>--<div class="bottom-panel">- <div class="info-row">- - - <div class="info-item"><span class="info-label">Desc</span><span class="info-value">Land pattern: Bourns CR Series Thick Film Chip Resistors datasheet, REV. 03/21, p.2 (Recommended Pad Layout: a 0.50, b 1.50, c 0.50, pad inner edge on the 0.25 mm bottom terminal)</span></div><div class="info-item"><span class="info-label">Pads</span><span class="info-value">2 · 0.5 × 0.5 mm, r 0 mm</span></div><div class="info-item"><span class="info-label">Body</span><span class="info-value">1 × 0.5 mm</span></div><div class="info-item"><span class="info-label">Courtyard</span><span class="info-value">2 × 1 mm</span></div>- </div>- <div class="info-divider"></div>- <div class="coord-row">- <span id="cursor-pos">X: 0.00 Y: 0.00 mm</span>- </div>-</div>--<script>- const pads = [{"number": "1", "signal": "", "x": -0.5, "y": 0.0, "width": 0.5, "height": 0.5, "shape": "roundrect", "mountType": "smd", "rotation": 0, "desc": ""}, {"number": "2", "signal": "", "x": 0.5, "y": 0.0, "width": 0.5, "height": 0.5, "shape": "roundrect", "mountType": "smd", "rotation": 0, "desc": ""}];- const svgW = 4;- const svgH = 3;- const svgVbX = 0;- const svgVbY = 0;- const offsetX = 2;- const offsetY = 1.5;- const datasheetUrl = "";- const _partName = "";- const _fpName = "CR0402-FX-4701GLF";- const _kicadModPath = "/home/adom/project/project-content/schematics/footprints/CR0402-FX-4701GLF/CR0402-FX-4701GLF.kicad_mod";-- const wrap = document.getElementById('canvas-wrap');- const canvas = document.getElementById('canvas');- const tooltip = document.getElementById('tooltip');- const zoomLabel = document.getElementById('zoom-level');- const cursorPos = document.getElementById('cursor-pos');-- let scale = 1, panX = 0, panY = 0;- let isDragging = false;- let dragStartX, dragStartY, panStartX, panStartY;- let gridVisible = true;-- const MM_TO_PX = 3.78;- const svgPxW = svgW * MM_TO_PX;- const svgPxH = svgH * MM_TO_PX;-- const svgEl = canvas.querySelector('svg');- if (svgEl) {- svgEl.setAttribute('width', svgPxW + 'px');- svgEl.setAttribute('height', svgPxH + 'px');- }-- function updateTransform() {- canvas.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + scale + ')';- zoomLabel.textContent = Math.round(scale * 100) + '%';- }-- function zoomFit() {- const ww = wrap.clientWidth, wh = wrap.clientHeight;- if (ww === 0 || wh === 0) return; // iframe not laid out yet — ResizeObserver will retry- const margin = 24;- // Use getBBox() to fit to visible content only (excludes hidden labels)- let fitW = svgPxW, fitH = svgPxH, fitX = 0, fitY = 0;- if (svgEl) {- try {- const bb = svgEl.getBBox();- if (bb.width > 0 && bb.height > 0) {- fitW = bb.width * MM_TO_PX;- fitH = bb.height * MM_TO_PX;- fitX = (bb.x - svgVbX) * MM_TO_PX;- fitY = (bb.y - svgVbY) * MM_TO_PX;- }- } catch {}- }- scale = Math.min((ww - margin * 2) / fitW, (wh - margin * 2) / fitH, 100);- panX = (ww - fitW * scale) / 2 - fitX * scale;- panY = (wh - fitH * scale) / 2 - fitY * scale;- updateTransform();- }-- function zoomReset() {- scale = 1;- panX = (wrap.clientWidth - svgPxW) / 2;- panY = (wrap.clientHeight - svgPxH) / 2;- updateTransform();- }-- function zoomIn() { zoomAt(wrap.clientWidth / 2, wrap.clientHeight / 2, 1.25); }- function zoomOut() { zoomAt(wrap.clientWidth / 2, wrap.clientHeight / 2, 0.8); }-- function zoomAt(cx, cy, factor) {- const ns = Math.max(0.1, Math.min(scale * factor, 40));- const r = ns / scale;- panX = cx - (cx - panX) * r;- panY = cy - (cy - panY) * r;- scale = ns;- updateTransform();- }-- function toggleGrid() {- gridVisible = !gridVisible;- wrap.classList.toggle('no-grid', !gridVisible);- document.getElementById('grid-btn').classList.toggle('active', gridVisible);- }-- // ── Solder blob jetting layer ──- let solderVisible = true;- let solderGroup = null;-- function setupSolderBlobs() {- if (!svgEl) return;-- solderGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');- solderGroup.setAttribute('id', 'solder-layer');- solderGroup.style.display = '';- solderGroup.style.pointerEvents = 'none';-- const BLOB_RADIUS = 0.125; // 250µm diameter / 2- const BLOB_SPACING = 0.25; // center-to-center (blobs touching — fills the pad)-- pads.forEach(pad => {- if (pad.mountType !== 'smd') return;-- const cx = pad.x + offsetX;- const cy = pad.y + offsetY;- const rot = (pad.rotation || 0) * Math.PI / 180;-- // Each axis computed independently: if pad is wide enough, grid; otherwise single centered blob- const inset = BLOB_RADIUS; // blob edges align with pad edges — no overhang- const availW = pad.width - 2 * inset;- const availH = pad.height - 2 * inset;-- // At minimum 1 blob per axis — even narrow pads get a centered row/column- const nCols = availW > 0 ? Math.max(1, Math.floor(availW / BLOB_SPACING) + 1) : 1;- const nRows = availH > 0 ? Math.max(1, Math.floor(availH / BLOB_SPACING) + 1) : 1;- const spanW = availW > 0 ? availW : 0;- const spanH = availH > 0 ? availH : 0;- const stepX = nCols > 1 ? spanW / (nCols - 1) : 0;- const stepY = nRows > 1 ? spanH / (nRows - 1) : 0;- const startX = nCols > 1 ? -spanW / 2 : 0;- const startY = nRows > 1 ? -spanH / 2 : 0;- const cosR = Math.cos(rot);- const sinR = Math.sin(rot);-- for (let row = 0; row < nRows; row++) {- for (let col = 0; col < nCols; col++) {- const lx = startX + col * stepX;- const ly = startY + row * stepY;- const rx = lx * cosR - ly * sinR;- const ry = lx * sinR + ly * cosR;-- const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');- circle.setAttribute('cx', (cx + rx).toFixed(4));- circle.setAttribute('cy', (cy + ry).toFixed(4));- circle.setAttribute('r', BLOB_RADIUS.toFixed(4));- circle.setAttribute('fill', '#D4AA70');- circle.setAttribute('fill-opacity', '0.75');- circle.setAttribute('stroke', '#B8860B');- circle.setAttribute('stroke-width', '0.015');- solderGroup.appendChild(circle);- }- }- });-- svgEl.appendChild(solderGroup);- }-- function toggleSolder() {- solderVisible = !solderVisible;- if (solderGroup) solderGroup.style.display = solderVisible ? '' : 'none';- document.getElementById('solder-btn').classList.toggle('active', solderVisible);- }-- // ── Label visibility toggle ──- let labelsVisible = false;- let labelGroups = [];-- function setupLabelToggle() {- if (!svgEl) return;- // KiCad SVGs render labels as <g> containing <text> + <g class="stroked-text">- // Find all <text> elements and collect their parent <g> groups- svgEl.querySelectorAll('text').forEach(txt => {- const parent = txt.parentElement;- if (parent && parent.tagName === 'g' && !labelGroups.includes(parent)) {- labelGroups.push(parent);- parent.style.display = 'none'; // hidden by default- }- });- }-- function toggleLabels() {- labelsVisible = !labelsVisible;- labelGroups.forEach(g => g.style.display = labelsVisible ? '' : 'none');- document.getElementById('labels-btn').classList.toggle('active', labelsVisible);- }-- // ── Regen prompt modal ──- let _regenPrompt = '';- function buildRegenPrompt() {- const name = _partName || _fpName;- const folder = _kicadModPath.replace(/\/[^/]+$/, '');- return 'Regenerate the FpView for ' + name + '.\n\n'- + 'Footprint: ' + _kicadModPath + '\n'- + 'Metadata: ' + folder + '/\n\n'- + 'Re-export the SVG via the KiCad service, regenerate the FpView HTML at the same path, '- + 'and re-display it.';- }- function showRegenPrompt() {- _regenPrompt = buildRegenPrompt();- document.getElementById('regen-prompt-text').textContent = _regenPrompt;- document.getElementById('regen-overlay').classList.add('visible');- const status = document.getElementById('regen-copy-status');- navigator.clipboard.writeText(_regenPrompt).then(() => {- status.textContent = 'Copied to clipboard';- status.classList.add('copied');- }).catch(() => {- status.textContent = 'Click Copy to copy';- status.classList.remove('copied');- });- }- function closeRegenPrompt() {- document.getElementById('regen-overlay').classList.remove('visible');- }- function copyRegenText() {- navigator.clipboard.writeText(_regenPrompt).then(() => {- const status = document.getElementById('regen-copy-status');- status.textContent = 'Copied to clipboard';- status.classList.add('copied');+ <aside>+ <div class="ah">Layers</div>+ <div class="layers" id="layers"></div>+ <div class="note">The <b>solder paste jet</b> layer shows the 300µm paste-dispensing dots that travel with the footprint. Off by default — toggle it on.</div>+ </aside>+ </main>+<script>/* ── adom-footprint shared viewer core (window.AV) ─────────────────────────+ Single source of truth for the layer list, toggle behaviour, defaults,+ pan/zoom and toolbar icons. Included VERBATIM by both app.html (live) and+ embed.html (static) so the two viewers cannot drift. Pure client-side; works+ on any SVG that carries the <g class="adom-layer-*"> groups. */+(function(){+ var ICON_ZIN='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M20 20l-4.5-4.5M10.5 8v5M8 10.5h5"/></svg>';+ var ICON_ZOUT='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M20 20l-4.5-4.5M8 10.5h5"/></svg>';+ // Zoom-to-FIT = a magnifier with a small square inside (fit content to view).+ // Deliberately NOT the four-corner expand/"fullscreen" glyph.+ var ICON_FIT='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M20 20l-4.5-4.5"/><rect x="7.6" y="7.6" width="5.8" height="5.8" rx="1.2"/></svg>';++ // The canonical layer list. Every layer is a <g class="adom-layer-*"> in the+ // self-rendered SVG, so toggling is group-based (robust) — never colour-match.+ // `kind:'group'` on every entry: all layers are toggled by their <g> group+ // (robust), never by colour-match. (app.html still reads def.kind.)+ var LAYER_DEFS=[+ {key:'copper', name:'Copper', kind:'group', sel:'.adom-layer-copper', sw:'#C83434'},+ // Auto-generated thermal-via array under the exposed pad — only present (and+ // listed) when the footprint has an exposed thermal pad.+ {key:'heatsink', name:'Heatsink vias', kind:'group', sel:'.adom-layer-heatsink', sw:'#e8a13a'},+ {key:'padnum', name:'Pad numbers', kind:'group', sel:'.adom-layer-padnum', sw:'#ffffff', ring:true},+ {key:'silk', name:'Silkscreen', kind:'group', sel:'.adom-layer-silk', sw:'#00cccc'},+ {key:'fab', name:'Fab (F.Fab)', kind:'group', sel:'.adom-layer-fab', sw:'#6e7681', ring:true},+ {key:'courtyard', name:'Courtyard', kind:'group', sel:'.adom-layer-courtyard', sw:'#FF26E2', ring:true},+ {key:'drill', name:'Drill', kind:'group', sel:'.adom-layer-drill', sw:'#888888', ring:true},+ {key:'paste-ap', name:'Paste apertures', kind:'group', sel:'.adom-layer-paste-apertures', sw:'#8aa0b8', ring:true},+ // Generic name — the 300µm solder-paste dispensing dots. Off by default.+ {key:'paste-dots',name:'Solder paste jet', kind:'group', sel:'.adom-layer-paste-dots', sw:'#37c8a6', special:true},+ ];++ // True only if the group actually holds drawn geometry (not an empty <g>) — so+ // layers with nothing in THIS footprint aren't listed as dead, dim rows.+ function groupHasGeo(svg,def){+ if(!svg) return false; var n=0,g=svg.querySelectorAll(def.sel);+ for(var i=0;i<g.length;i++){ n+=g[i].querySelectorAll('path,rect,circle,line,polygon,polyline,text').length; }+ return n>0;+ }+ function layerPresent(svg,def){ return groupHasGeo(svg,def); }+ function applyLayer(svg,def,on){+ if(!svg) return; var g=svg.querySelectorAll(def.sel);+ for(var i=0;i<g.length;i++) g[i].style.display=on?'':'none';+ }++ // Build layer rows into a container. opts:+ // svg, container, state {key:bool}, meta {pasteDots,pasteApertures},+ // onToggle(key,on). Missing state key -> special OFF, others ON. Skips empty+ // non-special groups. Returns the keys actually shown.+ function buildLayers(opts){+ var svg=opts.svg, box=opts.container, state=opts.state||{}, meta=opts.meta||{}, shown=[];+ box.innerHTML='';+ LAYER_DEFS.forEach(function(def){+ if(!def.special && !groupHasGeo(svg,def)) return;+ var on = def.special ? (state[def.key]===true) : (state[def.key]!==false);+ var m = def.key==='paste-dots' ? ((meta.pasteDots||0)+' @ 300µm')+ : def.key==='paste-ap' ? ((meta.pasteApertures||0)+' openings') : '';+ var row=document.createElement('div');+ row.className='av-lrow '+(on?'on':'off')+(def.special?' special':'');+ row.innerHTML='<span class="sw'+(def.ring?' ring':'')+'" style="'+(def.ring?('border-color:'+def.sw):('background:'+def.sw))+'"></span>'+ +'<span class="ln">'+def.name+'</span>'+(m?'<span class="meta">'+m+'</span>':'')+ +'<span class="chk">'+(on?'✓':'')+'</span>';+ row.onclick=function(){+ var nv=!row.classList.contains('on');+ row.classList.toggle('on',nv); row.classList.toggle('off',!nv);+ row.querySelector('.chk').textContent=nv?'✓':'';+ state[def.key]=nv; applyLayer(svg,def,nv);+ if(opts.onToggle) opts.onToggle(def.key,nv);+ };+ box.appendChild(row); applyLayer(svg,def,on); shown.push(def.key); });- }-- wrap.addEventListener('wheel', (e) => {- e.preventDefault();- const r = wrap.getBoundingClientRect();- zoomAt(e.clientX - r.left, e.clientY - r.top, e.deltaY < 0 ? 1.12 : 0.89);- }, { passive: false });-- wrap.addEventListener('mousedown', (e) => {- if (e.button !== 0) return;- isDragging = true;- dragStartX = e.clientX; dragStartY = e.clientY;- panStartX = panX; panStartY = panY;- wrap.classList.add('dragging');- });- window.addEventListener('mousemove', (e) => {- const r = wrap.getBoundingClientRect();- // Convert screen coords to kicad_mod coords- const svgX = (e.clientX - r.left - panX) / scale / MM_TO_PX;- const svgY = (e.clientY - r.top - panY) / scale / MM_TO_PX;- const kicadX = svgX - offsetX;- const kicadY = svgY - offsetY;- cursorPos.innerHTML = 'X: ' + kicadX.toFixed(2) + ' Y: ' + kicadY.toFixed(2) + ' mm';- if (!isDragging) return;- panX = panStartX + (e.clientX - dragStartX);- panY = panStartY + (e.clientY - dragStartY);- updateTransform();- });- window.addEventListener('mouseup', () => { isDragging = false; wrap.classList.remove('dragging'); });-- // ── Pad hit zones ──- let highlightedRects = [];- let stickyPad = null;-- function setupPadHitZones() {- if (!svgEl) return;- pads.forEach(pad => {- const cx = pad.x + offsetX;- const cy = pad.y + offsetY;- const rx = cx - pad.width / 2;- const ry = cy - pad.height / 2;-- const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');- rect.setAttribute('x', rx);- rect.setAttribute('y', ry);- rect.setAttribute('width', pad.width);- rect.setAttribute('height', pad.height);- rect.setAttribute('fill', 'transparent');- rect.setAttribute('stroke', 'none');- rect.setAttribute('data-pad', pad.number);- rect.style.cursor = 'pointer';- rect.style.pointerEvents = 'all';- svgEl.appendChild(rect);-- rect.addEventListener('mouseenter', (ev) => showPadTooltip(ev.clientX, ev.clientY, pad, rect));- rect.addEventListener('mousemove', (ev) => showPadTooltip(ev.clientX, ev.clientY, pad, rect));- rect.addEventListener('mouseleave', () => hidePadTooltip(rect));- rect.addEventListener('click', (ev) => { ev.stopPropagation(); handlePadClick(pad, rect); });- });- }-- function buildTooltipHtml(pad) {- let html = '<div><span class="tt-pad">Pad ' + pad.number + '</span>';- if (pad.signal) html += '<span class="tt-signal">' + pad.signal + '</span>';- html += '</div>';- html += '<div><span class="tt-dims">' + pad.width.toFixed(3) + ' × ' + pad.height.toFixed(3) + ' mm · ' + pad.shape + ' · ' + pad.mountType + '</span></div>';- if (pad.desc) {- html += '<div class="tt-desc">' + pad.desc + '</div>';- }- if (datasheetUrl) {- html += '<div class="tt-source"><span class="tt-source-label">Source:</span> <a href="' + datasheetUrl + '" target="_blank">Datasheet</a></div>';- }- html += '<div class="tt-hint">click to pin · select text</div>';- return html;- }-- function positionTooltip(x, y) {- // Render first to measure- tooltip.style.left = '0px';- tooltip.style.top = '0px';- const ttRect = tooltip.getBoundingClientRect();- const ttW = ttRect.width, ttH = ttRect.height;- const pad = 14;- let tx = x + 14, ty = y + 14;- if (tx + ttW > window.innerWidth - pad) tx = x - ttW - 14;- if (ty + ttH > window.innerHeight - pad) ty = y - ttH - 14;- tx = Math.max(pad, Math.min(tx, window.innerWidth - ttW - pad));- ty = Math.max(pad, Math.min(ty, window.innerHeight - ttH - pad));- tooltip.style.left = tx + 'px';- tooltip.style.top = ty + 'px';- }-- /* lbr-hover-bus v1 */ window.addEventListener('message',function(e){var d=e.data||{};if(d.type!=='lbr-hover')return;clearHighlight();if(d.pin==null||!svgEl)return;svgEl.querySelectorAll('rect[data-pad]').forEach(function(r){if(r.getAttribute('data-pad')===String(d.pin)){r.setAttribute('stroke','#00b8b1');r.setAttribute('stroke-width','0.12');r.setAttribute('fill','rgba(0,184,177,0.2)');highlightedRects.push(r);}});});- function showPadTooltip(x, y, pad, rect) {- try{parent.postMessage({type:'lbr-hover',pin:String(pad.number)},'*')}catch(_){}- - if (stickyPad !== null) return; // suppress hover while sticky-- clearHighlight();- rect.setAttribute('stroke', '#00b8b1');- rect.setAttribute('stroke-width', '0.12');- rect.setAttribute('fill', 'rgba(0,184,177,0.2)');- highlightedRects.push(rect);-- tooltip.innerHTML = buildTooltipHtml(pad);- tooltip.classList.add('visible');- tooltip.classList.remove('sticky');- positionTooltip(x, y);- }-- function hidePadTooltip(rect) { try{parent.postMessage({type:'lbr-hover',pin:null},'*')}catch(_){}- if (stickyPad !== null) return; // don't hide while sticky- tooltip.classList.remove('visible');- clearHighlight();- }-- function handlePadClick(pad, rect) {- if (stickyPad === pad.number) {- // Unpin- stickyPad = null;- tooltip.classList.remove('sticky', 'visible');- clearHighlight();- } else {- // Pin this pad- stickyPad = pad.number;- clearHighlight();- rect.setAttribute('stroke', '#00b8b1');- rect.setAttribute('stroke-width', '0.12');- rect.setAttribute('fill', 'rgba(0,184,177,0.2)');- highlightedRects.push(rect);- tooltip.innerHTML = buildTooltipHtml(pad);- tooltip.classList.add('visible', 'sticky');- // Remove hint when sticky- const hint = tooltip.querySelector('.tt-hint');- if (hint) hint.style.display = 'none';- }- }-- // Dismiss sticky tooltip on click outside- document.addEventListener('click', (e) => {- if (stickyPad !== null && !tooltip.contains(e.target)) {- stickyPad = null;- tooltip.classList.remove('sticky', 'visible');- clearHighlight();- }- });-- function clearHighlight() {- highlightedRects.forEach(r => {- r.setAttribute('fill', 'transparent');- r.setAttribute('stroke', 'none');- });- highlightedRects = [];- }-- wrap.addEventListener('mouseleave', () => {- if (stickyPad !== null) return;- tooltip.classList.remove('visible');- clearHighlight();- });-- // Keyboard- window.addEventListener('keydown', (e) => {- if (e.key === 'Escape') closeRegenPrompt();- else if (e.key === 'f' || e.key === 'F') zoomFit();- else if (e.key === '1') zoomReset();- else if (e.key === '+' || e.key === '=') zoomIn();- else if (e.key === '-') zoomOut();- else if (e.key === 'g' || e.key === 'G') toggleGrid();- else if (e.key === 's' || e.key === 'S') toggleSolder();- else if (e.key === 'l' || e.key === 'L') toggleLabels();- });-- // Screenshot capture via postMessage (parent viewer sends mgmt_capture_request)- window.addEventListener('message', (e) => {- if (e.data?.type === 'mgmt_capture_request' && svgEl) {- const xml = new XMLSerializer().serializeToString(svgEl);- parent.postMessage({- type: 'mgmt_canvas_capture',- _reqId: e.data._reqId,- svgXml: xml,- svgWidth: svgW,- svgHeight: svgH,- bgColor: 'undefined',- }, '*');- }- });-- // Auto-fit on window/container resize- new ResizeObserver(() => zoomFit()).observe(wrap);- window.addEventListener('resize', () => requestAnimationFrame(zoomFit));-- // Init — set up layers then fit. Use multiple retries because srcdoc iframes- // may not have layout dimensions during the first requestAnimationFrame.- let inited = false;- function init() {- if (inited) return;- setupLabelToggle();- setupSolderBlobs();- setupPadHitZones();- inited = true;- }- function initAndFit() {- init();- zoomFit();- }- requestAnimationFrame(initAndFit);- setTimeout(initAndFit, 100);- setTimeout(initAndFit, 500);+ return shown;+ }++ // Wire pan + zoom on a viewport element (the box that contains the <svg>).+ // Returns a controller {fit, zoomIn, zoomOut, apply}.+ function initViewport(vp){+ var svg=vp.querySelector('svg');+ if(svg){ svg.removeAttribute('width'); svg.removeAttribute('height'); }+ var s=1,tx=0,ty=0;+ function apply(){ var el=vp.querySelector('svg'); if(el) el.style.transform='translate('+tx+'px,'+ty+'px) scale('+s+')'; }+ function zoomAt(cx,cy,f){ tx=cx-(cx-tx)*f; ty=cy-(cy-ty)*f; s*=f; apply(); }+ function fit(){ s=1;tx=0;ty=0;apply(); }+ // Smooth, delta-PROPORTIONAL zoom (matches adom-symbol). A fixed per-event+ // factor makes the trackpad leap — one gentle two-finger nudge fires a burst+ // of tiny wheel events and a fixed 1.12× compounds. Instead derive the factor+ // from how much actually scrolled (exp → smooth + reversible), normalise+ // device units, and clamp one event so a chunky mouse notch can't jump.+ var ZOOM_K=0.0022; // sensitivity: lower = gentler+ vp.addEventListener('wheel',function(e){+ e.preventDefault();+ var d=e.deltaY;+ if(e.deltaMode===1) d*=16; else if(e.deltaMode===2) d*=vp.clientHeight;+ d=Math.max(-50,Math.min(50,d));+ var r=vp.getBoundingClientRect();+ zoomAt(e.clientX-r.left,e.clientY-r.top,Math.exp(-d*ZOOM_K));+ },{passive:false});+ var drag=false,lx=0,ly=0;+ vp.addEventListener('pointerdown',function(e){ if(e.target.closest('.av-tools'))return; drag=true;lx=e.clientX;ly=e.clientY;vp.classList.add('grab');vp.setPointerCapture(e.pointerId); });+ vp.addEventListener('pointermove',function(e){ if(!drag)return; tx+=e.clientX-lx;ty+=e.clientY-ly;lx=e.clientX;ly=e.clientY;apply(); });+ vp.addEventListener('pointerup',function(){ drag=false;vp.classList.remove('grab'); });+ fit();+ return {+ fit:fit,+ zoomIn:function(){ var r=vp.getBoundingClientRect(); zoomAt(r.width/2,r.height/2,1.2); },+ zoomOut:function(){ var r=vp.getBoundingClientRect(); zoomAt(r.width/2,r.height/2,1/1.2); },+ apply:apply+ };+ }++ // Inject the standard zoom toolbar (+ / − / fit) + pan hint into a viewport,+ // wired to a controller from initViewport. Idempotent.+ function addToolbar(vp,ctl){+ if(vp.querySelector('.av-tools')) return;+ var t=document.createElement('div'); t.className='av-tools';+ t.innerHTML='<button data-z="in" title="Zoom in">'+ICON_ZIN+'</button>'+ +'<button data-z="out" title="Zoom out">'+ICON_ZOUT+'</button>'+ +'<button data-z="fit" title="Zoom to fit">'+ICON_FIT+'</button>';+ vp.appendChild(t);+ t.querySelector('[data-z="in"]').onclick=ctl.zoomIn;+ t.querySelector('[data-z="out"]').onclick=ctl.zoomOut;+ t.querySelector('[data-z="fit"]').onclick=ctl.fit;+ var h=document.createElement('div'); h.className='av-panhint'; h.textContent='drag to pan · scroll to zoom'; vp.appendChild(h);+ }++ window.AV={+ LAYER_DEFS:LAYER_DEFS, groupHasGeo:groupHasGeo, layerPresent:layerPresent,+ applyLayer:applyLayer, buildLayers:buildLayers, initViewport:initViewport,+ addToolbar:addToolbar, ICON_FIT:ICON_FIT, ICON_ZIN:ICON_ZIN, ICON_ZOUT:ICON_ZOUT+ };+})(); </script>--</body>-</html>+<script>+var META={"pasteApertures":2,"pasteDots":2};+(function(){+ var vp=document.getElementById('vp');+ var svg=vp.querySelector('svg');+ var ctl=AV.initViewport(vp);+ AV.addToolbar(vp,ctl);+ AV.buildLayers({ svg:svg, container:document.getElementById('layers'), state:{}, meta:META });+})();+</script>+<style>/* lbr-hover-bus v1 */ g[data-pad]{cursor:pointer} g[data-pad].lbr-hi rect,g[data-pad].lbr-hi circle,g[data-pad].lbr-hi path,g[data-pad].lbr-hi ellipse{stroke:#00e6dc!important;stroke-width:.12!important;fill:#e8505b!important;filter:drop-shadow(0 0 .25px #00e6dc)}</style><script>/* lbr-hover-bus v1 */(function(){function clr(){document.querySelectorAll('g[data-pad].lbr-hi').forEach(function(g){g.classList.remove('lbr-hi')})}function hi(n){clr();if(n==null)return;document.querySelectorAll('g[data-pad]').forEach(function(g){if(g.getAttribute('data-pad')===String(n))g.classList.add('lbr-hi')})}function post(n){try{parent.postMessage({type:'lbr-hover',pin:n==null?null:String(n)},'*')}catch(_){}}function wire(){document.querySelectorAll('g[data-pad]').forEach(function(g){if(g.__lbr)return;g.__lbr=1;var n=g.getAttribute('data-pad');g.addEventListener('mouseenter',function(){hi(n);post(n)});g.addEventListener('mouseleave',function(){clr();post(null)})})}addEventListener('message',function(e){var d=e.data||{};if(d.type==='lbr-hover')hi(d.pin)});if(document.readyState==='loading')addEventListener('DOMContentLoaded',wire);else wire();setTimeout(wire,800)})();</script></body></html>