feat(frontend): cache 3D assets and restore loading feedback
Generic Container CI/CD / test-build-publish (push) Successful in 1m11s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m11s

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:
2026-10-16 18:13:04 +08:00
parent c4a90be0d3
commit 4a522bf42a
23 changed files with 938 additions and 59 deletions
+27 -9
View File
@@ -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()});