app
Component Widgets
Public Made by Adomby adom
Reusable component widgets for pinouts, thermal vias, molecule relationships, reviewed chip outlines and lightweight four-level LOD cache listings.
← Commit history
Publish 1.1.11
11 files changed
+258−34
SKILL.md+14files/chip-thumbnail-widget.js+37−5files/component-lod-preview.js+37−5package.json+1−1page.json+6−2readme.html+1−1skills/component-widgets/SKILL.md+14skills/component-widgets/widgets/chip-thumbnail-widget.js+37−5skills/component-widgets/widgets/component-lod-preview.js+37−5widgets/chip-thumbnail-widget.js+37−5widgets/component-lod-preview.js+37−5SKILL.md+14@@ -185,3 +185,17 @@ Before publication, test image-only network requests, all four zoom levels, idle The wiki registry resolves script paths as repository file names: do not append a cache-busting query to the registry `script` URL. Raw `/render/` JavaScript is served as `application/octet-stream` with `nosniff`, so do not dynamically import a companion module from that route. The shared widget bundles its small control runtime and imports only the canonical viewer from the wiki's supported `/static/vendor/adom-3d-viewer-babylon9/` module path after activation. Keep the source module and bundled runtime in sync.++## Hero versus board LOD asset contract++Read `wiki-component-lod` in the shared Wiki Skill Pack. All four board-use LODs, including high, exclude hero-only footprint pad outlines, pad names/numbers and silkscreen. Keep real chip terminals and reviewed high/medium virtual MPN markings. The separately published wiki hero retains its teal dashed footprint outlines and pad labels at 50% transparency, plus silkscreen at 70% transparency. Never remove physical package pads while stripping explicitly tagged nonphysical helpers.++Render cached PNG/WebP LOD previews from the clean board-use files themselves, using equal framing and source GLB hashes. Do not reuse the annotated hero image. A stale hash, helper leakage or missing physical terminals is a failed asset check; regenerate and review before presenting parity as verified.++## Authenticated LOD bytes inside the wiki sandbox++Registered wiki widgets receive server-inlined manifest JSON. This does not authenticate fetch from the opaque widget origin: private files reject anonymous access, and credentialed fetch cannot use wildcard CORS. Do not relax the wiki sandbox, expose private assets publicly, or embed all GLBs at initial page load.++For private wiki LODs, generate one lazy `*.frame.html` transport per level in the component repo. The transport contains that level's GLB bytes and posts an ArrayBuffer to its requesting parent. Record `transport.kind: wiki-sandbox-frame@1`, its path/hash/bytes and `sourceLevelSha256` in the level. It is navigated only after explicit enable and on demand for a selected level. The server's normal page authorization applies to that iframe navigation. The parent verifies the exact iframe window, random request nonce, expected GLB hash and byte count; times out after 12 seconds, aborts on disposal, removes the transport frame, and passes verified Uint8Array bytes directly to Babylon. Do not use a blob URL fetch where CSP disallows it. Never put an authentication ticket or session token in the transport.++Opaque widgets also deny Web Storage. If access throws, use ephemeral document-local storage for canonical viewer preferences; never overwrite working user storage. Test against the real wiki CSP and private-file authorization separately from an unrestricted local harness. Preserve optional 3D behavior and small cached previews by default.
files/chip-thumbnail-widget.js+37−5@@ -6,12 +6,38 @@ // 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='https://wiki.adom.inc/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);+ });+} 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.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 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'){@@ -28,6 +54,13 @@ async function mountLodPreview({host,manifest,resolveAsset,onState,signal}) { 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();@@ -50,12 +83,11 @@ async function mountLodPreview({host,manifest,resolveAsset,onState,signal}) { const request={level,controller:new AbortController()};loading=request;report(); let container; try{- stats.glbRequests++;const r=await fetch(resolveAsset(info.path),{credentials:'include',signal:request.controller.signal});if(!r.ok)throw Error('LOD request returned '+r.status);- const data=await r.arrayBuffer();if(data.byteLength!==info.sizeBytes)throw Error('LOD byte count does not match manifest.');+ 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;- const url=URL.createObjectURL(new Blob([data],{type:'model/gltf-binary'}));- try{container=await B.SceneLoader.LoadAssetContainerAsync('',url,scene,undefined,'.glb');}finally{URL.revokeObjectURL(url);}+ // 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;}}
files/component-lod-preview.js+37−5@@ -1,12 +1,38 @@ // 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='https://wiki.adom.inc/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.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 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'){@@ -23,6 +49,13 @@ export async function mountLodPreview({host,manifest,resolveAsset,onState,signal 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();@@ -45,12 +78,11 @@ export async function mountLodPreview({host,manifest,resolveAsset,onState,signal const request={level,controller:new AbortController()};loading=request;report(); let container; try{- stats.glbRequests++;const r=await fetch(resolveAsset(info.path),{credentials:'include',signal:request.controller.signal});if(!r.ok)throw Error('LOD request returned '+r.status);- const data=await r.arrayBuffer();if(data.byteLength!==info.sizeBytes)throw Error('LOD byte count does not match manifest.');+ 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;- const url=URL.createObjectURL(new Blob([data],{type:'model/gltf-binary'}));- try{container=await B.SceneLoader.LoadAssetContainerAsync('',url,scene,undefined,'.glb');}finally{URL.revokeObjectURL(url);}+ // 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;}}
package.json+1−1@@ -2,7 +2,7 @@ "slug": "component-widgets", "title": "Component Widgets", "type": "app",- "version": "1.1.10",+ "version": "1.1.11", "description": "Reusable component-page widgets for pinouts, thermal vias, molecule relationships, reviewed chip outlines and image-first LOD caches with an optional zoom-driven 3D preview.", "license": "MIT", "dependencies": {},
page.json+6−2@@ -4,7 +4,7 @@ "type": "app", "title": "Component Widgets", "brief": "Reusable component widgets for pinouts, thermal vias, molecule relationships, reviewed chip outlines and lightweight four-level LOD cache listings.",- "version": "1.1.10",+ "version": "1.1.11", "visibility": { "public": true },@@ -60,6 +60,10 @@ "drop a pinout into a page", "reusable component widget", "cross-highlight pins on a page",+ "cached chip thumbnails",+ "reuse chip outline",+ "reviewed MPN placement",+ "chip artwork cache", "component LOD widget", "LOD listing", "LOD manifest",@@ -90,4 +94,4 @@ "hero", "readme" ]-}+}
readme.html+1−1@@ -1181,7 +1181,7 @@ h2 .anchor:hover{opacity:1;color:var(--teal)} if (!customElements.get('adom-moleculize')) customElements.define('adom-moleculize', AdomMoleculize); })(); </script>-<section style="padding:20px;border:1px solid #30363d;border-radius:12px;margin:24px 0"><h2>Reuse reviewed chip artwork</h2><p>The chip-thumbnail widget now accepts a cached-artwork manifest as well as an image. Its cache mode shows vector chip outlines, download links, exact virtual MPN placement, Z-up orientation and review state. The page’s existing 3D viewer shows the full chip. Existing image-only pages continue to work.</p><p>Read the component’s <code>artwork/MPN-thumbs.json</code> before generating anything. Compare source hashes and reuse its reviewed placement. Hash verification is recorded at publication time; native pin-1 and model-seating qualification remain separate.</p><pre>::adom-chip-thumbnail{src="/OWNER/COMPONENT/render/artwork/MPN-thumbs.json"}</pre></section></body>+<section style="padding:20px;border:1px solid #30363d;border-radius:12px;margin:24px 0"><h2>Reuse reviewed chip artwork</h2><p>The chip-thumbnail widget now accepts a cached-artwork manifest as well as an image. Its cache mode shows vector chip outlines, download links, exact virtual MPN placement, Z-up orientation and review state. The page’s existing 3D viewer shows the full chip. Existing image-only pages continue to work.</p><p>Read the component’s <code>artwork/MPN-thumbs.json</code> before generating anything. Compare source hashes and reuse its reviewed placement. Hash verification is recorded at publication time; native pin-1 and model-seating qualification remain separate.</p><pre>::adom-chip-thumbnail{src="/OWNER/COMPONENT/render/artwork/MPN-thumbs.json"}</pre></section><p>LOD previews now use on-demand component-owned asset frames for authenticated wiki sandbox loading. No GLBs load before the user enables 3D; disabling releases the viewer resources.</p></body> </html> <section id="component-lods" style="padding:20px;border:1px solid #30363d;border-radius:12px;margin:24px 0"><h2>Component LOD listings</h2><p>The <code>adom-component-lod</code> widget lists high, medium, low and super-low model derivatives, download sizes, decoded geometry buffer estimates, triangles, primitives, source hashes and review states. By default it shows small cached images of each level and creates no WebGL context. Turn on the optional 3D preview to inspect zoom-driven detail changes, then turn it off to release the model and viewer resources. Missing levels remain visible as missing.</p><pre>::adom-component-lod{src="/OWNER/COMPONENT/render/lod/manifest.json"}</pre><p>Feed the <code>adom/component-lod@1</code> manifest through the registered Markdown tag. For sandboxed HTML, inline the shared script and manifest JSON. Reuse the reviewed pose and marking placement; verify hashes before downstream use. Candidate files and a listing alone do not establish native seating or manufacturing qualification.</p></section>
skills/component-widgets/SKILL.md+14@@ -185,3 +185,17 @@ Before publication, test image-only network requests, all four zoom levels, idle The wiki registry resolves script paths as repository file names: do not append a cache-busting query to the registry `script` URL. Raw `/render/` JavaScript is served as `application/octet-stream` with `nosniff`, so do not dynamically import a companion module from that route. The shared widget bundles its small control runtime and imports only the canonical viewer from the wiki's supported `/static/vendor/adom-3d-viewer-babylon9/` module path after activation. Keep the source module and bundled runtime in sync.++## Hero versus board LOD asset contract++Read `wiki-component-lod` in the shared Wiki Skill Pack. All four board-use LODs, including high, exclude hero-only footprint pad outlines, pad names/numbers and silkscreen. Keep real chip terminals and reviewed high/medium virtual MPN markings. The separately published wiki hero retains its teal dashed footprint outlines and pad labels at 50% transparency, plus silkscreen at 70% transparency. Never remove physical package pads while stripping explicitly tagged nonphysical helpers.++Render cached PNG/WebP LOD previews from the clean board-use files themselves, using equal framing and source GLB hashes. Do not reuse the annotated hero image. A stale hash, helper leakage or missing physical terminals is a failed asset check; regenerate and review before presenting parity as verified.++## Authenticated LOD bytes inside the wiki sandbox++Registered wiki widgets receive server-inlined manifest JSON. This does not authenticate fetch from the opaque widget origin: private files reject anonymous access, and credentialed fetch cannot use wildcard CORS. Do not relax the wiki sandbox, expose private assets publicly, or embed all GLBs at initial page load.++For private wiki LODs, generate one lazy `*.frame.html` transport per level in the component repo. The transport contains that level's GLB bytes and posts an ArrayBuffer to its requesting parent. Record `transport.kind: wiki-sandbox-frame@1`, its path/hash/bytes and `sourceLevelSha256` in the level. It is navigated only after explicit enable and on demand for a selected level. The server's normal page authorization applies to that iframe navigation. The parent verifies the exact iframe window, random request nonce, expected GLB hash and byte count; times out after 12 seconds, aborts on disposal, removes the transport frame, and passes verified Uint8Array bytes directly to Babylon. Do not use a blob URL fetch where CSP disallows it. Never put an authentication ticket or session token in the transport.++Opaque widgets also deny Web Storage. If access throws, use ephemeral document-local storage for canonical viewer preferences; never overwrite working user storage. Test against the real wiki CSP and private-file authorization separately from an unrestricted local harness. Preserve optional 3D behavior and small cached previews by default.
skills/component-widgets/widgets/chip-thumbnail-widget.js+37−5@@ -6,12 +6,38 @@ // 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='https://wiki.adom.inc/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);+ });+} 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.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 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'){@@ -28,6 +54,13 @@ async function mountLodPreview({host,manifest,resolveAsset,onState,signal}) { 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();@@ -50,12 +83,11 @@ async function mountLodPreview({host,manifest,resolveAsset,onState,signal}) { const request={level,controller:new AbortController()};loading=request;report(); let container; try{- stats.glbRequests++;const r=await fetch(resolveAsset(info.path),{credentials:'include',signal:request.controller.signal});if(!r.ok)throw Error('LOD request returned '+r.status);- const data=await r.arrayBuffer();if(data.byteLength!==info.sizeBytes)throw Error('LOD byte count does not match manifest.');+ 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;- const url=URL.createObjectURL(new Blob([data],{type:'model/gltf-binary'}));- try{container=await B.SceneLoader.LoadAssetContainerAsync('',url,scene,undefined,'.glb');}finally{URL.revokeObjectURL(url);}+ // 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;}}
skills/component-widgets/widgets/component-lod-preview.js+37−5@@ -1,12 +1,38 @@ // 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='https://wiki.adom.inc/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.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 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'){@@ -23,6 +49,13 @@ export async function mountLodPreview({host,manifest,resolveAsset,onState,signal 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();@@ -45,12 +78,11 @@ export async function mountLodPreview({host,manifest,resolveAsset,onState,signal const request={level,controller:new AbortController()};loading=request;report(); let container; try{- stats.glbRequests++;const r=await fetch(resolveAsset(info.path),{credentials:'include',signal:request.controller.signal});if(!r.ok)throw Error('LOD request returned '+r.status);- const data=await r.arrayBuffer();if(data.byteLength!==info.sizeBytes)throw Error('LOD byte count does not match manifest.');+ 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;- const url=URL.createObjectURL(new Blob([data],{type:'model/gltf-binary'}));- try{container=await B.SceneLoader.LoadAssetContainerAsync('',url,scene,undefined,'.glb');}finally{URL.revokeObjectURL(url);}+ // 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;}}
widgets/chip-thumbnail-widget.js+37−5@@ -6,12 +6,38 @@ // 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='https://wiki.adom.inc/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);+ });+} 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.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 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'){@@ -28,6 +54,13 @@ async function mountLodPreview({host,manifest,resolveAsset,onState,signal}) { 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();@@ -50,12 +83,11 @@ async function mountLodPreview({host,manifest,resolveAsset,onState,signal}) { const request={level,controller:new AbortController()};loading=request;report(); let container; try{- stats.glbRequests++;const r=await fetch(resolveAsset(info.path),{credentials:'include',signal:request.controller.signal});if(!r.ok)throw Error('LOD request returned '+r.status);- const data=await r.arrayBuffer();if(data.byteLength!==info.sizeBytes)throw Error('LOD byte count does not match manifest.');+ 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;- const url=URL.createObjectURL(new Blob([data],{type:'model/gltf-binary'}));- try{container=await B.SceneLoader.LoadAssetContainerAsync('',url,scene,undefined,'.glb');}finally{URL.revokeObjectURL(url);}+ // 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;}}
widgets/component-lod-preview.js+37−5@@ -1,12 +1,38 @@ // 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='https://wiki.adom.inc/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.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 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'){@@ -23,6 +49,13 @@ export async function mountLodPreview({host,manifest,resolveAsset,onState,signal 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();@@ -45,12 +78,11 @@ export async function mountLodPreview({host,manifest,resolveAsset,onState,signal const request={level,controller:new AbortController()};loading=request;report(); let container; try{- stats.glbRequests++;const r=await fetch(resolveAsset(info.path),{credentials:'include',signal:request.controller.signal});if(!r.ok)throw Error('LOD request returned '+r.status);- const data=await r.arrayBuffer();if(data.byteLength!==info.sizeBytes)throw Error('LOD byte count does not match manifest.');+ 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;- const url=URL.createObjectURL(new Blob([data],{type:'model/gltf-binary'}));- try{container=await B.SceneLoader.LoadAssetContainerAsync('',url,scene,undefined,'.glb');}finally{URL.revokeObjectURL(url);}+ // 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;}}