Keep the control panel geometry stable when toggling building context, and use accessible switch controls to prevent focus-driven movement. Add browser coverage for camera framing, opacity, responsive layout, and the default 6x pipe scale.
92 lines
11 KiB
JavaScript
92 lines
11 KiB
JavaScript
import * as THREE from 'three';
|
||
const WORLD_UP=new THREE.Vector3(0,1,0);
|
||
function boxCorners(box){const {min,max}=box;return [
|
||
new THREE.Vector3(min.x,min.y,min.z),new THREE.Vector3(min.x,min.y,max.z),new THREE.Vector3(min.x,max.y,min.z),new THREE.Vector3(min.x,max.y,max.z),
|
||
new THREE.Vector3(max.x,min.y,min.z),new THREE.Vector3(max.x,min.y,max.z),new THREE.Vector3(max.x,max.y,min.z),new THREE.Vector3(max.x,max.y,max.z),
|
||
];}
|
||
export function networkPrimaryAxis(model){
|
||
const points=(model.nodes??[]).map(node=>node.coordinate).filter(point=>Array.isArray(point)&&point.length>=2&&point.slice(0,2).every(Number.isFinite));
|
||
if(points.length<2)return new THREE.Vector3(1,0,0);
|
||
const center=points.reduce((sum,point)=>[sum[0]+point[0]/points.length,sum[1]-point[1]/points.length],[0,0]);let xx=0,xz=0,zz=0;
|
||
for(const point of points){const x=point[0]-center[0],z=-point[1]-center[1];xx+=x*x;xz+=x*z;zz+=z*z;}
|
||
const angle=.5*Math.atan2(2*xz,xx-zz),axis=new THREE.Vector3(Math.cos(angle),0,Math.sin(angle));if(axis.x<0)axis.negate();return axis.normalize();
|
||
}
|
||
function linkAxis(link,fallback){
|
||
const points=link?.coordinates;if(!Array.isArray(points)||points.length<2)return fallback.clone();
|
||
const first=points[0],last=points.at(-1),axis=new THREE.Vector3(last[0]-first[0],0,-(last[1]-first[1]));
|
||
if(axis.lengthSq()<1e-8)return fallback.clone();if(axis.x<0)axis.negate();return axis.normalize();
|
||
}
|
||
function viewingDirection(axis,reference,elevation){
|
||
const horizontal=axis.clone().cross(WORLD_UP).normalize(),referenceHorizontal=reference.clone().cross(WORLD_UP).normalize();
|
||
if(horizontal.dot(referenceHorizontal)<0)horizontal.negate();return horizontal.addScaledVector(WORLD_UP,elevation).normalize();
|
||
}
|
||
export function framePose(box,aspect,{direction=[.6,.8,1],up=[0,1,0],padding=1.12,fov=42}={}){
|
||
if(box.isEmpty())throw Error('视角目标没有可见模型。');
|
||
const target=box.getCenter(new THREE.Vector3()),viewDirection=new THREE.Vector3(...direction).normalize();let requestedUp=new THREE.Vector3(...up).normalize();
|
||
if(Math.abs(requestedUp.dot(viewDirection))>.98)requestedUp=Math.abs(viewDirection.y)<.98?WORLD_UP.clone():new THREE.Vector3(0,0,-1);
|
||
const right=requestedUp.clone().cross(viewDirection).normalize(),projectedUp=viewDirection.clone().cross(right).normalize();
|
||
const forward=viewDirection.clone().negate(),verticalTan=Math.tan(fov*Math.PI/360),horizontalTan=verticalTan*Math.max(.1,aspect);let distance=2;
|
||
for(const corner of boxCorners(box)){const offset=corner.sub(target),depthOffset=offset.dot(forward);distance=Math.max(distance,Math.abs(offset.dot(right))/horizontalTan-depthOffset,Math.abs(offset.dot(projectedUp))/verticalTan-depthOffset);}
|
||
distance*=padding;return {target:target.toArray(),position:target.clone().add(viewDirection.multiplyScalar(distance)).toArray(),up:requestedUp.toArray()};
|
||
}
|
||
export function createCameraNavigation({camera,controls,model,networkRoot,root,show,getMode,appearance,onChange,getDisplayMode=()=> 'global',setDisplayMode=()=>{}}){
|
||
const presets=[{id:'overview',label:'供水总览',mode:'network',note:'完整管网与站区背景'},{id:'plan',label:'管网俯视',mode:'network',note:'查看管网平面关系'},{id:'pump',label:'CAD 泵房参考',mode:'pump',note:'六泵与集管剖开检查 · 隐藏外墙和楼梯'},{id:'hydraulic',label:'泵组与管网',mode:'hydraulic',note:'六泵与下沉接入 · 竖向按 CAD,平面配准待核'},{id:'main',label:'主干管段',mode:'network',note:'定位模型中最长的供水管段'},{id:'meter',label:'BJ9 水表',mode:'network',note:'定位 CAD 水表及宿主管段'},{id:'crossing',label:'交叉下穿',mode:'network',note:'查看非连通交叉的示意下穿'},{id:'station',label:'站房外观',mode:'detail',note:'站房精细模型与周边关系'}];
|
||
presets.splice(4,0,{id:'singlePump',label:'主泵近景',mode:'hydraulic',note:'PU_VFD_1 · 设备法兰与进出水连接'});
|
||
if(model.cadAttachments?.length)presets.push({id:'riser',label:'站台上层立管',mode:'network',context:false,note:'CAD DN150 · 高差 10.85 m · 上层配水未推断'});
|
||
let request=0,flight=null,active=null;const key='zjb-camera-bookmarks-v24';let saved=[];
|
||
try{const v=JSON.parse(localStorage.getItem(key)||'[]');if(Array.isArray(v))saved=v.filter(p=>p&&typeof p.id==='string'&&typeof p.label==='string'&&p.label.length<=24&&['network','hydraulic','pump','detail','map','meters'].includes(p.mode)&&[p.position,p.target].every(a=>Array.isArray(a)&&a.length===3&&a.every(Number.isFinite))&&(!p.up||(Array.isArray(p.up)&&p.up.length===3&&p.up.every(Number.isFinite)))).slice(0,8);}catch{}
|
||
const primaryAxis=networkPrimaryAxis(model),planDirection=WORLD_UP.clone(),planUp=new THREE.Vector3(0,0,-1),overviewDirection=viewingDirection(primaryAxis,primaryAxis,.92);
|
||
const links=new Map((model.links??[]).map(link=>[link.id,link]));
|
||
const linkLength=link=>(link.coordinates??[]).slice(1).reduce((sum,point,index)=>sum+Math.hypot(point[0]-link.coordinates[index][0],point[1]-link.coordinates[index][1]),0);
|
||
const mainLink=(model.links??[]).filter(link=>link.kind==='PIPES').sort((a,b)=>linkLength(b)-linkLength(a))[0];
|
||
const mainAxis=linkAxis(mainLink,primaryAxis);
|
||
const meterBinding=(model.cadEquipmentBindings??[]).find(binding=>binding.assetId==='zjb:meter:BJ9'),meterAxis=linkAxis(links.get(meterBinding?.inpLinkId),primaryAxis);
|
||
const poseOptions={
|
||
pump:{direction:viewingDirection(primaryAxis,primaryAxis,.72).toArray(),padding:1.06},
|
||
hydraulic:{direction:viewingDirection(primaryAxis,primaryAxis,.58).toArray(),padding:1.08},
|
||
singlePump:{direction:viewingDirection(primaryAxis,primaryAxis,.36).toArray(),padding:1.28},
|
||
main:{direction:viewingDirection(mainAxis,primaryAxis,.72).toArray(),padding:1.1},
|
||
meter:{direction:viewingDirection(meterAxis,primaryAxis,1.6).toArray(),padding:1.16},
|
||
crossing:{direction:viewingDirection(primaryAxis,primaryAxis,2.3).toArray(),padding:1.16},
|
||
station:{direction:viewingDirection(primaryAxis,primaryAxis,.58).toArray(),padding:1.06},
|
||
riser:{direction:viewingDirection(primaryAxis,primaryAxis,.2).toArray(),padding:1.24},
|
||
};
|
||
function emit(note){onChange({active,views:[...presets,...saved],note});}
|
||
function stop(){request++;flight=null;active=null;emit('自由浏览 · 可保存当前视角');}
|
||
controls.addEventListener('start',stop);
|
||
function bounds(p){let box=new THREE.Box3();
|
||
if(p.id==='overview'||p.id==='plan')box.setFromObject(networkRoot);
|
||
else if(p.id==='pump'||p.id==='station'){for(const o of root.children)if(o.visible&&(p.id!=='pump'||/physical_|piping_reference/.test(o.userData.resourceId)))box.union(new THREE.Box3().setFromObject(o));if(p.id==='station'){const facade=root.children.find(o=>o.userData.resourceId==='detail_facade');if(facade)box.setFromObject(facade);}}
|
||
else if(p.id==='hydraulic'){for(const l of model.links.filter(l=>l.kind==='PUMPS'))for(const a of l.coordinates)box.expandByPoint(new THREE.Vector3(a[0]-513800,model.verticalCoordination?.pump.pipeCenterY??.35,-(a[1]-2344450)));networkRoot.traverse(o=>{if(o.userData.kind==='PUMPS'&&!o.isMesh)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(2);}
|
||
else if(p.id==='riser'){networkRoot.traverse(o=>{if(o.userData.assetId===model.cadAttachments[0].assetId)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(4);}
|
||
else if(p.id==='singlePump'){networkRoot.traverse(o=>{if(o.userData.assetId==='inp:link:PU_VFD_1'&&!o.isMesh)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(1.25);}
|
||
else if(p.id==='meter'){networkRoot.traverse(o=>{if(o.userData.assetId==='zjb:meter:BJ9')box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(3.2);}
|
||
else if(p.id==='main'){for(const point of mainLink.coordinates)box.expandByPoint(new THREE.Vector3(point[0]-513800,.35,-(point[1]-2344450)));box.expandByScalar(6);}
|
||
else if(p.id==='crossing'){const a=model.layouts[8].pipes;let i=0;for(let j=0;j<a.length;j+=16)if(a[j+13]<a[i+13])i=j;const point=new THREE.Vector3(a[i+12],a[i+13],a[i+14]);box.setFromCenterAndSize(point,new THREE.Vector3(30,14,30));}
|
||
return box;
|
||
}
|
||
async function visit(id){const p=[...presets,...saved].find(p=>p.id===id);if(!p)return;const token=++request;flight=null;emit('正在准备 '+p.label+'…');
|
||
try{await show(p.mode,{frame:false});if(token!==request)return;setDisplayMode(p.displayMode??(p.mode==='hydraulic'?'coordinated':'global'));root.visible=p.context??(p.mode!=='hydraulic'&&p.id!=='crossing');
|
||
for(const o of root.children)if(/roof|ceiling/.test(o.userData.resourceId))o.visible=p.roof??true;
|
||
if(p.id==='pump')for(const o of root.children)if(['detail_pump_walls','detail_pump_access'].includes(o.userData.resourceId))o.visible=false;
|
||
if(p.layers)for(const o of root.children)if(typeof p.layers[o.userData.resourceId]==='boolean')o.visible=p.layers[o.userData.resourceId];
|
||
let pose,box;if(p.position){pose=p;box=new THREE.Box3().setFromCenterAndSize(new THREE.Vector3(...p.target),new THREE.Vector3(20,20,20));}else{box=bounds(p);pose=framePose(box,camera.aspect,p.id==='plan'?{direction:planDirection.toArray(),up:planUp.toArray(),padding:1.04}:p.id==='overview'?{direction:overviewDirection.toArray(),padding:1.08}:poseOptions[p.id]);}
|
||
camera.up.fromArray(pose.up??[0,1,0]).normalize();
|
||
appearance.frame(box,p.mode);camera.near=Math.max(.05,new THREE.Vector3(...pose.position).distanceTo(new THREE.Vector3(...pose.target))/2000);camera.far=20000;camera.updateProjectionMatrix();active=id;
|
||
flight={start:performance.now(),from:camera.position.clone(),targetFrom:controls.target.clone(),to:new THREE.Vector3(...pose.position),targetTo:new THREE.Vector3(...pose.target)};
|
||
if(matchMedia('(prefers-reduced-motion: reduce)').matches){camera.position.copy(flight.to);controls.target.copy(flight.targetTo);flight=null;controls.update();}
|
||
emit(p.note??'已保存的相机位置');
|
||
}catch(e){if(token===request)emit('视角加载失败:'+e.message);}
|
||
}
|
||
function tick(now){if(!flight)return;const t=Math.min(1,(now-flight.start)/500),v=t*t*(3-2*t);camera.position.lerpVectors(flight.from,flight.to,v);controls.target.lerpVectors(flight.targetFrom,flight.targetTo,v);if(t===1)flight=null;}
|
||
function save(label){label=label.trim();if(!label||label.length>24)throw Error('请输入 1–24 字的视角名称。');if(saved.length>=8)throw Error('最多保存 8 个视角,请先移除不用的视角。');const p={id:'saved-'+Date.now(),label,mode:getMode(),displayMode:getDisplayMode(),position:camera.position.toArray(),target:controls.target.toArray(),up:camera.up.toArray(),context:root.visible,roof:root.children.filter(o=>/roof|ceiling/.test(o.userData.resourceId)).every(o=>o.visible)};p.layers=Object.fromEntries(root.children.map(o=>[o.userData.resourceId,o.visible]));const next=[...saved,p];localStorage.setItem(key,JSON.stringify(next));saved=next;active=p.id;emit('视角已保存在本机浏览器');}
|
||
function remove(id){saved=saved.filter(p=>p.id!==id);localStorage.setItem(key,JSON.stringify(saved));if(active===id)active=null;emit('已移除保存的视角');}
|
||
emit('选择一个观察位置');return {visit,tick,save,remove,stop,presets};
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|