Преглед на файлове

feat(cockpit): add configurable blue-tone scene grade filter

一张图三维场景整幅冷蓝调色(科技感滤镜),沿票 38 雾的特效架构:
- domain/sceneGrade.js:planSceneGrade 计划 + gradeColor 调色数学(shader 逐位镜像);
  刻意不做 hue-rotate 双色 —— 状态点红/绿是第一线索,色相顺序不动
- cesiumEngine:createSceneGradeStage 全屏 PostProcessStage(函数 uniform,
  复用票 38 的常量快照教训);兜底色取 GRADE_DEFAULTS.tint
- mapService:ensure/paint/destroy/reset 装配;建级在雾之后(雾出的白同被统一);
  data-map-grade 三态读数(on/off/pending),off→on 配置翻开即补建
- cockpitConfig:normalizeConfig 白名单透传 map.grade(脏值置空、色值报 issue)
- config.example.json:map.grade 块与语义红线说明
- 新增 t49 逻辑闸门;t37/t38 守卫收窄到顶栏清理后的新契约(区内限位/雾 chip 移除)
Kazerin преди 1 седмица
родител
ревизия
e2529b5169

+ 3 - 1
.scratch/cockpit-shell-panel-layout/verify/t37-area-limits.mjs

@@ -463,7 +463,9 @@ const shellSource = readModule('CockpitShell.vue')
 assert.match(shellSource, /data-return-global/, '区内要有「返回全球」按钮')
 assert.match(shellSource, /areaBarActions/, '顶栏按钮形态应走 domain 判定')
 assert.match(shellSource, /shouldReturnToIntro/, '闲置回引导应走 domain 判定')
-assert.match(shellSource, /data-area-state/, '外壳要有区状态读数')
+// 顶栏清理(维护者决策:区内限位 chip 从工具栏移除)后,区状态读数收敛为
+// 归位按钮上的 `data-area-locked` 与「返回全球」的显隐 —— 不再有独立的限位 chip。
+assert.match(shellSource, /data-area-locked/, '外壳要有区状态读数(归位按钮的 data-area-locked)')
 // 区外不显示「返回全球」(v-if 在 areaBar.returnGlobal 上)。
 assert.match(shellSource, /v-if="areaBar\.returnGlobal"/, '「返回全球」只在区内出现')
 

+ 11 - 3
.scratch/cockpit-shell-panel-layout/verify/t38-fog.mjs

