// Offline survey/active-model gap audit. A missing ID is not automatically a missing building. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' import { resolve, join } from 'node:path' import { createHash } from 'node:crypto' import * as turf from '@turf/turf' const root = resolve('tools/city-massing/city/cad-bim') const out = resolve(process.argv[2] || '.scratch/cockpit-shell-panel-layout/verify/artifacts/t35/large-building-audit') mkdirSync(out, { recursive: true }) const read = (file) => JSON.parse(readFileSync(file, 'utf8')) const plan = read(join(root, 'plan.json')), seated = read(join(root, 'seated-plan.json')) const survey = read(resolve('tools/city-massing/city/cad-buildings.geojson')).features const names = ['pilot', 'batch-urban8000-dedup', 'batch-urban8000b', 'batch-urban8000c', 'batch-urban-final', 'batch-urban-tail', 'batch-unfitted-fallback'].map((name) => `lod-${name}-roofed2`) const active = new Set(names.flatMap((name) => read(join(root, name, 'manifest.json')).cells.flatMap((c) => c.buildingIds))) const seatedById = new Map(seated.buildings.map((b) => [b.id, b])) const excluded = new Map(seated.excluded.map((b) => [b.id, b])) const planned = new Map(plan.buildings.map((b) => [b.id, b])) const featureFor = (id) => survey[Number(id.slice(4))] const grid = new Map(), gridSize = 80 const bboxENU = (b) => [Math.min(...b.footprintENU.map((p) => p[0])), Math.min(...b.footprintENU.map((p) => p[1])), Math.max(...b.footprintENU.map((p) => p[0])), Math.max(...b.footprintENU.map((p) => p[1]))] function keys(box) { const result = [] for (let x = Math.floor(box[0] / gridSize); x <= Math.floor(box[2] / gridSize); x++) for (let y = Math.floor(box[1] / gridSize); y <= Math.floor(box[3] / gridSize); y++) result.push(`${x},${y}`) return result } for (const id of active) for (const key of keys(bboxENU(planned.get(id)))) { if (!grid.has(key)) grid.set(key, []) grid.get(key).push(id) } const candidates = [] for (const b of plan.buildings.filter((b) => b.areaSqm >= 250 && !active.has(b.id)).sort((a, b) => b.areaSqm - a.areaSqm)) { const feature = featureFor(b.id) const nearby = new Set(keys(bboxENU(b)).flatMap((key) => grid.get(key) || [])) let bestOverlap = 0, overlapId = null for (const id of nearby) { const intersection = turf.intersect(turf.featureCollection([feature, featureFor(id)])) const fraction = intersection ? turf.area(intersection) / turf.area(feature) : 0 if (fraction > bestOverlap) { bestOverlap = fraction; overlapId = id } } const anchor = plan.samples[b.sampleIndices.at(-1)] const status = bestOverlap >= 0.8 ? 'covered-by-overlapping-survey-outline' : seatedById.has(b.id) ? 'seated-outside-active-selection' : excluded.get(b.id)?.reason || 'not-exported' candidates.push({ id: b.id, tier: b.tier, maskId: b.maskId, areaSqm: b.areaSqm, width: b.width, depth: b.depth, center: [anchor.lon, anchor.lat], status, overlapFraction: +bestOverlap.toFixed(4), overlapId, terrainRelief: excluded.get(b.id)?.relief ?? seatedById.get(b.id)?.terrainRelief, source: '1:500 surveyed JMD polygon; height/facade estimated', imageryReviewed: false, geometry: feature.geometry }) } const counts = {} for (const b of candidates) counts[b.status] = (counts[b.status] || 0) + 1 const report = { activeBuildings: active.size, thresholdSqm: 250, sourcePlanSha256: createHash('sha256').update(readFileSync(join(root, 'plan.json'))).digest('hex'), candidateCount: candidates.length, urbanCandidates: candidates.filter((b) => b.tier === 'urban').length, 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.' } writeFileSync(join(out, 'audit.json'), JSON.stringify(report, null, 2)) writeFileSync(join(out, 'candidates.geojson'), JSON.stringify(turf.featureCollection(candidates.map(({ geometry, ...properties }) => turf.feature(geometry, properties))))) console.log(JSON.stringify({ out, activeBuildings: active.size, candidateCount: candidates.length, counts }))