Explorar el Código

docs(cockpit): GIS 影像同心分级资产与部署约定

主场景边缘曾出现一圈低清影像:主场景内 z19(0.26m/px) 直落 z14(8.39m/px),
32 倍骤降且边缘距中心仅约 6.7km、雾从 18km 才起,断层完全暴露。
修复 = 同心分级:ring0 主场景 z0-19 / ring1 外扩3km z15-17 / ring2 3-8km z15 /
ring3 数据全范围 z0-14,衔接落差降到 2-8 倍。

· tools/gis-asset/crop-gis-lod.mjs:从原始切片裁剪分级资产(含层级白名单与
  瓦片预算断言,防误配吞盘);地形整份保留——低层级是全球覆盖,按兴趣区
  裁剪会在地球上留下空洞;
· 部署文档 §3.1:四层 nginx location 与 overlays 契约(外→内叠加,
  每层 maximumLevel 必须与数据一致,声明过高会在缺瓦片处 404 露底图;
  reverseY 必须保留——切片磁盘名是 TMS 编号);
· config.example.json 补同心分级说明。数据本体不入库(维护者裁定)。
ChenJZ hace 16 horas
padre
commit
b0b60ffa6b
Se han modificado 3 ficheros con 283 adiciones y 11 borrados
  1. 40 6
      docs/cockpit-deployment.md
  2. 22 5
      public/cockpit/config.example.json
  3. 221 0
      tools/gis-asset/crop-gis-lod.mjs

+ 40 - 6
docs/cockpit-deployment.md

@@ -441,7 +441,40 @@ DEV 下可采样:`__cockpitMap.startFogPerfSample('with-fog')` → 等一会
 ---
 
 ## 3. 挂 GIS 静态目录(nginx)