@@ -365,10 +365,16 @@ const jsFogCurveUsers = [
 assert.deepEqual(jsFogCurveUsers, [], `JS 侧不得重写雾衰减曲线(应调 domain/fog.js 的 fogAttenuation),实际:${jsFogCurveUsers.join(', ')}`)
 
 // ② 引擎侧必须只建一个 stage,且复用(不许每次进出 remove/add)。
+//    票 49 起引擎有了第二个后处理级(场景调色,domain/sceneGrade.js + t49 闸门),
+//    这条守卫因此收窄到**雾自己**的创建函数段:雾那一节里必须恰好一处 add。
 const engineSource = readModule('services/cesiumEngine.js')
-assert.match(engineSource, /postProcessStages\.add\(/, '引擎应把雾 stage 加进场景')
+const fogSection = engineSource.slice(
+    engineSource.indexOf('export function createFogStage'),
+    engineSource.indexOf('export function applyFogUniforms'),
+)
+assert.match(fogSection, /postProcessStages\.add\(/, '引擎应把雾 stage 加进场景')
 assert.equal(
-    (engineSource.match(/postProcessStages\.add\(/g) || []).length,
+    (fogSection.match(/postProcessStages\.add\(/g) || []).length,
     1,
     '自建雾只允许有**一处** add(单一 PostProcessStage —— 不引入第二套渲染器/第二个后处理级)',
 )
@@ -574,7 +580,9 @@ assert.match(serviceSource, /stopFogPerfSampleListener/, '性能采样监听要
 // ⑤ 外壳只读读数,不自己算**曲线**,也不自己判门控/降级。
 const shellSource = readModule('CockpitShell.vue')
 assert.match(shellSource, /getFogState\(\)/, '外壳应读服务层读数')
-assert.match(shellSource, /data-fog-chip/, '外壳要有雾读数 chip(人工与走查都能读)')
+// 顶栏清理(维护者决策:区内限位/视距雾 chip 从工具栏移除)后,雾的对外读数
+// 收敛到地图宿主 dataset(`data-map-fog*`,paintFog 写)——外壳不再放雾 chip。
+assert.equal(/data-fog-chip/.test(shellSource), false, '顶栏雾 chip 已按顶栏清理决策移除,雾读数归宿主 dataset')
 // **属性名不得与宿主读数重名**:`data-map-fog` 已挂在 `.map-canvas`(运行期读数)。
 // 若 chip 也用同名,`querySelector('[data-map-fog]')` 会命中**宿主**(整页文本)而不是芯片,
 // 断言会读到错误的元素 —— 本仓在票 07(`data-map-blocked-backend` 同名两处)栽过一次。

+ 164 - 0
.scratch/cockpit-shell-panel-layout/verify/t49-scene-grade.mjs

@@ -0,0 +1,164 @@
+/**
+ * T49 一张图场景调色(科技蓝滤镜)逻辑闸门 —— 不启动浏览器。
+ *
+ * 断言面:
+ *   · domain/sceneGrade.js 的计划口径(配置优先、脏值退默认)与调色数学;
+ *   · 调色的语义红线:阴影偏蓝、色相顺序不换(告警红仍是红);
+ *   · 引擎处理级:uniform 函数形态(票 38 的常量快照坑)、shader 与 domain 同序同式;
+ *   · 服务层装配:建级 / 写 uniform / 离开销毁 / 复跑归零 / 配置重算即生效;
+ *   · 配置模板暴露 map.grade。
+ */
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+
+const root = new URL('../../../', import.meta.url)
+const read = (path) => readFileSync(new URL(path, root), 'utf8')
+const grade = await import('../../../src/modules/cockpit/domain/sceneGrade.js')
+const { builtinConfig, normalizeConfig } = await import('../../../src/modules/cockpit/services/cockpitConfig.js')
+
+// ---- A. 计划口径 ----
+{
+    const plan = grade.planSceneGrade({})
+    assert.deepEqual(
+        { ...plan },
+        { ...grade.GRADE_DEFAULTS },
+        '缺省 = 规格默认',
+    )
+    // 脏值一律退默认(null/''/非数/越界同口径)。
+    const dirty = grade.planSceneGrade({
+        grade: { saturation: 'abc', brightness: null, contrast: '', tintStrength: 9, tint: '#zzzzzz', enabled: false },
+    })
+    assert.equal(dirty.saturation, grade.GRADE_DEFAULTS.saturation, '非数饱和度退默认')
+    assert.equal(dirty.brightness, grade.GRADE_DEFAULTS.brightness, 'null 亮度退默认')
+    assert.equal(dirty.contrast, grade.GRADE_DEFAULTS.contrast, '空串对比度退默认')
+    assert.equal(dirty.tintStrength, grade.GRADE_DEFAULTS.tintStrength, '越界混合比退默认')
+    assert.equal(dirty.tint, grade.GRADE_DEFAULTS.tint, '非法色值退默认')
+    assert.equal(dirty.enabled, false, '显式 enabled=false 必须被尊重')
+    assert.deepEqual(
+        grade.planSceneGrade({ grade: null }),
+        { ...grade.GRADE_DEFAULTS },
+        'grade=null = 未配置 = 默认',
+    )
+    console.log('PASS A1 计划口径:缺省=默认、脏值退默认、enabled=false 被尊重')
+}
+
+// ---- B. 调色数学(语义红线)----
+{
+    const plan = grade.planSceneGrade({})
+    const hex = (c) => `#${[c.r, c.g, c.b].map((x) => Math.round(clamp01(x) * 255).toString(16).padStart(2, '0')).join('')}`
+    const clamp01 = (x) => Math.min(1, Math.max(0, x))
+
+    const identity = grade.planSceneGrade({ grade: { saturation: 1, brightness: 1, contrast: 1, tintStrength: 0 } })
+    assert.deepEqual(grade.gradeColor({ r: 0.3, g: 0.6, b: 0.9 }, identity), { r: 0.3, g: 0.6, b: 0.9 }, '全中性系数 = 原样')
+
+    const gray = grade.gradeColor({ r: 0.5, g: 0.5, b: 0.5 }, plan)
+    assert.ok(gray.b - gray.r > 0.04 && gray.b > gray.g, `中灰偏蓝(${hex(gray)}):阴影带出科技蓝`)
+
+    const red = grade.gradeColor({ r: 1, g: 0, b: 0 }, plan)
+    assert.ok(red.r > red.g + 0.4 && red.r > red.b + 0.4, `告警红仍是红(${hex(red)}):色相顺序不换(票 14 红线)`)
+
+    const green = grade.gradeColor({ r: 0, g: 1, b: 0 }, plan)
+    assert.ok(green.g > green.r && green.g > green.b, `正常绿仍偏绿(${hex(green)}):降饱和不移相`)
+
+    const black = grade.gradeColor({ r: 0, g: 0, b: 0 }, plan)
+    assert.ok(black.b > black.r && black.b > 0.1, `黑场获得蓝调(${hex(black)}):暗部着色最重`)
+
+    const white = grade.gradeColor({ r: 1, g: 1, b: 1 }, plan)
+    assert.ok(white.r <= 1 && white.g <= 1 && white.b <= 1, '高光不越界')
+    // 对比度把 1 推到 1.04:shader 在对比后钳制再乘亮度 ⇒ 恰 0.96。
+    // domain 必须逐位一致(两处实现是"同一份数学"的契约)。
+    for (const channel of [white.r, white.g, white.b]) {
+        assert.ok(Math.abs(channel - 0.96) < 1e-9, `高光钳制位与 shader 对齐(实际 ${channel},期望 0.96)`)
+    }
+
+    for (const rgb of [{ r: 2, g: -1, b: 0.5 }, { r: 0, g: 0, b: 0 }]) {
+        const out = grade.gradeColor(rgb, plan)
+        for (const channel of Object.values(out)) assert.ok(channel >= 0 && channel <= 1, '输出通道全部钳在 [0,1]')
+    }
+    console.log('PASS B1 调色数学:阴影偏蓝、红绿语义保持、高光不动、端点钳制')
+}
+
+// ---- C. 引擎处理级 ----
+{
+    const engine = read('src/modules/cockpit/services/cesiumEngine.js')
+    assert.ok(/export function createSceneGradeStage\(viewer, params\)/.test(engine), '导出 createSceneGradeStage')
+    assert.ok(/export function applySceneGradeUniforms\(handle,/.test(engine), '导出 applySceneGradeUniforms')
+    assert.ok(/name: GRADE_STAGE_NAME/.test(engine) && /'cockpit-scene-grade'/.test(engine), '处理级有唯一名字')
+    // uniform 必须是函数形态 —— 票 38 实测:常量会被构造时快照,事后改 state 永远不生效。
+    for (const name of ['gradeSaturation', 'gradeBrightness', 'gradeContrast', 'gradeTint', 'gradeTintStrength']) {
+        assert.ok(new RegExp(`${name}: \\(\\) => state\\.`).test(engine), `uniform ${name} 用函数形态(每帧现取)`)
+    }
+    assert.ok(/stage\.enabled = params\.enabled !== false/.test(engine), 'enabled 随计划')
+    // shader 与 domain 同序同式:降饱和 → 对比度 → 亮度 → 阴影偏蓝。
+    const shader = engine.slice(engine.indexOf('const GRADE_FRAGMENT_SHADER'))
+    const order = ['mix(vec3(luma), base.rgb', '(color - 0.5) * gradeContrast', 'color * gradeBrightness', 'mix(color, gradeTint']
+    const at = order.map((needle) => shader.indexOf(needle))
+    assert.ok(at.every((x) => x >= 0) && at.every((x, i) => i === 0 || x > at[i - 1]), 'shader 数学与 gradeColor() 同序同式')
+    assert.ok(shader.includes('0.2126, 0.7152, 0.0722'), '同一套 Rec.709 亮度权重')
+    assert.ok(shader.includes('out_FragColor'), '用 Cesium 当前的 out_FragColor 出口')
+    assert.ok(shader.includes('gradeTintStrength * (1.0 - luma)'), '阴影权重公式与 domain 一致')
+    console.log('PASS C1 引擎处理级:函数 uniform、enabled 随计划、shader 与 domain 同序同式')
+}
+
+// ---- D. 服务层装配 ----
+{
+    const service = read('src/modules/cockpit/services/mapService.js')
+    assert.ok(/import \{ planSceneGrade \} from '\.\.\/domain\/sceneGrade\.js'/.test(service), '服务层只 import 计划函数')
+    assert.ok(/let gradeParams = planSceneGrade\(\{\}\)/.test(service), '模块态从空配置起算')
+    assert.ok(/function ensureGradeStage\(\)/.test(service), '幂等建级入口')
+    assert.ok(/function paintGrade\(\)/.test(service), '统一写 uniform 与 dataset 读数')
+    // 读数三态:off(配置关)/ on(在跑)/ pending(配置要但没建成)—— 不把"没建成"谎报成 on。
+    assert.ok(
+        /instance\.host\.dataset\.mapGrade = !gradeParams\.enabled \? 'off' : gradeHandle \? 'on' : 'pending'/.test(service),
+        'data-map-grade 三态读数(on/off/pending)',
+    )
+    // 配置把 enabled 翻开而处理级还没建:写参前补建(幂等),否则 off→on 只翻旗子不出画面。
+    assert.ok(/if \(!gradeHandle && gradeParams\.enabled\) ensureGradeStage\(\)/.test(service), 'off→on 配置翻开时补建级')
+    assert.ok(/if \(ensureGradeStage\(\)\) \{\s*\n\s*paintGrade\(\)/.test(service), '引擎就绪即建级并写参')
+    assert.ok(/gradeParams = planSceneGrade\(mapConfig \|\| \{\}\)\s*\n\s*paintGrade\(\)/.test(service), '配置重算立即生效')
+    assert.ok(/if \(gradeHandle\) \{\s*\n\s*try \{\s*\n\s*gradeHandle\.destroy\(\)/.test(service), '离开地图销毁处理级')
+    assert.ok(/delete record\.host\.dataset\.mapGrade/.test(service), '卸载收走读数')
+    assert.ok(/gradeHandle = null\s*\n\s*gradeParams = planSceneGrade\(\{\}\)/.test(service), '复跑归零不残留')
+    // 调色必须在雾之后建级(后处理按加入顺序执行)。
+    assert.ok(service.indexOf('ensureFogStage())') < service.indexOf('ensureGradeStage())'), '建级顺序在雾之后(雾出的白也被统一调色)')
+    // 数值读取与 hex 解析复用 fog.js 的一份(本模块不得再抄 private 副本)。
+    const gradeDomain = read('src/modules/cockpit/domain/sceneGrade.js')
+    assert.ok(!/function readNumber/.test(gradeDomain), 'sceneGrade 不再自带 readNumber 副本(复用 fog.js 导出)')
+    assert.ok(/import \{ parseHexColor, readNumber \} from '\.\/fog\.js'/.test(gradeDomain), '口径单一来源:parseHexColor + readNumber 都从 fog.js 复用')
+    // 引擎兜底色从 domain 默认派生,不再手抄浮点魔数。
+    const engine = read('src/modules/cockpit/services/cesiumEngine.js')
+    assert.ok(/import \{ GRADE_DEFAULTS, parseHexColor \} from '\.\.\/domain\/sceneGrade\.js'/.test(engine), '引擎兜底色引 domain 默认')
+    assert.ok(/params\.tintRgb \|\| parseHexColor\(GRADE_DEFAULTS\.tint\)/.test(engine), '兜底 = GRADE_DEFAULTS.tint(无第三层魔数)')
+    console.log('PASS D1 服务层装配:建级/写参/销毁/归零/配置生效/顺序在雾后/三态读数/口径复用')
+}
+
+// ---- D2. 配置层透传(normalizeConfig 是白名单重建:不加块 map.grade 就是死配置)----
+{
+    assert.deepEqual(builtinConfig().map.grade, {
+        enabled: true, saturation: null, brightness: null, contrast: null, tint: '', tintStrength: null,
+    }, '内置默认含 grade 形状(null/\'\' = 交给 domain 默认)')
+    const tuned = normalizeConfig({ map: { grade: { enabled: false, saturation: 0.6, tint: '#123456', tintStrength: 0.4 } } })
+    assert.deepEqual(tuned.config.map.grade, {
+        enabled: false, saturation: 0.6, brightness: null, contrast: null, tint: '#123456', tintStrength: 0.4,
+    }, 'map.grade 逐字段透传(enabled=false 必须到达 domain)')
+    const bad = normalizeConfig({ map: { grade: { saturation: 'abc', tint: '#xyz' } } })
+    assert.equal(bad.config.map.grade.saturation, null, '非数透传为 null(domain 层退默认)')
+    assert.equal(bad.config.map.grade.tint, '', '非法色值置空并报 issue')
+    assert.ok(bad.issues.some((line) => line.includes('map.grade.tint')), '色值错误必须进 issues(不静默)')
+    assert.deepEqual(normalizeConfig({}).config.map.grade, builtinConfig().map.grade, '未配置 = 内置默认形状')
+    console.log('PASS D2 配置层透传:map.grade 白名单块 + 脏值置空 + 色值报 issue')
+}
+
+// ---- E. 配置模板 ----
+{
+    const example = JSON.parse(read('public/cockpit/config.example.json'))
+    assert.deepEqual(
+        example.map.grade,
+        { enabled: true, saturation: 0.82, brightness: 0.96, contrast: 1.08, tint: '#2fa8ff', tintStrength: 0.18 },
+        '模板暴露 map.grade 且与代码默认一致',
+    )
+    assert.ok(example._readme.some((line) => line.includes('map.grade')), '模板 readme 说明调色的语义与红线')
+    console.log('PASS E1 配置模板:map.grade 与默认一致并有说明')
+}
+
+console.log('T49 场景调色逻辑闸门全部通过。')

+ 9 - 0
public/cockpit/config.example.json

@@ -13,6 +13,7 @@
     "map.viewMode.pipeline 可调管线视图的倾角/距离系数/地形与模型的半透明程度。",
     "map.primaryArea.bounds 是主场景区矩形 [西, 南, 东, 北](度),与 map.overlays[].bounds 同口径;顺序必须自己写好(west<east、south<north),运行时不翻转。不填或顺序非法 → 区域限位与自建雾气整体关闭,引擎浮标如实写明原因。下面的坐标是本部署(沅陵县城)的契约示例,换部署请按自己的数据重算。",
     "map.fog 是区内视距雾(只在主场景区内生效)。默认 18000 m 起雾、42000 m 完全遮蔽、#bdcdd6;色值必须是 #rrggbb,且正式色相要偏冷白灰、不得发绿(现场复核)。本模板里的 fadeInMs/fadeOutMs 与高度带是**代码默认(规格取向)**,不是某个部署的现场值 —— 现场按观感调好后请在自己那份 config.json 里覆盖,别改回本模板。altitudeFullM/altitudeZeroM 是相机高度收口的两端:相机高于后者时雾收到 0(否则高空时整个地球会被涂成雾色 —— 退出会「地球变白」)。**必须 altitudeZeroM > altitudeFullM**,写反等于关掉这层保护(配置层会报 issue,但不替你改值)。altitudeSlewMs 是这层收口的**时间限速**,保证雾不会「一瞬间产生或消失」(返回全球时相机 range 跨 1.6e6 倍,不加限速就会跳变)。enabled=false 可整级关掉这层效果(限位不受影响)。",
+    "map.grade 是一张图整幅调色(科技蓝滤镜):常驻全屏处理级,在雾之后执行(雾出的白也被统一成冷蓝调)。saturation/brightness/contrast 是色彩学三系数(1 = 原样),tint 是 #rrggbb 目标色调、tintStrength 是阴影向它混合的上限(实际权重随暗部加深,高光几乎不动)。刻意**不做** hue-rotate 双色:状态点的红/绿是第一线索,色相顺序不能动。enabled=false 可整级关掉;脏值一律退回代码默认。",
     "map.handover 是粗→精影像交接:全球/引导态隐藏精细影像,进区后等雾形成保护且瓦片可显示才渐显,返回反向交接(影像先收、雾后撤)。fadeMs 是渐显/收起时长(现为 380 ms:雾是距离雾、盖不住屏幕中心的脚下街区,渐显期间中心是「粗糙底图(陆地偏绿)↔ 精细影像」的混合,窗口越长那段绿越明显,故调短);minEdgeOcclusion 是相机到影像边处要求的雾遮蔽率(0–1,与 map.fog 的距离端点同源);requireTilesLoaded=false 可放宽『等瓦片』这一条。enabled=false 则精细层常显(回到交接之前的行为)。绝不承诺『边缘已被遮住』,那段观感请在真实画面上验收。",
     "map.locate.point/device 可覆盖完整定位视角的 heading/pitch/range(米);缺省点位 0/-55/500、设备 0/-55/150。设备档供后续设备联动票消费。",
     "map.cesium.enabled=false 可以让中央一张图退回临时影像兜底;map.cesium.baseUrl 只在把 Cesium 静态资源放到别处时才填(需绝对 URL)。",
@@ -74,6 +75,14 @@
       "minEdgeOcclusion": 0.9,
       "requireTilesLoaded": true
     },
+    "grade": {
+      "enabled": true,
+      "saturation": 0.82,
+      "brightness": 0.96,
+      "contrast": 1.08,
+      "tint": "#2fa8ff",
+      "tintStrength": 0.18
+    },
     "ui": {
       "debug": null
     },

+ 2 - 1
src/modules/cockpit/domain/fog.js

@@ -111,6 +111,7 @@ const HEX_PATTERN = /^#?([0-9a-fA-F]{6})$/
 
 /**
  * 读一个数值参数:**没给**(`undefined` / `null` / `''`)→ 用默认;给了但不可用 → 也用默认。
+ * (导出:sceneGrade 等需要同一"脏值退默认"口径的 domain 模块复用这一份,不再各抄一份。)
  *
  * 为什么要显式挡 `null` 与 `''`:内置默认里这两个状态就是 `null`/`''`("本部署没配"),
  * 而 `Number(null) === 0`、`Number('') === 0`。当 `min` 允许 0 时(淡入淡出时长正是这种情况),
@@ -118,7 +119,7 @@ const HEX_PATTERN = /^#?([0-9a-fA-F]{6})$/
  * 这个 bug 是本票真机验证时抓到的(`fadeInMs/fadeOutMs` 全成了 0),
  * 逻辑闸门当时只喂了 `{}`(字段是 `undefined`)所以没暴露 —— 现在两边都补上。
  */
-function readNumber(value, fallback, { min = 0, max = Number.POSITIVE_INFINITY } = {}) {
+export function readNumber(value, fallback, { min = 0, max = Number.POSITIVE_INFINITY } = {}) {
     if (value === undefined || value === null || value === '') return fallback
     const num = Number(value)
     if (!Number.isFinite(num)) return fallback

+ 79 - 0
src/modules/cockpit/domain/sceneGrade.js

@@ -0,0 +1,79 @@
+/**
+ * 一张图场景调色(科技蓝滤镜):把「整幅 3D 场景统一成冷蓝色调」翻成纯函数。
+ *
+ * 与票 38 的雾同一条架构立场:
+ *   · 本模块只做**计划与调色数学**(不 import cesium、不碰 DOM,可脱浏览器断言);
+ *   · 引擎侧(services/cesiumEngine.js)照这份计划建一个 PostProcessStage,
+ *     fragment shader 的数学与本模块 `gradeColor()` **同序同式** ——
+ *     逻辑闸门在 domain 上断言的结论才对引擎侧成立;
+ *   · 服务层(mapService.js)负责配置到手时重算、引擎就绪时建级、离开时销毁。
+ *
+ * 语义红线:**不得做成双色(duotone)**。状态点的红/绿是第一线索(票 14),
+ * 整幅 hue-rotate 会把告警红挪走。所以只做「降饱和 + 轻推对比亮度 + 阴影偏蓝」:
+ * 色相顺序不换,暗部获得科技蓝,亮部保持可读。
+ */
+
+// hex 解析与"脏值退默认"的数值读取全 domain 只留一份(fog.js 已导出),这里复用不重写。
+import { parseHexColor, readNumber } from './fog.js'
+
+/** 规格默认参数:温和起步,全部可被 `map.grade` 覆盖(脏值一律退回默认)。 */
+export const GRADE_DEFAULTS = {
+    /** 是否启用调色(部署不需要这层效果时的开关)。 */
+    enabled: true,
+    /** 饱和度系数:1 = 原样,<1 降饱和(压掉影像的植被绿/土黄,往冷色靠)。 */
+    saturation: 0.82,
+    /** 亮度系数:整体稍压,给辉光与状态灯让对比。 */
+    brightness: 0.96,
+    /** 对比度系数:围绕中灰拉伸,>1 增强轮廓。 */
+    contrast: 1.08,
+    /** 目标色调(科技蓝,与驾驶舱 --cyan 同族)。 */
+    tint: '#2fa8ff',
+    /** 阴影向 tint 的混合上限(实际权重 = tintStrength × (1 − 亮度),暗部偏蓝最多)。 */
+    tintStrength: 0.18,
+}
+
+const clamp01 = (x) => Math.min(1, Math.max(0, x))
+
+/**
+ * 调色参数计划:配置优先、缺项用默认、脏值退默认。
+ * @param {object} mapConfig 归一化后的 `config.map`
+ */
+export function planSceneGrade(mapConfig = {}) {
+    const grade = (mapConfig && mapConfig.grade) || {}
+    const tint = typeof grade.tint === 'string' && parseHexColor(grade.tint) ? grade.tint.trim() : GRADE_DEFAULTS.tint
+    return {
+        enabled: grade.enabled !== false,
+        saturation: readNumber(grade.saturation, GRADE_DEFAULTS.saturation, { min: 0, max: 2 }),
+        brightness: readNumber(grade.brightness, GRADE_DEFAULTS.brightness, { min: 0.2, max: 2 }),
+        contrast: readNumber(grade.contrast, GRADE_DEFAULTS.contrast, { min: 0.5, max: 2 }),
+        tint,
+        tintStrength: readNumber(grade.tintStrength, GRADE_DEFAULTS.tintStrength, { min: 0, max: 1 }),
+    }
+}
+
+/**
+ * 单像素调色(fragment shader 的逐位镜像,供逻辑闸门脱浏览器断言):
+ *   1. 按 Rec.709 亮度降饱和;
+ *   2. 围绕中灰拉伸对比度(**对比后先钳制一次** —— 与 shader 的 clamp 逐位对齐,
+ *      否则"先超 1 再乘亮度"的通道两边会差一点点);
+ *   3. 整体乘亮度后再钳制;
+ *   4. 向 tint 混合,权重 = tintStrength × (1 − 调色前的亮度) —— 暗部偏蓝最多,
+ *      高光几乎不动(权重在变换**之前**取样,两处实现必须保持同一顺序)。
+ * @returns {{r:number, g:number, b:number}} 各通道已钳到 [0,1]
+ */
+export function gradeColor(rgb, params = GRADE_DEFAULTS) {
+    if (!rgb) return rgb
+    const luma = 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b
+    const step = (channel) => {
+        const gray = channel + (luma - channel) * (1 - params.saturation)
+        const contrast = clamp01((gray - 0.5) * params.contrast + 0.5)
+        return clamp01(contrast * params.brightness)
+    }
+    const tint = parseHexColor(params.tint) || parseHexColor(GRADE_DEFAULTS.tint)
+    const weight = clamp01(params.tintStrength * (1 - luma))
+    return {
+        r: clamp01(step(rgb.r) * (1 - weight) + tint.r * weight),
+        g: clamp01(step(rgb.g) * (1 - weight) + tint.g * weight),
+        b: clamp01(step(rgb.b) * (1 - weight) + tint.b * weight),
+    }
+}

+ 89 - 0
src/modules/cockpit/services/cesiumEngine.js

@@ -54,6 +54,8 @@ import { createTilesetLoadingController } from './tilesetLoading.js'
 // 票 47:动效的**相位与映射**同样在 domain(纯函数,可跨时间采样断言);
 // 本层只把相位写成 billboard 的 scale/alpha/rotation。
 import { DEVICE_EFFECT, RING_LAYERS, blinkStrengthAt, glowScaleAt, glowStrengthAt, hasGlow, pulseStrengthAt, ringEntityCount, ringFrameAt, spinAngleAt } from '../domain/deviceEffects.js'
+// 场景调色的兜底色直接取 domain 的默认(不再手抄一份 #2fa8ff 的浮点,两处会漂)。
+import { GRADE_DEFAULTS, parseHexColor } from '../domain/sceneGrade.js'
 
 const DEG = Math.PI / 180
 
@@ -706,6 +708,93 @@ export function hasFogStage(viewer) {
     return viewer.scene.postProcessStages.length > 0
 }
 
+// ================================================================ 场景调色(科技蓝滤镜)
+
+const GRADE_STAGE_NAME = 'cockpit-scene-grade'
+
+/**
+ * 全屏调色 fragment shader:降饱和 → 对比度 → 亮度 → 阴影偏蓝。
+ * 数学与 `domain/sceneGrade.js` 的 `gradeColor()` **同序同式**(那边是本式的逐位镜像,
+ * 逻辑闸门在 domain 上断言的结论才对引擎侧成立)。不做 hue-rotate 双色 ——
+ * 状态点的红/绿是第一线索(票 14),色相顺序不能动。
+ */
+const GRADE_FRAGMENT_SHADER = `
+uniform sampler2D colorTexture;
+uniform float gradeSaturation;
+uniform float gradeBrightness;
+uniform float gradeContrast;
+uniform vec3 gradeTint;
+uniform float gradeTintStrength;
+varying vec2 v_textureCoordinates;
+void main(void)
+{
+    vec4 base = texture(colorTexture, v_textureCoordinates);
+    float luma = dot(base.rgb, vec3(0.2126, 0.7152, 0.0722));
+    vec3 color = mix(vec3(luma), base.rgb, gradeSaturation);
+    color = clamp((color - 0.5) * gradeContrast + 0.5, 0.0, 1.0);
+    color = clamp(color * gradeBrightness, 0.0, 1.0);
+    float tintWeight = clamp(gradeTintStrength * (1.0 - luma), 0.0, 1.0);
+    color = mix(color, gradeTint, tintWeight);
+    out_FragColor = vec4(color, base.a);
+}`
+
+/**
+ * 在场景上建**唯一**一个调色处理级。常驻(不做进出生命周期):enabled 随配置。
+ *
+ * uniform 一律**函数形态**(票 38 实测的坑:常量会被 PostProcessStage 在构造时快照,
+ * 事后改原始对象永远不生效;函数每帧现取,改 state 立即生效)。
+ * 不需要深度纹理能力(雾才要),所以没有 support 探测 —— 建失败走 try/catch 如实上抛。
+ *
+ * @param {Viewer} viewer
+ * @param {object} params `domain/sceneGrade.js` 的 `planSceneGrade()` 结果
+ */
+export function createSceneGradeStage(viewer, params) {
+    const scene = viewer.scene
+    const tint = params.tintRgb || parseHexColor(GRADE_DEFAULTS.tint)
+    const state = {
+        saturation: params.saturation,
+        brightness: params.brightness,
+        contrast: params.contrast,
+        tint: new Cartesian3(tint.r, tint.g, tint.b),
+        tintStrength: params.tintStrength,
+    }
+    const stage = scene.postProcessStages.add(
+        new PostProcessStage({
+            name: GRADE_STAGE_NAME,
+            fragmentShader: GRADE_FRAGMENT_SHADER,
+            uniforms: {
+                gradeSaturation: () => state.saturation,
+                gradeBrightness: () => state.brightness,
+                gradeContrast: () => state.contrast,
+                gradeTint: () => state.tint,
+                gradeTintStrength: () => state.tintStrength,
+            },
+        }),
+    )
+    stage.enabled = params.enabled !== false
+    return {
+        stage,
+        state,
+        destroy() {
+            if (scene.isDestroyed && scene.isDestroyed()) return
+            if (stage.isDestroyed && stage.isDestroyed()) return
+            scene.postProcessStages.remove(stage)
+        },
+    }
+}
+
+/** 把调色参数写进 uniform(**只改 dynamic state,不重建 stage**;与 applyFogUniforms 同纪律)。 */
+export function applySceneGradeUniforms(handle, { saturation, brightness, contrast, tintRgb, tintStrength, enabled }) {
+    if (!handle || !handle.state) return
+    const state = handle.state
+    if (Number.isFinite(saturation)) state.saturation = saturation
+    if (Number.isFinite(brightness)) state.brightness = brightness
+    if (Number.isFinite(contrast)) state.contrast = contrast
+    if (tintRgb) state.tint = new Cartesian3(tintRgb.r, tintRgb.g, tintRgb.b)
+    if (Number.isFinite(tintStrength)) state.tintStrength = tintStrength
+    if (handle.stage) handle.stage.enabled = enabled !== false
+}
+
 // ================================================================ 点位标记(票 09)
 
 /**

+ 29 - 2
src/modules/cockpit/services/cockpitConfig.js

@@ -112,6 +112,17 @@ export function builtinConfig() {
                 minEdgeOcclusion: null,
                 requireTilesLoaded: null,
             },
+            // 一张图调色(科技蓝滤镜)。null/'' = 用 `domain/sceneGrade.js` 的默认
+            // (降饱和 0.82 / 亮度 0.96 / 对比 1.08 / #2fa8ff / 阴影混合 0.18);
+            // `enabled=false` = 整级关掉。色彩口径(含"不做双色"红线)全在 domain,这里只透传。
+            grade: {
+                enabled: true,
+                saturation: null,
+                brightness: null,
+                contrast: null,
+                tint: '',
+                tintStrength: null,
+            },
             // 票 45:重叠设备展开阈值(K>maxFan 降级为聚合符号 + 清单提示)。
             overlap: { maxFan: 10 },
             // 票 47:设备状态动效(决策票 07)。`enabled: null` = 跟随构建形态;
@@ -301,9 +312,8 @@ export function normalizeConfig(raw) {
                 eager: readBoolean(tileset.eager, true, issues, `map.tilesets[${index}].eager`),
                 role: readString(tileset.role, '', issues, `map.tilesets[${index}].role`),
                 credit: readString(tileset.credit, '', issues, `map.tilesets[${index}].credit`),
+                regionManifest: readString(tileset.regionManifest, '', issues, `map.tilesets[${index}].regionManifest`),
                 progressiveRefinement: readBoolean(tileset.progressiveRefinement, false, issues, `map.tilesets[${index}].progressiveRefinement`),
-                preloadDistance: readNonNegativeNumber(tileset.preloadDistance, issues, `map.tilesets[${index}].preloadDistance`),
-                maxConcurrentStreams: readNonNegativeNumber(tileset.maxConcurrentStreams, issues, `map.tilesets[${index}].maxConcurrentStreams`),
                 ...(tileset.lighting === undefined ? {} : { lighting: readBoolean(tileset.lighting, false, issues, `map.tilesets[${index}].lighting`) }),
             }
         })
@@ -417,6 +427,23 @@ export function normalizeConfig(raw) {
         config.map.handover.minEdgeOcclusion = null
     }
 
+    // 一张图调色(科技蓝滤镜):宽松归一化(同雾/交接)—— 数值/色值不合法就置空,
+    // 由 `domain/sceneGrade.js` 的计划层退回默认;"不做双色"等口径全在 domain,只此一份。
+    const gradeConf = isPlainObject(map.grade) ? map.grade : {}
+    config.map.grade = {
+        enabled: readBoolean(gradeConf.enabled, true, issues, 'map.grade.enabled'),
+        saturation: readNumber(gradeConf.saturation, issues, 'map.grade.saturation'),
+        brightness: readNumber(gradeConf.brightness, issues, 'map.grade.brightness'),
+        contrast: readNumber(gradeConf.contrast, issues, 'map.grade.contrast'),
+        tint: readString(gradeConf.tint, '', issues, 'map.grade.tint'),
+        tintStrength: readNumber(gradeConf.tintStrength, issues, 'map.grade.tintStrength'),
+    }
+    // 色值写法错了要报出来(否则会静默用默认色,维护者以为改生效了)—— 与 map.fog.color 同款。
+    if (config.map.grade.tint && !parseHexColor(config.map.grade.tint)) {
+        issues.push(`map.grade.tint 应为 #rrggbb,收到 ${JSON.stringify(config.map.grade.tint)},已退回默认色`)
+        config.map.grade.tint = ''
+    }
+
     // 票 21:地面/管线视图的取值覆盖(缺省用 domain/viewModes.js 的默认;脏值忽略)
     const viewMode = isPlainObject(map.viewMode) ? map.viewMode : {}
     const pipeline = isPlainObject(viewMode.pipeline) ? viewMode.pipeline : {}

+ 65 - 0
src/modules/cockpit/services/mapService.js

@@ -69,6 +69,8 @@ import {
     planFogDegrade,
     stepFogLifecycle,
 } from '../domain/fog.js'
+// 一张图调色(科技蓝滤镜):计划归 domain/sceneGrade.js,引擎只照计划建处理级。
+import { planSceneGrade } from '../domain/sceneGrade.js'
 import {
     HANDOVER_BLOCK,
     HANDOVER_PHASE,
@@ -785,6 +787,11 @@ async function bootEngine(record) {
             retargetFogForArea(areaState)
         }
         paintFog()
+        // 一张图调色(科技蓝滤镜):**在雾之后建级** —— 后处理按加入顺序执行,
+        // 调色排在雾后面,雾出的白也被统一成同一冷蓝调。配置关掉时不建级。
+        if (ensureGradeStage()) {
+            paintGrade()
+        }
         // 票 39:挂上精细层句柄并驱动一次交接 —— 进区状态可能在引擎就绪前就定了
         // (配置先到手),这里补一次,免得第一次进区才生效。
         // 顺序有意:**在雾之后** —— 闸门要读雾的强度,雾没建好时闸门必然不放行。
@@ -965,6 +972,15 @@ export function unmountMap() {
         }
         fogHandle = null
     }
+    // 一张图调色级随地图销毁(同一条纪律:先摘处理级,再销毁 viewer)。
+    if (gradeHandle) {
+        try {
+            gradeHandle.destroy()
+        } catch (error) {
+            // 销毁失败不阻断离开页面(下面 viewer.destroy 会整体回收)。
+        }
+        gradeHandle = null
+    }
     // 状态一并归零:下次挂载从"全球态无雾"重新走,不残留上一轮的强度。
     fogLifecycle = createFogLifecycle()
     fogExecutable = false
@@ -972,6 +988,8 @@ export function unmountMap() {
     fogCreationFailed = false
     if (isDomHost(record.host)) {
         delete record.host.dataset.camera
+        // 调色读数一并收(宿主上不留上一轮的 on/off)。
+        delete record.host.dataset.mapGrade
         // 票 10:选中态随地图一起收(宿主上不留上一轮的选中标记)。
         delete record.host.dataset.mapPicked
         delete record.host.dataset.mapPickedStatus
@@ -1217,6 +1235,9 @@ function applyPrimaryArea(mapConfig) {
     fogReason = fogParams.enabled ? '' : '已由配置关闭自建雾(map.fog.enabled=false)'
     // 票 39:交接参数同源(同样不依赖引擎)。
     handoverParams = planHandover(mapConfig || {})
+    // 调色参数同源(同样不依赖引擎):配置一换,立即写进已建的处理级。
+    gradeParams = planSceneGrade(mapConfig || {})
+    paintGrade()
     // 影像矩形缓存随配置失效(判"会露边的是影像边界"要用它)。
     overlayBoundsResolved = false
     overlayBoundsCache = null
@@ -1421,6 +1442,9 @@ let fogHandle = null
 let fogSupported = true
 let fogExecutable = false
 let fogReason = ''
+// ---- 一张图调色(科技蓝滤镜):常驻全屏处理级,无进出生命周期,只有配置一个来源。----
+let gradeParams = planSceneGrade({})
+let gradeHandle = null
 /**
  * 是否曾经真正启用过 / 是否**曾经**观测到 `ready === true`(shader 编译并跑通过一遍)。
  *
@@ -1739,6 +1763,44 @@ function ensureFogStage() {
     }
 }
 
+// ---- 一张图调色(科技蓝滤镜):建级与写 uniform。与雾同一条「复用不重建」纪律。----
+
+/** 在场景上建**唯一**一个调色处理级;配置关掉时不建(幂等)。 */
+function ensureGradeStage() {
+    if (!instance || !instance.engine || !instance.viewer) return false
+    if (gradeHandle) return true
+    if (!gradeParams.enabled) return false
+    try {
+        gradeHandle = instance.engine.createSceneGradeStage(instance.viewer, {
+            ...gradeParams,
+            // 引擎吃 0..1 的 rgb;hex 解析沿用雾的同一份 parseHexColor。
+            tintRgb: parseHexColor(gradeParams.tint) || undefined,
+        })
+        return true
+    } catch (error) {
+        // 建失败只是这层滤镜缺席,不挡业务(与雾的失败立场一致)。
+        gradeHandle = null
+        console.warn('[mapService] 场景调色处理级创建失败,已跳过', error)
+        return false
+    }
+}
+
+/** 把当前计划写进 uniform 与 dataset 读数(走查可读 `data-map-grade`)。 */
+function paintGrade() {
+    // 配置把 enabled 翻开而处理级还没建(启动时关、后来配置翻开):这里补建(幂等)。
+    if (!gradeHandle && gradeParams.enabled) ensureGradeStage()
+    if (gradeHandle && instance && instance.engine) {
+        instance.engine.applySceneGradeUniforms(gradeHandle, {
+            ...gradeParams,
+            tintRgb: parseHexColor(gradeParams.tint) || undefined,
+        })
+    }
+    if (instance && isDomHost(instance.host)) {
+        // 读数三态:on(在跑)/ off(配置关)/ pending(配置要但没建成 —— 引擎未就绪或建级失败)。
+        instance.host.dataset.mapGrade = !gradeParams.enabled ? 'off' : gradeHandle ? 'on' : 'pending'
+    }
+}
+
 /**
  * 「可执行」的**唯一**口径(防止散在各调用点各写一遍而漂移)。
  *
@@ -3268,6 +3330,9 @@ export function resetLifecycleStatsForTest() {
     // 票 38:雾状态一并归零(同一进程复跑断言不得互相污染)。
     stopFogLoop()
     fogHandle = null
+    // 调色状态一并归零:下次挂载按(重算后的)配置重建,不残留上一轮的处理级。
+    gradeHandle = null
+    gradeParams = planSceneGrade({})
     fogLifecycle = createFogLifecycle()
     fogSupported = true
     fogExecutable = false