crop-gis-lod.mjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. /**
  2. * GIS 分级资产裁剪(同心 LOD)—— 从原始切片目录生成驾驶舱用的分级静态资产。
  3. *
  4. * 用法:node tools/gis-asset/crop-gis-lod.mjs [--src D:/GIS/GIS] [--out D:/GIS/cockpit-gis]
  5. *
  6. * 设计(维护者 2026-11-03):
  7. * 主场景边缘曾出现一圈低清影像(z19 直落 z14,32 倍骤降且无雾遮挡)。
  8. * 修复 = 同心分级,清晰度随距离递减,衔接落差降到 2~8 倍:
  9. * ring0 主场景 bbox z0..19(0.26 m/px,城市细节)
  10. * ring1 主场景外扩 R1=3 km z15..17(1.05 m/px)
  11. * ring2 R1..R2=8 km z15(4.19 m/px)
  12. * ring3 影像数据全范围 z0..14(8.39 m/px;覆盖到数据边界,让雾边界内外都是真实影像)
  13. * 地形 DEM 整份保留:低层级是全球覆盖,按兴趣区裁剪会在地球上留下空洞(v3 教训)。
  14. *
  15. * 配套契约(public/cockpit/config.json):
  16. * overlays 按「外→内」顺序声明四层,后加者在上面;
  17. * 每层 maximumLevel 与数据一致(z14/z15/z17/z19)——某层缺高层时 Cesium 自动用
  18. * 该层已有瓦片向上放大,**不会露底**;若把 maxLevel 声明得比数据高,缺瓦片的
  19. * 层级会 404 并露出更底的底图(v4 教训:绿带就是这么来的)。
  20. *
  21. * 防呆(v5 增补):
  22. * · 源层级白名单:ring3 只认 z0..14,绝不触碰 z15+(曾误拷 52GB / 41.7GB 后才发现);
  23. * · 输出前总量断言:瓦片数超出预算即中止,防止配置改错后无声吞盘。
  24. */
  25. import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
  26. import path from 'node:path'
  27. const argv = process.argv
  28. const readOpt = (name, fallback) => {
  29. const i = argv.indexOf(name)
  30. return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback
  31. }
  32. const SRC = readOpt('--src', 'D:/GIS/GIS')
  33. const OUT = readOpt('--out', 'D:/GIS/cockpit-gis')
  34. const PIC_SRC = path.join(SRC, 'yuanlingPic3')
  35. const DEM_SRC = path.join(SRC, 'yuanlingDEM')
  36. // —— 契约参数(与 public/cockpit/config.json 同源;换部署按自己的 primaryArea/home/fog 重算)——
  37. const PRIMARY = [110.328749941417, 28.3917230659234, 110.465779226663, 28.4913038992907]
  38. const HOME = [110.39028164, 28.4436787]
  39. const FOG_RADIUS_KM = 42 // 与 map.fog.endDistance 同源
  40. const RING1_KM = 3
  41. const RING2_KM = 8
  42. const FINE_MAX_Z = 19
  43. const RING1_MAX_Z = 17
  44. const MID_MAX_Z = 15
  45. const OUTER_MAX_Z = 14
  46. const DEM_MAX_Z = 14 // 源 DEM 的 maxzoom(layer.json)
  47. const TILE_BUDGET = 80_000 // 影像总瓦片预算(超出即中止,防止误配吞盘)
  48. const KM_LAT = 111.32
  49. const expand = (b, km) => {
  50. const dLat = km / KM_LAT
  51. const dLon = km / (KM_LAT * Math.cos(HOME[1] * Math.PI / 180))
  52. return [b[0] - dLon, b[1] - dLat, b[2] + dLon, b[3] + dLat]
  53. }
  54. const RING1 = expand(PRIMARY, RING1_KM)
  55. const RING2 = expand(PRIMARY, RING2_KM)
  56. const FOG = expand([HOME[0], HOME[1], HOME[0], HOME[1]], FOG_RADIUS_KM)
  57. const lon2x = (lon, z) => Math.floor((lon + 180) / 360 * 2 ** z)
  58. const lat2yXyz = (lat, z) => {
  59. const r = lat * Math.PI / 180
  60. return Math.floor((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2 * 2 ** z)
  61. }
  62. const yTms = (lat, z) => (2 ** z - 1) - lat2yXyz(lat, z)
  63. const box = (b, z) => {
  64. const a = yTms(b[3], z), c = yTms(b[1], z)
  65. return { x0: lon2x(b[0], z), x1: lon2x(b[2], z), y0: Math.min(a, c), y1: Math.max(a, c) }
  66. }
  67. const inBox = (bx, x, y) => x >= bx.x0 && x <= bx.x1 && y >= bx.y0 && y <= bx.y1
  68. /** 复制 outer 内、排除 excludes 各盒的瓦片;zMin..zMax 逐层。 */
  69. function copyRing(srcDir, dstDir, outer, excludes, zMin, zMax, label) {
  70. let files = 0, bytes = 0
  71. for (let z = zMin; z <= zMax; z++) {
  72. const o = box(outer, z)
  73. const ex = excludes.map((b) => box(b, z))
  74. let dirs = []
  75. try { dirs = readdirSync(path.join(srcDir, String(z))) } catch { continue }
  76. let zf = 0
  77. for (const xs of dirs) {
  78. const xi = Number(xs)
  79. if (!Number.isFinite(xi) || xi < o.x0 || xi > o.x1) continue
  80. let ys = []
  81. try { ys = readdirSync(path.join(srcDir, String(z), xs)) } catch { continue }
  82. for (const yf of ys) {
  83. const yi = Number(yf.replace('.png', ''))
  84. if (!Number.isFinite(yi) || yi < o.y0 || yi > o.y1) continue
  85. if (ex.some((bx) => inBox(bx, xi, yi))) continue
  86. const dst = path.join(dstDir, String(z), xs, yf)
  87. mkdirSync(path.dirname(dst), { recursive: true })
  88. copyFileSync(path.join(srcDir, String(z), xs, yf), dst)
  89. files++; zf++
  90. try { bytes += statSync(path.join(srcDir, String(z), xs, yf)).size } catch {}
  91. }
  92. }
  93. if (zf) console.log(` ${label} z${z}: ${zf}`)
  94. }
  95. return { files, bytes }
  96. }
  97. /** 地形整份拷贝(含 layer.json 原样)。 */
  98. function copyDemWhole() {
  99. let files = 0, bytes = 0
  100. const dstRoot = path.join(OUT, 'dem')
  101. for (const z of readdirSync(DEM_SRC).filter((n) => /^\d+$/.test(n))) {
  102. let xdirs = []
  103. try { xdirs = readdirSync(path.join(DEM_SRC, z)) } catch { continue }
  104. for (const xs of xdirs) {
  105. let ys = []
  106. try { ys = readdirSync(path.join(DEM_SRC, z, xs)) } catch { continue }
  107. for (const yf of ys) {
  108. const src = path.join(DEM_SRC, z, xs, yf)
  109. const dst = path.join(dstRoot, z, xs, yf)
  110. mkdirSync(path.dirname(dst), { recursive: true })
  111. copyFileSync(src, dst)
  112. files++
  113. try { bytes += statSync(src).size } catch {}
  114. }
  115. }
  116. }
  117. for (const name of readdirSync(DEM_SRC)) {
  118. if (/^\d+$/.test(name)) continue
  119. const src = path.join(DEM_SRC, name)
  120. try {
  121. if (statSync(src).isFile()) {
  122. mkdirSync(dstRoot, { recursive: true })
  123. copyFileSync(src, path.join(dstRoot, name))
  124. files++; bytes += statSync(src).size
  125. }
  126. } catch {}
  127. }
  128. return { files, bytes }
  129. }
  130. if (!existsSync(PIC_SRC) || !existsSync(DEM_SRC)) {
  131. console.error(`源目录不存在:${PIC_SRC} / ${DEM_SRC}`)
  132. process.exit(1)
  133. }
  134. console.log(`源:${SRC}\n产出:${OUT}`)
  135. console.log(`主场景:${PRIMARY.map((v) => v.toFixed(4)).join(', ')}`)
  136. console.log(`环1(+${RING1_KM}km) / 环2(+${RING2_KM}km) / 雾范围(${FOG_RADIUS_KM}km)\n`)
  137. console.log('1/5 ring0 主场景 z0-19 ...')
  138. const r0 = copyRing(PIC_SRC, path.join(OUT, 'pic-ring0'), PRIMARY, [], 0, FINE_MAX_Z, 'ring0')
  139. console.log(` ring0: ${r0.files} 文件 ${(r0.bytes / 1048576).toFixed(1)} MB`)
  140. console.log(`2/5 ring1 主场景..+${RING1_KM}km z15-17 ...`)
  141. const r1 = copyRing(PIC_SRC, path.join(OUT, 'pic-ring1'), RING1, [PRIMARY], 15, RING1_MAX_Z, 'ring1')
  142. console.log(` ring1: ${r1.files} 文件 ${(r1.bytes / 1048576).toFixed(1)} MB`)
  143. console.log(`3/5 ring2 +${RING1_KM}..+${RING2_KM}km z15 ...`)
  144. const r2 = copyRing(PIC_SRC, path.join(OUT, 'pic-ring2'), RING2, [RING1], MID_MAX_Z, MID_MAX_Z, 'ring2')
  145. console.log(` ring2: ${r2.files} 文件 ${(r2.bytes / 1048576).toFixed(1)} MB`)
  146. console.log('4/5 ring3 数据全范围 z0-14(严格层级白名单)...')
  147. let r3 = { files: 0, bytes: 0 }
  148. {
  149. const allowed = new Set(Array.from({ length: OUTER_MAX_Z + 1 }, (_, i) => String(i)))
  150. const dst = path.join(OUT, 'pic-ring3')
  151. for (const z of readdirSync(PIC_SRC)) {
  152. if (!allowed.has(z)) continue
  153. let xdirs = []
  154. try { xdirs = readdirSync(path.join(PIC_SRC, z)) } catch { continue }
  155. for (const xs of xdirs) {
  156. let ys = []
  157. try { ys = readdirSync(path.join(PIC_SRC, z, xs)) } catch { continue }
  158. for (const yf of ys) {
  159. const s = path.join(PIC_SRC, z, xs, yf)
  160. const d = path.join(dst, z, xs, yf)
  161. mkdirSync(path.dirname(d), { recursive: true })
  162. copyFileSync(s, d)
  163. r3.files++
  164. try { r3.bytes += statSync(s).size } catch {}
  165. }
  166. }
  167. }
  168. }
  169. console.log(` ring3: ${r3.files} 文件 ${(r3.bytes / 1048576).toFixed(1)} MB`)
  170. console.log('5/5 地形整份保留 ...')
  171. const dem = copyDemWhole()
  172. console.log(` 地形:${dem.files} 文件 ${(dem.bytes / 1048576).toFixed(1)} MB(layer.json 原样)`)
  173. // 预算断言:防误配吞盘
  174. const imgTiles = r0.files + r1.files + r2.files + r3.files
  175. if (imgTiles > TILE_BUDGET) {
  176. console.error(`影像瓦片 ${imgTiles.toLocaleString()} 超预算 ${TILE_BUDGET.toLocaleString()} —— 配置疑似有误,请检查后重跑(产出保留在 ${OUT},可手动删除)`)
  177. process.exit(1)
  178. }
  179. // 各层 meta(描述用)
  180. const picMeta = JSON.parse(readFileSync(path.join(PIC_SRC, 'meta.json'), 'utf8'))
  181. const metas = [
  182. ['pic-ring0', PRIMARY, FINE_MAX_Z],
  183. ['pic-ring1', RING1, RING1_MAX_Z],
  184. ['pic-ring2', RING2, MID_MAX_Z],
  185. ['pic-ring3', JSON.parse(readFileSync(path.join(PIC_SRC, 'meta.json'), 'utf8')).latLonBounds
  186. ? [
  187. picMeta.bounds.west, picMeta.bounds.south, picMeta.bounds.east, picMeta.bounds.north,
  188. ] : FOG, OUTER_MAX_Z],
  189. ]
  190. for (const [name, b, zmax] of metas) {
  191. writeFileSync(path.join(OUT, `${name}-meta.json`), JSON.stringify({
  192. bounds: { west: b[0], south: b[1], east: b[2], north: b[3] },
  193. minzoom: 0, maxzoom: zmax, proj: 3857, tilesize: picMeta.tilesize, tiletrans: 'tms', type: 'image', extension: 'png',
  194. }, null, 2))
  195. }
  196. const allBytes = r0.bytes + r1.bytes + r2.bytes + r3.bytes + dem.bytes
  197. console.log('\n================ 汇总 ================')
  198. console.log(`ring0 ${r0.files}(${(r0.bytes / 1048576).toFixed(0)}MB) ring1 ${r1.files}(${(r1.bytes / 1048576).toFixed(0)}MB) ring2 ${r2.files}(${(r2.bytes / 1048576).toFixed(0)}MB) ring3 ${r3.files}(${(r3.bytes / 1048576).toFixed(0)}MB) dem ${dem.files}(${(dem.bytes / 1048576).toFixed(0)}MB)`)
  199. console.log(`合计 ${(imgTiles + dem.files).toLocaleString()} 文件 ${(allBytes / 1048576).toFixed(0)} MB`)
  200. console.log('\n配套 config.json overlays(外→内叠加,后加者在上):')
  201. console.log(' pic-ring3 z0-14 bounds=影像全范围')
  202. console.log(' pic-ring2 z15 bounds=主场景+8km')
  203. console.log(' pic-ring1 z15-17 bounds=主场景+3km')
  204. console.log(' pic-ring0 z0-19 bounds=主场景')
  205. console.log(' terrain url 指向本脚本产出的 dem/ 目录')