The scene previously returned early when realtime rows were absent and selected map-tier assets for network mode, hiding SCADA markers and station interiors and floors. Use SCADA timestamps as valid frames, render node-aligned monitor markers, and pin the full network context in the manifest regression test. Keep the timeline close action inside its drag header.
209 lines
16 KiB
JavaScript
209 lines
16 KiB
JavaScript
import {createRenderEffects} from './render-effects.mjs';
|
|
import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=31-camera-presets';
|
|
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 {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 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 manifest=await (await fetch('./manifest.json',{cache:'no-store'})).json();
|
|
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 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,loader.loadAsync(asset.file).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;
|
|
}));
|
|
}
|
|
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'];
|
|
setStatus('正在载入 '+ids.length+' 组场景资源');
|
|
const objects=await Promise.all(ids.map(load));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+' 组场景资源');
|
|
}
|
|
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);
|
|
});
|
|
|
|
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);
|
|
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');
|
|
document.getElementById('boot').hidden=true;
|
|
postHost('ready',{nodeIds:net.nodes.map(node=>String(node.id)),linkIds:net.links.map(link=>String(link.id)),state:runtimeState()});
|