verify-cad-bim.mjs 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. // Acceptance for the CAD-driven BIM: geometry provenance, scene bounds and export accounting.
  2. import { readFileSync, existsSync } from 'node:fs'
  3. import { resolve } from 'node:path'
  4. import { Cartesian3, Matrix4, Cartographic } from 'cesium'
  5. import { PRIMARY_AREA_BOUNDS } from './primary-area.mjs'
  6. const dir = resolve('tools/city-massing/city/cad-bim')
  7. const seated = JSON.parse(readFileSync(resolve(dir, 'seated-plan.json'), 'utf8'))
  8. const previewDir = resolve(dir, 'lod-preview')
  9. const failures = []
  10. if (!existsSync(resolve(previewDir, 'manifest.json'))) {
  11. console.log(JSON.stringify({ passed: false, reason: 'no exported manifest yet', expected: resolve(previewDir, 'manifest.json') }, null, 1))
  12. process.exit(1)
  13. }
  14. const manifest = JSON.parse(readFileSync(resolve(previewDir, 'manifest.json'), 'utf8'))
  15. const tileset = JSON.parse(readFileSync(resolve(previewDir, 'tileset.json'), 'utf8'))
  16. // 1. Root anchor must be a valid GLB, otherwise Cesium skips the tree.
  17. const anchorUri = tileset.root?.content?.uri
  18. if (!anchorUri) failures.push('root has no content anchor')
  19. else {
  20. const anchor = readFileSync(resolve(previewDir, anchorUri))
  21. if (anchor.toString('ascii', 0, 4) !== 'glTF' || anchor.readUInt32LE(4) !== 2 ||
  22. anchor.readUInt32LE(8) !== anchor.length || anchor.toString('ascii', 16, 20) !== 'JSON')
  23. failures.push('root anchor GLB header is invalid')
  24. else {
  25. try {
  26. JSON.parse(anchor.toString('utf8', 20, 20 + anchor.readUInt32LE(12)))
  27. } catch {
  28. failures.push('root anchor GLB JSON chunk is invalid')
  29. }
  30. }
  31. }
  32. // 2. Every placed building must trace back to a seated building.
  33. const byId = new Map(seated.buildings.map((b) => [b.id, b]))
  34. const placed = new Set(manifest.cells.flatMap((cell) => cell.buildingIds))
  35. const orphans = [...placed].filter((id) => !byId.has(id))
  36. if (orphans.length) failures.push(`${orphans.length} placed ids have no seated building (first: ${orphans[0]})`)
  37. // 3. The primary area as an ENU box; footprints are stored in ENU metres.
  38. const inverse = Matrix4.inverseTransformation(Matrix4.fromArray(seated.transform), new Matrix4())
  39. const corners = [
  40. [PRIMARY_AREA_BOUNDS[0], PRIMARY_AREA_BOUNDS[1]], [PRIMARY_AREA_BOUNDS[2], PRIMARY_AREA_BOUNDS[1]],
  41. [PRIMARY_AREA_BOUNDS[2], PRIMARY_AREA_BOUNDS[3]], [PRIMARY_AREA_BOUNDS[0], PRIMARY_AREA_BOUNDS[3]],
  42. ].map(([lon, lat]) => Matrix4.multiplyByPoint(inverse, Cartesian3.fromDegrees(lon, lat), new Cartesian3()))
  43. const box = {
  44. minX: Math.min(...corners.map((c) => c.x)), maxX: Math.max(...corners.map((c) => c.x)),
  45. minY: Math.min(...corners.map((c) => c.y)), maxY: Math.max(...corners.map((c) => c.y)),
  46. }
  47. let outside = 0
  48. for (const building of seated.buildings) {
  49. for (const [x, y] of building.footprintENU) {
  50. if (x < box.minX - 0.5 || x > box.maxX + 0.5 || y < box.minY - 0.5 || y > box.maxY + 0.5) {
  51. outside++
  52. if (outside <= 3) failures.push(`${building.id} footprint vertex outside the primary area`)
  53. break
  54. }
  55. }
  56. }
  57. // 4. Geometry sanity: massing needs a real outline, heights must be usable.
  58. let degenerateOutlines = 0
  59. let badHeights = 0
  60. for (const building of seated.buildings) {
  61. const ring = building.footprintENU
  62. if (building.kind === 'massing' && (!Array.isArray(ring) || ring.length < 3)) degenerateOutlines++
  63. if (!(building.heightM > 0) || !Number.isFinite(building.positionENU?.[2]) || !Number.isFinite(building.foundationBottom))
  64. badHeights++
  65. }
  66. if (degenerateOutlines) failures.push(`${degenerateOutlines} massing buildings have fewer than 3 outline points`)
  67. if (badHeights) failures.push(`${badHeights} buildings have unusable height or elevation`)
  68. const byTier = {}
  69. const byKind = {}
  70. for (const building of seated.buildings) {
  71. byTier[building.tier] = (byTier[building.tier] || 0) + 1
  72. byKind[building.kind] = (byKind[building.kind] || 0) + 1
  73. }
  74. const report = {
  75. passed: failures.length === 0,
  76. source: seated.source,
  77. seated: seated.buildings.length,
  78. byTier,
  79. byKind,
  80. construction: seated.construction,
  81. excludedRelief: (seated.excluded || []).filter((e) => e.relief !== undefined).length,
  82. exported: {
  83. placed: placed.size,
  84. cells: manifest.cellCount,
  85. coarseCells: manifest.hierarchy?.coarseCellCount ?? 0,
  86. lod1Bytes: manifest.totals?.lod1?.bytes ?? null,
  87. lod0Bytes: manifest.totals?.lod0?.bytes ?? null,
  88. lod1Triangles: manifest.totals?.lod1?.triangles ?? null,
  89. lod0Triangles: manifest.totals?.lod0?.triangles ?? null,
  90. },
  91. outsidePrimaryArea: outside,
  92. failures: failures.slice(0, 15),
  93. }
  94. console.log(JSON.stringify(report, null, 1))
  95. if (failures.length) process.exitCode = 1