build-instanced-coarse-pilot.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. // Offline coarse-only instancing candidate. Never edits deployed config or fine assets.
  2. // Shared exterior wall/window quads + entrance box; survey roof/bottom caps remain unique.
  3. import assert from 'node:assert/strict'
  4. import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
  5. import { resolve, join } from 'node:path'
  6. import earcut from 'earcut'
  7. const root = resolve('tools/city-massing/city/cad-bim')
  8. const out = join(root, process.argv[2] || 'instanced-coarse-pilot-v1')
  9. assert.ok(!existsSync(join(out, 'manifest.json')), 'immutable output already exists')
  10. const plan = JSON.parse(readFileSync(join(root, 'near-estate-pilot-input/seated-plan.json'), 'utf8'))
  11. const source = JSON.parse(readFileSync(join(root, 'lod-near-estate-pilot-v6/streaming-v14/manifest.json'), 'utf8'))
  12. const buildingMap = new Map(plan.buildings.map(b => [b.id, b]))
  13. const tones = ['E4DAC4', 'F2ECDD', 'C2AF96', 'C8C7BD', '596264', '697272', '98634A', 'AC7758', '405F6D', '715747']
  14. const linear = x => x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4
  15. const materials = tones.map(hex => ({ pbrMetallicRoughness: { baseColorFactor: [0, 2, 4].map(i => linear(parseInt(hex.slice(i, i + 2), 16) / 255)).concat(1), metallicFactor: 0, roughnessFactor: 0.85 }, doubleSided: false }))
  16. function seed(id) { let v = 2166136261; for (const c of id) v = Math.imul(v ^ c.charCodeAt(0), 16777619) >>> 0; return v }
  17. function ringArea(p) { return p.reduce((sum, a, i) => { const b = p[(i + 1) % p.length]; return sum + a[0] * b[1] - b[0] * a[1] }, 0) / 2 }
  18. function cleanRing(p) {
  19. const ring = []
  20. for (const point of p) if (!ring.length || Math.hypot(point[0] - ring.at(-1)[0], point[1] - ring.at(-1)[1]) > 0.011) ring.push(point)
  21. if (ring.length > 1 && Math.hypot(ring[0][0] - ring.at(-1)[0], ring[0][1] - ring.at(-1)[1]) <= 0.011) ring.pop()
  22. return ringArea(ring) > 0 ? ring : ring.reverse()
  23. }
  24. const toGltf = ([x, y, z]) => [x, z, -y]
  25. const records = [], children = []
  26. mkdirSync(out, { recursive: true })
  27. for (const cell of source.cells) {
  28. const buildings = cell.buildingIds.map(id => { assert.ok(buildingMap.has(id), id); return buildingMap.get(id) })
  29. const chunks = [], bufferViews = [], accessors = [], meshes = [], nodes = [], groups = new Map()
  30. let length = 0, renderedTriangles = 0, storedTriangles = 0
  31. const bounds = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] }
  32. const points = buildings.flatMap(b => b.footprintENU.map(p => [...p, b.positionENU[2]]))
  33. const anchor = [0, 1, 2].map(a => (Math.min(...points.map(p => p[a])) + Math.max(...points.map(p => p[a]))) / 2)
  34. function touch(p) { p.forEach((v, a) => { bounds.min[a] = Math.min(bounds.min[a], v); bounds.max[a] = Math.max(bounds.max[a], v) }) }
  35. function accessor(values, type, integer = false) {
  36. const components = { SCALAR: 1, VEC3: 3, VEC4: 4 }[type]
  37. assert.equal(values.length % components, 0)
  38. const bytes = Buffer.alloc(values.length * 4)
  39. values.forEach((v, i) => { assert.ok(Number.isFinite(v)); integer ? bytes.writeUInt32LE(v, i * 4) : bytes.writeFloatLE(v, i * 4) })
  40. const view = bufferViews.length
  41. bufferViews.push({ buffer: 0, byteOffset: length, byteLength: bytes.length }); chunks.push(bytes); length += bytes.length
  42. const count = values.length / components, ranges = {}
  43. if (!integer && type === 'VEC3') {
  44. ranges.min = [0, 1, 2].map(a => Math.min(...values.filter((_, i) => i % 3 === a)))
  45. ranges.max = [0, 1, 2].map(a => Math.max(...values.filter((_, i) => i % 3 === a)))
  46. }
  47. accessors.push({ bufferView: view, componentType: integer ? 5125 : 5126, count, type, ...ranges })
  48. return accessors.length - 1
  49. }
  50. const quad = { positions: [-0.5,-0.5,0, 0.5,-0.5,0, 0.5,0.5,0, -0.5,0.5,0], normals: [0,0,1, 0,0,1, 0,0,1, 0,0,1], indices: [0,1,2,0,2,3] }
  51. const cube = { positions: [], normals: [], indices: [] }
  52. for (const [normal, u, v] of [
  53. [[1,0,0],[0,1,0],[0,0,1]], [[-1,0,0],[0,-1,0],[0,0,1]],
  54. [[0,1,0],[0,0,1],[1,0,0]], [[0,-1,0],[0,0,-1],[1,0,0]],
  55. [[0,0,1],[1,0,0],[0,1,0]], [[0,0,-1],[-1,0,0],[0,1,0]],
  56. ]) {
  57. const start = cube.positions.length / 3
  58. for (const [s,t] of [[-1,-1],[1,-1],[1,1],[-1,1]]) {
  59. cube.positions.push(...normal.map((x,a) => (x + s*u[a] + t*v[a]) / 2)); cube.normals.push(...normal)
  60. }
  61. cube.indices.push(start,start+1,start+2,start,start+2,start+3)
  62. }
  63. const geometry = shape => ({ attributes: { POSITION: accessor(shape.positions, 'VEC3'), NORMAL: accessor(shape.normals, 'VEC3') }, indices: accessor(shape.indices, 'SCALAR', true) })
  64. const quadPrimitive = geometry(quad), cubePrimitive = geometry(cube)
  65. function instance(kind, material, translationENU, rotation, scale, id) {
  66. assert.ok(scale.every(v => v > 0))
  67. const key = `${kind}/${material}`
  68. if (!groups.has(key)) groups.set(key, { kind, material, translations: [], rotations: [], scales: [], buildingIds: [] })
  69. const group = groups.get(key)
  70. group.translations.push(...toGltf(translationENU.map((v,a) => v - anchor[a])))
  71. group.rotations.push(0, Math.sin(rotation / 2), 0, Math.cos(rotation / 2))
  72. group.scales.push(...scale); group.buildingIds.push(id)
  73. renderedTriangles += kind === 'quad' ? 2 : 12
  74. }
  75. const caps = new Map()
  76. function triangle(material, ps, normal) {
  77. if (!caps.has(material)) caps.set(material, { positions: [], normals: [], indices: [] })
  78. const cap = caps.get(material), start = cap.positions.length / 3
  79. for (const p of ps) { touch(p); cap.positions.push(...toGltf(p.map((v,a) => v - anchor[a]))); cap.normals.push(...normal) }
  80. cap.indices.push(start,start+1,start+2); renderedTriangles++
  81. }
  82. for (const b of buildings) {
  83. const ring = cleanRing(b.footprintENU), bottom = b.foundationBottom, top = b.positionENU[2] + b.heightM
  84. const s = seed(b.id), wall = s % 4, roof = 4 + Math.floor(s / 4) % 4
  85. assert.ok(top > bottom && ringArea(ring) > 0)
  86. for (const p of ring) { touch([...p, bottom]); touch([...p, top]) }
  87. const capIndices = earcut(ring.flat())
  88. let covered = 0
  89. for (let i = 0; i < capIndices.length; i += 3) {
  90. const ps = capIndices.slice(i, i+3).map(j => ring[j])
  91. const area = ringArea(ps)
  92. if (area < 0) ps.reverse()
  93. covered += Math.abs(area)
  94. triangle(roof, ps.map(p => [...p, top]), [0,1,0])
  95. triangle(wall, ps.toReversed().map(p => [...p, bottom]), [0,-1,0])
  96. }
  97. const perimeter = ring.reduce((sum,p,i) => sum + Math.hypot(p[0]-ring[(i+1)%ring.length][0],p[1]-ring[(i+1)%ring.length][1]),0)
  98. assert.ok(Math.abs(covered - ringArea(ring)) <= Math.max(0.001, perimeter * 0.01))
  99. let longest = null
  100. const floors = Math.max(1, b.floors || Math.round(b.heightM/3.2)), floorHeight = Math.max(2.4,(top-b.positionENU[2])/floors)
  101. for (let i=0; i<ring.length; i++) {
  102. const a=ring[i], z=ring[(i+1)%ring.length], dx=z[0]-a[0], dy=z[1]-a[1], edge=Math.hypot(dx,dy), angle=Math.atan2(dy,dx)
  103. if (edge <= 0) continue
  104. const center=[(a[0]+z[0])/2,(a[1]+z[1])/2]
  105. instance('quad',wall,[...center,(bottom+top)/2],angle,[edge,top-bottom,1],b.id)
  106. if (!longest || edge > longest.edge) longest={edge,angle,center,dx,dy}
  107. if (edge < 0.35) continue
  108. for (let floor=0; floor<floors; floor++) {
  109. const z0=b.positionENU[2]+floor*floorHeight+floorHeight*0.34, z1=Math.min(top-0.08,z0+floorHeight*0.36)
  110. if (z1 <= z0) continue
  111. const half=Math.min(edge*0.68*0.22, Math.max(0.45,edge*0.68/3.5*0.42))
  112. instance('quad',8,[center[0]+dy/edge*0.035,center[1]-dx/edge*0.035,(z0+z1)/2],angle,[half*2,z1-z0,1],b.id)
  113. }
  114. }
  115. if (longest?.edge >= 0.35) {
  116. const {edge,angle,center,dx,dy}=longest, base=b.positionENU[2]+0.08
  117. const end=Math.min(top-0.05,b.positionENU[2]+Math.min(2.2,Math.max(1.8,floorHeight*0.76)))
  118. if (end>base) instance('cube',9,[center[0]+dy/edge*0.1475,center[1]-dx/edge*0.1475,(base+end)/2],angle,[Math.min(edge,3.5)*0.44,end-base,0.095],b.id)
  119. }
  120. }
  121. for (const group of groups.values()) {
  122. const primitive = group.kind === 'quad' ? quadPrimitive : cubePrimitive
  123. meshes.push({ primitives: [{ ...primitive, material: group.material }] })
  124. nodes.push({ mesh: meshes.length-1, extensions: { EXT_mesh_gpu_instancing: { attributes: {
  125. TRANSLATION: accessor(group.translations,'VEC3'), ROTATION: accessor(group.rotations,'VEC4'), SCALE: accessor(group.scales,'VEC3'),
  126. } } }, extras: { buildingIds: group.buildingIds } })
  127. storedTriangles += group.kind === 'quad' ? 2 : 12
  128. }
  129. for (const [material, cap] of caps) {
  130. meshes.push({ primitives: [{ ...geometry(cap), material }] }); nodes.push({ mesh: meshes.length-1 }); storedTriangles += cap.indices.length/3
  131. }
  132. // Conservative detail margin, not a change to survey outline or seating.
  133. bounds.min = bounds.min.map(v=>v-0.3); bounds.max=bounds.max.map(v=>v+0.3)
  134. const center=bounds.min.map((v,a)=>(v+bounds.max[a])/2), half=bounds.min.map((v,a)=>(bounds.max[a]-v)/2)
  135. const json={ asset:{version:'2.0',generator:'Surveyed coarse exterior instancing pilot'}, extensionsUsed:['EXT_mesh_gpu_instancing'], extensionsRequired:['EXT_mesh_gpu_instancing'],
  136. scene:0, scenes:[{nodes:[0]}], nodes:[{translation:toGltf(anchor),children:nodes.map((_,i)=>i+1)},...nodes], meshes,materials,accessors,bufferViews,buffers:[{byteLength:length}] }
  137. let jsonBytes=Buffer.from(JSON.stringify(json)); jsonBytes=Buffer.concat([jsonBytes,Buffer.alloc((-jsonBytes.length)&3,0x20)])
  138. const bin=Buffer.concat(chunks), header=Buffer.alloc(20), binHeader=Buffer.alloc(8)
  139. header.write('glTF');header.writeUInt32LE(2,4);header.writeUInt32LE(28+jsonBytes.length+bin.length,8);header.writeUInt32LE(jsonBytes.length,12);header.write('JSON',16)
  140. binHeader.writeUInt32LE(bin.length);binHeader.write('BIN\0',4)
  141. const bytes=Buffer.concat([header,jsonBytes,binHeader,bin]),uri=`${cell.id}-instanced-lod1.glb`
  142. writeFileSync(join(out,uri),bytes)
  143. records.push({uri,buildingIds:cell.buildingIds,bytes:bytes.length,storedTriangles,renderedTriangles,instanceCount:[...groups.values()].reduce((sum,g)=>sum+g.buildingIds.length,0),boundsENU:bounds})
  144. children.push({boundingVolume:{box:[...center,half[0],0,0,0,half[1],0,0,0,half[2]]},geometricError:0,
  145. viewerRequestVolume:{sphere:[...center,Math.hypot(...half)+5000]},content:{uri}})
  146. }
  147. writeFileSync(join(out,'tileset.json'),JSON.stringify({asset:{version:'1.1',generator:'Instanced coarse pilot only'},geometricError:1000000,
  148. root:{transform:plan.transform,boundingVolume:JSON.parse(readFileSync(join(root,'lod-near-estate-pilot-v6/streaming-v14/tileset.json'),'utf8')).root.boundingVolume,geometricError:1000000,refine:'REPLACE',children}},null,2))
  149. writeFileSync(join(out,'manifest.json'),JSON.stringify({candidateOnly:true,buildings:records.flatMap(r=>r.buildingIds).length,records,
  150. caveat:'True GPU-instanced coarse exterior pilot. No fine LOD replacement or runtime/FPS validation; not configured. Windows/doors are procedural estimates, not surveyed facade detail.'},null,2))
  151. console.log(JSON.stringify({output:out,buildings:records.flatMap(r=>r.buildingIds).length,files:records.length,bytes:records.reduce((s,r)=>s+r.bytes,0),storedTriangles:records.reduce((s,r)=>s+r.storedTriangles,0),renderedTriangles:records.reduce((s,r)=>s+r.renderedTriangles,0)}))