build-town.mjs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. /**
  2. * 沅陵县城「轻量体块」生成器(票 34)。
  3. *
  4. * ⚠️ 这是**示意体块,不是实测建筑**:除 OSM 的少量真实轮廓外,其余是按真实路网/居民地面
  5. * 程序化排布的**可复用楼房单元**。用途是让一张图**远景**看起来像一座县城(维护者 2026-09-18
  6. * 对上一版「白色立方体 + 明显错位」的模型的替代方案:参照 GIS 数据做轻量建模,
  7. * 远景基本达标、不要求近景细节、楼房模型可复用)。
  8. *
  9. * 地理配准**由构造保证**:原料是经纬度,生成时投影到以目标点(沅陵县城 110.39028164,
  10. * 28.4436787)为原点的 ENU 米制坐标,再写成 3D Tiles 的 `transform` —— 不存在"猜原点"。
  11. *
  12. * 关键输入(本机):
  13. * · planet_...-shp/shpOutput/buildings.json OSM 真实轮廓(173 个,城区内最可靠的部分)
  14. * · planet_...-shp/shpOutput/roads.json OSM 路网(体块沿街排布)
  15. * · planet_...-shp/shpOutput/waterways.json 水系(沅江,体块避让)
  16. * · output/resa.json 1:5 万居民地面(建成区范围)
  17. * · cityMassing/terrain-grid.json 本地 DEM 采样网格(体块坐落到地形上)
  18. *
  19. * 用法:node tools/city-massing/build-town.mjs
  20. * 产物:<DATA_DIR>/{town.glb, tileset.json, meta.json}
  21. */
  22. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
  23. import { join } from 'node:path'
  24. const GIS = 'D:/GIS/GIS'
  25. const OSM = join(GIS, 'planet_110.059,28.258_110.744,28.625-shp/shpOutput')
  26. const DATA_DIR = process.env.CITY_DATA_DIR || join(GIS, 'cityMassing')
  27. /** 原点 = 维护者给的目标坐标(CGCS2000/WGS-84 本项目视为一致)。 */
  28. const ORIGIN = { lon: 110.39028164, lat: 28.4436787 }
  29. /** 出图窗口:目标点 ±0.020 经 / ±0.015 纬(约 1.9 km × 1.7 km,县城建成区尺度)。 */
  30. const WINDOW = { lon: 0.02, lat: 0.015 }
  31. /** 楼高随离中心距离衰减:城区(≤400m)出 6–8 层,边缘出 1–2 层。 */
  32. const CORE_RADIUS = 400
  33. const CAP = 5000
  34. const DEG = Math.PI / 180
  35. const M_PER_DEG_LAT = 110540
  36. const M_PER_DEG_LON = 111320 * Math.cos(ORIGIN.lat * DEG)
  37. const toLocal = (lon, lat) => ({
  38. x: (lon - ORIGIN.lon) * M_PER_DEG_LON,
  39. y: (lat - ORIGIN.lat) * M_PER_DEG_LAT,
  40. })
  41. const inWindow = (lon, lat) => Math.abs(lon - ORIGIN.lon) <= WINDOW.lon && Math.abs(lat - ORIGIN.lat) <= WINDOW.lat
  42. /** 稳定的整数哈希 → [0,1):同一输入每次生成结果一致(可复现,不是每次刷新都变样)。 */
  43. function hash01(...nums) {
  44. let h = 2166136261
  45. for (const n of nums) {
  46. let v = Math.round(n * 1000) | 0
  47. for (let i = 0; i < 4; i++) {
  48. h ^= (v >> (i * 8)) & 0xff
  49. h = Math.imul(h, 16777619)
  50. }
  51. }
  52. return ((h >>> 0) % 100000) / 100000
  53. }
  54. /** 线性插值取名次(用于从几个候选里挑一个,且分布可控)。 */
  55. const pick = (arr, r) => arr[Math.min(arr.length - 1, Math.floor(r * arr.length))]
  56. // ---------------------------------------------------------------- 读入原料
  57. const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'))
  58. const buildings = readJson(join(OSM, 'buildings.json')).features
  59. const roads = readJson(join(OSM, 'roads.json')).features
  60. const waterways = readJson(join(OSM, 'waterways.json')).features
  61. const resa = readJson(join(GIS, 'output/resa.json')).features
  62. const terrain = JSON.parse(readFileSync(join(DATA_DIR, 'terrain-grid.json'), 'utf8'))
  63. /** 高程:网格双线性插值(缺失点回落到中心值)。 */
  64. function elevationAt(lon, lat) {
  65. const gx = (lon - terrain.lon0) / terrain.dLon
  66. const gy = (lat - terrain.lat0) / terrain.dLat
  67. const n = terrain.n
  68. const cx = Math.max(0, Math.min(n - 1.001, gx))
  69. const cy = Math.max(0, Math.min(n - 1.001, gy))
  70. const x0 = Math.floor(cx), y0 = Math.floor(cy)
  71. const fx = cx - x0, fy = cy - y0
  72. const at = (x, y) => terrain.heights[y * n + x]
  73. const corners = [at(x0, y0), at(x0 + 1, y0), at(x0, y0 + 1), at(x0 + 1, y0 + 1)].map((v) => (typeof v === 'number' ? v : 111.6))
  74. const top = corners[0] * (1 - fx) + corners[1] * fx
  75. const bottom = corners[2] * (1 - fx) + corners[3] * fx
  76. return top * (1 - fy) + bottom * fy
  77. }
  78. // ---------------------------------------------------------------- 避让与参考线
  79. /** 水系折线(局部坐标),用于避让。 */
  80. const waterLines = []
  81. for (const f of waterways) {
  82. const ring = f.geometry && f.geometry.type === 'LineString' ? f.geometry.coordinates : null
  83. if (!ring) continue
  84. const seg = []
  85. for (const [lon, lat] of ring) {
  86. if (inWindow(lon, lat)) seg.push(toLocal(lon, lat))
  87. else if (seg.length) { waterLines.push(seg); seg.length = 0 }
  88. }
  89. if (seg.length) waterLines.push(seg)
  90. }
  91. function distanceToWater(p) {
  92. let best = Infinity
  93. for (const line of waterLines) {
  94. for (let i = 1; i < line.length; i++) {
  95. const a = line[i - 1], b = line[i]
  96. const dx = b.x - a.x, dy = b.y - a.y
  97. const len2 = dx * dx + dy * dy || 1
  98. let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2
  99. t = Math.max(0, Math.min(1, t))
  100. best = Math.min(best, Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)))
  101. }
  102. }
  103. return best
  104. }
  105. /** 居民地面(建成区)多边形,局部坐标。 */
  106. const resaPolys = []
  107. for (const f of resa) {
  108. const ring = f.geometry && f.geometry.type === 'Polygon' ? f.geometry.coordinates[0] : null
  109. if (!ring || ring.length < 4) continue
  110. if (!ring.some(([lon, lat]) => inWindow(lon, lat))) continue
  111. resaPolys.push(ring.map(([lon, lat]) => toLocal(lon, lat)))
  112. }
  113. function inResa(p) {
  114. for (const ring of resaPolys) {
  115. let inside = false
  116. for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
  117. const a = ring[i], b = ring[j]
  118. 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
  119. }
  120. if (inside) return true
  121. }
  122. return false
  123. }
  124. /** 可沿街的等级(trunk/primary/... → 体块贴街);纯步道/小径不排。 */
  125. const STREET_TYPES = new Set(['trunk', 'primary', 'secondary', 'tertiary', 'residential', 'unclassified', 'living_street'])
  126. const streetSegments = []
  127. for (const f of roads) {
  128. const type = (f.properties && f.properties.type) || ''
  129. if (!STREET_TYPES.has(type)) continue
  130. const ring = f.geometry && f.geometry.type === 'LineString' ? f.geometry.coordinates : null
  131. if (!ring) continue
  132. for (let i = 1; i < ring.length; i++) {
  133. const [lon1, lat1] = ring[i - 1]
  134. const [lon2, lat2] = ring[i]
  135. if (!inWindow(lon1, lat1) && !inWindow(lon2, lat2)) continue
  136. streetSegments.push({ a: toLocal(lon1, lat1), b: toLocal(lon2, lat2), type })
  137. }
  138. }
  139. // ---------------------------------------------------------------- 可复用楼房单元
  140. /**
  141. * 三种**可复用**单元(同一种几何反复用,只是位置/朝向/高度/材质不同):
  142. * house 坡屋顶 1–2 层 · block 平顶 3–5 层 · slab 板楼 6–8 层。
  143. */
  144. const ARCHETYPES = {
  145. house: {
  146. w: 9, d: 7.2, floors: [1, 2], floorH: 3.2, pitched: true,
  147. walls: [0xf2efe6, 0xe8e2d4, 0xdfd8c8], roofs: [0x8c4a3a, 0x7a5140],
  148. },
  149. block: {
  150. w: 14, d: 10, floors: [3, 5], floorH: 3.1, pitched: false,
  151. walls: [0xe9e6df, 0xd9d6cf, 0xcfd2d4], roofs: [0x6f7478, 0x5c6165],
  152. },
  153. slab: {
  154. w: 26, d: 11, floors: [6, 8], floorH: 3.0, pitched: false,
  155. walls: [0xe4e6e8, 0xd6d9dc], roofs: [0x545a5f],
  156. },
  157. }
  158. const UNIT_BOX = makeBox()
  159. /** 单位立方体(0..1 各轴),实例化时按尺寸缩放 —— 几何只有一份,靠矩阵复用。 */
  160. function makeBox() {
  161. return {
  162. // 6 面 × 4 顶点
  163. positions: [
  164. 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, // 底
  165. 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, // 顶
  166. 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, // 南
  167. 0, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, // 北
  168. 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, // 西
  169. 1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1, 1, // 东
  170. ],
  171. normals: [
  172. 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0,
  173. 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0,
  174. 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1,
  175. 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1,
  176. -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0,
  177. 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0,
  178. ],
  179. indices: [
  180. 0, 1, 2, 0, 2, 3,
  181. 4, 5, 6, 4, 6, 7,
  182. 8, 9, 10, 8, 10, 11,
  183. 12, 13, 14, 12, 14, 15,
  184. 16, 17, 18, 16, 18, 19,
  185. 20, 21, 22, 20, 22, 23,
  186. ],
  187. }
  188. }
  189. /** 把一栋楼推成三角面,写进按材质分组的桶里。 */
  190. function emitBuilding(buckets, { x, y, z, heading, type, height, wallColor, roofColor, width, depth }) {
  191. const cos = Math.cos(heading), sin = Math.sin(heading)
  192. // 局部(x,y) → 世界;glTF 是 Y-up:写 (east, up, -north)
  193. const put = (lx, ly, lz) => {
  194. const ex = lx * cos - ly * sin
  195. const ny = lx * sin + ly * cos
  196. return [x + ex, z + lz, -(y + ny)]
  197. }
  198. const hw = width / 2, hd = depth / 2
  199. const wallBucket = buckets.get(wallColor) || { positions: [], normals: [], indices: [], base: 0 }
  200. const pushQuad = (bucket, quad, normal) => {
  201. const base = bucket.positions.length / 3
  202. for (const v of quad) bucket.positions.push(v[0], v[1], v[2])
  203. for (let i = 0; i < 4; i++) bucket.normals.push(normal[0], normal[1], normal[2])
  204. bucket.indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
  205. }
  206. // 四片墙(用旋转后的法线)
  207. const rot = (nx, ny) => [nx * cos - ny * sin, -(nx * sin + ny * cos)]
  208. const corners = [
  209. put(-hw, -hd, 0), put(hw, -hd, 0), put(hw, hd, 0), put(-hw, hd, 0),
  210. ]
  211. const top = [
  212. put(-hw, -hd, height), put(hw, -hd, height), put(hw, hd, height), put(-hw, hd, height),
  213. ]
  214. const wallNormal = (i) => {
  215. const n = [[0, -1], [1, 0], [0, 1], [-1, 0]][i]
  216. const r = rot(n[0], n[1])
  217. return [r[0], 0, r[1]]
  218. }
  219. for (let i = 0; i < 4; i++) {
  220. const j = (i + 1) % 4
  221. pushQuad(wallBucket, [corners[i], corners[j], top[j], top[i]], wallNormal(i))
  222. }
  223. buckets.set(wallColor, wallBucket)
  224. if (type === 'house' && ARCHETYPES.house.pitched) {
  225. // 坡屋顶:沿长边起脊
  226. const roofBucket = buckets.get(roofColor) || { positions: [], normals: [], indices: [], base: 0 }
  227. const ridgeH = 1.9
  228. const r1 = put(0, -hd, height + ridgeH)
  229. const r2 = put(0, hd, height + ridgeH)
  230. pushQuad(roofBucket, [top[0], top[1], r1, r1], [0, 1, 0]) // 占位,替换如下
  231. roofBucket.positions.length -= 12
  232. roofBucket.normals.length -= 12
  233. roofBucket.indices.length -= 6
  234. const tri = (p1, p2, p3, n) => {
  235. const base = roofBucket.positions.length / 3
  236. for (const v of [p1, p2, p3]) roofBucket.positions.push(v[0], v[1], v[2])
  237. for (let i = 0; i < 3; i++) roofBucket.normals.push(n[0], n[1], n[2])
  238. roofBucket.indices.push(base, base + 1, base + 2)
  239. }
  240. tri(top[0], top[1], r1, [0, 1, 0])
  241. tri(top[1], top[3], r2, [0, 1, 0])
  242. tri(top[1], r2, r1, [0, 1, 0])
  243. tri(top[3], top[2], r2, [0, 1, 0])
  244. tri(top[2], top[0], r1, [0, 1, 0])
  245. tri(top[2], r1, r2, [0, 1, 0])
  246. tri(top[0], top[3], r1, [0, 1, 0])
  247. tri(top[3], r2, r1, [0, 1, 0])
  248. buckets.set(roofColor, roofBucket)
  249. return
  250. }
  251. const roofBucket = buckets.get(roofColor) || { positions: [], normals: [], indices: [], base: 0 }
  252. pushQuad(roofBucket, [top[0], top[3], top[2], top[1]], [0, 1, 0])
  253. buckets.set(roofColor, roofBucket)
  254. }
  255. // ---------------------------------------------------------------- 生成实例
  256. const buckets = new Map()
  257. let placed = 0
  258. const stats = { house: 0, block: 0, slab: 0, real: 0, skippedWater: 0 }
  259. /** 按离中心距离选单元:近中心出板楼/条楼,边缘出坡顶民居。 */
  260. function chooseType(x, y, r) {
  261. const dist = Math.hypot(x, y)
  262. if (dist < CORE_RADIUS) return r < 0.28 ? 'slab' : 'block'
  263. if (dist < CORE_RADIUS * 2.4) return r < 0.7 ? 'block' : 'house'
  264. return r < 0.25 ? 'block' : 'house'
  265. }
  266. function emitInstance(x, y, heading, type, r) {
  267. if (placed >= CAP) return
  268. const spec = ARCHETYPES[type]
  269. const lon = ORIGIN.lon + x / M_PER_DEG_LON
  270. const lat = ORIGIN.lat + y / M_PER_DEG_LAT
  271. if (!inWindow(lon, lat)) return
  272. if (distanceToWater({ x, y }) < 26) { stats.skippedWater += 1; return }
  273. const floors = spec.floors[0] + Math.floor(r * (spec.floors[1] - spec.floors[0] + 1))
  274. const height = floors * spec.floorH
  275. const width = spec.w * (0.85 + 0.3 * hash01(x, y, 7))
  276. const depth = spec.d * (0.9 + 0.2 * hash01(x, y, 11))
  277. emitBuilding(buckets, {
  278. x, y, z: elevationAt(lon, lat) - 0.6, // 略埋 0.6m,避免与地形之间露缝
  279. heading,
  280. type,
  281. height,
  282. width,
  283. depth,
  284. wallColor: pick(spec.walls, hash01(x, y, 3)),
  285. roofColor: pick(spec.roofs, hash01(x, y, 5)),
  286. })
  287. placed += 1
  288. stats[type] += 1
  289. }
  290. // 1) 真实 OSM 轮廓:能用的都用(它们的形状是真的)
  291. for (const f of buildings) {
  292. const geom = f.geometry
  293. if (!geom) continue
  294. const rings = geom.type === 'Polygon' ? [geom.coordinates[0]] : geom.type === 'MultiPolygon' ? geom.coordinates.map((p) => p[0]) : []
  295. for (const ring of rings) {
  296. if (!ring || ring.length < 4) continue
  297. const local = ring.filter(([lon, lat]) => inWindow(lon, lat)).map(([lon, lat]) => toLocal(lon, lat))
  298. if (local.length < 4) continue
  299. // 以轮廓中心为实例位置,按外接盒尺寸建体块(真实轮廓保留下来做形状约束太细,远景不需要)
  300. const xs = local.map((p) => p.x), ys = local.map((p) => p.y)
  301. const cx = (Math.min(...xs) + Math.max(...xs)) / 2
  302. const cy = (Math.min(...ys) + Math.max(...ys)) / 2
  303. const w = Math.max(6, Math.max(...xs) - Math.min(...xs))
  304. const d = Math.max(5, Math.max(...ys) - Math.min(...ys))
  305. const lon = ORIGIN.lon + cx / M_PER_DEG_LON
  306. const lat = ORIGIN.lat + cy / M_PER_DEG_LAT
  307. if (distanceToWater({ x: cx, y: cy }) < 22) continue
  308. const r = hash01(cx, cy, 13)
  309. // 城区真实轮廓按条楼/条楼处理
  310. const type = w * d > 420 ? 'slab' : 'block'
  311. const spec = ARCHETYPES[type]
  312. const floors = 2 + Math.floor(r * 4)
  313. emitBuilding(buckets, {
  314. x: cx, y: cy, z: elevationAt(lon, lat) - 0.6,
  315. heading: 0, type, height: floors * spec.floorH,
  316. width: w, depth: d,
  317. wallColor: pick(spec.walls, r),
  318. roofColor: pick(spec.roofs, hash01(cx, cy, 17)),
  319. })
  320. placed += 1
  321. stats.real += 1
  322. }
  323. }
  324. // 2) 沿街排布:每条可沿街的线段两侧按间距放楼(贴街是我国县城的常态)
  325. const SPACING = 23
  326. for (const seg of streetSegments) {
  327. const dx = seg.b.x - seg.a.x, dy = seg.b.y - seg.a.y
  328. const len = Math.hypot(dx, dy)
  329. if (len < 12) continue
  330. const heading = Math.atan2(dy, dx)
  331. const steps = Math.floor(len / SPACING)
  332. for (let s = 0; s <= steps; s++) {
  333. const t = steps === 0 ? 0.5 : s / steps
  334. const px = seg.a.x + dx * t
  335. const py = seg.a.y + dy * t
  336. for (const side of [-1, 1]) {
  337. const off = 11 + 6 * hash01(px, py, side)
  338. const ox = px - Math.sin(heading) * off * side
  339. const oy = py + Math.cos(heading) * off * side
  340. const r = hash01(ox, oy, 19)
  341. emitInstance(ox, oy, heading + (r < 0.5 ? 0 : Math.PI / 2), chooseType(ox, oy, hash01(ox, oy, 23)), r)
  342. }
  343. }
  344. }
  345. // 3) 建成区补白:居民地面内做抖动网格,避免"只有街边有楼"的空旷感
  346. const FILL = 30
  347. for (const ring of resaPolys) {
  348. const xs = ring.map((p) => p.x), ys = ring.map((p) => p.y)
  349. for (let gx = Math.min(...xs); gx <= Math.max(...xs); gx += FILL) {
  350. for (let gy = Math.min(...ys); gy <= Math.max(...ys); gy += FILL) {
  351. const jx = gx + (hash01(gx, gy, 29) - 0.5) * FILL * 0.7
  352. const jy = gy + (hash01(gx, gy, 31) - 0.5) * FILL * 0.7
  353. if (!inResa({ x: jx, y: jy })) continue
  354. const r = hash01(jx, jy, 37)
  355. if (r > 0.72) continue // 留出空隙:不是每格都盖楼
  356. emitInstance(jx, jy, (hash01(jx, jy, 41) - 0.5) * 0.6, chooseType(jx, jy, hash01(jx, jy, 43)), r)
  357. }
  358. }
  359. }
  360. // ---------------------------------------------------------------- 写 GLB
  361. function writeGlb(path, bucketList) {
  362. const json = {
  363. asset: { version: '2.0', generator: 'cockpit-city-massing' },
  364. scene: 0,
  365. scenes: [{ nodes: [0] }],
  366. nodes: [{ mesh: 0, name: 'yuanling-town-massing' }],
  367. meshes: [{ name: 'town', primitives: [] }],
  368. materials: [],
  369. accessors: [],
  370. bufferViews: [],
  371. buffers: [],
  372. }
  373. const bin = []
  374. let byteOffset = 0
  375. const pushView = (data, target) => {
  376. const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength)
  377. json.bufferViews.push({ buffer: 0, byteOffset, byteLength: buf.length, target })
  378. bin.push(buf)
  379. byteOffset += buf.length + ((4 - (buf.length % 4)) % 4)
  380. return json.bufferViews.length - 1
  381. }
  382. for (const [color, bucket] of bucketList) {
  383. if (!bucket.indices.length) continue
  384. const positions = new Float32Array(bucket.positions)
  385. const normals = new Float32Array(bucket.normals)
  386. const indices = new Uint32Array(bucket.indices)
  387. const min = [Infinity, Infinity, Infinity]
  388. const max = [-Infinity, -Infinity, -Infinity]
  389. for (let i = 0; i < positions.length; i += 3) {
  390. for (let k = 0; k < 3; k++) {
  391. min[k] = Math.min(min[k], positions[i + k])
  392. max[k] = Math.max(max[k], positions[i + k])
  393. }
  394. }
  395. const posView = pushView(positions, 34962)
  396. const norView = pushView(normals, 34962)
  397. const idxView = pushView(indices, 34963)
  398. const posAcc = json.accessors.push({ bufferView: posView, componentType: 5126, count: positions.length / 3, type: 'VEC3', min, max }) - 1
  399. const norAcc = json.accessors.push({ bufferView: norView, componentType: 5126, count: normals.length / 3, type: 'VEC3' }) - 1
  400. const idxAcc = json.accessors.push({ bufferView: idxView, componentType: 5125, count: indices.length, type: 'SCALAR' }) - 1
  401. const material = json.materials.push({
  402. pbrMetallicRoughness: {
  403. baseColorFactor: [((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255, 1],
  404. metallicFactor: 0,
  405. roughnessFactor: 0.85,
  406. },
  407. doubleSided: false,
  408. }) - 1
  409. json.meshes[0].primitives.push({
  410. attributes: { POSITION: posAcc, NORMAL: norAcc },
  411. indices: idxAcc,
  412. material,
  413. })
  414. }
  415. const binBuffer = Buffer.concat(bin)
  416. json.buffers.push({ byteLength: binBuffer.length })
  417. const jsonText = Buffer.from(JSON.stringify(json), 'utf8')
  418. const jsonPad = (4 - (jsonText.length % 4)) % 4
  419. const jsonChunk = Buffer.concat([jsonText, Buffer.alloc(jsonPad, 0x20)])
  420. const binPad = (4 - (binBuffer.length % 4)) % 4
  421. const binChunk = Buffer.concat([binBuffer, Buffer.alloc(binPad, 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. const out = Buffer.concat([header, jsonHeader, jsonChunk, binHeader, binChunk])
  434. writeFileSync(path, out)
  435. return { bytes: out.length, primitives: json.meshes[0].primitives.length, materials: json.materials.length }
  436. }
  437. // ---------------------------------------------------------------- 写 tileset + meta
  438. const R = 6378137
  439. const F = 1 / 298.257223563
  440. const E2 = 2 * F - F * F
  441. function enuTransform(lon, lat, height) {
  442. const lonR = lon * DEG, latR = lat * DEG
  443. const sinLat = Math.sin(latR), cosLat = Math.cos(latR)
  444. const sinLon = Math.sin(lonR), cosLon = Math.cos(lonR)
  445. const N = R / Math.sqrt(1 - E2 * sinLat * sinLat)
  446. const ox = (N + height) * cosLat * cosLon
  447. const oy = (N + height) * cosLat * sinLon
  448. const oz = (N * (1 - E2) + height) * sinLat
  449. return [-sinLon, cosLon, 0, 0, -sinLat * cosLon, -sinLat * sinLon, cosLat, 0, cosLat * cosLon, cosLat * sinLon, sinLat, 0, ox, oy, oz, 1]
  450. }
  451. mkdirSync(DATA_DIR, { recursive: true })
  452. const bucketList = [...buckets.entries()]
  453. const glbInfo = writeGlb(join(DATA_DIR, 'town.glb'), bucketList)
  454. // 包围盒(局部 ENU 米):从顶点直接算
  455. let bx = [-Infinity, Infinity], by = [-Infinity, Infinity], bz = [-Infinity, Infinity]
  456. for (const [, b] of bucketList) {
  457. for (let i = 0; i < b.positions.length; i += 3) {
  458. // glTF Y-up → ENU:east=x, up=y, north=-z
  459. const east = b.positions[i], up = b.positions[i + 1], north = -b.positions[i + 2]
  460. bx = [Math.max(bx[0], east), Math.min(bx[1], east)]
  461. by = [Math.max(by[0], north), Math.min(by[1], north)]
  462. bz = [Math.max(bz[0], up), Math.min(bz[1], up)]
  463. }
  464. }
  465. const box = [
  466. (bx[0] + bx[1]) / 2, (by[0] + by[1]) / 2, (bz[0] + bz[1]) / 2,
  467. (bx[0] - bx[1]) / 2, 0, 0,
  468. 0, (by[0] - by[1]) / 2, 0,
  469. 0, 0, (bz[0] - bz[1]) / 2,
  470. ]
  471. const tileset = {
  472. asset: { version: '1.1', generator: 'cockpit-city-massing' },
  473. // 注意:tileset 级 geometricError 必须 > 0 —— 实测写成 0 时 Cesium 不请求根瓦片内容
  474. // (tileset.json 拉到了、town.glb 始终不下载,页面上一栋楼都没有)。
  475. geometricError: 800,
  476. root: {
  477. transform: enuTransform(ORIGIN.lon, ORIGIN.lat, 0),
  478. boundingVolume: { box },
  479. // 根节点只有一块内容,不需要再细分:0 = 到此为止(refine 也就没机会生效)。
  480. geometricError: 0,
  481. refine: 'ADD',
  482. content: { uri: 'town.glb' },
  483. },
  484. }
  485. writeFileSync(join(DATA_DIR, 'tileset.json'), JSON.stringify(tileset, null, 2), 'utf8')
  486. const meta = {
  487. _disclaimer:
  488. '示意体块,不是实测建筑。除 OSM 少量真实轮廓外,其余体块按真实路网/建成区程序化排布,用于远景观感;不得用于任何量测、统计或应急决策。',
  489. origin: ORIGIN,
  490. window: WINDOW,
  491. counts: { total: placed, ...stats, cap: CAP },
  492. archetypes: Object.keys(ARCHETYPES),
  493. materials: glbInfo.materials,
  494. glbBytes: glbInfo.bytes,
  495. localBox: { x: [bx[1], bx[0]], y: [by[1], by[0]], z: [bz[1], bz[0]] },
  496. generatedAt: new Date().toISOString(),
  497. }
  498. writeFileSync(join(DATA_DIR, 'meta.json'), JSON.stringify(meta, null, 2), 'utf8')
  499. console.log(`生成完成:${placed} 栋(真实轮廓 ${stats.real} / 民居 ${stats.house} / 条楼 ${stats.block} / 板楼 ${stats.slab},避让水系 ${stats.skippedWater})`)
  500. console.log(`GLB:${(glbInfo.bytes / 1024).toFixed(0)} KB,${glbInfo.primitives} 个图元 / ${glbInfo.materials} 种材质`)
  501. 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)}`)
  502. console.log(`产物目录:${DATA_DIR}`)