prepare-cad-bim.mjs 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. // Turn the surveyed CAD footprints into a BIM plan: ENU geometry, tier, height and asset choice.
  2. // Footprints that are close to a rectangle use an approved library asset (windows, balconies, roof
  3. // detail); everything else keeps its real outline as an extruded massing prism, so no building is
  4. // forced into a rectangle it does not have.
  5. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
  6. import { resolve } from 'node:path'
  7. import { createHash } from 'node:crypto'
  8. import { Cartesian3, Cartographic, Matrix4, Transforms } from 'cesium'
  9. import * as turf from '@turf/turf'
  10. import { PRIMARY_AREA_BOUNDS, isWithinPrimaryArea } from './primary-area.mjs'
  11. const cityDir = resolve('tools/city-massing/city')
  12. const OUT = resolve(cityDir, 'cad-bim')
  13. mkdirSync(OUT, { recursive: true })
  14. const footprints = JSON.parse(readFileSync(resolve(cityDir, 'cad-buildings.geojson'), 'utf8')).features
  15. const masks = JSON.parse(readFileSync(resolve(cityDir, 'residential-clipped.geojson'))).features
  16. const assetFit = JSON.parse(readFileSync(resolve('tools/city-massing/asset-fit.json'), 'utf8'))
  17. const library = JSON.parse(readFileSync(resolve('tools/city-massing/house-library/library.json'), 'utf8')).archetypes
  18. const origin = { lon: 110.395, lat: 28.454 }
  19. const transform = Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(origin.lon, origin.lat))
  20. const inverse = Matrix4.inverseTransformation(transform, new Matrix4())
  21. const toLocal = (lon, lat) => Matrix4.multiplyByPoint(inverse, Cartesian3.fromDegrees(lon, lat), new Cartesian3())
  22. // Footprints flatter than this are treated as a rectangle and can host a detailed asset.
  23. const RECT_FILL_MIN = 0.85
  24. const MAX_MASSING_VERTICES = 24
  25. /** Storey estimate from footprint area, in the range this town actually shows. */
  26. function storeysForArea(areaSqm, inMask) {
  27. if (areaSqm < 40) return 1
  28. if (areaSqm < 100) return 2
  29. if (areaSqm < 200) return 3
  30. if (areaSqm < 400) return 4
  31. if (areaSqm < 800) return inMask ? 6 : 5
  32. return inMask ? 8 : 6
  33. }
  34. function orientedRectangle(points) {
  35. let best = null
  36. for (let i = 0; i < points.length; i++) {
  37. const a = points[i], b = points[(i + 1) % points.length]
  38. const dx = b[0] - a[0], dy = b[1] - a[1]
  39. const length = Math.hypot(dx, dy)
  40. if (length < 0.05) continue
  41. const u = [dx / length, dy / length], v = [-u[1], u[0]]
  42. const projected = points.map((p) => [p[0] * u[0] + p[1] * u[1], p[0] * v[0] + p[1] * v[1]])
  43. const minU = Math.min(...projected.map((p) => p[0])), maxU = Math.max(...projected.map((p) => p[0]))
  44. const minV = Math.min(...projected.map((p) => p[1])), maxV = Math.max(...projected.map((p) => p[1]))
  45. const area = (maxU - minU) * (maxV - minV)
  46. if (!best || area < best.area)
  47. best = { area, width: maxU - minU, depth: maxV - minV, heading: Math.atan2(u[1], u[0]) }
  48. }
  49. return best
  50. }
  51. function assetFor(width, depth) {
  52. let best = null
  53. for (const [family, limits] of Object.entries(assetFit.variants)) {
  54. const [nominalWidth, nominalDepth] = limits.nominal_m
  55. for (const [w, d] of [[width, depth], [depth, width]]) {
  56. const sw = w / nominalWidth, sd = d / nominalDepth
  57. if (sw < limits.minScale[0] || sw > limits.maxScale[0]) continue
  58. if (sd < limits.minScale[1] || sd > limits.maxScale[1]) continue
  59. const penalty = family === 'house' ? 0 : 0.05
  60. const score = Math.abs(Math.log(sw)) + Math.abs(Math.log(sd)) + penalty
  61. if (!best || score < best.score) best = { family, score }
  62. }
  63. }
  64. return best ? best.family : null
  65. }
  66. const variants = {
  67. house: ['house-2f', 'house-4f', 'house'],
  68. slab: ['slab-4f', 'slab'],
  69. midrise: ['midrise-8f', 'midrise'],
  70. 'midrise-wide': ['midrise-wide', 'midrise'],
  71. }
  72. const samples = []
  73. const buildings = []
  74. const excluded = []
  75. let assetCount = 0
  76. let massingCount = 0
  77. let outsideMask = 0
  78. let skippedTiny = 0
  79. for (let index = 0; index < footprints.length; index++) {
  80. const feature = footprints[index]
  81. if (!isWithinPrimaryArea(feature)) {
  82. excluded.push({ id: index, reason: 'outside primary area' })
  83. continue
  84. }
  85. const area = turf.area(feature)
  86. if (area < 4) {
  87. // Survey noise and structures too small to read as a building at scene scale.
  88. skippedTiny++
  89. continue
  90. }
  91. const ring = feature.geometry.coordinates[0]
  92. const local = ring.map(([lon, lat]) => {
  93. const p = toLocal(lon, lat)
  94. return [p.x, p.y]
  95. })
  96. // Drop the duplicated closing vertex.
  97. if (local.length > 1 && Math.hypot(local[0][0] - local[local.length - 1][0], local[0][1] - local[local.length - 1][1]) < 0.01)
  98. local.pop()
  99. if (local.length < 3) {
  100. excluded.push({ id: index, reason: 'degenerate outline' })
  101. continue
  102. }
  103. // Simplify heavily for the far field: a 60k-building scene cannot carry full survey outlines.
  104. const simplified = local.length > MAX_MASSING_VERTICES
  105. ? turf.simplify(turf.polygon([[...ring]]), { tolerance: 0.00002, highQuality: false }).geometry.coordinates[0]
  106. .slice(0, -1)
  107. .map(([lon, lat]) => {
  108. const p = toLocal(lon, lat)
  109. return [p.x, p.y]
  110. })
  111. : local
  112. const mask = masks.find((candidate) => turf.booleanIntersects(feature, candidate))
  113. const inMask = !!mask
  114. if (!inMask) outsideMask++
  115. const rect = orientedRectangle(local)
  116. const fill = rect && rect.area > 0 ? area / rect.area : 0
  117. const archetype = rect && fill >= RECT_FILL_MIN ? assetFor(rect.width, rect.depth) : null
  118. const id = `cad-${index}`
  119. const positions = simplified.map((p) => [Math.round(p[0] * 100) / 100, Math.round(p[1] * 100) / 100])
  120. // Sample the outline plus the centroid; the seating stage needs real ground under every corner.
  121. const sampleIndices = [...ring.slice(0, -1), turf.centroid(feature).geometry.coordinates].map(([lon, lat]) => {
  122. samples.push({ lon, lat, building: id })
  123. return samples.length - 1
  124. })
  125. let kind, width, depth, heading
  126. if (archetype) {
  127. kind = 'asset'
  128. width = rect.width
  129. depth = rect.depth
  130. heading = rect.heading
  131. assetCount++
  132. } else {
  133. kind = 'massing'
  134. width = rect ? rect.width : 6
  135. depth = rect ? rect.depth : 6
  136. heading = rect ? rect.heading : 0
  137. massingCount++
  138. }
  139. buildings.push({
  140. id,
  141. kind,
  142. tier: inMask ? 'urban' : 'rural',
  143. maskId: mask ? mask.properties.ORIG_FID : null,
  144. footprintENU: positions,
  145. areaSqm: Math.round(area * 10) / 10,
  146. rectFillRatio: Math.round(fill * 1000) / 1000,
  147. width: Math.round(width * 100) / 100,
  148. depth: Math.round(depth * 100) / 100,
  149. heading: Math.round(heading * 10000) / 10000,
  150. archetype,
  151. preferredAssets: archetype ? variants[archetype] : null,
  152. floors: storeysForArea(area, inMask),
  153. heightSource: 'estimated-from-footprint-area',
  154. sampleIndices,
  155. })
  156. }
  157. const reasonCounts = {}
  158. for (const entry of excluded) reasonCounts[entry.reason] = (reasonCounts[entry.reason] || 0) + 1
  159. const plan = {
  160. version: 1,
  161. source: '1:500 survey CAD layer JMD (居民地), converted through ArcGIS to WGS84',
  162. policy: 'Real survey outlines are authoritative. Rectangular footprints may host a library asset; all others keep their outline as an extruded massing prism.',
  163. bounds: [...PRIMARY_AREA_BOUNDS],
  164. origin,
  165. transform: Matrix4.toArray(transform),
  166. buildings,
  167. samples,
  168. excluded,
  169. exclusionReasons: reasonCounts,
  170. counts: {
  171. footprints: footprints.length,
  172. modeled: buildings.length,
  173. asset: assetCount,
  174. massing: massingCount,
  175. urban: buildings.filter((b) => b.tier === 'urban').length,
  176. rural: buildings.filter((b) => b.tier === 'rural').length,
  177. skippedTiny,
  178. },
  179. heightCaveat: 'Storey counts and heights are estimated from footprint area; the survey carries no usable storey attribute.',
  180. disclaimer: 'Massing built on surveyed footprints; heights and facades are estimates, not measured.',
  181. }
  182. plan.sha256 = createHash('sha256').update(JSON.stringify(buildings)).digest('hex')
  183. writeFileSync(resolve(OUT, 'plan.json'), JSON.stringify(plan, null, 2))
  184. console.log(JSON.stringify({ ...plan.counts, samples: samples.length, exclusionReasons: reasonCounts, sha256: plan.sha256 }))