← 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

Ray ·7f503e2722 ·2mo ago ·parents d8f00f6, c8b85e3

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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 &nbsp;·&nbsp; <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">&#9654; Lifecycle</button>+  <button class="act plain" id=reset>Reset</button>+</div>+<div class=stage id=stage><div class=ph id=ph>loading 3D&hellip;</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>