Files
TJWaterFrontend_Refine/public/three-dimensional/zjb/v29/preview.mjs
T
jiang d013f34493
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
fix(scene): resume chunk downloads after proxy interruptions
Whole-file loading still failed after adding caching because the public proxy interrupted large GLB response bodies. Download validated 1 MiB ranges, retry transient failures up to three attempts, and persist completed chunks so reloads resume from their last boundary.

Add regression cases observed failing before the change for interrupted downloads, cross-reload resume, and temporary HTTP failures. Verify range integrity, full-cache reuse, permanent errors, and storage fallback.
2026-10-16 18:37:24 +08:00

227 lines
18 KiB
JavaScript

import {createRenderEffects} from './render-effects.mjs';
import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=36-resumable-cache';
import {createSceneResourceCache} from './resourceCache.mjs?v=36-resumable-cache';
import {beginLoading,reportProgress,finishLoading,failLoading} from './loadingProgress.mjs?v=36-resumable-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,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'};
function postHost(type,detail={}){
if(window.parent===window)return;
window.parent.postMessage({channel:HOST_CHANNEL,version:HOST_VERSION,type,projectCode:PROJECT_CODE,modelId:net?.modelId??MODEL_ID,...detail},window.location.origin);
}
function trustedHostMessage(event){
const data=event.data;
return event.origin===window.location.origin&&event.source===window.parent&&data&&typeof data==='object'&&data.channel===HOST_CHANNEL&&data.version===HOST_VERSION&&data.projectCode===PROJECT_CODE&&data.modelId===(net?.modelId??MODEL_ID);
}
function runtimeState(){
return {
mode,status:sceneStatus,contextVisible,roofVisible,
displayMode:networkStyle?.displayMode??'global',
style:networkStyle?.style??{...DEFAULT_STYLE},
styleSummary:networkStyle?.summary??{},
appearance:{...appearanceState},
camera:{...cameraState,views:cameraState.views.map(view=>({...view}))},
};
}
function postState(){if(net&&networkStyle)postHost('scene-state',{state:runtimeState()});}
function setStatus(value){sceneStatus=value;postState();}
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 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});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));renderer.setSize(initialViewport.width,initialViewport.height);renderer.setClearColor(0xeaf1f8);document.body.append(renderer.domElement);renderer.info.autoReset=false;
const scene=new THREE.Scene();
const camera=new THREE.PerspectiveCamera(42,initialViewport.width/initialViewport.height,.05,10000);
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=''){
return /^(map_|detail_(roof|facade|interior|ceiling|pump_walls|pump_roof|pump_access))/.test(resourceId);
}
function applyVisibility(){
root.visible=mode!=='hydraulic';
for(const object of root.children){
if(isBuildingResource(object.userData.resourceId))object.visible=contextVisible;
if(/roof|ceiling/.test(object.userData.resourceId))object.visible=contextVisible&&roofVisible;
}
if(mode==='pump')for(const object of root.children)if(['detail_pump_walls','detail_pump_access'].includes(object.userData.resourceId))object.visible=false;
}
function fit(){
let box=new THREE.Box3();
if(['network','hydraulic'].includes(mode)&&networkRoot)box.setFromObject(networkRoot);
else for(const object of root.children)if(object.visible)box.union(new THREE.Box3().setFromObject(object));
if(mode==='detail')box=new THREE.Box3().setFromObject(root.children.find(object=>object.userData.resourceId==='detail_facade')??root);
if(mode==='hydraulic'&&net){
box=new THREE.Box3();
for(const link of net.links.filter(link=>link.kind==='PUMPS'))for(const point of link.coordinates)box.expandByPoint(new THREE.Vector3(...cadToGltf(point,.35)));
box.expandByScalar(4);
}
if(box.isEmpty())return;
appearance.frame(box,mode);
const sphere=box.getBoundingSphere(new THREE.Sphere());controls.target.copy(sphere.center);
camera.up.copy(new THREE.Vector3(0,1,0));
camera.position.copy(sphere.center).add(new THREE.Vector3(.65,.72,1).normalize().multiplyScalar(sphere.radius*3.1/Math.min(1,camera.aspect)));
camera.near=Math.max(.005,sphere.radius/5000);camera.far=sphere.radius*30+100;camera.updateProjectionMatrix();controls.update();
}
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,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);
}
async function show(next,{frame=true}={}){
if(!['network','hydraulic','map','detail','pump','meters'].includes(next))throw Error('不支持的场景模式:'+next);
const token=++generation;mode=next;let ids=next==='network'?manifest.networkContext:manifest.mapDefault;
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+' 组场景资源');
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);});
});
if(networkRoot)networkRoot.visible=['network','hydraulic'].includes(next);
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();}
function applyAppearance(patch){
if(patch.preset!==undefined){if(!['day','studio','evening'].includes(patch.preset))throw Error('不支持的光照场景');appearance.preset(patch.preset);appearanceState.preset=patch.preset;appearanceState.exposure=appearance.state.exposure;}
if(patch.exposure!==undefined){if(!Number.isFinite(patch.exposure)||patch.exposure<.55||patch.exposure>1.6)throw Error('亮度超出有效范围');appearance.exposure(patch.exposure);appearanceState.exposure=patch.exposure;}
if(patch.contextOpacity!==undefined){if(!Number.isFinite(patch.contextOpacity)||patch.contextOpacity<0||patch.contextOpacity>1)throw Error('建筑背景透明度超出有效范围');appearanceState.contextOpacity=patch.contextOpacity;for(const object of root.children)object.traverse(mesh=>{if(mesh.isMesh)for(const material of Array.isArray(mesh.material)?mesh.material:[mesh.material])applyBuildingContext(material,['network','hydraulic'].includes(mode),appearanceState.contextOpacity);});appearance.refresh();}
if(patch.shadows!==undefined){appearance.shadows(Boolean(patch.shadows));appearanceState.shadows=Boolean(patch.shadows);}
if(patch.effects!==undefined){effects.setEnabled(Boolean(patch.effects));appearanceState.effects=Boolean(patch.effects);}
if(patch.quality!==undefined){if(!['standard','high'].includes(patch.quality))throw Error('不支持的画质');effects.setQuality(patch.quality);appearanceState.quality=patch.quality;localStorage.setItem('zjb-render-quality-v27',patch.quality);}
window.webQA.appearance={...appearanceState};
postState();
}
function toggleContext(){contextVisible=!contextVisible;window.webQA.contextVisible=contextVisible;applyVisibility();appearance.refresh();postState();}
function toggleRoof(){roofVisible=!roofVisible;applyVisibility();appearance.refresh();postState();}
function resizeView(){const current=viewport();camera.aspect=current.width/current.height;camera.updateProjectionMatrix();renderer.setSize(current.width,current.height);effects.resize(current.width,current.height);}
addEventListener('resize',resizeView);
const ray=new THREE.Raycaster();let pointerStart=null,pointerMoved=false;
renderer.domElement.addEventListener('pointerdown',event=>{pointerStart=[event.clientX,event.clientY];pointerMoved=false;});
renderer.domElement.addEventListener('pointermove',event=>{if(pointerStart&&Math.hypot(event.clientX-pointerStart[0],event.clientY-pointerStart[1])>6)pointerMoved=true;});
renderer.domElement.addEventListener('click',event=>{
if(pointerMoved||!inspector)return;
const current=viewport();ray.setFromCamera(new THREE.Vector2(event.clientX/current.width*2-1,1-event.clientY/current.height*2),camera);
const visible=object=>{while(object){if(!object.visible)return false;object=object.parent;}return true;};
const hits=ray.intersectObjects([...(networkRoot?.visible?[networkRoot]:[]),...(root.visible?[root]:[])],true).filter(hit=>visible(hit.object));
for(const hit of hits){
let data=hit.object.userData.instances?.[hit.instanceId];
if(!data){let object=hit.object;while(object.parent&&!object.userData.inpId&&!object.userData.hostLinkId&&!object.userData.assetId)object=object.parent;data=object.userData;}
if(data.assetId){try{inspector.select(data.assetId);window.webQA.picked=data.assetId;break;}catch{}}
}
});
let locateRequest=0;
async function locateAsset(item){
const request=++locateRequest;navigation?.stop();const targetMode=net.coordination.scopeLinkIds.includes(item.id)?'hydraulic':'network';const expected=mode!==targetMode?generation+1:generation;
if(mode!==targetMode)await show(targetMode,{frame:false});if(request!==locateRequest||generation!==expected)return;
setDisplayMode(targetMode==='hydraulic'?'coordinated':'global');contextVisible=targetMode!=='hydraulic';applyVisibility();
const box=inspector.bounds(item);if(box.isEmpty())return;box.expandByScalar(item.link?.kind==='PUMPS'?.6:1);
const pose=framePose(box,camera.aspect,{padding:1.3});camera.up.fromArray(pose.up??[0,1,0]).normalize();controls.target.fromArray(pose.target);camera.position.fromArray(pose.position);camera.far=20000;controls.update();appearance.frame(box,targetMode);inspector.highlight();postState();
}
let lastFrame=performance.now(),frameTimes=[];
renderer.setAnimationLoop(()=>{
const now=performance.now(),delta=now-lastFrame;lastFrame=now;if(delta<250){frameTimes.push(delta);if(frameTimes.length>120)frameTimes.shift();}
if(frameTimes.length>=60)window.webQA.performance={samples:frameTimes.length,meanFps:1000/(frameTimes.reduce((sum,value)=>sum+value,0)/frameTimes.length),viewport:viewport(),quality:effects.state.quality};
renderer.info.reset();navigation?.tick(performance.now());controls.update();const near=Math.max(.02,camera.position.distanceTo(controls.target)/150);
if(Math.abs(camera.near-near)>.001){camera.near=near;camera.updateProjectionMatrix();}
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);
});
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');}
window.webQA.network={...net.connectivity,meters:net.cadEquipmentBindings.length,modelId:net.modelId,runtimeInpRequired:false};
window.webQA.networkStyle={...networkStyle.summary,style:networkStyle.style};window.webQA.appearance={...appearanceState};
navigation=createCameraNavigation({camera,controls,model:net,networkRoot,root,show,getMode:()=>mode,appearance,getDisplayMode:()=>networkStyle.displayMode,setDisplayMode,onChange(state){
cameraState={active:state.active,note:state.note,views:state.views.map(view=>({id:view.id,label:view.label,mode:view.mode,note:view.note,saved:view.id.startsWith('saved-')}))};postState();
}});
inspector=createAssetInspector({model:net,networkRoot,scene,style:networkStyle,onLocate:locateAsset,onSelectionChange(selection){postHost('selection-changed',{selection});}});
window.addEventListener('message',async event=>{
if(!trustedHostMessage(event))return;
try{
const data=event.data;
if(data.type==='results'){
networkStyle.setResults(data.payload);if(networkStyle.style.mode==='uniform')networkStyle.setStyle({mode:'pressure'});updateStyle();postHost('results-applied',{summary:networkStyle.summary});return;
}
if(data.type==='clear-results'){
networkStyle.clearResults();updateStyle();postHost('results-cleared');return;
}
if(data.type!=='command'||!data.command||typeof data.command!=='object')return;
const command=data.command;
if(command.name==='set-mode'){navigation.stop();await show(command.mode);}
else if(command.name==='visit-camera')await navigation.visit(command.viewId);
else if(command.name==='save-camera')navigation.save(command.label);
else if(command.name==='remove-camera')navigation.remove(command.viewId);
else if(command.name==='set-style'){networkStyle.setStyle(command.patch);localStorage.setItem('zjb-network-style-v24',JSON.stringify(networkStyle.style));updateStyle();}
else if(command.name==='reset-style'){networkStyle.setStyle(DEFAULT_STYLE);localStorage.removeItem('zjb-network-style-v24');updateStyle();}
else if(command.name==='set-display-mode')setDisplayMode(command.mode);
else if(command.name==='set-appearance')applyAppearance(command.patch);
else if(command.name==='toggle-context')toggleContext();
else if(command.name==='toggle-roof')toggleRoof();
else if(command.name==='fit-view'){navigation.stop();fit();postState();}
else if(command.name==='select-asset')inspector.select(command.assetId);
else if(command.name==='locate-selection')await inspector.locate();
else if(command.name==='clear-selection')inspector.clear();
else throw Error('不支持的场景命令');
}catch(error){const message=error instanceof Error?error.message:String(error);postHost('error',{message});}
});
await navigation.visit('overview');
sceneReady=true;
completeLoading();
postHost('ready',{nodeIds:net.nodes.map(node=>String(node.id)),linkIds:net.links.map(link=>String(link.id)),state:runtimeState()});