prepare-district.mjs 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. // Prepare an auditable district from original rectangular OSM outlines + individually traced roofs.
  2. // No random infill. Exact WGS84/ECEF -> ENU. All heights must be sampled by sample-district.mjs.
  3. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
  4. import { resolve } from 'node:path'
  5. import { Cartesian3, Cartographic, Matrix4, Transforms, Math as CM } from 'cesium'
  6. import { PNG } from 'pngjs'
  7. const OUT = resolve('tools/city-massing/district')
  8. mkdirSync(OUT, { recursive: true })
  9. const artifacts = resolve('.scratch/cockpit-shell-panel-layout/verify/artifacts/t35/imagery')
  10. const src = JSON.parse(readFileSync(resolve(artifacts, 'north-source.json')))
  11. const trace = JSON.parse(readFileSync('tools/city-massing/traced-roofs.json'))
  12. const origin = { lon: 110.395, lat: 28.454 }
  13. const mat = Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(origin.lon, origin.lat))
  14. const inv = Matrix4.inverseTransformation(mat, new Matrix4())
  15. const local = (c) => {
  16. let a = Matrix4.multiplyByPoint(inv, Cartesian3.fromDegrees(c[0], c[1]), new Cartesian3())
  17. return [a.x, a.y, a.z]
  18. }
  19. const toGeo = (p) => {
  20. const c = Cartographic.fromCartesian(Matrix4.multiplyByPoint(mat, new Cartesian3(...p), new Cartesian3()))
  21. return [CM.toDegrees(c.longitude), CM.toDegrees(c.latitude)]
  22. }
  23. function geoPx([x, y]) {
  24. const s = 2 ** src.z * 256
  25. const lon = ((x + src.x0 * 256) / s) * 360 - 180
  26. const lat = (Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + src.y0 * 256)) / s))) * 180) / Math.PI
  27. return [lon, lat]
  28. }
  29. const selected = src.features
  30. .filter((f) => f.ring.length === 5)
  31. .map((f) => ({
  32. id: 'osm-' + f.id,
  33. ring: f.ring.slice(0, 4),
  34. polygonPx: f.px.slice(0, 4),
  35. floors: f.id.startsWith('128533681') ? 10 : 5,
  36. archetype: f.id.startsWith('128533681') ? 'midrise' : 'apartment',
  37. source: 'OSM original footprint; locally checked against imagery, not a rescaled AABB',
  38. }))
  39. // Add individually traced roofs only if separate from original controls (traces overlap source excluded below).
  40. for (const f of trace.roofs)
  41. selected.push({
  42. ...f,
  43. ring: f.polygonPx.map(geoPx),
  44. source: 'individually traced roof on local orthoimagery; floors/facade estimated',
  45. })
  46. function convexInside(p, poly) {
  47. let signs = poly.map((a, i) => {
  48. const b = poly[(i + 1) % poly.length]
  49. return (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])
  50. })
  51. return signs.every((x) => x >= 0) || signs.every((x) => x <= 0)
  52. }
  53. function overlap(a, b) {
  54. for (const poly of [a, b])
  55. for (let i = 0; i < poly.length; i++) {
  56. const v = poly[i],
  57. w = poly[(i + 1) % poly.length],
  58. axis = [w[1] - v[1], v[0] - w[0]]
  59. const ap = a.map((p) => p[0] * axis[0] + p[1] * axis[1]),
  60. bp = b.map((p) => p[0] * axis[0] + p[1] * axis[1])
  61. if (Math.max(...ap) <= Math.min(...bp) || Math.max(...bp) <= Math.min(...ap)) return false
  62. }
  63. return true
  64. }
  65. const accepted = [],
  66. excluded = [],
  67. samples = []
  68. for (const f of selected) {
  69. const p = f.ring.map(local)
  70. const center = p.reduce((a, b) => [a[0] + b[0] / 4, a[1] + b[1] / 4, 0], [0, 0, 0])
  71. const edges = p.map((a, i) => {
  72. const b = p[(i + 1) % 4]
  73. return { len: Math.hypot(b[0] - a[0], b[1] - a[1]), dir: [b[0] - a[0], b[1] - a[1]] }
  74. })
  75. const e = edges.reduce((a, b) => (a.len > b.len ? a : b))
  76. const u = e.dir.map((x) => x / e.len),
  77. v = [-u[1], u[0]]
  78. const pro = p.map((a) => [
  79. (a[0] - center[0]) * u[0] + (a[1] - center[1]) * u[1],
  80. (a[0] - center[0]) * v[0] + (a[1] - center[1]) * v[1],
  81. ])
  82. const width = Math.max(...pro.map((a) => a[0])) - Math.min(...pro.map((a) => a[0]))
  83. const depth = Math.max(...pro.map((a) => a[1])) - Math.min(...pro.map((a) => a[1]))
  84. if (width < 5 || depth < 4) {
  85. excluded.push({ id: f.id, reason: 'too narrow for supported archetype' })
  86. continue
  87. }
  88. // Require a near-rectangle; preserve rotation and original control polygon for audits.
  89. const rect = [
  90. [-width / 2, -depth / 2],
  91. [width / 2, -depth / 2],
  92. [width / 2, depth / 2],
  93. [-width / 2, depth / 2],
  94. ].map(([x, y]) => [center[0] + u[0] * x + v[0] * y, center[1] + u[1] * x + v[1] * y, 0])
  95. const error = Math.max(...rect.map((q) => Math.min(...p.map((a) => Math.hypot(q[0] - a[0], q[1] - a[1])))))
  96. if (error > 1.5) {
  97. excluded.push({ id: f.id, reason: 'irregular outline; needs custom mesh', cornerError: error })
  98. continue
  99. }
  100. if (accepted.some((a) => overlap(a.footprintENU, rect))) {
  101. excluded.push({ id: f.id, reason: 'overlap with higher-priority source footprint' })
  102. continue
  103. }
  104. const points = []
  105. const columns = Math.ceil(width / 8),
  106. rows = Math.ceil(depth / 8)
  107. for (let y = 0; y <= rows; y++)
  108. for (let x = 0; x <= columns; x++) {
  109. const lx = -width / 2 + (width * x) / columns,
  110. ly = -depth / 2 + (depth * y) / rows
  111. const q = [center[0] + u[0] * lx + v[0] * ly, center[1] + u[1] * lx + v[1] * ly, 0]
  112. points.push(toGeo(q))
  113. }
  114. const sampleIndices = points.map((g) => {
  115. samples.push({ lon: g[0], lat: g[1], building: f.id })
  116. return samples.length - 1
  117. })
  118. const centerGeo = toGeo(center)
  119. accepted.push({
  120. ...f,
  121. centerENU: center,
  122. center: { lon: centerGeo[0], lat: centerGeo[1] },
  123. footprintENU: rect,
  124. width,
  125. depth,
  126. heading: Math.atan2(u[1], u[0]),
  127. cornerError: error,
  128. sampleIndices,
  129. })
  130. }
  131. const plan = {
  132. version: 3,
  133. origin,
  134. transform: Matrix4.toArray(mat),
  135. source: trace.description,
  136. facadesAndFloors: 'design estimates; not survey heights',
  137. buildings: accepted,
  138. excluded,
  139. samples,
  140. }
  141. writeFileSync(resolve(OUT, 'plan.json'), JSON.stringify(plan, null, 2))
  142. const img = PNG.sync.read(readFileSync(resolve(artifacts, 'north-satellite.png')))
  143. for (const f of accepted) {
  144. const poly = f.polygonPx
  145. for (let n = 0; n < poly.length; n++) {
  146. let a = poly[n],
  147. b = poly[(n + 1) % poly.length]
  148. const steps = Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]))
  149. for (let i = 0; i <= steps; i++) {
  150. const x = Math.round(a[0] + ((b[0] - a[0]) * i) / steps),
  151. y = Math.round(a[1] + ((b[1] - a[1]) * i) / steps)
  152. if (x < 0 || y < 0 || x >= img.width || y >= img.height) continue
  153. const k = (y * img.width + x) * 4
  154. img.data[k] = f.source.startsWith('OSM') ? 255 : 0
  155. img.data[k + 1] = 220
  156. img.data[k + 2] = f.source.startsWith('OSM') ? 0 : 255
  157. }
  158. }
  159. }
  160. writeFileSync(resolve(OUT, 'footprint-audit.png'), PNG.sync.write(img))
  161. console.log({
  162. accepted: accepted.length,
  163. excluded: excluded.length,
  164. original: accepted.filter((f) => f.id.startsWith('osm-')).length,
  165. traced: accepted.filter((f) => f.id.startsWith('trace-')).length,
  166. samplePoints: samples.length,
  167. out: OUT,
  168. })