Browse Source

chore(cockpit): T03 结票(维护者人工验收通过)

维护者 2026-09-17 确认「T03 验收完毕」,本票 close:进度 100%、八项验收全部勾选。
证据:逻辑闸门 **25/25** + 真机走查 **10/10** + 极点专项探针 + 跨 ±180 专项走查 + 人工验收。
执行索引(map.md)追加本票 gist,并订正票 30 的状态(实施中,非已关闭)。

本次一并改掉走查过程中暴露的两个工具问题:

- **heading/经度比较改走最短弧**:360° 与 0° 是同一个角度,直接用减法会把「没变」
  判成「变了 360°」(A2 曾因此假红)。
- **A10 的单调性只在「换中心飞行结束之后」判定**:飞行期间目标自己在动,缺口会先被
  拉开再收拢,拿全程做单调判断会误判成阻尼没收敛;采样窗同时放宽到 6s(换中心 700ms
  加阻尼把缺口从 ~0.02° 收敛到 1e-7° 约需 1.3s,原来的 2s 窗刚好卡在边界)。
- 走查适配票 30 的「进入引导」:先关遮罩再测(引导期间拖动是转星球、滚轮是缩放星球,
  不先关掉第一次拖拽会被遮罩吃掉)。
- 走查识别 HMR 重载:并发会话改同仓文件会热重载页面(引导重现、把手重建、相机回初始
  视角),此时断言全是假红;现在打运行标记,收尾不一致即判「本轮作废」(退出码 2)。

`cesiumEngine.js` 里 `projectPivot()` 的坐标口径修正(Cesium 返回的是画布局部坐标,
需加画布原点才是窗口坐标;修后 A5/A8 的投影与画布中心偏差 0px)与 `mapService.js` 里
`setCameraDamping` 把手一样,按维护者裁定随「底图/3D Tiles」那一路一起提交 ——
这两个文件当前都带着该路未提交的改动(引用尚未提交的 `domain/basemap.js` 等),
单独提交会产出引用缺失模块的树。
Kazerin 1 tháng trước cách đây
mục cha
commit
f77508a870

+ 35 - 16
.scratch/cockpit-shell-panel-layout/issues/03-camera-control-contract.md

@@ -6,21 +6,23 @@ Blocked by: 02, 27
 
 > 依赖说明(2026-09-16 维护者确认):三键相机契约必须落在真实引擎上,故新增票 27(接入 Cesium 常驻 Viewer)作为本票前置。
 
-Status: claimed
+Status: resolved
 Labels: ready-for-agent
 
+> 请勿据标签重复接单:本票已通过维护者人工验收并结票,`Labels` 保留只是为了让面板不把它退化成 `needs-triage`。
+
 ## 契约来源
 
 [ADR 0008](../../docs/adr/0008-web-cockpit-camera-controls.md) 与决策地图票 [Spring-arm orbit pivot](../web-cockpit-decision-map/issues/04-spring-arm-orbit-pivot.md):三键分工、旋转中心 = 选中对象地表点(未选中时 = 屏幕中心的地面拾取点)、右键按下锁定中心、取消选中不回退、只有左键平移才把中心交回地面拾取点、换中心时保持 heading 与 range。弹簧臂原语锁定为 `camera.lookAtTransform(中心东北天局部系, HeadingPitchRange)`,不用 Tres `OrbitControls`、不用 vue-cesium 的鼠标绑定。
 
 ## 验收
 
-- [x] 鼠标左键拖拽只执行摄像机平移,不旋转、不缩放。 —— 逻辑 1/2 绿;真机 A2 待跑。
-- [x] 鼠标右键拖拽绕旋转中心执行轨道旋转;右键按下时锁定中心,拖拽中不重新拾取。 —— 逻辑 3/5 绿;真机 A3 待跑。
-- [x] 滚轮只改变摄像机与旋转中心的距离。 —— 逻辑 6 绿;真机 A4 待跑。
-- [x] 选中监测点/设备时中心为该对象地表点;未选中时为地面拾取点;取消选中不回退中心。 —— 逻辑 7 绿;真机 A5/A6/A7/A8 待跑。
-- [x] 从无选中到选中时镜头平滑换中心,保持 heading 与 range。 —— 逻辑 8 绿;真机 A6 待跑。
-- [x] Cesium 默认左键旋转、滚轮缩放等行为被替换。 —— `createViewer()` 里逐项关闭(`enableRotate/Zoom/Tilt/Look/Translate`),左键平移/滚轮距离由本票接管;真机 A2/A4 待跑。
+- [x] 鼠标左键拖拽只执行摄像机平移,不旋转、不缩放。 —— 逻辑 1/2 绿 + 真机 A2 绿(heading/pitch/range 逐项不变,中心沿「抓地图」方向移动)。
+- [x] 鼠标右键拖拽绕旋转中心执行轨道旋转;右键按下时锁定中心,拖拽中不重新拾取。 —— 逻辑 3/5 绿 + 真机 A3 绿(dHeading 33.6° = 120px×0.28,range 与中心不动)。
+- [x] 滚轮只改变摄像机与旋转中心的距离。 —— 逻辑 6 绿 + 真机 A4 绿(12000 → 8997m,heading/pitch/中心不动)。
+- [x] 选中监测点/设备时中心为该对象地表点;未选中时为地面拾取点;取消选中不回退中心。 —— 逻辑 7 绿 + 真机 A5/A6/A7/A8 绿(中心点投影回画布与画布中心偏差 0px)。
+- [x] 从无选中到选中时镜头平滑换中心,保持 heading 与 range。 —— 逻辑 8 绿 + 真机 A6 绿(17 帧推进、全程 heading/range 不变)。
+- [x] Cesium 默认左键旋转、滚轮缩放等行为被替换。 —— `createViewer()` 里逐项关闭(`enableRotate/Zoom/Tilt/Look/Translate`),左键平移/滚轮距离由本票接管;真机 A2/A4 绿。
 - [x] 相机控制集中在地图服务层。 —— `domain/camera.js`(纯数学)+ `services/cameraControl.js`(状态机与 DOM 绑定)+ `services/cesiumEngine.js`(Cesium 方言翻译)+ `services/mapService.js`(生命周期与对外 API);组件与外壳未改。
 - [x] 三类交互各有可重复演示路径。 —— 见下面「演示路径」一节。
 
