main
John Lauer Publish 0.1.0 a97df8d 18h ago
// walkthrough-director.js: drive the Adom 3D viewer through a narrated part-by-part walkthrough.
// Inject it into a page showing the Adom 3D viewer (pup_evaluate), then:
//   GHW.CASE_PARTS=[...]; GHW.setupCase('<root node>', '^(scene_|hand)');   // enclosure, built up part by part
//   await GHW.setupBoard('<board .glb url>'); await GHW.mergeBoard();        // the board alone, top and underside
//   await GHW.run(steps, '<root node>' | 'WALK_BOARD');                      // steps: see docs/walkthrough.md
// Every step flies the camera to its parts, tints and outlines them, shows a label, holds for the narration,
// and logs its start time in GHW.log so the cut can line the narration up. MIT.
window.GHW = window.GHW || {};
(function(G){
const sc=()=>BABYLON.EngineStore.LastCreatedScene;
const V3=(a)=>new BABYLON.Vector3(a[0],a[1],a[2]);
const ease=t=>t<.5?4*t*t*t:1-Math.pow(-2*t+2,3)/2;
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
G.log=[];
function nodeByName(n){const s=sc();return s.getTransformNodeByName(n)||s.getMeshByName(n)||s.getNodeByName(n);}
function under(node,rootName){let p=node;while(p){if(p.name===rootName)return true;p=p.parent}return false;}
G.meshesOf=(a,b)=>meshesOf(a,b);function meshesOf(names,rootName){const out=[];for(const nm of names){const s=sc();const cands=s.getNodes().filter(x=>x.name===nm&&(!rootName||under(x,rootName)));for(const n of cands){if(n.getChildMeshes)out.push(...n.getChildMeshes(false));if(n instanceof BABYLON.AbstractMesh)out.push(n)}}return out.filter(m=>m.getTotalVertices&&m.getTotalVertices()>0);}
function bbox(ms){let mn=[1e9,1e9,1e9],mx=[-1e9,-1e9,-1e9];for(const m of ms){m.computeWorldMatrix(true);const b=m.getBoundingInfo().boundingBox;for(let i=0;i<3;i++){mn[i]=Math.min(mn[i],b.minimumWorld.asArray()[i]);mx[i]=Math.max(mx[i],b.maximumWorld.asArray()[i])}}return{mn,mx,c:mn.map((v,i)=>(v+mx[i])/2),d:Math.hypot(mx[0]-mn[0],mx[1]-mn[1],mx[2]-mn[2])};}
function label(text){let el=document.getElementById('ghw-label');if(!el){el=document.createElement('div');el.id='ghw-label';el.style.cssText='position:fixed;left:40px;bottom:96px;z-index:99999;padding:12px 22px;border-radius:12px;background:rgba(10,18,20,.78);color:#e8fffb;font:600 30px/1.2 "Familjen Grotesk",system-ui,sans-serif;border:2px solid #14c4b8;box-shadow:0 6px 24px rgba(0,0,0,.35);transition:opacity .35s;opacity:0;pointer-events:none';document.body.appendChild(el)}
 if(!text){el.style.opacity=0;return}el.textContent=text;el.style.opacity=1;}
let HL=null;const tinted=[];function highlight(ms){const s=sc();if(!HL){HL=new BABYLON.HighlightLayer('ghw-hl2',s,{blurHorizontalSize:1.6,blurVerticalSize:1.6,mainTextureRatio:0.5});HL.innerGlow=false;HL.outerGlow=true;}
 HL.removeAllMeshes();while(tinted.length){const [m,mat]=tinted.pop();m.material=mat;}
 for(const m of ms){try{HL.addMesh(m,new BABYLON.Color3(0.0,0.9,0.82))}catch(e){}
  if(m.material){const E=new BABYLON.Color3(0.0,0.45,0.40);const tint=x=>{const c=x.clone(x.name+'_ghw');if('emissiveColor' in c)c.emissiveColor=E;return c;};let k;
   if(m.material.subMaterials){k=m.material.clone(m.material.name+'_ghw',true);for(const x of k.subMaterials){if(x&&'emissiveColor' in x)x.emissiveColor=E;}}else k=tint(m.material);
   tinted.push([m,m.material]);m.material=k;}}}
function cam(){return sc().activeCamera;}
async function tween(to,secs){const c=cam();const f={a:c.alpha,b:c.beta,r:c.radius,t:c.target.clone()};const t0=performance.now();const ms=secs*1000;
 while(true){const u=Math.min(1,(performance.now()-t0)/ms),e=ease(u);c.setTarget(BABYLON.Vector3.Lerp(f.t,V3(to.target),e));c.alpha=f.a+(to.alpha-f.a)*e;c.beta=f.b+(to.beta-f.b)*e;c.radius=f.r+(to.radius-f.r)*e;if(u>=1)break;await sleep(16);}}
async function drift(secs,dAlpha){const c=cam();const a0=c.alpha,t0=performance.now();while(performance.now()-t0<secs*1000){c.alpha=a0+dAlpha*((performance.now()-t0)/(secs*1000));await sleep(16);}}
G.CASE_PARTS=G.CASE_PARTS||[];   // set by the caller: the node names of the enclosure parts, bottom to top
G.bottomLight=function(on){const b=[...document.querySelectorAll('button')].find(x=>/Bottom light/.test(x.textContent));if(!b)return;const pressed=!!G._bl;if(on!==pressed){b.click();G._bl=on;}};
G.setupCase=function(rootName,hide){G.ROOT=rootName;const hideRx=hide?new RegExp(hide):null;const s=sc();const wb=nodeByName('WALK_BOARD');if(wb)wb.setEnabled(false);
 if(hideRx)for(const n of s.getNodes()){if(hideRx.test(n.name))n.setEnabled(false)}
 const hero=nodeByName(rootName);if(hero){hero.setEnabled(true);for(const m of s.meshes)if(under(m,rootName))m.setEnabled(true);}
 for(const g of s.animationGroups){g.stop();g.start(false,1,g.from,g.to);g.goToFrame(g.to);g.pause();}
 for(const n of G.CASE_PARTS){const x=nodeByName(n);if(x)x.setEnabled(false)}
 const c=cam();c.enforceBounds=false;c.lowerRadiusLimit=0.001;c.upperBetaLimit=Math.PI;c.minZ=0.0005;G.ground(false);return 'case ready';};
G.ground=function(on){for(const m of sc().meshes){if(/ground|shadowCatcher|backdrop/i.test(m.name)&&!(G.ROOT&&under(m,G.ROOT))&&!under(m,'WALK_BOARD'))m.setEnabled(on)}};
G.setupBoard=async function(boardGlbUrl){const s=sc();let wb=nodeByName('WALK_BOARD');
 if(!wb){const ct=await BABYLON.LoadAssetContainerAsync(boardGlbUrl,s);ct.addAllToScene();wb=ct.rootNodes[0];wb.name='WALK_BOARD';}
 for(const m of s.meshes){if(!under(m,'WALK_BOARD'))m.setEnabled(false)}wb.setEnabled(true);
 const c=cam();c.enforceBounds=false;c.lowerRadiusLimit=0.001;c.upperBetaLimit=Math.PI;c.lowerBetaLimit=0.0001;c.minZ=0.0005;G.ground(false);return 'board ready';};
G.frameFor=function(st,root){const ms=meshesOf(st.frame||(st.nodes.length?st.nodes:[root]),root);const b=bbox(ms);
 const bottom=st.side==='bottom';const r=Math.max(st.nodes.length?b.d*(st.rmul||2.6):b.d*(st.rmul||1.25),root==="WALK_BOARD"?0.03:0.055);
 return{target:b.c,radius:r,alpha:st.alpha??(bottom?(-Math.PI/2+0.45):(Math.PI/2+0.45)),beta:st.beta??(bottom?Math.PI-0.75:0.85),ms};};
G.run=async function(steps,root){G.log=[];G.t0=performance.now();G.wall0=Date.now();G.running=true;
 for(const st of steps){const f=G.frameFor(st,root);G.log.push({id:st.id,t:(performance.now()-G.t0)/1000});
  if(st.show){for(const n of G.CASE_PARTS){const x=nodeByName(n);if(x)x.setEnabled(st.show.includes(n))}}
  if(st.reveal)for(const n of st.reveal){const x=nodeByName(n);if(x)x.setEnabled(true)}
  if(st.hide)for(const n of st.hide){const x=nodeByName(n);if(x)x.setEnabled(false)}
  G.bottomLight(st.side==='bottom');label(st.label);highlight(st.nodes.length?G.meshesOf(st.nodes,root):[]);
  const move=Math.min(1.4,st.dur*0.35);await tween(f,move);await drift(Math.max(0.6,st.dur+0.5-move),0.18);}
 highlight([]);label('');G.log.push({id:'end',t:(performance.now()-G.t0)/1000});G.running=false;return G.log;};

// A KiCad board GLB can hold 20,000+ tiny meshes (3 fps). Merge each part's meshes into one (60 fps).
G.mergeBoard=async function(refRx){const s=sc();const wb=s.getNodeByName('WALK_BOARD');const rx=refRx?new RegExp(refRx):/^(U|J|D|PD|Q|MP|MC|C|R|L|TP|SW|Y|FB|BT)\d+$/;
 const parts={};for(const m of s.meshes){if(!under(m,'WALK_BOARD'))continue;let p=m,ref=null;while(p&&p!==wb){if(rx.test(p.name))ref=p.name;p=p.parent}(parts[ref||'BOARD']=parts[ref||'BOARD']||[]).push(m)}
 const holder=new BABYLON.TransformNode('WALK_BOARD_M',s);G.merge={done:0,total:Object.keys(parts).length};
 for(const k of Object.keys(parts)){const ms=parts[k].filter(m=>m.getTotalVertices()>0);const node=new BABYLON.TransformNode(k,s);node.parent=holder;
  for(let i=0;i<ms.length;i+=300){const m=BABYLON.Mesh.MergeMeshes(ms.slice(i,i+300),true,true,undefined,false,true);if(m){m.name=k+'_m'+i;m.parent=node}await sleep(0)}
  G.merge.done++;await sleep(0)}
 wb.dispose();holder.name='WALK_BOARD';return s.meshes.length;};
})(window.GHW);
'director loaded'