/** * 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/ 目录')