cesiumPipeModels.js 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. /** Cesium-only adapter: terrain - virtual depth, ENU tiles, glTF GPU instance streams. */
  2. import { Cartesian3, Cartographic, Color, Matrix4, Model, Quaternion, Transforms, sampleTerrainMostDetailed } from 'cesium'
  3. import { planPipeModels, PIPE_MODEL_NOTE } from '../domain/pipeModels.js'
  4. import { createPipeModelBatches } from './pipeModelBatches.js'
  5. import { enuToGltf, instanceGltf } from './pipeInstanceGltf.js'
  6. const templates = new Map()
  7. const DEG = Math.PI / 180
  8. const TILE_DEG = 0.01
  9. const COLORS = { GAS:'#ff9d4d', WATER_SUPPLY:'#4db8ff', DRAINAGE:'#7c6cff', MANHOLE:'#8fa3ad' }
  10. const gltfCoordinates = (point) => enuToGltf([point.x, point.y, point.z])
  11. async function templateOf(kind, base) {
  12. const url = `${base}cockpit/models/pipes/${kind}.gltf`
  13. if (!templates.has(url)) {
  14. const promise = fetch(url).then(async (response) => {
  15. if (!response.ok) throw new Error(`管件素材 ${response.status}`)
  16. const gltf = await response.json()
  17. if (gltf.asset?.version !== '2.0' || gltf.nodes?.length !== 1 || !gltf.meshes?.length) throw new Error('Invalid pipe template')
  18. for (const buffer of gltf.buffers || []) {
  19. const resource = new URL(buffer.uri, new URL(url, window.location.href))
  20. if (resource.origin !== window.location.origin) throw new Error('Pipe buffer must be same-origin')
  21. buffer.uri = resource.href
  22. }
  23. return gltf
  24. }).catch((error) => { templates.delete(url); throw error })
  25. templates.set(url, promise)
  26. }
  27. return templates.get(url)
  28. }
  29. function rotationFromZ(direction) {
  30. const length = Cartesian3.magnitude(direction)
  31. const unit = Cartesian3.divideByScalar(direction, length, new Cartesian3())
  32. const dot = Math.max(-1, Math.min(1, unit.z))
  33. if (dot > 0.99999999) return [0,0,0,1]
  34. const axis = dot < -0.99999999 ? Cartesian3.UNIT_X : Cartesian3.normalize(Cartesian3.cross(Cartesian3.UNIT_Z, unit, new Cartesian3()), new Cartesian3())
  35. const q = Quaternion.fromAxisAngle(axis, Math.acos(dot), new Quaternion())
  36. return [q.x,q.y,q.z,q.w]
  37. }
  38. /** Geographic subspans lie ON the original path; not smoothing and not connection inference. */
  39. function terrainSpans(instance) {
  40. if (instance.kind !== 'spline') return [instance]
  41. const a=instance.start,b=instance.end
  42. const approximateM=Math.hypot((b[0]-a[0])*Math.cos(a[1]*DEG),(b[1]-a[1]))*111320
  43. const count=Math.max(1,Math.ceil(approximateM/20))
  44. const point=(t)=>[a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t]
  45. return Array.from({length:count},(_,i)=>({...instance,start:point(i/count),end:point((i+1)/count)}))
  46. }
  47. async function groupsFor(viewer, plan, heights, alive) {
  48. const rows=plan.instances.flatMap(terrainSpans)
  49. const needed=new Map()
  50. const keyOf=(p)=>`${p[0]},${p[1]}`
  51. for(const row of rows) for(const point of row.kind==='spline'?[row.start,row.end]:[row.position]) {
  52. const key=keyOf(point)
  53. if(!heights.has(key)) needed.set(key,point)
  54. }
  55. const entries=[...needed]
  56. for(let i=0;i<entries.length;i+=512) {
  57. if(!alive()) return []
  58. const chunk=entries.slice(i,i+512)
  59. try {
  60. const values=await sampleTerrainMostDetailed(viewer.terrainProvider,chunk.map(([,p])=>Cartographic.fromDegrees(p[0],p[1])))
  61. if(!alive()) return []
  62. for(let j=0;j<chunk.length;j+=1) if(Number.isFinite(values[j]?.height)) heights.set(chunk[j][0],values[j].height)
  63. } catch { /* No invented ground elevation: retain fallback. */ }
  64. }
  65. const world=(p)=>{
  66. const height=heights.get(keyOf(p))
  67. return Number.isFinite(height)?Cartesian3.fromDegrees(p[0],p[1],height-plan.virtualDepthM):null
  68. }
  69. const groups=new Map(), missing=new Set()
  70. for(const row of rows) {
  71. const p=row.start||row.position
  72. const tileX=Math.floor(p[0]/TILE_DEG),tileY=Math.floor(p[1]/TILE_DEG)
  73. const key=`${tileX}:${tileY}:${row.kind}:${row.industry||'unknown'}`
  74. const origin=[(tileX+.5)*TILE_DEG,(tileY+.5)*TILE_DEG]
  75. const matrix=Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(...origin))
  76. const inverse=Matrix4.inverseTransformation(matrix,new Matrix4())
  77. const a=world(p),b=row.end?world(row.end):null
  78. if(!a||(row.end&&!b)){missing.add(row.id);continue}
  79. const local=Matrix4.multiplyByPoint(inverse,a,new Cartesian3())
  80. let translation=gltfCoordinates(local),rotation=[0,0,0,1],scale=[row.sizeM,row.sizeM,row.sizeM]
  81. if(row.kind==='spline') {
  82. const end=Matrix4.multiplyByPoint(inverse,b,new Cartesian3()),delta=Cartesian3.subtract(end,local,new Cartesian3())
  83. const length=Cartesian3.magnitude(delta)
  84. if(length<0.001){missing.add(row.id);continue}
  85. rotation=rotationFromZ(new Cartesian3(...gltfCoordinates(delta)))
  86. scale=[row.diameterM,row.diameterM,length]
  87. }
  88. if(!groups.has(key)) groups.set(key,{key,kind:row.kind,industry:row.industry,origin,instances:[]})
  89. groups.get(key).instances.push({id:row.id,translation,rotation,scale})
  90. }
  91. // One missing span keeps its entire business pipe's fallback visible.
  92. return { groups:[...groups.values()], missing }
  93. }
  94. export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=import.meta.env?.BASE_URL||'/' }={}) {
  95. const heights=new Map()
  96. let active=true,generation=0,fingerprint='',missing=new Set(),allIds=[],task=null,planning=false
  97. let lastGroups=[], signatures=new Map(), terrainFailed=false, lastTerrainAttempt=0
  98. const publish=(state)=>{
  99. if(!active) return
  100. const covered=new Set(state.covered.filter((id)=>!missing.has(id)))
  101. for(const entity of source.entities.values) {
  102. const show=!covered.has(entity.id)
  103. if(entity.show!==show) entity.show=show
  104. }
  105. onChange({...state,covered:[...covered],fallback:allIds.length-covered.size,planning,terrainFailed,note:PIPE_MODEL_NOTE})
  106. viewer.scene.requestRender()
  107. }
  108. const batches=createPipeModelBatches({
  109. load:async(group,events)=>{
  110. const template=await templateOf(group.kind,base)
  111. if (!active || !events.alive() || viewer.isDestroyed()) throw new Error('Cancelled pipe batch')
  112. const gltf=instanceGltf(template,group.instances)
  113. // Use a public URL/Resource entry, not Cesium's private options.gltf JSON hook.
  114. const url=URL.createObjectURL(new Blob([JSON.stringify(gltf)],{type:'model/gltf+json'}))
  115. let model
  116. try {
  117. model=await Model.fromGltfAsync({url,modelMatrix:Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(...group.origin)),
  118. id:{kind:'pipe-model-batch',key:group.key,instances:group.instances.map((row)=>row.id)},incrementallyLoadTextures:false,
  119. color:group.kind==='spline'?Color.fromCssColorString(COLORS[group.industry]||'#8fa3ad'):Color.WHITE})
  120. const release=()=>{ URL.revokeObjectURL(url) }
  121. model.readyEvent.addEventListener(()=>{release();events.ready()})
  122. model.errorEvent.addEventListener(()=>{release();events.error()})
  123. if(!active || !events.alive() || viewer.isDestroyed()){model.destroy();throw new Error('Disposed pipe batch')}
  124. viewer.scene.primitives.add(model)
  125. viewer.scene.requestRender()
  126. return {model,url}
  127. } catch(error){URL.revokeObjectURL(url);throw error}
  128. },
  129. remove:({model,url})=>{
  130. URL.revokeObjectURL(url)
  131. if(!viewer.isDestroyed()&&!model.isDestroyed()) viewer.scene.primitives.remove(model)
  132. else if(!model.isDestroyed()) model.destroy()
  133. },
  134. onChange:publish,
  135. })
  136. function sync(network) {
  137. if (!active || viewer.isDestroyed()) return Promise.resolve()
  138. const plan=planPipeModels(network)
  139. const next=JSON.stringify(plan)
  140. if(next===fingerprint && !(terrainFailed && Date.now()-lastTerrainAttempt>=30000)) {
  141. if (planning) return task
  142. task=batches.sync(lastGroups)
  143. publish(batches.state())
  144. return task
  145. }
  146. fingerprint=next
  147. const token=++generation
  148. const previousSignatures=signatures
  149. const byId=new Map()
  150. for(const row of plan.instances) {
  151. if(!byId.has(row.id)) byId.set(row.id,[])
  152. byId.get(row.id).push(row)
  153. }
  154. signatures=new Map([...byId].map(([id,rows])=>[id,JSON.stringify(rows)]))
  155. allIds=[...(network.pipes||[]).map((p)=>`pipe:${p.code}`),...(network.components||[]).map((c)=>`component:${c.code}`)]
  156. planning=true
  157. // Remove only changed/removed batches before awaits. Keep stable geometry visible.
  158. batches.retain((group)=>group.instances.every((row)=>signatures.has(row.id)&&signatures.get(row.id)===previousSignatures.get(row.id)))
  159. task=(async()=>{
  160. const alive=()=>active&&token===generation&&!viewer.isDestroyed()
  161. lastTerrainAttempt=Date.now()
  162. const result=await groupsFor(viewer,plan,heights,alive)
  163. if(!alive()) return
  164. missing=result.missing
  165. terrainFailed=missing.size>0
  166. for(const row of plan.fallbacks) missing.add(row.id)
  167. lastGroups=result.groups
  168. planning=false
  169. await batches.sync(lastGroups)
  170. if(alive()) publish(batches.state())
  171. })().catch(()=>{if(active&&token===generation){terrainFailed=true;planning=false;publish(batches.state())}})
  172. return task
  173. }
  174. function destroy() {
  175. active=false;generation+=1
  176. batches.clear()
  177. heights.clear()
  178. }
  179. return {sync,destroy,state:batches.state}
  180. }