verify-district.mjs 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. // Verify decoded GLB transforms/feet (not self-reported zBase) against original outline + direct DEM samples.
  2. import assert from 'node:assert/strict'
  3. import { readFileSync, writeFileSync } from 'node:fs'
  4. import { resolve } from 'node:path'
  5. import { Cartesian3, Cartographic, Matrix4, Quaternion, Math as CM } from 'cesium'
  6. const out = resolve(process.env.CITY_PREVIEW_DIR || 'tools/city-massing/district/generated')
  7. const file = readFileSync(resolve(out, 'district.glb'))
  8. assert.equal(file.toString('ascii', 0, 4), 'glTF')
  9. assert.equal(file.readUInt32LE(4), 2)
  10. assert.equal(file.readUInt32LE(8), file.length)
  11. const jsonLen = file.readUInt32LE(12),
  12. gltf = JSON.parse(file.toString('utf8', 20, 20 + jsonLen)),
  13. binStart = 28 + jsonLen
  14. const plan = JSON.parse(readFileSync('tools/city-massing/district/seated-plan.json'))
  15. const samples = JSON.parse(readFileSync('tools/city-massing/district/terrain-samples.json'))
  16. const library = JSON.parse(readFileSync('tools/city-massing/house-library/library.json'))
  17. const report = JSON.parse(readFileSync(resolve(out, 'placement-report.json')))
  18. const tile = JSON.parse(readFileSync(resolve(out, 'tileset.json')))
  19. const root = Matrix4.fromArray(tile.root.transform)
  20. function nodeMatrices(doc) {
  21. const map = new Map()
  22. function visit(i, parent) {
  23. const n = doc.nodes[i]
  24. const local = n.matrix
  25. ? Matrix4.fromArray(n.matrix)
  26. : Matrix4.fromTranslationQuaternionRotationScale(
  27. Cartesian3.fromArray(n.translation || [0, 0, 0]),
  28. new Quaternion(...(n.rotation || [0, 0, 0, 1])),
  29. Cartesian3.fromArray(n.scale || [1, 1, 1])
  30. )
  31. const world = Matrix4.multiply(parent, local, new Matrix4())
  32. map.set(n.name, { node: n, world })
  33. for (const c of n.children || []) visit(c, world)
  34. }
  35. for (const i of doc.scenes[doc.scene || 0].nodes) visit(i, Matrix4.IDENTITY)
  36. return map
  37. }
  38. function positions(primitive) {
  39. const a = gltf.accessors[primitive.attributes.POSITION],
  40. v = gltf.bufferViews[a.bufferView],
  41. out = []
  42. assert.equal(a.componentType, 5126)
  43. assert.equal(a.type, 'VEC3')
  44. for (let i = 0; i < a.count; i++) {
  45. const o = binStart + (v.byteOffset || 0) + (a.byteOffset || 0) + i * (v.byteStride || 12)
  46. out.push(new Cartesian3(file.readFloatLE(o), file.readFloatLE(o + 4), file.readFloatLE(o + 8)))
  47. }
  48. return out
  49. }
  50. function enu(p) {
  51. return new Cartesian3(p.x, -p.z, p.y)
  52. }
  53. function fromNode(point, m) {
  54. return enu(Matrix4.multiplyByPoint(m, point, new Cartesian3()))
  55. }
  56. function errors(doc) {
  57. const failures = [],
  58. mats = nodeMatrices(doc)
  59. const metrics = []
  60. for (const b of report.buildings) {
  61. const actual = mats.get(b.id),
  62. foot = mats.get(b.id + '_buried_foundation')
  63. if (!actual || !foot) {
  64. failures.push(b.id + ' missing node')
  65. continue
  66. }
  67. const src = plan.buildings.find((x) => x.id === b.id)
  68. const entry = library.archetypes.find((x) => x.id === b.asset)
  69. const zero = fromNode(new Cartesian3(0, 0, 0), actual.world)
  70. const world = Matrix4.multiplyByPoint(root, zero, new Cartesian3())
  71. const ll = Cartographic.fromCartesian(world)
  72. // Independent absolute ECEF control using WGS84 converter, not degree-to-metre approximations.
  73. const expected = Cartesian3.fromDegrees(src.center.lon, src.center.lat, src.floorHeight)
  74. const displacement = Cartesian3.distance(expected, world)
  75. if (displacement > 0.03) failures.push(b.id + ' centre/height shifted ' + displacement)
  76. const nominal = entry.nominal_footprint_m,
  77. w = nominal[0],
  78. d = nominal[1]
  79. const corners = [
  80. [-w / 2, 0, d / 2],
  81. [w / 2, 0, d / 2],
  82. [w / 2, 0, -d / 2],
  83. [-w / 2, 0, -d / 2],
  84. ].map((c) => fromNode(Cartesian3.fromArray(c), actual.world))
  85. const cornerError = Math.max(
  86. ...corners.map((p) => Math.min(...src.footprintENU.map((q) => Math.hypot(p.x - q[0], p.y - q[1]))))
  87. )
  88. if (cornerError > 0.05) failures.push(b.id + ' footprint size/orientation mismatch ' + cornerError)
  89. const points = doc.meshes[actual.node.mesh].primitives.flatMap(positions).map((p) => fromNode(p, actual.world))
  90. const bottoms = doc.meshes[foot.node.mesh].primitives.flatMap(positions).map((p) => fromNode(p, foot.world))
  91. const minFoot = Math.min(...bottoms.map((p) => p.z)),
  92. maxFoot = Math.max(...bottoms.map((p) => p.z)),
  93. bodyMin = Math.min(...points.map((p) => p.z)),
  94. roofMax = Math.max(...points.map((p) => p.z))
  95. // Recompute the original DEM controls instead of trusting seated-plan's groundControls/zBase.
  96. const inverse = Matrix4.inverseTransformation(root, new Matrix4())
  97. const controls = src.sampleIndices.map((i) => {
  98. const coordinate = samples.planPoints[i]
  99. const height = samples.heights[i]
  100. assert(Number.isFinite(height), 'Missing original DEM height')
  101. return Matrix4.multiplyByPoint(inverse, Cartesian3.fromDegrees(coordinate.lon, coordinate.lat, height), new Cartesian3())
  102. })
  103. const minGround = Math.min(...controls.map((p) => p.z)),
  104. maxGround = Math.max(...controls.map((p) => p.z))
  105. if (minFoot > minGround - 2.8 || maxFoot < maxGround - 0.4)
  106. failures.push(b.id + ' foundation no longer intersects sampled ground')
  107. if (bodyMin > maxGround + 0.1 || bodyMin < maxGround - 0.5) failures.push(b.id + ' body floor floated or sank')
  108. if (roofMax - maxGround < b.actualFloors * 2.95) failures.push(b.id + ' usable facade/roof buried')
  109. if (points.some((p) => !Number.isFinite(p.x + p.y + p.z))) failures.push(b.id + ' NaN geometry')
  110. metrics.push({
  111. id: b.id,
  112. sourceId: b.sourceId,
  113. displacement,
  114. cornerError,
  115. bodyMin,
  116. roofMax,
  117. minGround,
  118. maxGround,
  119. foundationMin: minFoot,
  120. foundationMax: maxFoot,
  121. visibleFoundation: bodyMin - minGround,
  122. actualFloors: b.actualFloors,
  123. })
  124. }
  125. return { failures, metrics }
  126. }
  127. const baseline = errors(gltf)
  128. console.log({
  129. nodes: report.count,
  130. geoFailures: baseline.failures.slice(0, 5),
  131. maxCornerError: Math.max(...baseline.metrics.map((x) => x.cornerError)),
  132. })
  133. assert.equal(baseline.failures.length, 0, baseline.failures.join('\n'))
  134. // Negative controls modify actual final GLB scene data, keep all source/metadata untouched.
  135. const first = report.buildings[0].id
  136. for (const mode of ['horizontal', 'vertical', 'scale']) {
  137. let d = structuredClone(gltf)
  138. let n = d.nodes.find((n) => n.name === first)
  139. n.translation ||= [0, 0, 0]
  140. n.scale ||= [1, 1, 1]
  141. if (mode === 'horizontal') n.translation[0] += 10
  142. if (mode === 'vertical') n.translation[1] += 5
  143. if (mode === 'scale') n.scale[0] *= 1.2
  144. const r = errors(d)
  145. assert(r.failures.length > 0, 'Mutated ' + mode + ' should fail')
  146. console.log('negative control detected:', mode)
  147. }
  148. writeFileSync(
  149. resolve(out, 'geometry-verification.json'),
  150. JSON.stringify(
  151. {
  152. passed: true,
  153. sourceFootprints: plan.buildings.length,
  154. placed: report.count,
  155. directTerrainSamples: samples.heights.length,
  156. metrics: baseline.metrics,
  157. negativeControls: ['horizontal shift 10m', 'height shift 5m', 'X scale 1.2'],
  158. },
  159. null,
  160. 2
  161. )
  162. )
  163. console.log('PASS actual GLB anchors, dimensions, terrain intersection, facade clearance, and mutation controls')