verify-city-models.mjs 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. // Acceptance checks for the generated city models: primary-area containment, road clearance and
  2. // source provenance. Reads the exported LOD manifests so it validates what Cesium actually loads.
  3. import { readFileSync } from 'node:fs'
  4. import { resolve } from 'node:path'
  5. import * as turf from '@turf/turf'
  6. import { PRIMARY_AREA_BOUNDS, isWithinPrimaryArea } from './primary-area.mjs'
  7. import { candidateFootprint } from './oldtown-geometry.mjs'
  8. const cityDir = resolve('tools/city-massing/city')
  9. const ROAD_BUFFER_M = 3.5
  10. // Roads that carry traffic: a building overlapping one of these is an outright defect.
  11. const HARD_ROAD_TYPES = new Set(['trunk', 'trunk_link', 'primary', 'primary_link', 'secondary', 'secondary_link', 'tertiary', 'tertiary_link', 'motorway', 'motorway_link'])
  12. // Hand-mapped estate lanes and service ways disagree with imagery-traced roofs in this dataset.
  13. // Those are recorded as source conflicts for a human to settle, not silently resolved either way.
  14. const CONFLICT_ROAD_TYPES = new Set(['residential', 'unclassified', 'service', 'living_street', 'pedestrian', 'track'])
  15. const failures = []
  16. const conflicts = []
  17. const summary = {}
  18. function feature(building) {
  19. // Plans differ in where the centre lives: city plans use lon/lat, the district plan uses center.
  20. const lon = building.lon ?? building.center?.lon
  21. const lat = building.lat ?? building.center?.lat
  22. if (!Number.isFinite(lon) || !Number.isFinite(lat))
  23. throw new Error(`Building ${building.id} has no usable centre coordinate`)
  24. return candidateFootprint({ lon, lat, width: building.width, depth: building.depth, heading: building.heading })
  25. }
  26. function checkTrees() {
  27. const trees = [
  28. { id: 'oldtown', dir: resolve(cityDir, 'oldtown/lod-preview'), plan: resolve(cityDir, 'oldtown/seated-plan.json') },
  29. { id: 'regular-estate', dir: resolve(cityDir, 'regular-estate/lod-preview'), plan: resolve(cityDir, 'regular-estate/seated-plan.json') },
  30. { id: 'district', dir: resolve('tools/city-massing/district/lod-preview'), plan: resolve('tools/city-massing/district/seated-plan.json') },
  31. ]
  32. const roads = JSON.parse(readFileSync(resolve(cityDir, 'roads-clipped.geojson'))).features
  33. const buffersOf = (types) => roads
  34. .filter((road) => types.has(road.properties?.type || 'unclassified'))
  35. .map((road) => {
  36. try {
  37. return { road, area: turf.buffer(road, ROAD_BUFFER_M, { units: 'meters' }) }
  38. } catch {
  39. return null
  40. }
  41. })
  42. .filter((entry) => entry && entry.area)
  43. const hardRoads = buffersOf(HARD_ROAD_TYPES)
  44. const conflictRoads = buffersOf(CONFLICT_ROAD_TYPES)
  45. for (const tree of trees) {
  46. const tileset = JSON.parse(readFileSync(resolve(tree.dir, 'tileset.json')))
  47. const manifest = JSON.parse(readFileSync(resolve(tree.dir, 'manifest.json')))
  48. const plan = JSON.parse(readFileSync(tree.plan))
  49. const placedIds = new Set(manifest.cells.flatMap((cell) => cell.buildingIds))
  50. const byId = new Map(plan.buildings.map((b) => [b.id, b]))
  51. // Every reported count must be traceable to a seated building.
  52. const missing = [...placedIds].filter((id) => !byId.has(id))
  53. if (missing.length) failures.push(`${tree.id}: ${missing.length} placed ids have no seated building`)
  54. let outsideArea = 0
  55. let onRoad = 0
  56. let offMask = 0
  57. const masks = tree.id === 'district'
  58. ? null
  59. : JSON.parse(readFileSync(resolve(cityDir, 'residential-clipped.geojson'))).features
  60. for (const id of placedIds) {
  61. const building = byId.get(id)
  62. if (!building) continue
  63. const foot = feature(building)
  64. if (!isWithinPrimaryArea(foot)) {
  65. outsideArea++
  66. if (outsideArea <= 3) failures.push(`${tree.id}: ${id} footprint outside the primary area`)
  67. }
  68. if (hardRoads.some((entry) => turf.booleanIntersects(foot, entry.area))) {
  69. onRoad++
  70. if (onRoad <= 3) failures.push(`${tree.id}: ${id} footprint sits on a mapped traffic road`)
  71. }
  72. for (const entry of conflictRoads) {
  73. if (!turf.booleanIntersects(foot, entry.area)) continue
  74. const overlap = turf.intersect(turf.featureCollection([foot, entry.area]))
  75. const overlapArea = overlap ? turf.area(overlap) : 0
  76. const share = overlapArea / turf.area(foot)
  77. // Only meaningful overlap counts: a graze against a lane edge is noise.
  78. if (share >= 0.15)
  79. conflicts.push({
  80. tree: tree.id,
  81. building: id,
  82. roadOsmId: entry.road.properties?.osm_id,
  83. roadType: entry.road.properties?.type,
  84. overlapPercent: Math.round(share * 100),
  85. note: 'imagery-traced footprint overlaps a mapped residential/service lane',
  86. })
  87. }
  88. if (masks && tree.id === 'oldtown' && !masks.some((mask) => turf.booleanWithin(foot, mask))) {
  89. offMask++
  90. if (offMask <= 3) failures.push(`${tree.id}: ${id} footprint is outside every verified residential mask`)
  91. }
  92. }
  93. summary[tree.id] = {
  94. placed: placedIds.size,
  95. seatedInPlan: plan.buildings.length,
  96. unfitted: manifest.excludedUnfittedIds?.length ?? 0,
  97. cells: manifest.cellCount,
  98. coarseCells: manifest.hierarchy?.coarseCellCount ?? 0,
  99. outsidePrimaryArea: outsideArea,
  100. onMappedTrafficRoad: onRoad,
  101. outsideResidentialMask: offMask,
  102. rootAnchor: tileset.root?.content?.uri || null,
  103. }
  104. }
  105. }
  106. function checkSourceProvenance() {
  107. const plan = JSON.parse(readFileSync(resolve(cityDir, 'oldtown/plan.json')))
  108. const kinds = {}
  109. for (const building of plan.buildings) kinds[building.placementSource] = (kinds[building.placementSource] || 0) + 1
  110. const roadDerived = plan.buildings.filter((b) => /road/i.test(b.placementSource) && !/footprint|segmentation/.test(b.placementSource))
  111. if (roadDerived.length) failures.push(`oldtown: ${roadDerived.length} buildings are still road-derived rather than source-backed`)
  112. // Every module must name the footprint source it came from, and that source must be recorded.
  113. const sourceIds = new Set(plan.sources.map((s) => s.id))
  114. const orphans = plan.buildings.filter((b) => !b.sourceId || !sourceIds.has(b.sourceId))
  115. if (orphans.length) failures.push(`oldtown: ${orphans.length} modules have no recorded footprint source`)
  116. summary.oldtownProvenance = {
  117. placementSources: kinds,
  118. footprintSources: plan.sources.length,
  119. modulesWithRecordedSource: plan.buildings.length - orphans.length,
  120. }
  121. }
  122. checkTrees()
  123. checkSourceProvenance()
  124. const report = {
  125. passed: failures.length === 0,
  126. primaryAreaBounds: [...PRIMARY_AREA_BOUNDS],
  127. roadBufferMeters: ROAD_BUFFER_M,
  128. checkedAt: new Date().toISOString(),
  129. summary,
  130. sourceConflicts: conflicts.slice(0, 40),
  131. sourceConflictCount: conflicts.length,
  132. failures: failures.slice(0, 20),
  133. }
  134. const out = resolve(cityDir, 'city-model-verification.json')
  135. import('node:fs').then(({ writeFileSync }) => writeFileSync(out, JSON.stringify(report, null, 2)))
  136. console.log(JSON.stringify(report, null, 2))
  137. if (failures.length) process.exitCode = 1