seat-cad-bim.mjs 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. // Seat every CAD BIM building on the original DEM.
  2. // Flat ground is seated directly; a gently sloped footprint gets a bounded stone plinth; steeper
  3. // footprints are refused rather than hidden behind a fabricated platform.
  4. import { readFileSync, writeFileSync, existsSync } from 'node:fs'
  5. import { resolve } from 'node:path'
  6. import { Cartesian3, Matrix4 } from 'cesium'
  7. import { localSeatingLevels } from './terrain-height-frame.mjs'
  8. // Existing published assets keep their original coordinates. New candidates opt
  9. // into the corrected frame; never silently reseat the accepted city in place.
  10. const useLocalHeightFrame = process.argv.includes('--local-height-frame')
  11. const dir = 'tools/city-massing/city/cad-bim/'
  12. const option = name => { const at = process.argv.indexOf(name); return at < 0 ? null : process.argv[at + 1] }
  13. const output = option('--output') || dir + 'seated-plan.json'
  14. if (useLocalHeightFrame && (!option('--plan') || !option('--samples') || !option('--output') ||
  15. resolve(output) === resolve(dir + 'seated-plan.json') || existsSync(output))) {
  16. throw new Error('local height frame requires explicit candidate --plan, --samples and separate --output')
  17. }
  18. const plan = JSON.parse(readFileSync(option('--plan') || dir + 'plan.json'))
  19. const sampled = JSON.parse(readFileSync(option('--samples') || dir + 'terrain-samples.json'))
  20. if (plan.samples.length !== sampled.heights.length) throw new Error('DEM sample count does not match the plan')
  21. const FLAT_RELIEF = 2.5
  22. const MAX_PLINTH = 3.2
  23. const PLINTH_HEIGHT_RATIO = 0.4
  24. const FLOOR_HEIGHT_M = 3.2
  25. const MAX_HEIGHT_M = 30
  26. const inverse = Matrix4.inverseTransformation(Matrix4.fromArray(plan.transform), new Matrix4())
  27. const local = (lon, lat, h) => Matrix4.multiplyByPoint(inverse, Cartesian3.fromDegrees(lon, lat, h), new Cartesian3())
  28. // Survey outlines occasionally fold back on themselves (a "bow tie"), repeat a vertex, or enclose a
  29. // negligible area. Those cannot be extruded into a solid, so they are rejected here — with a reason —
  30. // instead of reaching the exporter and aborting a full-city run.
  31. const MIN_MASSING_AREA = 4
  32. const DUPLICATE_VERTEX_M = 0.05
  33. function cleanRing(points) {
  34. const out = []
  35. for (const point of points) {
  36. const previous = out[out.length - 1]
  37. if (previous && Math.hypot(point[0] - previous[0], point[1] - previous[1]) < DUPLICATE_VERTEX_M) continue
  38. out.push(point)
  39. }
  40. if (out.length > 2) {
  41. const first = out[0], last = out[out.length - 1]
  42. if (Math.hypot(first[0] - last[0], first[1] - last[1]) < DUPLICATE_VERTEX_M) out.pop()
  43. }
  44. return out
  45. }
  46. function signedArea(points) {
  47. let total = 0
  48. for (let i = 0; i < points.length; i++) {
  49. const [x1, y1] = points[i]
  50. const [x2, y2] = points[(i + 1) % points.length]
  51. total += x1 * y2 - x2 * y1
  52. }
  53. return total / 2
  54. }
  55. function segmentsCross(a, b) {
  56. const cross = (o, p, q) => (p[0] - o[0]) * (q[1] - o[1]) - (p[1] - o[1]) * (q[0] - o[0])
  57. const d1 = cross(a[0], a[1], b[0]), d2 = cross(a[0], a[1], b[1])
  58. const d3 = cross(b[0], b[1], a[0]), d4 = cross(b[0], b[1], a[1])
  59. return ((d1 > 0) !== (d2 > 0)) && ((d3 > 0) !== (d4 > 0))
  60. }
  61. /** True when no two non-adjacent edges of the ring intersect. */
  62. function isSimpleRing(points) {
  63. const n = points.length
  64. for (let i = 0; i < n; i++) {
  65. const a = [points[i], points[(i + 1) % n]]
  66. for (let j = i + 1; j < n; j++) {
  67. if (j === i || (j + 1) % n === i || j === (i + 1) % n) continue
  68. const b = [points[j], points[(j + 1) % n]]
  69. if (segmentsCross(a, b)) return false
  70. }
  71. }
  72. return true
  73. }
  74. const buildings = []
  75. const excluded = []
  76. const construction = { flat: 0, plinth: 0 }
  77. let missing = 0
  78. let invalidGeometry = 0
  79. for (const building of plan.buildings) {
  80. const heights = building.sampleIndices.map((i) => sampled.heights[i])
  81. if (heights.some((h) => !Number.isFinite(h) || h <= 1)) {
  82. missing++
  83. excluded.push({ id: building.id, reason: 'missing or invalid DEM sample' })
  84. continue
  85. }
  86. let ring = building.footprintENU
  87. if (building.kind === 'massing') {
  88. ring = cleanRing(ring)
  89. if (ring.length < 3) {
  90. invalidGeometry++
  91. excluded.push({ id: building.id, tier: building.tier, reason: 'outline collapses to fewer than 3 distinct points' })
  92. continue
  93. }
  94. if (!isSimpleRing(ring)) {
  95. invalidGeometry++
  96. excluded.push({ id: building.id, tier: building.tier, reason: 'self-intersecting survey outline cannot be extruded' })
  97. continue
  98. }
  99. if (Math.abs(signedArea(ring)) < MIN_MASSING_AREA) {
  100. invalidGeometry++
  101. excluded.push({ id: building.id, tier: building.tier, reason: 'outline encloses negligible area' })
  102. continue
  103. }
  104. }
  105. const levels = useLocalHeightFrame ? localSeatingLevels(plan.transform,
  106. building.sampleIndices.map(i => plan.samples[i]), heights) : null
  107. const min = levels ? levels.minimum : Math.min(...heights)
  108. const max = levels ? levels.maximum : Math.max(...heights)
  109. const relief = max - min
  110. const heightM = Math.min(MAX_HEIGHT_M, Math.max(FLOOR_HEIGHT_M, building.floors * FLOOR_HEIGHT_M))
  111. if (relief > FLAT_RELIEF) {
  112. if (relief > MAX_PLINTH || relief > heightM * PLINTH_HEIGHT_RATIO) {
  113. excluded.push({
  114. id: building.id, tier: building.tier,
  115. reason: 'terrain relief exceeds the allowed plinth; needs stepped construction',
  116. relief: Math.round(relief * 100) / 100,
  117. })
  118. continue
  119. }
  120. construction.plinth++
  121. } else {
  122. construction.flat++
  123. }
  124. // Floor sits just below the highest measured ground so the building never floats.
  125. const floorZ = max - 0.25
  126. const centroid = building.sampleIndices[building.sampleIndices.length - 1]
  127. const anchor = plan.samples[centroid]
  128. const anchorHeight = levels ? levels.ellipsoidMaximum - 0.25 : floorZ
  129. buildings.push({
  130. id: building.id,
  131. kind: building.kind,
  132. tier: building.tier,
  133. maskId: building.maskId,
  134. footprintENU: ring,
  135. width: building.width,
  136. depth: building.depth,
  137. heading: building.heading,
  138. archetype: building.archetype,
  139. preferredAssets: building.preferredAssets,
  140. floors: building.floors,
  141. heightM: Math.round(heightM * 100) / 100,
  142. heightSource: building.heightSource,
  143. positionENU: [
  144. Math.round(local(anchor.lon, anchor.lat, anchorHeight).x * 100) / 100,
  145. Math.round(local(anchor.lon, anchor.lat, anchorHeight).y * 100) / 100,
  146. Math.round(floorZ * 100) / 100,
  147. ],
  148. ...(levels ? { heightFrame: levels.heightFrame,
  149. terrainEllipsoidMin: levels.ellipsoidMinimum, terrainEllipsoidMax: levels.ellipsoidMaximum } : {}),
  150. foundationBottom: Math.round((min - 3) * 100) / 100,
  151. terrainMin: Math.round(min * 100) / 100,
  152. terrainMax: Math.round(max * 100) / 100,
  153. terrainRelief: Math.round(relief * 100) / 100,
  154. construction: relief > FLAT_RELIEF ? 'plinth' : 'flat',
  155. })
  156. }
  157. const out = {
  158. ...plan,
  159. buildings,
  160. excluded: [...(plan.excluded || []), ...excluded],
  161. construction,
  162. notice: plan.disclaimer + ' Seated on the original quantized-mesh DEM; steep footprints stay excluded.',
  163. }
  164. writeFileSync(output, JSON.stringify(out, null, 2))
  165. console.log(JSON.stringify({
  166. planned: plan.buildings.length,
  167. seated: buildings.length,
  168. byKind: {
  169. asset: buildings.filter((b) => b.kind === 'asset').length,
  170. massing: buildings.filter((b) => b.kind === 'massing').length,
  171. },
  172. byConstruction: construction,
  173. missingSamples: missing,
  174. invalidGeometry,
  175. excludedRelief: excluded.filter((e) => e.relief !== undefined).length,
  176. urbanSeated: buildings.filter((b) => b.tier === 'urban').length,
  177. ruralSeated: buildings.filter((b) => b.tier === 'rural').length,
  178. }))