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.
source: sync src/ with the 2.7.1 the published binary was built from #2
recover-2.7.1-source → masterThe binary is reproducible, just not from this repo
Following up John's report. The merge is not lost and nothing needs recovering from a working tree: the source lives on adom-inc/adom-lbr (GitHub), whose main carries the merge this page never received. This page is a stale source mirror.
GitHub adom-inc/adom-lbr main:
435bb23 release: v2.7.1 - packaging cleanup
e31f459 release: v2.7.0 - adom-lbr view (configurable composed viewer)
877566e Merge pull request #1 from adom-inc/export-viewers <- the "deferred" merge
A clean build of that commit reproduces the published binary by every fingerprint John used:
published bin/adom-lbr |
clean build of GitHub main | |
|---|---|---|
| size | 14.52 MB | 14.52 MB |
BABYLON |
280 | 280 |
Add to Library |
3 | 3 |
/api/apply-progress |
2 | 2 |
selfcontain |
2 | 2 |
showViewCube |
4 | 4 |
environmentUrl |
2 | 2 |
(sha256 differs, as expected: Rust release builds are not bit-reproducible by default. Every content fingerprint matches.)
One measurement I could not reproduce: John read the published binary as 16.37 MB, I get 14.52 MB for the copy on this page today. Possibly a different release's artifact. Worth a second look, though it does not change the conclusion.
Why the vendored files looked orphaned
src/adom-3d-viewer.min.js and src/environmentSpecular.env appear unreferenced here because the code that references them is not here. On GitHub main, manage.rs has three include_str!("adom-3d-viewer.min.js") sites, and viewer2d.html / viewer3d.html / composed.html do not exist on this page at all.
So it reads as "barrett dropped the refs and the binary kept Babylon anyway", when what actually happened is that the refs moved into files this mirror never received.
What this PR does
Syncs src/ with the commit the binary was built from. Source only, bin/adom-lbr untouched, as asked.
- adds
composed.html,viewer2d.html,viewer3d.html(absent here) - updates
main.rs,manage.rs(theinclude_str!sites) - updates
Cargo.toml,VERSION
After this, cargo build --release on this page reproduces the published binary.
The process gap is real, and it is the mirror
John's two points stand, with one correction to where the hole is: the release script bumps package.json while VERSION and Cargo.toml drift, and there is no build.sh or adom-lbr-build dev skill the way adom-jlcpcb, adom-mouser, adom-digikey and adom-parts-search all have. Add to those: a release publishes a tarball built from GitHub, and nothing ensures the page's src/ matches it. That is the divergence that made a reproducible binary look unreproducible. A build skill should push source to the page and publish from the same tree.
On vendoring Babylon into the binary
Agreed, and this PR does not change it. 13 MB rides in every adom-lbr generate for a manage subcommand most callers never open, and include_str! makes the payload invisible in a source diff. Shipping the viewer as files beside the binary is the same bytes and visible in every diff. Worth its own change, especially now the Fusion bridge depends on this binary.
Also noted in passing, unrelated: src/manage.rs hardcodes /home/barrett-land/Documents/KiCAD/... with a TODO: detect from the bridge.
Diff Skip to comments
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.6.3"+version = "2.6.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>
Comments
Log in to comment.
No comments yet.