verify-roof-caps.mjs 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. // Verify REAL decoded roof triangles against seated GIS footprints, not material names.
  2. // node tools/city-massing/verify-roof-caps.mjs <batch...> [--report <new-report.json>]
  3. import assert from 'node:assert/strict'
  4. import { readFileSync, writeFileSync } from 'node:fs'
  5. import { resolve, join } from 'node:path'
  6. import draco3d from 'draco3d'
  7. const args = process.argv.slice(2)
  8. const reportAt = args.indexOf('--report')
  9. const reportPath = reportAt < 0 ? null : args.splice(reportAt, 2)[1]
  10. assert.ok(args.length, 'one or more batch directories are required')
  11. const draco = await draco3d.createDecoderModule({})
  12. const report = { passed: false, batches: [] }
  13. const area = (ring) => Math.abs(ring.reduce((sum, p, i) => {
  14. const q = ring[(i + 1) % ring.length]
  15. return sum + p[0] * q[1] - q[0] * p[1]
  16. }, 0) / 2)
  17. const perimeter = (ring) => ring.reduce((sum, p, i) => {
  18. const q = ring[(i + 1) % ring.length]
  19. return sum + Math.hypot(p[0] - q[0], p[1] - q[1])
  20. }, 0)
  21. const inside = (x, y, ring) => {
  22. let yes = false
  23. for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
  24. const a = ring[i], b = ring[j]
  25. if ((a[1] > y) !== (b[1] > y) && x < (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0]) yes = !yes
  26. }
  27. return yes
  28. }
  29. function readGlb(file) {
  30. const data = readFileSync(file)
  31. assert.equal(data.toString('ascii', 0, 4), 'glTF', file)
  32. const jsonLength = data.readUInt32LE(12)
  33. const json = JSON.parse(data.toString('utf8', 20, 20 + jsonLength))
  34. const offset = 20 + jsonLength
  35. assert.equal(data.toString('ascii', offset + 4, offset + 8), 'BIN\0', file)
  36. return { json, bin: data.subarray(offset + 8) }
  37. }
  38. function roofTriangles(file) {
  39. const { json, bin } = readGlb(file)
  40. const result = []
  41. let reversed = 0
  42. const node = json.nodes.find((value) => value.mesh !== undefined)
  43. assert.ok(node && !node.matrix && !node.rotation && !node.scale, `${file}: unexpected model transform`)
  44. const translation = node.translation || [0, 0, 0]
  45. for (const part of json.meshes[node.mesh].primitives) {
  46. if (!json.materials[part.material]?.name?.startsWith('Roof_')) continue
  47. const ext = part.extensions.KHR_draco_mesh_compression
  48. const view = json.bufferViews[ext.bufferView]
  49. const encoded = bin.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength)
  50. const decoder = new draco.Decoder(), buffer = new draco.DecoderBuffer(), mesh = new draco.Mesh()
  51. const allocated = []
  52. let status
  53. try {
  54. buffer.Init(new Int8Array(encoded.buffer, encoded.byteOffset, encoded.byteLength), encoded.byteLength)
  55. status = decoder.DecodeBufferToMesh(buffer, mesh)
  56. assert.ok(status.ok(), `${file}: Draco decode failed`)
  57. const values = (semantic) => {
  58. const attribute = decoder.GetAttributeByUniqueId(mesh, ext.attributes[semantic])
  59. const bytes = mesh.num_points() * 3 * 4
  60. const ptr = draco._malloc(bytes); allocated.push(ptr)
  61. decoder.GetAttributeDataArrayForAllPoints(mesh, attribute, draco.DT_FLOAT32, bytes, ptr)
  62. return new Float32Array(draco.HEAPF32.buffer, ptr, mesh.num_points() * 3).slice()
  63. }
  64. const positions = values('POSITION'), normals = values('NORMAL')
  65. const bytes = mesh.num_faces() * 3 * 4
  66. const ptr = draco._malloc(bytes); allocated.push(ptr)
  67. decoder.GetTrianglesUInt32Array(mesh, bytes, ptr)
  68. const indices = new Uint32Array(draco.HEAPU32.buffer, ptr, mesh.num_faces() * 3)
  69. for (let i = 0; i < indices.length; i += 3) {
  70. const points = [indices[i], indices[i + 1], indices[i + 2]].map((index) => [
  71. positions[index * 3] + translation[0], -(positions[index * 3 + 2] + translation[2]),
  72. positions[index * 3 + 1] + translation[1]])
  73. const [a, b, c] = points
  74. const projected = ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) / 2
  75. // Up-facing roof area in source ENU; below-ground downward faces aren't a roof.
  76. if (projected <= 1e-7) { if (projected < -1e-7) reversed++; continue }
  77. const flat = Math.max(a[2], b[2], c[2]) - Math.min(a[2], b[2], c[2]) < 0.001
  78. // Roof material also covers library fascia. Check vertical normals only after
  79. // identifying a triangle as a GIS cap, rather than judging every library detail.
  80. result.push({ x: (a[0] + b[0] + c[0]) / 3, y: (a[1] + b[1] + c[1]) / 3,
  81. z: (a[2] + b[2] + c[2]) / 3, area: projected, flat, points,
  82. upNormal: Math.min(...[indices[i], indices[i + 1], indices[i + 2]].map((index) => normals[index * 3 + 1])) })
  83. }
  84. } finally {
  85. for (const ptr of allocated) draco._free(ptr)
  86. if (status) draco.destroy(status)
  87. draco.destroy(mesh); draco.destroy(buffer); draco.destroy(decoder)
  88. }
  89. }
  90. return { triangles: result, reversed }
  91. }
  92. function checkFile(file, buildings, level, exactFootprints) {
  93. const { triangles } = roofTriangles(file)
  94. const expected = buildings.filter((building) => exactFootprints || building.kind === 'massing')
  95. const coverage = new Map(expected.map((building) => [building.id, 0]))
  96. // Heights + bounds reject other buildings cheaply; point in surveyed ring identifies the cap.
  97. const targets = expected.map((building) => ({ building,
  98. top: building.positionENU[2] + building.heightM,
  99. minX: Math.min(...building.footprintENU.map((point) => point[0])),
  100. maxX: Math.max(...building.footprintENU.map((point) => point[0])),
  101. minY: Math.min(...building.footprintENU.map((point) => point[1])),
  102. maxY: Math.max(...building.footprintENU.map((point) => point[1])),
  103. }))
  104. for (const triangle of triangles) {
  105. if (!triangle.flat) continue
  106. const matches = targets.filter(({ building, top, minX, maxX, minY, maxY }) => Math.abs(triangle.z - top) < 0.3
  107. && triangle.x >= minX && triangle.x <= maxX && triangle.y >= minY && triangle.y <= maxY
  108. && inside(triangle.x, triangle.y, building.footprintENU))
  109. // Distinct polygons in the same city sometimes overlap; avoid attributing one face twice.
  110. if (matches.length) {
  111. matches.sort((a, b) => Math.abs(a.top - triangle.z) - Math.abs(b.top - triangle.z))
  112. const id = matches[0].building.id
  113. assert.ok(triangle.upNormal > 0.99, `${file}: ${id} cap normal is not upwards`)
  114. coverage.set(id, coverage.get(id) + triangle.area)
  115. }
  116. }
  117. const missing = expected.filter((building) => coverage.get(building.id) <= 1e-7)
  118. assert.equal(missing.length, 0, `${file}: ${missing.length} buildings have no upward roof, first ${missing[0]?.id}`)
  119. let checkedArea = 0
  120. for (const building of expected) {
  121. const wanted = area(building.footprintENU), actual = coverage.get(building.id)
  122. const tolerance = Math.max(0.02, wanted * 0.015, perimeter(building.footprintENU) * 0.15)
  123. assert.ok(Math.abs(actual - wanted) <= tolerance,
  124. `${file}: ${building.id} roof coverage ${actual.toFixed(3)} differs from GIS area ${wanted.toFixed(3)}`)
  125. checkedArea += actual
  126. }
  127. return { level, buildings: expected.length, roofTriangles: triangles.length, roofAreaM2: checkedArea }
  128. }
  129. for (const batch of args) {
  130. const dir = resolve(batch)
  131. const manifest = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'))
  132. const tree = JSON.parse(readFileSync(join(dir, 'tileset.json'), 'utf8'))
  133. // Older manifests may lack plan identity; the active batch name still maps to a seated source.
  134. const folder = dir.split(/[\\/]/).at(-1)
  135. const planName = manifest.provenance?.plan?.startsWith('seated-') ? manifest.provenance.plan
  136. : 'seated-' + folder.replace(/^lod-/, '').replace(/-(aligned|roofed)$/, '') + '.json'
  137. const plan = JSON.parse(readFileSync(join(dir, '..', planName), 'utf8'))
  138. const byId = new Map(plan.buildings.map((building) => [building.id, building]))
  139. const byContent = new Map(manifest.cells.map((cell) => [cell.lod0.uri, cell]))
  140. const result = { batch, buildings: manifest.count, checkedFiles: 0, checks: { coarse: 0, lod1: 0, lod0: 0 }, roofAreaM2: 0 }
  141. for (const cell of manifest.cells) {
  142. const buildings = cell.buildingIds.map((id) => { assert.ok(byId.has(id), `unknown GIS building ${id}`); return byId.get(id) })
  143. for (const level of ['lod1', 'lod0']) {
  144. const checked = checkFile(join(dir, cell[level].uri), buildings, level, level === 'lod1')
  145. result.checks[level] += checked.buildings; result.roofAreaM2 += checked.roofAreaM2; result.checkedFiles++
  146. }
  147. }
  148. for (const coarse of tree.root.children) {
  149. const buildings = coarse.children.flatMap((child) => byContent.get(child.content.uri).buildingIds).map((id) => byId.get(id))
  150. const checked = checkFile(join(dir, coarse.content.uri), buildings, 'coarse', true)
  151. result.checks.coarse += checked.buildings; result.roofAreaM2 += checked.roofAreaM2; result.checkedFiles++
  152. }
  153. report.batches.push(result)
  154. console.log(JSON.stringify(result))
  155. }
  156. report.passed = true
  157. if (reportPath) writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n')
  158. console.log(JSON.stringify({ passed: true, batches: report.batches.length,
  159. files: report.batches.reduce((sum, batch) => sum + batch.checkedFiles, 0),
  160. roofChecks: report.batches.reduce((sum, batch) => sum + Object.values(batch.checks).reduce((a, b) => a + b, 0), 0) }))