skill
Publish a Hardware Component to the Adom Wiki
Public Made by Adomby adom
The one-stop-shop for publishing a hardware component (or molecule/board) to the Adom Wiki. The whole lifecycle: page anatomy, the clone→edit→preview→push loop and its hard rules, making a great hero image (Hero Component Studio — 16:10, overlay-safe, datasheet-backed), the animated 3D composite, the interactive 3D viewer, ct thumbnails, the datasheet-accuracy audit, and every wiki push/pull gotcha we've hit. Now includes the worked Path B (board) pipeline: STEP-to-GLB, refdes tagging, layer de-
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
<meta charset="utf-8">
<title>Feeder Reader — interactive board viewer</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root{ --bg:#2a3340; --ink:#e8eef5; --dim:#9fb0c3; --line:rgba(255,255,255,.14); }
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);color:var(--ink);
font-family:ui-sans-serif,-apple-system,"Segoe UI",Inter,system-ui,sans-serif;overflow:hidden}
#app{position:absolute;inset:0}
canvas{outline:none;display:block;width:100%;height:100%;touch-action:none}
#status{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);color:var(--dim);
font-size:13px;letter-spacing:.02em;text-align:center;pointer-events:none}
.panel{position:absolute;background:rgba(16,22,31,.82);backdrop-filter:blur(9px);
border:1px solid var(--line);border-radius:12px}
/* mode switch */
#modes{top:14px;left:50%;transform:translateX(-50%);display:flex;gap:2px;padding:3px;z-index:6}
#modes button{appearance:none;background:transparent;border:0;color:var(--dim);cursor:pointer;
font:600 12px/1 inherit;padding:8px 14px;border-radius:9px;letter-spacing:.02em}
#modes button.on{background:rgba(255,255,255,.13);color:#fff}
/* caption card */
#caption{left:20px;bottom:64px;max-width:min(430px,42vw);padding:14px 16px;z-index:5;
opacity:0;transition:opacity .45s ease;pointer-events:none}
#caption.show{opacity:1}
#caption .eyebrow{font:700 10px/1 inherit;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);margin-bottom:7px}
#caption .head{font:650 17px/1.3 inherit;margin-bottom:9px;color:#fff}
#caption .rows{display:grid;grid-template-columns:auto 1fr;gap:3px 12px;font-size:12px;margin-bottom:9px}
#caption .rows b{color:var(--dim);font-weight:500;white-space:nowrap}
#caption .rows span{color:#dce6f2;font-variant-numeric:tabular-nums}
#caption .note{font-size:12px;line-height:1.5;color:#b9c7d7}
#caption .chip{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:7px;vertical-align:1px}
/* legend + layers */
#legend{left:20px;top:64px;padding:11px 13px;z-index:5;font-size:11.5px}
#legend h3{margin:0 0 8px;font:700 10px/1 inherit;letter-spacing:.14em;text-transform:uppercase;color:var(--dim)}
#legend .row{display:flex;align-items:center;gap:8px;padding:3px 0;cursor:pointer;color:#c6d3e1}
#legend .row:hover{color:#fff}
#legend .row.off{opacity:.38}
#legend .sw{width:9px;height:9px;border-radius:2px;flex:none}
#legend .n{margin-left:auto;color:#7f92a6;font-variant-numeric:tabular-nums;font-size:10.5px}
/* buttons + scrubber */
.btn-row{position:absolute;right:20px;bottom:20px;display:flex;gap:7px;z-index:6}
.btn{appearance:none;background:rgba(16,22,31,.82);border:1px solid var(--line);color:var(--dim);
border-radius:9px;padding:8px 12px;font:600 11.5px/1 inherit;cursor:pointer;backdrop-filter:blur(9px)}
.btn:hover{color:#fff}
.btn.on{background:rgba(255,255,255,.15);color:#fff}
#topbar{position:absolute;left:0;right:0;top:0;height:3px;background:rgba(255,255,255,.08);cursor:pointer;z-index:7}
#topbar-fill{height:100%;width:0;background:linear-gradient(90deg,#3b82f6,#22c55e)}
/* ticks only — the chapter NAME already leads the caption card, so labels here just
collided with the mode switch */
#marks{position:absolute;left:0;right:0;top:3px;height:6px;z-index:7;pointer-events:none}
#marks i{position:absolute;top:0;width:1px;height:6px;background:rgba(255,255,255,.32)}
#tip{position:absolute;pointer-events:none;z-index:8;padding:8px 10px;max-width:230px;
background:rgba(10,15,22,.94);border:1px solid var(--line);border-radius:9px;
font-size:11.5px;line-height:1.45;color:#dce6f2;opacity:0;transition:opacity .12s}
#tip .t{font-weight:650;color:#fff;margin-bottom:3px}
#tip .m{color:#8fa2b6;font-size:10.5px;font-variant-numeric:tabular-nums}
@media (max-width:760px){ #legend{display:none!important} #caption{max-width:calc(100vw - 40px);bottom:58px} }
/* embedded in a short frame the legend (top-left) and the caption (bottom-left) collide —
the caption is the one that carries the narrative, so the legend yields */
@media (max-height:640px){ #legend{display:none} }
</style>
<div id="app"></div>
<div id="status">loading board…</div>
<div id="topbar"><div id="topbar-fill"></div></div>
<div id="marks"></div>
<div class="panel" id="modes"></div>
<div class="panel" id="legend"><h3>Sections</h3><div id="legend-list"></div></div>
<div class="panel" id="caption"><div class="eyebrow"></div><div class="head"></div><div class="rows"></div><div class="note"></div></div>
<div class="btn-row">
<button class="btn" id="btn-power" style="display:none">⏻ Power</button>
<button class="btn on" id="btn-play">⏸ Pause</button>
<button class="btn" id="btn-replay">↺ Replay</button>
<button class="btn on" id="btn-ground">Ground</button>
<button class="btn on" id="btn-labels">Labels</button>
</div>
<div id="tip"></div>
<script>
(async () => {
const statusEl = document.getElementById('status');
// Wiki blobs render SANDBOXED (no allow-same-origin) → localStorage/sessionStorage throw on
// ACCESS, which aborts the bundle's SceneBuilder (no lights/IBL/shadows). Shim before load.
for (const k of ['localStorage','sessionStorage']) {
try { window[k] && window[k].getItem('x'); }
catch (e) { try { Object.defineProperty(window,k,{configurable:true,value:{getItem:()=>null,setItem:()=>{},removeItem:()=>{},clear:()=>{},key:()=>null,length:0}}); } catch(_){} }
}
// Blob storage serves .js as application/octet-stream + nosniff → <script src> is refused.
// Fetch as text, inject inline.
if (!window.Adom3DViewer) {
try {
const code = await (await fetch('./adom-3d-viewer.min.js')).text();
const s = document.createElement('script'); s.textContent = code; document.head.appendChild(s);
} catch (e) { statusEl.textContent = 'viewer bundle fetch failed: ' + e.message; return; }
}
const V = window.Adom3DViewer;
if (!V) { statusEl.textContent = 'Adom3DViewer bundle failed to load'; return; }
const B = V.BABYLON;
try {
// ---------------------------------------------------------------- config + index
const CFG = await (await fetch('./viewer-config.json',{cache:'no-store'})).json();
const IDX = await (await fetch('./board-index.json',{cache:'no-store'})).json();
const BLOCKS = CFG.blocks;
const BCOL = Object.fromEntries(BLOCKS.map(b => [b.id, b.color]));
const BLAB = Object.fromEntries(BLOCKS.map(b => [b.id, b.label]));
// ---------------------------------------------------------------- scene
const viewer = V.init(document.getElementById('app'), { zUp:true, showViewCube:true, showGround:true });
await viewer.loadModel('./feeder-reader.board.glb');
const scene = viewer.getScene();
scene.animationGroups.slice().forEach(g => g.dispose()); // we drive all motion
// Match Colby's current native viewer: flat #2a3340, no skybox mesh, soft radial ground.
const bgBot = new B.Color3(0.165,0.200,0.251);
const Color4 = scene.clearColor && scene.clearColor.constructor;
let _fade=null;
const groundFadeTex = () => {
if (_fade) return _fade;
const t=new B.DynamicTexture('groundFadeTex',256,scene,true), c=t.getContext();
const g=c.createRadialGradient(128,128,6,128,128,128);
g.addColorStop(0,'rgba(255,255,255,1)'); g.addColorStop(.55,'rgba(255,255,255,.6)'); g.addColorStop(1,'rgba(255,255,255,0)');
c.fillStyle=g; c.fillRect(0,0,256,256); t.update(); t.hasAlpha=true; _fade=t; return t;
};
const matchColby = () => {
if (Color4) scene.clearColor = new Color4(bgBot.r,bgBot.g,bgBot.b,1);
scene.environmentIntensity = 1.0;
scene.meshes.filter(m=>/^Background/.test(m.name)).forEach(m=>m.setEnabled(false));
const gnd = scene.getMeshByName('shadowGround');
if (gnd && gnd.material) {
const m = gnd.material;
if (!(m.baseTexture && m.baseTexture.name==='groundFadeTex')) {
if ('baseTexture' in m) m.baseTexture = groundFadeTex();
m.alpha=0.2; m.transparencyMode=2; m.backFaceCulling=false;
}
gnd.receiveShadows = true;
}
};
matchColby();
// ---------------------------------------------------------------- group meshes
// Every mesh is named "<block>|<ref>|<i>" (transforms baked at export, so a plain
// position offset is a rigid move — see bake2.py).
const parts = new Map(); // ref -> {ref, blk, meshes[], home:Vector3, centre}
const byBlock = new Map(); // blk -> [part]
for (const m of scene.meshes) {
const nm = (m.name||'').split('_primitive')[0];
if (nm.indexOf('|') < 0) continue;
const [blk, ref] = nm.split('|');
m.rotationQuaternion = null; // glTF quaternion would override Euler
let p = parts.get(ref);
if (!p) { p = { ref, blk, meshes:[], home:m.position.clone() }; parts.set(ref, p); (byBlock.get(blk)||byBlock.set(blk,[]).get(blk)).push(p); }
p.meshes.push(m);
}
for (const p of parts.values()) {
let lo=null, hi=null;
for (const m of p.meshes) {
m.computeWorldMatrix(true);
const 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();
}
p.lo=lo; p.hi=hi; p.centre=lo.add(hi).scale(0.5); p.size=hi.subtract(lo);
}
const blockBox = blk => {
let lo=null,hi=null;
for (const p of (byBlock.get(blk)||[])) {
lo = lo ? B.Vector3.Minimize(lo,p.lo) : p.lo.clone();
hi = hi ? B.Vector3.Maximize(hi,p.hi) : p.hi.clone();
}
// fold in footprints that have no 3D body (feeder pads, switches, jumpers…)
for (const f of Object.values(IDX.fps)) {
if (f.blk!==blk || f.has3d) continue;
const v=new B.Vector3(f.x,f.y,0.0015);
lo = lo ? B.Vector3.Minimize(lo,v) : v.clone(); hi = hi ? B.Vector3.Maximize(hi,v) : v.clone();
}
return lo ? {lo,hi,c:lo.add(hi).scale(.5),s:hi.subtract(lo)} : null;
};
const SUB = ['PCB','COPPER','MASK','SILK'].map(r=>parts.get(r)).filter(Boolean);
const COMPONENTS = [...parts.values()].filter(p => p.blk!=='substrate');
statusEl.textContent = '';
// ---------------------------------------------------------------- camera
const cam = scene.activeCamera;
const home = { a:-Math.PI/2 - 0.55, b:0.92, r:0.165, t:new B.Vector3(0,0,0.004) };
const setCam = (o,ease) => {
const tgt = { alpha:o.a, beta:o.b, radius:o.r, target:o.t };
if (!ease) { cam.alpha=tgt.alpha; cam.beta=tgt.beta; cam.radius=tgt.radius; cam.target.copyFrom(tgt.target); return; }
camGoal = tgt;
};
let camGoal = null, userHold = 0;
setCam(home,false);
cam.lowerRadiusLimit = 0.03; cam.upperRadiusLimit = 1.2; cam.wheelDeltaPercentage = 0.02;
// Depth-precision fix: the bundle's camera sets minZ = radius/1000, which makes one 24-bit
// depth step ~10 um at board-viewing distance — exactly the gap between mask/silk/copper
// sheets, so coplanar-ish layers shimmer. radius/100 gives ~1 um steps and the near plane is
// still only 0.3 mm out at the closest zoom. (Bake-side the layers are also nudged apart.)
scene.onBeforeRenderObservable.add(() => { cam.minZ = Math.max(cam.radius / 100, 2e-4); });
cam.panningSensibility = 6000;
// The camera is "yours for 5 s": any drag/wheel pauses the scripted chase (assembly only),
// which then eases back — same convention as the single-component Timeline viewer. In static
// mode there is no scripted camera at all, so input is simply never fought.
['pointerdown','wheel'].forEach(ev => scene.getEngine().getRenderingCanvas()
.addEventListener(ev, () => { userHold = 5; }, {passive:true}));
// ---------------------------------------------------------------- helpers
const emissive = (col, alpha) => {
const m = new B.StandardMaterial('em'+Math.random(), scene);
m.emissiveColor = col; m.diffuseColor = new B.Color3(0,0,0); m.specularColor = new B.Color3(0,0,0);
m.disableLighting = true; m.alpha = alpha==null?1:alpha; if (alpha!=null) m.alphaMode = 1;
return m;
};
const hex2c3 = h => new B.Color3(parseInt(h.slice(1,3),16)/255, parseInt(h.slice(3,5),16)/255, parseInt(h.slice(5,7),16)/255);
// Section outline boxes (wireframe) — one per block, hidden by default.
const outlines = new Map();
for (const b of BLOCKS) {
const bb = blockBox(b.id); if (!bb) continue;
const pad = 0.0018;
const box = B.MeshBuilder.CreateBox('outline_'+b.id, {
width: Math.max(bb.s.x,0.002)+pad*2, height: Math.max(bb.s.y,0.002)+pad*2, depth: Math.max(bb.s.z,0.002)+pad*2 }, scene);
box.position.copyFrom(bb.c);
const mt = emissive(hex2c3(b.color)); mt.wireframe = true; mt.alpha = .45; mt.alphaMode = 1;
box.material = mt; box.isPickable = false; box.setEnabled(false);
outlines.set(b.id, box);
}
// Static-mode visual function (AUTHOR-SPECIFIED, see viewer-config): e.g. the rail LEDs
// lighting when "Power" is toggled. Purely illustrative of the powered state.
const FN = (CFG.modes.static && CFG.modes.static.functions && CFG.modes.static.functions.power_leds) || null;
const glows = new Map();
let powerOn = !!(FN && FN.default);
for (const ref of (FN ? FN.refs : [])) {
const f = IDX.fps[ref]; if (!f) continue;
const d = B.MeshBuilder.CreateBox('glow_'+ref, {width:.0028,height:.0028,depth:.0009}, scene);
d.position.set(f.x, f.y, 0.0022); d.material = emissive(hex2c3(FN.color), .95); d.isPickable=false; d.setEnabled(false);
glows.set(ref, d);
}
// DOM labels for sections
const labelLayer = document.createElement('div');
labelLayer.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:4';
document.body.appendChild(labelLayer);
const labels = new Map();
for (const b of BLOCKS) {
const bb = blockBox(b.id); if (!bb) continue;
const el = document.createElement('div');
el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);font:650 10.5px/1 ui-sans-serif,system-ui;'+
'letter-spacing:.03em;padding:3px 7px;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity .3s;'+
'background:rgba(10,15,22,.82);border:1px solid '+b.color+'66;color:'+b.color;
el.textContent = b.label;
labelLayer.appendChild(el); labels.set(b.id, {el, pos:bb.c.clone()});
}
let labelsOn = true;
// Only ONE section label is drawn at a time — the block the current chapter is about.
// (Blocks overlap heavily in plan view: feeder-io alone spans the whole board, so every
// centroid lands near the middle and all eight labels stack on top of each other.)
let focusBlk = null, hoverBlk = null;
// ---------------------------------------------------------------- visibility control
const setPartVis = (p, v) => { for (const m of p.meshes) m.visibility = v; };
const showAllComponents = v => COMPONENTS.forEach(p => setPartVis(p, v));
const layerOn = { PCB:true, COPPER:true, SILK:true, MASK:true };
const blockOn = Object.fromEntries(BLOCKS.map(b=>[b.id,true]));
const applyLayers = () => {
for (const p of SUB) { const on = layerOn[p.ref]!==false; p.meshes.forEach(m=>m.setEnabled(on)); }
for (const p of COMPONENTS) { const on = blockOn[p.blk]!==false; p.meshes.forEach(m=>m.setEnabled(on)); }
};
// ---------------------------------------------------------------- legend
const legendList = document.getElementById('legend-list');
const counts = {};
for (const f of Object.values(IDX.fps)) counts[f.blk] = (counts[f.blk]||0)+1;
for (const b of BLOCKS) {
const row = document.createElement('div'); row.className='row';
row.innerHTML = `<span class="sw" style="background:${b.color}"></span><span>${b.label}</span><span class="n">${counts[b.id]||0}</span>`;
row.onclick = () => { blockOn[b.id] = !blockOn[b.id]; row.classList.toggle('off', !blockOn[b.id]); applyLayers(); };
row.onmouseenter = () => { hoverBlk = b.id; outlines.get(b.id)?.setEnabled(true); };
row.onmouseleave = () => { hoverBlk = null; if (focusBlk!==b.id) outlines.get(b.id)?.setEnabled(false); };
legendList.appendChild(row);
}
const subRow = document.createElement('div'); subRow.className='row';
subRow.innerHTML = `<span class="sw" style="background:#b78227"></span><span>Copper & silkscreen</span><span class="n">4</span>`;
subRow.onclick = () => { const on = !(layerOn.COPPER && layerOn.SILK); layerOn.COPPER=layerOn.SILK=on; subRow.classList.toggle('off',!on); applyLayers(); };
legendList.appendChild(subRow);
// ---------------------------------------------------------------- caption
const capEl = document.getElementById('caption');
const showCap = (c) => {
if (!c) { capEl.classList.remove('show'); return; }
const dot = c.block ? `<span class="chip" style="background:${BCOL[c.block]}"></span>` : '';
capEl.querySelector('.eyebrow').innerHTML = dot + (c.eyebrow||'');
capEl.querySelector('.head').textContent = c.head||'';
capEl.querySelector('.rows').innerHTML = (c.rows||[]).map(r=>`<b>${r[0]}</b><span>${r[1]}</span>`).join('');
capEl.querySelector('.note').textContent = c.note||'';
capEl.classList.add('show');
};
let lastCapKey = null;
const showCapOnce = (key,c) => { if (key!==lastCapKey) { lastCapKey=key; showCap(c); } };
// ---------------------------------------------------------------- modes
const A = CFG.modes.assembly, S = CFG.modes.static;
// Assembly schedule — the GROUP is the unit: all of a grouping's parts fall together
// (tiny in-group stagger for texture) while that grouping's caption explains it.
const T = A.timing;
const sched = []; // {p, t, blk} — individual part drops
const groups = []; // {id, t0, t1} — caption/camera windows
let tA = (A.intro && A.intro.d) || 6;
for (const bid of A.order) {
const ps = (byBlock.get(bid)||[]).slice().sort((p,q)=>p.centre.x-q.centre.x);
ps.forEach((p,i)=> sched.push({p, t: tA + i*T.stagger_s, blk:bid}));
const dwell = (A.captions[bid] && A.captions[bid].d) || T.dwell_s;
groups.push({ id: bid, t0: tA, t1: tA + dwell });
tA += dwell;
}
const A_LEN = tA + T.rest_s;
const LEN = { assembly: A_LEN, static: 1 }; // static has NO timeline
let mode = 'assembly', t = 0, playing = true;
const modesEl = document.getElementById('modes');
for (const k of ['assembly','static']) {
const b = document.createElement('button'); b.textContent = CFG.modes[k].label; b.dataset.k = k;
b.onclick = () => setMode(k);
modesEl.appendChild(b);
}
const marksEl = document.getElementById('marks');
function drawMarks() {
marksEl.innerHTML = '';
if (mode!=='assembly') return;
for (const g of groups) marksEl.insertAdjacentHTML('beforeend', `<i style="left:${100*g.t0/A_LEN}%"></i>`);
}
function setMode(k) {
mode = k; t = 0; lastCapKey = null; camGoal = null; userHold = 0; focusBlk = null;
[...modesEl.children].forEach(b => b.classList.toggle('on', b.dataset.k===k));
outlines.forEach(o => o.setEnabled(false));
COMPONENTS.forEach(p => { setPartVis(p, k==='assembly' ? 0 : 1);
p.meshes.forEach(m => m.position.copyFrom(p.home)); });
// static: no timeline, no scripted camera — the camera is entirely the user's
const isA = k==='assembly';
document.getElementById('topbar').style.display = isA ? 'block' : 'none';
document.getElementById('btn-play').style.display = isA ? '' : 'none';
document.getElementById('btn-replay').style.display = isA ? '' : 'none';
document.getElementById('btn-power').style.display = (!isA && FN) ? '' : 'none';
glows.forEach(g => g.setEnabled(!isA && powerOn));
setCam(home,false);
drawMarks(); applyLayers(); showCap(null);
if (!isA && S.blurb) showCap({ eyebrow:'Static', head:'Inspect the board', note:S.blurb });
}
// ---------------------------------------------------------------- render loop
// B exposes only MeshBuilder/StandardMaterial/Vector3/Color3/DynamicTexture — Matrix is NOT
// on it (§5b). Harvest it from a live instance instead of reaching for B.Matrix.
const Matrix = scene.getTransformMatrix().constructor;
const IDM = Matrix.Identity();
const worldToScreen = (v) => {
try {
const e = scene.getEngine();
return B.Vector3.Project(v, IDM, scene.getTransformMatrix(),
cam.viewport.toGlobal(e.getRenderWidth(), e.getRenderHeight()));
} catch (_) { return null; }
};
let last = performance.now();
scene.onBeforeRenderObservable.add(() => {
const now = performance.now(); const dt = Math.min((now-last)/1000, .05); last = now;
if (playing) t = (t + dt) % LEN[mode];
if (userHold > 0) userHold -= dt;
// camera easing — assembly only; static never steers
if (mode==='assembly' && camGoal && userHold <= 0 &&
Number.isFinite(camGoal.alpha) && Number.isFinite(camGoal.beta) && Number.isFinite(camGoal.radius)) {
const k = 1 - Math.pow(0.0016, dt);
cam.alpha += (camGoal.alpha - cam.alpha) * k;
cam.beta += (camGoal.beta - cam.beta) * k;
cam.radius += (camGoal.radius - cam.radius) * k;
cam.target.x += (camGoal.target.x - cam.target.x) * k;
cam.target.y += (camGoal.target.y - cam.target.y) * k;
cam.target.z += (camGoal.target.z - cam.target.z) * k;
}
if (mode==='assembly' && userHold <= 0) cam.alpha += dt * 0.005; // gentle drift
if (mode === 'assembly') {
// drops
for (const d of sched) {
const u = (t - d.t) / T.fall_s;
if (u < 0) { setPartVis(d.p, 0); continue; }
const k = Math.min(u, 1), e = 1 - Math.pow(1-k, 3); // easeOutCubic
setPartVis(d.p, Math.min(1, k*2.2));
const dz = (1-e) * T.drop_height_mm/1000;
for (const m of d.p.meshes) { m.position.copyFrom(d.p.home); m.position.z += dz; }
}
// caption + focus + camera per GROUP window (intro before, rest after)
const g = groups.find(g => t>=g.t0 && t<g.t1);
if (t < groups[0].t0) {
showCapOnce('a-intro', A.intro); focusBlk = null;
outlines.forEach((o,id)=>o.setEnabled(id===hoverBlk));
camGoal = { alpha:home.a, beta:home.b, radius:home.r, target:home.t };
} else if (g) {
const c = A.captions[g.id] || {};
showCapOnce('a'+g.id, { ...c, block:g.id });
focusBlk = g.id;
outlines.forEach((o,id)=>o.setEnabled(id===g.id || id===hoverBlk));
const bb = blockBox(g.id);
if (bb) camGoal = { alpha: home.a + 0.25*Math.sin(t*0.12), beta: 0.86,
radius: Math.max(0.105, Math.min(0.24, bb.s.length()*2.6)),
target: bb.c };
} else { // rest: whole board seated
showCapOnce('a-rest', null); focusBlk = null;
outlines.forEach((o,id)=>o.setEnabled(id===hoverBlk));
camGoal = { alpha:home.a, beta:home.b, radius:home.r, target:home.t };
}
}
// static mode: nothing per-frame — no timeline, no captions changing, no camera goals.
// Hover tooltips, legend isolate, labels and the Power toggle all work event-driven.
// scrub + labels
if (mode==='assembly') document.getElementById('topbar-fill').style.width = (100*t/A_LEN) + '%';
for (const [id, L] of labels) {
const vis = labelsOn && blockOn[id]!==false && (id===focusBlk || id===hoverBlk);
if (!vis) { L.el.style.opacity = 0; continue; }
const p = worldToScreen(L.pos);
if (!p || p.z > 1 || p.z < 0) { L.el.style.opacity = 0; continue; }
L.el.style.left = p.x+'px'; L.el.style.top = p.y+'px'; L.el.style.opacity = 0.92;
}
});
// ---------------------------------------------------------------- hover tooltip
const tipEl = document.getElementById('tip');
scene.getEngine().getRenderingCanvas().addEventListener('pointermove', ev => {
const pick = scene.pick(scene.pointerX, scene.pointerY, m => (m.name||'').indexOf('|')>=0);
if (!pick || !pick.hit) { tipEl.style.opacity = 0; return; }
const nm = pick.pickedMesh.name.split('_primitive')[0];
const [blk, ref] = nm.split('|');
const f = IDX.fps[ref];
const p = parts.get(ref);
const mm = v => (v*1000).toFixed(2);
if (blk === 'substrate') {
tipEl.innerHTML = `<div class="t">Bare board</div><div class="m">130.00 × 50.00 mm · 1.6 mm · 4 layers<br>`+
`${{PCB:'FR-4 substrate',COPPER:'copper (all 4 layers)',MASK:'soldermask',SILK:'silkscreen'}[ref]||ref}</div>`;
} else if (f) {
tipEl.innerHTML = `<div class="t">${ref} · ${f.val}</div>`+
`<div class="m">${f.fp}<br>${BLAB[blk]||blk} · ${f.layer==='F.Cu'?'top':'bottom'} · ${f.rot?f.rot+'°':'0°'}`+
(p? `<br>extent ${mm(p.size.x)} × ${mm(p.size.y)} × ${mm(p.size.z)} mm` : '')+`</div>`;
} else return;
tipEl.style.opacity = 1;
tipEl.style.left = Math.min(ev.clientX+14, innerWidth-244)+'px';
tipEl.style.top = Math.min(ev.clientY+14, innerHeight-90)+'px';
});
scene.getEngine().getRenderingCanvas().addEventListener('pointerleave', ()=> tipEl.style.opacity=0);
// ---------------------------------------------------------------- controls
document.getElementById('btn-play').onclick = e => { playing=!playing; e.target.classList.toggle('on',playing); e.target.textContent = playing?'⏸ Pause':'▶ Play'; };
document.getElementById('btn-replay').onclick = () => { t=0; lastCapKey=null; if(mode==='assembly') COMPONENTS.forEach(p=>setPartVis(p,0)); };
document.getElementById('btn-ground').onclick = e => {
const g = scene.getMeshByName('shadowGround'); if (!g) return;
const on = !g.isEnabled(); g.setEnabled(on); e.target.classList.toggle('on',on); matchColby();
};
document.getElementById('btn-labels').onclick = e => { labelsOn=!labelsOn; e.target.classList.toggle('on',labelsOn); };
document.getElementById('btn-power').onclick = e => {
powerOn = !powerOn; e.target.classList.toggle('on', powerOn);
glows.forEach(g => g.setEnabled(mode!=='assembly' && powerOn));
};
document.getElementById('topbar').onclick = ev => { if (mode==='assembly') { t = A_LEN * (ev.clientX/innerWidth); lastCapKey=null; } };
// debug handle (harmless; also handy from the console)
window.__dbg = { parts, COMPONENTS, byBlock, sched, groups, outlines, labels, cam,
get t(){return t}, set t(v){t=v; lastCapKey=null;},
get mode(){return mode}, get focus(){return focusBlk},
setMode, A_LEN, LEN,
// jump the camera straight to its goal — the eased chase needs many frames,
// and headless swiftshader renders this board at well under 1 fps.
snap(){ if (camGoal) { cam.alpha=camGoal.alpha; cam.beta=camGoal.beta;
cam.radius=camGoal.radius; cam.target.copyFrom(camGoal.target); } },
pause(){ playing=false; }, play(){ playing=true; } };
setMode('assembly');
addEventListener('resize', () => scene.getEngine().resize());
} catch (err) {
statusEl.textContent = 'viewer error: ' + (err && err.message ? err.message : err);
console.error(err);
}
})();
</script>