|
|
@@ -0,0 +1,221 @@
|
|
|
+/**
|
|
|
+ * GIS 分级资产裁剪(同心 LOD)—— 从原始切片目录生成驾驶舱用的分级静态资产。
|
|
|
+ *
|
|
|
+ * 用法:node tools/gis-asset/crop-gis-lod.mjs [--src D:/GIS/GIS] [--out D:/GIS/cockpit-gis]
|
|
|
+ *
|
|
|
+ * 设计(维护者 2026-11-03):
|
|
|
+ * 主场景边缘曾出现一圈低清影像(z19 直落 z14,32 倍骤降且无雾遮挡)。
|
|
|
+ * 修复 = 同心分级,清晰度随距离递减,衔接落差降到 2~8 倍:
|
|
|
+ * ring0 主场景 bbox z0..19(0.26 m/px,城市细节)
|
|
|
+ * ring1 主场景外扩 R1=3 km z15..17(1.05 m/px)
|
|
|
+ * ring2 R1..R2=8 km z15(4.19 m/px)
|
|
|
+ * ring3 影像数据全范围 z0..14(8.39 m/px;覆盖到数据边界,让雾边界内外都是真实影像)
|
|
|
+ * 地形 DEM 整份保留:低层级是全球覆盖,按兴趣区裁剪会在地球上留下空洞(v3 教训)。
|
|
|
+ *
|
|
|
+ * 配套契约(public/cockpit/config.json):
|
|
|
+ * overlays 按「外→内」顺序声明四层,后加者在上面;
|
|
|
+ * 每层 maximumLevel 与数据一致(z14/z15/z17/z19)——某层缺高层时 Cesium 自动用
|
|
|
+ * 该层已有瓦片向上放大,**不会露底**;若把 maxLevel 声明得比数据高,缺瓦片的
|
|
|
+ * 层级会 404 并露出更底的底图(v4 教训:绿带就是这么来的)。
|
|
|
+ *
|
|
|
+ * 防呆(v5 增补):
|
|
|
+ * · 源层级白名单:ring3 只认 z0..14,绝不触碰 z15+(曾误拷 52GB / 41.7GB 后才发现);
|
|
|
+ * · 输出前总量断言:瓦片数超出预算即中止,防止配置改错后无声吞盘。
|
|
|
+ */
|
|
|
+import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
|
+import path from 'node:path'
|
|
|
+
|
|
|
+const argv = process.argv
|
|
|
+const readOpt = (name, fallback) => {
|
|
|
+ const i = argv.indexOf(name)
|
|
|
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback
|
|
|
+}
|
|
|
+const SRC = readOpt('--src', 'D:/GIS/GIS')
|
|
|
+const OUT = readOpt('--out', 'D:/GIS/cockpit-gis')
|
|
|
+const PIC_SRC = path.join(SRC, 'yuanlingPic3')
|
|
|
+const DEM_SRC = path.join(SRC, 'yuanlingDEM')
|
|
|
+
|
|
|
+// —— 契约参数(与 public/cockpit/config.json 同源;换部署按自己的 primaryArea/home/fog 重算)——
|
|
|
+const PRIMARY = [110.328749941417, 28.3917230659234, 110.465779226663, 28.4913038992907]
|
|
|
+const HOME = [110.39028164, 28.4436787]
|
|
|
+const FOG_RADIUS_KM = 42 // 与 map.fog.endDistance 同源
|
|
|
+const RING1_KM = 3
|
|
|
+const RING2_KM = 8
|
|
|
+const FINE_MAX_Z = 19
|
|
|
+const RING1_MAX_Z = 17
|
|
|
+const MID_MAX_Z = 15
|
|
|
+const OUTER_MAX_Z = 14
|
|
|
+const DEM_MAX_Z = 14 // 源 DEM 的 maxzoom(layer.json)
|
|
|
+
|
|
|
+const TILE_BUDGET = 80_000 // 影像总瓦片预算(超出即中止,防止误配吞盘)
|
|
|
+
|
|
|
+const KM_LAT = 111.32
|
|
|
+const expand = (b, km) => {
|
|
|
+ const dLat = km / KM_LAT
|
|
|
+ const dLon = km / (KM_LAT * Math.cos(HOME[1] * Math.PI / 180))
|
|
|
+ return [b[0] - dLon, b[1] - dLat, b[2] + dLon, b[3] + dLat]
|
|
|
+}
|
|
|
+const RING1 = expand(PRIMARY, RING1_KM)
|
|
|
+const RING2 = expand(PRIMARY, RING2_KM)
|
|
|
+const FOG = expand([HOME[0], HOME[1], HOME[0], HOME[1]], FOG_RADIUS_KM)
|
|
|
+
|
|
|
+const lon2x = (lon, z) => Math.floor((lon + 180) / 360 * 2 ** z)
|
|
|
+const lat2yXyz = (lat, z) => {
|
|
|
+ const r = lat * Math.PI / 180
|
|
|
+ return Math.floor((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2 * 2 ** z)
|
|
|
+}
|
|
|
+const yTms = (lat, z) => (2 ** z - 1) - lat2yXyz(lat, z)
|
|
|
+const box = (b, z) => {
|
|
|
+ const a = yTms(b[3], z), c = yTms(b[1], z)
|
|
|
+ return { x0: lon2x(b[0], z), x1: lon2x(b[2], z), y0: Math.min(a, c), y1: Math.max(a, c) }
|
|
|
+}
|
|
|
+const inBox = (bx, x, y) => x >= bx.x0 && x <= bx.x1 && y >= bx.y0 && y <= bx.y1
|
|
|
+
|
|
|
+/** 复制 outer 内、排除 excludes 各盒的瓦片;zMin..zMax 逐层。 */
|
|
|
+function copyRing(srcDir, dstDir, outer, excludes, zMin, zMax, label) {
|
|
|
+ let files = 0, bytes = 0
|
|
|
+ for (let z = zMin; z <= zMax; z++) {
|
|
|
+ const o = box(outer, z)
|
|
|
+ const ex = excludes.map((b) => box(b, z))
|
|
|
+ let dirs = []
|
|
|
+ try { dirs = readdirSync(path.join(srcDir, String(z))) } catch { continue }
|
|
|
+ let zf = 0
|
|
|
+ for (const xs of dirs) {
|
|
|
+ const xi = Number(xs)
|
|
|
+ if (!Number.isFinite(xi) || xi < o.x0 || xi > o.x1) continue
|
|
|
+ let ys = []
|
|
|
+ try { ys = readdirSync(path.join(srcDir, String(z), xs)) } catch { continue }
|
|
|
+ for (const yf of ys) {
|
|
|
+ const yi = Number(yf.replace('.png', ''))
|
|
|
+ if (!Number.isFinite(yi) || yi < o.y0 || yi > o.y1) continue
|
|
|
+ if (ex.some((bx) => inBox(bx, xi, yi))) continue
|
|
|
+ const dst = path.join(dstDir, String(z), xs, yf)
|
|
|
+ mkdirSync(path.dirname(dst), { recursive: true })
|
|
|
+ copyFileSync(path.join(srcDir, String(z), xs, yf), dst)
|
|
|
+ files++; zf++
|
|
|
+ try { bytes += statSync(path.join(srcDir, String(z), xs, yf)).size } catch {}
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (zf) console.log(` ${label} z${z}: ${zf}`)
|
|
|
+ }
|
|
|
+ return { files, bytes }
|
|
|
+}
|
|
|
+
|
|
|
+/** 地形整份拷贝(含 layer.json 原样)。 */
|
|
|
+function copyDemWhole() {
|
|
|
+ let files = 0, bytes = 0
|
|
|
+ const dstRoot = path.join(OUT, 'dem')
|
|
|
+ for (const z of readdirSync(DEM_SRC).filter((n) => /^\d+$/.test(n))) {
|
|
|
+ let xdirs = []
|
|
|
+ try { xdirs = readdirSync(path.join(DEM_SRC, z)) } catch { continue }
|
|
|
+ for (const xs of xdirs) {
|
|
|
+ let ys = []
|
|
|
+ try { ys = readdirSync(path.join(DEM_SRC, z, xs)) } catch { continue }
|
|
|
+ for (const yf of ys) {
|
|
|
+ const src = path.join(DEM_SRC, z, xs, yf)
|
|
|
+ const dst = path.join(dstRoot, z, xs, yf)
|
|
|
+ mkdirSync(path.dirname(dst), { recursive: true })
|
|
|
+ copyFileSync(src, dst)
|
|
|
+ files++
|
|
|
+ try { bytes += statSync(src).size } catch {}
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for (const name of readdirSync(DEM_SRC)) {
|
|
|
+ if (/^\d+$/.test(name)) continue
|
|
|
+ const src = path.join(DEM_SRC, name)
|
|
|
+ try {
|
|
|
+ if (statSync(src).isFile()) {
|
|
|
+ mkdirSync(dstRoot, { recursive: true })
|
|
|
+ copyFileSync(src, path.join(dstRoot, name))
|
|
|
+ files++; bytes += statSync(src).size
|
|
|
+ }
|
|
|
+ } catch {}
|
|
|
+ }
|
|
|
+ return { files, bytes }
|
|
|
+}
|
|
|
+
|
|
|
+if (!existsSync(PIC_SRC) || !existsSync(DEM_SRC)) {
|
|
|
+ console.error(`源目录不存在:${PIC_SRC} / ${DEM_SRC}`)
|
|
|
+ process.exit(1)
|
|
|
+}
|
|
|
+console.log(`源:${SRC}\n产出:${OUT}`)
|
|
|
+console.log(`主场景:${PRIMARY.map((v) => v.toFixed(4)).join(', ')}`)
|
|
|
+console.log(`环1(+${RING1_KM}km) / 环2(+${RING2_KM}km) / 雾范围(${FOG_RADIUS_KM}km)\n`)
|
|
|
+
|
|
|
+console.log('1/5 ring0 主场景 z0-19 ...')
|
|
|
+const r0 = copyRing(PIC_SRC, path.join(OUT, 'pic-ring0'), PRIMARY, [], 0, FINE_MAX_Z, 'ring0')
|
|
|
+console.log(` ring0: ${r0.files} 文件 ${(r0.bytes / 1048576).toFixed(1)} MB`)
|
|
|
+
|
|
|
+console.log(`2/5 ring1 主场景..+${RING1_KM}km z15-17 ...`)
|
|
|
+const r1 = copyRing(PIC_SRC, path.join(OUT, 'pic-ring1'), RING1, [PRIMARY], 15, RING1_MAX_Z, 'ring1')
|
|
|
+console.log(` ring1: ${r1.files} 文件 ${(r1.bytes / 1048576).toFixed(1)} MB`)
|
|
|
+
|
|
|
+console.log(`3/5 ring2 +${RING1_KM}..+${RING2_KM}km z15 ...`)
|
|
|
+const r2 = copyRing(PIC_SRC, path.join(OUT, 'pic-ring2'), RING2, [RING1], MID_MAX_Z, MID_MAX_Z, 'ring2')
|
|
|
+console.log(` ring2: ${r2.files} 文件 ${(r2.bytes / 1048576).toFixed(1)} MB`)
|
|
|
+
|
|
|
+console.log('4/5 ring3 数据全范围 z0-14(严格层级白名单)...')
|
|
|
+let r3 = { files: 0, bytes: 0 }
|
|
|
+{
|
|
|
+ const allowed = new Set(Array.from({ length: OUTER_MAX_Z + 1 }, (_, i) => String(i)))
|
|
|
+ const dst = path.join(OUT, 'pic-ring3')
|
|
|
+ for (const z of readdirSync(PIC_SRC)) {
|
|
|
+ if (!allowed.has(z)) continue
|
|
|
+ let xdirs = []
|
|
|
+ try { xdirs = readdirSync(path.join(PIC_SRC, z)) } catch { continue }
|
|
|
+ for (const xs of xdirs) {
|
|
|
+ let ys = []
|
|
|
+ try { ys = readdirSync(path.join(PIC_SRC, z, xs)) } catch { continue }
|
|
|
+ for (const yf of ys) {
|
|
|
+ const s = path.join(PIC_SRC, z, xs, yf)
|
|
|
+ const d = path.join(dst, z, xs, yf)
|
|
|
+ mkdirSync(path.dirname(d), { recursive: true })
|
|
|
+ copyFileSync(s, d)
|
|
|
+ r3.files++
|
|
|
+ try { r3.bytes += statSync(s).size } catch {}
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+console.log(` ring3: ${r3.files} 文件 ${(r3.bytes / 1048576).toFixed(1)} MB`)
|
|
|
+
|
|
|
+console.log('5/5 地形整份保留 ...')
|
|
|
+const dem = copyDemWhole()
|
|
|
+console.log(` 地形:${dem.files} 文件 ${(dem.bytes / 1048576).toFixed(1)} MB(layer.json 原样)`)
|
|
|
+
|
|
|
+// 预算断言:防误配吞盘
|
|
|
+const imgTiles = r0.files + r1.files + r2.files + r3.files
|
|
|
+if (imgTiles > TILE_BUDGET) {
|
|
|
+ console.error(`影像瓦片 ${imgTiles.toLocaleString()} 超预算 ${TILE_BUDGET.toLocaleString()} —— 配置疑似有误,请检查后重跑(产出保留在 ${OUT},可手动删除)`)
|
|
|
+ process.exit(1)
|
|
|
+}
|
|
|
+
|
|
|
+// 各层 meta(描述用)
|
|
|
+const picMeta = JSON.parse(readFileSync(path.join(PIC_SRC, 'meta.json'), 'utf8'))
|
|
|
+const metas = [
|
|
|
+ ['pic-ring0', PRIMARY, FINE_MAX_Z],
|
|
|
+ ['pic-ring1', RING1, RING1_MAX_Z],
|
|
|
+ ['pic-ring2', RING2, MID_MAX_Z],
|
|
|
+ ['pic-ring3', JSON.parse(readFileSync(path.join(PIC_SRC, 'meta.json'), 'utf8')).latLonBounds
|
|
|
+ ? [
|
|
|
+ picMeta.bounds.west, picMeta.bounds.south, picMeta.bounds.east, picMeta.bounds.north,
|
|
|
+ ] : FOG, OUTER_MAX_Z],
|
|
|
+]
|
|
|
+for (const [name, b, zmax] of metas) {
|
|
|
+ writeFileSync(path.join(OUT, `${name}-meta.json`), JSON.stringify({
|
|
|
+ bounds: { west: b[0], south: b[1], east: b[2], north: b[3] },
|
|
|
+ minzoom: 0, maxzoom: zmax, proj: 3857, tilesize: picMeta.tilesize, tiletrans: 'tms', type: 'image', extension: 'png',
|
|
|
+ }, null, 2))
|
|
|
+}
|
|
|
+
|
|
|
+const allBytes = r0.bytes + r1.bytes + r2.bytes + r3.bytes + dem.bytes
|
|
|
+console.log('\n================ 汇总 ================')
|
|
|
+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)`)
|
|
|
+console.log(`合计 ${(imgTiles + dem.files).toLocaleString()} 文件 ${(allBytes / 1048576).toFixed(0)} MB`)
|
|
|
+console.log('\n配套 config.json overlays(外→内叠加,后加者在上):')
|
|
|
+console.log(' pic-ring3 z0-14 bounds=影像全范围')
|
|
|
+console.log(' pic-ring2 z15 bounds=主场景+8km')
|
|
|
+console.log(' pic-ring1 z15-17 bounds=主场景+3km')
|
|
|
+console.log(' pic-ring0 z0-19 bounds=主场景')
|
|
|
+console.log(' terrain url 指向本脚本产出的 dem/ 目录')
|