| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144 |
- // Acceptance checks for the generated city models: primary-area containment, road clearance and
- // source provenance. Reads the exported LOD manifests so it validates what Cesium actually loads.
- import { readFileSync } from 'node:fs'
- import { resolve } from 'node:path'
- import * as turf from '@turf/turf'
- import { PRIMARY_AREA_BOUNDS, isWithinPrimaryArea } from './primary-area.mjs'
- import { candidateFootprint } from './oldtown-geometry.mjs'
- const cityDir = resolve('tools/city-massing/city')
- const ROAD_BUFFER_M = 3.5
- // Roads that carry traffic: a building overlapping one of these is an outright defect.
- const HARD_ROAD_TYPES = new Set(['trunk', 'trunk_link', 'primary', 'primary_link', 'secondary', 'secondary_link', 'tertiary', 'tertiary_link', 'motorway', 'motorway_link'])
- // Hand-mapped estate lanes and service ways disagree with imagery-traced roofs in this dataset.
- // Those are recorded as source conflicts for a human to settle, not silently resolved either way.
- const CONFLICT_ROAD_TYPES = new Set(['residential', 'unclassified', 'service', 'living_street', 'pedestrian', 'track'])
- const failures = []
- const conflicts = []
- const summary = {}
- function feature(building) {
- // Plans differ in where the centre lives: city plans use lon/lat, the district plan uses center.
- const lon = building.lon ?? building.center?.lon
- const lat = building.lat ?? building.center?.lat
- if (!Number.isFinite(lon) || !Number.isFinite(lat))
- throw new Error(`Building ${building.id} has no usable centre coordinate`)
- return candidateFootprint({ lon, lat, width: building.width, depth: building.depth, heading: building.heading })
- }
- function checkTrees() {
- const trees = [
- { id: 'oldtown', dir: resolve(cityDir, 'oldtown/lod-preview'), plan: resolve(cityDir, 'oldtown/seated-plan.json') },
- { id: 'regular-estate', dir: resolve(cityDir, 'regular-estate/lod-preview'), plan: resolve(cityDir, 'regular-estate/seated-plan.json') },
- { id: 'district', dir: resolve('tools/city-massing/district/lod-preview'), plan: resolve('tools/city-massing/district/seated-plan.json') },
- ]
- const roads = JSON.parse(readFileSync(resolve(cityDir, 'roads-clipped.geojson'))).features
- const buffersOf = (types) => roads
- .filter((road) => types.has(road.properties?.type || 'unclassified'))
- .map((road) => {
- try {
- return { road, area: turf.buffer(road, ROAD_BUFFER_M, { units: 'meters' }) }
- } catch {
- return null
- }
- })
- .filter((entry) => entry && entry.area)
- const hardRoads = buffersOf(HARD_ROAD_TYPES)
- const conflictRoads = buffersOf(CONFLICT_ROAD_TYPES)
- for (const tree of trees) {
- const tileset = JSON.parse(readFileSync(resolve(tree.dir, 'tileset.json')))
- const manifest = JSON.parse(readFileSync(resolve(tree.dir, 'manifest.json')))
- const plan = JSON.parse(readFileSync(tree.plan))
- const placedIds = new Set(manifest.cells.flatMap((cell) => cell.buildingIds))
- const byId = new Map(plan.buildings.map((b) => [b.id, b]))
- // Every reported count must be traceable to a seated building.
- const missing = [...placedIds].filter((id) => !byId.has(id))
- if (missing.length) failures.push(`${tree.id}: ${missing.length} placed ids have no seated building`)
- let outsideArea = 0
- let onRoad = 0
- let offMask = 0
- const masks = tree.id === 'district'
- ? null
- : JSON.parse(readFileSync(resolve(cityDir, 'residential-clipped.geojson'))).features
- for (const id of placedIds) {
- const building = byId.get(id)
- if (!building) continue
- const foot = feature(building)
- if (!isWithinPrimaryArea(foot)) {
- outsideArea++
- if (outsideArea <= 3) failures.push(`${tree.id}: ${id} footprint outside the primary area`)
- }
- if (hardRoads.some((entry) => turf.booleanIntersects(foot, entry.area))) {
- onRoad++
- if (onRoad <= 3) failures.push(`${tree.id}: ${id} footprint sits on a mapped traffic road`)
- }
- for (const entry of conflictRoads) {
- if (!turf.booleanIntersects(foot, entry.area)) continue
- const overlap = turf.intersect(turf.featureCollection([foot, entry.area]))
- const overlapArea = overlap ? turf.area(overlap) : 0
- const share = overlapArea / turf.area(foot)
- // Only meaningful overlap counts: a graze against a lane edge is noise.
- if (share >= 0.15)
- conflicts.push({
- tree: tree.id,
- building: id,
- roadOsmId: entry.road.properties?.osm_id,
- roadType: entry.road.properties?.type,
- overlapPercent: Math.round(share * 100),
- note: 'imagery-traced footprint overlaps a mapped residential/service lane',
- })
- }
- if (masks && tree.id === 'oldtown' && !masks.some((mask) => turf.booleanWithin(foot, mask))) {
- offMask++
- if (offMask <= 3) failures.push(`${tree.id}: ${id} footprint is outside every verified residential mask`)
- }
- }
- summary[tree.id] = {
- placed: placedIds.size,
- seatedInPlan: plan.buildings.length,
- unfitted: manifest.excludedUnfittedIds?.length ?? 0,
- cells: manifest.cellCount,
- coarseCells: manifest.hierarchy?.coarseCellCount ?? 0,
- outsidePrimaryArea: outsideArea,
- onMappedTrafficRoad: onRoad,
- outsideResidentialMask: offMask,
- rootAnchor: tileset.root?.content?.uri || null,
- }
- }
- }
- function checkSourceProvenance() {
- const plan = JSON.parse(readFileSync(resolve(cityDir, 'oldtown/plan.json')))
- const kinds = {}
- for (const building of plan.buildings) kinds[building.placementSource] = (kinds[building.placementSource] || 0) + 1
- const roadDerived = plan.buildings.filter((b) => /road/i.test(b.placementSource) && !/footprint|segmentation/.test(b.placementSource))
- if (roadDerived.length) failures.push(`oldtown: ${roadDerived.length} buildings are still road-derived rather than source-backed`)
- // Every module must name the footprint source it came from, and that source must be recorded.
- const sourceIds = new Set(plan.sources.map((s) => s.id))
- const orphans = plan.buildings.filter((b) => !b.sourceId || !sourceIds.has(b.sourceId))
- if (orphans.length) failures.push(`oldtown: ${orphans.length} modules have no recorded footprint source`)
- summary.oldtownProvenance = {
- placementSources: kinds,
- footprintSources: plan.sources.length,
- modulesWithRecordedSource: plan.buildings.length - orphans.length,
- }
- }
- checkTrees()
- checkSourceProvenance()
- const report = {
- passed: failures.length === 0,
- primaryAreaBounds: [...PRIMARY_AREA_BOUNDS],
- roadBufferMeters: ROAD_BUFFER_M,
- checkedAt: new Date().toISOString(),
- summary,
- sourceConflicts: conflicts.slice(0, 40),
- sourceConflictCount: conflicts.length,
- failures: failures.slice(0, 20),
- }
- const out = resolve(cityDir, 'city-model-verification.json')
- import('node:fs').then(({ writeFileSync }) => writeFileSync(out, JSON.stringify(report, null, 2)))
- console.log(JSON.stringify(report, null, 2))
- if (failures.length) process.exitCode = 1
|