generate-oldtown-fill.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. // Source-backed building plan. A building is only created where an independent footprint source
  2. // says there is one: mapped OSM polygons and imagery-segmented roofs. Road lines are used purely
  3. // as exclusion geometry (never as evidence that a building exists).
  4. import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
  5. import { resolve } from 'node:path'
  6. import { createHash } from 'node:crypto'
  7. import * as turf from '@turf/turf'
  8. import { PRIMARY_AREA_BOUNDS, isWithinPrimaryArea } from './primary-area.mjs'
  9. import { candidateFootprint, isSafeOldTownPlacement } from './oldtown-geometry.mjs'
  10. const dir = resolve('tools/city-massing/city')
  11. const out = resolve(dir, 'oldtown')
  12. mkdirSync(out, { recursive: true })
  13. const masks = JSON.parse(readFileSync(resolve(dir, 'residential-clipped.geojson'))).features
  14. const roads = JSON.parse(readFileSync(resolve(dir, 'roads-clipped.geojson'))).features
  15. const water = JSON.parse(readFileSync(resolve(dir, 'water-clipped.geojson'))).features
  16. const osmBuildings = JSON.parse(readFileSync(resolve(dir, 'osm-buildings-clipped.geojson'))).features
  17. const modeledRegular = JSON.parse(readFileSync(resolve(dir, 'regular-estate-roofs.geojson'))).features
  18. const districtSources = new Set(
  19. JSON.parse(readFileSync(resolve('tools/city-massing/district/lod-preview/manifest.json')))
  20. .cells.flatMap((cell) => cell.sourceIds)
  21. )
  22. const imageryPath = resolve(dir, 'imagery-footprints.geojson')
  23. const imagery = existsSync(imageryPath)
  24. ? JSON.parse(readFileSync(imageryPath)).features
  25. : []
  26. // Mask reliability, decided by reading the 1:1 audit crops
  27. // (tools/city-massing/city/imagery-footprints-verify.png), not by the segmentation itself.
  28. // Mask 63 is a riverbank sliver dominated by greenhouses and field strips; mask 130 is an
  29. // industry park where bright concrete yards read as roofs. Neither is trustworthy as housing, so
  30. // their imagery footprints are excluded instead of being turned into buildings.
  31. const IMAGERY_TRUSTED_MASKS = new Set([65, 66, 126])
  32. // Carriageway exclusion: a built module may sit beside a street but never on the mapped roadway.
  33. const ROAD_BUFFER_M = 3.5
  34. const roadCorridors = roads.map((road) => {
  35. try {
  36. return turf.buffer(road, ROAD_BUFFER_M, { units: 'meters' })
  37. } catch {
  38. return null
  39. }
  40. }).filter(Boolean)
  41. // Families the approved library can carry without distorting facades. Nominal footprints and the
  42. // allowed scale window come from asset-fit.json, the same contract the Blender exporter enforces.
  43. const assetFit = JSON.parse(readFileSync(resolve('tools/city-massing/asset-fit.json'), 'utf8'))
  44. const FAMILIES = Object.entries(assetFit.variants).map(([key, value]) => ({ key, ...value }))
  45. const LATTICE = assetFit.lattice
  46. const VARIANTS = {
  47. house: ['house-2f', 'house-4f', 'house'],
  48. slab: ['slab-4f', 'slab'],
  49. midrise: ['midrise-8f', 'midrise'],
  50. 'midrise-wide': ['midrise-wide', 'midrise'],
  51. }
  52. const roots = []
  53. const excluded = []
  54. const digest = (value) => Number.parseInt(createHash('sha1').update(String(value)).digest('hex').slice(0, 8), 16)
  55. function orientedShape(feature) {
  56. const ring = feature.geometry.coordinates[0].slice(0, -1)
  57. const center = ring.reduce((sum, point) => [sum[0] + point[0] / ring.length, sum[1] + point[1] / ring.length], [0, 0])
  58. const cosLat = Math.cos((center[1] * Math.PI) / 180)
  59. const metresPerLon = 111320 * cosLat
  60. const points = ring.map(([lon, lat]) => [lon * metresPerLon, lat * 111320])
  61. let best = null
  62. for (let i = 0; i < points.length; i++) {
  63. const a = points[i], b = points[(i + 1) % points.length]
  64. const dx = b[0] - a[0], dy = b[1] - a[1], length = Math.hypot(dx, dy)
  65. if (!length) continue
  66. const u = [dx / length, dy / length], v = [-u[1], u[0]]
  67. const projected = points.map((p) => [p[0] * u[0] + p[1] * u[1], p[0] * v[0] + p[1] * v[1]])
  68. const minX = Math.min(...projected.map((p) => p[0])), maxX = Math.max(...projected.map((p) => p[0]))
  69. const minY = Math.min(...projected.map((p) => p[1])), maxY = Math.max(...projected.map((p) => p[1]))
  70. const corners = [[minX, minY], [maxX, minY], [maxX, maxY], [minX, maxY]]
  71. const error = Math.max(...corners.map((corner) => Math.min(
  72. ...projected.map((p) => Math.hypot(p[0] - corner[0], p[1] - corner[1]))
  73. )))
  74. if (!best || error < best.error)
  75. best = { center, width: maxX - minX, depth: maxY - minY, heading: Math.atan2(u[1], u[0]), error, u, v, metresPerLon }
  76. }
  77. return best
  78. }
  79. function familyFor(width, depth) {
  80. const ranked = []
  81. for (const family of FAMILIES) {
  82. const [nominalAlong, nominalAcross] = family.nominal_m
  83. for (let rows = 1; rows <= 6; rows++) {
  84. for (let cols = 1; cols <= 10; cols++) {
  85. if (rows * cols > LATTICE.maxModulesPerFootprint) continue
  86. const moduleAlong = (width - 2 * LATTICE.inset_m - LATTICE.gap_m * (cols - 1)) / cols
  87. const moduleAcross = (depth - 2 * LATTICE.inset_m - LATTICE.gap_m * (rows - 1)) / rows
  88. if (moduleAlong <= 0 || moduleAcross <= 0) continue
  89. const alongScale = moduleAlong / nominalAlong
  90. const acrossScale = moduleAcross / nominalAcross
  91. if (alongScale < family.minScale[0] || alongScale > family.maxScale[0]) continue
  92. if (acrossScale < family.minScale[1] || acrossScale > family.maxScale[1]) continue
  93. // Prefer the family whose natural proportions match the module, then the tiling
  94. // that uses the fewest, least-stretched modules.
  95. ranked.push({
  96. family, rows, cols, moduleAlong, moduleAcross,
  97. alongScale, acrossScale,
  98. penalty: Math.abs(Math.log((nominalAlong / nominalAcross) / (moduleAlong / moduleAcross))) * 0.6 +
  99. Math.abs(Math.log(alongScale)) + Math.abs(Math.log(acrossScale)) +
  100. (rows * cols - 1) * 0.12,
  101. })
  102. }
  103. }
  104. }
  105. ranked.sort((a, b) => a.penalty - b.penalty)
  106. return ranked[0] || null
  107. }
  108. function modulesFor(feature, shape, seed) {
  109. const width = Math.max(shape.width, shape.depth)
  110. const depth = Math.min(shape.width, shape.depth)
  111. const alongIsWidth = shape.width >= shape.depth
  112. const choice = familyFor(width, depth)
  113. if (!choice) return null
  114. const { family, rows, cols } = choice
  115. const gap = LATTICE.gap_m
  116. const heading = alongIsWidth ? shape.heading : shape.heading + Math.PI / 2
  117. // The lattice is laid out inside the source polygon, not just inside its bounding rectangle:
  118. // the measured outline error is removed from the usable area so modules cannot poke outside.
  119. const margin = Math.min(2.5, Math.max(LATTICE.inset_m, shape.error + LATTICE.inset_m))
  120. const usableWidth = width - 2 * margin
  121. const usableDepth = depth - 2 * margin
  122. if (usableWidth <= 0 || usableDepth <= 0) return null
  123. const moduleAlong = (usableWidth - gap * (cols - 1)) / cols
  124. const moduleAcross = (usableDepth - gap * (rows - 1)) / rows
  125. if (moduleAlong <= 0 || moduleAcross <= 0) return null
  126. const alongScale = moduleAlong / family.nominal_m[0]
  127. const acrossScale = moduleAcross / family.nominal_m[1]
  128. if (alongScale < family.minScale[0] || alongScale > family.maxScale[0]) return null
  129. if (acrossScale < family.minScale[1] || acrossScale > family.maxScale[1]) return null
  130. const modules = []
  131. for (let row = 0; row < rows; row++) {
  132. for (let col = 0; col < cols; col++) {
  133. const alongOffset = -usableWidth / 2 + moduleAlong / 2 + col * (moduleAlong + gap)
  134. const acrossOffset = -usableDepth / 2 + moduleAcross / 2 + row * (moduleAcross + gap)
  135. const ux = alongIsWidth ? shape.u[0] : shape.v[0]
  136. const uy = alongIsWidth ? shape.u[1] : shape.v[1]
  137. const vx = alongIsWidth ? shape.v[0] : shape.u[0]
  138. const vy = alongIsWidth ? shape.v[1] : shape.u[1]
  139. const localX = ux * alongOffset + vx * acrossOffset
  140. const localY = uy * alongOffset + vy * acrossOffset
  141. const lon = shape.center[0] + localX / shape.metresPerLon
  142. const lat = shape.center[1] + localY / 111320
  143. let footprint = null
  144. // A module whose corners still escape the source polygon is shrunk before being refused.
  145. for (const shrink of [0, 0.6, 1.2, 1.8]) {
  146. const candidate = candidateFootprint({
  147. lon, lat, width: moduleAlong - shrink, depth: moduleAcross - shrink, heading,
  148. })
  149. if (turf.booleanWithin(candidate, feature)) {
  150. footprint = candidate
  151. break
  152. }
  153. }
  154. if (!footprint) return null
  155. modules.push({
  156. id: `${feature.properties.id}-m${row}-${col}`,
  157. sourceId: feature.properties.id,
  158. sourceKind: feature.properties.sourceKind,
  159. placementSource: feature.properties.sourceKind === 'imagery-segmentation'
  160. ? 'imagery-roof-segmentation'
  161. : 'osm-building-footprint',
  162. sourceMask: null,
  163. sourceEvidence: feature.properties.evidence || 'mapped OSM building polygon',
  164. roadSource: null,
  165. roadType: null,
  166. lon,
  167. lat,
  168. width: moduleAlong,
  169. depth: moduleAcross,
  170. heading,
  171. archetype: family.key,
  172. preferredAssets: VARIANTS[family.key],
  173. floors: family.key === 'house' ? [2, 3, 4][seed % 3]
  174. : family.key === 'slab' ? (seed % 2 ? 4 : 6)
  175. : family.key === 'midrise' ? (seed % 2 ? 8 : 10)
  176. : 10,
  177. heightSource: 'estimated',
  178. sourceFootprintError: shape.error,
  179. sourcePart: row * cols + col,
  180. sourceModuleCount: rows * cols,
  181. })
  182. }
  183. }
  184. return modules
  185. }
  186. function footprintCandidates() {
  187. const list = []
  188. for (const feature of osmBuildings) {
  189. const id = `osm-${feature.properties.osm_id}`
  190. if (districtSources.has(id)) {
  191. excluded.push({ id, reason: 'already represented by the audited district sample' })
  192. continue
  193. }
  194. if (feature.geometry.coordinates[0].length - 1 > 8) {
  195. excluded.push({ id, reason: 'irregular OSM outline; needs custom geometry rather than a rectangle' })
  196. continue
  197. }
  198. list.push({
  199. ...feature,
  200. properties: {
  201. ...feature.properties,
  202. id,
  203. sourceKind: 'osm-building',
  204. evidence: `mapped OSM building polygon ${feature.properties.osm_id}`,
  205. },
  206. })
  207. }
  208. for (const feature of imagery) {
  209. if (!IMAGERY_TRUSTED_MASKS.has(feature.properties.maskId)) {
  210. excluded.push({
  211. id: feature.properties.id,
  212. reason: `imagery mask ${feature.properties.maskId} is not a trusted building area (see imagery-footprints-verify.png)`,
  213. })
  214. continue
  215. }
  216. list.push({
  217. ...feature,
  218. properties: {
  219. ...feature.properties,
  220. sourceKind: 'imagery-segmentation',
  221. evidence: `satellite roof segmentation ${feature.properties.id}`,
  222. },
  223. })
  224. }
  225. return list
  226. }
  227. const candidates = footprintCandidates()
  228. const accepted = []
  229. for (const feature of candidates) {
  230. const id = feature.properties.id
  231. if (!isWithinPrimaryArea(feature)) {
  232. excluded.push({ id, reason: 'footprint outside the configured primary area' })
  233. continue
  234. }
  235. const mask = masks.find((candidate) => turf.booleanWithin(feature, candidate))
  236. if (!mask) {
  237. excluded.push({ id, reason: 'footprint is not fully inside a verified residential mask' })
  238. continue
  239. }
  240. if (water.some((polygon) => turf.booleanIntersects(feature, polygon))) {
  241. excluded.push({ id, reason: 'footprint intersects mapped water' })
  242. continue
  243. }
  244. if (modeledRegular.some((roof) => turf.booleanIntersects(feature, roof))) {
  245. excluded.push({ id, reason: 'overlaps the separately traced regular estate' })
  246. continue
  247. }
  248. const shape = orientedShape(feature)
  249. if (!shape || shape.error > 1.75) {
  250. excluded.push({ id, reason: 'source outline is not rectangular enough for the approved assets' })
  251. continue
  252. }
  253. const modules = modulesFor(feature, shape, digest(id))
  254. if (!modules) {
  255. excluded.push({ id, reason: 'approved asset library cannot carry this footprint without distortion' })
  256. continue
  257. }
  258. const placed = []
  259. let blocked = null
  260. for (const module of modules) {
  261. const footprint = candidateFootprint(module)
  262. if (roadCorridors.some((corridor) => turf.booleanIntersects(footprint, corridor))) {
  263. blocked = 'module would sit on the mapped roadway'
  264. break
  265. }
  266. // Imagery segments are new geometry, so they must also avoid already mapped OSM buildings.
  267. const already = feature.properties.sourceKind === 'imagery-segmentation' ? osmBuildings : []
  268. if (!isSafeOldTownPlacement(footprint, {
  269. mask,
  270. roads,
  271. water,
  272. existing: already,
  273. modeled: modeledRegular,
  274. accepted: accepted.map((entry) => entry.footprint),
  275. })) {
  276. blocked = 'module collides with mapped road, water, building or an accepted module'
  277. break
  278. }
  279. placed.push({ module, footprint })
  280. }
  281. if (blocked || placed.length !== modules.length) {
  282. excluded.push({ id, reason: blocked || 'part of the source footprint could not be placed safely' })
  283. continue
  284. }
  285. for (const entry of placed)
  286. accepted.push({ ...entry.module, sourceMask: `resa-${mask.properties.ORIG_FID}`, footprint: entry.footprint })
  287. roots.push({
  288. id,
  289. sourceKind: feature.properties.sourceKind,
  290. maskId: mask.properties.ORIG_FID,
  291. moduleIds: placed.map((entry) => entry.module.id),
  292. areaSqm: Math.round(turf.area(feature)),
  293. longSideM: Math.round(Math.max(shape.width, shape.depth) * 10) / 10,
  294. shortSideM: Math.round(Math.min(shape.width, shape.depth) * 10) / 10,
  295. headingDeg: Math.round(((shape.heading * 180) / Math.PI) * 10) / 10,
  296. outlineErrorM: Math.round(shape.error * 100) / 100,
  297. })
  298. }
  299. const reasonCounts = {}
  300. for (const entry of excluded) reasonCounts[entry.reason] = (reasonCounts[entry.reason] || 0) + 1
  301. const plan = {
  302. version: 4,
  303. seed: 20260924,
  304. scope: 'source-backed urban fill from mapped OSM polygons and satellite roof segmentation',
  305. bounds: [...PRIMARY_AREA_BOUNDS],
  306. origin: { lon: 110.395, lat: 28.454 },
  307. source: 'mapped OSM building polygons + satellite roof segmentation; road/water/mask exclusion',
  308. policy: 'A building exists only where a footprint source says so. Roads are never building evidence.',
  309. buildings: accepted.map(({ footprint, ...module }) => module),
  310. sources: roots,
  311. excluded,
  312. exclusionReasons: reasonCounts,
  313. sourceMasks: masks.map((mask) => mask.properties.ORIG_FID),
  314. disclaimer:
  315. 'Illustrative massing on source footprints; heights/facades estimated. Missing or irregular source footprints remain unmodeled rather than guessed.',
  316. }
  317. plan.sha256 = createHash('sha256').update(JSON.stringify(plan.buildings)).digest('hex')
  318. writeFileSync(resolve(out, 'plan.json'), JSON.stringify(plan, null, 2))
  319. console.log(JSON.stringify({
  320. footprintCandidates: candidates.length,
  321. osmCandidates: osmBuildings.length,
  322. imageryCandidates: imagery.length,
  323. sourceBuildings: roots.length,
  324. modules: plan.buildings.length,
  325. byKind: {
  326. osm: roots.filter((r) => r.sourceKind === 'osm-building').length,
  327. imagery: roots.filter((r) => r.sourceKind === 'imagery-segmentation').length,
  328. },
  329. byAsset: Object.fromEntries([...new Set(plan.buildings.map((b) => b.archetype))]
  330. .map((key) => [key, plan.buildings.filter((b) => b.archetype === key).length])),
  331. exclusionReasons: reasonCounts,
  332. sha256: plan.sha256,
  333. }))