@@ -41,9 +43,9 @@ Labels: ready-for-agent
 
 提交情况(2026-09-17):方向反转 + 阻尼一次提交,平移惯性修正紧随其后一次(`git log` 里搜「松手有惯性」)。`mapService.js` 里那一行 `__cockpitMap.setCameraDamping` 把手**故意没随这两次提交** —— 该文件当时还带着并发会话「底图/配置」票的未提交改动(引用尚未提交的 `domain/basemap.js`、`services/cockpitConfig.js` 等),单独提交会产出引用缺失模块的树。维护者裁定:**等那一路落地后随它一起提交**(那行改动内容极小,落地前 `setCameraDamping` 只影响 DEV 调参便利性,不影响契约本身)。
 
-## 进度:95%
+## 进度:100%
 
-维护者已明确:**解决平移惯性问题后由其人工验收,验收通过即算本票验收通过**。实现与逻辑闸门(17/17)全绿,待人工验收 + 真机走查。
+**已通过维护者人工验收(2026-09-17「T03 确认验收完毕」)。** 实现、逻辑闸门(25/25)与真机走查(10/10)全绿。期间维护者四次复验共提了四条修改意见(方向反转、加阻尼、松手惯性、极点异常旋转)与一条经度最短弧,全部落地并各自带可复现的断言。
 
 ### 交付物
 
@@ -51,8 +53,9 @@ Labels: ready-for-agent
 - `src/modules/cockpit/services/cameraControl.js`:状态机(中心、弹簧臂、拖拽态、锁定中心、飞行)+ `attachCameraInput()` 事件绑定(左/右键 pointer、滚轮 `passive:false` + `preventDefault`、右键菜单 `preventDefault`、`setPointerCapture`)。引擎以接口注入,所以能脱离浏览器断言。
 - `src/modules/cockpit/services/cesiumEngine.js`:新增 `createCameraEngine(viewer)`,把契约用到的 Cesium 方言包出来 —— `applyView`(`lookAtTransform` + `HeadingPitchRange`)、`readView`(反读当前相机相对中心的 HPR)、`pickGroundAtCenter`(屏幕中心椭球求交;地形归票 21)、`projectPivot`(中心点投回画布)、`inputTarget`/`viewport`/`fovY`/`now`/`requestFrame`。本文件仍是全仓唯一 `import 'cesium'` 的地方。
 - `src/modules/cockpit/services/mapService.js`:引擎就绪后挂相机控制,卸载时先摘事件再销毁实例;对外只暴露 `setCameraFocus(point|null)` / `clearCameraFocus()` / `getCameraState()`;宿主写 `data-camera`(状态串,走查与人工排查都读它);DEV 下挂 `window.__cockpitMap` 把手(演示与走查用,生产构建不含)。
-- `verify/t03-camera-contract.mjs`:10 项逻辑断言(假引擎)。
-- `verify/t03-camera-walkthrough.mjs`:9 项真机断言(真 Cesium + `page.mouse` 真实输入事件)。
+- `verify/t03-camera-contract.mjs`:25 项逻辑断言(假引擎)。
+- `verify/t03-camera-walkthrough.mjs`:10 项真机断言(真 Cesium + `page.mouse` 真实输入事件;含「先关进入引导」与「识别 HMR 重载」两处环境适配)。
+- 专项诊断/复验脚本:`t03-pole-probe.mjs`(真机极点)、`t03-pole-domain-probe.mjs` 与 `t03-pan-direction-probe.mjs`(纯 domain,用来量清 dy↔南北 的符号)、`t03-antimeridian-probe.mjs`(纯 domain 弧长)、`t03-antimeridian-walkthrough.mjs`(真机跨 ±180)。
 
 ### 设计要点
 
