| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186 |
- /** 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;i<entries.length;i+=512) {
- if(!alive()) return []
- const chunk=entries.slice(i,i+512)
- try {
- const values=await sampleTerrainMostDetailed(viewer.terrainProvider,chunk.map(([,p])=>Cartographic.fromDegrees(p[0],p[1])))
- if(!alive()) return []
- for(let j=0;j<chunk.length;j+=1) if(Number.isFinite(values[j]?.height)) heights.set(chunk[j][0],values[j].height)
- } catch { /* No invented ground elevation: retain fallback. */ }
- }
- const world=(p)=>{
- 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}
- }
|