← Commit history

README viewer: Motor assembly toggle loads the fitted thrust-stand stack (viewer/motor-assembly.glb) into the board viewer

Caleb ·9c1406124b ·29d ago ·parent 50575a1
2 files changed +192−173
README.html+192−173
@@ -79,10 +79,10 @@ <!-- ============================ VIEWER (the centrepiece) ============================ --> <div class="viewer-card">   <iframe id="board-viewer" title="Feeder Reader interactive board viewer"-    sandbox="allow-scripts" data-viewer-src="<meta charset=&quot;utf-8&quot;>-<title>Feeder Reader — interactive board viewer</title>-<meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot;>-<style>+    sandbox="allow-scripts" data-viewer-src="&lt;meta charset=&quot;utf-8&quot;&gt;+&lt;title&gt;Feeder Reader — interactive board viewer&lt;/title&gt;+&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot;&gt;+&lt;style&gt;   :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);@@ -143,80 +143,80 @@   /* 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=&quot;app&quot;></div>-<div id=&quot;status&quot;>loading board…</div>-<div id=&quot;topbar&quot;><div id=&quot;topbar-fill&quot;></div></div>-<div id=&quot;marks&quot;></div>-<div class=&quot;panel&quot; id=&quot;modes&quot;></div>-<div class=&quot;panel&quot; id=&quot;legend&quot;><h3>Sections</h3><div id=&quot;legend-list&quot;></div></div>-<div class=&quot;panel&quot; id=&quot;caption&quot;><div class=&quot;eyebrow&quot;></div><div class=&quot;head&quot;></div><div class=&quot;rows&quot;></div><div class=&quot;note&quot;></div></div>-<div class=&quot;btn-row&quot;>-  <button class=&quot;btn&quot; id=&quot;btn-power&quot; style=&quot;display:none&quot;>⏻ Power</button>-  <button class=&quot;btn on&quot; id=&quot;btn-play&quot;>⏸ Pause</button>-  <button class=&quot;btn&quot; id=&quot;btn-replay&quot;>↺ Replay</button>-  <button class=&quot;btn on&quot; id=&quot;btn-ground&quot;>Ground</button>-  <button class=&quot;btn on&quot; id=&quot;btn-labels&quot;>Labels</button>-</div>-<div id=&quot;tip&quot;></div>--<script>-(async () => {-const statusEl = document.getElementById('status');+&lt;/style&gt;++&lt;div id=&quot;app&quot;&gt;&lt;/div&gt;+&lt;div id=&quot;status&quot;&gt;loading board…&lt;/div&gt;+&lt;div id=&quot;topbar&quot;&gt;&lt;div id=&quot;topbar-fill&quot;&gt;&lt;/div&gt;&lt;/div&gt;+&lt;div id=&quot;marks&quot;&gt;&lt;/div&gt;+&lt;div class=&quot;panel&quot; id=&quot;modes&quot;&gt;&lt;/div&gt;+&lt;div class=&quot;panel&quot; id=&quot;legend&quot;&gt;&lt;h3&gt;Sections&lt;/h3&gt;&lt;div id=&quot;legend-list&quot;&gt;&lt;/div&gt;&lt;/div&gt;+&lt;div class=&quot;panel&quot; id=&quot;caption&quot;&gt;&lt;div class=&quot;eyebrow&quot;&gt;&lt;/div&gt;&lt;div class=&quot;head&quot;&gt;&lt;/div&gt;&lt;div class=&quot;rows&quot;&gt;&lt;/div&gt;&lt;div class=&quot;note&quot;&gt;&lt;/div&gt;&lt;/div&gt;+&lt;div class=&quot;btn-row&quot;&gt;+  &lt;button class=&quot;btn&quot; id=&quot;btn-power&quot; style=&quot;display:none&quot;&gt;⏻ Power&lt;/button&gt;+  &lt;button class=&quot;btn on&quot; id=&quot;btn-play&quot;&gt;⏸ Pause&lt;/button&gt;+  &lt;button class=&quot;btn&quot; id=&quot;btn-replay&quot;&gt;↺ Replay&lt;/button&gt;+  &lt;button class=&quot;btn on&quot; id=&quot;btn-ground&quot;&gt;Ground&lt;/button&gt;+  &lt;button class=&quot;btn on&quot; id=&quot;btn-labels&quot;&gt;Labels&lt;/button&gt;&lt;button class=&quot;btn&quot; id=&quot;btn-motor&quot; title=&quot;Show or hide the fitted thrust-stand stack (load cell, EMAX 2808, APC 7x11E)&quot;&gt;Motor assembly&lt;/button&gt;+&lt;/div&gt;+&lt;div id=&quot;tip&quot;&gt;&lt;/div&gt;++&lt;script&gt;+(async () =&gt; {+const statusEl = document.getElementById(&#x27;status&#x27;);  // 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] &amp;&amp; window[k].getItem('x'); }-  catch (e) { try { Object.defineProperty(window,k,{configurable:true,value:{getItem:()=>null,setItem:()=>{},removeItem:()=>{},clear:()=>{},key:()=>null,length:0}}); } catch(_){} }+// ACCESS, which aborts the bundle&#x27;s SceneBuilder (no lights/IBL/shadows). Shim before load.+for (const k of [&#x27;localStorage&#x27;,&#x27;sessionStorage&#x27;]) {+  try { window[k] &amp;&amp; window[k].getItem(&#x27;x&#x27;); }+  catch (e) { try { Object.defineProperty(window,k,{configurable:true,value:{getItem:()=&gt;null,setItem:()=&gt;{},removeItem:()=&gt;{},clear:()=&gt;{},key:()=&gt;null,length:0}}); } catch(_){} } }-// Blob storage serves .js as application/octet-stream + nosniff → <script src> is refused.+// Blob storage serves .js as application/octet-stream + nosniff → &lt;script src&gt; is refused. // Fetch as text, inject inline. if (!window.Adom3DViewer) {   try {-    const code = await (await fetch('https://wiki.adom.inc/blob/component/drone-motor-carrier/viewer/adom-3d-viewer.min.js?cap=__WIKICAP__')).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 code = await (await fetch(&#x27;https://wiki.adom.inc/blob/component/drone-motor-carrier/viewer/adom-3d-viewer.min.js?cap=__WIKICAP__&#x27;)).text();+    const s = document.createElement(&#x27;script&#x27;); s.textContent = code; document.head.appendChild(s);+  } catch (e) { statusEl.textContent = &#x27;viewer bundle fetch failed: &#x27; + e.message; return; } } const V = window.Adom3DViewer;-if (!V) { statusEl.textContent = 'Adom3DViewer bundle failed to load'; return; }+if (!V) { statusEl.textContent = &#x27;Adom3DViewer bundle failed to load&#x27;; return; } const B = V.BABYLON;  try { // ---------------------------------------------------------------- config + index-const CFG = await (await fetch('https://wiki.adom.inc/blob/component/drone-motor-carrier/viewer/viewer-config.json?cap=__WIKICAP__',{cache:'no-store'})).json();-const IDX = await (await fetch('https://wiki.adom.inc/blob/component/drone-motor-carrier/viewer/board-index.json?cap=__WIKICAP__',{cache:'no-store'})).json();+const CFG = await (await fetch(&#x27;https://wiki.adom.inc/blob/component/drone-motor-carrier/viewer/viewer-config.json?cap=__WIKICAP__&#x27;,{cache:&#x27;no-store&#x27;})).json();+const IDX = await (await fetch(&#x27;https://wiki.adom.inc/blob/component/drone-motor-carrier/viewer/board-index.json?cap=__WIKICAP__&#x27;,{cache:&#x27;no-store&#x27;})).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]));+const BCOL   = Object.fromEntries(BLOCKS.map(b =&gt; [b.id, b.color]));+const BLAB   = Object.fromEntries(BLOCKS.map(b =&gt; [b.id, b.label]));  // ---------------------------------------------------------------- scene-const viewer = V.init(document.getElementById('app'), { zUp:true, showViewCube:true, showGround:true });-await viewer.loadModel('https://wiki.adom.inc/blob/component/drone-motor-carrier/viewer/board-detailed.glb?cap=__WIKICAP__');+const viewer = V.init(document.getElementById(&#x27;app&#x27;), { zUp:true, showViewCube:true, showGround:true });+await viewer.loadModel(&#x27;https://wiki.adom.inc/blob/component/drone-motor-carrier/viewer/board-detailed.glb?cap=__WIKICAP__&#x27;); const scene = viewer.getScene();-scene.animationGroups.slice().forEach(g => g.dispose());     // we drive all motion+scene.animationGroups.slice().forEach(g =&gt; g.dispose());     // we drive all motion -// Match Colby's current native viewer: flat #2a3340, no skybox mesh, soft radial ground.+// Match Colby&#x27;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 &amp;&amp; scene.clearColor.constructor; let _fade=null;-const groundFadeTex = () => {+const groundFadeTex = () =&gt; {   if (_fade) return _fade;-  const t=new B.DynamicTexture('groundFadeTex',256,scene,true), c=t.getContext();+  const t=new B.DynamicTexture(&#x27;groundFadeTex&#x27;,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)');+  g.addColorStop(0,&#x27;rgba(255,255,255,1)&#x27;); g.addColorStop(.55,&#x27;rgba(255,255,255,.6)&#x27;); g.addColorStop(1,&#x27;rgba(255,255,255,0)&#x27;);   c.fillStyle=g; c.fillRect(0,0,256,256); t.update(); t.hasAlpha=true; _fade=t; return t; };-const matchColby = () => {+const matchColby = () =&gt; {   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');+  scene.meshes.filter(m=&gt;/^Background/.test(m.name)).forEach(m=&gt;m.setEnabled(false));+  const gnd = scene.getMeshByName(&#x27;shadowGround&#x27;);   if (gnd &amp;&amp; gnd.material) {     const m = gnd.material;-    if (!(m.baseTexture &amp;&amp; m.baseTexture.name==='groundFadeTex')) {-      if ('baseTexture' in m) m.baseTexture = groundFadeTex();+    if (!(m.baseTexture &amp;&amp; m.baseTexture.name===&#x27;groundFadeTex&#x27;)) {+      if (&#x27;baseTexture&#x27; in m) m.baseTexture = groundFadeTex();       m.alpha=0.2; m.transparencyMode=2; m.backFaceCulling=false;     }     gnd.receiveShadows = true;@@ -225,14 +225,14 @@ const matchColby = () => { matchColby();  // ---------------------------------------------------------------- group meshes-// Every mesh is named &quot;<block>|<ref>|<i>&quot; (transforms baked at export, so a plain+// Every mesh is named &quot;&lt;block&gt;|&lt;ref&gt;|&lt;i&gt;&quot; (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]+const parts = new Map();          // ref -&gt; {ref, blk, meshes[], home:Vector3, centre}+const byBlock = new Map();        // blk -&gt; [part] for (const m of scene.meshes) {-  const nm = (m.name||'').split('_primitive')[0];-  if (nm.indexOf('|') < 0) continue;-  const [blk, ref] = nm.split('|');+  const nm = (m.name||&#x27;&#x27;).split(&#x27;_primitive&#x27;)[0];+  if (nm.indexOf(&#x27;|&#x27;) &lt; 0) continue;+  const [blk, ref] = nm.split(&#x27;|&#x27;);   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); }@@ -248,7 +248,7 @@ for (const p of parts.values()) {   }   p.lo=lo; p.hi=hi; p.centre=lo.add(hi).scale(0.5); p.size=hi.subtract(lo); }-const blockBox = blk => {+const blockBox = blk =&gt; {   let lo=null,hi=null;   for (const p of (byBlock.get(blk)||[])) {     lo = lo ? B.Vector3.Minimize(lo,p.lo) : p.lo.clone();@@ -262,14 +262,14 @@ const blockBox = blk => {   }   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 = '';+const SUB = [&#x27;PCB&#x27;,&#x27;COPPER&#x27;,&#x27;MASK&#x27;,&#x27;SILK&#x27;].map(r=&gt;parts.get(r)).filter(Boolean);+const COMPONENTS = [...parts.values()].filter(p =&gt; p.blk!==&#x27;substrate&#x27;);+statusEl.textContent = &#x27;&#x27;;  // ---------------------------------------------------------------- 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 setCam = (o,ease) =&gt; {   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;@@ -277,33 +277,33 @@ const setCam = (o,ease) => { 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-precision fix: the bundle&#x27;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); });+scene.onBeforeRenderObservable.add(() =&gt; { cam.minZ = Math.max(cam.radius / 100, 2e-4); }); cam.panningSensibility = 6000; // The camera is &quot;yours for 5 s&quot;: 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}));+[&#x27;pointerdown&#x27;,&#x27;wheel&#x27;].forEach(ev =&gt; scene.getEngine().getRenderingCanvas()+  .addEventListener(ev, () =&gt; { userHold = 5; }, {passive:true}));  // ---------------------------------------------------------------- helpers-const emissive = (col, alpha) => {-  const m = new B.StandardMaterial('em'+Math.random(), scene);+const emissive = (col, alpha) =&gt; {+  const m = new B.StandardMaterial(&#x27;em&#x27;+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);+const hex2c3 = h =&gt; 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, {+  const box = B.MeshBuilder.CreateBox(&#x27;outline_&#x27;+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;@@ -318,22 +318,22 @@ const glows = new Map(); let powerOn = !!(FN &amp;&amp; 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);+  const d = B.MeshBuilder.CreateBox(&#x27;glow_&#x27;+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';+const labelLayer = document.createElement(&#x27;div&#x27;);+labelLayer.style.cssText = &#x27;position:absolute;inset:0;pointer-events:none;z-index:4&#x27;; 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;+  const el = document.createElement(&#x27;div&#x27;);+  el.style.cssText = &#x27;position:absolute;transform:translate(-50%,-50%);font:650 10.5px/1 ui-sans-serif,system-ui;&#x27;++    &#x27;letter-spacing:.03em;padding:3px 7px;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity .3s;&#x27;++    &#x27;background:rgba(10,15,22,.82);border:1px solid &#x27;+b.color+&#x27;66;color:&#x27;+b.color;   el.textContent = b.label;   labelLayer.appendChild(el); labels.set(b.id, {el, pos:bb.c.clone()}); }@@ -344,57 +344,57 @@ let labelsOn = true; 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 setPartVis = (p, v) =&gt; { for (const m of p.meshes) m.visibility = v; };+const showAllComponents = v =&gt; COMPONENTS.forEach(p =&gt; 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)); }+const blockOn = Object.fromEntries(BLOCKS.map(b=&gt;[b.id,true]));+const applyLayers = () =&gt; {+  for (const p of SUB) { const on = layerOn[p.ref]!==false; p.meshes.forEach(m=&gt;m.setEnabled(on)); }+  for (const p of COMPONENTS) { const on = blockOn[p.blk]!==false; p.meshes.forEach(m=&gt;m.setEnabled(on)); } };  // ---------------------------------------------------------------- legend-const legendList = document.getElementById('legend-list');+const legendList = document.getElementById(&#x27;legend-list&#x27;); 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=&quot;sw&quot; style=&quot;background:${b.color}&quot;></span><span>${b.label}</span><span class=&quot;n&quot;>${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); };+  const row = document.createElement(&#x27;div&#x27;); row.className=&#x27;row&#x27;;+  row.innerHTML = `&lt;span class=&quot;sw&quot; style=&quot;background:${b.color}&quot;&gt;&lt;/span&gt;&lt;span&gt;${b.label}&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;${counts[b.id]||0}&lt;/span&gt;`;+  row.onclick = () =&gt; { blockOn[b.id] = !blockOn[b.id]; row.classList.toggle(&#x27;off&#x27;, !blockOn[b.id]); applyLayers(); };+  row.onmouseenter = () =&gt; { hoverBlk = b.id; outlines.get(b.id)?.setEnabled(true); };+  row.onmouseleave = () =&gt; { 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=&quot;sw&quot; style=&quot;background:#b78227&quot;></span><span>Copper &amp;amp; silkscreen</span><span class=&quot;n&quot;>4</span>`;-subRow.onclick = () => { const on = !(layerOn.COPPER &amp;&amp; layerOn.SILK); layerOn.COPPER=layerOn.SILK=on; subRow.classList.toggle('off',!on); applyLayers(); };+const subRow = document.createElement(&#x27;div&#x27;); subRow.className=&#x27;row&#x27;;+subRow.innerHTML = `&lt;span class=&quot;sw&quot; style=&quot;background:#b78227&quot;&gt;&lt;/span&gt;&lt;span&gt;Copper &amp;amp; silkscreen&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;4&lt;/span&gt;`;+subRow.onclick = () =&gt; { const on = !(layerOn.COPPER &amp;&amp; layerOn.SILK); layerOn.COPPER=layerOn.SILK=on; subRow.classList.toggle(&#x27;off&#x27;,!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=&quot;chip&quot; style=&quot;background:${BCOL[c.block]}&quot;></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');+const capEl = document.getElementById(&#x27;caption&#x27;);+const showCap = (c) =&gt; {+  if (!c) { capEl.classList.remove(&#x27;show&#x27;); return; }+  const dot = c.block ? `&lt;span class=&quot;chip&quot; style=&quot;background:${BCOL[c.block]}&quot;&gt;&lt;/span&gt;` : &#x27;&#x27;;+  capEl.querySelector(&#x27;.eyebrow&#x27;).innerHTML = dot + (c.eyebrow||&#x27;&#x27;);+  capEl.querySelector(&#x27;.head&#x27;).textContent = c.head||&#x27;&#x27;;+  capEl.querySelector(&#x27;.rows&#x27;).innerHTML = (c.rows||[]).map(r=&gt;`&lt;b&gt;${r[0]}&lt;/b&gt;&lt;span&gt;${r[1]}&lt;/span&gt;`).join(&#x27;&#x27;);+  capEl.querySelector(&#x27;.note&#x27;).textContent = c.note||&#x27;&#x27;;+  capEl.classList.add(&#x27;show&#x27;); }; let lastCapKey = null;-const showCapOnce = (key,c) => { if (key!==lastCapKey) { lastCapKey=key; showCap(c); } };+const showCapOnce = (key,c) =&gt; { 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.+// Assembly schedule — the GROUP is the unit: all of a grouping&#x27;s parts fall together+// (tiny in-group stagger for texture) while that grouping&#x27;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 &amp;&amp; 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 ps = (byBlock.get(bid)||[]).slice().sort((p,q)=&gt;p.centre.x-q.centre.x);+  ps.forEach((p,i)=&gt; sched.push({p, t: tA + i*T.stagger_s, blk:bid}));   const dwell = (A.captions[bid] &amp;&amp; A.captions[bid].d) || T.dwell_s;   groups.push({ id: bid, t0: tA, t1: tA + dwell });   tA += dwell;@@ -402,36 +402,36 @@ for (const bid of A.order) { 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;+let mode = &#x27;assembly&#x27;, 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);+const modesEl = document.getElementById(&#x27;modes&#x27;);+for (const k of [&#x27;assembly&#x27;,&#x27;static&#x27;]) {+  const b = document.createElement(&#x27;button&#x27;); b.textContent = CFG.modes[k].label; b.dataset.k = k;+  b.onclick = () =&gt; setMode(k);   modesEl.appendChild(b); }-const marksEl = document.getElementById('marks');+const marksEl = document.getElementById(&#x27;marks&#x27;); function drawMarks() {-  marksEl.innerHTML = '';-  if (mode!=='assembly') return;-  for (const g of groups) marksEl.insertAdjacentHTML('beforeend', `<i style=&quot;left:${100*g.t0/A_LEN}%&quot;></i>`);+  marksEl.innerHTML = &#x27;&#x27;;+  if (mode!==&#x27;assembly&#x27;) return;+  for (const g of groups) marksEl.insertAdjacentHTML(&#x27;beforeend&#x27;, `&lt;i style=&quot;left:${100*g.t0/A_LEN}%&quot;&gt;&lt;/i&gt;`); } 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 &amp;&amp; FN) ? '' : 'none';-  glows.forEach(g => g.setEnabled(!isA &amp;&amp; powerOn));+  [...modesEl.children].forEach(b =&gt; b.classList.toggle(&#x27;on&#x27;, b.dataset.k===k));+  outlines.forEach(o =&gt; o.setEnabled(false));+  COMPONENTS.forEach(p =&gt; { setPartVis(p, k===&#x27;assembly&#x27; ? 0 : 1);+                            p.meshes.forEach(m =&gt; m.position.copyFrom(p.home)); });+  // static: no timeline, no scripted camera — the camera is entirely the user&#x27;s+  const isA = k===&#x27;assembly&#x27;;+  document.getElementById(&#x27;topbar&#x27;).style.display = isA ? &#x27;block&#x27; : &#x27;none&#x27;;+  document.getElementById(&#x27;btn-play&#x27;).style.display = isA ? &#x27;&#x27; : &#x27;none&#x27;;+  document.getElementById(&#x27;btn-replay&#x27;).style.display = isA ? &#x27;&#x27; : &#x27;none&#x27;;+  document.getElementById(&#x27;btn-power&#x27;).style.display = (!isA &amp;&amp; FN) ? &#x27;&#x27; : &#x27;none&#x27;;+  glows.forEach(g =&gt; g.setEnabled(!isA &amp;&amp; powerOn));   setCam(home,false);   drawMarks(); applyLayers(); showCap(null);-  if (!isA &amp;&amp; S.blurb) showCap({ eyebrow:'Static', head:'Inspect the board', note:S.blurb });+  if (!isA &amp;&amp; S.blurb) showCap({ eyebrow:&#x27;Static&#x27;, head:&#x27;Inspect the board&#x27;, note:S.blurb }); }  // ---------------------------------------------------------------- render loop@@ -439,7 +439,7 @@ function setMode(k) { // 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) => {+const worldToScreen = (v) =&gt; {   try {     const e = scene.getEngine();     return B.Vector3.Project(v, IDM, scene.getTransformMatrix(),@@ -447,13 +447,13 @@ const worldToScreen = (v) => {   } catch (_) { return null; } }; let last = performance.now();-scene.onBeforeRenderObservable.add(() => {+scene.onBeforeRenderObservable.add(() =&gt; {   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;+  if (userHold &gt; 0) userHold -= dt;    // camera easing — assembly only; static never steers-  if (mode==='assembly' &amp;&amp; camGoal &amp;&amp; userHold <= 0 &amp;&amp;+  if (mode===&#x27;assembly&#x27; &amp;&amp; camGoal &amp;&amp; userHold &lt;= 0 &amp;&amp;       Number.isFinite(camGoal.alpha) &amp;&amp; Number.isFinite(camGoal.beta) &amp;&amp; Number.isFinite(camGoal.radius)) {     const k = 1 - Math.pow(0.0016, dt);     cam.alpha  += (camGoal.alpha  - cam.alpha)  * k;@@ -463,36 +463,36 @@ scene.onBeforeRenderObservable.add(() => {     cam.target.y += (camGoal.target.y - cam.target.y) * k;     cam.target.z += (camGoal.target.z - cam.target.z) * k;   }-  if (mode==='assembly' &amp;&amp; userHold <= 0) cam.alpha += dt * 0.005;   // gentle drift+  if (mode===&#x27;assembly&#x27; &amp;&amp; userHold &lt;= 0) cam.alpha += dt * 0.005;   // gentle drift -  if (mode === 'assembly') {+  if (mode === &#x27;assembly&#x27;) {     // drops     for (const d of sched) {       const u = (t - d.t) / T.fall_s;-      if (u < 0) { setPartVis(d.p, 0); continue; }+      if (u &lt; 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 &amp;&amp; t<g.t1);-    if (t < groups[0].t0) {-      showCapOnce('a-intro', A.intro); focusBlk = null;-      outlines.forEach((o,id)=>o.setEnabled(id===hoverBlk));+    const g = groups.find(g =&gt; t&gt;=g.t0 &amp;&amp; t&lt;g.t1);+    if (t &lt; groups[0].t0) {+      showCapOnce(&#x27;a-intro&#x27;, A.intro); focusBlk = null;+      outlines.forEach((o,id)=&gt;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 });+      showCapOnce(&#x27;a&#x27;+g.id, { ...c, block:g.id });       focusBlk = g.id;-      outlines.forEach((o,id)=>o.setEnabled(id===g.id || id===hoverBlk));+      outlines.forEach((o,id)=&gt;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));+      showCapOnce(&#x27;a-rest&#x27;, null); focusBlk = null;+      outlines.forEach((o,id)=&gt;o.setEnabled(id===hoverBlk));       camGoal = { alpha:home.a, beta:home.b, radius:home.r, target:home.t };     }   }@@ -500,53 +500,72 @@ scene.onBeforeRenderObservable.add(() => {   // 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) + '%';+  if (mode===&#x27;assembly&#x27;) document.getElementById(&#x27;topbar-fill&#x27;).style.width = (100*t/A_LEN) + &#x27;%&#x27;;   for (const [id, L] of labels) {     const vis = labelsOn &amp;&amp; blockOn[id]!==false &amp;&amp; (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;+    if (!p || p.z &gt; 1 || p.z &lt; 0) { L.el.style.opacity = 0; continue; }+    L.el.style.left = p.x+&#x27;px&#x27;; L.el.style.top = p.y+&#x27;px&#x27;; 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);+const tipEl = document.getElementById(&#x27;tip&#x27;);+scene.getEngine().getRenderingCanvas().addEventListener(&#x27;pointermove&#x27;, ev =&gt; {+  const pick = scene.pick(scene.pointerX, scene.pointerY, m =&gt; (m.name||&#x27;&#x27;).indexOf(&#x27;|&#x27;)&gt;=0);   if (!pick || !pick.hit) { tipEl.style.opacity = 0; return; }-  const nm = pick.pickedMesh.name.split('_primitive')[0];-  const [blk, ref] = nm.split('|');+  const nm = pick.pickedMesh.name.split(&#x27;_primitive&#x27;)[0];+  const [blk, ref] = nm.split(&#x27;|&#x27;);   const f = IDX.fps[ref];   const p = parts.get(ref);-  const mm = v => (v*1000).toFixed(2);-  if (blk === 'substrate') {-    tipEl.innerHTML = `<div class=&quot;t&quot;>Bare board</div><div class=&quot;m&quot;>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>`;+  const mm = v =&gt; (v*1000).toFixed(2);+  if (blk === &#x27;substrate&#x27;) {+    tipEl.innerHTML = `&lt;div class=&quot;t&quot;&gt;Bare board&lt;/div&gt;&lt;div class=&quot;m&quot;&gt;130.00 × 50.00 mm · 1.6 mm · 4 layers&lt;br&gt;`++      `${{PCB:&#x27;FR-4 substrate&#x27;,COPPER:&#x27;copper (all 4 layers)&#x27;,MASK:&#x27;soldermask&#x27;,SILK:&#x27;silkscreen&#x27;}[ref]||ref}&lt;/div&gt;`;   } else if (f) {-    tipEl.innerHTML = `<div class=&quot;t&quot;>${ref} · ${f.val}</div>`+-      `<div class=&quot;m&quot;>${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>`;+    tipEl.innerHTML = `&lt;div class=&quot;t&quot;&gt;${ref} · ${f.val}&lt;/div&gt;`++      `&lt;div class=&quot;m&quot;&gt;${f.fp}&lt;br&gt;${BLAB[blk]||blk} · ${f.layer===&#x27;F.Cu&#x27;?&#x27;top&#x27;:&#x27;bottom&#x27;} · ${f.rot?f.rot+&#x27;°&#x27;:&#x27;0°&#x27;}`++      (p? `&lt;br&gt;extent ${mm(p.size.x)} × ${mm(p.size.y)} × ${mm(p.size.z)} mm` : &#x27;&#x27;)+`&lt;/div&gt;`;   } 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';+  tipEl.style.left = Math.min(ev.clientX+14, innerWidth-244)+&#x27;px&#x27;;+  tipEl.style.top  = Math.min(ev.clientY+14, innerHeight-90)+&#x27;px&#x27;; });-scene.getEngine().getRenderingCanvas().addEventListener('pointerleave', ()=> tipEl.style.opacity=0);+scene.getEngine().getRenderingCanvas().addEventListener(&#x27;pointerleave&#x27;, ()=&gt; 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(&#x27;btn-play&#x27;).onclick = e =&gt; { playing=!playing; e.target.classList.toggle(&#x27;on&#x27;,playing); e.target.textContent = playing?&#x27;⏸ Pause&#x27;:&#x27;▶ Play&#x27;; };+document.getElementById(&#x27;btn-replay&#x27;).onclick = () =&gt; { t=0; lastCapKey=null; if(mode===&#x27;assembly&#x27;) COMPONENTS.forEach(p=&gt;setPartVis(p,0)); };+document.getElementById(&#x27;btn-ground&#x27;).onclick = e =&gt; {+  const g = scene.getMeshByName(&#x27;shadowGround&#x27;); if (!g) return;+  const on = !g.isEnabled(); g.setEnabled(on); e.target.classList.toggle(&#x27;on&#x27;,on); matchColby();+};+// ---- Motor assembly toggle: the fitted thrust-stand stack (Loadcell Thrust Stand Assembly.step), lazily loaded on first use.+// viewer.loadModel APPENDS (it never clears), so the stack lands in the same frame handling as the board.+let motorRoot = null, motorOn = false, motorLoading = null;+const motorBtn = document.getElementById(&#x27;btn-motor&#x27;);+async function ensureMotor() {+  if (motorRoot || motorLoading) return motorLoading;+  const before = new Set(scene.meshes);+  motorLoading = viewer.loadModel(&#x27;https://wiki.adom.inc/blob/component/drone-motor-carrier/viewer/motor-assembly.glb?cap=__WIKICAP__&#x27;).then(() =&gt; {+    const added = scene.meshes.filter(m =&gt; !before.has(m));+    motorRoot = added.find(m =&gt; m.name === &#x27;__root__&#x27;) || added[0] || null;+    added.forEach(m =&gt; { m.isPickable = false; });+  }).catch(e =&gt; { console.warn(&#x27;motor assembly failed to load&#x27;, e); motorBtn.textContent = &#x27;Motor assembly (unavailable)&#x27;; });+  return motorLoading;+}+motorBtn.onclick = async () =&gt; {+  motorBtn.disabled = true; await ensureMotor(); motorBtn.disabled = false;+  if (!motorRoot) return;+  motorOn = !motorOn; motorRoot.setEnabled(motorOn); motorBtn.classList.toggle(&#x27;on&#x27;, motorOn); };-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' &amp;&amp; powerOn));+document.getElementById(&#x27;btn-labels&#x27;).onclick = e =&gt; { labelsOn=!labelsOn; e.target.classList.toggle(&#x27;on&#x27;,labelsOn); };+document.getElementById(&#x27;btn-power&#x27;).onclick = e =&gt; {+  powerOn = !powerOn; e.target.classList.toggle(&#x27;on&#x27;, powerOn);+  glows.forEach(g =&gt; g.setEnabled(mode!==&#x27;assembly&#x27; &amp;&amp; powerOn)); };-document.getElementById('topbar').onclick = ev => { if (mode==='assembly') { t = A_LEN * (ev.clientX/innerWidth); lastCapKey=null; } };+document.getElementById(&#x27;topbar&#x27;).onclick = ev =&gt; { if (mode===&#x27;assembly&#x27;) { 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,@@ -559,15 +578,15 @@ window.__dbg = { parts, COMPONENTS, byBlock, sched, groups, outlines, labels, ca                           cam.radius=camGoal.radius; cam.target.copyFrom(camGoal.target); } },                  pause(){ playing=false; }, play(){ playing=true; } }; -setMode('assembly');-addEventListener('resize', () => scene.getEngine().resize());+setMode(&#x27;assembly&#x27;);+addEventListener(&#x27;resize&#x27;, () =&gt; scene.getEngine().resize());  } catch (err) {-  statusEl.textContent = 'viewer error: ' + (err &amp;&amp; err.message ? err.message : err);+  statusEl.textContent = &#x27;viewer error: &#x27; + (err &amp;&amp; err.message ? err.message : err);   console.error(err); } })();-</script>+&lt;/script&gt; "></iframe> <script> /* wiki#73 caps: the injected bootstrap patches THIS document, but a sandboxed srcdoc child is a
viewer/motor-assembly.glbadded
⋯ 1 unchanged line ⋯