feat(3d): integrate ZJB scene with project context
Generic Container CI/CD / test-build-publish (push) Successful in 17s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 18s

This commit is contained in:
2026-09-11 18:49:55 +08:00
parent 3ebe3328aa
commit 35b6819459
76 changed files with 109136 additions and 22 deletions
@@ -0,0 +1,131 @@
import * as THREE from 'three';
export function createAssetInspector({model,networkRoot,scene,style,onLocate,onSelectionChange=()=>{}}){
const links=new Map(model.links.map(link=>[link.id,link]));
const nodes=new Map(model.nodes.map(node=>[node.id,node]));
const meters=new Map(model.cadEquipmentBindings.map(meter=>[meter.assetId,meter]));
const halo=new THREE.Group();halo.name='Selection presentation';scene.add(halo);
const material=new THREE.MeshBasicMaterial({color:0xffbd45,transparent:true,opacity:.35,depthWrite:false,polygonOffset:true,polygonOffsetFactor:-2});
let selected=null;
const status=input=>input==null?'暂无数据':({open:'开启',closed:'关闭',active:'调节中'}[String(input).toLowerCase()]??String(input));
const value=(input,unit)=>typeof input==='number'?input.toFixed(3)+' '+unit:'暂无数据';
const fields=rows=>rows.map(([label,input])=>({label,value:input==null?'暂无数据':String(input)}));
function resolve(input){
let id=String(input);
if(id.startsWith('zjb:physical_'))id='inp:link:'+id.slice(13);
const cad=model.cadAttachments?.find(record=>record.assetId===id);
if(cad)return {assetId:id,id:cad.hostNodeId,node:nodes.get(cad.hostNodeId),cad,kind:'CAD 立管'};
const meter=meters.get(id);
if(meter)return {assetId:id,id:meter.inpLinkId,meter,link:links.get(meter.inpLinkId),kind:'流量计'};
if(id.startsWith('inp:link:'))id=id.slice(9);
else if(id.startsWith('inp:node:')){
id=id.slice(9);const node=nodes.get(id);
if(node)return {assetId:'inp:node:'+id,id,node,kind:'节点'};
}
const link=links.get(id);
if(link)return {assetId:'inp:link:'+id,id,link,kind:{PIPES:'管段',PUMPS:'水泵',VALVES:'阀门'}[link.kind]};
const node=nodes.get(id);
if(node)return {assetId:'inp:node:'+id,id,node,kind:'节点'};
throw Error('未找到构件:'+input);
}
function bounds(item=selected){
const box=new THREE.Box3();if(!item)return box;networkRoot.updateMatrixWorld(true);
if(item.cad||item.meter||item.link?.kind==='PUMPS'){
networkRoot.traverse(object=>{if(!object.isMesh&&object.userData.assetId===item.assetId)box.union(new THREE.Box3().setFromObject(object));});
}
if(box.isEmpty()){
const records=item.node?model.nodeInstances:model.pipeInstances;
const mesh=item.node?style.nodes:style.pipes;
for(let index=0;index<records.length;index++)if(records[index].inpId===item.id){
const matrix=new THREE.Matrix4();mesh.getMatrixAt(index,matrix);
if(!mesh.geometry.boundingBox)mesh.geometry.computeBoundingBox();
box.union(mesh.geometry.boundingBox.clone().applyMatrix4(matrix).applyMatrix4(mesh.matrixWorld));
}
}
return box;
}
function highlight(){
for(const object of halo.children)if(object.userData.owned){object.geometry.dispose();object.material.dispose();}
halo.clear();if(!selected||!networkRoot.visible)return;
if(selected.cad||selected.meter||selected.link?.kind==='PUMPS'){
const box=bounds();if(!box.isEmpty()){const helper=new THREE.Box3Helper(box,0xffbd45);helper.userData.owned=true;halo.add(helper);}
}else{
const records=selected.node?model.nodeInstances:model.pipeInstances;
const source=selected.node?style.nodes:style.pipes;
records.forEach((record,index)=>{
if(record.inpId!==selected.id)return;
const matrix=new THREE.Matrix4();source.getMatrixAt(index,matrix);
const object=new THREE.Mesh(source.geometry,material);object.matrixAutoUpdate=false;
object.matrix.copy(source.matrixWorld).multiply(matrix);object.renderOrder=3;halo.add(object);
});
}
}
function neighbors(item=selected){
if(!item)return [];
const nodeIds=item.node?[item.id]:[item.link.from,item.link.to];
return model.links.filter(link=>link.id!==item.id&&[link.from,link.to].some(nodeId=>nodeIds.includes(nodeId)));
}
function details(item=selected){
if(!item)return null;
const link=item.link;
const result=style.getResult(item.id,item.node?'nodes':'links');
const sections=[];
sections.push({title:'基本信息',fields:fields([
['编号',item.id],['类型',item.kind],
...(link?[['名义管径',link.kind==='PUMPS'?'连接模型参数 '+link.diameterMm+' mm':link.diameterMm+' mm'],['初始状态',status(link.initialStatus)]]:[]),
['展示比例',style.displayMode==='coordinated'?'泵房协调比例,范围外 '+style.style.scale+'×':'全局 '+style.style.scale+'×'],
])});
if(item.cad)sections.push({title:'CAD 竖向依据',fields:fields([
['接入节点',item.cad.hostNodeId],['管径',item.cad.diameter_mm+' mm'],['图示高差',item.cad.vertical_height_m+' m'],
['原图标注',item.cad.source_annotation],['CAD 句柄',item.cad.evidence.map(evidence=>evidence.handle).join('、')],
['上端','上层配水走向未确认'],['几何基准','全局显示抬高 0.35 m;高差按图纸'],['水力关系','附着于现有节点,不新增 INP 链接'],
])});
if(link&&model.verticalCoordination&&model.coordination.scopeLinkIds.includes(link.id))sections.push({title:'泵房竖向依据',fields:fields([
['管中心','CAD 换算为地面下 2.90 m,协调模式采用'],['接入走向','靠泵房端增加竖向过渡,折点位置估算'],['平面配准','INP 示意位置;与机械图接口尚未完成配准'],
])});
if(link)sections.push({title:'连接关系',fields:fields([
['起点',link.from],['终点',link.to],...(item.meter?[['宿主管段',item.meter.inpLinkId]]:[]),
])});
let pressure=result?.pressure,pressureBasis='直接结果';
if(link&&pressure==null){
const fromPressure=style.getResult(link.from,'nodes')?.pressure;
const toPressure=style.getResult(link.to,'nodes')?.pressure;
if(Number.isFinite(fromPressure)&&Number.isFinite(toPressure)){pressure=(fromPressure+toPressure)/2;pressureBasis='两端节点均值';}
}
if(item.cad)pressureBasis='接入节点压力,非立管上端压力';
const pressureSource=result?.source==='scada'?'SCADA '+(result.deviceId??'监测'):pressure!=null?'在线模拟':'暂无数据';
sections.push({title:'运行结果',fields:fields([
['运行状态',status(result?.status)],['压力',value(pressure,'mH₂O')],
...(pressure!=null?[['压力依据',pressureBasis],['压力来源',pressureSource]]:[]),
...(result?.source==='scada'&&Number.isFinite(result.simulationPressure)?[['同期模拟压力',value(result.simulationPressure,'mH₂O')]]:[]),
...(link?[['流速',value(result?.velocity==null?null:Math.abs(result.velocity),'m/s')],['流量',value(result?.flow,'L/s')],['流向',result?.status?.toLowerCase()==='closed'?'停流':result?.direction!=null||result?.flow!=null?((result.direction??Math.sign(result.flow))>0?'起点 → 终点':(result.direction??Math.sign(result.flow))<0?'终点 → 起点':'停流'):'暂无数据']]:[]),
])});
sections.push({title:'来源说明',fields:fields([
['拓扑来源','已核对 INP;本页不求解水力'],['位置与高度','展示调整,不代表实测埋深'],
...(link?.kind==='PUMPS'?[['设备对应','CAD 槽位顺序;现场铭牌未确认']]:[]),
...(item.meter?[['CAD 句柄',item.meter.cadHandles.join('、')]]:[]),
])});
return {
assetId:item.assetId,elementId:item.id,kind:item.kind,
title:item.kind+' · '+(item.cad?.label??item.meter?.label??item.id),
sections,
neighbors:neighbors(item).map(record=>({id:record.id,label:record.id})),
};
}
function refresh(){if(selected)onSelectionChange(details());highlight();}
function select(id){selected=resolve(id);highlight();const selection=details();onSelectionChange(selection);return selection;}
function clear(){
for(const object of halo.children)if(object.userData.owned){object.geometry.dispose();object.material.dispose();}
halo.clear();selected=null;onSelectionChange(null);
}
function locate(){if(selected)return onLocate(selected,bounds());}
return {select,clear,resolve,bounds,refresh,highlight,details,locate,get selected(){return selected;},neighbors};
}