/** Cesium-only adapter: terrain - virtual depth, ENU tiles, glTF GPU instance streams. */ import { Cartesian3, Cartographic, Color, Matrix4, Model, Quaternion, Transforms, sampleTerrainMostDetailed } from 'cesium' import { planPipeModels, PIPE_MODEL_NOTE } from '../domain/pipeModels.js' import { createPipeModelBatches } from './pipeModelBatches.js' import { enuToGltf, instanceGltf } from './pipeInstanceGltf.js' const templates = new Map() const DEG = Math.PI / 180 const TILE_DEG = 0.01 const COLORS = { GAS:'#ff9d4d', WATER_SUPPLY:'#4db8ff', DRAINAGE:'#7c6cff', MANHOLE:'#8fa3ad' } const gltfCoordinates = (point) => enuToGltf([point.x, point.y, point.z]) async function templateOf(kind, base) { const url = `${base}cockpit/models/pipes/${kind}.gltf` if (!templates.has(url)) { const promise = fetch(url).then(async (response) => { if (!response.ok) throw new Error(`管件素材 ${response.status}`) const gltf = await response.json() if (gltf.asset?.version !== '2.0' || gltf.nodes?.length !== 1 || !gltf.meshes?.length) throw new Error('Invalid pipe template') for (const buffer of gltf.buffers || []) { const resource = new URL(buffer.uri, new URL(url, window.location.href)) if (resource.origin !== window.location.origin) throw new Error('Pipe buffer must be same-origin') buffer.uri = resource.href } return gltf }).catch((error) => { templates.delete(url); throw error }) templates.set(url, promise) } return templates.get(url) } function rotationFromZ(direction) { const length = Cartesian3.magnitude(direction) const unit = Cartesian3.divideByScalar(direction, length, new Cartesian3()) const dot = Math.max(-1, Math.min(1, unit.z)) if (dot > 0.99999999) return [0,0,0,1] const axis = dot < -0.99999999 ? Cartesian3.UNIT_X : Cartesian3.normalize(Cartesian3.cross(Cartesian3.UNIT_Z, unit, new Cartesian3()), new Cartesian3()) const q = Quaternion.fromAxisAngle(axis, Math.acos(dot), new Quaternion()) return [q.x,q.y,q.z,q.w] } /** Geographic subspans lie ON the original path; not smoothing and not connection inference. */ function terrainSpans(instance) { if (instance.kind !== 'spline') return [instance] const a=instance.start,b=instance.end const approximateM=Math.hypot((b[0]-a[0])*Math.cos(a[1]*DEG),(b[1]-a[1]))*111320 const count=Math.max(1,Math.ceil(approximateM/20)) const point=(t)=>[a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t] return Array.from({length:count},(_,i)=>({...instance,start:point(i/count),end:point((i+1)/count)})) } async function groupsFor(viewer, plan, heights, alive) { const rows=plan.instances.flatMap(terrainSpans) const needed=new Map() const keyOf=(p)=>`${p[0]},${p[1]}` for(const row of rows) for(const point of row.kind==='spline'?[row.start,row.end]:[row.position]) { const key=keyOf(point) if(!heights.has(key)) needed.set(key,point) } const entries=[...needed] for(let i=0;iCartographic.fromDegrees(p[0],p[1]))) if(!alive()) return [] for(let j=0;j{ const height=heights.get(keyOf(p)) return Number.isFinite(height)?Cartesian3.fromDegrees(p[0],p[1],height-plan.virtualDepthM):null } const groups=new Map(), missing=new Set() for(const row of rows) { const p=row.start||row.position const tileX=Math.floor(p[0]/TILE_DEG),tileY=Math.floor(p[1]/TILE_DEG) const key=`${tileX}:${tileY}:${row.kind}:${row.industry||'unknown'}` const origin=[(tileX+.5)*TILE_DEG,(tileY+.5)*TILE_DEG] const matrix=Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(...origin)) const inverse=Matrix4.inverseTransformation(matrix,new Matrix4()) const a=world(p),b=row.end?world(row.end):null if(!a||(row.end&&!b)){missing.add(row.id);continue} const local=Matrix4.multiplyByPoint(inverse,a,new Cartesian3()) let translation=gltfCoordinates(local),rotation=[0,0,0,1],scale=[row.sizeM,row.sizeM,row.sizeM] if(row.kind==='spline') { const end=Matrix4.multiplyByPoint(inverse,b,new Cartesian3()),delta=Cartesian3.subtract(end,local,new Cartesian3()) const length=Cartesian3.magnitude(delta) if(length<0.001){missing.add(row.id);continue} rotation=rotationFromZ(new Cartesian3(...gltfCoordinates(delta))) scale=[row.diameterM,row.diameterM,length] } if(!groups.has(key)) groups.set(key,{key,kind:row.kind,industry:row.industry,origin,instances:[]}) groups.get(key).instances.push({id:row.id,translation,rotation,scale}) } // One missing span keeps its entire business pipe's fallback visible. return { groups:[...groups.values()], missing } } export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=import.meta.env?.BASE_URL||'/' }={}) { const heights=new Map() let active=true,generation=0,fingerprint='',missing=new Set(),allIds=[],task=null,planning=false let lastGroups=[], signatures=new Map(), terrainFailed=false, lastTerrainAttempt=0 const publish=(state)=>{ if(!active) return const covered=new Set(state.covered.filter((id)=>!missing.has(id))) for(const entity of source.entities.values) { const show=!covered.has(entity.id) if(entity.show!==show) entity.show=show } onChange({...state,covered:[...covered],fallback:allIds.length-covered.size,planning,terrainFailed,note:PIPE_MODEL_NOTE}) viewer.scene.requestRender() } const batches=createPipeModelBatches({ load:async(group,events)=>{ const template=await templateOf(group.kind,base) if (!active || !events.alive() || viewer.isDestroyed()) throw new Error('Cancelled pipe batch') const gltf=instanceGltf(template,group.instances) // Use a public URL/Resource entry, not Cesium's private options.gltf JSON hook. const url=URL.createObjectURL(new Blob([JSON.stringify(gltf)],{type:'model/gltf+json'})) let model try { model=await Model.fromGltfAsync({url,modelMatrix:Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(...group.origin)), id:{kind:'pipe-model-batch',key:group.key,instances:group.instances.map((row)=>row.id)},incrementallyLoadTextures:false, color:group.kind==='spline'?Color.fromCssColorString(COLORS[group.industry]||'#8fa3ad'):Color.WHITE}) const release=()=>{ URL.revokeObjectURL(url) } model.readyEvent.addEventListener(()=>{release();events.ready()}) model.errorEvent.addEventListener(()=>{release();events.error()}) if(!active || !events.alive() || viewer.isDestroyed()){model.destroy();throw new Error('Disposed pipe batch')} viewer.scene.primitives.add(model) viewer.scene.requestRender() return {model,url} } catch(error){URL.revokeObjectURL(url);throw error} }, remove:({model,url})=>{ URL.revokeObjectURL(url) if(!viewer.isDestroyed()&&!model.isDestroyed()) viewer.scene.primitives.remove(model) else if(!model.isDestroyed()) model.destroy() }, onChange:publish, }) function sync(network) { if (!active || viewer.isDestroyed()) return Promise.resolve() const plan=planPipeModels(network) const next=JSON.stringify(plan) if(next===fingerprint && !(terrainFailed && Date.now()-lastTerrainAttempt>=30000)) { if (planning) return task task=batches.sync(lastGroups) publish(batches.state()) return task } fingerprint=next const token=++generation const previousSignatures=signatures const byId=new Map() for(const row of plan.instances) { if(!byId.has(row.id)) byId.set(row.id,[]) byId.get(row.id).push(row) } signatures=new Map([...byId].map(([id,rows])=>[id,JSON.stringify(rows)])) allIds=[...(network.pipes||[]).map((p)=>`pipe:${p.code}`),...(network.components||[]).map((c)=>`component:${c.code}`)] planning=true // Remove only changed/removed batches before awaits. Keep stable geometry visible. batches.retain((group)=>group.instances.every((row)=>signatures.has(row.id)&&signatures.get(row.id)===previousSignatures.get(row.id))) task=(async()=>{ const alive=()=>active&&token===generation&&!viewer.isDestroyed() lastTerrainAttempt=Date.now() const result=await groupsFor(viewer,plan,heights,alive) if(!alive()) return missing=result.missing terrainFailed=missing.size>0 for(const row of plan.fallbacks) missing.add(row.id) lastGroups=result.groups planning=false await batches.sync(lastGroups) if(alive()) publish(batches.state()) })().catch(()=>{if(active&&token===generation){terrainFailed=true;planning=false;publish(batches.state())}}) return task } function destroy() { active=false;generation+=1 batches.clear() heights.clear() } return {sync,destroy,state:batches.state} }