build-far-shell-city.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. // Light distant shells + exact cell-owned v8 fallback branches; no approximate boxes.
  2. import assert from 'node:assert/strict'
  3. import {readFileSync,writeFileSync,mkdirSync,existsSync} from 'node:fs'
  4. import {resolve,join,relative} from 'node:path'
  5. import draco3d from 'draco3d'
  6. import {surveyShell,verifyClosedTriangles} from './far-shell-geometry.mjs'
  7. import {packGlb,parseGlb} from './instance-repeated-meshes.mjs'
  8. const root=resolve('tools/city-massing/city/cad-bim'),out=join(root,process.argv[2]||'city-far-shell-v4'),source=join(root,'city-continuous-v8')
  9. assert.ok(!existsSync(out),'immutable generation exists')
  10. const load=p=>JSON.parse(readFileSync(p,'utf8'))
  11. const tree=load(join(source,'tileset.json')),oldManifest=load(join(source,'manifest.json')),active=load(join(root,'instanced-active-input.json')),near=load(join(root,'near-estate-pilot-input/seated-plan.json'))
  12. const bm=new Map([...active.buildings,...near.buildings].map(b=>[b.id,b])),cellNodes=new Map(),cellInfo=new Map(),shells=new Map()
  13. assert.equal(tree.root.children.length,oldManifest.decisions.length)
  14. oldManifest.decisions.forEach((c,i)=>{cellNodes.set(c.cell,tree.root.children[i]);cellInfo.set(c.cell,c)})
  15. const groupCells=[],assigned=new Set()
  16. for(const batch of ['pilot','batch-urban8000-dedup','batch-urban8000b','batch-urban8000c','batch-urban-final','batch-urban-tail','batch-unfitted-fallback']){
  17. const base=load(join(root,'lod-'+batch+'-roofed2/tileset.json'))
  18. for(const p of base.root.children){
  19. const keys=p.children.map(c=>batch+'-'+c.content.uri.replace(/-lod0\.glb$/,''))
  20. keys.forEach(k=>{assert.ok(cellNodes.has(k),k);assert.ok(!assigned.has(k),k);assigned.add(k)})
  21. groupCells.push(keys)
  22. }
  23. }
  24. for(const key of cellNodes.keys())if(!assigned.has(key)){assert.ok(key.startsWith('near-estate-pilot-v6-'));groupCells.push([key]);assigned.add(key)}
  25. assert.equal(assigned.size,532)
  26. const rejected=process.argv[3]?load(resolve(process.argv[3])):null
  27. const unsafeCells=new Set(rejected?.cells||[]),unsafeBuildings=[]
  28. for(const key of unsafeCells)assert.ok(cellNodes.has(key),'unknown rejected cell '+key)
  29. for(const [key,c] of cellInfo)for(const id of c.buildingIds){
  30. assert.ok(bm.has(id),id)
  31. try{const shell=surveyShell(bm.get(id));verifyClosedTriangles([...shell.roof,...shell.body]);shells.set(id,shell)}
  32. catch(error){unsafeCells.add(key);unsafeBuildings.push({id,cell:key,reason:error.message})}
  33. }
  34. const cost=key=>cellInfo.get(key).buildingIds.reduce((s,id)=>s+shells.get(id).triangles,0)
  35. function split(keys){
  36. if(keys.reduce((s,k)=>s+cost(k),0)<=20000)return[keys]
  37. assert.ok(keys.length>1,'single shell cell over budget')
  38. const center=k=>{const t=cellNodes.get(k),v=t.boundingVolume.sphere;assert.ok(v);return v.slice(0,3)}
  39. const span=[0,1].map(a=>Math.max(...keys.map(k=>center(k)[a]))-Math.min(...keys.map(k=>center(k)[a])))
  40. const axis=span[0]>span[1]?0:1,sorted=[...keys].sort((a,b)=>center(a)[axis]-center(b)[axis]),mid=sorted.length>>1
  41. return [...split(sorted.slice(0,mid)),...split(sorted.slice(mid))]
  42. }
  43. const groups=groupCells.map(keys=>keys.filter(k=>!unsafeCells.has(k))).filter(keys=>keys.length).flatMap(split),encoderModule=await draco3d.createEncoderModule({}),decoder=await draco3d.createDecoderModule({}),children=[],records=[],seen=new Set()
  44. mkdirSync(out,{recursive:true})
  45. function rebase(t){const clone=structuredClone(t);if(t.content)clone.content.uri=relative(out,resolve(source,t.content.uri)).replaceAll('\\','/');if(t.children)clone.children=t.children.map(rebase);delete clone.viewerRequestVolume;return clone}
  46. function ancestorBounds(keys,ids){const min=[Infinity,Infinity,Infinity],max=[-Infinity,-Infinity,-Infinity]
  47. for(const key of keys){const sphere=cellNodes.get(key).boundingVolume.sphere;for(let a=0;a<3;a++){min[a]=Math.min(min[a],sphere[a]-sphere[3]);max[a]=Math.max(max[a],sphere[a]+sphere[3])}}
  48. for(const id of ids)for(const tri of [...shells.get(id).roof,...shells.get(id).body])for(const p of tri)for(let a=0;a<3;a++){min[a]=Math.min(min[a],p[a]);max[a]=Math.max(max[a],p[a])}
  49. return {min,max}}
  50. for(let index=0;index<groups.length;index++){
  51. const keys=groups[index],ids=keys.flatMap(k=>cellInfo.get(k).buildingIds),bounds=ancestorBounds(keys,ids),anchor=bounds.min.map((v,a)=>(v+bounds.max[a])/2)
  52. ids.forEach(id=>{assert.ok(!seen.has(id));seen.add(id)})
  53. const chunks=[],views=[],accessors=[],primitives=[];let offset=0,triangles=0,vertices=0
  54. for(const kind of ['body','roof']){
  55. const positions=[],normals=[],indices=[]
  56. for(const id of ids)for(const tri of shells.get(id)[kind]){
  57. const ps=tri.map(p=>[p[0]-anchor[0],p[2]-anchor[2],-(p[1]-anchor[1])]),a=ps[1].map((v,i)=>v-ps[0][i]),b=ps[2].map((v,i)=>v-ps[0][i]),cross=[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],n=Math.hypot(...cross)
  58. assert.ok(n>1e-10,id+' degenerate shell face');const normal=cross.map(v=>v/n),start=positions.length/3
  59. ps.forEach(p=>{positions.push(...p);normals.push(...normal)});indices.push(start,start+1,start+2)
  60. }
  61. const builder=new encoderModule.MeshBuilder(),mesh=new encoderModule.Mesh(),encoder=new encoderModule.Encoder(),encoded=new encoderModule.DracoInt8Array()
  62. try{
  63. const positionId=builder.AddFloatAttributeToMesh(mesh,encoderModule.POSITION,positions.length/3,3,new Float32Array(positions)),normalId=builder.AddFloatAttributeToMesh(mesh,encoderModule.NORMAL,positions.length/3,3,new Float32Array(normals))
  64. builder.AddFacesToMesh(mesh,indices.length/3,new Uint32Array(indices));encoder.SetAttributeQuantization(encoderModule.POSITION,24);encoder.SetAttributeQuantization(encoderModule.NORMAL,12);encoder.SetSpeedOptions(5,5)
  65. const len=encoder.EncodeMeshToDracoBuffer(mesh,encoded);assert.ok(len>0)
  66. const bytes=Buffer.from(Array.from({length:len},(_,i)=>encoded.GetValue(i))),pad=(-offset)&3
  67. if(pad){chunks.push(Buffer.alloc(pad));offset+=pad}
  68. const vi=views.length;views.push({buffer:0,byteOffset:offset,byteLength:len});chunks.push(bytes);offset+=len
  69. // Draco may merge identical corners; decoder determines final accessor count below.
  70. const positionAccessor=accessors.length
  71. accessors.push({componentType:5126,type:'VEC3',count:0,min:[0,1,2].map(a=>Math.min(...positions.filter((_,i)=>i%3===a))),max:[0,1,2].map(a=>Math.max(...positions.filter((_,i)=>i%3===a)))})
  72. accessors.push({componentType:5126,type:'VEC3',count:0});accessors.push({componentType:5125,type:'SCALAR',count:indices.length})
  73. primitives.push({attributes:{POSITION:positionAccessor,NORMAL:positionAccessor+1},indices:positionAccessor+2,material:kind==='body'?0:1,extensions:{KHR_draco_mesh_compression:{bufferView:vi,attributes:{POSITION:positionId,NORMAL:normalId}}}})
  74. triangles+=indices.length/3
  75. }finally{encoderModule.destroy(encoded);encoderModule.destroy(encoder);encoderModule.destroy(mesh);encoderModule.destroy(builder)}
  76. }
  77. // Read actual compressed point count rather than assuming source corner counts.
  78. let decodedValid=true
  79. for(const p of primitives){const ext=p.extensions.KHR_draco_mesh_compression,v=views[ext.bufferView],bin=Buffer.concat(chunks),data=bin.subarray(v.byteOffset,v.byteOffset+v.byteLength),dec=new decoder.Decoder(),db=new decoder.DecoderBuffer(),m=new decoder.Mesh();let status
  80. try{
  81. db.Init(new Int8Array(data.buffer,data.byteOffset,data.byteLength),data.byteLength);status=dec.DecodeBufferToMesh(db,m);assert.ok(status.ok())
  82. const n=m.num_points(),f=m.num_faces();accessors[p.attributes.POSITION].count=n;accessors[p.attributes.NORMAL].count=n;vertices+=n
  83. function values(semantic){const ptr=decoder._malloc(n*12);try{assert.ok(dec.GetAttributeDataArrayForAllPoints(m,dec.GetAttributeByUniqueId(m,ext.attributes[semantic]),decoder.DT_FLOAT32,n*12,ptr));return new Float32Array(decoder.HEAPF32.buffer,ptr,n*3).slice()}finally{decoder._free(ptr)}}
  84. const positions=values('POSITION'),normals=values('NORMAL'),ptr=decoder._malloc(f*12)
  85. let indices;try{assert.ok(dec.GetTrianglesUInt32Array(m,f*12,ptr));indices=new Uint32Array(decoder.HEAPU32.buffer,ptr,f*3).slice()}finally{decoder._free(ptr)}
  86. for(let i=0;i<indices.length;i+=3){const ids=indices.slice(i,i+3),ps=[...ids].map(j=>positions.slice(j*3,j*3+3)),a=ps[1].map((v,k)=>v-ps[0][k]),b=ps[2].map((v,k)=>v-ps[0][k]),cross=[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],length=Math.hypot(...cross)
  87. if(length<=1e-10){decodedValid=false;break}
  88. for(const j of ids){const normal=normals.slice(j*3,j*3+3),dot=normal.reduce((s,v,k)=>s+v*cross[k],0)/length/Math.hypot(...normal);if(!Number.isFinite(dot)||dot<0.9)decodedValid=false}
  89. }
  90. }finally{if(status)decoder.destroy(status);decoder.destroy(m);decoder.destroy(db);decoder.destroy(dec)}
  91. }
  92. if(!decodedValid){keys.forEach(k=>unsafeCells.add(k));ids.forEach(id=>seen.delete(id));continue}
  93. assert.ok(triangles<=20000&&vertices<=60000)
  94. const glb={asset:{version:'2.0',generator:'Closed surveyed low-cost distant shells'},extensionsUsed:['KHR_draco_mesh_compression'],extensionsRequired:['KHR_draco_mesh_compression'],scene:0,scenes:[{nodes:[0]}],nodes:[{mesh:0,translation:[anchor[0],anchor[2],-anchor[1]]}],meshes:[{primitives}],
  95. materials:[{name:'Far_Wall',doubleSided:false,pbrMetallicRoughness:{baseColorFactor:[0.68,0.64,0.55,1],metallicFactor:0,roughnessFactor:0.9}},{name:'Far_Roof',doubleSided:false,pbrMetallicRoughness:{baseColorFactor:[0.26,0.30,0.30,1],metallicFactor:0,roughnessFactor:0.9}}],accessors,bufferViews:views,buffers:[{byteLength:offset}]}
  96. const bytes=packGlb(glb,Buffer.concat(chunks)),uri='shell-'+String(index).padStart(3,'0')+'.glb';writeFileSync(join(out,uri),bytes)
  97. const radius=Math.hypot(...bounds.min.map((v,a)=>(bounds.max[a]-v)/2)),local=keys.flatMap(k=>{const t=rebase(cellNodes.get(k));return t.content?[t]:t.children})
  98. children.push({boundingVolume:{sphere:[...anchor,radius]},geometricError:192,refine:'REPLACE',viewerRequestVolume:{sphere:[...anchor,radius+14000]},content:{uri},children:local})
  99. records.push({uri,level:'shell',triangles,vertices,bytes:bytes.length,buildingIds:ids,sourceCells:keys,boundsENU:bounds,primitives:2})
  100. }
  101. const fallbackCells=[...unsafeCells].map(key=>({cell:key,buildingIds:cellInfo.get(key).buildingIds}))
  102. for(const c of fallbackCells){const t=rebase(cellNodes.get(c.cell));t.extras={fallbackSourceCell:c.cell};children.push(t);for(const id of c.buildingIds){assert.ok(!seen.has(id));seen.add(id)}}
  103. assert.equal(seen.size,30172)
  104. const manifest={buildings:seen.size,fallbackCells,unsafeBuildings,shellFiles:records.length,shellTriangles:records.reduce((s,r)=>s+r.triangles,0),shellVertices:records.reduce((s,r)=>s+r.vertices,0),shellBytes:records.reduce((s,r)=>s+r.bytes,0),shellPrimitives:records.length*2,
  105. source:'city-continuous-v8',records:[...records,...oldManifest.records.map(r=>({...r,uri:relative(out,resolve(source,r.uri)).replaceAll('\\','/')}))],decisions:oldManifest.decisions,
  106. errors:{shell:192,far:128,near:48},far:oldManifest.far,near:oldManifest.near,fine:oldManifest.fine,closedRoofCaps:true,
  107. caveat:'Far-only neutral palette, no windows/interior details; exact GIS ring/top/bottom. Cell/fine geometry unchanged. No FPS/render claim.'}
  108. const rootMin=[Infinity,Infinity,Infinity],rootMax=[-Infinity,-Infinity,-Infinity]
  109. for(const t of children){const s=t.boundingVolume.sphere;assert.ok(s);for(let a=0;a<3;a++){rootMin[a]=Math.min(rootMin[a],s[a]-s[3]);rootMax[a]=Math.max(rootMax[a],s[a]+s[3])}}
  110. const rc=rootMin.map((v,a)=>(v+rootMax[a])/2),rh=rootMin.map((v,a)=>(rootMax[a]-v)/2)
  111. writeFileSync(join(out,'tileset.json'),JSON.stringify({...tree,asset:{version:'1.1',generator:'Low-cost far survey shells with continuous local replacement'},root:{...tree.root,boundingVolume:{box:[...rc,rh[0],0,0,0,rh[1],0,0,0,rh[2]]},children}},null,2))
  112. writeFileSync(join(out,'manifest.json'),JSON.stringify(manifest,null,2))
  113. console.log(JSON.stringify({out,buildings:manifest.buildings,shellFiles:manifest.shellFiles,shellTriangles:manifest.shellTriangles,shellBytes:manifest.shellBytes,shellPrimitives:manifest.shellPrimitives}))