feat(frontend): cache 3D assets and restore loading feedback
Persist versioned models and validated network metadata in IndexedDB. Show scene download progress and recoverable errors with the existing frosted material. Connect existing skeletons to pending navigation and route boundaries while preserving the map. Previously only dynamic imports showed fallbacks, and rejected model promises prevented retries. Verified: 32 focused unit tests; 4 navigation and scene browser checks; 7 cache browser checks including invalid-metadata recovery; isolated production build and type checks.
This commit is contained in:
@@ -76,7 +76,7 @@ export function createCameraNavigation({camera,controls,model,networkRoot,root,s
|
||||
flight={start:performance.now(),from:camera.position.clone(),targetFrom:controls.target.clone(),to:new THREE.Vector3(...pose.position),targetTo:new THREE.Vector3(...pose.target)};
|
||||
if(matchMedia('(prefers-reduced-motion: reduce)').matches){camera.position.copy(flight.to);controls.target.copy(flight.targetTo);flight=null;controls.update();}
|
||||
emit(p.note??'已保存的相机位置');
|
||||
}catch(e){if(token===request)emit('视角加载失败:'+e.message);}
|
||||
}catch(e){if(token===request)emit('视角加载失败:'+e.message);throw e;}
|
||||
}
|
||||
function tick(now){if(!flight)return;const t=Math.min(1,(now-flight.start)/500),v=t*t*(3-2*t);camera.position.lerpVectors(flight.from,flight.to,v);controls.target.lerpVectors(flight.targetFrom,flight.targetTo,v);if(t===1)flight=null;}
|
||||
function save(label){label=label.trim();if(!label||label.length>24)throw Error('请输入 1–24 字的视角名称。');if(saved.length>=8)throw Error('最多保存 8 个视角,请先移除不用的视角。');const p={id:'saved-'+Date.now(),label,mode:getMode(),displayMode:getDisplayMode(),position:camera.position.toArray(),target:controls.target.toArray(),up:camera.up.toArray(),context:root.visible,roof:root.children.filter(o=>/roof|ceiling/.test(o.userData.resourceId)).every(o=>o.visible)};p.layers=Object.fromEntries(root.children.map(o=>[o.userData.resourceId,o.visible]));const next=[...saved,p];localStorage.setItem(key,JSON.stringify(next));saved=next;active=p.id;emit('视角已保存在本机浏览器');}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
// Framework-independent data adapter. Does not parse or modify the project's INP.
|
||||
export const CAD_ORIGIN=Object.freeze([513800,2344450]);
|
||||
// This decoder runs before IDB writes, including when checking an existing entry.
|
||||
// A parseable HTTP 200 error response must not become a permanent model cache.
|
||||
export function parseNetworkMetadata(data,modelId){
|
||||
const model=JSON.parse(new TextDecoder().decode(data));
|
||||
const arrays=['nodes','links','pipeInstances','nodeInstances','bendInstances','cadEquipmentBindings','pumpEntityBindings','cadAttachments'];
|
||||
const objects=['layouts','connectivity','coordination','coordinatedLayouts','verticalCoordination'];
|
||||
if(!model||model.modelId!==modelId||!arrays.every(key=>Array.isArray(model[key]))||!objects.every(key=>model[key]&&typeof model[key]==='object'&&!Array.isArray(model[key]))||!model.nodes.length||!model.links.length)throw Error('管网元数据格式无效,请重新载入场景。');
|
||||
return model;
|
||||
}
|
||||
export function cadToGltf(point,displayZ=0){
|
||||
if(!Array.isArray(point)||point.length<2||!point.slice(0,2).every(Number.isFinite)||!Number.isFinite(displayZ))throw Error('Expected finite CAD XY and an explicit display height.');
|
||||
return [point[0]-CAD_ORIGIN[0],displayZ,-(point[1]-CAD_ORIGIN[1])];
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
const boot = document.getElementById('boot');
|
||||
const title = document.getElementById('boot-title');
|
||||
const detail = document.getElementById('boot-detail');
|
||||
const progress = document.getElementById('boot-progress');
|
||||
const retry = document.getElementById('boot-retry');
|
||||
const formatBytes = bytes => (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||
let entries = new Map();
|
||||
let failed = false;
|
||||
let lastPaint = 0;
|
||||
|
||||
export function beginLoading(files, message = '正在载入三维场景') {
|
||||
entries = new Map(files.map(file => [file, {stage:'waiting', loaded:0, total:0}]));
|
||||
failed = false;
|
||||
boot.hidden = false;
|
||||
boot.dataset.state = 'loading';
|
||||
boot.setAttribute('aria-busy', 'true');
|
||||
title.textContent = message;
|
||||
detail.textContent = '正在检查本地资源缓存';
|
||||
progress.hidden = false;
|
||||
progress.removeAttribute('value');
|
||||
retry.hidden = true;
|
||||
lastPaint = 0;
|
||||
}
|
||||
|
||||
export function reportProgress(update) {
|
||||
if (failed || !entries.has(update.file)) return;
|
||||
entries.set(update.file, update);
|
||||
// Large streamed files can emit hundreds of chunks in a frame.
|
||||
if (update.stage === 'download' && performance.now() - lastPaint < 100) return;
|
||||
lastPaint = performance.now();
|
||||
const values = [...entries.values()];
|
||||
const done = values.filter(item => item.stage === 'ready').length;
|
||||
const loaded = values.reduce((sum, item) => sum + item.loaded, 0);
|
||||
const cached = values.filter(item => item.stage === 'cache').length;
|
||||
const parsing = values.some(item => item.stage === 'parse');
|
||||
const downloading = values.some(item => item.stage === 'download');
|
||||
progress.max = Math.max(1, values.length);
|
||||
progress.value = done;
|
||||
title.textContent = `正在载入场景资源 ${done} / ${values.length}`;
|
||||
detail.textContent = `${parsing ? '正在解析模型' : downloading ? '正在下载资源' : cached ? '正在读取本地缓存' : '正在准备场景'} · 已读取 ${formatBytes(loaded)}`;
|
||||
}
|
||||
|
||||
export function finishLoading() {
|
||||
if (failed) return;
|
||||
boot.setAttribute('aria-busy', 'false');
|
||||
boot.hidden = true;
|
||||
}
|
||||
|
||||
export function failLoading(error) {
|
||||
failed = true;
|
||||
boot.hidden = false;
|
||||
boot.dataset.state = 'error';
|
||||
boot.setAttribute('aria-busy', 'false');
|
||||
title.textContent = '场景资源加载失败';
|
||||
detail.textContent = '部分资源未能载入,请检查网络后重试。' + (error?.message ? '(' + error.message + ')' : '');
|
||||
progress.hidden = true;
|
||||
retry.hidden = false;
|
||||
}
|
||||
|
||||
retry.addEventListener('click', () => window.location.reload());
|
||||
@@ -17094,6 +17094,7 @@
|
||||
"id": "zjb-water-network-v23",
|
||||
"file": "models/water_network_v28.glb",
|
||||
"metadata": "water-network-v28.json",
|
||||
"metadataSha256": "b9af91296f7f9269a51141b732ffb9fcd82906133eca75b96273a85f776c75f5",
|
||||
"sha256": "c3d16fd287a2e057649e3f25a939ff7edcc5f6b152c9dc09860bee7675912768",
|
||||
"runtimeInpRequired": false,
|
||||
"representationRevision": 28
|
||||
|
||||
@@ -8,18 +8,36 @@
|
||||
html,body{width:100%;height:100%;margin:0;overflow:hidden;background:#e2e8e9}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
|
||||
canvas{display:block;width:100%;height:100%;touch-action:none}
|
||||
#boot{position:absolute;inset:0;z-index:2;display:grid;place-items:center;color:#526777;background:#e2e8e9;transition:opacity 180ms cubic-bezier(.16,1,.3,1)}
|
||||
#boot{position:absolute;inset:0;z-index:2;display:grid;place-items:center;padding:16px;box-sizing:border-box;color:#334155;background:rgba(241,245,249,.38)}
|
||||
#boot[hidden]{display:none}
|
||||
#boot span{display:flex;align-items:center;gap:10px;font-size:13px}
|
||||
#boot i{width:18px;height:18px;border:2px solid rgba(37,125,212,.22);border-top-color:#257dd4;border-radius:50%;animation:spin .8s linear infinite}
|
||||
.boot-panel{box-sizing:border-box;width:min(100%,360px);padding:24px;border-radius:16px;background:linear-gradient(135deg,rgba(255,255,255,.50),rgba(224,239,250,.28));backdrop-filter:blur(24px) saturate(170%) contrast(94%);-webkit-backdrop-filter:blur(24px) saturate(170%) contrast(94%);box-shadow:inset 0 1px 0 rgba(255,255,255,.88),inset 0 -1px 0 rgba(112,145,168,.18),0 18px 50px rgba(15,43,69,.20),0 0 0 1px rgba(255,255,255,.55)}
|
||||
.boot-heading{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:600;color:#0f172a}
|
||||
#boot i{flex:none;width:18px;height:18px;border:2px solid rgba(37,125,212,.22);border-top-color:#257dd4;border-radius:50%;animation:spin .8s linear infinite}
|
||||
#boot-detail{margin:12px 0 0;font-size:12px;line-height:1.75;overflow-wrap:anywhere}
|
||||
#boot-progress{display:block;width:100%;height:6px;margin-top:20px;accent-color:#2563eb}
|
||||
#boot-progress[hidden],#boot-retry[hidden]{display:none}
|
||||
#boot-retry{min-height:40px;margin-top:16px;padding:0 16px;border:0;border-radius:8px;background:#2563eb;color:white;font:inherit;font-size:13px;cursor:pointer;transition:transform 150ms ease,background-color 150ms ease}
|
||||
#boot-retry:hover{background:#1d4ed8}#boot-retry:active{transform:scale(.96)}#boot-retry:focus-visible{outline:2px solid #2563eb;outline-offset:3px}
|
||||
#boot[data-state=error] i{animation:none;border-color:#dc2626;background:#fee2e2}
|
||||
#boot[data-state=error] #boot-title{color:#991b1b}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
@media(prefers-reduced-motion:reduce){#boot i{animation:none;border-top-color:rgba(37,125,212,.22)}}
|
||||
@media(prefers-reduced-motion:reduce){#boot i{animation:none;border-top-color:rgba(37,125,212,.22)}#boot-retry{transition:none}}
|
||||
</style>
|
||||
<script type="importmap">{"imports":{"three":"./vendor/three/three.module.js","three/addons/":"./vendor/three/addons/"}}</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="boot" role="status"><span><i aria-hidden="true"></i>正在载入三维模型</span></div>
|
||||
<div id="boot" role="status" aria-live="polite" aria-busy="true" data-state="loading">
|
||||
<div class="boot-panel">
|
||||
<div class="boot-heading"><i aria-hidden="true"></i><span id="boot-title">正在准备三维场景</span></div>
|
||||
<p id="boot-detail">首次载入需要下载模型,后续将优先使用本地缓存。</p>
|
||||
<progress id="boot-progress" aria-label="场景资源加载进度"></progress>
|
||||
<button id="boot-retry" type="button" hidden>重新载入场景</button>
|
||||
</div>
|
||||
</div>
|
||||
<output id="qa" hidden></output>
|
||||
<script type="module" src="./preview.mjs?v=34-detailed-network-context"></script>
|
||||
<script type="module">
|
||||
import {failLoading} from './loadingProgress.mjs?v=35-resource-cache';
|
||||
import('./preview.mjs?v=35-resource-cache').catch(failLoading);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import {createRenderEffects} from './render-effects.mjs';
|
||||
import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=31-camera-presets';
|
||||
import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=35-resource-cache';
|
||||
import {createSceneResourceCache} from './resourceCache.mjs?v=35-resource-cache';
|
||||
import {beginLoading,reportProgress,finishLoading,failLoading} from './loadingProgress.mjs?v=35-resource-cache';
|
||||
import {createAssetInspector} from './asset-inspector.mjs?v=29-platform2';
|
||||
import * as THREE from 'three';
|
||||
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
|
||||
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
|
||||
import {MeshoptDecoder} from './vendor/meshopt_decoder.module.js';
|
||||
import {attachNetworkStyle,DEFAULT_STYLE} from './network-style.mjs?v=33-scada-markers';
|
||||
import {cadToGltf} from './integration.mjs';
|
||||
import {cadToGltf,parseNetworkMetadata} from './integration.mjs';
|
||||
import {createAppearance,enhanceMaterials,grain,applyBuildingContext} from './appearance.mjs?v=30-context-opacity';
|
||||
|
||||
const HOST_CHANNEL='tjwater:zjb-scene',HOST_VERSION=2,PROJECT_CODE='zjb',MODEL_ID='zjb-water-network-v23';
|
||||
let net,networkRoot,networkStyle,navigation,inspector;
|
||||
let mode='network',generation=0,contextVisible=true,roofVisible=true;
|
||||
let sceneStatus='正在载入三维模型';
|
||||
let sceneReady=false;
|
||||
let cameraState={active:null,note:'正在准备观察位置',views:[]};
|
||||
let appearanceState={preset:'day',exposure:.95,contextOpacity:.4,shadows:true,effects:true,quality:'standard'};
|
||||
|
||||
@@ -41,7 +44,12 @@ window.webQA={errors:[],loaded:[],mode:'network',contextVisible};
|
||||
window.addEventListener('error',event=>{const message=String(event.message||'三维场景运行错误');window.webQA.errors.push(message);postHost('error',{message});});
|
||||
window.addEventListener('unhandledrejection',event=>{const message=String(event.reason||'三维场景异步任务失败');window.webQA.errors.push(message);postHost('error',{message});});
|
||||
|
||||
const manifest=await (await fetch('./manifest.json',{cache:'no-store'})).json();
|
||||
const manifestResponse=await fetch('./manifest.json',{cache:'no-cache',signal:AbortSignal.timeout(30000)});
|
||||
if(!manifestResponse.ok)throw Error('场景清单加载失败:HTTP '+manifestResponse.status);
|
||||
const manifest=await manifestResponse.json();
|
||||
const resourceCache=createSceneResourceCache(import.meta.url,reportProgress);
|
||||
function startLoading(files){beginLoading(files);postHost('loading',{active:true});}
|
||||
function completeLoading(){finishLoading();postHost('loading',{active:false});}
|
||||
const viewport=()=>({width:Math.max(240,innerWidth),height:Math.max(240,innerHeight)});
|
||||
const initialViewport=viewport();
|
||||
const renderer=new THREE.WebGLRenderer({antialias:true,logarithmicDepthBuffer:false});
|
||||
@@ -52,6 +60,7 @@ const controls=new OrbitControls(camera,renderer.domElement);
|
||||
const appearance=createAppearance(renderer,scene);
|
||||
const effects=createRenderEffects(renderer,scene,camera,initialViewport.width,initialViewport.height);
|
||||
const loader=new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
|
||||
const loadModel=asset=>resourceCache.load(asset.file,asset.sha256,data=>loader.parseAsync(data,new URL('./',new URL(asset.file,import.meta.url)).href));
|
||||
const cache=new Map(),root=new THREE.Group();scene.add(root);
|
||||
|
||||
function isBuildingResource(resourceId=''){
|
||||
@@ -86,11 +95,11 @@ async function load(id){
|
||||
if(!cache.has(id)){
|
||||
const asset=manifest.assets.find(item=>item.id===id);
|
||||
if(!asset)throw Error('场景清单中不存在资源:'+id);
|
||||
cache.set(id,loader.loadAsync(asset.file).then(gltf=>{
|
||||
cache.set(id,loadModel(asset).then(gltf=>{
|
||||
enhanceMaterials(gltf.scene);gltf.scene.userData.assetId=asset.logicalId??asset.assetId;gltf.scene.userData.resourceId=id;
|
||||
gltf.scene.traverse(object=>{if(object.isMesh)for(const material of Array.isArray(object.material)?object.material:[object.material])material.userData.original={opacity:material.opacity,transparent:material.transparent,depthWrite:material.depthWrite};});
|
||||
return gltf.scene;
|
||||
}));
|
||||
}).catch(error=>{cache.delete(id);throw error;}));
|
||||
}
|
||||
return cache.get(id);
|
||||
}
|
||||
@@ -100,8 +109,14 @@ async function show(next,{frame=true}={}){
|
||||
if(next==='detail')ids=[...manifest.mapDefault.filter(id=>!['map_roof','map_facade'].includes(id)),'detail_roof','detail_facade','detail_interior','detail_ceiling'];
|
||||
if(next==='pump')ids=['detail_pump_walls','detail_pump_access','detail_pump_auxiliary','detail_pump_piping_reference',...manifest.assets.filter(asset=>asset.id.startsWith('physical_')).map(asset=>asset.id)];
|
||||
if(next==='meters')ids=['meter_dn150','valve_dn250','pump_symbol'];
|
||||
if(sceneReady)startLoading(ids.map(id=>manifest.assets.find(asset=>asset.id===id).file));
|
||||
setStatus('正在载入 '+ids.length+' 组场景资源');
|
||||
const objects=await Promise.all(ids.map(load));if(token!==generation)return;
|
||||
let objects;
|
||||
try{objects=await Promise.all(ids.map(async id=>{
|
||||
const object=await load(id);const asset=manifest.assets.find(item=>item.id===id);
|
||||
reportProgress({file:asset.file,stage:'ready',loaded:asset.bytes,total:asset.bytes});return object;
|
||||
}));}catch(error){if(token===generation){setStatus('场景资源加载失败,请重试');failLoading(error);postHost('loading',{active:false,failed:true});}throw error;}
|
||||
if(token!==generation)return;
|
||||
root.clear();objects.forEach((object,index)=>{
|
||||
object.position.set(next==='meters'?index*1.5:0,0,0);object.visible=true;root.add(object);
|
||||
object.traverse(mesh=>{if(mesh.isMesh)for(const material of Array.isArray(mesh.material)?mesh.material:[mesh.material])applyBuildingContext(material,['network','hydraulic'].includes(next),appearanceState.contextOpacity);});
|
||||
@@ -110,6 +125,7 @@ async function show(next,{frame=true}={}){
|
||||
networkStyle?.setFocusScope(next==='hydraulic');applyVisibility();inspector?.highlight();if(frame)fit();
|
||||
window.webQA.loaded=ids;window.webQA.mode=next;
|
||||
setStatus(next==='pump'?'六泵实体与 CAD 管路参考,接口配准待核':next==='meters'?'设备样件,主管不包含在样件内':['network','hydraulic'].includes(next)?'管网实体模型,建筑为透明背景':'已载入 '+ids.length+' 组场景资源');
|
||||
completeLoading();
|
||||
}
|
||||
function updateStyle(){appearance.refresh();inspector?.refresh();window.webQA.networkStyle={...networkStyle.summary,style:networkStyle.style};postState();}
|
||||
function setDisplayMode(value){networkStyle.setDisplayMode(value);navigation?.stop();updateStyle();}
|
||||
@@ -161,8 +177,9 @@ renderer.setAnimationLoop(()=>{
|
||||
effects.render(mode);window.webQA.drawCalls=renderer.info.render.calls;window.webQA.triangles=renderer.info.render.triangles;window.webQA.camera={active:cameraState.active,position:camera.position.toArray(),target:controls.target.toArray(),up:camera.up.toArray()};document.getElementById('qa').textContent=JSON.stringify(window.webQA);
|
||||
});
|
||||
|
||||
net=await (await fetch(manifest.networkModel.metadata,{cache:'no-store'})).json();
|
||||
const built=await loader.loadAsync(manifest.networkModel.file+'?v='+manifest.networkModel.sha256);networkRoot=built.scene;enhanceMaterials(networkRoot);scene.add(networkRoot);
|
||||
startLoading([manifest.networkModel.metadata,manifest.networkModel.file,...manifest.networkContext.map(id=>manifest.assets.find(asset=>asset.id===id).file)]);
|
||||
net=await resourceCache.load(manifest.networkModel.metadata,manifest.networkModel.metadataSha256,data=>parseNetworkMetadata(data,manifest.networkModel.id));
|
||||
const built=await loadModel(manifest.networkModel);networkRoot=built.scene;enhanceMaterials(networkRoot);scene.add(networkRoot);
|
||||
networkStyle=attachNetworkStyle(networkRoot,net);grain(networkStyle.pipes.material,{frequency:140,amplitude:.00004,variation:.08});
|
||||
try{const saved=localStorage.getItem('zjb-network-style-v24');if(saved)networkStyle.setStyle(JSON.parse(saved));}catch{}
|
||||
try{const quality=localStorage.getItem('zjb-render-quality-v27')??'standard';effects.setQuality(quality);appearanceState.quality=quality;}catch{effects.setQuality('standard');}
|
||||
@@ -204,5 +221,6 @@ window.addEventListener('message',async event=>{
|
||||
});
|
||||
|
||||
await navigation.visit('overview');
|
||||
document.getElementById('boot').hidden=true;
|
||||
sceneReady=true;
|
||||
completeLoading();
|
||||
postHost('ready',{nodeIds:net.nodes.map(node=>String(node.id)),linkIds:net.links.map(link=>String(link.id)),state:runtimeState()});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Only static scene assets enter this database. Live simulation/SCADA data do not.
|
||||
const DATABASE = 'tjwater-scene-assets-v1';
|
||||
const STORE = 'resources';
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
function openDatabase() {
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = value => { if (!settled) { settled = true; resolve(value); } };
|
||||
try {
|
||||
const request = globalThis.indexedDB.open(DATABASE, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(STORE, {keyPath:'url'});
|
||||
request.onerror = request.onblocked = () => finish(null);
|
||||
request.onsuccess = () => {
|
||||
const database = request.result;
|
||||
if (settled) { database.close(); return; }
|
||||
database.onversionchange = () => database.close();
|
||||
finish(database);
|
||||
};
|
||||
} catch { finish(null); }
|
||||
});
|
||||
}
|
||||
|
||||
function transact(database, mode, operation) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORE, mode);
|
||||
const request = operation(transaction.objectStore(STORE));
|
||||
transaction.oncomplete = () => resolve(request.result);
|
||||
transaction.onerror = transaction.onabort = () => reject(transaction.error ?? request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export function createSceneResourceCache(baseUrl, onProgress = () => {}) {
|
||||
// Open lazily, after synchronous WebGL setup, so database callbacks can run promptly.
|
||||
let database;
|
||||
const pending = new Map();
|
||||
|
||||
async function read(file, revision, decode) {
|
||||
const url = new URL(file, baseUrl);
|
||||
const key = url.href;
|
||||
const report = (stage, loaded = 0, total = 0) => onProgress({file, stage, loaded, total});
|
||||
const storage = await (database ??= openDatabase());
|
||||
if (storage && revision) {
|
||||
try {
|
||||
const entry = await transact(storage, 'readonly', store => store.get(key));
|
||||
if (entry?.revision === revision && entry.data instanceof ArrayBuffer) {
|
||||
report('cache', entry.data.byteLength, entry.data.byteLength);
|
||||
const result = await decode(entry.data);
|
||||
report('ready', entry.data.byteLength, entry.data.byteLength);
|
||||
return result;
|
||||
}
|
||||
} catch { /* Unavailable or invalid cache: fetch a fresh copy below. */ }
|
||||
}
|
||||
|
||||
// Revisioned URLs also keep the browser's HTTP cache in sync with the manifest.
|
||||
if (revision) url.searchParams.set('v', revision);
|
||||
const controller = new AbortController();
|
||||
let timer;
|
||||
const resetTimeout = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
};
|
||||
report('download');
|
||||
resetTimeout();
|
||||
let data;
|
||||
try {
|
||||
const response = await fetch(url, {signal:controller.signal, cache:'no-cache'});
|
||||
if (!response.ok) throw Error('HTTP ' + response.status);
|
||||
const total = Number(response.headers.get('content-length')) || 0;
|
||||
if (response.body) {
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let loaded = 0;
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value); loaded += value.byteLength;
|
||||
report('download', loaded, total); resetTimeout();
|
||||
}
|
||||
const bytes = new Uint8Array(loaded);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
||||
data = bytes.buffer;
|
||||
} else {
|
||||
data = await response.arrayBuffer();
|
||||
}
|
||||
} catch (error) {
|
||||
throw Error(file + ':' + (controller.signal.aborted ? '下载超时,请重试' : error.message));
|
||||
} finally { clearTimeout(timer); }
|
||||
|
||||
report('parse', data.byteLength, data.byteLength);
|
||||
// Validate/parse before persisting so an HTML error page or bad GLB cannot poison the cache.
|
||||
const result = await decode(data);
|
||||
if (storage && revision) {
|
||||
try {
|
||||
// One entry per URL: a successful new revision replaces the old bytes.
|
||||
await transact(storage, 'readwrite', store => store.put({url:key, revision, data}));
|
||||
} catch { /* Storage denied/full must not prevent scene rendering. */ }
|
||||
}
|
||||
report('ready', data.byteLength, data.byteLength);
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
load(file, revision, decode = data => data) {
|
||||
const key = file + ':' + revision;
|
||||
if (!pending.has(key)) {
|
||||
const task = read(file, revision, decode).finally(() => pending.delete(key));
|
||||
pending.set(key, task);
|
||||
}
|
||||
return pending.get(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user