-DEM / 影像 / 3D Tiles 都是**静态文件**,直接从磁盘服务,**不要放进前端产物**(上百 MB 起)。
+DEM / 影像 / 3D Tiles 都是**静态文件**,直接从磁盘服务,**不要放进前端产物**(分级影像资产约 1.2 GB、6 万文件)。
+
+### 3.1 影像分级(同心 LOD,2026-11-03 起)
+
+主场景边缘曾出现一圈低清影像:主场景内 z19(0.26 m/px)直落 z14(8.39 m/px),32 倍骤降
+且边缘距中心仅 ~6.7 km、雾从 18 km 才起,断层完全暴露。修复 = **同心分级**,清晰度随距离递减:
+
+| 层 | 范围 | 层级 | 分辨率 |
+| --- | --- | --- | --- |
+| pic-ring0 | 主场景 bbox | z0–19 | 0.26 m/px |
+| pic-ring1 | 主场景外扩 3 km | z15–17 | 1.05 m/px |
+| pic-ring2 | 外扩 3→8 km | z15 | 4.19 m/px |
+| pic-ring3 | 影像数据全范围 | z0–14 | 8.39 m/px |
+
+数据由 `tools/gis-asset/crop-gis-lod.mjs` 从原始切片目录裁剪生成(在能读到源数据的机器上跑一次):
+
+```bash
+node tools/gis-asset/crop-gis-lod.mjs --src D:/GIS/GIS --out D:/GIS/cockpit-gis
+```
+
+`config.json` 的 overlays 按**外→内**顺序声明四层(后加者叠在上),每层 `maximumLevel`
+必须与该层数据一致——声明得比数据高时,缺瓦片的层级会请求 404 并露出更底的底图:
+
+```jsonc
+"overlays": [
+  { "url": "/gis/pic-ring3/{z}/{x}/{reverseY}.png", "minimumLevel": 0,  "maximumLevel": 14, "bounds": [影像数据全范围] },
+  { "url": "/gis/pic-ring2/{z}/{x}/{reverseY}.png", "minimumLevel": 15, "maximumLevel": 15, "bounds": [主场景外扩8km] },
+  { "url": "/gis/pic-ring1/{z}/{x}/{reverseY}.png", "minimumLevel": 15, "maximumLevel": 17, "bounds": [主场景外扩3km] },
+  { "url": "/gis/pic-ring0/{z}/{x}/{reverseY}.png", "minimumLevel": 0,  "maximumLevel": 19, "bounds": [主场景] }
+]
+```
+
+`reverseY` **必须保留**:cesiumlab 导出的影像切片磁盘名是 TMS 编号(y 轴自下而上),
+Cesium 请求用 XYZ(自上而下),`{reverseY}` 就是两者的转换器。去掉它 = 全部瓦片错位。
 
 ```nginx
 # 地形 DEM(里面必须有 layer.json)
@@ -452,11 +485,12 @@ location /gis/dem/ {
     default_type application/octet-stream;
 }
 
-# 本地影像切片
-location /gis/pic3/ {
-    alias /data/gis/yuanlingPic3/;
-    add_header Cache-Control "public, max-age=86400";
-}
+# 分级影像(四层;alias 指向 crop-gis-lod.mjs 的产出目录)
+location /gis/pic-ring0/ { alias /data/gis/pic-ring0/; add_header Cache-Control "public, max-age=86400"; }
+location /gis/pic-ring1/ { alias /data/gis/pic-ring1/; add_header Cache-Control "public, max-age=86400"; }
+location /gis/pic-ring2/ { alias /data/gis/pic-ring2/; add_header Cache-Control "public, max-age=86400"; }
+location /gis/pic-ring3/ { alias /data/gis/pic-ring3/; add_header Cache-Control "public, max-age=86400"; }
+```
 
 # 3D Tiles(若启用旧 /gis/model/ 配置)
 location /gis/model/ {

+ 22 - 5
public/cockpit/config.example.json

@@ -8,6 +8,7 @@
     "本项目优先用内网离线数据:底图默认 natural-earth(Cesium 自带离线影像,无需账号/外网/密钥)。在线源(tianditu 需 tk、arcgis-imagery 免 key)只作为可选项。",
     "map.basemap.source 取值:none(不配底图)/ natural-earth(离线,推荐)/ xyz(任意 {z}/{x}/{y} 模板,本地或自建瓦片)/ tianditu(需 tk)/ arcgis-imagery(在线,免 key)。",
     "map.terrain.url 指向量化网格 DEM 目录(内含 layer.json);map.overlays[] 叠本地精细影像(TMS 用 {reverseY},bounds 填数据实际范围)。",
+    "主场景边缘精度(维护者 2026-11-03):overlays 建议按**同心分级**声明——pic-ring0(主场景 z0-19) / pic-ring1(+3km z15-17) / pic-ring2(+8km z15) / pic-ring3(数据全范围 z0-14),外→内叠加、后加者在上;每层 maximumLevel 必须与该层数据一致(声明过高会在缺瓦片处 404 露底图)。数据用 tools/gis-asset/crop-gis-lod.mjs 裁剪生成;reverseY 必须保留(切片磁盘名是 TMS 编号)。详见 docs/cockpit-deployment.md §3.1。",
     "map.performance 是渲染开销档(票 35/59):不写 = 用代码默认档(resolutionScale 0.75 / msaaSamples 0 / fxaa / atmosphere / globeScreenSpaceError 3 / terrainVertexNormals / ibl / maxConcurrentRequests 6)。**写哪项覆盖哪项**,没写的继续用默认;回退到整改前画面写 {\"resolutionScale\":1,\"msaaSamples\":4,\"atmosphere\":true,\"globeScreenSpaceError\":2}。弱机可再降:resolutionScale 0.65、tilesetScreenSpaceErrorScale 1.2、discreteLod true。",
     "map.detail.tiers 是**层级表**(票 61 第十六轮,为五级 LOD 准备):形如 [{name, geometry, activateWithinM}, …],按距离从远到近声明,运行时会用「上界 ≥ 距离里最小的那个」并据此反解精模阈值;不写就是默认五级(6000/3000/1600/600/250)。注意当前资产只有 coarse/preview/fine 三种几何,所以第 0-1 级与第 3-4 级暂时落在同一份几何上——等票 91 产出 medium 级资产后自动生效。",
     "map.detail 里另有 fineScreenSpaceError(默认 24) 与 fineFoveated(默认 false) 两项,专管『近景建筑是不是精模』:细模区域 tileset 的层级是 preview(ge=48) → fine(ge=0),窗户/立面全在 fine 层,要靠屏幕空间误差下钻。实测 sse=115 只有 293m 内才有窗、sse=24 有 1403m —— 阈值越大越省性能但越早变火柴盒。fineFoveated=true 会让屏幕中心比边缘更精细(同距离也不一致),默认关闭以保证『同距离同细节』。",
@@ -53,16 +54,32 @@
     "overlays": [],
     "tilesets": [],
     "performance": null,
-    "detail": { "frontier": null, "budget": null },
+    "detail": {
+      "frontier": null,
+      "budget": null
+    },
     "overlap": {
       "maxFan": 10
     },
     "locate": {
-      "point": { "heading": 0, "pitch": -55, "range": 500 },
-      "device": { "heading": 0, "pitch": -55, "range": 150 }
+      "point": {
+        "heading": 0,
+        "pitch": -55,
+        "range": 500
+      },
+      "device": {
+        "heading": 0,
+        "pitch": -55,
+        "range": 150
+      }
     },
     "primaryArea": {
-      "bounds": [110.328749941417, 28.3917230659234, 110.465779226663, 28.4913038992907]
+      "bounds": [
+        110.328749941417,
+        28.3917230659234,
+        110.465779226663,
+        28.4913038992907
+      ]
     },
     "fog": {
       "enabled": true,
@@ -87,7 +104,7 @@
       "brightness": 0.96,
       "contrast": 1.08,
       "tint": "#2fa8ff",
-      "tintStrength":  0.26
+      "tintStrength": 0.26
     },
     "ui": {
       "debug": null

+ 221 - 0
tools/gis-asset/crop-gis-lod.mjs

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