main
John Lauer 0.1.29: pin complete canonical library viewer and preserve unowned zones eef56c4
<!doctype html><meta charset="utf-8"><title>Component library tour</title>
<link rel="stylesheet" href="vendor/3d-viewer/style.css">
<style>body{margin:0;background:#151b23;color:#e9eff8;font:15px system-ui}header{padding:14px 20px;display:flex;gap:15px;align-items:center}button,select,a{background:#263647;color:#dcefff;border:1px solid #4e647d;border-radius:5px;padding:8px;text-decoration:none}#stage{width:100%;aspect-ratio:16/9;position:relative;max-height:85vh}#status{color:#b6c5d7}#error{color:#ffb09a;padding:10px}#viewer{position:absolute;inset:0}canvas{outline:none}small{padding:0 20px;display:block;color:#b6c5d7}</style>
<header><b>Component library</b><button id="overview">All components</button><button id="detail">Record full tour</button><button id="short">Record 5s overview</button><select id="pick"></select><a id="wiki" target="_blank">Component wiki</a><span id="status">Loading…</span></header><small id="policy"></small><div id="stage"><div id="viewer"></div></div><div id="error"></div>
<script type="module">
const state=window.libraryTour={ready:false,recording:false,error:null,mode:'overview',completed:[]};const status=document.querySelector('#status');
try{
 await import('./vendor/3d-viewer/adom-3d-viewer-babylon9.esm.js');
 const B=window.BABYLON,V=window.Adom3DViewerBabylon9;
 if(!B||!V)throw Error('Canonical viewer did not expose its expected API');
 const data=await(await fetch('tour.json')).json();state.manifest=data;document.querySelector('#policy').textContent=`${data.components.length} reusable components · MPN marking ${data.mpnMarking} · Preview sizes normalized for inspection; original CAD is unchanged. Detailed tour is separate; the final video uses the 5s overview.`;
 const viewer=V.init(document.querySelector('#viewer'),{zUp:true,showGround:false,showViewCube:false});window.tourViewer=viewer;await viewer.loadModel(data.components[0].glb);const scene=viewer.getScene(),engine=viewer.getEngine(),camera=viewer.getCamera();viewer.clearScene();camera.lowerRadiusLimit=.1;camera.upperRadiusLimit=200;camera.minZ=.01;camera.maxZ=500;const groups=[];
 for(let i=0;i<data.components.length;i++){
  const item=data.components[i];status.textContent=`Loading ${i+1}/${data.components.length}: ${item.mpn}`;const imported=await B.SceneLoader.ImportMeshAsync('',item.glb,'',scene);const group=new B.TransformNode('component_'+i,scene),content=new B.TransformNode('geometry_'+i,scene);content.parent=group;
  let lo=new B.Vector3(Infinity,Infinity,Infinity),hi=new B.Vector3(-Infinity,-Infinity,-Infinity);for(const m of imported.meshes){m.computeWorldMatrix(true);if(m.getTotalVertices()>0){const z=m.getBoundingInfo().boundingBox;lo=B.Vector3.Minimize(lo,z.minimumWorld);hi=B.Vector3.Maximize(hi,z.maximumWorld);}}
  const dims=hi.subtract(lo),span=Math.max(dims.x,dims.y,dims.z);if(!Number.isFinite(span)||span<=0)throw Error('No finite geometry: '+item.mpn);const scale=2.6/span,center=lo.add(hi).scale(.5);for(const m of imported.meshes)if(!m.parent)m.parent=content;content.scaling.setAll(scale);content.position=center.scale(-scale);
  const x=(i%8-3.5)*4.6,y=(Math.floor(i/8)-(Math.ceil(data.components.length/8)-1)/2)*4.3;group.position.set(x,y,0);
  const label=B.MeshBuilder.CreatePlane('label_'+i,{width:4.2,height:.45},scene);label.parent=group;label.position.set(0,-1.85,.1);label.billboardMode=B.Mesh.BILLBOARDMODE_ALL;const tex=new B.DynamicTexture('text_'+i,{width:768,height:80},scene,false);tex.hasAlpha=true;tex.drawText(item.mpn,null,55,'bold 38px sans-serif','#eff6ff','transparent',true);const mat=new B.StandardMaterial('labelmat_'+i,scene);mat.diffuseTexture=tex;mat.emissiveColor=B.Color3.White();mat.disableLighting=true;mat.backFaceCulling=false;label.material=mat;
  groups.push({group,content,item,label,base:content.position.clone()});const option=document.createElement('option');option.value=i;option.textContent=item.mpn;document.querySelector('#pick').appendChild(option);
 }
 // Rotate around the part centre, not around the source CAD origin.
 for(const g of groups){const pivot=new B.TransformNode('spin_'+g.item.mpn,scene);pivot.parent=g.group;g.content.parent=pivot;g.pivot=pivot;}
 const center=new B.Vector3(0,0,0);function all(){state.mode='overview';groups.forEach(g=>g.group.setEnabled(true));camera.setTarget(center);camera.setPosition(new B.Vector3(0,-13,40));document.querySelector('#wiki').href=data.projectUrl;status.textContent='All '+groups.length+' components';}
 function one(i){state.mode='detail';state.index=i;groups.forEach((g,j)=>g.group.setEnabled(i===j));const g=groups[i];camera.setTarget(g.group.position);camera.setPosition(g.group.position.add(new B.Vector3(4,-6,5)));document.querySelector('#wiki').href=g.item.wiki;document.querySelector('#pick').value=i;status.textContent=`${i+1}/${groups.length} · ${g.item.mpn}`;}
 state.all=all;state.one=one;state.stats=()=>({ready:state.ready,recording:state.recording,mode:state.mode,components:groups.length,meshes:scene.meshes.length,vertices:scene.getTotalVertices(),frames:state.frames||0,error:state.error});
 let previous=performance.now();scene.onBeforeRenderObservable.add(()=>{let now=performance.now(),dt=Math.min((now-previous)/1000,.1);previous=now;state.frames=(state.frames||0)+1;for(const g of groups)if(g.group.isEnabled())g.pivot.rotation.z+=dt*(state.mode==='detail'?.7:.24);});
 const sleep=ms=>new Promise(r=>setTimeout(r,ms));state.record=async function(kind){if(state.recording)throw Error('Already recording');state.recording=true;state.error=null;try{
  if(document.visibilityState!=='visible')throw Error('Tour tab is hidden; make it visible before recording');engine.setSize(1280,720);kind==='detail'?one(0):all();await sleep(350);const canvas=engine.getRenderingCanvas();const mime=['video/webm;codecs=vp9','video/webm;codecs=vp8'].find(x=>MediaRecorder.isTypeSupported(x));if(!mime)throw Error('No supported WebM recorder');const stream=canvas.captureStream(30),chunks=[],rec=new MediaRecorder(stream,{mimeType:mime,videoBitsPerSecond:1200000});rec.ondataavailable=e=>{if(e.data.size)chunks.push(e.data)};let stopped=new Promise(r=>rec.onstop=r);rec.start(500);
  if(kind==='detail'){for(let i=0;i<groups.length;i++){one(i);await sleep(data.secondsPerComponent*1000);}}else await sleep(5000);
  rec.stop();await stopped;stream.getTracks().forEach(t=>t.stop());const blob=new Blob(chunks,{type:mime});let response=await fetch('/recordings/'+kind+'.webm',{method:'POST',body:blob});if(!response.ok)throw Error(await response.text());state.completed.push(kind);status.textContent=`Saved ${kind} recording`;all();
 }catch(e){state.error=String(e);document.querySelector('#error').textContent=String(e);}finally{state.recording=false;}};
 document.querySelector('#overview').onclick=all;document.querySelector('#pick').onchange=e=>one(+e.target.value);document.querySelector('#detail').onclick=()=>state.record('detail');document.querySelector('#short').onclick=()=>state.record('overview');state.ready=true;all();
}catch(e){state.error=String(e);document.querySelector('#error').textContent=String(e);status.textContent='Failed';}
</script>