audit-large-building-coverage.mjs 4.1 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
  1. // Offline survey/active-model gap audit. A missing ID is not automatically a missing building.
  2. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
  3. import { resolve, join } from 'node:path'
  4. import { createHash } from 'node:crypto'
  5. import * as turf from '@turf/turf'
  6. const root = resolve('tools/city-massing/city/cad-bim')
  7. const out = resolve(process.argv[2] || '.scratch/cockpit-shell-panel-layout/verify/artifacts/t35/large-building-audit')
  8. mkdirSync(out, { recursive: true })
  9. const read = (file) => JSON.parse(readFileSync(file, 'utf8'))
  10. const plan = read(join(root, 'plan.json')), seated = read(join(root, 'seated-plan.json'))
  11. const survey = read(resolve('tools/city-massing/city/cad-buildings.geojson')).features
  12. const names = ['pilot', 'batch-urban8000-dedup', 'batch-urban8000b', 'batch-urban8000c',
  13. 'batch-urban-final', 'batch-urban-tail', 'batch-unfitted-fallback'].map((name) => `lod-${name}-roofed2`)
  14. const active = new Set(names.flatMap((name) => read(join(root, name, 'manifest.json')).cells.flatMap((c) => c.buildingIds)))
  15. const seatedById = new Map(seated.buildings.map((b) => [b.id, b]))
  16. const excluded = new Map(seated.excluded.map((b) => [b.id, b]))
  17. const planned = new Map(plan.buildings.map((b) => [b.id, b]))
  18. const featureFor = (id) => survey[Number(id.slice(4))]
  19. const grid = new Map(), gridSize = 80
  20. const bboxENU = (b) => [Math.min(...b.footprintENU.map((p) => p[0])), Math.min(...b.footprintENU.map((p) => p[1])),
  21. Math.max(...b.footprintENU.map((p) => p[0])), Math.max(...b.footprintENU.map((p) => p[1]))]
  22. function keys(box) {
  23. const result = []
  24. for (let x = Math.floor(box[0] / gridSize); x <= Math.floor(box[2] / gridSize); x++)
  25. for (let y = Math.floor(box[1] / gridSize); y <= Math.floor(box[3] / gridSize); y++) result.push(`${x},${y}`)
  26. return result
  27. }
  28. for (const id of active) for (const key of keys(bboxENU(planned.get(id)))) {
  29. if (!grid.has(key)) grid.set(key, [])
  30. grid.get(key).push(id)
  31. }
  32. const candidates = []
  33. for (const b of plan.buildings.filter((b) => b.areaSqm >= 250 && !active.has(b.id)).sort((a, b) => b.areaSqm - a.areaSqm)) {
  34. const feature = featureFor(b.id)
  35. const nearby = new Set(keys(bboxENU(b)).flatMap((key) => grid.get(key) || []))
  36. let bestOverlap = 0, overlapId = null
  37. for (const id of nearby) {
  38. const intersection = turf.intersect(turf.featureCollection([feature, featureFor(id)]))
  39. const fraction = intersection ? turf.area(intersection) / turf.area(feature) : 0
  40. if (fraction > bestOverlap) { bestOverlap = fraction; overlapId = id }
  41. }
  42. const anchor = plan.samples[b.sampleIndices.at(-1)]
  43. const status = bestOverlap >= 0.8 ? 'covered-by-overlapping-survey-outline'
  44. : seatedById.has(b.id) ? 'seated-outside-active-selection' : excluded.get(b.id)?.reason || 'not-exported'
  45. candidates.push({ id: b.id, tier: b.tier, maskId: b.maskId, areaSqm: b.areaSqm, width: b.width, depth: b.depth,
  46. center: [anchor.lon, anchor.lat], status, overlapFraction: +bestOverlap.toFixed(4), overlapId,
  47. terrainRelief: excluded.get(b.id)?.relief ?? seatedById.get(b.id)?.terrainRelief,
  48. source: '1:500 surveyed JMD polygon; height/facade estimated', imageryReviewed: false,
  49. geometry: feature.geometry })
  50. }
  51. const counts = {}
  52. for (const b of candidates) counts[b.status] = (counts[b.status] || 0) + 1
  53. const report = { activeBuildings: active.size, thresholdSqm: 250, sourcePlanSha256: createHash('sha256').update(readFileSync(join(root, 'plan.json'))).digest('hex'),
  54. candidateCount: candidates.length, urbanCandidates: candidates.filter((b) => b.tier === 'urban').length,
  55. counts, candidates, caveat: 'Candidate IDs are not confirmed missing buildings. Review imagery, partial overlaps, roads and DEM before adding. Do not restore scattered rural houses wholesale.' }
  56. writeFileSync(join(out, 'audit.json'), JSON.stringify(report, null, 2))
  57. writeFileSync(join(out, 'candidates.geojson'), JSON.stringify(turf.featureCollection(candidates.map(({ geometry, ...properties }) => turf.feature(geometry, properties)))))
  58. console.log(JSON.stringify({ out, activeBuildings: active.size, candidateCount: candidates.length, counts }))