build-town.mjs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. /**
  2. * 沅陵县城「轻量体块」生成器 · 第二版(票 35,替代票 34 被否的第一版)。
  3. *
  4. * ⚠️ 示意体块,不是实测建筑:除 OSM 真实轮廓外,其余按真实路网/建成区程序化排布。
  5. * 产物 meta.json/README 均显著标注;不得用于量测、统计或应急决策。
  6. *
  7. * 对第一版三条否决理由的修复(票 34 Answer 记录的根因):
  8. * ① 漏顶 → 所有三角面做**程序化定向**(按「面重心 − 体中心」翻正绕序),
  9. * 且材质 doubleSided —— 从机制上消灭"背面被剔除"整类漏面;
  10. * ② 尺寸不搭边 → 单元尺寸**从 74 个真实轮廓的分位数推导**(长边中位 53m/p90 83m、
  11. * 短边 27–45m),第一版的 9×7/14×10/26×11 全部过小;
  12. * ③ 不贴合地形 → 高程网格 17×17 → **64×64**(~27m),并**逐栋取足印四角采样**:
  13. * 基准 = min(四角) − 裙边 4m,楼体向下延伸埋入地下 ⇒ 数学上不悬空,
  14. * 正确落位后地形自然遮挡("模型压在 Cesium 之上"随之消失)。
  15. * 中近景可辨 → 楼层色带(每层一圈略深的墙)+ 平顶女儿墙 / 坡屋顶出檐。
  16. *
  17. * 地理配准由构造保证(同第一版):经纬度 → 目标点 ENU → 3D Tiles transform。
  18. * 用法:node tools/city-massing/build-town.mjs
  19. */
  20. import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs'
  21. import { join } from 'node:path'
  22. const GIS = 'D:/GIS/GIS'
  23. const OSM = join(GIS, 'planet_110.059,28.258_110.744,28.625-shp/shpOutput')
  24. const DATA_DIR = process.env.CITY_DATA_DIR || join(GIS, 'cityMassing')
  25. const ORIGIN = { lon: 110.39028164, lat: 28.4436787 }
  26. const WINDOW = { lon: 0.02, lat: 0.015 }
  27. const CORE_RADIUS = 500
  28. const CAP = 5000
  29. const SKIRT = 4 // 每栋向下多埋 4m:斜坡上不悬空、不露缝
  30. const DEG = Math.PI / 180
  31. const M_PER_DEG_LAT = 110540
  32. const M_PER_DEG_LON = 111320 * Math.cos(ORIGIN.lat * DEG)
  33. const toLocal = (lon, lat) => ({
  34. x: (lon - ORIGIN.lon) * M_PER_DEG_LON,
  35. y: (lat - ORIGIN.lat) * M_PER_DEG_LAT,
  36. })
  37. const inWindow = (lon, lat) => Math.abs(lon - ORIGIN.lon) <= WINDOW.lon && Math.abs(lat - ORIGIN.lat) <= WINDOW.lat
  38. function hash01(...nums) {
  39. let h = 2166136261
  40. for (const n of nums) {
  41. let v = Math.round(n * 1000) | 0
  42. for (let i = 0; i < 4; i++) {
  43. h ^= (v >> (i * 8)) & 0xff
  44. h = Math.imul(h, 16777619)
  45. }
  46. }
  47. return ((h >>> 0) % 100000) / 100000
  48. }
  49. const pick = (arr, r) => arr[Math.min(arr.length - 1, Math.floor(r * arr.length))]
  50. // ---------------------------------------------------------------- 原料
  51. const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'))
  52. const buildings = readJson(join(OSM, 'buildings.json')).features
  53. const roads = readJson(join(OSM, 'roads.json')).features
  54. const waterways = readJson(join(OSM, 'waterways.json')).features
  55. const resa = readJson(join(GIS, 'output/resa.json')).features
  56. const terrain = JSON.parse(readFileSync(join(DATA_DIR, 'terrain-grid.json'), 'utf8'))
  57. /** 高程:64×64 网格双线性插值(缺失点回落到中心值)。 */
  58. function elevationAt(lon, lat) {
  59. const gx = (lon - terrain.lon0) / terrain.dLon
  60. const gy = (lat - terrain.lat0) / terrain.dLat
  61. const n = terrain.n
  62. const cx = Math.max(0, Math.min(n - 1.001, gx))
  63. const cy = Math.max(0, Math.min(n - 1.001, gy))
  64. const x0 = Math.floor(cx), y0 = Math.floor(cy)
  65. const fx = cx - x0, fy = cy - y0
  66. const at = (x, y) => terrain.heights[y * n + x]
  67. const c = [at(x0, y0), at(x0 + 1, y0), at(x0, y0 + 1), at(x0 + 1, y0 + 1)].map((v) => (typeof v === 'number' ? v : 111.6))
  68. return (c[0] * (1 - fx) + c[1] * fx) * (1 - fy) + (c[2] * (1 - fx) + c[3] * fx) * fy
  69. }
  70. // 水系避让
  71. const waterLines = []
  72. for (const f of waterways) {
  73. const ring = f.geometry && f.geometry.type === 'LineString' ? f.geometry.coordinates : null
  74. if (!ring) continue
  75. const seg = []
  76. for (const [lon, lat] of ring) {
  77. if (inWindow(lon, lat)) seg.push(toLocal(lon, lat))
  78. else if (seg.length) { waterLines.push(seg); seg.length = 0 }
  79. }
  80. if (seg.length) waterLines.push(seg)
  81. }
  82. function distanceToWater(p) {
  83. let best = Infinity
  84. for (const line of waterLines) {
  85. for (let i = 1; i < line.length; i++) {
  86. const a = line[i - 1], b = line[i]
  87. const dx = b.x - a.x, dy = b.y - a.y
  88. const len2 = dx * dx + dy * dy || 1
  89. let t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2))
  90. best = Math.min(best, Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)))
  91. }
  92. }
  93. return best
  94. }
  95. // 建成区
  96. const resaPolys = []
  97. for (const f of resa) {
  98. const ring = f.geometry && f.geometry.type === 'Polygon' ? f.geometry.coordinates[0] : null
  99. if (!ring || ring.length < 4) continue
  100. if (!ring.some(([lon, lat]) => inWindow(lon, lat))) continue
  101. resaPolys.push(ring.map(([lon, lat]) => toLocal(lon, lat)))
  102. }
  103. function inResa(p) {
  104. for (const ring of resaPolys) {
  105. let inside = false
  106. for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
  107. const a = ring[i], b = ring[j]
  108. if ((a.y > p.y) !== (b.y > p.y) && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y || 1e-9) + a.x) inside = !inside
  109. }
  110. if (inside) return true
  111. }
  112. return false
  113. }
  114. const STREET_TYPES = new Set(['trunk', 'primary', 'secondary', 'tertiary', 'residential', 'unclassified', 'living_street'])
  115. const streetSegments = []
  116. for (const f of roads) {
  117. const type = (f.properties && f.properties.type) || ''
  118. if (!STREET_TYPES.has(type)) continue
  119. const ring = f.geometry && f.geometry.type === 'LineString' ? f.geometry.coordinates : null
  120. if (!ring) continue
  121. for (let i = 1; i < ring.length; i++) {
  122. const [lon1, lat1] = ring[i - 1]
  123. const [lon2, lat2] = ring[i]
  124. if (!inWindow(lon1, lat1) && !inWindow(lon2, lat2)) continue
  125. streetSegments.push({ a: toLocal(lon1, lat1), b: toLocal(lon2, lat2) })
  126. }
  127. }
  128. // ---------------------------------------------------------------- 可复用单元(尺寸来自真实轮廓分位数)
  129. /**
  130. * 单元尺寸依据(tools 脚本对 74 个真实轮廓的实测分位数):
  131. * 长边 中位 53m / p90 83m;短边 p25–p90 27–45m;面积 1000–3800㎡。
  132. * 据此(乘以 0.45 的体块系数 —— 体块只求轮廓感,不必占满整个真实地块):
  133. * house 长边 24–34m · block 长边 34–60m · slab 长边 60–84m
  134. */
  135. const ARCHETYPES = {
  136. house: { long: [24, 34], short: [13, 18], floors: [2, 3], floorH: 3.1, pitched: true },
  137. block: { long: [34, 60], short: [16, 24], floors: [3, 6], floorH: 3.1, pitched: false },
  138. slab: { long: [60, 84], short: [22, 34], floors: [6, 11], floorH: 3.0, pitched: false },
  139. }
  140. // 墙色带:墙体主色 + 楼层带更深一档(中近景读出"层");屋色独立。
  141. const WALLS = [
  142. { wall: 0xf1ede3, band: 0xd9d2c2, roof: 0x8c4a3a },
  143. { wall: 0xe8e4da, band: 0xcfc9bb, roof: 0x6f7478 },
  144. { wall: 0xdfe1e3, band: 0xc4c8cb, roof: 0x545a5f },
  145. { wall: 0xe3d9cd, band: 0xcabfae, roof: 0x7a5140 },
  146. ]
  147. // ---------------------------------------------------------------- 几何发射(定向 pass 消灭漏面)
  148. const buckets = new Map()
  149. let placed = 0
  150. const stats = { house: 0, block: 0, slab: 0, real: 0, skippedWater: 0 }
  151. /** 回验样本:每栋楼的位置与基准高程(供 verify 脚本独立复核"逐栋贴地")。 */
  152. const verificationSample = []
  153. function bucket(color) {
  154. if (!buckets.has(color)) buckets.set(color, { positions: [], normals: [], indices: [] })
  155. return buckets.get(color)
  156. }
  157. /** 把一个世界坐标三角形写入桶:**自动定向** —— 法线取"远离体中心"的方向,绕序随法线生成。
  158. * 第一版"漏顶"的根因是手写绕序出错 + 单面材质;这里从机制上保证每个面朝外。 */
  159. function emitTri(b, p1, p2, p3, center) {
  160. // 面法线
  161. const ux = p2[0] - p1[0], uy = p2[1] - p1[1], uz = p2[2] - p1[2]
  162. const vx = p3[0] - p1[0], vy = p3[1] - p1[1], vz = p3[2] - p1[2]
  163. let nx = uy * vz - uz * vy
  164. let ny = uz * vx - ux * vz
  165. let nz = ux * vy - uy * vx
  166. const len = Math.hypot(nx, ny, nz) || 1
  167. nx /= len; ny /= len; nz /= len
  168. // 面重心指向"体中心反方向"即为朝外;若与法线同向则翻转(含绕序交换)
  169. const gx = (p1[0] + p2[0] + p3[0]) / 3 - center[0]
  170. const gy = (p1[1] + p2[1] + p3[1]) / 3 - center[1]
  171. const gz = (p1[2] + p2[2] + p3[2]) / 3 - center[2]
  172. const outward = nx * gx + ny * gy + nz * gz
  173. // outward = 法线 · (重心 − 体中心):朝外的面这个点积应为**正**;
  174. // 第一版把翻转条件写反(>0 才翻),恰好一半面朝内 —— 这就是"漏顶"的机制根源。
  175. const flip = outward < 0
  176. const a = flip ? p3 : p1
  177. const bb = flip ? p2 : p2
  178. const c = flip ? p1 : p3
  179. const base = b.positions.length / 3
  180. for (const v of [a, bb, c]) b.positions.push(v[0], v[1], v[2])
  181. for (let i = 0; i < 3; i++) {
  182. const s = flip ? -1 : 1
  183. b.normals.push(nx * s, ny * s, nz * s)
  184. }
  185. b.indices.push(base, base + 1, base + 2)
  186. }
  187. function emitBuilding({ x, y, zBase, heading, type, floors, width, depth, palette }) {
  188. const spec = ARCHETYPES[type]
  189. const height = floors * spec.floorH
  190. const cos = Math.cos(heading), sin = Math.sin(heading)
  191. // 体中心(用于面定向;z 取半高)
  192. const center = [x, zBase + height / 2, -y]
  193. const put = (lx, ly, lz) => {
  194. const ex = lx * cos - ly * sin
  195. const ny = lx * sin + ly * cos
  196. return [x + ex, zBase + lz, -(y + ny)]
  197. }
  198. const hw = width / 2, hd = depth / 2
  199. const b = bucket(palette.wall)
  200. const corners = [put(-hw, -hd, 0), put(hw, -hd, 0), put(hw, hd, 0), put(-hw, hd, 0)]
  201. const top = [put(-hw, -hd, height), put(hw, -hd, height), put(hw, hd, height), put(-hw, hd, height)]
  202. // 四片墙
  203. for (let i = 0; i < 4; i++) {
  204. const j = (i + 1) % 4
  205. emitTri(b, corners[i], corners[j], top[j], center)
  206. emitTri(b, corners[i], top[j], top[i], center)
  207. }
  208. // 楼层色带:每层在墙面贴一圈更深的带(外凸 0.15m),中近景读出"层"
  209. const band = bucket(palette.band)
  210. for (let f = 1; f < floors; f++) {
  211. const z0 = f * spec.floorH - 0.35
  212. const z1 = f * spec.floorH - 0.05
  213. const b0 = [put(-hw - 0.15, -hd - 0.15, z0), put(hw + 0.15, -hd - 0.15, z0), put(hw + 0.15, hd + 0.15, z0), put(-hw - 0.15, hd + 0.15, z0)]
  214. const b1 = [put(-hw - 0.15, -hd - 0.15, z1), put(hw + 0.15, -hd - 0.15, z1), put(hw + 0.15, hd + 0.15, z1), put(-hw - 0.15, hd + 0.15, z1)]
  215. for (let i = 0; i < 4; i++) {
  216. const j = (i + 1) % 4
  217. emitTri(band, b0[i], b0[j], b1[j], center)
  218. emitTri(band, b0[i], b1[j], b1[i], center)
  219. }
  220. }
  221. // 屋顶
  222. const roof = bucket(palette.roof)
  223. if (spec.pitched) {
  224. const ridge = height + Math.min(3.2, width * 0.14)
  225. const r1 = put(0, -hd, ridge)
  226. const r2 = put(0, hd, ridge)
  227. // 出檐 0.5m
  228. const e = 0.5
  229. const eave = [put(-hw - e, -hd - e, height - 0.1), put(hw + e, -hd - e, height - 0.1), put(hw + e, hd + e, height - 0.1), put(-hw - e, hd + e, height - 0.1)]
  230. const slope = [0, 0, 0]
  231. slope[0] = 0
  232. slope[1] = Math.sin(Math.atan2(ridge - height, hw))
  233. slope[2] = -Math.cos(Math.atan2(ridge - height, hw))
  234. emitTri(roof, eave[0], eave[1], r1, center)
  235. emitTri(roof, eave[1], eave[2], r2, center)
  236. emitTri(roof, eave[2], eave[3], r2, center)
  237. emitTri(roof, eave[3], eave[0], r1, center)
  238. // 山墙两片
  239. emitTri(roof, eave[0], r1, r2, center)
  240. emitTri(roof, eave[2], r2, r1, center)
  241. void slope
  242. } else {
  243. // 平顶 + 女儿墙(高出屋面 0.6m、内收 0.4m),远景读出"楼"而不是"盒盖"
  244. emitQuadTop(roof, top, center)
  245. const p0 = [put(-hw + 0.4, -hd + 0.4, height + 0.6), put(hw - 0.4, -hd + 0.4, height + 0.6), put(hw - 0.4, hd + 0.4, height + 0.6), put(-hw + 0.4, hd + 0.4, height + 0.6)]
  246. const p1 = [put(-hw, -hd, height), put(hw, -hd, height), put(hw, hd, height), put(-hw, hd, height)]
  247. for (let i = 0; i < 4; i++) {
  248. const j = (i + 1) % 4
  249. emitTri(roof, p1[i], p1[j], p0[j], center)
  250. emitTri(roof, p1[i], p0[j], p0[i], center)
  251. }
  252. }
  253. placed += 1
  254. stats[type] += 1
  255. }
  256. /** 顶面四边形(绕序按定向 pass 自动处理)。 */
  257. function emitQuadTop(b, top, center) {
  258. emitTri(b, top[0], top[1], top[2], center)
  259. emitTri(b, top[0], top[2], top[3], center)
  260. }
  261. // ---------------------------------------------------------------- 布点
  262. function chooseType(x, y, r) {
  263. const dist = Math.hypot(x, y)
  264. if (dist < CORE_RADIUS) return r < 0.3 ? 'slab' : 'block'
  265. if (dist < CORE_RADIUS * 2.4) return r < 0.65 ? 'block' : 'house'
  266. return r < 0.2 ? 'block' : 'house'
  267. }
  268. function emitInstance(x, y, heading, type, r) {
  269. if (placed >= CAP) return
  270. const lon = ORIGIN.lon + x / M_PER_DEG_LON
  271. const lat = ORIGIN.lat + y / M_PER_DEG_LAT
  272. if (!inWindow(lon, lat)) return
  273. if (distanceToWater({ x, y }) < 28) { stats.skippedWater += 1; return }
  274. const spec = ARCHETYPES[type]
  275. const long = spec.long[0] + (spec.long[1] - spec.long[0]) * hash01(x, y, 7)
  276. const short = spec.short[0] + (spec.short[1] - spec.short[0]) * hash01(x, y, 11)
  277. const floors = spec.floors[0] + Math.floor(hash01(x, y, 13) * (spec.floors[1] - spec.floors[0] + 1))
  278. // 足印四角采地形,取最小值 - 裙边 ⇒ 不悬空(楼体下延埋入地下)
  279. const cornersLonLat = [
  280. [lon - long / 2 / M_PER_DEG_LON, lat - short / 2 / M_PER_DEG_LAT],
  281. [lon + long / 2 / M_PER_DEG_LON, lat - short / 2 / M_PER_DEG_LAT],
  282. [lon + long / 2 / M_PER_DEG_LON, lat + short / 2 / M_PER_DEG_LAT],
  283. [lon - long / 2 / M_PER_DEG_LON, lat + short / 2 / M_PER_DEG_LAT],
  284. ].map(([a, b]) => elevationAt(a, b))
  285. const zBase = Math.min(...cornersLonLat) - SKIRT
  286. verificationSample.push({ lon, lat, zBase: Math.round(zBase * 100) / 100, type })
  287. emitBuilding({
  288. x, y, zBase, heading,
  289. type, floors,
  290. width: heading % (Math.PI) < Math.PI / 2 ? long : short, // 长边沿街
  291. depth: heading % Math.PI < Math.PI / 2 ? short : long,
  292. palette: pick(WALLS, hash01(x, y, 3)),
  293. })
  294. }
  295. // 1) 真实 OSM 轮廓
  296. for (const f of buildings) {
  297. const ring = f.geometry && f.geometry.type === 'Polygon' ? f.geometry.coordinates[0] : null
  298. if (!ring) continue
  299. const pts = ring.filter(([lon, lat]) => inWindow(lon, lat))
  300. if (pts.length < 4) continue
  301. const ex = pts.map(([lon]) => (lon - ORIGIN.lon) * M_PER_DEG_LON)
  302. const ny = pts.map(([, lat]) => (lat - ORIGIN.lat) * M_PER_DEG_LAT)
  303. const minX = Math.min(...ex), maxX = Math.max(...ex)
  304. const minY = Math.min(...ny), maxY = Math.max(...ny)
  305. const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2
  306. const w = Math.max(12, maxX - minX), d = Math.max(10, maxY - minY)
  307. const lon = ORIGIN.lon + cx / M_PER_DEG_LON
  308. const lat = ORIGIN.lat + cy / M_PER_DEG_LAT
  309. if (distanceToWater({ x: cx, y: cy }) < 24) continue
  310. const r = hash01(cx, cy, 13)
  311. const area = w * d
  312. const type = area > 3200 ? 'slab' : area > 1200 ? 'block' : 'house'
  313. const spec = ARCHETYPES[type]
  314. const floors = 2 + Math.floor(r * 5)
  315. const cornerEls = [
  316. elevationAt(ORIGIN.lon + minX / M_PER_DEG_LON, ORIGIN.lat + minY / M_PER_DEG_LAT),
  317. elevationAt(ORIGIN.lon + maxX / M_PER_DEG_LON, ORIGIN.lat + minY / M_PER_DEG_LAT),
  318. elevationAt(ORIGIN.lon + maxX / M_PER_DEG_LON, ORIGIN.lat + maxY / M_PER_DEG_LAT),
  319. elevationAt(ORIGIN.lon + minX / M_PER_DEG_LON, ORIGIN.lat + maxY / M_PER_DEG_LAT),
  320. ]
  321. emitBuilding({
  322. x: cx, y: cy, zBase: Math.min(...cornerEls) - SKIRT,
  323. heading: 0, type, floors, width: w, depth: d,
  324. palette: pick(WALLS, r),
  325. })
  326. verificationSample.push({ lon, lat, zBase: Math.round((Math.min(...cornerEls) - SKIRT) * 100) / 100, type })
  327. placed += 1
  328. stats.real += 1
  329. }
  330. // 2) 沿街排布(长边沿街)
  331. const SPACING = 34
  332. for (const seg of streetSegments) {
  333. const dx = seg.b.x - seg.a.x, dy = seg.b.y - seg.a.y
  334. const len = Math.hypot(dx, dy)
  335. if (len < 14) continue
  336. const heading = Math.atan2(dy, dx)
  337. const steps = Math.floor(len / SPACING)
  338. for (let s = 0; s <= steps; s++) {
  339. const t = steps === 0 ? 0.5 : s / steps
  340. const px = seg.a.x + dx * t
  341. const py = seg.a.y + dy * t
  342. for (const side of [-1, 1]) {
  343. const off = 16 + 10 * hash01(px, py, side)
  344. const ox = px - Math.sin(heading) * off * side
  345. const oy = py + Math.cos(heading) * off * side
  346. const r = hash01(ox, oy, 19)
  347. emitInstance(ox, oy, heading, chooseType(ox, oy, hash01(ox, oy, 23)), r)
  348. }
  349. }
  350. }
  351. // 3) 建成区补白(朝向跟最近的路向难以一一对应,这里给小角度扰动)
  352. const FILL = 52
  353. for (const ring of resaPolys) {
  354. const xs = ring.map((p) => p.x), ys = ring.map((p) => p.y)
  355. for (let gx = Math.min(...xs); gx <= Math.max(...xs); gx += FILL) {
  356. for (let gy = Math.min(...ys); gy <= Math.max(...ys); gy += FILL) {
  357. const jx = gx + (hash01(gx, gy, 29) - 0.5) * FILL * 0.7
  358. const jy = gy + (hash01(gx, gy, 31) - 0.5) * FILL * 0.7
  359. if (!inResa({ x: jx, y: jy })) continue
  360. const r = hash01(jx, jy, 37)
  361. if (r > 0.7) continue
  362. emitInstance(jx, jy, (hash01(jx, jy, 41) - 0.5) * 0.7, chooseType(jx, jy, hash01(jx, jy, 43)), r)
  363. }
  364. }
  365. }
  366. // ---------------------------------------------------------------- 写 GLB(含定向复查)
  367. function writeGlb(path, bucketList) {
  368. const json = {
  369. asset: { version: '2.0', generator: 'cockpit-city-massing-v2' },
  370. scene: 0,
  371. scenes: [{ nodes: [0] }],
  372. nodes: [{ mesh: 0, name: 'yuanling-town-massing' }],
  373. meshes: [{ name: 'town', primitives: [] }],
  374. materials: [],
  375. accessors: [],
  376. bufferViews: [],
  377. buffers: [],
  378. }
  379. const bin = []
  380. let byteOffset = 0
  381. const pushView = (data, target) => {
  382. const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength)
  383. json.bufferViews.push({ buffer: 0, byteOffset, byteLength: buf.length, target })
  384. bin.push(buf)
  385. byteOffset += buf.length + ((4 - (buf.length % 4)) % 4)
  386. return json.bufferViews.length - 1
  387. }
  388. for (const [color, b] of bucketList) {
  389. if (!b.indices.length) continue
  390. const positions = new Float32Array(b.positions)
  391. const normals = new Float32Array(b.normals)
  392. const indices = new Uint32Array(b.indices)
  393. const min = [Infinity, Infinity, Infinity]
  394. const max = [-Infinity, -Infinity, -Infinity]
  395. for (let i = 0; i < positions.length; i += 3) {
  396. for (let k = 0; k < 3; k++) {
  397. min[k] = Math.min(min[k], positions[i + k])
  398. max[k] = Math.max(max[k], positions[i + k])
  399. }
  400. }
  401. const posView = pushView(positions, 34962)
  402. const norView = pushView(normals, 34962)
  403. const idxView = pushView(indices, 34963)
  404. const posAcc = json.accessors.push({ bufferView: posView, componentType: 5126, count: positions.length / 3, type: 'VEC3', min, max }) - 1
  405. const norAcc = json.accessors.push({ bufferView: norView, componentType: 5126, count: normals.length / 3, type: 'VEC3' }) - 1
  406. const idxAcc = json.accessors.push({ bufferView: idxView, componentType: 5125, count: indices.length, type: 'SCALAR' }) - 1
  407. const material = json.materials.push({
  408. pbrMetallicRoughness: {
  409. baseColorFactor: [((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255, 1],
  410. metallicFactor: 0,
  411. roughnessFactor: 0.85,
  412. },
  413. doubleSided: true, // 票 35:配合定向 pass,杜绝任何"漏面"
  414. }) - 1
  415. json.meshes[0].primitives.push({ attributes: { POSITION: posAcc, NORMAL: norAcc }, indices: idxAcc, material })
  416. }
  417. const binBuffer = Buffer.concat(bin)
  418. json.buffers.push({ byteLength: binBuffer.length })
  419. const jsonText = Buffer.from(JSON.stringify(json), 'utf8')
  420. const jsonChunk = Buffer.concat([jsonText, Buffer.alloc((4 - (jsonText.length % 4)) % 4, 0x20)])
  421. const binChunk = Buffer.concat([binBuffer, Buffer.alloc((4 - (binBuffer.length % 4)) % 4, 0)])
  422. const total = 12 + 8 + jsonChunk.length + 8 + binChunk.length
  423. const header = Buffer.alloc(12)
  424. header.write('glTF', 0, 'ascii')
  425. header.writeUInt32LE(2, 4)
  426. header.writeUInt32LE(total, 8)
  427. const jsonHeader = Buffer.alloc(8)
  428. jsonHeader.writeUInt32LE(jsonChunk.length, 0)
  429. jsonHeader.write('JSON', 4, 'ascii')
  430. const binHeader = Buffer.alloc(8)
  431. binHeader.writeUInt32LE(binChunk.length, 0)
  432. binHeader.write('BIN\0', 4, 'ascii')
  433. writeFileSync(path, Buffer.concat([header, jsonHeader, jsonChunk, binHeader, binChunk]))
  434. return { bytes: total, primitives: json.meshes[0].primitives.length, materials: json.materials.length }
  435. }
  436. // ---------------------------------------------------------------- tileset + meta
  437. const R = 6378137
  438. const F = 1 / 298.257223563
  439. const E2 = 2 * F - F * F
  440. function enuTransform(lon, lat, height) {
  441. const lonR = lon * DEG, latR = lat * DEG
  442. const sinLat = Math.sin(latR), cosLat = Math.cos(latR)
  443. const sinLon = Math.sin(lonR), cosLon = Math.cos(lonR)
  444. const N = R / Math.sqrt(1 - E2 * sinLat * sinLat)
  445. const ox = (N + height) * cosLat * cosLon
  446. const oy = (N + height) * cosLat * sinLon
  447. const oz = (N * (1 - E2) + height) * sinLat
  448. return [-sinLon, cosLon, 0, 0, -sinLat * cosLon, -sinLat * sinLon, cosLat, 0, cosLat * cosLon, cosLat * sinLon, sinLat, 0, ox, oy, oz, 1]
  449. }
  450. mkdirSync(DATA_DIR, { recursive: true })
  451. const bucketList = [...buckets.entries()]
  452. const glbInfo = writeGlb(join(DATA_DIR, 'town.glb'), bucketList)
  453. // 包围盒
  454. let bx = [-Infinity, Infinity], by = [-Infinity, Infinity], bz = [-Infinity, Infinity]
  455. for (const [, b] of bucketList) {
  456. for (let i = 0; i < b.positions.length; i += 3) {
  457. const east = b.positions[i], up = b.positions[i + 1], north = -b.positions[i + 2]
  458. bx = [Math.max(bx[0], east), Math.min(bx[1], east)]
  459. by = [Math.max(by[0], north), Math.min(by[1], north)]
  460. bz = [Math.max(bz[0], up), Math.min(bz[1], up)]
  461. }
  462. }
  463. const box = [
  464. (bx[0] + bx[1]) / 2, (by[0] + by[1]) / 2, (bz[0] + bz[1]) / 2,
  465. (bx[0] - bx[1]) / 2, 0, 0,
  466. 0, (by[0] - by[1]) / 2, 0,
  467. 0, 0, (bz[0] - bz[1]) / 2,
  468. ]
  469. const tileset = {
  470. asset: { version: '1.1', generator: 'cockpit-city-massing-v2' },
  471. geometricError: 800, // 必须 > 0:票 34 实测 0 时 Cesium 不请求根瓦片内容
  472. root: {
  473. transform: enuTransform(ORIGIN.lon, ORIGIN.lat, 0),
  474. boundingVolume: { box },
  475. geometricError: 0,
  476. refine: 'ADD',
  477. content: { uri: 'town.glb' },
  478. },
  479. }
  480. writeFileSync(join(DATA_DIR, 'tileset.json'), JSON.stringify(tileset, null, 2), 'utf8')
  481. const meta = {
  482. _disclaimer: '示意体块,不是实测建筑。除 OSM 少量真实轮廓外,其余体块按真实路网/建成区程序化排布;不得用于量测、统计或应急决策。',
  483. _v2_changes: {
  484. 尺寸依据: '74 个真实轮廓分位数(长边中位 53m/p90 83m,短边 27-45m),单元=真实尺寸×0.45 体块系数',
  485. 贴地: '高程网格 64×64 + 逐栋足印四角采样,基准=min-4m 裙边(楼体向下延伸,不悬空)',
  486. 漏顶: '所有三角面程序化定向(法线取远离体中心方向)+ 材质 doubleSided',
  487. 中近景: '楼层色带(每层外凸 0.15m 深色带)+ 平顶女儿墙 / 坡屋顶出檐',
  488. },
  489. origin: ORIGIN,
  490. window: WINDOW,
  491. counts: { total: placed, ...stats, cap: CAP },
  492. archetypes: Object.fromEntries(Object.entries(ARCHETYPES).map(([k, v]) => [k, { long: v.long, short: v.short, floors: v.floors }])),
  493. glbBytes: glbInfo.bytes,
  494. localBox: { x: [bx[1], bx[0]], y: [by[1], by[0]], z: [bz[1], bz[0]] },
  495. // 回验样本:让 verify 脚本能独立复核"逐栋贴地"(重新按足印四角对高程网格插值)
  496. verificationSample: verificationSample.slice(0, 300),
  497. generatedAt: new Date().toISOString(),
  498. }
  499. writeFileSync(join(DATA_DIR, 'meta.json'), JSON.stringify(meta, null, 2), 'utf8')
  500. console.log(`生成完成:${placed} 栋(真实轮廓 ${stats.real} / 民居 ${stats.house} / 条楼 ${stats.block} / 板楼 ${stats.slab},避让水系 ${stats.skippedWater})`)
  501. console.log(`GLB:${(glbInfo.bytes / 1024 / 1024).toFixed(2)} MB,${glbInfo.primitives} 个图元 / ${glbInfo.materials} 种材质`)
  502. console.log(`局部范围(米):x ${bx[1].toFixed(0)}~${bx[0].toFixed(0)},y ${by[1].toFixed(0)}~${by[0].toFixed(0)},z ${bz[1].toFixed(0)}~${bz[0].toFixed(0)}`)