205 lines
15 KiB
JavaScript
205 lines
15 KiB
JavaScript
import {createRenderEffects} from './render-effects.mjs';
|
|
import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=28';
|
|
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=28b';
|
|
import {cadToGltf} from './integration.mjs';
|
|
import {createAppearance,enhanceMaterials,grain,applyBuildingContext} from './appearance.mjs?v=29';
|
|
|
|
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,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'};
|
|
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.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=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));});
|
|
});
|
|
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.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);}
|
|
postState();
|
|
}
|
|
function toggleContext(){contextVisible=!contextVisible;applyVisibility();appearance.refresh();fit();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});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;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-v23');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};
|
|
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-v23',JSON.stringify(networkStyle.style));updateStyle();}
|
|
else if(command.name==='reset-style'){networkStyle.setStyle(DEFAULT_STYLE);localStorage.removeItem('zjb-network-style-v23');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()});
|