@@ -78,7 +81,16 @@ Labels: ready-for-agent
 - 真机极点复验:`node .scratch/cockpit-shell-panel-layout/verify/t03-pole-probe.mjs` → 南极段 12 次向南拖经度恒 0.000、纬度恒 -85.000;北极段单调下降、无经度跳变(修复前经度在 0 与 -180 之间无限来回)。
 - 真机反经线复验:`node .scratch/cockpit-shell-panel-layout/verify/t03-antimeridian-walkthrough.mjs` → 从 179.0° 连续向西拖过 ±180,落点经度 `179.0 → -171.1 → … → -81.7`,按最短弧解缠后**单调推进、单步恒为 9.933°、总弧长 99.330° 与首尾最短弧完全相等(差 0.000)** —— 全程零绕路,不再出现「抽搐后旋转一周」。
 - 回归:`t01-panel-catalog.mjs` 通过(`getLifecycleStats` 新增 `camera` 字段,原字段语义未变)、`t06-assets.mjs` 15/15。
-- 真机:**待跑**(见下一步)。脚本已按新方向与阻尼更新:A2 改为断言中心沿**相机左手**方向移动、新增 A10「阻尼在真机上生效」(实际态滞后目标态且缺口单调收敛),并在量几何前统一等 `settled`(有阻尼后手势结束不等于画面停稳)。
+- **真机走查:`node .scratch/cockpit-shell-panel-layout/verify/t03-camera-walkthrough.mjs` → 10/10 通过**(A1 状态可读、A2 左键只平移、A3 右键只旋转、A4 滚轮只改距离、A5 中心=屏幕中心地面拾取点(投影与画布中心偏差 **0px**)、A6 换中心飞行、A7 取消不回退、A8 平移交回地面拾取点、A10 阻尼在真机生效、A9 无页面报错)。维护者人工验收据此确认。
+
+### 走查工具适配(票 30 起)
+
+票 30 引入「进入引导」全屏遮罩后,走查做了三处适配,都记在这里以免后人误读成回归:
+
+- **先关遮罩再测**:引导层点击即进入系统,且**引导期间拖动是转星球、滚轮是缩放星球**;不先关掉,第一次拖拽会被遮罩吃掉,后面测的就成了引导而不是相机契约。
+- **heading/经度按最短弧比较**:360° 与 0° 是同一个角度,直接用减法会把「没变」判成「变了 360°」(A2 曾因此假红)。
+- **识别 HMR 重载**:并发会话改同仓文件会触发 Vite 热重载,页面一重载引导层重现、dev 把手重建、相机回初始视角,断言全变假红。走查现在打运行标记,收尾核验不一致就判「本轮作废」(退出码 2)而不是报一堆假失败。
+- 另修一处**真 bug**:`projectPivot()` 用 Cesium 的 `worldToWindowCoordinates` 时,实测返回的是**相对画布左上角**的值,与 `getBoundingClientRect()` 差一个画布原点偏移(走查里表现为「中心点没落在画布中心」)。现在显式加上画布原点,返回真正的窗口坐标。
 
 ## 验证方案讨论(2026-09-17,维护者裁定暂缓)
 
@@ -90,12 +102,19 @@ Labels: ready-for-agent
 
 记下来的原因:底图/真数据到位后,上面两套断言可直接复用来做回归(标定场景与真场景换内容、不换断言);本 effort 里承接正式数据的是票 21(3D Tiles / 地形基线)。
 
-## 下一步(待确认什么)
+## 结票
 
-真机走查 9 项还没跑 —— 它**全是数值断言,不依赖可见内容**,所以底图未就位也可以跑。需要 `http://localhost:8000` 上的 `yarn dev`(维护者自行起服务,项目规则下我不自动起)。服务起来后执行:
+维护者 2026-09-17 确认「T03 验收完毕」,本票关闭。复跑方式(需要 `http://localhost:8000` 上的 `yarn dev`,按项目规则不自动起服务):
 
 ```
-node .scratch/cockpit-shell-panel-layout/verify/t03-camera-walkthrough.mjs
+node .scratch/cockpit-shell-panel-layout/verify/t03-camera-contract.mjs      # 逻辑,25 项
+node .scratch/cockpit-shell-panel-layout/verify/t03-camera-walkthrough.mjs   # 真机,10 项
+node .scratch/cockpit-shell-panel-layout/verify/t03-pole-probe.mjs           # 真机极点专项
+node .scratch/cockpit-shell-panel-layout/verify/t03-antimeridian-walkthrough.mjs  # 真机反经线专项
 ```
 
