| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345 |
- // Source-backed building plan. A building is only created where an independent footprint source
- // says there is one: mapped OSM polygons and imagery-segmented roofs. Road lines are used purely
- // as exclusion geometry (never as evidence that a building exists).
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
- import { resolve } from 'node:path'
- import { createHash } from 'node:crypto'
- import * as turf from '@turf/turf'
- import { PRIMARY_AREA_BOUNDS, isWithinPrimaryArea } from './primary-area.mjs'
- import { candidateFootprint, isSafeOldTownPlacement } from './oldtown-geometry.mjs'
- const dir = resolve('tools/city-massing/city')
- const out = resolve(dir, 'oldtown')
- mkdirSync(out, { recursive: true })
- const masks = JSON.parse(readFileSync(resolve(dir, 'residential-clipped.geojson'))).features
- const roads = JSON.parse(readFileSync(resolve(dir, 'roads-clipped.geojson'))).features
- const water = JSON.parse(readFileSync(resolve(dir, 'water-clipped.geojson'))).features
- const osmBuildings = JSON.parse(readFileSync(resolve(dir, 'osm-buildings-clipped.geojson'))).features
- const modeledRegular = JSON.parse(readFileSync(resolve(dir, 'regular-estate-roofs.geojson'))).features
- const districtSources = new Set(
- JSON.parse(readFileSync(resolve('tools/city-massing/district/lod-preview/manifest.json')))
- .cells.flatMap((cell) => cell.sourceIds)
- )
- const imageryPath = resolve(dir, 'imagery-footprints.geojson')
- const imagery = existsSync(imageryPath)
- ? JSON.parse(readFileSync(imageryPath)).features
- : []
- // Mask reliability, decided by reading the 1:1 audit crops
- // (tools/city-massing/city/imagery-footprints-verify.png), not by the segmentation itself.
- // Mask 63 is a riverbank sliver dominated by greenhouses and field strips; mask 130 is an
- // industry park where bright concrete yards read as roofs. Neither is trustworthy as housing, so
- // their imagery footprints are excluded instead of being turned into buildings.
- const IMAGERY_TRUSTED_MASKS = new Set([65, 66, 126])
- // Carriageway exclusion: a built module may sit beside a street but never on the mapped roadway.
- const ROAD_BUFFER_M = 3.5
- const roadCorridors = roads.map((road) => {
- try {
- return turf.buffer(road, ROAD_BUFFER_M, { units: 'meters' })
- } catch {
- return null
- }
- }).filter(Boolean)
- // Families the approved library can carry without distorting facades. Nominal footprints and the
- // allowed scale window come from asset-fit.json, the same contract the Blender exporter enforces.
- const assetFit = JSON.parse(readFileSync(resolve('tools/city-massing/asset-fit.json'), 'utf8'))
- const FAMILIES = Object.entries(assetFit.variants).map(([key, value]) => ({ key, ...value }))
- const LATTICE = assetFit.lattice
- const VARIANTS = {
- house: ['house-2f', 'house-4f', 'house'],
- slab: ['slab-4f', 'slab'],
- midrise: ['midrise-8f', 'midrise'],
- 'midrise-wide': ['midrise-wide', 'midrise'],
- }
- const roots = []
- const excluded = []
- const digest = (value) => Number.parseInt(createHash('sha1').update(String(value)).digest('hex').slice(0, 8), 16)
- function orientedShape(feature) {
- const ring = feature.geometry.coordinates[0].slice(0, -1)
- const center = ring.reduce((sum, point) => [sum[0] + point[0] / ring.length, sum[1] + point[1] / ring.length], [0, 0])
- const cosLat = Math.cos((center[1] * Math.PI) / 180)
- const metresPerLon = 111320 * cosLat
- const points = ring.map(([lon, lat]) => [lon * metresPerLon, lat * 111320])
- let best = null
- for (let i = 0; i < points.length; i++) {
- const a = points[i], b = points[(i + 1) % points.length]
- const dx = b[0] - a[0], dy = b[1] - a[1], length = Math.hypot(dx, dy)
- if (!length) continue
- const u = [dx / length, dy / length], v = [-u[1], u[0]]
- const projected = points.map((p) => [p[0] * u[0] + p[1] * u[1], p[0] * v[0] + p[1] * v[1]])
- const minX = Math.min(...projected.map((p) => p[0])), maxX = Math.max(...projected.map((p) => p[0]))
- const minY = Math.min(...projected.map((p) => p[1])), maxY = Math.max(...projected.map((p) => p[1]))
- const corners = [[minX, minY], [maxX, minY], [maxX, maxY], [minX, maxY]]
- const error = Math.max(...corners.map((corner) => Math.min(
- ...projected.map((p) => Math.hypot(p[0] - corner[0], p[1] - corner[1]))
- )))
- if (!best || error < best.error)
- best = { center, width: maxX - minX, depth: maxY - minY, heading: Math.atan2(u[1], u[0]), error, u, v, metresPerLon }
- }
- return best
- }
- function familyFor(width, depth) {
- const ranked = []
- for (const family of FAMILIES) {
- const [nominalAlong, nominalAcross] = family.nominal_m
- for (let rows = 1; rows <= 6; rows++) {
- for (let cols = 1; cols <= 10; cols++) {
- if (rows * cols > LATTICE.maxModulesPerFootprint) continue
- const moduleAlong = (width - 2 * LATTICE.inset_m - LATTICE.gap_m * (cols - 1)) / cols
- const moduleAcross = (depth - 2 * LATTICE.inset_m - LATTICE.gap_m * (rows - 1)) / rows
- if (moduleAlong <= 0 || moduleAcross <= 0) continue
- const alongScale = moduleAlong / nominalAlong
- const acrossScale = moduleAcross / nominalAcross
- if (alongScale < family.minScale[0] || alongScale > family.maxScale[0]) continue
- if (acrossScale < family.minScale[1] || acrossScale > family.maxScale[1]) continue
- // Prefer the family whose natural proportions match the module, then the tiling
- // that uses the fewest, least-stretched modules.
- ranked.push({
- family, rows, cols, moduleAlong, moduleAcross,
- alongScale, acrossScale,
- penalty: Math.abs(Math.log((nominalAlong / nominalAcross) / (moduleAlong / moduleAcross))) * 0.6 +
- Math.abs(Math.log(alongScale)) + Math.abs(Math.log(acrossScale)) +
- (rows * cols - 1) * 0.12,
- })
- }
- }
- }
- ranked.sort((a, b) => a.penalty - b.penalty)
- return ranked[0] || null
- }
- function modulesFor(feature, shape, seed) {
- const width = Math.max(shape.width, shape.depth)
- const depth = Math.min(shape.width, shape.depth)
- const alongIsWidth = shape.width >= shape.depth
- const choice = familyFor(width, depth)
- if (!choice) return null
- const { family, rows, cols } = choice
- const gap = LATTICE.gap_m
- const heading = alongIsWidth ? shape.heading : shape.heading + Math.PI / 2
- // The lattice is laid out inside the source polygon, not just inside its bounding rectangle:
- // the measured outline error is removed from the usable area so modules cannot poke outside.
- const margin = Math.min(2.5, Math.max(LATTICE.inset_m, shape.error + LATTICE.inset_m))
- const usableWidth = width - 2 * margin
- const usableDepth = depth - 2 * margin
- if (usableWidth <= 0 || usableDepth <= 0) return null
- const moduleAlong = (usableWidth - gap * (cols - 1)) / cols
- const moduleAcross = (usableDepth - gap * (rows - 1)) / rows
- if (moduleAlong <= 0 || moduleAcross <= 0) return null
- const alongScale = moduleAlong / family.nominal_m[0]
- const acrossScale = moduleAcross / family.nominal_m[1]
- if (alongScale < family.minScale[0] || alongScale > family.maxScale[0]) return null
- if (acrossScale < family.minScale[1] || acrossScale > family.maxScale[1]) return null
- const modules = []
- for (let row = 0; row < rows; row++) {
- for (let col = 0; col < cols; col++) {
- const alongOffset = -usableWidth / 2 + moduleAlong / 2 + col * (moduleAlong + gap)
- const acrossOffset = -usableDepth / 2 + moduleAcross / 2 + row * (moduleAcross + gap)
- const ux = alongIsWidth ? shape.u[0] : shape.v[0]
- const uy = alongIsWidth ? shape.u[1] : shape.v[1]
- const vx = alongIsWidth ? shape.v[0] : shape.u[0]
- const vy = alongIsWidth ? shape.v[1] : shape.u[1]
- const localX = ux * alongOffset + vx * acrossOffset
- const localY = uy * alongOffset + vy * acrossOffset
- const lon = shape.center[0] + localX / shape.metresPerLon
- const lat = shape.center[1] + localY / 111320
- let footprint = null
- // A module whose corners still escape the source polygon is shrunk before being refused.
- for (const shrink of [0, 0.6, 1.2, 1.8]) {
- const candidate = candidateFootprint({
- lon, lat, width: moduleAlong - shrink, depth: moduleAcross - shrink, heading,
- })
- if (turf.booleanWithin(candidate, feature)) {
- footprint = candidate
- break
- }
- }
- if (!footprint) return null
- modules.push({
- id: `${feature.properties.id}-m${row}-${col}`,
- sourceId: feature.properties.id,
- sourceKind: feature.properties.sourceKind,
- placementSource: feature.properties.sourceKind === 'imagery-segmentation'
- ? 'imagery-roof-segmentation'
- : 'osm-building-footprint',
- sourceMask: null,
- sourceEvidence: feature.properties.evidence || 'mapped OSM building polygon',
- roadSource: null,
- roadType: null,
- lon,
- lat,
- width: moduleAlong,
- depth: moduleAcross,
- heading,
- archetype: family.key,
- preferredAssets: VARIANTS[family.key],
- floors: family.key === 'house' ? [2, 3, 4][seed % 3]
- : family.key === 'slab' ? (seed % 2 ? 4 : 6)
- : family.key === 'midrise' ? (seed % 2 ? 8 : 10)
- : 10,
- heightSource: 'estimated',
- sourceFootprintError: shape.error,
- sourcePart: row * cols + col,
- sourceModuleCount: rows * cols,
- })
- }
- }
- return modules
- }
- function footprintCandidates() {
- const list = []
- for (const feature of osmBuildings) {
- const id = `osm-${feature.properties.osm_id}`
- if (districtSources.has(id)) {
- excluded.push({ id, reason: 'already represented by the audited district sample' })
- continue
- }
- if (feature.geometry.coordinates[0].length - 1 > 8) {
- excluded.push({ id, reason: 'irregular OSM outline; needs custom geometry rather than a rectangle' })
- continue
- }
- list.push({
- ...feature,
- properties: {
- ...feature.properties,
- id,
- sourceKind: 'osm-building',
- evidence: `mapped OSM building polygon ${feature.properties.osm_id}`,
- },
- })
- }
- for (const feature of imagery) {
- if (!IMAGERY_TRUSTED_MASKS.has(feature.properties.maskId)) {
- excluded.push({
- id: feature.properties.id,
- reason: `imagery mask ${feature.properties.maskId} is not a trusted building area (see imagery-footprints-verify.png)`,
- })
- continue
- }
- list.push({
- ...feature,
- properties: {
- ...feature.properties,
- sourceKind: 'imagery-segmentation',
- evidence: `satellite roof segmentation ${feature.properties.id}`,
- },
- })
- }
- return list
- }
- const candidates = footprintCandidates()
- const accepted = []
- for (const feature of candidates) {
- const id = feature.properties.id
- if (!isWithinPrimaryArea(feature)) {
- excluded.push({ id, reason: 'footprint outside the configured primary area' })
- continue
- }
- const mask = masks.find((candidate) => turf.booleanWithin(feature, candidate))
- if (!mask) {
- excluded.push({ id, reason: 'footprint is not fully inside a verified residential mask' })
- continue
- }
- if (water.some((polygon) => turf.booleanIntersects(feature, polygon))) {
- excluded.push({ id, reason: 'footprint intersects mapped water' })
- continue
- }
- if (modeledRegular.some((roof) => turf.booleanIntersects(feature, roof))) {
- excluded.push({ id, reason: 'overlaps the separately traced regular estate' })
- continue
- }
- const shape = orientedShape(feature)
- if (!shape || shape.error > 1.75) {
- excluded.push({ id, reason: 'source outline is not rectangular enough for the approved assets' })
- continue
- }
- const modules = modulesFor(feature, shape, digest(id))
- if (!modules) {
- excluded.push({ id, reason: 'approved asset library cannot carry this footprint without distortion' })
- continue
- }
- const placed = []
- let blocked = null
- for (const module of modules) {
- const footprint = candidateFootprint(module)
- if (roadCorridors.some((corridor) => turf.booleanIntersects(footprint, corridor))) {
- blocked = 'module would sit on the mapped roadway'
- break
- }
- // Imagery segments are new geometry, so they must also avoid already mapped OSM buildings.
- const already = feature.properties.sourceKind === 'imagery-segmentation' ? osmBuildings : []
- if (!isSafeOldTownPlacement(footprint, {
- mask,
- roads,
- water,
- existing: already,
- modeled: modeledRegular,
- accepted: accepted.map((entry) => entry.footprint),
- })) {
- blocked = 'module collides with mapped road, water, building or an accepted module'
- break
- }
- placed.push({ module, footprint })
- }
- if (blocked || placed.length !== modules.length) {
- excluded.push({ id, reason: blocked || 'part of the source footprint could not be placed safely' })
- continue
- }
- for (const entry of placed)
- accepted.push({ ...entry.module, sourceMask: `resa-${mask.properties.ORIG_FID}`, footprint: entry.footprint })
- roots.push({
- id,
- sourceKind: feature.properties.sourceKind,
- maskId: mask.properties.ORIG_FID,
- moduleIds: placed.map((entry) => entry.module.id),
- areaSqm: Math.round(turf.area(feature)),
- longSideM: Math.round(Math.max(shape.width, shape.depth) * 10) / 10,
- shortSideM: Math.round(Math.min(shape.width, shape.depth) * 10) / 10,
- headingDeg: Math.round(((shape.heading * 180) / Math.PI) * 10) / 10,
- outlineErrorM: Math.round(shape.error * 100) / 100,
- })
- }
- const reasonCounts = {}
- for (const entry of excluded) reasonCounts[entry.reason] = (reasonCounts[entry.reason] || 0) + 1
- const plan = {
- version: 4,
- seed: 20260924,
- scope: 'source-backed urban fill from mapped OSM polygons and satellite roof segmentation',
- bounds: [...PRIMARY_AREA_BOUNDS],
- origin: { lon: 110.395, lat: 28.454 },
- source: 'mapped OSM building polygons + satellite roof segmentation; road/water/mask exclusion',
- policy: 'A building exists only where a footprint source says so. Roads are never building evidence.',
- buildings: accepted.map(({ footprint, ...module }) => module),
- sources: roots,
- excluded,
- exclusionReasons: reasonCounts,
- sourceMasks: masks.map((mask) => mask.properties.ORIG_FID),
- disclaimer:
- 'Illustrative massing on source footprints; heights/facades estimated. Missing or irregular source footprints remain unmodeled rather than guessed.',
- }
- plan.sha256 = createHash('sha256').update(JSON.stringify(plan.buildings)).digest('hex')
- writeFileSync(resolve(out, 'plan.json'), JSON.stringify(plan, null, 2))
- console.log(JSON.stringify({
- footprintCandidates: candidates.length,
- osmCandidates: osmBuildings.length,
- imageryCandidates: imagery.length,
- sourceBuildings: roots.length,
- modules: plan.buildings.length,
- byKind: {
- osm: roots.filter((r) => r.sourceKind === 'osm-building').length,
- imagery: roots.filter((r) => r.sourceKind === 'imagery-segmentation').length,
- },
- byAsset: Object.fromEntries([...new Set(plan.buildings.map((b) => b.archetype))]
- .map((key) => [key, plan.buildings.filter((b) => b.archetype === key).length])),
- exclusionReasons: reasonCounts,
- sha256: plan.sha256,
- }))
|