seat-oldtown.mjs 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. // Seat source-backed modules on the original DEM.
  2. // Flat footprints are seated as one slab. Sloped footprints are cut into strips perpendicular to
  3. // the measured downhill direction, so each step is a believable terrace that keeps the building's
  4. // facade proportions instead of an arbitrarily narrowed sliver.
  5. import { readFileSync, writeFileSync } from 'node:fs'
  6. import { Cartesian3, Matrix4 } from 'cesium'
  7. const dir = 'tools/city-massing/city/oldtown/'
  8. const plan = JSON.parse(readFileSync(dir + 'plan.json'))
  9. const sampled = JSON.parse(readFileSync(dir + 'terrain-samples.json'))
  10. if (plan.samples.length !== sampled.heights.length) throw new Error('Old-town sample length mismatch')
  11. const FLAT_RELIEF = 2.5 // relief a single slab may carry
  12. const STEP_RELIEF = 2.2 // relief one terrace step may carry
  13. const MAX_TERRACES = 5 // beyond this the footprint is a hillside, not a building
  14. // A stone plinth (台基/挡土墙) is normal hillside construction in this town, so a modest visible
  15. // foundation wall is allowed. It must stay well below the building's own height, otherwise the
  16. // "building" is really a platform and is refused instead.
  17. const MAX_PLINTH = 3.2
  18. const PLINTH_HEIGHT_RATIO = 0.4
  19. const FLOOR_HEIGHT_M = 3.2
  20. const assetFit = JSON.parse(readFileSync('tools/city-massing/asset-fit.json', 'utf8'))
  21. const inverse = Matrix4.inverseTransformation(Matrix4.fromArray(plan.transform), new Matrix4())
  22. const local = (lon, lat, h) => Matrix4.multiplyByPoint(inverse, Cartesian3.fromDegrees(lon, lat, h), new Cartesian3())
  23. const buildings = []
  24. const excluded = [...(plan.excluded || []).map((entry) => ({ ...entry, stage: 'source-footprint' }))]
  25. const terraced = []
  26. /** Sample positions in the module's own metre frame plus the terrain height at each. */
  27. function frameSamples(module) {
  28. const cosLat = Math.cos((module.lat * Math.PI) / 180)
  29. const u = [Math.cos(module.heading), Math.sin(module.heading)]
  30. const v = [-u[1], u[0]]
  31. return module.sampleIndices.map((index) => {
  32. const point = plan.samples[index]
  33. const dx = (point.lon - module.lon) * 111320 * cosLat
  34. const dy = (point.lat - module.lat) * 111320
  35. return { u: dx * u[0] + dy * u[1], v: -dx * u[1] + dy * u[0], height: sampled.heights[index] }
  36. })
  37. }
  38. function bounds(values) {
  39. return { min: Math.min(...values), max: Math.max(...values) }
  40. }
  41. function seat(module, heights, extra) {
  42. const min = Math.min(...heights)
  43. const max = Math.max(...heights)
  44. const floor = local(module.lon, module.lat, max - 0.25)
  45. const bottom = local(module.lon, module.lat, min - 3)
  46. return {
  47. ...module,
  48. ...extra,
  49. positionENU: [floor.x, floor.y, floor.z],
  50. foundationBottom: bottom.z,
  51. terrainMin: min,
  52. terrainMax: max,
  53. terrainRelief: max - min,
  54. floorHeight: max - 0.25,
  55. groundControls: module.sampleIndices.map((i) => [plan.samples[i].lon, plan.samples[i].lat, sampled.heights[i]]),
  56. }
  57. }
  58. /** Does a strip of this size still fit one of the module's planned assets? */
  59. function fitsAsset(width, depth, families) {
  60. for (const family of families) {
  61. const limits = assetFit.variants[family]
  62. if (!limits) continue
  63. const [nominalWidth, nominalDepth] = limits.nominal_m
  64. for (const [w, d] of [[width, depth], [depth, width]]) {
  65. const sw = w / nominalWidth
  66. const sd = d / nominalDepth
  67. if (sw >= limits.minScale[0] && sw <= limits.maxScale[0] &&
  68. sd >= limits.minScale[1] && sd <= limits.maxScale[1]) return true
  69. }
  70. }
  71. return false
  72. }
  73. for (const module of plan.modules) {
  74. const heights = module.sampleIndices.map((i) => sampled.heights[i])
  75. if (heights.some((height) => !Number.isFinite(height) || height <= 1)) {
  76. excluded.push({ id: module.id, sourceId: module.sourceId, sourceMask: module.sourceMask, reason: 'missing or invalid DEM sample' })
  77. continue
  78. }
  79. const relief = Math.max(...heights) - Math.min(...heights)
  80. if (relief <= FLAT_RELIEF) {
  81. buildings.push(seat(module, heights, { construction: 'flat' }))
  82. continue
  83. }
  84. // Gentle slopes: one building on a bounded stone plinth, with the visible plinth wall kept
  85. // below the proportion a real retaining base would show.
  86. const bodyHeight = (module.floors || 2) * FLOOR_HEIGHT_M
  87. if (relief <= MAX_PLINTH && relief <= bodyHeight * PLINTH_HEIGHT_RATIO) {
  88. buildings.push(seat(module, heights, { construction: 'plinth', plinthHeight: Number(relief.toFixed(2)) }))
  89. continue
  90. }
  91. const samples = frameSamples(module)
  92. // Steepest-descent direction from the sampled ground, expressed in the module frame.
  93. const us = bounds(samples.map((s) => s.u))
  94. const vs = bounds(samples.map((s) => s.v))
  95. const du = us.max - us.min
  96. const dv = vs.max - vs.min
  97. // Cut across the axis with the larger span-to-relief contribution; this keeps the other axis
  98. // (the facade) at full size so the terrace still matches the planned asset.
  99. const split = du >= dv ? 'u' : 'v'
  100. const spanAlong = split === 'u' ? du : dv
  101. const families = module.preferredAssets?.length
  102. ? Object.keys(assetFit.variants).filter((key) => module.preferredAssets.some((id) => id.startsWith(key)))
  103. : [module.archetype]
  104. let chosen = null
  105. for (let count = 2; count <= MAX_TERRACES; count++) {
  106. const strip = spanAlong / count
  107. const bins = Array.from({ length: count }, () => [])
  108. const lo = split === 'u' ? us.min : vs.min
  109. for (const sample of samples) {
  110. const position = split === 'u' ? sample.u : sample.v
  111. const bin = Math.min(count - 1, Math.max(0, Math.floor((position - lo) / strip)))
  112. bins[bin].push(sample)
  113. }
  114. if (bins.some((bin) => !bin.length)) continue
  115. const worst = Math.max(...bins.map((bin) => {
  116. const values = bin.map((sample) => sample.height)
  117. return Math.max(...values) - Math.min(...values)
  118. }))
  119. if (worst > STEP_RELIEF) continue
  120. const stripWidth = split === 'u' ? strip : module.width
  121. const stripDepth = split === 'u' ? module.depth : strip
  122. if (!fitsAsset(stripWidth, stripDepth, families)) continue
  123. chosen = { count, strip, bins, split, stripWidth, stripDepth }
  124. break
  125. }
  126. if (!chosen) {
  127. excluded.push({
  128. id: module.id, sourceId: module.sourceId, sourceMask: module.sourceMask,
  129. reason: `relief ${relief.toFixed(2)}m cannot be terraced inside the approved asset proportions`,
  130. relief,
  131. })
  132. continue
  133. }
  134. const { count, strip, bins, split: axis, stripWidth, stripDepth } = chosen
  135. const u = [Math.cos(module.heading), Math.sin(module.heading)]
  136. for (let index = 0; index < count; index++) {
  137. // Step centres move along the cut axis; the other axis stays centred on the source footprint.
  138. const offset = (index + 0.5) * strip - (axis === 'u' ? module.width : module.depth) / 2
  139. const dx = axis === 'u' ? u[0] * offset : -u[1] * offset
  140. const dy = axis === 'u' ? u[1] * offset : u[0] * offset
  141. const stripHeights = bins[index].map((sample) => sample.height)
  142. buildings.push(seat(module, stripHeights, {
  143. id: `${module.id}-t${index}`,
  144. construction: 'terraced',
  145. terraceIndex: index,
  146. terraceCount: count,
  147. width: stripWidth,
  148. depth: stripDepth,
  149. lon: module.lon + dx / (111320 * Math.cos((module.lat * Math.PI) / 180)),
  150. lat: module.lat + dy / 111320,
  151. }))
  152. }
  153. terraced.push({ id: module.id, sourceId: module.sourceId, relief: Number(relief.toFixed(2)), terraces: count })
  154. }
  155. const out = {
  156. ...plan,
  157. buildings,
  158. excluded,
  159. terraced,
  160. notice: plan.disclaimer + ' Sloped footprints use stepped terraces; hillside footprints stay excluded.',
  161. }
  162. writeFileSync(dir + 'seated-plan.json', JSON.stringify(out, null, 2))
  163. console.log(JSON.stringify({
  164. candidateModules: plan.modules.length,
  165. accepted: buildings.length,
  166. acceptedSources: new Set(buildings.map((b) => b.sourceId)).size,
  167. terracedModules: terraced.length,
  168. terracedParts: buildings.filter((b) => b.construction === 'terraced').length,
  169. excluded: excluded.length,
  170. maxRelief: Number(Math.max(...buildings.map((b) => b.terrainRelief), 0).toFixed(2)),
  171. }))