-把结果补进「验收证据」并把进度提到 100% 再 close。若真机暴露出与逻辑不一致的地方(例如 Cesium 事件被上游吞掉、中心投影偏移),据实回调进度并写明根因。
+遗留说明(不阻塞结票,已另处跟踪):
+
+- `mapService.js` 里 `__cockpitMap.setCameraDamping` 那一行、以及 `cesiumEngine.js` 里 `projectPivot()` 的坐标口径修正(加上画布原点,返回真正的窗口坐标),都按维护者裁定**随「底图/3D Tiles」那一路一起提交**:这两个文件当时带着并发会话的未提交改动(引用尚未提交的 `domain/basemap.js`、`services/cockpitConfig.js` 等),单独提交会产出引用缺失模块的树。两者都只影响 DEV 调参/走查,不影响相机契约本身;真机走查 10/10 是在含这两处修正的工作区上跑出来的。
+- 「初始视角不设预设」已由票 30(进入引导 + 主页视角预设:沅陵县城、俯角 -55°、12 km)接手,本票只保证控制语义;两者相接处(引导期间的拖动/滚轮归引导,进入系统后归三键契约)已由本票真机走查覆盖。
+- 极点带内不追求严格等距(东西向偏快、朝极点拖会顶住),±85° 远在沅陵之外;若将来要支持极区绕行,需要把中心改为三维笛卡尔点,属结构性改动,单开票。

+ 8 - 4
.scratch/cockpit-shell-panel-layout/map.md

@@ -3,7 +3,7 @@
 Status: open
 Labels: wayfinder:map
 
-本目录是 `/to-spec` + `/to-tickets` 的产物,不是 wayfinder effort:`spec.md` 是规格,`issues/01`–`23` 是实现票(01、02、06 已关闭),`issues/24`–`28` 是调研、待人工处理项与后续新增票(24 调研已关闭;25 待人工;26 watcher 崩溃已修复;**27 接入 Cesium 常驻 Viewer**,是票 03 的前置,已关闭;28 外壳填满容器已关闭)。
+本目录是 `/to-spec` + `/to-tickets` 的产物,不是 wayfinder effort:`spec.md` 是规格,`issues/01`–`23` 是实现票(01、02、03、04、06 已关闭),`issues/24`–`30` 是调研、待人工处理项与后续新增票(24 调研已关闭;25 待人工;26 watcher 崩溃已修复;**27 接入 Cesium 常驻 Viewer**,是票 03 的前置,已关闭;28 外壳填满容器已关闭;29 底图影像(配置驱动)已关闭;30 进入引导与视角预设**实施中**)。
 本 `map.md` 的唯一作用是把这个 effort 暴露给 issue tracker 面板——面板只枚举含 `map.md` 的 `.scratch/<effort>/`,没有 `map.md` 的目录连同它的票都不会被列出。
 `Labels: wayfinder:map` 在这里只用于让面板按**地图视图**渲染本 effort 的子票状态、进度与依赖分层;不代表本 effort 由 `/wayfinder` 驱动——推进仍走 `/implement`。
 
@@ -17,7 +17,8 @@ Labels: wayfinder:map
 - `issues/24`(resolved)是 AFK 调研,产物在 `research/panel-field-coverage.md`,不改业务代码。
 - `issues/25`(ready-for-human)是后端补契约与业务定义缺口,需要人或后端负责人处理;不阻塞 `01`–`23` 的 mock 阶段。
 - `issues/26`(resolved)是 dev server watcher 的 EBUSY 崩溃修复:忽略清单须同时覆盖 `.scratch` 面板原子写与 DSH 编辑审阅临时文件。
-- `issues/27`(resolved)是引擎接入票:装 `cesium` + 静态资源/`CESIUM_BASE_URL` + 服务层创建唯一 Viewer;**票 03 依赖它**(相机契约必须落在真实引擎上)。已关闭,验证证据与遗留(本工作区 `fes build` 因两个未跟踪插件缺依赖而跑不完)见票面。
+- `issues/27`(resolved)是引擎接入票:装 `cesium` + 静态资源/`CESIUM_BASE_URL` + 服务层创建唯一 Viewer;**票 03 依赖它**(相机契约必须落在真实引擎上)。已关闭:走查 20/20、`yarn build` 亦已跑通并产出 cesium 独立分包,验证证据与收口项见票面。
+- `issues/21`(claimed / 75%)是地形与 3D Tiles 基线:**已接上维护者本机 GIS 数据** —— `D:\GIS\GIS\yuanlingDEM`(量化网格 DEM)与 `D:\GIS\GIS\yuanlingPic3`(cesiumlab TMS 影像切片),dev 由 `.env.local` 的 `COCKPIT_GIS_MOUNTS` 挂载、地址写在 `public/cockpit/config.json`;全局粗糙层用 Cesium 自带离线影像(本机外网瓦片实测不可达,在线源只作可选项,维护者明确"能用内网离线就不用在线")。实测沅陵拉近 4.6km:DEM 192 块 + 影像 199 块全 200、零 404。**3D Tiles 入口已实现并验证**(`map.tilesets[]` → `Cesium3DTileset`,单项失败不影响其它),但 `D:\GIS\GIS\yuanlingSHP` 那份模型数据**缺根 tileset.json 且分块是局部坐标**,需重导或提供模型原点才能接上。**生产部署手册已写成 `docs/cockpit-deployment.md`**(运维改哪个文件、怎么改、怎么自检)。剩下:地面/管线视图切换、模型数据补齐、地形感知的相机拾取(现在拉到几百米会钻进山头)。
 - **真实启动路径是 `yarn dev`**(= `fes dev` → `.fes.js`:`layout.navigation: null`,无页头);**真实应用环境不使用 predev 启动**(维护者 2026-09-16 明确)。`yarn pre.dev` / `pre.dev.one` 是 TvT 预演站布局(`navigation: 'top'` + `isFixedHeader`,顶部多 54px 固定页头),那里出现的布局异常(如 A12 红)属环境差异。A12 证据里的 `pageHeaderH` 用来分辨这一点(见票 28)。
 - **复跑走查前重启 dev server**:长跑实例可能仍给浏览器发旧模块(懒加载的 `cesiumEngine.js` 曾因此把 A15 判红一次:`widgetChrome: 1`;重启后同一份代码 20/20、`widgetChrome: 0`)。涉及引擎、懒加载模块的走查尤其要先重启。
 - 相关技能:`implement`、`tdd`、`code-review`;TvT.js 侧基线是 `skills/tvt-js-plugin-builder/`。
