# 39: 粗→精影像交接 **What to build:** 从全球飞到县城视角的全过程不出现肉眼可辨的矩形色块跳变。全球/引导态先隐藏精细叠加影像、保留粗底图;进入时**等雾先形成保护、目标视野可显示**,再渐显精细层;返回时反向交接。这是需求③的落地。 Blocked by: 38 Status: resolved Labels: ready-for-agent ## 契约来源 - 规格:`cockpit-primary-area-guardrails/spec-fog.md` 的「与影像的边界」;`cockpit-primary-area-guardrails/spec.md` 的进出状态机。 - 决策:`cockpit-primary-area-guardrails/issues/08-coarse-to-fine-transition-solution.md`(已 resolved,含维护者在真实场景的三条反馈)。 - 可丢弃原型(只作参考,不进生产):`src/modules/cockpit/prototypes/TransitionPrototype.vue` 与同目录 scene 脚本、`prototypes/prototype-coarse-to-fine-transition.html`;打开方式写在决策票 08 的 Answer。 ## 验收 - [x] 全球自转/引导态:精细叠加影像**隐藏**、保留粗底图;隐藏期间**可继续预取**瓦片(看不见≠停止加载)。 - [x] 进入:先满足「雾保护成立」且「目标视野瓦片可显示」,才渐显精细层;目标未就绪**不揭示**,避免先空白再跳方块。 - [x] 返回:**反向交接** —— 精细层先收起,保护再撤除;**镜头高度上升与雾气模糊减弱同步**,不得出现「影像收完 → 停顿 → 镜头才飞走」。 - [x] 取消 / 快速返回 / 再次进入:只认**最新目标**;过期的渐显或撤雾不得生效,不留半透明卡死态。 - [x] 瓦片失败:保持隐藏、继续用粗底图,并如实提示。 - [x] 雾失效:业务可用优先 —— 提示降级并渐显精细层,**不承诺完全遮边**。 - [x] 三条实测反馈必须落地:进出雾变化比原型**稍快**;雾**偏冷白灰、不发绿**;返回时**高度与雾同步**。 - [x] 判据:真机「进入 → 停留 → 返回」全程无肉眼可辨的方形跳变;覆盖浅俯角与多个旋转方向,结论不能只基于一个方向。**维护者 2026-09-30 真机调定后确认观感通过**("目前的效果就行")。 - [x] 矩形内部随地形 LOD 的**逐级换挡**不由整层 alpha 修复;若仍可辨,按 40 评估局部过渡手段。**已如实记录为"本层不解决"**(决策 08 明确),是否可辨的判定与修复评估归 40。 - [x] 原型不进生产:`/cockpit` 入口的 DEV 原型接线(`transitionPrototype` 一路)在本票内收敛,生产只吸收已确认的交接语义。**按维护者裁定收窄为"隔离"**:生产交接是 alpha 唯一写入方、与原型显式互斥;删除与归档归 40(见下)。 ## 进度:100% **实现完成、过闸门;真机观感由维护者 2026-09-30 现场调定并确认通过,本票收口。** 维护者在真机上自行完成一轮时长/阈值调试并裁定"以我为准"(部署值见 「维护者真机调定与收口」),观感判据(验收第 8 条)据此通过。 两条**有意放宽**(`requireTilesLoaded: false`、退出发白窗口 ≈387 ms)已如实记录在票面与部署手册。 按维护者裁定(2026-09-28):**39 只做隔离,原型的删除与归档留给 40**。 **一处口径按维护者裁定收窄**(见下「原型接线的归属」)。 ### 原型接线的归属(维护者 2026-09-28 裁定) 验收第 10 条原文要求 `transitionPrototype` 那路 DEV 接线「在本票内收敛」,但票 40 的验收又写着 「可丢弃原型与 `/cockpit` 入口的 DEV 接线单独保存到 throwaway 分支,并在本票留分支指针」—— 两条重叠、归属不清。**维护者裁定:39 只做隔离,删除与归档留给 40。** 据此本票的实际落点(**不是**原文的字面"删掉接线"): - **生产与原型互斥**:两套逻辑都写 `ImageryLayer.alpha`,同时跑必然互相覆盖。 现在生产交接是 alpha 的**唯一写入方**;原型挂载时调 `setHandoverPrototypeActive(true)` **显式接管**(不是"谁后跑谁赢"),卸载时交还并重画一次。 - 已加静态守卫 + 变异测试确认:把让位判断改成 `if (false)` 会被闸门打红。 - **原型的文件与入口接线仍在**,由票 40 负责归档到 throwaway 分支并留指针。 这样 40 的「原型归档」验收不至于是空的。 ### 改动 - **新增 `domain/imageryHandover.js`**:交接状态机(相位 / alpha 推进 / 落定必为 0 或 1)、 **揭示闸门**(全球不揭示、雾保护、目标视野、边缘风险、瓦片失败)、**返回同步判据**、 **边缘遮蔽率**(与雾曲线同源)、过期判定、配置归一化、读数。 纯函数、不 import cesium、不读 DOM。 - **`services/cesiumEngine.js`**:`applyOverlays` 改为返回**句柄**(不再只返回层数)—— 句柄的 `setAlpha()` 是 **alpha 的唯一写入点**;新增 `createOverlayReadiness()` 采集 `globe.tilesLoaded` 与**瓦片失败**(后者很关键:Cesium 在**没有 errorEvent 监听者**时 会把瓦片错误直接抛到窗口,见 `ImageryLayer.js:1708`,走查的「无页面级报错」会因此误伤)。 - **`services/mapService.js`**:交接的**策略与生命周期唯一所有者**; 门控与顺序收口在 `setAreaState` 与渲染循环;宿主读数 8 个 `data-map-handover*`; DEV 把手 `getHandoverState` / `getHandoverParams` / `advanceHandover` / `setHandoverPrototypeActive`。 - **`services/cockpitConfig.js`**:`map.handover`(缺省=默认;`minEdgeOcclusion` 越界退回默认并记 issue)。 - **`docs/cockpit-deployment.md`**:新增 **2.12 粗→精影像交接** + 一页速查 + 自检两行; `config.example.json` 同步该字段。 ### 关键取舍(两条**故意不照搬原型**的口径) 1. **边缘保护判据不拍脑袋。** 原型用「相机距矩形边 ≥ 38 km」这个魔数。本模块改为 「把相机到矩形四边的最小距离代入**同一条雾衰减曲线**,要求遮蔽率 ≥ `minEdgeOcclusion`(默认 0.9)」。 阈值与雾参数**同源**,日后调雾端点时边缘判据自动跟随,不会各调各的。 **实算证明不能照抄**:区内最坏点(西南角)距影像边 35.8 km,遮蔽率 0.967(过闸); 若照抄 38 km 字面值,这个点会被**误判为不安全**而挡住本可显示的影像。 2. **瓦片就绪只是代理信号。** `globe.tilesLoaded` 仅表示**加载队列为空**,不证明目标视野逐像素就绪 (决策票 08 明确警告)。读数里带 `tilesReadyIsProxy: true`、宿主带 `data-map-handover-tiles-proxy="yes"` 如实标注,免得下游把它当充分证明。 另有两处实现上的判断: 3. **"隐藏"必须用 `alpha = 0` + `show = true`。** 瓦片是否被请求由 `GlobeSurfaceTile.js:691` 按 **`layer.show`** 决定,**不看 alpha**。用 `show = false` 会真的停掉预取, 揭示时才开始下载 —— 正好造成"先空白再跳方块"。读数里 `layerProbe` 可直接核对。 4. **返回拆成两个并行进程,而不是串行等待。** 决策 08 同时要求 「影像在失去保护前收起」与「不能影像收完→停顿→镜头才飞走」,字面上矛盾。 解法:按下返回的那一帧就同时开始收影像与飞镜头;雾**延后**到影像确实不可见(alpha=0)才淡出。 ### 证据 **逻辑闸门 `verify/t39-handover.mjs` 通过**,覆盖:相位与 alpha 端点(落定必为 0/1)、 闸门四类拦截与"三条件齐备才放行"、降级(雾不可用仍揭示但不承诺遮边、且**不绕过瓦片就绪**)、 瓦片失败保持隐藏且优先报出、边缘判据与雾曲线同源、返回同步(按下返回那一帧绝不撤雾)、 过期取消与幂等、配置可调且脏值退回默认、读数不承诺已遮边、静态守卫 8 条。 **变异测试 15 处,全部被对应断言打红**(一次性脚本跑完即删):闸门不看雾强度/瓦片/边缘、 全球态也揭示、瓦片失败也放行、降级锁死业务、返回立刻撤雾、落定停中间值、 边缘判据自写一套曲线、读数谎称已遮边、隐藏改用 `show=false`、原型不互斥、 配置脏值不退回默认、瓦片信号不标代理、进出动作被稳态节流吞掉、 **渐显途中不幂等(见下方真 bug ②)**。 **真机时序证据(页面可见时采集,逐 100 ms 采样)——进入**: | 时间 | 雾强度 | 精细层 alpha | 挡板 | | --- | --- | --- | --- | | 102–901 ms | 0.03 → **1.00** | **恒为 0** | `fog`(等待雾形成保护) | | 1009 ms | 1.00 | 0.025 | 放行 | | 1009–1710 ms | 1.00 | 0.025 → 0.142 → 0.323 → 0.534 → 0.770 → 0.904 → 0.993 | 渐显 | | 1710 ms 起 | 1.00 | **1.00**(`revealed`) | — | **返回**: | 时间 | 精细层 alpha | 雾强度 | 说明 | | --- | --- | --- | --- | | 103–703 ms | 0.942 → 0.797 → 0.603 → 0.390 → 0.196 → 0.053 → **0** | **恒为 1.00** | 影像先收起,雾全程保持保护 | | 803–1502 ms | 0(已收起) | 1.00 → 0.96 → 0.85 → 0.69 → 0.50 → 0.32 → 0.16 → 0.04 → **0** | **收起之后**才开始撤雾 | 全程 **`appliedAlpha` 与状态机 `alpha` 逐帧一致** —— 图层真的被写(票 38 的"读数对、画面没变"陷阱)。 另有隐藏期间的可核事实:`layerProbe = [{ show: true, alpha: 0 }]` —— **alpha 归零但 show 仍为 true**,即"看不见 ≠ 停止加载"成立。 全量回归:**26 个零依赖逻辑闸门全绿**(t05 守卫面纳入 `imageryHandover.js`,236→**248**)。 ### 真机验证抓到的两个真 bug(都已修 + 补了回归) ① **隐藏若用 `show = false` 会停掉预取。** 实现时查了 Cesium `GlobeSurfaceTile.js:691`, 确认瓦片请求只看 `show` 不看 `alpha`,据此定成 `alpha=0 + show=true`; 并加静态守卫禁止 `layer.show = false`(变异确认有牙)。 ② **渐显途中 `beginHandover` 不幂等 → alpha 永远到不了 1,精细层永不出现。** 服务层每渲染帧都会调一次 `beginHandover`("条件还成立吗"天然要重复问)。 第一版只在端点(`REVEALED`/`GLOBAL`)幂等,于是渐显**途中**每帧都被当成新一轮: `since` 每帧被重置 → `elapsed` 恒 ≈0 → alpha 一直是 0 附近。 **最阴的地方**:所有读数都显示"entering / 正在渐显 / 保护成立",看起来完全正常 —— **只有画面不动**。逻辑闸门当时抓不到(它只验相位,不验"推进时间后 alpha 是否真的变大")。 已修(同一相位幂等、不重置时间轴;换方向才重置),并补一条**行为断言**: 同一相位连续调 `beginHandover` 后 `since` 不得变、且推进一个 `fadeMs` 后 alpha 必须到 1 (变异确认能抓住)。 ### 维护者第二次现场复核后的两处修正(2026-09-29) 维护者复核票 38 第一版的高度收口后,指出两处新问题;详细的根因与反解过程记在 **票 38 的「维护者第二次现场复核」一节**(雾是那边的实现面),此处只记与**交接**有关的部分: 1. **「进入时最后一小段时间画面会变绿」** —— 维护者诊断:"粗糙地球贴图出现了但是精细地形 还未出现,**雾的渐出与场景的渐入没有很好的衔接上**",并选定修法「缩短精细影像渐显时长」。 原因是**几何**:雾是距离雾(18 km 起步),而区内相机只有约 9.8 km 高 ⇒ **屏幕中心(脚下街区)视线距离恒 < 18 km,雾恒为 0,结构上盖不住中心**。 于是中心的观感只看精细层 alpha:渐显期间它是「粗糙底图(陆地偏绿)↔ 精细影像」的混合, **窗口越长绿越明显**。 **改动**:`HANDOVER_DEFAULTS.fadeMs` **700 → 380 ms**(比原型 900 ms 短得多, 但仍是一段可辨的渐变而不是硬切)。**刻意没有去调雾的强度** —— 按上面的几何,调强度对中心无效。 2. **「退出速度有一点太快」** —— 返回全球原先走阻尼,目标 `range` 是 20,000,000 m、 起点 12,000 m(跨 1.7e6 倍),指数逼近**起步最快**,头一帧就越过整条雾带(实算 0.15 ms)。 即"雾几乎一瞬间就散了"、且那时雾还满着 ⇒ 整球发白。 修法在相机侧(`RETURN_FLIGHT_MS` + 慢起缓动),**但约束是交接定的**: 雾淡完于 `handover.fadeMs + fog.fadeOutMs`,那一刻相机应仍在 42 km 以下。 该跨模块关系已固化成本票闸门的第 12 节 —— **但 2026-09-30 维护者按现场观感有意放宽了它**,故第 12 节改为 "把窗口算出来并钉住已接受上限(400 ms)"而非硬断言(详见下方收口一节)。 `map.handover.fadeMs` 保持可配;380 ms 是当时的取值,最终由维护者现场定为 500 ms。 ### 观感判据(原"待维护者目视",**2026-09-30 已由维护者真机确认**) > 下面四项原本是 agent 不能代判的观感项。维护者在真机上走查并调定时长后 > 确认"目前的效果就行"(见「维护者真机调定与收口」),故本票据此收口。 > 保留原文以便日后回归时知道**该看什么**、以及当时依据的是哪条判据。 1. **「全程无肉眼可辨的方形跳变」** —— 本票提供的是**时序正确**的读数证据: 雾先到位、影像后亮、返回先收后撤,且全程无"影像可见而雾已撤"的帧。 回归时应覆盖**浅俯角与多个旋转方向**(决策票要求结论不能只基于一个方向)。 2. **矩形内部随地形 LOD 的逐级换挡** —— 决策 08 明确"**不能靠整层 alpha 修复**"。 本层**不声称已解决**;若日后仍可辨,按票 40 评估局部过渡或 shader 羽化(预烘焙中精度层仅作兜底)。 3. **三条实测反馈的最终观感**:进出雾变化是否够"稍快"(`map.fog.fadeInMs/fadeOutMs` 可调)、 雾色是否"冷白灰不发绿"(票 38 已确认色相)、返回时高度与雾是否"看起来同步"(本票已保证时序)。 4. **2026-09-29 新增两条**: - 「进入时最后一段还会不会变绿」—— 渐显窗口从 700 ms 压到 380 ms,最终由维护者现场调为 **500 ms**; - 「退出够不够慢、雾是不是一段淡出而不是一闪就没」—— 返回改成慢起飞行 + 高度系数时间限速(`altitudeSlewMs`),最终现场值为 `RETURN_FLIGHT_MS = 1000`、`altitudeSlewMs = 600`。 ### 已知限制(如实记录) - 本会话浏览器标签页**大部分时间 `document.hidden=true`**:rAF 被冻结, 雾的时间轴推进与交接的逐帧推进都停摆(读数为 0)。上表的时序证据是在标签页**可见**时采集的; **隐藏标签页下**闸门行为本身仍正确(如实报 `block: fog`、拒绝过早揭示),但不能用隐藏页判断推进。 - 本地 puppeteer 不可用(`@puppeteer/browsers` 为空目录),故 `verify/t39-handover-walkthrough.mjs` 已按既有写法备好、供修复后一键复跑; 本票的真机证据由 DSH 浏览器逐项采集(上表),两者逐条对应。 ### 维护者真机调定与收口(2026-09-30) 维护者在本机对**时长与阈值**做了完整一轮现场调试,并明确裁定:**以现场观感为准**。 原话(两轮问答): > ①「就按我调整的数值来,不要继续检查和改动了,目前的效果就行」 > ②(关于高度带)「我已改为 `altitudeFullM: 14000` / `altitudeZeroM: 3600000`(后修至 3000000),**以我为准**」 据此的最终落点(**部署层=`public/cockpit/config.json`,已 gitignore**): | 项 | 代码默认(规格取向) | **本部署现场值** | | --- | --- | --- | | `fog.fadeInMs` | 800 | **900** | | `fog.fadeOutMs` | 400 | **300** | | `fog.altitudeFullM` | 20000 | **14000** | | `fog.altitudeZeroM` | 42000 | **3000000** | | `fog.altitudeSlewMs` | 400 | **600** | | `handover.fadeMs` | 380 | **500** | | `handover.minEdgeOcclusion` | 0.9 | **0.7** | | `handover.requireTilesLoaded` | true | **false** | | `RETURN_FLIGHT_MS` | — | **1000** | | 返回缓动 | — | `smoothstep^6.5` 混入 0.1% 线性项 | **两条必须如实记录的放宽**(都是维护者的明确取舍,不是疏漏): 1. **`requireTilesLoaded: false`** —— 不再等"瓦片加载队列为空"就允许揭示。 决策 08 当初设这一条正是为了避免"先空白再跳方块";关掉后**牺牲的就是那个保障**。 已写进部署手册 2.12 的字段说明(含代价与回退方法)。 2. **退出时"相机已高于雾的全遮蔽端点、而雾仍可见"的窗口** —— 本组合下 相机在 ~413 ms 越过 42 km,而雾要到 `500 + 300 = 800 ms` 才淡完,窗口 ≈ **387 ms**。 结构性约束(雾应先淡完)在部署层被**有意放宽**;维护者已确认当前观感可接受。 **没有**把它偷偷写成"通过":见下面对闸门的处理。 **闸门的相应处理(不掩盖、保留牙)**:原第 12 节是一条硬断言 ("雾淡完时相机必须仍在 42 km 以下"),它在 `T=1000` 下**不可能成立**, 留着就是一条永远红的假门。已改写为**把窗口算出来并钉住已接受上限(400 ms)**: - 缓动的结构性性质(慢起、两端精确、单调、真的走 `flightMs` 而非阻尼)仍**硬断言**; - 窗口值改为"超过已接受量级就红",逼下一次"这是有意的吗"的确认 —— **6/6 定向变异全部打红**(退回光滑缓动、加大线性项、放大 `fadeMs`/`fadeOutMs`、 返回退回阻尼、缓动停在中途)。 **同时修掉一处会误导人的注释**:`camera.js` 里 `RETURN_FLIGHT_MS` 与缓动的 JSDoc 原先写着 "指数 7 由不变量反解"的推导,而维护者已把缓动改成 `6.5 + 0.1% 线性项`、 且约束已被放宽 —— 注释若照旧就是**在源码里说谎**。已改写成: 说明结构性约束本身、说明本部署按观感有意放宽、并指出代码默认值仍保持"雾先淡完"的取向。 **新增一条配置层守卫**:`altitudeZeroM` 必须大于 `altitudeFullM`。 写反会让高度系数恒为 1(**等于把这层保护关掉**),而它正是"退出整球发白"的防线。 **只报 issue、不替改值** —— 两个数各自都合法,"要不要在高空保留雾"是部署取舍 (本部署有意把上端放到 3000 km,使飞入全程都有雾)。 **验证**:`node --check` 全部通过;两个配置文件 JSON 合法且归一化 `issues` 为空; `t38-fog` / `t39-handover` 均绿;**全量 29 个闸门通过**, 仅 2 个红且**与本票无关**(`t04-config` 的"不该硬编码后端地址"与 `t44-device-assets` 由**并发会话**正在改的 `cockpitApi.js` / `deviceAssets.js` 引起,我未触碰这些文件)。 **环境限制(如实记录)**:本会话的工具清单里**已没有浏览器工具** (`browser_refresh` 报 "unknown tool"),故本轮的"观感通过"是**维护者在真机上自行确认的**, 不是我这边采集的证据。我提供的是算术、闸门与变异证据。 ### 结论 验收第 8 条(观感判据)由**维护者真机确认通过**("目前的效果就行"); 其余 9 条此前已逐条落地并有闸门/变异证据。**本票按维护者裁定收口。**