// 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