@@ -32,12 +33,15 @@ Labels: wayfinder:map
 - [26: Dev server 文件监视被 `.scratch` 面板原子写击穿](issues/26-devserver-watch-scratch-crash.md) — 已修复(含重开补验):`.fes.js` 的 dev server watch 忽略 `**/.scratch/**` 与 `**/.dsh-*`(Vite 默认 ignore 合并不覆盖);两个触发源——面板原子写与 DSH 编辑审阅临时文件——均实测不再击穿。
 - [02: MapCanvas 常驻挂载](issues/02-mapcanvas-persistent-mount.md) — 已关闭:新增 `services/mapService.js` 持有实例生命周期(幂等挂载、尺寸上报、离开才销毁)+ 常驻宿主组件 `MapCanvas.vue`;逻辑单测 + 走查 A14 证明「切模式与收放面板都不重建」。
 - [06: 驾驶舱自有静态资产与视觉占位](issues/06-cockpit-static-assets.md) — 已关闭:token 唯一来源 `styles/tokens.css` 挂 `.cockpit-shell`(不挂 `:root`,免漏插件样式)、`public/cockpit/` 自持资产、占位/图标/状态标签三件套(`PanelPlaceholder` / `icons.js`+`CockpitIcon` / `statusTones.js`+`StatusTag`,标签带文字不吃纯色);零依赖脚本 15/15 + 走查 19/19。副产:查清 A12「铺满」的 predev 环境差异(并立票 28),走查脚本加 `pageHeaderH` 与前置条件说明。
-- [27: 接入 Cesium 常驻 Viewer](issues/27-cesium-viewer-integration.md) — 已关闭:`cesium@1.145.0` 固定版本(不进 federation `shared`、进 `manualChunks` 单独分包)+ `src/common/cesiumStaticPlugin.js`(dev/pre.dev 中间件与 build 拷贝四个静态目录,`pre.dev.one` 的 `copyPublicDir:false` 同样生效)+ `services/cesiumAssets.js`(`CESIUM_BASE_URL` 在 import 之前写入,取 `publicPath: './'` / qiankun 注入路径)+ `services/cesiumEngine.js`(唯一 Viewer:控件与默认相机输入全关、`baseLayer:false` 不伪造配准)+ `mapService` 持有引擎生命周期与宿主状态浮标;`yarn dev` 走查 20/20(含强制失败的兜底断言)、`pre.dev.one` 19/20(仅 A12 环境差异红)、`yarn pre.build.one` 日志证明四目录进产物。遗留:本工作区 `fes build` 被两个未跟踪插件缺依赖挡住(`@takram/three-3d-tiles-support`、`leaflet`),cesium 分包暂无产物证据。
+- [27: 接入 Cesium 常驻 Viewer](issues/27-cesium-viewer-integration.md) — 已关闭:`cesium@1.145.0` 固定版本(不进 federation `shared`、进 `manualChunks` 单独分包)+ `src/common/cesiumStaticPlugin.js`(dev/pre.dev 中间件与 build 拷贝四个静态目录,`pre.dev.one` 的 `copyPublicDir:false` 同样生效)+ `services/cesiumAssets.js`(`CESIUM_BASE_URL` 在 import 之前写入,取 `publicPath: './'` / qiankun 注入路径)+ `services/cesiumEngine.js`(唯一 Viewer:控件与默认相机输入全关、`baseLayer:false` 不伪造配准)+ `mapService` 持有引擎生命周期与宿主状态浮标;`yarn dev` 走查 20/20(含强制失败的兜底断言)、`pre.dev.one` 19/20(仅 A12 环境差异红)、`yarn build` exit 0 且产物里 `cesium.*.js` 为 3.97MB 独立懒加载分包、四静态目录进 `dist/cesium/`。连带修掉 build 阻塞:装 `leaflet` 与四个已发布的 `@takram/three-*`,未发布的四个用 `src/common/vendoredPluginLibAliases.js` 别名回插件自带副本(判据是 API 是否对得上,不是有没有包),并给 `build` 脚本补 8GB 堆。
 - [28: 外壳填满所在容器,而不是硬用 100vh](issues/28-shell-fill-container.md) — 已关闭:`.cockpit-shell` 由 `height: 100vh` 改为 `absolute + inset: 0`,贴着最近的定位容器(没有定位祖先时按规范落回初始包含块=视口);氛围层同步由 `fixed` 改 `absolute`,不再盖到宿主页头那一带。零依赖脚本 7/7(旧实现复现「越界 54px + 宿主滚动条」,交付实现容器等高、底边贴合、真·视口 resize 跟随),走查 A12 仍绿。附带确立:真实启动路径是 `yarn dev`,predev 预演站不是真实应用环境。
