verify-roof-caps.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  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. function roofTone(id) {
  14. let seed = 2166136261
  15. for (const character of id) seed = Math.imul(seed ^ character.charCodeAt(0), 16777619) >>> 0
  16. return ['Roof_Slate', 'Roof_Slate_Light', 'Roof_Terracotta', 'Roof_Terra_Light'][Math.floor(seed / 4) % 4]
  17. }
  18. const area = (ring) => Math.abs(ring.reduce((sum, p, i) => {
  19. const q = ring[(i + 1) % ring.length]
  20. return sum + p[0] * q[1] - q[0] * p[1]
  21. }, 0) / 2)
  22. const perimeter = (ring) => ring.reduce((sum, p, i) => {
  23. const q = ring[(i + 1) % ring.length]
  24. return sum + Math.hypot(p[0] - q[0], p[1] - q[1])
  25. }, 0)
  26. const inside = (x, y, ring) => {
  27. let yes = false
  28. for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
  29. const a = ring[i], b = ring[j]
  30. if ((a[1] > y) !== (b[1] > y) && x < (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0]) yes = !yes
  31. }
  32. return yes
  33. }
  34. function readGlb(file) {
  35. const data = readFileSync(file)
  36. assert.equal(data.toString('ascii', 0, 4), 'glTF', file)
  37. const jsonLength = data.readUInt32LE(12)
  38. const json = JSON.parse(data.toString('utf8', 20, 20 + jsonLength))
  39. const offset = 20 + jsonLength
  40. assert.equal(data.toString('ascii', offset + 4, offset + 8), 'BIN\0', file)
  41. return { json, bin: data.subarray(offset + 8) }
  42. }
  43. function roofTriangles(file) {
  44. const { json, bin } = readGlb(file)
  45. const result = []
  46. let reversed = 0
  47. const node = json.nodes.find((value) => value.mesh !== undefined)
  48. assert.ok(node && !node.matrix && !node.rotation && !node.scale, `${file}: unexpected model transform`)
  49. const translation = node.translation || [0, 0, 0]
  50. for (const part of json.meshes[node.mesh].primitives) {
  51. if (!/^(Roof_|SatelliteRoof_)/.test(json.materials[part.material]?.name || '')) continue
  52. const ext = part.extensions.KHR_draco_mesh_compression
  53. const view = json.bufferViews[ext.bufferView]
  54. const encoded = bin.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength)
  55. const decoder = new draco.Decoder(), buffer = new draco.DecoderBuffer(), mesh = new draco.Mesh()
  56. const allocated = []
  57. let status
  58. try {
  59. buffer.Init(new Int8Array(encoded.buffer, encoded.byteOffset, encoded.byteLength), encoded.byteLength)
  60. status = decoder.DecodeBufferToMesh(buffer, mesh)
  61. assert.ok(status.ok(), `${file}: Draco decode failed`)
  62. const values = (semantic) => {
  63. const attribute = decoder.GetAttributeByUniqueId(mesh, ext.attributes[semantic])
  64. const bytes = mesh.num_points() * 3 * 4
  65. const ptr = draco._malloc(bytes); allocated.push(ptr)
  66. decoder.GetAttributeDataArrayForAllPoints(mesh, attribute, draco.DT_FLOAT32, bytes, ptr)
  67. return new Float32Array(draco.HEAPF32.buffer, ptr, mesh.num_points() * 3).slice()
  68. }
  69. const positions = values('POSITION'), normals = values('NORMAL')
  70. const bytes = mesh.num_faces() * 3 * 4
  71. const ptr = draco._malloc(bytes); allocated.push(ptr)
  72. decoder.GetTrianglesUInt32Array(mesh, bytes, ptr)
  73. const indices = new Uint32Array(draco.HEAPU32.buffer, ptr, mesh.num_faces() * 3)
  74. for (let i = 0; i < indices.length; i += 3) {
  75. const points = [indices[i], indices[i + 1], indices[i + 2]].map((index) => [
  76. positions[index * 3] + translation[0], -(positions[index * 3 + 2] + translation[2]),
  77. positions[index * 3 + 1] + translation[1]])
  78. const [a, b, c] = points
  79. const projected = ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) / 2
  80. // Up-facing roof area in source ENU; below-ground downward faces aren't a roof.
  81. if (projected <= 1e-7) { if (projected < -1e-7) reversed++; continue }
  82. const flat = Math.max(a[2], b[2], c[2]) - Math.min(a[2], b[2], c[2]) < 0.001
  83. // Roof material also covers library fascia. Check vertical normals only after
  84. // identifying a triangle as a GIS cap, rather than judging every library detail.
  85. result.push({ x: (a[0] + b[0] + c[0]) / 3, y: (a[1] + b[1] + c[1]) / 3,
  86. z: (a[2] + b[2] + c[2]) / 3, area: projected, flat, points,
  87. material: json.materials[part.material].name,
  88. upNormal: Math.min(...[indices[i], indices[i + 1], indices[i + 2]].map((index) => normals[index * 3 + 1])) })
  89. }
  90. } finally {
  91. for (const ptr of allocated) draco._free(ptr)
  92. if (status) draco.destroy(status)
  93. draco.destroy(mesh); draco.destroy(buffer); draco.destroy(decoder)
  94. }
  95. }
  96. return { triangles: result, reversed }
  97. }
  98. function checkFile(files, buildings, level, exactFootprints) {
  99. const list = Array.isArray(files) ? files : [files]
  100. const file = list[0]
  101. const triangles = list.flatMap((path) => roofTriangles(path).triangles)
  102. const expected = buildings.filter((building) => exactFootprints || building.kind === 'massing')
  103. const coverage = new Map(expected.map((building) => [building.id, 0]))
  104. const upwardShading = new Map(expected.map((building) => [building.id, 0]))
  105. // Heights + bounds reject other buildings cheaply; point in surveyed ring identifies the cap.
  106. const targets = expected.map((building) => ({ building,
  107. top: building.positionENU[2] + building.heightM,
  108. minX: Math.min(...building.footprintENU.map((point) => point[0])),
  109. maxX: Math.max(...building.footprintENU.map((point) => point[0])),
  110. minY: Math.min(...building.footprintENU.map((point) => point[1])),
  111. maxY: Math.max(...building.footprintENU.map((point) => point[1])),
  112. }))
  113. for (const triangle of triangles) {
  114. if (!triangle.flat) continue
  115. const matches = targets.filter(({ building, top, minX, maxX, minY, maxY }) => (triangle.material === roofTone(building.id) || triangle.material === `SatelliteRoof_${building.id}`)
  116. && Math.abs(triangle.z - top) < 0.3
  117. && triangle.x >= minX && triangle.x <= maxX && triangle.y >= minY && triangle.y <= maxY
  118. && inside(triangle.x, triangle.y, building.footprintENU))
  119. .map((target) => ({ ...target, vertexError: Math.max(...triangle.points.map((point) => Math.min(
  120. ...target.building.footprintENU.map((corner) => Math.hypot(point[0] - corner[0], point[1] - corner[1]))))) }))
  121. .filter((target) => target.vertexError < 0.3)
  122. // An overlapping footprint may also contain the centroid. Cap vertices must match the GIS
  123. // ring, which distinguishes actual roof ownership instead of borrowing a neighbour's face.
  124. if (matches.length) {
  125. matches.sort((a, b) => a.vertexError - b.vertexError || Math.abs(a.top - triangle.z) - Math.abs(b.top - triangle.z))
  126. const best = matches[0]
  127. // A few GIS records have different IDs but exactly the same ring in a different order.
  128. // Preserve those existing records; verify their combined geometry against combined area.
  129. const signature = (target) => JSON.stringify([...target.building.footprintENU].sort((a, b) => a[0] - b[0] || a[1] - b[1]))
  130. const equivalent = matches.filter((target) => target.top === best.top && signature(target) === signature(best))
  131. for (const target of equivalent) {
  132. const id = target.building.id, contribution = triangle.area / equivalent.length
  133. coverage.set(id, coverage.get(id) + contribution)
  134. if (triangle.upNormal > 0.99) upwardShading.set(id, upwardShading.get(id) + contribution)
  135. }
  136. }
  137. }
  138. const missing = expected.filter((building) => coverage.get(building.id) <= 1e-7)
  139. assert.equal(missing.length, 0, `${file}: ${missing.length} buildings have no upward roof, first ${missing[0]?.id}`)
  140. let checkedArea = 0
  141. for (const building of expected) {
  142. const wanted = area(building.footprintENU), actual = coverage.get(building.id)
  143. const tolerance = Math.max(0.02, wanted * 0.015, perimeter(building.footprintENU) * 0.15)
  144. assert.ok(Math.abs(actual - wanted) <= tolerance,
  145. `${file}: ${building.id} roof coverage ${actual.toFixed(3)} differs from GIS area ${wanted.toFixed(3)}`)
  146. assert.ok(Math.abs(upwardShading.get(building.id) - wanted) <= tolerance,
  147. `${file}: ${building.id} upward shading does not cover its GIS roof`)
  148. checkedArea += actual
  149. }
  150. return { level, buildings: expected.length, roofTriangles: triangles.length, roofAreaM2: checkedArea }
  151. }
  152. for (const batch of args) {
  153. const dir = resolve(batch)
  154. const manifest = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'))
  155. const streaming = Array.isArray(manifest.files)
  156. const sourceDir = streaming ? resolve(dir, '..') : dir
  157. const tree = JSON.parse(readFileSync(join(dir, 'tileset.json'), 'utf8'))
  158. // Older manifests may lack plan identity; the active batch name still maps to a seated source.
  159. const folder = sourceDir.split(/[\\/]/).at(-1)
  160. const planName = manifest.provenance?.plan?.startsWith('seated-') ? manifest.provenance.plan
  161. : 'seated-' + folder.replace(/^lod-/, '').replace(/-(aligned|roofed2|roofed)$/, '').replace(/^batch-unfitted/, 'unfitted') + '.json'
  162. const pilotPlan = manifest.provenance?.planPath || (/^lod-near-estate-pilot-v/.test(folder)
  163. ? join(sourceDir, '..', 'near-estate-pilot-input', 'seated-plan.json') : join(sourceDir, '..', planName))
  164. const plan = JSON.parse(readFileSync(pilotPlan, 'utf8'))
  165. const byId = new Map(plan.buildings.map((building) => [building.id, building]))
  166. if (streaming) {
  167. const result = { batch, buildings: manifest.count, checkedFiles: 0, checks: { lod1: 0, lod0: 0 }, roofAreaM2: 0 }
  168. for (const cell of manifest.cells) {
  169. const buildings = cell.buildingIds.map((id) => byId.get(id))
  170. for (const level of ['lod1', 'lod0']) {
  171. const files = manifest.files.filter((file) => file.uri.startsWith(cell.id + '-p') && file.level === level)
  172. .map((file) => join(dir, file.uri))
  173. const checked = checkFile(files, buildings, level, level === 'lod1')
  174. result.checks[level] += checked.buildings; result.roofAreaM2 += checked.roofAreaM2; result.checkedFiles += files.length
  175. }
  176. }
  177. report.batches.push(result)
  178. console.log(JSON.stringify(result))
  179. continue
  180. }
  181. const byContent = new Map(manifest.cells.map((cell) => [cell.lod0.uri, cell]))
  182. const result = { batch, buildings: manifest.count, checkedFiles: 0, checks: { coarse: 0, lod1: 0, lod0: 0 }, roofAreaM2: 0 }
  183. for (const cell of manifest.cells) {
  184. const buildings = cell.buildingIds.map((id) => { assert.ok(byId.has(id), `unknown GIS building ${id}`); return byId.get(id) })
  185. for (const level of ['lod1', 'lod0']) {
  186. const checked = checkFile(join(dir, cell[level].uri), buildings, level, level === 'lod1')
  187. result.checks[level] += checked.buildings; result.roofAreaM2 += checked.roofAreaM2; result.checkedFiles++
  188. }
  189. }
  190. for (const coarse of tree.root.children) {
  191. const buildings = coarse.children.flatMap((child) => byContent.get(child.content.uri).buildingIds).map((id) => byId.get(id))
  192. const checked = checkFile(join(dir, coarse.content.uri), buildings, 'coarse', true)
  193. result.checks.coarse += checked.buildings; result.roofAreaM2 += checked.roofAreaM2; result.checkedFiles++
  194. }
  195. report.batches.push(result)
  196. console.log(JSON.stringify(result))
  197. }
  198. report.passed = true
  199. if (reportPath) writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n')
  200. console.log(JSON.stringify({ passed: true, batches: report.batches.length,
  201. files: report.batches.reduce((sum, batch) => sum + batch.checkedFiles, 0),
  202. roofChecks: report.batches.reduce((sum, batch) => sum + Object.values(batch.checks).reduce((a, b) => a + b, 0), 0) }))