prepare-regular-estate.mjs 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. // Subdivide the individually mapped apartment roofs into reusable 4/6-floor modules.
  2. // The roof outlines are not surveyed wall footprints: inset each body and label the uncertainty.
  3. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
  4. import { resolve } from 'node:path'
  5. import { createHash } from 'node:crypto'
  6. import { Cartesian3, Cartographic, Matrix4, Transforms, Math as CM } from 'cesium'
  7. import * as turf from '@turf/turf'
  8. const OUT = resolve('tools/city-massing/city/regular-estate')
  9. mkdirSync(OUT, { recursive: true })
  10. const roofs = JSON.parse(readFileSync('tools/city-massing/city/regular-estate-roofs.geojson', 'utf8'))
  11. const source = JSON.parse(readFileSync('tools/city-massing/city/regular-estate-roofs.json', 'utf8'))
  12. const origin = { lon: 110.395, lat: 28.454 } // shared ENU anchor with the audited stadium sample
  13. const transform = Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(origin.lon, origin.lat))
  14. const inverse = Matrix4.inverseTransformation(transform, new Matrix4())
  15. const toLocal = ([lon, lat]) => Matrix4.multiplyByPoint(inverse, Cartesian3.fromDegrees(lon, lat), new Cartesian3())
  16. const toGeo = (q) => {
  17. const c = Cartographic.fromCartesian(
  18. Matrix4.multiplyByPoint(transform, new Cartesian3(q[0], q[1], 0), new Cartesian3())
  19. )
  20. return { lon: CM.toDegrees(c.longitude), lat: CM.toDegrees(c.latitude) }
  21. }
  22. const rows = []
  23. const samples = []
  24. const modules = []
  25. for (const f of roofs.features) {
  26. const corners = f.geometry.coordinates[0].slice(0, 4).map(toLocal)
  27. const left = Cartesian3.midpoint(corners[0], corners[3], new Cartesian3())
  28. const right = Cartesian3.midpoint(corners[1], corners[2], new Cartesian3())
  29. const center = Cartesian3.midpoint(left, right, new Cartesian3())
  30. const long = Cartesian3.subtract(right, left, new Cartesian3())
  31. const width = Cartesian3.magnitude(long)
  32. const depth = Cartesian3.distance(corners[0], corners[3])
  33. const u = [long.x / width, long.y / width],
  34. v = [-u[1], u[0]]
  35. const moduleCount = Math.max(1, Math.ceil((width - 2) / 34))
  36. const gap = 1.2
  37. const moduleWidth = (width - 2 - gap * (moduleCount - 1)) / moduleCount
  38. const moduleDepth = depth - 1.4
  39. if (moduleWidth < 17 || moduleDepth < 9 || moduleDepth > 18)
  40. throw new Error(`Template cannot fit ${f.properties.id}: ${moduleWidth.toFixed(1)}x${moduleDepth.toFixed(1)}m`)
  41. const roof = {
  42. id: f.properties.id,
  43. source: 'traced-roof',
  44. floors: f.properties.floors,
  45. width,
  46. depth,
  47. moduleCount,
  48. lon: toGeo([center.x, center.y]).lon,
  49. lat: toGeo([center.x, center.y]).lat,
  50. }
  51. rows.push(roof)
  52. for (let part = 0; part < moduleCount; part++) {
  53. const x = -width / 2 + 1 + moduleWidth * (part + 0.5) + gap * part
  54. const xy = [center.x + u[0] * x, center.y + u[1] * x]
  55. const cornersENU = [
  56. [-moduleWidth / 2, -moduleDepth / 2],
  57. [moduleWidth / 2, -moduleDepth / 2],
  58. [moduleWidth / 2, moduleDepth / 2],
  59. [-moduleWidth / 2, moduleDepth / 2],
  60. ].map(([a, b]) => [xy[0] + u[0] * a + v[0] * b, xy[1] + u[1] * a + v[1] * b])
  61. const ring = [
  62. ...cornersENU.map(toGeo).map((p) => [p.lon, p.lat]),
  63. [toGeo(cornersENU[0]).lon, toGeo(cornersENU[0]).lat],
  64. ]
  65. const candidate = turf.polygon([ring])
  66. if (!turf.booleanContains(f, candidate)) throw new Error(`Body escapes traced roof ${f.properties.id}-${part}`)
  67. const id = `${f.properties.id}-part${part}`
  68. const cols = Math.ceil(moduleWidth / 5),
  69. lines = Math.ceil(moduleDepth / 5)
  70. const sampleIndices = []
  71. for (let y = 0; y <= lines; y++)
  72. for (let k = 0; k <= cols; k++) {
  73. const xx = -moduleWidth / 2 + (moduleWidth * k) / cols,
  74. yy = -moduleDepth / 2 + (moduleDepth * y) / lines
  75. const p = toGeo([xy[0] + u[0] * xx + v[0] * yy, xy[1] + u[1] * xx + v[1] * yy])
  76. sampleIndices.push(samples.length)
  77. samples.push({ ...p, building: id })
  78. }
  79. modules.push({
  80. id,
  81. sourceId: f.properties.id,
  82. category: 'regular',
  83. placementSource: 'traced-roof',
  84. archetype: 'apartment',
  85. floors: f.properties.floors,
  86. heightSource: 'estimated',
  87. width: moduleWidth,
  88. depth: moduleDepth,
  89. heading: Math.atan2(u[1], u[0]),
  90. center: toGeo(xy),
  91. centerENU: [xy[0], xy[1], 0],
  92. footprintENU: cornersENU.map(([a, b]) => [a, b, 0]),
  93. sampleIndices,
  94. })
  95. }
  96. }
  97. for (let i = 0; i < modules.length; i++)
  98. for (let j = i + 1; j < modules.length; j++) {
  99. const a = modules[i],
  100. b = modules[j]
  101. const pa = turf.polygon([[...a.footprintENU.map((p) => p.slice(0, 2)), a.footprintENU[0].slice(0, 2)]])
  102. const pb = turf.polygon([[...b.footprintENU.map((p) => p.slice(0, 2)), b.footprintENU[0].slice(0, 2)]])
  103. if (turf.booleanOverlap(pa, pb)) throw new Error(`Bodies overlap ${a.id} and ${b.id}`)
  104. }
  105. const plan = {
  106. site: source.site,
  107. scope: 'one individually traced estate, not city coverage',
  108. source: source.source,
  109. origin,
  110. transform: Matrix4.toArray(transform),
  111. roofs: rows,
  112. modules,
  113. samples,
  114. samplesSha256: createHash('sha256').update(JSON.stringify(samples)).digest('hex'),
  115. exclusions: [],
  116. disclaimer:
  117. 'Illustrative model, estimated heights/facades, traced roof parallax; not surveyed geometry or for measurement/statistics/emergency decisions.',
  118. }
  119. writeFileSync(resolve(OUT, 'plan.json'), JSON.stringify(plan, null, 2))
  120. console.log(
  121. JSON.stringify({
  122. roofs: rows.length,
  123. modules: modules.length,
  124. samples: samples.length,
  125. levels: [...new Set(modules.map((m) => m.floors))],
  126. })
  127. )