+- [04: 配置与凭据注入](issues/04-config-and-credentials.md) — 已关闭:四层运行时配置(内置默认 → `VITE_COCKPIT_*` → `<base>cockpit/config.json` → 宿主注入 `__COCKPIT_CONFIG__`)由 `services/cockpitConfig.js` 唯一读取,三态 `ready/missing/failed` 在顶栏可演示(`data-config-state`);`services/cockpitApi.js` 是 mock/real 的唯一出口与唯一附加 `Authorization` 的地方(mock 分支不带凭据),失败一律回 `{ok:false, reason}` 且全仓零登录跳转;默认 mock、real 由部署配置显式开启;`map.cesium.enabled/baseUrl` 已被真消费。逻辑脚本 + 真机走查 9/9,回归 t01 20/20、build exit 0。坑:dev 的 HTML 回退只认 `Accept: text/html`,所以「默认探测配置文件」会刷 404 并打红 A11 —— 改为没有显式 URL 就不发请求。
+- [29: 底图影像(配置驱动)](issues/29-basemap-imagery-config.md) — 已关闭:`map.basemap.source` 四选一 `none`(默认,维持票 27 的不伪造配准)/ `natural-earth`(Cesium 自带离线影像,无需账号外网,实测取到 17 块瓦片 200)/ `tianditu`(tk 由 `map.tianditu.token` 给,开发走 `/tianditu.map` 代理、生产 `t{s}.tianditu.gov.cn`,缺 tk 为明确失败态)/ `xyz`(任意 `{z}/{x}/{y}` 模板,甲方本地瓦片走这条);计划层 `domain/basemap.js` 是纯函数,逻辑脚本 + 真机走查 6/6;默认行为与 T01 20/20、T04 9/9、build 均不回退。维护者「怎么配出基础地图场景」的答案就是改这一个字段。
+- [03: 相机控制契约](issues/03-camera-control-contract.md) — 已关闭(维护者人工验收「T03 验收完毕」):三键契约落在服务层(`domain/camera.js` 纯数学 + `services/cameraControl.js` 状态机与事件绑定 + `cesiumEngine.createCameraEngine()` 翻译 Cesium 方言),逻辑闸门 25/25 + 真机走查 10/10。四次复验共改五处:**平移方向反转为「抓着地图拖」**、**三类交互加帧率无关阻尼**(目标态/实际态双状态,`DAMPING_TAU_MS` 与 DEV 把手 `setCameraDamping` 可现场拖调)、**松手保留惯性**(不再用拾取点改写目标)、**极点保护**(中心硬限 ±85°,承认「经线在极点收敛、东西向失去意义」,避免在奇点上做特判)、**经度走最短弧**(`approachLongitude`;`lerpPivot`/`isSettled`/`pivotDistanceMeters` 同步,消除跨 ±180 的抽搐与绕地球一圈)。工具副产:走查新增「先关进入引导」「HMR 重载即判本轮作废」,并修掉 `projectPivot()` 的坐标口径(Cesium 返回的是画布局部坐标,需加画布原点才是窗口坐标)。
 
 ## Not yet specified
 
-- 甲方本地瓦片、地形与 3D Tiles 的实际交付物清单。
+- 甲方本地瓦片、地形与 3D Tiles 的实际交付物清单 —— **2026-09-17 已落实**:本地 DEM(`yuanlingDEM`,量化网格)与影像切片(`yuanlingPic3`,TMS)已接上并通过走查;**3D Tiles 数据也找到了**(`D:\GIS\GIS\yuanlingSHP`,cesiumlab `model2tiles`,16–20 级 769 分块 + 772 模型),但那批分块**缺根 `tileset.json`、`boundingVolume` 是局部坐标且无 `transform`**,故入口已就绪而数据尚未接上 —— 需重导(勾选生成根 tileset)或提供模型原点,见 `issues/21`。正式部署的操作手册在 **`docs/cockpit-deployment.md`**(改哪个文件、怎么改、8 条自检)。
 - 设计公司视觉终稿如何替换驾驶舱占位 token。
 - 后端补契约的落地时间与责任人(见 `issues/25`)。
 

