build-progressive-instanced-city.mjs 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. // One formal city tree: cell-owned distant coarse -> bounded local coarse/fine pairs.
  2. // Use instance candidates only where offline payload AND primitive counts beat the
  3. // old fine partitions; otherwise reuse the original immutable branch.
  4. import assert from 'node:assert/strict'
  5. import {readFileSync,writeFileSync,mkdirSync,existsSync} from 'node:fs'
  6. import {resolve,join,relative} from 'node:path'
  7. import {parseGlb} from './instance-repeated-meshes.mjs'
  8. const root=resolve('tools/city-massing/city/cad-bim'),inst=resolve(process.argv[2]),out=resolve(process.argv[3])
  9. assert.ok(!existsSync(out),'immutable output exists')
  10. const im=JSON.parse(readFileSync(join(inst,'manifest.json'))),it=JSON.parse(readFileSync(join(inst,'tileset.json')))
  11. const current=JSON.parse(readFileSync(join(root,'city-continuous-v5/manifest.json')))
  12. const groups=new Map(),nodes=new Map(it.root.children.map(t=>[t.children[0].content.uri,t]))
  13. for(const c of im.cells){if(!groups.has(c.sourceCell))groups.set(c.sourceCell,[]);groups.get(c.sourceCell).push(c)}
  14. const children=[],coverage=new Set(),records=[],decisions=[]
  15. let farFiles=0,nearFiles=0,fineFiles=0
  16. function load(p){return JSON.parse(readFileSync(p))}
  17. function uri(dir,p){return relative(out,resolve(dir,p)).replaceAll('\\','/')}
  18. function rebase(tile,dir){
  19. const t=structuredClone(tile)
  20. if(t.content)t.content.uri=uri(dir,t.content.uri)
  21. if(t.children?.length){
  22. t.children=t.children.map(c=>rebase(c,dir))
  23. if(t.boundingVolume.box){const b=t.boundingVolume.box;t.boundingVolume={sphere:[...b.slice(0,3),Math.hypot(b[3],b[7],b[11])]}}
  24. t.geometricError=1000000;t.refine='REPLACE'
  25. }
  26. return t
  27. }
  28. function countPrimitives(path){const {json}=parseGlb(readFileSync(path));return json.nodes.reduce((s,n)=>s+(n.mesh===undefined?0:json.meshes[n.mesh].primitives.length),0)}
  29. function sphere(bounds){const center=bounds.min.map((v,a)=>(v+bounds.max[a])/2),half=bounds.min.map((v,a)=>(bounds.max[a]-v)/2);return [...center,Math.hypot(...half)]}
  30. for(const s of current.sources){
  31. const dir=join(root,s.source),sm=load(join(dir,'manifest.json')),st=load(join(dir,'tileset.json'))
  32. const batch=s.source.split('/')[0],baseDir=join(root,batch),base=load(join(baseDir,'manifest.json'))
  33. const baseCells=new Map(base.cells.map(c=>[c.id,c]))
  34. assert.equal(sm.cells.length,st.root.children.length)
  35. for(let index=0;index<sm.cells.length;index++){
  36. const original=sm.cells[index],baseCell=baseCells.get(original.id)
  37. assert.ok(baseCell)
  38. for(const id of original.buildingIds){assert.ok(!coverage.has(id));coverage.add(id)}
  39. const key=batch.replace(/^lod-/,'').replace(/-roofed2$/,'')+'-'+original.id
  40. const candidates=groups.get(key)||[]
  41. const oldFiles=[]
  42. function fine(t){if(t.content?.uri?.endsWith('-lod0.glb'))oldFiles.push(t.content.uri);for(const c of t.children||[])fine(c)}
  43. fine(st.root.children[index])
  44. const oldBytes=oldFiles.reduce((a,p)=>a+readFileSync(join(dir,p)).length,0)
  45. const oldPrimitives=oldFiles.reduce((a,p)=>a+countPrimitives(join(dir,p)),0)
  46. const candidateBytes=candidates.reduce((a,c)=>a+c.lod0.bytes,0)
  47. const candidatePrimitives=candidates.reduce((a,c)=>a+c.lod0.drawPrimitives,0)
  48. const candidateIds=candidates.flatMap(c=>c.buildingIds),candidateSet=new Set(candidateIds)
  49. const exactCoverage=candidateSet.size===candidateIds.length&&candidateSet.size===original.buildingIds.length&&original.buildingIds.every(i=>candidateSet.has(i))
  50. const use=exactCoverage&&candidates.some(c=>c.instanceGroups>0)&&candidateBytes<oldBytes&&candidatePrimitives<=oldPrimitives
  51. let local=use?candidates.map(c=>rebase(nodes.get(c.lod0.uri),inst)):rebase(st.root.children[index],dir).children
  52. const bounds=structuredClone(baseCell.boundsENU)
  53. if(use)for(const c of candidates)for(let a=0;a<3;a++){
  54. bounds.min[a]=Math.min(bounds.min[a],c.boundsENU.min[a]);bounds.max[a]=Math.max(bounds.max[a],c.boundsENU.max[a])
  55. }
  56. const volume=sphere(bounds),coarseWhole=baseCell.lod1.triangles<=20000
  57. if(coarseWhole){
  58. // Admit all local coarse branches at one shared camera gate, protecting
  59. // residual coverage when only some fine request volumes are admitted.
  60. const gate={sphere:[...volume.slice(0,3),volume[3]+900]}
  61. for(const t of local){if(t.children?.length)t.viewerRequestVolume=structuredClone(gate)}
  62. const outer={boundingVolume:{sphere:volume},geometricError:1000000,refine:'REPLACE',
  63. viewerRequestVolume:{sphere:[...volume.slice(0,3),volume[3]+6000]},
  64. content:{uri:uri(baseDir,baseCell.lod1.uri)},children:[{
  65. boundingVolume:{sphere:volume},geometricError:1000000,refine:'REPLACE',viewerRequestVolume:gate,children:local}]}
  66. children.push(outer);farFiles++
  67. records.push({uri:outer.content.uri,triangles:baseCell.lod1.triangles,level:'far',buildingIds:original.buildingIds})
  68. }else{
  69. // Exactly two legacy coarse cells exceed the whole-cell budget; retain
  70. // bounded partition granularity rather than exporting a larger GLB.
  71. children.push({boundingVolume:{sphere:volume},geometricError:1000000,refine:'REPLACE',children:local})
  72. }
  73. const localOwners=new Map()
  74. for(const t of local){
  75. const uri=t.content?.uri
  76. const owner=use?candidates.find(c=>uri?.endsWith('/'+c.lod1.uri.split('/').at(-1))):null
  77. if(use)assert.ok(owner,'missing candidate pair ownership')
  78. localOwners.set(t,owner?owner.buildingIds:original.buildingIds)
  79. }
  80. function collect(t,ownerIds){if(t.content){const level=t.children?.length?'near':'fine';if(level==='near')nearFiles++;else fineFiles++;
  81. records.push({uri:t.content.uri,level,sourceCell:key,buildingIds:ownerIds})}for(const c of t.children||[])collect(c,ownerIds)}
  82. local.forEach(t=>collect(t,localOwners.get(t)))
  83. decisions.push({cell:key,buildingIds:original.buildingIds,useInstancing:use,oldBytes,candidateBytes,oldPrimitives,candidatePrimitives,
  84. instanceGroups:use?candidates.reduce((s,c)=>s+c.instanceGroups,0):0,instancedBuildings:use?candidates.reduce((s,c)=>s+c.instancedBuildings,0):0,
  85. wholeCellCoarse:coarseWhole})
  86. }
  87. }
  88. assert.equal(coverage.size,30172)
  89. mkdirSync(out,{recursive:true})
  90. const currentTree=load(join(root,'city-continuous-v5/tileset.json'))
  91. writeFileSync(join(out,'tileset.json'),JSON.stringify({asset:{version:'1.1',generator:'Cell coarse + bounded translation-instance candidates'},geometricError:1000000,
  92. root:{transform:currentTree.root.transform,boundingVolume:currentTree.root.boundingVolume,geometricError:1000000,refine:'REPLACE',children}},null,2))
  93. const report={buildings:coverage.size,farFiles,nearFiles,fineFiles,decisions,records,sourceInstancing:inst,
  94. retainedCells:decisions.filter(d=>!d.useInstancing).length,
  95. payloadBeforeAcceptedCells:decisions.filter(d=>d.useInstancing).reduce((s,d)=>s+d.oldBytes,0),
  96. payloadAfterAcceptedCells:decisions.filter(d=>d.useInstancing).reduce((s,d)=>s+d.candidateBytes,0),
  97. primitivesBeforeAcceptedCells:decisions.filter(d=>d.useInstancing).reduce((s,d)=>s+d.oldPrimitives,0),
  98. primitivesAfterAcceptedCells:decisions.filter(d=>d.useInstancing).reduce((s,d)=>s+d.candidatePrimitives,0),
  99. candidateOnly:true,instancedCells:decisions.filter(d=>d.useInstancing).length,instancedBuildings:decisions.reduce((s,d)=>s+d.instancedBuildings,0),
  100. policy:'Far coarse +6000m, whole-cell shared local coarse gate +900m, original fine +250/new +350m; bounding sphere half-diagonal expands these camera gates. Normal-invariant shared primitives retain exact TRS, unsafe slopes keep ordinary transforms. No runtime/FPS claim.'}
  101. writeFileSync(join(out,'manifest.json'),JSON.stringify(report,null,2))
  102. console.log(JSON.stringify({out,buildings:report.buildings,farFiles,nearFiles,fineFiles,instancedCells:report.instancedCells,instancedBuildings:report.instancedBuildings}))