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()});