main
Caleb Revert the 2026-09-29 merge of adom/drone-motor-carrier-assembly: restore the pre-merge model, viewer stack model, READ… cb22b3e 21d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776
<style>.overview-doc{font-size:14px;line-height:1.6}.overview-doc table{border-collapse:collapse;margin:10px 0}.overview-doc th,.overview-doc td{border:1px solid rgba(128,140,160,.3);padding:4px 10px;text-align:left}.overview-doc pre{background:rgba(128,140,160,.12);padding:10px;border-radius:6px;overflow-x:auto;font-size:12.5px}.overview-doc code{font-family:ui-monospace,monospace}</style><meta charset="utf-8">
<title>Feeder Reader — Feeder Pin Input Module</title>
<style>
  :root{ --bg:#151b23; --card:#1b232d; --ink:#e8eef5; --dim:#9fb0c3; --dim2:#7f92a6;
         --line:rgba(255,255,255,.10); --acc:#22c55e; }
  *{box-sizing:border-box}
  body{margin:0;background:var(--bg);color:var(--ink);
    font-family:ui-sans-serif,-apple-system,"Segoe UI",Inter,system-ui,sans-serif;
    font-size:15px;line-height:1.62;-webkit-font-smoothing:antialiased}
  .wrap{max-width:960px;margin:0 auto;padding:0 24px}
  a{color:#7fb2ff;text-decoration:none} a:hover{text-decoration:underline}
  h2{font-size:13px;letter-spacing:.15em;text-transform:uppercase;color:var(--dim2);
     margin:56px 0 18px;font-weight:700}
  h3{font-size:16px;margin:0;font-weight:650}
  p{margin:0 0 15px}
  .lede{font-size:17px;line-height:1.65;color:#cfdae7}
  .mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px}
  .dot{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:7px;vertical-align:0}

  /* ---------- viewer ---------- */
  .viewer-card{width:100%;background:#2a3340;border-bottom:1px solid var(--line);position:relative}
  .viewer-card iframe{width:100%;border:0;display:block}
  .viewer-hint{position:absolute;left:0;right:0;bottom:0;padding:7px 20px;font-size:11px;
    color:#8fa2b6;background:linear-gradient(transparent,rgba(10,15,22,.75));pointer-events:none}

  /* ---------- title ---------- */
  .title{padding:38px 0 6px}
  .title h1{font-size:38px;line-height:1.12;margin:0 0 6px;font-weight:680;letter-spacing:-.015em}
  .title .sub{font-size:15px;color:var(--dim);margin-bottom:18px}
  .facts{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:20px}
  .facts span{background:var(--card);border:1px solid var(--line);border-radius:7px;
    padding:5px 10px;font-size:12px;color:#cfdae7}
  .facts span b{color:#fff;font-weight:600}
  .prov{background:rgba(59,130,246,.09);border:1px solid rgba(59,130,246,.28);
    border-radius:10px;padding:13px 15px;font-size:13px;color:#b9c7d7;margin:6px 0 8px}
  .prov b{color:#dce6f2}

  /* ---------- generic blocks ---------- */
  .grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
  .card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:17px 19px}
  .card h3{margin-bottom:8px}
  .card p:last-child{margin-bottom:0}
  .sec{background:var(--card);border:1px solid var(--line);border-left:3px solid var(--c);
    border-radius:10px;padding:15px 17px;margin-bottom:11px}
  .sec-h{display:flex;align-items:center;gap:0;margin-bottom:7px}
  .sec-h .count{margin-left:auto;font-size:11.5px;color:var(--dim2);
    font-variant-numeric:tabular-nums}
  .sec p{margin:0 0 10px;font-size:14px;color:#c2d0de}
  .chips{display:flex;flex-wrap:wrap;gap:5px}
  .chip{background:rgba(255,255,255,.05);border:1px solid var(--line);border-radius:6px;
    padding:3px 8px;font:600 11.5px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:#dce6f2}
  .chip i{font-style:normal;color:var(--dim2);font-weight:400}

  table{width:100%;border-collapse:collapse;font-size:13.5px}
  th{text-align:left;font-size:11px;letter-spacing:.09em;text-transform:uppercase;
    color:var(--dim2);font-weight:700;padding:0 10px 8px 0;border-bottom:1px solid var(--line)}
  td{padding:8px 10px 8px 0;border-bottom:1px solid rgba(255,255,255,.05);vertical-align:top}
  td.num{font-variant-numeric:tabular-nums;color:var(--dim);width:34px}
  td.refs{color:var(--dim2);font-size:11.5px}
  .tag{display:inline-block;font:700 9.5px/1 inherit;letter-spacing:.06em;padding:3px 5px;
    border-radius:4px;vertical-align:1px;margin-left:6px}
  .tag.dnp{background:rgba(239,68,68,.16);color:#f8a6a6;border:1px solid rgba(239,68,68,.3)}

  figure{margin:0 0 18px}
  figure img{width:100%;display:block;background:#0b1220;border:1px solid var(--line);border-radius:10px}
  figcaption{font-size:12px;color:var(--dim2);margin-top:8px}
  .legend{display:flex;flex-wrap:wrap;gap:12px;margin:12px 0 0;font-size:12px;color:#c2d0de}
  .legend span{display:flex;align-items:center}

  .note{background:rgba(245,165,36,.08);border:1px solid rgba(245,165,36,.26);
    border-radius:10px;padding:13px 15px;font-size:13.5px;color:#e5d3b4;margin:0 0 14px}
  .note b{color:#f5d9a8}
  ul{margin:0 0 15px;padding-left:20px} li{margin-bottom:6px}
  footer{margin:64px 0 60px;padding-top:20px;border-top:1px solid var(--line);
    font-size:12px;color:var(--dim2)}
  @media (max-width:720px){ .grid2{grid-template-columns:1fr} .title h1{font-size:29px} }
</style>

<!-- ============================ VIEWER (the centrepiece) ============================ -->
<div class="viewer-card">
  <iframe id="board-viewer" title="Feeder Reader interactive board viewer"
    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);
    font-family:ui-sans-serif,-apple-system,&quot;Segoe UI&quot;,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} }
&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&#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 → &lt;script src&gt; is refused.
// Fetch as text, inject inline.
if (!window.Adom3DViewer) {
  try {
    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 = &#x27;Adom3DViewer bundle failed to load&#x27;; return; }
const B = V.BABYLON;

try {
// ---------------------------------------------------------------- config + index
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 =&gt; [b.id, b.color]));
const BLAB   = Object.fromEntries(BLOCKS.map(b =&gt; [b.id, b.label]));

// ---------------------------------------------------------------- scene
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 =&gt; g.dispose());     // we drive all motion

// 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 = () =&gt; {
  if (_fade) return _fade;
  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,&#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 = () =&gt; {
  if (Color4) scene.clearColor = new Color4(bgBot.r,bgBot.g,bgBot.b,1);
  scene.environmentIntensity = 1.0;
  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===&#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;
  }
};
matchColby();

// ---------------------------------------------------------------- group meshes
// 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 -&gt; {ref, blk, meshes[], home:Vector3, centre}
const byBlock = new Map();        // blk -&gt; [part]
for (const m of scene.meshes) {
  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); }
  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 =&gt; {
  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 = [&#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) =&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;
};
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&#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(() =&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.
[&#x27;pointerdown&#x27;,&#x27;wheel&#x27;].forEach(ev =&gt; scene.getEngine().getRenderingCanvas()
  .addEventListener(ev, () =&gt; { userHold = 5; }, {passive:true}));

// ---------------------------------------------------------------- helpers
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 =&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(&#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;
  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 &quot;Power&quot; is toggled. Purely illustrative of the powered state.
const FN = (CFG.modes.static &amp;&amp; CFG.modes.static.functions &amp;&amp; CFG.modes.static.functions.power_leds) || null;
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(&#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(&#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(&#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()});
}
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) =&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=&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(&#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(&#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(&#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(&#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) =&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&#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)=&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;
}
const A_LEN = tA + T.rest_s;
const LEN = { assembly: A_LEN, static: 1 };   // static has NO timeline

let mode = &#x27;assembly&#x27;, t = 0, playing = true;

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(&#x27;marks&#x27;);
function drawMarks() {
  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 =&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:&#x27;Static&#x27;, head:&#x27;Inspect the board&#x27;, 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) =&gt; {
  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(() =&gt; {
  const now = performance.now(); const dt = Math.min((now-last)/1000, .05); last = now;
  if (playing) t = (t + dt) % LEN[mode];
  if (userHold &gt; 0) userHold -= dt;

  // camera easing — assembly only; static never steers
  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;
    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===&#x27;assembly&#x27; &amp;&amp; userHold &lt;= 0) cam.alpha += dt * 0.005;   // gentle drift

  if (mode === &#x27;assembly&#x27;) {
    // drops
    for (const d of sched) {
      const u = (t - d.t) / T.fall_s;
      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 =&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(&#x27;a&#x27;+g.id, { ...c, block:g.id });
      focusBlk = g.id;
      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(&#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 };
    }
  }
  // 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===&#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 &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(&#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(&#x27;_primitive&#x27;)[0];
  const [blk, ref] = nm.split(&#x27;|&#x27;);
  const f = IDX.fps[ref];
  const p = parts.get(ref);
  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 = `&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)+&#x27;px&#x27;;
  tipEl.style.top  = Math.min(ev.clientY+14, innerHeight-90)+&#x27;px&#x27;;
});
scene.getEngine().getRenderingCanvas().addEventListener(&#x27;pointerleave&#x27;, ()=&gt; tipEl.style.opacity=0);

// ---------------------------------------------------------------- controls
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(&#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(&#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,
                 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(&#x27;assembly&#x27;);
addEventListener(&#x27;resize&#x27;, () =&gt; scene.getEngine().resize());

} catch (err) {
  statusEl.textContent = &#x27;viewer error: &#x27; + (err &amp;&amp; err.message ? err.message : err);
  console.error(err);
}
})();
&lt;/script&gt;
"></iframe>
<script>
/* wiki#73 caps: the injected bootstrap patches THIS document, but a sandboxed srcdoc child is a
   separate document with its own unpatched fetch and no __wikiCap. The bootstrap is appended at
   the END of the served document, so wait for it before substituting the cap into the template. */
(function(){
  var tries=0;
  function go(){
    var f=document.getElementById('board-viewer'); if(!f) return;
    var tpl=f.getAttribute('data-viewer-src'); if(!tpl) return;
    var cap=window.__wikiCap||'';
    if(!cap && tries++ < 60) return setTimeout(go,50);
    f.srcdoc=tpl.replace(/__WIKICAP__/g,cap);
    f.removeAttribute('data-viewer-src');
    /* The bootstrap rewrites img/source/video/audio srcs, but NOT iframe srcs — an iframe load is
       a navigation, so a self-contained app embed on a private page needs the cap appended here. */
    Array.prototype.forEach.call(document.querySelectorAll('iframe[data-cap-src]'),function(el){
      var u=el.getAttribute('data-cap-src');
      /* Mirror the wiki bootstrap's own needs()/add() semantics exactly, rather than inventing
         our own: (1) never touch data:/blob:/#/javascript:, (2) SAME-HOST only - compared against
         location.host, not a hardcoded domain, so it holds on any deployment, (3) emit a RELATIVE
         url so the token cannot ride a redirect to another origin. The cap is a bearer credential
         for this page's files; handing it to a third-party origin would be a real leak. */
      var out=u;
      try {
        if (!/^(data:|blob:|#|javascript:)/i.test(u)) {
          var a=new URL(u, location.href);
          if (cap && a.host===location.host && !a.searchParams.has('cap')) {
            a.searchParams.set('cap', cap);
            out = a.pathname + a.search + a.hash;
          }
        }
      } catch(e) { out=u; }
      el.src = out;
      el.removeAttribute('data-cap-src');
    });
  }
  if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',go); else go();
})();
</script>
  <div class="viewer-hint">Assembly — each grouping drops in while its caption explains it · Static — the camera is yours — drag to orbit, scroll to zoom, hover any part</div>
</div>

<div class="wrap">
  <div class="title">
    <h1>Drone Motor Carrier</h1>
    <div class="sub">drone-motor-carrier v1.1 · passive baseplate for the single-motor test cell</div>
    <div class="facts">
      <span><b>104 × 104</b> mm</span><span><b>4</b>-layer, 1.6 mm</span>
      <span><b>96 × 96</b> mm datum</span><span><b>hosts</b> RP2350A/ADS1220 + PiProbe</span>
    </div>
  </div>

  
  <h2>Project overview</h2>
<p class="note" style="border-left:3px solid #2f81f7;background:rgba(31,111,235,.08);padding:10px 14px;border-radius:0 6px 6px 0;margin:0 0 16px;font-size:14px"><b>With or without the motor assembly:</b> the 3D model on this page carries the fitted thrust-stand stack (load-cell mounts, FCU force sensor, EMAX ECO III 2808, APC 7x11E, phase wires) as a second <b>layer</b>. It starts hidden and previews once after load; use the <b>Layers</b> panel in the viewer to switch it on. The same assembly, shown by default and importable into Hydrogen as its own molecule, is <a href="https://wiki.adom.inc/adom/drone-motor-carrier-assembly">Drone Motor Carrier - Assembly</a>.</p>
  <p class="prov">From the project's own overview document (author-written).</p>
  <div class="overview-doc">
<h1>Motor Carrier v1.1 — Function</h1>
<p>104 × 104 mm, 4-layer JLC04161H-7628, 2.000 mm corner arcs. Machine-pin datum 96.000 × 96.000 mm.</p>
<p>Carries the motor, passes the three phases from the ESC through to the motor leads, and hosts the
RP2350A+ADS1220 molecule (N2) and a PiProbe (N1).</p>
<h2>Analogue routing</h2>
<p>The machine contacts sit <strong>upstream</strong> of the molecule's RC filter, so anything the carrier picks up
arrives unfiltered at the bridge's 350 Ω source. The layout follows from that:</p>
<ul>
<li>Pair pitch <strong>0.450 mm, constant over the entire run</strong> on both channels</li>
<li>Lengths 22.75 / 23.47 mm (ch1) and 44.08 / 43.31 mm (ch2)</li>
<li>All four pairs on B.Cu, no vias, referenced to In2 GND 0.2104 mm away</li>
<li>In2 plane coverage 91–96 %; every gap is a pad antipad</li>
<li>Nearest same-layer neighbour 2.04–3.08 mm; nearest phase copper 4.53 mm</li>
<li>Digital crossings run on F.Cu with two GND planes between</li>
</ul>
<p><code>+5V_REF1</code> and <code>+5V_REF2</code> are separate nets with <strong>no carrier-side tie to <code>+5V</code></strong>. They merge only
inside the molecule. See <code>03-Molecule/Overview.md</code> for the ratiometric topology this preserves.</p>
<h2>UART arbitration</h2>
<p>The FC and the nested PiProbe both reach the molecule's RX line. debugprobe drives its UART TX from
power-up regardless of USB enumeration, so a populated probe holds that pin continuously.</p>
<pre><code>Net-(N1-TX) : N1.9 (PiProbe TX), R1.1          two pads only
/UART0_RX   : MC18 (FC), N2.14 (molecule RX), R1.2
</code></pre>
<p><strong>R1 = 300R 0603 1 %</strong> sits on a 4.31 mm stub in the PiProbe branch alone. The probe reaches the net
only through R1; the FC and the molecule's RX connect directly.</p>
<pre><code>contention    3.3 / (300 + 50 + 50)  =  8.25 mA
R1 dissipates 300 x 8.25mA^2         =  20 mW of 100 mW
FC drives 0   3.3 x 50/400           =  0.41 V   valid LOW
</code></pre>
<p>R1 is not in the BOM — it has no LCSC field.</p>
<h2>Phase path</h2>
<p>Three large contacts (Ø2.62 hole / Ø4.40 ring, ~20.8 A each), one per phase, into a 4-layer pour
with <strong>49 vias at 2 mm pitch</strong> per phase, out to 8 × 8 mm wire pads with a 2.0 mm hole.</p>
<p>Phase copper is 12.02 mm × 4 layers = <strong>1.207 mm², 0.214 mΩ</strong> over the 15 mm run.</p>
<h2>Harness rules</h2>
<ul>
<li><code>/nRST</code> reaches both MC16 (FC) and MC13 (Scaffold). Wire one end only, or make both open-drain.
  There is no external pull-up on either board — only the RP2350's internal 32–86 kΩ.</li>
<li><code>/SWD</code> and <code>/SWCLK</code> on MC22/MC24 are an external-probe landing. Keep them out of the FC harness.</li>
<li><code>/I2C0_SCL</code> and <code>/I2C0_SDA</code> on MC20/MC21 have no destination; the FC carrier has no I2C contacts.</li>
</ul>
  </div>

  <h2>What it does</h2>
  <p>A passive carrier baseplate. It mounts the BLDC motor, passes the motor's three phases straight
  through from the ESC to the motor leads, routes two load-cell channels, and <b>nests the RP2350A +
  ADS1220 acquisition molecule and a PiProbe</b> over its machine-pin datum. It carries no active
  silicon of its own beyond a single arbitration resistor — its job is mechanical location and clean
  analogue routing.</p>

  <h2>The board, section by section</h2>

  <h3>Molecule nests</h3>
  <p>Two carrier slots (16 × 32 mm): <b>N2</b> takes the <b>RP2350A / ADS1220</b> acquisition molecule,
  <b>N1</b> takes a <b>PiProbe</b> debug molecule. Each seats over the carrier's machine contacts as an
  already-populated sub-assembly. R1's placement (below) lets N1 and N2 sit without conflict.</p>

  <h3>BLDC phases</h3>
  <p>The three motor phases — <b>A, B, C</b> (J1 / J2 / J3) — pass from the ESC through the carrier to the
  motor leads. These are solder-pad terminals; they carry no 3D body in the STEP export, so they appear
  in the section legend but not as dropped parts in the viewer.</p>

  <h3>Load-cell headers</h3>
  <p>Two <b>JST-PH 5-pin</b> connectors (J4 / J5) — Load Cell Conn. 1 and 2. The analogue pairs run at a
  <b>constant 0.45 mm pitch on B.Cu with no vias</b>, referenced to the In2 GND plane. The machine
  contacts sit upstream of the molecule's RC filter, so what the carrier picks up arrives unfiltered at
  the bridge's source — which is why the routing is disciplined.</p>

  <h3>UART arbitration</h3>
  <p>A single resistor keeps the debug bus clean: <b>R1 = 300 R (0603, 1%)</b>. The PiProbe reaches the
  <span class="mono">/UART0_RX</span> net only through R1, while the flight controller and the molecule's
  RX connect directly — so a populated probe (whose debugprobe drives its TX from power-up) never
  contends the bus, and R1 dissipates only ~20 mW of its 100 mW rating under contention.</p>

  <h3>Machine-pin datum</h3>
  <p>The <b>96 × 96 mm machine-pin datum</b> — 25 contacts (MC1–MC25) plus four corner large pins
  (MP1–MP4) — locates the carrier in the cell and passes signals to the nested molecule.</p>

  <h3>Base-plate mounting</h3>
  <p>Four mounting holes (H1–H4) fix the carrier to the base plate.</p>

  <h2>Layout</h2>
  <p>Interactive PCB — click any pad, trace or zone to highlight its whole net; hover for pad and live part data.</p>
  <div class="embed-card">
    <iframe class="app-embed" title="drone-motor-carrier — interactive PCB layout" loading="lazy"
      data-cap-src="https://wiki.adom.inc/blob/component/drone-motor-carrier/viewer/layout.html"
      style="width:100%;height:720px;border:0;display:block;background:#1b232d"></iframe>
  </div>

  <h2>Schematic</h2>
  <p>Interactive schematic — hover a symbol for its KiCad highlight plus live MPN / stock / price / LCSC.</p>
  <div class="embed-card">
    <iframe class="app-embed" title="drone-motor-carrier — interactive schematic" loading="lazy"
      data-cap-src="https://wiki.adom.inc/blob/component/drone-motor-carrier/viewer/sch-root.html"
      style="width:100%;height:720px;border:0;display:block;background:#fff"></iframe>
  </div>

  <h2>How this page was built</h2>
  <ul>
    <li><b>Generated from the design files.</b> The model, sections and part list derive from
    <span class="mono">drone-motor-carrier.kicad_pcb</span> / the schematic / the STEP export. Coverage:
    404 solids, 34 refs; J1/J2/J3 (the phase pads) are legitimately bodiless in the export.</li>
    <li><b>Author-asserted.</b> The section narrative and routing intent come from the designer's schematic
    notes and the project overview, not a prior page.</li>
    <li><b>Illustrative.</b> Anything shown to demonstrate behaviour is labelled as such.</li>
  </ul>

  <footer>
    Drone Motor Carrier · drone-motor-carrier v1.1 · drawn in KiCad · board page generated from
    <span class="mono">drone-motor-carrier.kicad_pcb</span>.<br>
    Interactive viewer built on the Adom 3D viewer bundle.
  </footer>
</div>

<script>
  // Self-sizing (publish skill §8k-0b): the wiki's app.js listens for `readme-height` and grows
  // #readme-frame to fit, which is what makes this one seamless full-page document.
  (function () {
    var f = document.getElementById('board-viewer');
    // the sandbox cannot read the parent viewport, so size the viewer off the screen
    f.style.height = Math.max(520, (screen.availHeight || 900) - 200) + 'px';
    function post() {
      var h = document.documentElement.scrollHeight;
      parent.postMessage({ type: 'readme-height', height: h }, '*');
    }
    addEventListener('load', post); addEventListener('resize', post);
    setTimeout(post, 400); setTimeout(post, 1600);
  })();
</script>