app
Adom Library
Public Made by Adomby adom
adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.
← Commit history
Merge PR #47: source: sync src/ with the 2.7.1 the published binary was built from (recover-2.7.1-source -> master) merge
Showing changes introduced by the merge (against its first parent).
7 files changed
+953−42
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.6.3"+version = "2.7.1" edition = "2021" description = "adom-lbr generator / EDA translator: the canonical adom-lbr JSON is the hub, with native converters to Altium (.SchLib/.PcbLib/.IntLib), EAGLE/Fusion (.lbr), and KiCad. Altium output is written natively — no Altium needed at conversion time."
VERSION+1−1@@ -1 +1 @@-2.6.3+2.7.1
src/composed.htmladded+407@@ -0,0 +1,407 @@+<!doctype html><html lang=en><head><meta charset=utf-8>+<meta name=viewport content="width=device-width,initial-scale=1"><title>__TITLE__</title>+<style>+:root{--bg:#0a0e14;--panel:#111722;--panel2:#161d2b;--edge:#232c3d;--ink:#e6edf3;--mut:#9fb0c4;--mut2:#6b7a90;+ --acc:#00e6dc;--blue:#39b8ff;--purple:#8c6bf7;--hi:#00e6dc;+ --accent:linear-gradient(100deg,#00e6dc,#39b8ff 60%,#8c6bf7)}+*{box-sizing:border-box;margin:0}html,body{height:100%}+body{background:+ radial-gradient(1100px 700px at 88% -10%, rgba(0,184,177,.10), transparent 60%),+ radial-gradient(900px 700px at -5% 110%, rgba(140,107,247,.09), transparent 55%),+ var(--bg);+ color:var(--ink);font-family:'Satoshi',system-ui,-apple-system,'Segoe UI',sans-serif;+ -webkit-font-smoothing:antialiased;display:flex;flex-direction:column;overflow:hidden}+.wrap{height:100%;display:flex;flex-direction:column;padding:14px 18px 12px}+header{display:flex;align-items:baseline;gap:12px;margin-bottom:10px;flex:none}+header h1{font-size:17px;font-weight:750;letter-spacing:-.3px}+header .accent{background:var(--accent);-webkit-background-clip:text;background-clip:text;color:transparent}+header .lay{margin-left:auto;font:600 10.5px 'JetBrains Mono',ui-monospace,monospace;color:var(--mut2);+ text-transform:uppercase;letter-spacing:.1em;border:1px solid var(--edge);border-radius:6px;padding:3px 9px}+.stage{flex:1;min-height:0;display:grid;gap:13px}+.pane{background:var(--panel);border:1px solid var(--edge);border-radius:14px;display:flex;flex-direction:column;+ min-height:0;min-width:0;overflow:hidden;position:relative;transition:border-color .18s,box-shadow .18s}+.pane.lit{border-color:rgba(0,230,220,.4);box-shadow:0 0 0 1px rgba(0,230,220,.18),0 10px 40px rgba(0,0,0,.4)}+.pane h2{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mut2);font-weight:600;+ padding:11px 13px 8px;display:flex;align-items:center;gap:8px;flex:none}+.pane h2 .k{font:600 10px 'JetBrains Mono',ui-monospace,monospace;color:var(--mut);+ border:1px solid var(--edge);border-radius:5px;padding:1px 6px}+.pane h2 .ctl{margin-left:auto;display:flex;gap:5px;flex-wrap:wrap;justify-content:flex-end}+.pane .body{flex:1;min-height:0;min-width:0;position:relative}+.svgstage{position:absolute;inset:0;overflow:hidden;cursor:grab;touch-action:none}+.svgstage.drag{cursor:grabbing}+.svgstage svg{position:absolute;inset:0;width:100%;height:100%;display:block}+.v3d{position:absolute;inset:0}+.v3d canvas{width:100%;height:100%;display:block;outline:none;touch-action:none}+.ph{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--mut2);font-size:12px}++/* shared toggle/button chrome */+.tog{display:inline-flex;align-items:center;gap:6px;font:600 10.5px 'JetBrains Mono',ui-monospace,monospace;color:var(--mut2);+ background:var(--panel2);border:1px solid var(--edge);border-radius:999px;padding:3px 9px;cursor:pointer;user-select:none}+.tog.on{color:var(--ink);border-color:#3a4b66}.tog:not(.on){opacity:.55}+.tog .d{width:8px;height:8px;border-radius:50%;background:#485162}+.btn{font:600 10px 'JetBrains Mono',ui-monospace,monospace;letter-spacing:.03em;color:var(--mut2);background:var(--panel2);+ border:1px solid var(--edge);border-radius:6px;padding:3px 8px;cursor:pointer;transition:color .12s,border-color .12s}+.btn:hover{color:var(--ink);border-color:rgba(0,230,220,.4)}.btn.on{color:var(--acc);border-color:rgba(0,230,220,.5)}+.btn:disabled{opacity:.5;cursor:default}++/* SVG highlight (data-num based, adom-lbr convention) */+.pingroup,.pad{cursor:pointer}+[data-num].hi .pinvis,.pingroup.hi .pinvis{stroke:var(--hi)!important;stroke-width:7}+.pingroup.hi text,.pingroup.hi .pin-name,.pingroup.hi .pin-num{fill:var(--hi)!important}+rect.pad.hi{stroke:var(--hi);stroke-width:.2;paint-order:stroke;filter:drop-shadow(0 0 5px rgba(0,230,220,.7))}+.lyr-off{display:none}++/* pin-map pane */+.pins{position:absolute;inset:0;display:flex;flex-direction:column;min-height:0}+.pinscroll{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;padding:3px 6px 6px;+ scrollbar-width:thin;scrollbar-color:#3a4658 transparent}+.pinscroll::-webkit-scrollbar{width:9px}.pinscroll::-webkit-scrollbar-track{background:transparent}+.pinscroll::-webkit-scrollbar-thumb{background:#3a4658;border-radius:6px;border:2px solid transparent;background-clip:content-box}+.pinscroll::-webkit-scrollbar-thumb:hover{background:#4c5b71;background-clip:content-box}+.pinscroll::-webkit-scrollbar-button{display:none;height:0;width:0}+.prow-head,.prow{display:grid;align-items:center;gap:11px;padding:0 12px;height:36px;border-radius:7px}+.prow-head{position:sticky;top:0;z-index:1;background:linear-gradient(var(--panel),var(--panel) 72%,rgba(17,23,34,.55));+ color:var(--mut2);font:600 9.5px 'JetBrains Mono',monospace;letter-spacing:.1em;text-transform:uppercase;height:27px}+.prow{cursor:pointer;transition:background .1s}+.prow:hover{background:rgba(255,255,255,.04)}+.prow.hi{background:rgba(0,230,220,.12);box-shadow:inset 2px 0 0 var(--acc)}+.prow.off{display:none}+.pdot{width:9px;height:9px;border-radius:50%;justify-self:center;box-shadow:inset 0 0 0 1px rgba(0,0,0,.35)}+.prow.hi .pdot{box-shadow:0 0 7px 1px currentColor}+.prow .pn{font:600 12px 'JetBrains Mono',monospace;color:var(--mut2);text-align:right}+.prow.hi .pn{color:var(--acc)}+.prow .pnm{font:600 12.5px 'Satoshi',sans-serif;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+.prow .pdesc{min-width:0;font:500 11.5px 'Satoshi',sans-serif;color:var(--mut2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+.prow.hi .pdesc{color:var(--mut)}+.prow .ptype{font:600 10px 'JetBrains Mono',monospace;color:#06121a;padding:1px 7px;border-radius:5px;white-space:nowrap;justify-self:end}+.pinfilter{width:74px;background:var(--panel2);border:1px solid var(--edge);border-radius:6px;color:var(--ink);+ font:500 11px 'JetBrains Mono',ui-monospace,monospace;padding:3px 8px;outline:none;transition:border-color .12s,width .18s}+.pinfilter:focus{border-color:rgba(0,230,220,.45);width:104px}.pinfilter::placeholder{color:var(--mut2)}+.pinempty{color:var(--mut2);font:12px 'JetBrains Mono',monospace;padding:22px 12px;text-align:center}+.pinfoot{flex:none;border-top:1px solid var(--edge);padding:10px 13px 12px;background:rgba(0,0,0,.14)}+.pinstat{font:600 10.5px 'JetBrains Mono',monospace;color:var(--mut2);margin-bottom:9px}+.pinstat b{color:var(--ink);font-weight:700}.pinstat .lbl{text-transform:uppercase;letter-spacing:.1em;opacity:.75;margin-right:9px}+.typechips{display:flex;flex-wrap:wrap;gap:6px}+.typechip{display:inline-flex;align-items:center;gap:6px;cursor:pointer;user-select:none;+ font:600 10.5px 'JetBrains Mono',ui-monospace,monospace;color:var(--mut);+ background:color-mix(in srgb,var(--tc) 13%,transparent);border:1px solid color-mix(in srgb,var(--tc) 32%,transparent);+ border-radius:7px;padding:3px 8px;transition:background .12s,border-color .12s,color .12s}+.typechip .sw{width:8px;height:8px;border-radius:2px;background:var(--tc)}+.typechip .c{color:var(--mut2);font-weight:700}+.typechip:hover,.typechip.on{color:var(--ink);background:color-mix(in srgb,var(--tc) 26%,transparent);border-color:var(--tc)}+.readout{position:absolute;left:12px;bottom:11px;background:rgba(10,16,24,.85);border:1px solid var(--edge);+ border-radius:9px;padding:7px 11px;backdrop-filter:blur(7px);min-width:130px;font:600 12px 'JetBrains Mono',monospace;+ color:var(--mut);pointer-events:none}+.readout b{color:var(--acc)}.readout .rt{margin-left:8px;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut2)}+.readout.empty{color:var(--mut2);font-weight:500;font-size:11px}+</style></head><body>+<div class=wrap>+ <header>+ <h1>Adom <span class=accent>Library</span> <span id=hsub></span></h1>+ <span class=lay id=laytag></span>+ </header>+ <div class=stage id=stage></div>+</div>++<!-- data blobs (inert until JS wires them) -->+<script id=cfg type="application/json">__CONFIG__</script>+<template id=tpl-symbol>__SYM_SVG__</template>+<template id=tpl-footprint>__FP_SVG__</template>+<template id=tpl-layerbar>__LAYERBAR__</template>++<script>__VIEWERJS_INLINE__</script>+<script>+"use strict";+var CFG = JSON.parse(document.getElementById('cfg').textContent);+var PINS = CFG.pins.rows || [];+var GLB = CFG.glb || "", MARKS = CFG.marks || [];+document.getElementById('hsub').textContent = '· ' + (CFG.panes.join(' · '));+document.getElementById('laytag').textContent = CFG.layout;+document.title = CFG.mpn + ' — ' + CFG.layout;++var byNum = {}; PINS.forEach(function(p){ byNum[String(p.number)] = p; });+var stage = document.getElementById('stage');++var PANE_META = {+ symbol: {title:'Symbol', k:'.kicad_sym'},+ footprint: {title:'Footprint', k:'.kicad_mod'},+ '3d': {title:'3D model', k:'chip.glb'},+ pins: {title:'Pin map', k:''},+};++/* ---------- layout ---------- */+function applyLayout(){+ var panes = CFG.panes, n = panes.length;+ if (CFG.layout === 'columns') {+ stage.style.gridTemplateRows = '1fr';+ stage.style.gridTemplateColumns = panes.map(function(p){+ if (p === 'pins') return CFG.pins.description ? '300px' : '234px'; // narrow far-right column+ if (p === '3d') return '1.3fr';+ return '1fr';+ }).join(' ');+ } else if (CFG.layout === 'single') {+ stage.style.gridTemplateColumns = '1fr';+ stage.style.gridTemplateRows = 'repeat(' + n + ',1fr)';+ } else { /* quad */+ stage.style.gridTemplateColumns = n > 1 ? '1fr 1fr' : '1fr';+ stage.style.gridTemplateRows = n > 2 ? '1fr 1fr' : '1fr';+ }+}++function mkPane(id){+ var meta = PANE_META[id] || {title:id, k:''};+ var pane = document.createElement('div'); pane.className = 'pane'; pane.id = 'pane-' + id;+ var h = document.createElement('h2');+ h.innerHTML = meta.title + (meta.k ? ' <span class="k">' + meta.k + '</span>' : '');+ var ctl = document.createElement('span'); ctl.className = 'ctl'; h.appendChild(ctl);+ var body = document.createElement('div'); body.className = 'body';+ pane.appendChild(h); pane.appendChild(body); stage.appendChild(pane);+ return {pane:pane, ctl:ctl, body:body};+}++/* ---------- cross-highlight bus (one page → direct DOM; + BroadcastChannel for external embed) ---------- */+var SELF = 'c' + Math.random().toString(36).slice(2);+var BUS = ('BroadcastChannel' in window) ? new BroadcastChannel('lbr-hover') : null;+function els(n){ return [].slice.call(document.querySelectorAll('[data-num="' + cssq(n) + '"]')); }+function cssq(s){ return String(s).replace(/["\\]/g, '\\$&'); }+function clearHi(){ [].slice.call(document.querySelectorAll('.hi')).forEach(function(e){ e.classList.remove('hi'); }); }+function paneLit(on){ ['symbol','footprint','3d','pins'].forEach(function(id){ var p=document.getElementById('pane-'+id); if(p)p.classList.toggle('lit', !!on); }); }+function apply(n){+ clearHi();+ if (n != null && n !== '') {+ els(n).forEach(function(e){ e.classList.add('hi'); });+ var r = document.querySelector('.prow[data-num="' + cssq(n) + '"]');+ if (r && r.scrollIntoView) { try { r.scrollIntoView({block:'nearest'}); } catch(_){} }+ }+ if (window.__lbr3dHighlight) window.__lbr3dHighlight(n == null ? null : n);+ updateReadouts(n);+ paneLit(n != null && n !== '');+}+function post(n){ var m={type:'lbr-hover',pin:(n==null||n==='')?null:String(n),mpn:CFG.mpn,src:SELF};+ try{ BUS && BUS.postMessage(m); }catch(_){}+ try{ if(window.parent && window.parent!==window) window.parent.postMessage(m,'*'); }catch(_){} }+function setPin(n){ apply(n); post(n); }+if (BUS) BUS.onmessage = function(e){ var d=e.data||{}; if(d.type!=='lbr-hover'||d.src===SELF) return; apply(d.pin); };+window.addEventListener('message', function(e){ var d=e.data||{}; if(d.type!=='lbr-hover'||d.src===SELF) return; apply(d.pin); });++var readouts = [];+function updateReadouts(n){+ var p = n==null?null:byNum[String(n)];+ readouts.forEach(function(r){+ if (!p) { r.className='readout empty'; r.textContent='hover a pin'; return; }+ r.className='readout';+ r.innerHTML='<b>PIN '+esc(p.number)+'</b> '+esc(p.name)+(p.type?'<span class="rt">'+esc(p.type)+'</span>':'');+ });+}+function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }++function wireHover(root){+ [].slice.call(root.querySelectorAll('[data-num]')).forEach(function(g){+ var n = g.getAttribute('data-num');+ g.addEventListener('mouseenter', function(){ setPin(n); });+ g.addEventListener('mouseleave', function(){ setPin(null); });+ });+}++/* ---------- SVG pane (symbol / footprint): inject + pan/zoom + hover + (footprint) layers ---------- */+function buildSvgPane(id, tplId, withLayers, ctl, body){+ var stg = document.createElement('div'); stg.className='svgstage'; body.appendChild(stg);+ var ro = document.createElement('div'); ro.className='readout empty'; ro.textContent='hover a pin'; body.appendChild(ro); readouts.push(ro);+ var frag = document.getElementById(tplId).content.cloneNode(true);+ stg.appendChild(frag);+ var svg = stg.querySelector('svg');+ if (!svg) { stg.innerHTML = '<div class="ph">no '+id+' render</div>'; return; }+ wireHover(stg);+ attachPanZoom(stg, svg);+ if (withLayers) buildLayerToggles(ctl, stg);+}+function attachPanZoom(stg, svg){+ var vb = (svg.getAttribute('viewBox')||'0 0 100 100').split(/\s+/).map(Number), base = vb.slice();+ function setVB(){ svg.setAttribute('viewBox', vb.join(' ')); }+ stg.addEventListener('wheel', function(e){ e.preventDefault(); var r=stg.getBoundingClientRect();+ var mx=(e.clientX-r.left)/r.width, my=(e.clientY-r.top)/r.height, f=e.deltaY<0?0.88:1.14;+ var nw=vb[2]*f, nh=vb[3]*f; vb[0]+=(vb[2]-nw)*mx; vb[1]+=(vb[3]-nh)*my; vb[2]=nw; vb[3]=nh; setVB(); }, {passive:false});+ var pan=null;+ stg.addEventListener('pointerdown', function(e){ pan={x:e.clientX,y:e.clientY}; stg.classList.add('drag'); try{stg.setPointerCapture(e.pointerId);}catch(_){} });+ stg.addEventListener('pointermove', function(e){ if(!pan)return; var r=stg.getBoundingClientRect();+ vb[0]-=(e.clientX-pan.x)/r.width*vb[2]; vb[1]-=(e.clientY-pan.y)/r.height*vb[3]; pan={x:e.clientX,y:e.clientY}; setVB(); });+ function end(){ pan=null; stg.classList.remove('drag'); }+ stg.addEventListener('pointerup', end); stg.addEventListener('pointerleave', end);+ stg.addEventListener('dblclick', function(){ vb=base.slice(); setVB(); });+}+function buildLayerToggles(ctl, stg){+ var bar = document.getElementById('tpl-layerbar').content.cloneNode(true);+ var holder = document.createElement('span'); holder.appendChild(bar);+ [].slice.call(holder.querySelectorAll('.lyrtog')).forEach(function(b){+ b.classList.add('tog');+ function apply(on){ [].slice.call(stg.querySelectorAll('.ly-'+b.getAttribute('data-lyr'))).forEach(function(el){ el.classList.toggle('lyr-off', !on); }); }+ apply(b.classList.contains('on'));+ b.addEventListener('click', function(){ apply(b.classList.toggle('on')); });+ ctl.appendChild(b);+ });+}++/* ---------- pin-map pane ---------- */+function buildPinsPane(ctl, body){+ var wrap = document.createElement('div'); wrap.className='pins'; body.appendChild(wrap);+ var full = !!CFG.pins.description, rollup = !!CFG.pins.rollup;+ // header controls: count + filter+ var count = document.createElement('span'); count.className='k'; count.textContent = PINS.length + ' pins';+ var h2 = body.parentNode.querySelector('h2'); h2.insertBefore(count, ctl);+ var filter = document.createElement('input'); filter.className='pinfilter'; filter.type='text';+ filter.placeholder='filter'; filter.setAttribute('aria-label','filter pins'); filter.spellcheck=false; ctl.appendChild(filter);++ var scroll = document.createElement('div'); scroll.className='pinscroll'; wrap.appendChild(scroll);+ var head = document.createElement('div'); head.className='prow-head';+ var cols = full ? '15px 28px 74px minmax(0,1fr) auto' : '15px 30px 1fr auto';+ head.style.gridTemplateColumns = cols;+ head.innerHTML = full+ ? '<span></span><span style="text-align:right">#</span><span>Name</span><span>Description</span><span>Type</span>'+ : '<span></span><span style="text-align:right">#</span><span>Name</span><span>Type</span>';+ scroll.appendChild(head);++ var rows = [];+ PINS.forEach(function(p){+ var row = document.createElement('div'); row.className='prow'; row.setAttribute('data-num', p.number);+ row.style.gridTemplateColumns = cols;+ row.dataset.hay = ((p.name||'')+' '+(p.type||'')+' '+(p.desc||'')).toLowerCase();+ var col = p.color || '#5c6b82';+ var chip = '<span class="ptype" style="background:'+col+'">'+esc(p.type||'—')+'</span>';+ row.innerHTML = '<span class="pdot" style="background:'+col+';color:'+col+'"></span>'+ + '<span class="pn">'+esc(p.number)+'</span>'+ + '<span class="pnm" title="'+esc(p.name)+'">'+esc(p.name)+'</span>'+ + (full ? '<span class="pdesc" title="'+esc(p.desc||'')+'">'+esc(p.desc||'')+'</span>' : '')+ + chip;+ scroll.appendChild(row); rows.push(row);+ row.addEventListener('mouseenter', function(){ setPin(p.number); });+ row.addEventListener('mouseleave', function(){ setPin(null); });+ });++ // type roll-up footer (full/quad only) — the "earn its space" summary + type highlighter+ if (rollup) {+ var foot = document.createElement('div'); foot.className='pinfoot'; wrap.appendChild(foot);+ var typeMap = {}; var order = [];+ PINS.forEach(function(p){ var t=p.type||'—'; if(!typeMap[t]){typeMap[t]=[];order.push(t);} typeMap[t].push(p.number); });+ var stat = document.createElement('div'); stat.className='pinstat';+ stat.innerHTML = '<span class="lbl">types</span><b>'+PINS.length+'</b> pins · <b>'+order.length+'</b> types';+ foot.appendChild(stat);+ var chips = document.createElement('div'); chips.className='typechips'; var chipEls=[];+ order.forEach(function(t){ var nums=typeMap[t], col=(PINS.find(function(p){return (p.type||'—')===t;})||{}).color||'#5c6b82';+ var c=document.createElement('div'); c.className='typechip'; c.style.setProperty('--tc',col); c.dataset.nums=nums.join(',');+ c.innerHTML='<span class="sw"></span>'+esc(t)+'<span class="c">'+nums.length+'</span>';+ c.addEventListener('mouseenter', function(){ hiSet(nums); }); c.addEventListener('mouseleave', function(){ setPin(null); });+ chips.appendChild(c); chipEls.push(c); });+ foot.appendChild(chips);+ // light chips whose pins are active+ window.__pinChipSync = function(n){ chipEls.forEach(function(c){ c.classList.toggle('on', n!=null && c.dataset.nums.split(',').indexOf(String(n))>=0); }); };+ }++ // filter+ var empty=null;+ filter.addEventListener('input', function(){+ var q=filter.value.trim().toLowerCase(), shown=0;+ rows.forEach(function(r){ var ok=!q||(r.getAttribute('data-num')+' '+r.dataset.hay).indexOf(q)>=0; r.classList.toggle('off',!ok); if(ok)shown++; });+ count.textContent = q ? shown+' / '+PINS.length : PINS.length+' pins';+ if(!shown){ if(!empty){empty=document.createElement('div');empty.className='pinempty';scroll.appendChild(empty);} empty.textContent='no pins match "'+filter.value+'"'; empty.style.display=''; }+ else if(empty) empty.style.display='none';+ });+}+// highlight a SET of pins (type chip hover): light all their [data-num] + rows+function hiSet(nums){+ clearHi();+ nums.forEach(function(n){ els(n).forEach(function(e){ e.classList.add('hi'); }); });+ paneLit(true);+ // no single readout for a set; leave readouts as-is+}++/* ---------- 3D pane ---------- */+function build3dPane(ctl, body){+ var stg = document.createElement('div'); stg.className='v3d'; body.appendChild(stg);+ var ph = document.createElement('div'); ph.className='ph'; ph.textContent='loading 3D…'; body.appendChild(ph);+ if (!GLB || typeof window.Adom3DViewer === 'undefined') { ph.textContent='3D unavailable'; return; }+ var B = (window.Adom3DViewer && Adom3DViewer.BABYLON) || {};+ var SCENE=null, pinMeshes=new Map(), pinCenter=new Map(), lit3d=[];+ function isHelper(n){ return /arcrotate|background|skybox|indicator|helper|ground|viewcube|viewerroot|laseretch|dbg/i.test(n||''); }+ function realMeshes(s){ return (s.meshes||[]).filter(function(m){ return m.getTotalVertices&&m.getTotalVertices()>0 && !isHelper(m.name); }); }+ function byRe(s,re){ return realMeshes(s).filter(function(m){ return re.test(m.name||''); }); }+ var RE={solder:/solder/i, copper:/copper/i, fr4:/fr4/i};+ var v=null;+ try {+ v=Adom3DViewer.init(stg,{modelUrl:GLB,zUp:true,showGround:false,showViewCube:true,initialViewMode:"perspective"});+ var prev=-1, stable=0, tries=0, iv=setInterval(function(){+ tries++;+ try { var s=v.getScene(), n=realMeshes(s).length; stable=(n>0&&n===prev)?stable+1:0; prev=n;+ if(n>0&&stable>=3){ clearInterval(iv); try{v.frameModel&&v.frameModel();}catch(_){} try{v.goHome&&v.goHome();}catch(_){} ph.remove(); onReady(s); } }catch(_){}+ if(tries>100){ clearInterval(iv); ph.textContent='3D: model did not load'; }+ },200);+ } catch(err){ ph.textContent='3D unavailable: '+err; }++ // lead/pad naming is convention-tolerant: adom board GLBs use pad_N / lead_N /+ // pin_N; chip GLBs from the fit pipeline use U1pN (pad) / U1lN (lead). group1 = pin number.+ var leadRe = /(?:lead|pin|pad|u\d*[pl])[^0-9]?0*(\d+)(?:[^0-9]|$)/i;+ function onReady(s){+ SCENE=s;+ try{ s.lights&&s.lights.forEach(function(l){ l.shadowEnabled=false; }); (s.meshes||[]).forEach(function(m){ m.receiveShadows=false; }); }catch(_){}+ // layer toggles+ [['solder','Solder','#c9ccd1'],['copper','Copper','#d8a24a'],['fr4','Board','#2f7d4f']].forEach(function(t){+ var b=document.createElement('button'); b.className='tog on'; b.setAttribute('data-mesh',t[0]);+ b.innerHTML='<span class="d" style="background:'+t[2]+'"></span>'+t[1];+ b.addEventListener('click', function(){ var on=b.classList.toggle('on'); byRe(s,RE[t[0]]).forEach(function(m){ m.setEnabled(on); }); });+ ctl.appendChild(b);+ });+ // index every lead/pad mesh by pin number + its world centre (for the pick fallback)+ realMeshes(s).forEach(function(m){+ var mm=leadRe.exec(m.name||''); if(!mm) return;+ var n=mm[1]; if(!pinMeshes.has(n))pinMeshes.set(n,[]); pinMeshes.get(n).push(m);+ try{ m.computeWorldMatrix(true); }catch(_){}+ });+ pinMeshes.forEach(function(ms,n){ try{ pinCenter.set(n, ms[0].getBoundingInfo().boundingSphere.centerWorld.clone()); }catch(_){} });+ // 3D -> others: prefer a direct lead hit; else map the hit point to the NEAREST pin+ // (forgiving — the leads are tiny, so a pixel-perfect hit isn't required)+ function pinAtPointer(){+ try{+ var hit=s.pick(s.pointerX,s.pointerY,function(m){ return leadRe.test(m.name||''); });+ if(hit&&hit.hit&&hit.pickedMesh){ var mm=leadRe.exec(hit.pickedMesh.name||''); if(mm) return mm[1]; }+ if(pinCenter.size){+ var any=s.pick(s.pointerX,s.pointerY);+ if(any&&any.hit&&any.pickedPoint){+ var best=null,bd=Infinity;+ pinCenter.forEach(function(c,n){ var d=B.Vector3.DistanceSquared(c,any.pickedPoint); if(d<bd){bd=d;best=n;} });+ return best;+ }+ }+ }catch(_){}+ return null;+ }+ // NOTE: this bundle's Adom3DViewer.BABYLON does NOT expose PointerEventTypes or+ // HighlightLayer — drive off the scene's onPointerMove hook instead.+ var lastPin=null;+ s.onPointerMove=function(){ var n=pinAtPointer(); if(n!==lastPin){ lastPin=n; setPin(n); } };+ }+ // others -> 3D: per-mesh overlay. The GLB shares ONE material across all leads,+ // so emissiveColor would bleed to every pin; renderOverlay is per-mesh and+ // isolates the hovered pin's lead + pad. (HighlightLayer isn't in this bundle.)+ window.__lbr3dHighlight = function(n){+ lit3d.forEach(function(m){ try{ m.renderOverlay=false; }catch(_){} }); lit3d=[];+ if(n==null||!SCENE||!B.Color3) return;+ var ms=pinMeshes.get(String(n)); if(!ms) return;+ ms.forEach(function(m){ try{ m.overlayColor=new B.Color3(0.0,0.90,0.85); m.overlayAlpha=0.55; m.renderOverlay=true; lit3d.push(m); }catch(_){} });+ };+}++/* ---------- boot ---------- */+applyLayout();+CFG.panes.forEach(function(id){+ var slot = mkPane(id);+ if (id==='symbol') buildSvgPane('symbol','tpl-symbol', false, slot.ctl, slot.body);+ else if (id==='footprint') buildSvgPane('footprint','tpl-footprint', true, slot.ctl, slot.body);+ else if (id==='3d') build3dPane(slot.ctl, slot.body);+ else if (id==='pins') buildPinsPane(slot.ctl, slot.body);+});+// keep the type-chip sync hooked into apply()+var _apply = apply;+apply = function(n){ _apply(n); if(window.__pinChipSync) window.__pinChipSync(n==null?null:n); };+function reheight(){ try{ parent.postMessage({type:'readme-height',height:Math.max(360,Math.min(16000,document.body.scrollHeight||600))},'*'); }catch(_){} }+window.addEventListener('load', reheight); setTimeout(reheight, 400);+</script>+</body></html>
src/main.rs+107−38@@ -83,6 +83,57 @@ enum Commands { #[arg(long)] out: Option<PathBuf>, },+ /// Export per-slot interactive viewers (viewers/symbol.html + footprint.html)+ /// with the cross-highlight bus + tooltips + layer toggles, for a wiki+ /// component page's native viewer slots. CAD is rendered from the wiki slug;+ /// pin descriptions come from <MPN>-symbol.extracted.json in --dir.+ ExportViewers {+ /// Wiki page slug (renders symbol/footprint from its CAD)+ #[arg(long)]+ slug: String,+ /// Local chip dir holding <MPN>-symbol.extracted.json (pin type + descriptions)+ #[arg(long)]+ dir: PathBuf,+ /// Output directory for the viewer HTML files (default: ./viewers)+ #[arg(long)]+ out: Option<PathBuf>,+ /// Omit the symbol slot's built-in pin table (use when the page carries its+ /// own pin table, e.g. a rich readme datasheet table, to avoid duplication)+ #[arg(long)]+ no_pin_table: bool,+ },+ /// Export ONE configurable, cross-linked viewer combining symbol + footprint ++ /// 3D + pin-map in a declared layout (columns | quad | single). Which panes and+ /// per-pane options (e.g. the pin-map description column) are configurable via+ /// flags or a page.json `lbrView` block — same spirit as the wiki `sections`+ /// allowlist. Renders CAD from the hub/slug; pin descriptions come from+ /// <MPN>-symbol.extracted.json in --dir.+ View {+ /// Wiki page slug (or hub <MPN>.adom-lbr.json in --dir)+ #[arg(long)]+ slug: String,+ /// Local chip dir holding the hub JSON + <MPN>-symbol.extracted.json + .glb+ #[arg(long)]+ dir: PathBuf,+ /// Output .html path (default: <MPN>-lbr-<layout>.html in --dir)+ #[arg(long)]+ out: Option<PathBuf>,+ /// Layout mode: columns | quad | single+ #[arg(long, default_value = "quad")]+ layout: String,+ /// Comma-separated panes in order (default: all available). e.g. symbol,footprint,3d,pins+ #[arg(long)]+ panes: Option<String>,+ /// Show the pin-map description column (default: on for quad/single, off for columns)+ #[arg(long)]+ description: Option<bool>,+ /// Show the pin-map type roll-up footer (default: on for quad)+ #[arg(long)]+ rollup: Option<bool>,+ /// Read layout/panes/paneOpts from a page.json `lbrView` block (overrides the flags)+ #[arg(long)]+ config: Option<PathBuf>,+ }, /// Run the live app server (AI-drivable: /state, /load, /eval, /console) Serve { /// Port to listen on@@ -1389,7 +1440,7 @@ fn read_or_exit(path: &std::path::Path) -> String { } /// Load an adom-lbr JSON into the canonical part.-fn load_part(path: &std::path::Path) -> altium_codec::AdomLbrPart {+pub(crate) fn load_part(path: &std::path::Path) -> altium_codec::AdomLbrPart { let json = read_or_exit(path); serde_json::from_str(&json).unwrap_or_else(|e| { eprintln!("ERROR: parse adom-lbr JSON {}: {e}", path.display());@@ -1615,12 +1666,26 @@ fn main() { } } + Commands::ExportViewers { slug, dir, out, no_pin_table } => {+ let out_dir = out.unwrap_or_else(|| PathBuf::from("viewers"));+ if let Err(e) = manage::export_viewers(&slug, &dir, &out_dir, no_pin_table) {+ eprintln!("ERROR: {e}"); std::process::exit(1);+ }+ } Commands::Embed { dir, out } => { if let Err(e) = cmd_embed(&dir, out) { eprintln!("ERROR: {e:#}"); process::exit(1); } }+ Commands::View { slug, dir, out, layout, panes, description, rollup, config } => {+ let (layout, panes_vec, description, rollup) =+ resolve_view_cfg(layout, panes, description, rollup, config.as_deref());+ if let Err(e) = manage::export_composed(&slug, &dir, out, &layout, &panes_vec, description, rollup) {+ eprintln!("ERROR: {e}");+ process::exit(1);+ }+ } Commands::Manage { port } => { if let Err(e) = manage::run(port) { eprintln!("ERROR: {e}");@@ -1785,7 +1850,7 @@ fn esc_html(s: &str) -> String { } /// (human label, hex color) for a ds2sf/kicad pin type token.-fn pin_type_style(t: &str) -> (&'static str, &'static str) {+pub(crate) fn pin_type_style(t: &str) -> (&'static str, &'static str) { match t.to_lowercase().replace('-', "_").as_str() { "power_in" | "power_out" | "power" => ("Power", "#ef5350"), "input" | "in" => ("Input", "#26c6da"),@@ -1800,6 +1865,40 @@ fn pin_type_style(t: &str) -> (&'static str, &'static str) { } } +/// Resolve the `View` layout config. A `--config page.json` with an `lbrView`+/// block fully determines layout/panes/paneOpts (overriding the flags); otherwise+/// the flags are used. Returns (layout, panes, pins.description, pins.rollup) where+/// the two Options are None when the caller should derive the layout default.+fn resolve_view_cfg(+ layout: String,+ panes: Option<String>,+ description: Option<bool>,+ rollup: Option<bool>,+ config: Option<&std::path::Path>,+) -> (String, Vec<String>, Option<bool>, Option<bool>) {+ if let Some(cfgpath) = config {+ if let Ok(txt) = fs::read_to_string(cfgpath) {+ if let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) {+ if let Some(lv) = v.get("lbrView") {+ let layout = lv.get("layout").and_then(|x| x.as_str()).unwrap_or(&layout).to_string();+ let panes: Vec<String> = lv.get("panes").and_then(|x| x.as_array())+ .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())+ .unwrap_or_default();+ let pins = lv.get("paneOpts").and_then(|x| x.get("pins"));+ let desc = pins.and_then(|p| p.get("description")).and_then(|x| x.as_bool());+ let roll = pins.and_then(|p| p.get("rollup")).and_then(|x| x.as_bool());+ return (layout, panes, desc, roll);+ }+ }+ }+ eprintln!("WARN: --config {} has no usable `lbrView` block; using flags", cfgpath.display());+ }+ let panes_vec = panes+ .map(|s| s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect())+ .unwrap_or_default();+ (layout, panes_vec, description, rollup)+}+ fn read_first(dir: &std::path::Path, names: &[String]) -> Option<String> { for n in names { if let Ok(s) = fs::read_to_string(dir.join(n)) {@@ -1809,47 +1908,17 @@ fn read_first(dir: &std::path::Path, names: &[String]) -> Option<String> { None } -/// Find the first file in `dir` whose name ends with `suffix` (case-insensitive-/// on the suffix), returning its path. Results are sorted for determinism.-///-/// ds2sf names its emitted files by the MPN / symbolName (e.g.-/// `74HC595BQ,115-symbol.extracted.json`), which frequently differs — in case or-/// exact form — from the chip *directory* name (e.g. `74hc595bq-dhvqfn16`). So-/// deriving the filename from the dir name misses the file entirely. Matching by-/// suffix finds it regardless of the prefix.-fn find_by_suffix(dir: &std::path::Path, suffix: &str) -> Option<PathBuf> {- let suf = suffix.to_lowercase();- let mut hits: Vec<PathBuf> = fs::read_dir(dir).ok()?- .filter_map(|e| e.ok().map(|e| e.path()))- .filter(|p| p.file_name().and_then(|n| n.to_str())- .map(|n| n.to_lowercase().ends_with(&suf)).unwrap_or(false))- .collect();- hits.sort();- hits.into_iter().next()-}--/// Read a dir file by exact name (any of `names`) first, then fall back to the-/// first file matching `suffix`. Robust to MPN-vs-dirname prefix drift.-fn read_first_or_suffix(dir: &std::path::Path, names: &[String], suffix: &str) -> Option<String> {- read_first(dir, names)- .or_else(|| find_by_suffix(dir, suffix).and_then(|p| fs::read_to_string(p).ok()))-}- fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String> { let mpn = dir.file_name().and_then(|s| s.to_str()).ok_or("bad --dir")?.to_string(); // renders- let sym_svg = read_first_or_suffix(dir, &[format!("{mpn}-symbol.svg")], "-symbol.svg")+ let sym_svg = read_first(dir, &[format!("{mpn}-symbol.svg")])+ .map(|s| s) .unwrap_or_else(|| "<div class=\"ph\">no symbol render in this directory</div>".into());- let fp_svg = read_first(dir,- &[format!("{mpn}-footprint.authoritative.svg"), format!("{mpn}-footprint.svg")])- .or_else(|| find_by_suffix(dir, "-footprint.authoritative.svg").and_then(|p| fs::read_to_string(p).ok()))- .or_else(|| find_by_suffix(dir, "-footprint.svg").and_then(|p| fs::read_to_string(p).ok()))+ let fp_svg = read_first(dir, &[format!("{mpn}-footprint.authoritative.svg"), format!("{mpn}-footprint.svg")]) .unwrap_or_else(|| "<div class=\"ph\">no footprint render in this directory</div>".into()); let threed = { let cands = ["-3d-iso-named.png", "-3d-iso.png", "-3d-iso-lg.png"]- .iter().map(|s| dir.join(format!("{mpn}{s}"))).find(|p| p.exists())- .or_else(|| ["-3d-iso-named.png", "-3d-iso.png", "-3d-iso-lg.png"]- .iter().find_map(|s| find_by_suffix(dir, s)));+ .iter().map(|s| dir.join(format!("{mpn}{s}"))).find(|p| p.exists()); match cands.and_then(|p| fs::read(&p).ok()) { Some(bytes) => format!("<img src=\"data:image/png;base64,{}\">", b64(&bytes)), None => "<div class=\"ph\">no 3D render in this directory</div>".into(),@@ -1857,7 +1926,7 @@ fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String> }; // optional pin→pad connects from a .lbr let mut pin_to_pad: std::collections::HashMap<String, String> = std::collections::HashMap::new();- if let Some(lbr) = read_first_or_suffix(dir, &[format!("{mpn}.lbr")], ".lbr") {+ if let Some(lbr) = read_first(dir, &[format!("{mpn}.lbr")]) { if let Ok(re) = regex_lite::Regex::new(r#"<connect gate="[^"]*" pin="([^"]+)" pad="([^"]+)"/>"#) { for cap in re.captures_iter(&lbr) { pin_to_pad.insert(cap[1].to_string(), cap[2].to_string());@@ -1867,7 +1936,7 @@ fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String> // pins + descriptions from the extracted symbol JSON let mut pins_json = String::from("[]"); let mut pad_count = fp_svg.matches("adom-pad").count().max(0);- if let Some(ext) = read_first_or_suffix(dir, &[format!("{mpn}-symbol.extracted.json")], "-symbol.extracted.json") {+ if let Some(ext) = read_first(dir, &[format!("{mpn}-symbol.extracted.json")]) { if let Ok(v) = serde_json::from_str::<serde_json::Value>(&ext) { if let Some(arr) = v.get("pins").and_then(|p| p.as_array()) { let rows: Vec<serde_json::Value> = arr.iter().map(|p| {
src/manage.rs+217−2@@ -379,7 +379,8 @@ fn symbol_svg(sym: &Symbol) -> String { for g in &sym.graphics { match g { Graphic::Rect { x1, y1, x2, y2, filled, .. } => {- let fill = if *filled { "#e35d5d" } else { "none" };+ // KiCad "background" fill → a faint tint, not a solid slab (reads as an outlined body on dark)+ let fill = if *filled { "#e35d5d22" } else { "none" }; body += &format!("<rect x='{:.1}' y='{:.1}' width='{:.1}' height='{:.1}' fill='{}' stroke='#e35d5d' stroke-width='4'/>", x1.min(*x2), fy(y1.max(*y2)), (x2 - x1).abs(), (y2 - y1).abs(), fill); }@@ -487,8 +488,12 @@ fn footprint_svg(fp: &Footprint, names: &std::collections::HashMap<String, Strin let rx = if p.shape == "circle" || p.shape == "roundrect" { p.w_mm.min(p.h_mm) * 0.25 } else { 0.0 }; // the connected pin name (if the symbol has one) becomes the pad's label let name = names.get(&p.number).cloned().unwrap_or_default();- body += &format!("<rect class='ly-copper pad' data-num='{}' data-name='{}' x='{:.3}' y='{:.3}' width='{:.3}' height='{:.3}' rx='{:.3}' fill='{}' opacity='0.9'/>",+ body += &format!("<rect class='ly-copper pad' data-num='{}' data-name='{}' x='{:.3}' y='{:.3}' width='{:.3}' height='{:.3}' rx='{:.3}' fill='{}' stroke='#00000033' stroke-width='0.03' opacity='0.94'/>", esc_attr(&p.number), esc_attr(&name), p.x_mm - p.w_mm / 2.0, p.y_mm - p.h_mm / 2.0, p.w_mm, p.h_mm, rx, col);+ // pad number, centred on the pad (dark on copper) — matches the CAD mockup+ let fs = (p.w_mm.min(p.h_mm) * 0.6).clamp(0.35, 1.1);+ body += &format!("<text class='padnum' x='{:.3}' y='{:.3}' font-size='{:.3}' fill='#1c1206' text-anchor='middle' dominant-baseline='central' pointer-events='none' font-family='ui-monospace,monospace' font-weight='700'>{}</text>",+ p.x_mm, p.y_mm, fs, esc_attr(&p.number)); } format!( "<svg xmlns='http://www.w3.org/2000/svg' viewBox='{:.3} {:.3} {:.3} {:.3}' style='width:100%;height:100%'>{}</svg>",@@ -927,3 +932,213 @@ fn json_resp(s: &str) -> Response<Cursor<Vec<u8>>> { } const INDEX_HTML: &str = include_str!("manage.html");++// ---------------------------------------------------------------------------+// export-viewers: emit per-slot, self-contained interactive viewers+// (viewers/symbol.html + viewers/footprint.html) that carry the cross-highlight+// bus, hover tooltips, and (footprint) layer toggles. These drop into a wiki+// component page's native viewer slots (page.json component.viewers) — NOT the+// 3-column embed. Symbol/footprint SVG are rendered on the fly from the hub;+// pin type + datasheet description come from <MPN>-symbol.extracted.json.+// ---------------------------------------------------------------------------+pub fn export_viewers(slug: &str, dir: &std::path::Path, out_dir: &std::path::Path, no_pin_table: bool) -> Result<(), String> {+ let mpn = up(slug);+ // Prefer the canonical local hub (<MPN>.adom-lbr.json in --dir); fall back to+ // fetching + importing the wiki page's CAD by slug.+ let hub = dir.join(format!("{mpn}.adom-lbr.json"));+ let part = if hub.exists() { crate::load_part(&hub) } else { load_part(slug, &mpn)?.0 };+ let sym_svg = symbol_svg(&part.symbol);+ let fp_svg = footprint_svg(&part.footprint, &pin_names(&part.symbol));++ // pin type + datasheet description keyed by pin number, from the ds2sf extract+ let mut meta: std::collections::HashMap<String, (String, String)> = std::collections::HashMap::new();+ let ext = dir.join(format!("{mpn}-symbol.extracted.json"));+ if let Ok(txt) = std::fs::read_to_string(&ext) {+ if let Ok(v) = serde_json::from_str::<Value>(&txt) {+ if let Some(arr) = v.get("pins").and_then(|p| p.as_array()) {+ for p in arr {+ let num = p.get("number").and_then(|x| x.as_str()).unwrap_or("").to_string();+ let (label, _c) = crate::pin_type_style(p.get("type").and_then(|x| x.as_str()).unwrap_or(""));+ let d = p.get("description").and_then(|x| x.as_str()).unwrap_or("").to_string();+ meta.insert(num, (label.to_string(), d));+ }+ }+ }+ }+ let rows: Vec<Value> = part.symbol.pins.iter().map(|p| {+ let (ty, d) = meta.get(&p.number).cloned().unwrap_or_default();+ let short = d.split_once(". ").map(|(a, _)| format!("{a}.")).unwrap_or(d);+ json!({ "number": p.number, "name": p.name, "type": ty, "desc": short })+ }).collect();+ let pins_json = serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into());+ let mpn_json = serde_json::to_string(&mpn).unwrap_or_else(|_| "\"\"".into());++ let tmpl = include_str!("viewer2d.html");+ std::fs::create_dir_all(out_dir).map_err(|e| format!("mkdir {}: {e}", out_dir.display()))?;++ let sym_html = tmpl+ .replace("__TITLE__", &format!("{mpn} symbol"))+ .replace("__KIND__", "Schematic symbol")+ .replace("__KINDJSON__", "\"symbol\"")+ .replace("__MPNJSON__", &mpn_json)+ .replace("__PINS__", &pins_json)+ .replace("__NOTABLE__", if no_pin_table { "true" } else { "false" })+ .replace("__LAYERBAR__", "")+ .replace("__SVG__", &sym_svg);+ std::fs::write(out_dir.join("symbol.html"), sym_html).map_err(|e| e.to_string())?;++ let layerbar = "<button class=\"lyrtog on\" data-lyr=\"copper\"><span class=\"d\" style=\"background:#e35d5d\"></span>Copper</button>\+<button class=\"lyrtog on\" data-lyr=\"silk\"><span class=\"d\" style=\"background:#e6edf3\"></span>Silkscreen</button>\+<button class=\"lyrtog\" data-lyr=\"fab\"><span class=\"d\" style=\"background:#8b949e\"></span>Fab</button>\+<button class=\"lyrtog\" data-lyr=\"courtyard\"><span class=\"d\" style=\"background:#bc8cff\"></span>Courtyard</button>";+ let fp_html = tmpl+ .replace("__TITLE__", &format!("{mpn} footprint"))+ .replace("__KIND__", "PCB footprint")+ .replace("__KINDJSON__", "\"footprint\"")+ .replace("__MPNJSON__", &mpn_json)+ .replace("__PINS__", &pins_json)+ .replace("__NOTABLE__", "false")+ .replace("__LAYERBAR__", layerbar)+ .replace("__SVG__", &fp_svg);+ std::fs::write(out_dir.join("footprint.html"), fp_html).map_err(|e| e.to_string())?;++ // 3D viewer: interactive Babylon (self-contained Draco) loading the board GLB.+ // References the vendored viewer + the GLB by URL relative to viewers/3d.html+ // (../adom-3d-viewer.min.js and ../<glb>), so all fetches are same-origin.+ let glb = std::fs::read_dir(dir).ok().and_then(|rd| {+ let mut names: Vec<String> = rd.filter_map(|e| e.ok()).filter_map(|e| e.file_name().into_string().ok())+ .filter(|n| n.ends_with(".glb")).collect();+ names.sort_by_key(|n| if n.contains("-board") { 0 } else { 1 }); // prefer the board-context GLB+ names.into_iter().next()+ });+ if let Some(glb) = glb {+ // Inline the self-contained Babylon bundle: the blob store serves .js as+ // application/octet-stream + nosniff, which blocks <script src>; inline+ // scripts execute under script-src 'unsafe-inline'. Escape any literal+ // </script> in the bundle so it can't close our tag early.+ let bundle = include_str!("adom-3d-viewer.min.js").replace("</script", "<\\/script");+ // laser marking lines: the MPN (chips are laser-marked; the STEP never has it)+ let marks_json = serde_json::to_string(&vec![mpn.clone()]).unwrap_or_else(|_| "[]".into());+ let three = include_str!("viewer3d.html")+ .replace("__TITLE__", &format!("{mpn} 3D"))+ .replace("__MPNJSON__", &mpn_json)+ .replace("__MARKS__", &marks_json)+ .replace("__GLB__", &format!("../{glb}"))+ .replace("__VIEWERJS_INLINE__", &bundle);+ std::fs::write(out_dir.join("3d.html"), three).map_err(|e| e.to_string())?;+ println!("OK: per-slot viewers ({} pins) → symbol.html + footprint.html + 3d.html (glb {glb}, bundle inlined) in {}", rows.len(), out_dir.display());+ } else {+ println!("OK: per-slot viewers ({} pins) → symbol.html + footprint.html (no .glb found for 3d) in {}", rows.len(), out_dir.display());+ }+ Ok(())+}++// ---------------------------------------------------------------------------+// export-composed: ONE configurable cross-linked viewer (symbol + footprint + 3D+// + pin-map) in a declared layout. layout ∈ {columns, quad, single}; `panes` is an+// allowlist/order; per-pane options (pin-map description column + type roll-up)+// derive from the layout unless overridden. Same data path as export_viewers+// (live SVG renderers + the ds2sf pin extract); 3D rendered DIRECTLY (not an+// iframe) via the inlined Babylon bundle. Composed.html assembles panes per config.+// ---------------------------------------------------------------------------+pub fn export_composed(+ slug: &str,+ dir: &std::path::Path,+ out: Option<std::path::PathBuf>,+ layout: &str,+ panes_in: &[String],+ desc: Option<bool>,+ rollup: Option<bool>,+) -> Result<(), String> {+ let mpn = up(slug);+ let hub = dir.join(format!("{mpn}.adom-lbr.json"));+ let part = if hub.exists() { crate::load_part(&hub) } else { load_part(slug, &mpn)?.0 };+ let sym_svg = symbol_svg(&part.symbol);+ let fp_svg = footprint_svg(&part.footprint, &pin_names(&part.symbol));++ // pin type label + color + datasheet description keyed by pin number+ let mut meta: std::collections::HashMap<String, (String, String, String)> = std::collections::HashMap::new();+ let ext = dir.join(format!("{mpn}-symbol.extracted.json"));+ if let Ok(txt) = std::fs::read_to_string(&ext) {+ if let Ok(v) = serde_json::from_str::<Value>(&txt) {+ if let Some(arr) = v.get("pins").and_then(|p| p.as_array()) {+ for p in arr {+ let num = p.get("number").and_then(|x| x.as_str()).unwrap_or("").to_string();+ let (label, color) = crate::pin_type_style(p.get("type").and_then(|x| x.as_str()).unwrap_or(""));+ let d = p.get("description").and_then(|x| x.as_str()).unwrap_or("").to_string();+ meta.insert(num, (label.to_string(), color.to_string(), d));+ }+ }+ }+ }+ let rows: Vec<Value> = part.symbol.pins.iter().map(|p| {+ let (ty, color, d) = meta.get(&p.number).cloned()+ .unwrap_or_else(|| ("—".to_string(), "#90a4ae".to_string(), String::new()));+ let short = d.split_once(". ").map(|(a, _)| format!("{a}.")).unwrap_or(d);+ json!({ "number": p.number, "name": p.name, "type": ty, "color": color, "desc": short })+ }).collect();++ // prefer the board-context GLB+ let glb = std::fs::read_dir(dir).ok().and_then(|rd| {+ let mut names: Vec<String> = rd.filter_map(|e| e.ok()).filter_map(|e| e.file_name().into_string().ok())+ .filter(|n| n.ends_with(".glb")).collect();+ names.sort_by_key(|n| if n.contains("-board") { 0 } else { 1 });+ names.into_iter().next()+ });+ let has3d = glb.is_some();++ // resolve panes: default = all available, in canonical order; drop 3d when no glb+ let mut panes: Vec<String> = if panes_in.is_empty() {+ let mut v = vec!["symbol".to_string(), "footprint".to_string()];+ if has3d { v.push("3d".to_string()); }+ if !rows.is_empty() { v.push("pins".to_string()); }+ v+ } else {+ panes_in.iter().map(|p| p.trim().to_lowercase()).collect()+ };+ if !has3d { panes.retain(|p| p != "3d"); }++ // per-pane pin-map options derive from layout unless overridden+ let pins_description = desc.unwrap_or(layout == "quad" || layout == "single");+ let pins_rollup = rollup.unwrap_or(layout == "quad");++ let cfg = json!({+ "mpn": mpn,+ "package": part.package,+ "layout": layout,+ "panes": panes,+ // the composed HTML is written alongside the GLB (default --out is in --dir),+ // so reference it by bare filename relative to the HTML's own location+ "glb": glb.clone().unwrap_or_default(),+ "marks": vec![mpn.clone()],+ "pins": { "description": pins_description, "rollup": pins_rollup, "rows": rows },+ });+ // guard: a `</...` inside the JSON must not close our <script> tag+ let cfg_json = serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".into()).replace("</", "<\\/");++ // inline the Babylon bundle only when a 3D pane is present+ let bundle = if panes.iter().any(|p| p == "3d") {+ include_str!("adom-3d-viewer.min.js").replace("</script", "<\\/script")+ } else {+ String::new()+ };++ let layerbar = "<button class=\"lyrtog on\" data-lyr=\"copper\"><span class=\"d\" style=\"background:#e35d5d\"></span>Copper</button>\+<button class=\"lyrtog on\" data-lyr=\"silk\"><span class=\"d\" style=\"background:#e6edf3\"></span>Silkscreen</button>\+<button class=\"lyrtog\" data-lyr=\"fab\"><span class=\"d\" style=\"background:#8b949e\"></span>Fab</button>\+<button class=\"lyrtog\" data-lyr=\"courtyard\"><span class=\"d\" style=\"background:#bc8cff\"></span>Courtyard</button>";++ let html = include_str!("composed.html")+ .replace("__TITLE__", &format!("{mpn} — {layout}"))+ .replace("__CONFIG__", &cfg_json)+ .replace("__SYM_SVG__", &sym_svg)+ .replace("__FP_SVG__", &fp_svg)+ .replace("__LAYERBAR__", layerbar)+ .replace("__VIEWERJS_INLINE__", &bundle);++ let out = out.unwrap_or_else(|| dir.join(format!("{mpn}-lbr-{layout}.html")));+ std::fs::write(&out, &html).map_err(|e| format!("writing {}: {e}", out.display()))?;+ println!("OK: composed viewer [{layout}: {}] ({} pins, 3d={}) → {}",+ panes.join("+"), rows.len(), has3d, out.display());+ Ok(())+}
src/viewer2d.htmladded+65@@ -0,0 +1,65 @@+<!doctype html><html lang=en><head><meta charset=utf-8>+<meta name=viewport content="width=device-width,initial-scale=1"><title>__TITLE__</title>+<style>+:root{--bg:#0d1117;--panel:#161b22;--edge:#30363d;--ink:#e6edf3;--mut:#8b949e;--acc:#00b8b0;--accb:#00e6dc;--hi:#6ea8fe}+*{box-sizing:border-box;margin:0}html,body{height:100%}+body{background:var(--bg);color:var(--ink);font-family:'Satoshi',system-ui,sans-serif;display:flex;flex-direction:column}+.bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 11px;border-bottom:1px solid var(--edge);flex:none}+.bar .h{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);font-weight:700}+.bar .sp{flex:1}+.lyrtog{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--mut);background:#1a2130;border:1px solid var(--edge);border-radius:999px;padding:3px 9px;cursor:pointer;user-select:none}+.lyrtog.on{color:var(--ink);border-color:#485162}.lyrtog:not(.on){opacity:.5}+.lyrtog .d{width:8px;height:8px;border-radius:50%;background:#485162}+.stage{flex:1;min-height:0;position:relative;overflow:hidden;background:var(--bg);cursor:grab;touch-action:none}+.stage.drag{cursor:grabbing}+.stage svg{position:absolute;inset:0;width:100%;height:100%}+.pingroup,.pad{cursor:pointer}+.pingroup.hi .pinvis{stroke:var(--hi)!important;stroke-width:7}+rect.pad.hi{stroke:var(--hi);stroke-width:.15;paint-order:stroke}+.lyr-off{display:none}+#tip{position:fixed;z-index:99;pointer-events:none;display:none;max-width:250px;background:#0d1117;border:1px solid var(--acc);border-radius:8px;padding:7px 10px;box-shadow:0 8px 26px #000a}+#tip .tn{font:700 12px ui-monospace,monospace;color:var(--accb)}+#tip .tt{font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--mut);margin-top:1px}+#tip .td{font-size:11px;color:var(--ink);margin-top:5px;line-height:1.4}+</style></head><body>+<div class=bar><span class=h>__KIND__</span><span class=sp></span>__LAYERBAR__</div>+<div class=stage id=stage>__SVG__</div>+<div id=tip></div>+<script>+var KIND=__KINDJSON__, MPN=__MPNJSON__, PINS=__PINS__;+var byNum={}; PINS.forEach(function(p){byNum[String(p.number)]=p;});+var stage=document.getElementById('stage'), svg=stage.querySelector('svg'), tip=document.getElementById('tip');+// ---- pan/zoom ----+var vb=(svg.getAttribute('viewBox')||'0 0 100 100').split(/\s+/).map(Number), base=vb.slice();+function setVB(){svg.setAttribute('viewBox',vb.join(' '));}+stage.addEventListener('wheel',function(e){e.preventDefault();var r=stage.getBoundingClientRect();var mx=(e.clientX-r.left)/r.width,my=(e.clientY-r.top)/r.height;var f=e.deltaY<0?0.88:1.14;var nw=vb[2]*f,nh=vb[3]*f;vb[0]+=(vb[2]-nw)*mx;vb[1]+=(vb[3]-nh)*my;vb[2]=nw;vb[3]=nh;setVB();},{passive:false});+var pan=null;+stage.addEventListener('pointerdown',function(e){pan={x:e.clientX,y:e.clientY};stage.classList.add('drag');stage.setPointerCapture(e.pointerId);});+stage.addEventListener('pointermove',function(e){if(!pan)return;var r=stage.getBoundingClientRect();vb[0]-=(e.clientX-pan.x)/r.width*vb[2];vb[1]-=(e.clientY-pan.y)/r.height*vb[3];pan={x:e.clientX,y:e.clientY};setVB();});+function endPan(){pan=null;stage.classList.remove('drag');}+stage.addEventListener('pointerup',endPan);stage.addEventListener('pointerleave',endPan);+stage.addEventListener('dblclick',function(){vb=base.slice();setVB();});+// ---- cross-highlight bus ----+function els(n){return [].slice.call(document.querySelectorAll('[data-num="'+n+'"]'));}+function clearHi(){[].slice.call(document.querySelectorAll('.hi')).forEach(function(e){e.classList.remove('hi');});}+function hi(n){els(n).forEach(function(e){e.classList.add('hi');});}+function post(n){try{parent.postMessage({type:'lbr-hover',pin:n===null?null:String(n),mpn:MPN},'*');}catch(_){}}+function showTip(n,x,y){var p=byNum[String(n)];if(!p)return;tip.innerHTML='<span class=tn></span><div class=tt></div><div class=td></div>';tip.querySelector('.tn').textContent=n+' '+(p.name||'');tip.querySelector('.tt').textContent=p.type||'';var d=p.desc||'';if(d)tip.querySelector('.td').textContent=d;else tip.querySelector('.td').style.display='none';tip.style.display='block';tip.style.left=(x+14)+'px';tip.style.top=(y+12)+'px';}+[].slice.call(document.querySelectorAll('.pingroup,.pad')).forEach(function(g){+ var n=g.getAttribute('data-num');+ g.addEventListener('mouseenter',function(e){clearHi();hi(n);post(n);showTip(n,e.clientX,e.clientY);});+ g.addEventListener('mousemove',function(e){tip.style.left=(e.clientX+14)+'px';tip.style.top=(e.clientY+12)+'px';});+ g.addEventListener('mouseleave',function(){clearHi();post(null);tip.style.display='none';});+});+window.addEventListener('message',function(e){var d=e.data||{};if(d.type!=='lbr-hover')return;clearHi();if(d.pin!=null)hi(d.pin);});+// ---- footprint layer toggles ----+[].slice.call(document.querySelectorAll('.lyrtog')).forEach(function(b){+ function apply(on){[].slice.call(document.querySelectorAll('.ly-'+b.getAttribute('data-lyr'))).forEach(function(el){el.classList.toggle('lyr-off',!on);});}+ apply(b.classList.contains('on'));+ b.addEventListener('click',function(){apply(b.classList.toggle('on'));});+});+// ---- auto-grow the host readme iframe ----+function ph(){var h=Math.max(360,Math.min(16000,document.body.scrollHeight||420));parent.postMessage({type:'readme-height',height:h},'*');}+window.addEventListener('load',ph);setTimeout(ph,300);+</script>+</body></html>
src/viewer3d.htmladded+155@@ -0,0 +1,155 @@+<!doctype html><html lang=en><head><meta charset=utf-8>+<meta name=viewport content="width=device-width,initial-scale=1"><title>__TITLE__</title>+<style>+:root{--bg:#0d1117;--edge:#30363d;--ink:#e6edf3;--mut:#8b949e;--acc:#00b8b0}+*{box-sizing:border-box;margin:0}html,body{height:100%}+body{background:var(--bg);color:var(--ink);font-family:'Satoshi',system-ui,sans-serif;display:flex;flex-direction:column}+.bar{display:flex;flex-wrap:wrap;align-items:center;gap:7px;padding:8px 11px;border-bottom:1px solid var(--edge);flex:none}+.bar .h{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);font-weight:700}+.bar .sp{flex:1}+.lyrtog{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--mut);background:#1a2130;border:1px solid var(--edge);border-radius:999px;padding:3px 9px;cursor:pointer;user-select:none}+.lyrtog.on{color:var(--ink);border-color:#485162}.lyrtog:not(.on){opacity:.5}+.lyrtog .d{width:8px;height:8px;border-radius:50%}+.act{font-size:11px;color:var(--acc);background:rgba(0,184,176,.1);border:1px solid rgba(0,184,176,.4);border-radius:999px;padding:3px 11px;cursor:pointer;font-family:'Satoshi'}+.act:hover{background:rgba(0,184,176,.18)}.act:disabled{opacity:.5;cursor:default}+.act.plain{color:var(--ink);background:#1a2130;border-color:var(--edge)}.act.plain:hover{border-color:var(--acc);background:#1a2130}+.stage{flex:1;min-height:0;position:relative;overflow:hidden;background:var(--bg)}+.stage canvas{width:100%;height:100%;display:block;outline:none;touch-action:none}+.ph{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--mut);font-size:12px}+</style></head><body>+<div class=bar><span class=h>3D model</span><span class=sp></span>+ <button class="lyrtog on" data-mesh="solder"><span class=d style="background:#c9ccd1"></span>Solder</button>+ <button class="lyrtog on" data-mesh="copper"><span class=d style="background:#d8a24a"></span>Copper</button>+ <button class="lyrtog on" data-mesh="fr4"><span class=d style="background:#2f7d4f"></span>Board</button>+ <button class=act id=play title="Watch it get placed, soldered, and powered on">▶ Lifecycle</button>+ <button class="act plain" id=reset>Reset</button>+</div>+<div class=stage id=stage><div class=ph id=ph>loading 3D…</div></div>+<script>__VIEWERJS_INLINE__</script>+<script>+var MPN=__MPNJSON__, GLB="__GLB__", MARKS=__MARKS__;+var v=null, B=(window.Adom3DViewer&&Adom3DViewer.BABYLON)||{};+function rmPh(){var p=document.getElementById('ph');if(p)p.remove();}+function isHelper(n){return /arcrotate|background|skybox|indicator|helper|ground|viewcube|viewerroot|laseretch|dbg/i.test(n||'');}+function realMeshes(s){return (s.meshes||[]).filter(function(m){return m.getTotalVertices&&m.getTotalVertices()>0 && !isHelper(m.name);});}+function byName(s,re){return realMeshes(s).filter(function(m){return re.test(m.name||'');});}+var RE={solder:/solder/i, copper:/copper/i, fr4:/fr4/i, chip:/LQFP|chip/i};+function boundsOf(mm){var lo=null,hi=null;mm.forEach(function(m){try{m.computeWorldMatrix(true);var bb=m.getBoundingInfo().boundingBox;lo=lo?B.Vector3.Minimize(lo,bb.minimumWorld):bb.minimumWorld.clone();hi=hi?B.Vector3.Maximize(hi,bb.maximumWorld):bb.maximumWorld.clone();}catch(_){}});return (lo&&hi)?{lo:lo,hi:hi}:null;}++try{+ v=Adom3DViewer.init(document.getElementById('stage'),{modelUrl:GLB,zUp:true,showGround:false,showViewCube:true,initialViewMode:"perspective"});+ // wait until the mesh count is STABLE (all parts loaded), not just the first primitive+ var prev=-1, stable=0, tries=0, iv=setInterval(function(){+ tries++;+ try{+ var s=v.getScene(), n=realMeshes(s).length;+ stable=(n>0&&n===prev)?stable+1:0; prev=n;+ if(n>0&&stable>=3){ clearInterval(iv); try{v.frameModel&&v.frameModel();}catch(_){} try{v.goHome&&v.goHome();}catch(_){} rmPh(); onReady(s); }+ }catch(_){}+ if(tries>100){ clearInterval(iv); var p=document.getElementById('ph'); if(p)p.textContent='3D: model did not load'; }+ },200);+}catch(err){ var p=document.getElementById('ph'); if(p)p.textContent='3D unavailable: '+err; }++// the GLB places the solder dots with a small gap above the copper pads; seat them+function seatSolder(s){+ try{+ var cop=boundsOf(byName(s,RE.copper)), sol=byName(s,RE.solder), sb=boundsOf(sol);+ if(!cop||!sb)return;+ var gap=sb.lo.z-cop.hi.z; // solder bottom minus pad top+ if(gap>0){ sol.forEach(function(m){ try{var p=m.getAbsolutePosition(); m.setAbsolutePosition(new B.Vector3(p.x,p.y,p.z-gap*1.05));}catch(_){} }); }+ }catch(_){}+}+// a chip sits flush on its board; the fixed-light cast shadow reads as a baked smudge, so drop it+function disableShadows(s){+ try{ s.lights&&s.lights.forEach(function(l){ l.shadowEnabled=false; }); }catch(_){}+ try{ var sg=v.getShadowGenerator&&v.getShadowGenerator(); if(sg&&sg.getShadowMap&&sg.getShadowMap()) sg.getShadowMap().renderList=[]; }catch(_){}+ try{ (s.meshes||[]).forEach(function(m){ m.receiveShadows=false; }); }catch(_){}+}+function onReady(s){+ seatSolder(s);+ disableShadows(s);+ try{ if(MARKS && MARKS.length) addEtch(s, MARKS); }catch(_){}+ // layer toggles by part name (fr4 / copper / solder_paste / LQFP-chip)+ [].slice.call(document.querySelectorAll('.lyrtog')).forEach(function(b){+ b.addEventListener('click',function(){+ var on=b.classList.toggle('on'), re=RE[b.getAttribute('data-mesh')];+ byName(s,re).forEach(function(m){ m.setEnabled(on); });+ });+ });+ var pb=document.getElementById('play'); if(pb) pb.addEventListener('click',function(){ playLifecycle(s); });+ var rb=document.getElementById('reset'); if(rb) rb.addEventListener('click',function(){ try{v.goHome&&v.goHome();}catch(_){} });+ if((location.hash||'').indexOf('play')>=0) setTimeout(function(){playLifecycle(s);},500);+ if((location.hash||'').indexOf('low')>=0){ try{var c=v.getCamera(); if(c){c.beta=1.46; c.radius=c.radius*0.32;}}catch(_){} }+ if((location.hash||'').indexOf('demo')>=0){+ try{var c=v.getCamera(); if(c){c.beta=1.05; c.radius=c.radius*1.12;}}catch(_){}+ var reps=6,i=0; (function loop(){ if(i++>=reps)return; playLifecycle(s); setTimeout(loop,9800); })();+ }+ if((location.hash||'').indexOf('vid')>=0){ // single play from a nice angle, delayed so recording can start first+ try{var c=v.getCamera(); if(c){c.beta=1.08; c.radius=c.radius*0.9;}}catch(_){}+ setTimeout(function(){ playLifecycle(s); }, 3800);+ }+}++// ---- laser-etch the MPN onto the chip top (world up is +Z) ----+function addEtch(s, lines){+ if(!B.DynamicTexture || !B.MeshBuilder) return;+ var chipList=byName(s,RE.chip);+ var bnd=boundsOf(chipList); if(!bnd) return;+ var w=bnd.hi.x-bnd.lo.x, h=bnd.hi.y-bnd.lo.y, cx=(bnd.lo.x+bnd.hi.x)/2, cy=(bnd.lo.y+bnd.hi.y)/2, topz=bnd.hi.z;+ if(!(w>0&&h>0)) return;+ var side=Math.min(w,h)*0.62, W=1024, H=1024; // sit inside the body, not over the leads+ var L=lines.slice(), maxc=1; L.forEach(function(x){maxc=Math.max(maxc,String(x).length);});+ var fs=Math.min((W*0.86)/(maxc*0.62), (H*0.6)/L.length); // fit text to the plane (width + height)+ var tex=new B.DynamicTexture("etchTex",{width:W,height:H},s,true), ctx=tex.getContext();+ ctx.clearRect(0,0,W,H); ctx.fillStyle="rgba(207,210,214,0.78)"; ctx.textAlign="center"; ctx.textBaseline="middle";+ ctx.font="400 "+Math.round(fs)+"px monospace"; // thin, like a real laser mark+ for(var i=0;i<L.length;i++) ctx.fillText(String(L[i]), W/2, H*(i+1)/(L.length+1));+ tex.update();+ var mat=new B.StandardMaterial("etchMat",s);+ mat.diffuseTexture=tex; tex.hasAlpha=true; mat.useAlphaFromDiffuseTexture=true;+ mat.emissiveColor=new B.Color3(0.66,0.67,0.69); mat.disableLighting=true; mat.backFaceCulling=false;+ var pl=B.MeshBuilder.CreatePlane("laserEtch",{size:side,sideOrientation:2},s);+ var dz=bnd.hi.z-bnd.lo.z; // chip height+ pl.position=new B.Vector3(cx, cy, topz+dz*0.02); // just skimming the chip top face+ pl.material=mat; pl.isPickable=false;+ // attach the etch to the chip body so it moves with the chip (e.g. during the animation)+ var body=null; chipList.forEach(function(m){ if(!body||m.getTotalVertices()>body.getTotalVertices())body=m; });+ if(body){ try{ pl.setParent(body); }catch(_){} }+}++// ---- lifecycle animation: lift -> toggle solder -> place -> reflow -> power-on ----+var animing=false;+function setEmis(mm,r,g,b){mm.forEach(function(m){try{if(m.material&&m.material.emissiveColor&&B.Color3)m.material.emissiveColor=new B.Color3(r,g,b);}catch(_){}});}+function tween(dur,upd,done){var t0=performance.now();(function st(now){var t=Math.min(1,(now-t0)/dur);upd(t);if(t<1)requestAnimationFrame(st);else if(done)done();})(t0);}+function easeIO(t){return t<0.5?4*t*t*t:1-Math.pow(-2*t+2,3)/2;}+function playLifecycle(s){+ if(animing||!B.Vector3)return; animing=true;+ var pb=document.getElementById('play'); if(pb)pb.disabled=true;+ var chip=byName(s,RE.chip), sol=byName(s,RE.solder), solBtn=document.querySelector('.lyrtog[data-mesh="solder"]');+ var cb=boundsOf(chip); var lift=cb?Math.max(cb.hi.x-cb.lo.x, cb.hi.y-cb.lo.y)*0.55:0.004;+ chip.forEach(function(m){ if(!m.__oz){m.computeWorldMatrix(true); m.__op=m.getAbsolutePosition().clone(); m.__oz=1;} });+ function place(f){ chip.forEach(function(m){ try{m.setAbsolutePosition(new B.Vector3(m.__op.x, m.__op.y, m.__op.z+lift*f));}catch(_){} }); }+ function solOn(on){ sol.forEach(function(m){m.setEnabled(on);}); if(solBtn)solBtn.classList.toggle('on',on); }+ function fin(){ place(0); setEmis(chip,0,0,0); setEmis(sol,0,0,0); animing=false; if(pb)pb.disabled=false; }+ place(0); solOn(true);+ tween(1000, function(t){ place(easeIO(t)); }, function(){ // 1) lift the chip up+ setTimeout(function(){ solOn(false); // 2) solder off+ setTimeout(function(){ solOn(true); // solder back on+ setTimeout(function(){+ tween(1400, function(t){ place(1-easeIO(t)); }, function(){ // 3) lower onto the solder+ tween(1600, function(t){ var g=Math.sin(t*Math.PI); setEmis(sol,0.95*g,0.30*g,0); setEmis(chip,0.26*g,0.08*g,0); }, function(){ setEmis(sol,0,0,0); setEmis(chip,0,0,0); // 4) slow reflow glow+ tween(1700, function(t){ var p=Math.sin(t*Math.PI); setEmis(chip,0,0.5*p,0.48*p); }, fin); // 5) slow power-on pulse+ });+ });+ }, 700);+ }, 800);+ }, 700);+ });+}++// cross-highlight bus: 3D joins as a listener (per-lead highlight needs named leads)+window.addEventListener('message',function(e){var d=e.data||{};if(d.type!=='lbr-hover')return;});+function ph(){parent.postMessage({type:'readme-height',height:Math.max(360,document.body.scrollHeight||420)},'*');}+window.addEventListener('load',ph);setTimeout(ph,400);+</script>+</body></html>