|
|
@@ -0,0 +1,186 @@
|
|
|
+/** 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}
|
|
|
+}
|