+ 80 - 9
.scratch/cockpit-shell-panel-layout/verify/t03-camera-walkthrough.mjs

@@ -25,6 +25,8 @@ const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/pupp
 const XIANGLING = { lon: 110.396, lat: 28.455, height: 0 }
 
 const results = []
+/** 页面在走查期间被 HMR 重载时置位:本轮红绿不作数(并发编辑场景)。 */
+let runInvalidated = false
 function check(id, desc, pass, evidence) {
     results.push({ id, desc, pass })
     console.log(`${pass ? 'PASS' : 'FAIL'}  ${id}  ${desc}${evidence ? `  — ${evidence}` : ''}`)
@@ -32,6 +34,8 @@ function check(id, desc, pass, evidence) {
 
 const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
 const round = (n, digits = 3) => Number(n.toFixed(digits))
+/** 角度/经度的最短弧差:heading 是 360 ≡ 0、经度是 ±180 同一点,直接相减会得到假差值。 */
+const shortest = (from, to) => ((to - from + 540) % 360) - 180
 
 const puppeteer = (await import(PUPPETEER)).default
 const browser = await puppeteer.launch({
@@ -81,10 +85,40 @@ try {
      * 等阻尼收敛(实际相机跟上目标)。有了阻尼之后,手势结束不等于画面停稳,
      * 量「中心是否落在画布中心」这类**实际态**几何之前必须先等 settled。
      */
-    const waitSettled = async (timeout = 4000) => {
+    const waitSettled = async (timeout = 5000) => {
         await page.waitForFunction(() => window.__cockpitMap.getCameraState().settled === true, { timeout }).catch(() => {})
     }
 
+    /**
+     * 票 30 起有「进入引导」全屏遮罩:点击即进入,而且**引导期间拖动是转星球、滚轮是缩放星球**
+     * —— 不先关掉它,第一次拖拽会被遮罩吃掉,后续断言测的就成了引导层而不是相机契约。
+     * 所以进入系统、等镜头在主页视角停稳,再开始走查。
+     */
+    const dismissIntro = async () => {
+        const overlay = await page.$('[data-cockpit-intro]')
+        if (!overlay) return false
+        await overlay.click()
+        await page
+            .waitForFunction(() => !document.querySelector('[data-cockpit-intro]'), { timeout: 5000 })
+            .catch(() => {})
+        return true
+    }
+
+    const hadIntro = await dismissIntro()
+    console.log(`进入引导:${hadIntro ? '已关闭(点击进入系统)' : '未出现(配置里未启用)'}`)
+    await waitSettled()
+    await sleep(300)
+
+    // 并发会话在改同仓代码时会触发 Vite HMR 重载页面:一重载,引导层重新出现、dev 把手重建、
+    // 相机回到初始视角 —— 后面所有断言都会变成假失败。这里打个运行标记,收尾时核验;
+    // 不一致就把本轮判为「环境重载、结果作废」,而不是报一堆红。
+    const runTag = await page.evaluate(() => {
+        const tag = `t03-${Math.random().toString(36).slice(2)}`
+        window.__t03RunTag = tag
+        return tag
+    })
+    const pageStillSameRun = () => page.evaluate((tag) => window.__t03RunTag === tag, runTag)
+
     // ---------- A1 就绪与状态可读 ----------
     const base = await readState()
     check(
@@ -111,17 +145,20 @@ try {
     const a2after = await readState()
 
     {
-        const dEast = a2after.pivot.lon - a2before.pivot.lon
+        // 经度差按最短弧取:跨 ±180 时直接相减会得到 ±359 的假位移,让方向判断整个反过来。
+        const dEast = shortest(a2before.pivot.lon, a2after.pivot.lon)
         const dNorth = a2after.pivot.lat - a2before.pivot.lat
         // 相机右手方向(东北分量):heading 上「前方」=(sin h, cos h),右手 = (cos h, -sin h)。
         // 维护者 2026-09-17 裁定:**抓着地图拖** —— 拖右 → 画面右移、相机左移,
         // 所以中心沿相机右手的**反方向**移动(alongRight 应为负)。
         const h = (a2before.hpr.heading * Math.PI) / 180
         const alongRight = dEast * Math.cos(h) + dNorth * -Math.sin(h)
+        // heading 是圆周量:360 与 0 是同一个角度,必须按最短弧比较(此前直接用减法,360→0 会被误判成变了 360°)。
+        const headingDrift = Math.abs(shortest(a2before.hpr.heading, a2after.hpr.heading))
         check(
             'A2-left-drag-pans-only',
             '左键拖拽只平移:heading/pitch/range 不变,中心沿「抓地图」方向移动(默认左键旋转已被替换)',
-            Math.abs(a2after.hpr.heading - a2before.hpr.heading) < 0.05 &&
+            headingDrift < 0.05 &&
                 Math.abs(a2after.hpr.pitch - a2before.hpr.pitch) < 0.05 &&
                 Math.abs(a2after.hpr.range - a2before.hpr.range) < 1 &&
                 Math.abs(dEast) + Math.abs(dNorth) > 1e-6 &&
@@ -129,6 +166,7 @@ try {
             JSON.stringify({
                 hBefore: round(a2before.hpr.heading),
                 hAfter: round(a2after.hpr.heading),
+                headingDrift: round(headingDrift),
                 pivotBefore: a2before.pivot,
                 pivotAfter: a2after.pivot,
                 direction: alongRight < 0 ? '沿相机左手方向(画面右移)' : '方向反了',
@@ -309,9 +347,17 @@ try {
 
         const samples = []
         const began = performance.now()
-        while (performance.now() - began < 2000) {
+        // 采样窗要够长:换中心飞行 700ms + 阻尼把缺口从 ~0.02° 收敛到阈值 1e-7° 还要
+        // τ·ln(0.02/1e-7) ≈ 1.3s,合计约 2s —— 之前的 2000ms 窗口刚好卡在边界上会假失败。
+        while (performance.now() - began < 6000) {
             const s = read()
-            samples.push({ t: Math.round(performance.now() - began), settled: s.settled, flying: s.flying, lon: s.pivot.lon, shownLon: s.shownPivot ? s.shownPivot.lon : null })
+            samples.push({
+                t: Math.round(performance.now() - began),
+                settled: s.settled,
+                flying: s.flying,
+                lon: s.pivot.lon,
+                shownLon: s.shownPivot ? s.shownPivot.lon : null,
+            })
             if (s.settled) break
             await new Promise((r) => setTimeout(r, 30))
         }
@@ -323,27 +369,52 @@ try {
         const last = s[s.length - 1]
         // 实际态必须**滞后于**目标态:中途至少采样到一次 shown != target(否则就是瞬移,没有阻尼)。
         const lagged = s.some((x) => x.shownLon !== null && Math.abs(x.shownLon - x.lon) > 1e-9)
-        // 且滞后是收敛的:缺口单调缩小到 0。
-        const gaps = s.filter((x) => x.shownLon !== null).map((x) => Math.abs(x.lon - x.shownLon))
+        const flyingFrames = s.filter((x) => x.flying).length
+
+        // 单调收敛只对「换中心飞行结束之后」那一段成立:飞行期间目标自己在动(缓动推进),
+        // 目标-实际缺口会先被拉开再收拢,拿全程做单调判断会误判成「阻尼没在收敛」。
+        const afterFlight = s.filter((x) => !x.flying && x.shownLon !== null)
+        const gaps = afterFlight.map((x) => Math.abs(x.lon - x.shownLon))
         const shrinking = gaps.every((g, i) => i === 0 || g <= gaps[i - 1] + 1e-12)
+
         check(
             'A10-damping-is-live',
-            '阻尼在真机上生效:实际相机滞后目标态、缺口单调收敛,最终 settled 且吸附到目标',
+            '阻尼在真机上生效:飞行期间实际态滞后、飞行后缺口单调收敛到 0,最终 settled 且吸附到目标',
             lagged &&
+                flyingFrames >= 1 &&
                 gaps.length >= 2 &&
                 shrinking &&
                 last.settled === true &&
                 Math.abs(last.lon - XIANGLING.lon) < 1e-6,
-            JSON.stringify({ frames: s.length, lagged, firstGap: round(gaps[0] || 0, 6), lastGap: round(gaps[gaps.length - 1] || 0, 6) }),
+            JSON.stringify({
+                frames: s.length,
+                flyingFrames,
+                afterFlightFrames: gaps.length,
+                lagged,
+                firstGap: round(gaps[0] || 0, 6),
+                lastGap: round(gaps[gaps.length - 1] || 0, 6),
+                lastLon: round(last.lon, 6),
+                settled: last.settled,
+            }),
         )
     }
 
     check('A9-no-page-errors', '走查期间无页面级报错', pageErrors.length === 0, JSON.stringify(pageErrors.slice(0, 3)))
+
+    // 收尾核验:页面若在走查期间被 HMR 重载,上面的红绿都不作数。
+    const sameRun = await pageStillSameRun()
+    if (!sameRun) {
+        runInvalidated = true
+    }
 } finally {
     await browser.close()
 }
 
 const failed = results.filter((r) => !r.pass)
+if (runInvalidated) {
+    console.log('\n⚠ 页面在走查期间被重载(并发编辑触发 Vite HMR)——本轮结果作废,请在没有其它会话改文件时重跑。')
+    process.exit(2)
+}
 console.log(
     `\nT03 浏览器走查:${results.length - failed.length}/${results.length} 项通过${failed.length ? `,未通过:${failed.map((r) => r.id).join(', ')}` : ''}`,
 )