master
Colby Knox Widgets: safe to render inline in a wiki README (adom/wiki#229) 4408302 7d ago
// Opt-in LOD inspection, built on the canonical Adom Babylon viewer.
// No module, engine or model is loaded until the user enables this preview.
const VIEWER='/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js?v=20260828a';
async function loadAssetBytes(info,resolveAsset,signal) {
 if(info.transport?.kind!=='wiki-sandbox-frame@1'){
  const r=await fetch(resolveAsset(info.path),{credentials:'include',signal});
  if(!r.ok)throw Error('LOD request returned '+r.status);return r.arrayBuffer();
 }
 if(info.transport.sourceLevelSha256!==info.sha256||info.sizeBytes>8*1024*1024)throw Error('Invalid or oversized LOD transport');
 const nonce=[...crypto.getRandomValues(new Uint8Array(16))].map(x=>x.toString(16).padStart(2,'0')).join('');
 const frame=document.createElement('iframe');frame.hidden=true;frame.setAttribute('aria-hidden','true');frame.title='Component LOD asset transfer';
 const url=new URL(resolveAsset(info.transport.path));url.hash=new URLSearchParams({nonce,sha256:info.sha256}).toString();
 return new Promise((resolve,reject)=>{
  let timer;
  const cleanup=()=>{clearTimeout(timer);window.removeEventListener('message',receive);signal?.removeEventListener('abort',abort);frame.remove();};
  const fail=e=>{cleanup();reject(e);};
  const abort=()=>fail(new DOMException('LOD load cancelled','AbortError'));
  const receive=e=>{
   if(e.source!==frame.contentWindow||e.data?.type!=='adom-lod-asset'||e.data.nonce!==nonce)return;
   const d=e.data;
   if(d.sha256!==info.sha256||d.sizeBytes!==info.sizeBytes||!(d.buffer instanceof ArrayBuffer)||d.buffer.byteLength!==info.sizeBytes)return fail(Error('LOD transport payload does not match manifest'));
   cleanup();resolve(d.buffer);
  };
  if(signal?.aborted)return abort();
  window.addEventListener('message',receive);signal?.addEventListener('abort',abort,{once:true});
  timer=setTimeout(()=>fail(Error('LOD asset transfer timed out; check wiki login and transport URL')),12000);
  frame.src=url.href;document.body.append(frame);
 });
}
export async function mountLodPreview({host,manifest,resolveAsset,onState,signal}) {
 let viewer,engine,scene,root,camera,raf=0,timer,observer,channel,stopped=false;
 let current=null,wanted='super-low',loading=null,candidate=null,since=0,frames=0,lastFrame=0,awakeUntil=0,lastCamera='';
 const cache=new Map(),levels=['super-low','low','medium','high'],thresholds=[90,190,340],budget=16*1024*1024;
 const stats={active:true,loading:true,level:null,wanted,frames:0,downloads:0,glbRequests:0,evictions:0,residentLevels:[],decodedBufferBytes:0,errors:[],initialLevel:'super-low'};
 function report(){stats.sceneReady=!!scene?.isReady();const overlay=engine?.loadingScreen?._loadingDiv;stats.loadingOverlayVisible=!!overlay&&getComputedStyle(overlay).display!=='none'&&overlay.getBoundingClientRect().height>0;stats.frames=frames;stats.wanted=wanted;stats.loading=!!loading;stats.residentLevels=[...cache.values()].map(e=>e.info.level);stats.decodedBufferBytes=[...cache.values()].reduce((s,e)=>s+e.info.decodedBufferBytes,0);onState({...stats});}
 function invalidate(){awakeUntil=performance.now()+1100;if(!raf&&!stopped&&!document.hidden)raf=requestAnimationFrame(render);}
 function render(now){raf=0;if(stopped||document.hidden)return;if(now-lastFrame>=1000/30){engine.beginFrame();try{scene.render();}finally{engine.endFrame();}frames++;lastFrame=now;}if(now<awakeUntil)raf=requestAnimationFrame(render);}
 function dispose(reason='turned off'){
  if(stopped)return;stopped=true;loading?.controller.abort();clearInterval(timer);cancelAnimationFrame(raf);observer?.disconnect();channel?.close();
  document.removeEventListener('visibilitychange',hidden);signal?.removeEventListener('abort',aborted);
  for(const e of cache.values())e.container.dispose();cache.clear();
  const canvas=engine?.getRenderingCanvas();const gl=canvas&&(canvas.getContext('webgl2')||canvas.getContext('webgl'));
  viewer?.destroy();gl?.getExtension('WEBGL_lose_context')?.loseContext();host.replaceChildren();
  stats.active=false;stats.loading=false;stats.level=null;stats.residentLevels=[];stats.decodedBufferBytes=0;stats.stopReason=reason;onState({...stats,frames});
 }
 const hidden=()=>{if(document.hidden)dispose('tab hidden');},aborted=()=>dispose('turned off');
 signal?.addEventListener('abort',aborted,{once:true});
 try{
  if(signal?.aborted)throw new DOMException('Preview cancelled','AbortError');
  if(manifest.upAxis!=='Z'||!['metres','metre'].includes(manifest.units))throw Error('Preview requires a declared Z-up, metre-scale display model.');
  if(levels.some(level=>!manifest.levels.find(l=>l.level===level)?.path))throw Error('All four LOD files must be available before interactive inspection.');
  // Canonical viewer camera preferences use Web Storage. Opaque wiki widgets
  // have none; provide document-local ephemeral storage only when access throws.
  for(const key of ['localStorage','sessionStorage']){
   try{void window[key].length;}catch{
    const values=new Map();Object.defineProperty(window,key,{configurable:true,value:{getItem:k=>values.get(String(k))??null,setItem:(k,v)=>values.set(String(k),String(v)),removeItem:k=>values.delete(String(k)),clear:()=>values.clear(),key:i=>[...values.keys()][i]??null,get length(){return values.size;}}});
   }
  }
  await import(VIEWER);if(stopped||signal?.aborted)return {dispose,state:()=>({...stats})};
  const api=window.Adom3DViewerBabylon9,B=api.BABYLON;
  viewer=api.init(host,{zUp:true,showViewCube:true});engine=viewer.getEngine();scene=viewer.getScene();scene.useRightHandedSystem=true;camera=viewer.getCamera();
  engine.stopRenderLoop();engine.runRenderLoop=()=>invalidate();
  viewer.setGroundVisible(false);scene.shadowsEnabled=false;viewer.getShadowGenerator()?.dispose();root=new B.TransformNode('component-z-up-mm',scene);root.scaling.setAll(1000);viewer.addContentRoot(root);
  const fill=new B.HemisphericLight('lod-inspection-fill',new B.Vector3(0,0,1),scene);fill.intensity=.9;fill.groundColor=new B.Color3(.5,.5,.5);
  const canvas=engine.getRenderingCanvas();for(const event of ['pointerdown','pointermove','pointerup','wheel','keydown'])canvas.addEventListener(event,invalidate,{passive:true});
  document.addEventListener('visibilitychange',hidden);
  observer=new IntersectionObserver(entries=>{if(!entries[0].isIntersecting)dispose('preview scrolled offscreen');});observer.observe(host);
  try{channel=new BroadcastChannel('adom-component-lod-preview');channel.onmessage=()=>dispose('another component preview enabled');channel.postMessage({enabled:true});}catch{channel=null;}
  function evict(){for(const [key,e] of cache)if(e!==current&&e.info.level!=='super-low'){e.container.dispose();cache.delete(key);stats.evictions++;}}
  function apply(e,level){
   if(current!==e){current?.container.removeAllFromScene();e.container.addAllToScene();for(const n of e.container.rootNodes)if(!n.parent)n.parent=root;current=e;}
   stats.level=level;evict();invalidate();report();
  }
  async function load(level){
   const info=manifest.levels.find(l=>l.level===level);if(cache.has(info.sha256)){apply(cache.get(info.sha256),level);return;}
   if(loading)return;
   evict();if(info.decodedBufferBytes+[...cache.values()].reduce((s,e)=>s+e.info.decodedBufferBytes,0)>budget)throw Error('Component preview exceeds the 16 MiB decoded-geometry budget.');
   const request={level,controller:new AbortController()};loading=request;report();
   let container;
   try{
    stats.glbRequests++;const data=await loadAssetBytes(info,resolveAsset,request.controller.signal);if(data.byteLength!==info.sizeBytes)throw Error('LOD byte count does not match manifest.');
    const hash=[...new Uint8Array(await crypto.subtle.digest('SHA-256',data))].map(x=>x.toString(16).padStart(2,'0')).join('');if(hash!==info.sha256)throw Error('LOD SHA-256 does not match manifest.');
    if(stopped)return;
    // Feed verified bytes directly; blob fetches are disallowed in wiki widgets.
    container=await B.SceneLoader.LoadAssetContainerAsync('',new Uint8Array(data),scene,undefined,'.glb');
    if(stopped||wanted!==level){container.dispose();return;}
    const e={info,container};cache.set(info.sha256,e);stats.downloads++;apply(e,level);
   }catch(e){container?.dispose();if(e.name!=='AbortError'&&!stopped){stats.errors.push(String(e));dispose('model loading failed');throw e;}}
   finally{if(loading===request)loading=null;if(!stopped)report();}
  }
  await load('super-low');if(stopped)return {dispose,state:()=>({...stats})};
  const box=manifest.levels.find(l=>l.level==='super-low').bounds;
  const min=B.Vector3.FromArray(box.min).scale(1000),max=B.Vector3.FromArray(box.max).scale(1000),diameter=max.subtract(min).length();
  const center=min.add(max).scale(.5);camera.minZ=.001;camera.lowerRadiusLimit=Math.max(.01,diameter*.35);camera.upperRadiusLimit=diameter*35;
  camera.target=center;camera.setPosition(center.add(new B.Vector3(2,-3,3).normalize().scale(diameter*12)));
  const tick=()=>{
   if(stopped)return;
   const sig=JSON.stringify([camera.alpha,camera.beta,camera.radius,...camera.target.asArray(),host.clientWidth,host.clientHeight].map(n=>Math.round(n*1e7)/1e7));
   if(sig!==lastCamera){lastCamera=sig;engine.resize();invalidate();}
   const distance=B.Vector3.Distance(camera.globalPosition,center),pixels=diameter*host.clientHeight/(2*Math.max(.01,distance)*Math.tan(camera.fov/2));
   let index=levels.indexOf(wanted);while(index<3&&pixels>thresholds[index]*1.2)index++;while(index>0&&pixels<thresholds[index-1]*.8)index--;
   if(levels[index]!==candidate){candidate=levels[index];since=performance.now();}
   if(performance.now()-since>350){wanted=candidate;if(loading&&loading.level!==wanted)loading.controller.abort();if(!loading&&stats.level!==wanted)void load(wanted).catch(e=>{stats.errors.push(String(e));dispose('preview error');});}
   report();
  };
  timer=setInterval(tick,150);invalidate();report();
  return {dispose,state:()=>({...stats}),inspectDistance:multiple=>{camera.radius=diameter*multiple;invalidate();},camera:()=>({radius:camera.radius,diameter,alpha:camera.alpha,beta:camera.beta,target:camera.target.asArray(),size:[host.clientWidth,host.clientHeight]})};
 }catch(e){dispose(e.name==='AbortError'?'turned off':String(e));throw e;}
}