# 03: 相机控制契约 **What to build:** 实现驾驶舱三维地图的显式相机控制:左键拖拽平移、右键绕中心旋转、滚轮调整弹簧臂距离,并替换 Cesium 默认交互。 Blocked by: 02, 27 > 依赖说明(2026-09-16 维护者确认):三键相机契约必须落在真实引擎上,故新增票 27(接入 Cesium 常驻 Viewer)作为本票前置。 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 绿(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] 三类交互各有可重复演示路径。 —— 见下面「演示路径」一节。 ## 维护者复验与修改(2026-09-17,相机运动不符合预期) 维护者目视复验后给出两条修改意见,均已落地: 1. **平移方向反转**:原实现按 ADR 0008 的字面「拖拽方向映射为摄像机移动方向」做成「相机跟着拖」(拖右 → 相机右移、画面左移);维护者裁定改为**抓着地图拖**(拖右 → 画面右移、相机左移)。落地方式是 `domain/camera.js` 的 `PAN_FOLLOWS_DRAG = false` 一处开关,方向语义集中在 `pivotOffsetMeters()`。**ADR 0008 已同步改写**,否则下个会话照旧文读会把方向改回去。 2. **三类交互加阻尼**:要求「移动和旋转时更丝滑」。实现为**目标态 / 实际态双状态**——输入立刻写 `target`(不丢输入、位移量与方向仍可精确断言),相机 `shown` 按 `1 − e^(−dt/τ)` 朝目标收敛(`DAMPING_TAU_MS = 110`),差到 `SETTLE_EPSILON` 以内吸附到目标。这个系数**与帧率无关**(同样真实时长,60fps 与 144fps 收敛比例一致),起点也不是写死的每帧毫秒而是排帧时刻的真实时间戳。`shown/target/settled` 都进了 `getState()` 与 `data-camera`,因此「阻尼是否真的在生效」可被断言而不只是手感描述。 3. **手感现场可调**:DEV 把手加 `__cockpitMap.setCameraDamping(毫秒)`(`0` = 不做阻尼、直接跟手),τ 越小越跟手、越大越飘 —— 「丝滑」是主观量,控制台拖调比改代码快。 4. **平移松手有惯性**(2026-09-17 第二次复验反馈):原实现里 `endDrag` 会把目标改写为「屏幕中心的地面拾取点」,而阻尼攒下的滞后正好等于这段位移——目标被拽回当前实际位置,滞后当场清零,表现为**松手即停、没有惯性**;滚轮与右键没有这一步改写,所以会继续滑到目标,三种交互手感不一致。修法:松手只做**条件重锚**——偏差 ≤ `range × REANCHOR_LIMIT_RATIO`(1%,即肉眼不可见)时才把中心重锚到拾取点(顺手清掉等距近似的累积误差),偏差过大就保持推算值,宁可不重锚也不让画面跳。于是松手后目标不再被改写,惯性由阻尼自然延续。 5. **极点保护**(2026-09-17 第三次复验反馈:视角移到南北极点时整个地球异常旋转)。实测复现(新增真机探针 `verify/t03-pole-probe.mjs`):中心送到 ±89.5° 附近再朝极点拖,纬度会跑到 -96 / -168 这类**非法值**(`Cartesian3.fromDegrees` 不校验纬度),且经度在 0 与 -180 之间**无限来回** —— 后者正是异常旋转的直接来源。根因有两层:等距圆柱近似的 `cos(lat)→0` 让经度增量爆走;改成精确球面大圆后,其 `bearing` 是方位角,极点附近「向北」会绕到对面、经度翻转 180° 并逐帧来回(叠加「折回 / 夹到界上」两种补救都残留 2 周期环)。**最终方案是承认奇点并从根上避开**(维护者指出「纬线在极点收敛,东/西在极点失去意义」):`POLAR_LAT_LIMIT = 85` 硬限中心纬度(±85° 处 ENU 系完全良态,奇点只在 ±90°),平移回到「纬度线性 + 夹限、经度按 `cos(min(|lat|,60°))` 换算」的单调有界映射;`sanitizePivot()` 统一收口所有入口(拾取点/选中点/外部点),引擎侧 `frameAt()` 再夹一次作第二道防线。代价:极点带内不再严格等距(东西向偏快),但那里不是驾驶舱观察区。 6. **经度最短弧**(2026-09-17 第四次复验反馈:到达经度 0 的区域时会抽搐后旋转一周到达另一边)。根因:经度是圆周量,但 `[-180,180)` 的归一化掩盖了这一点 —— `approachPivot()`(阻尼收敛)与 `lerpPivot()`(换中心飞行)都对经度做**线性插值**,于是 `179.9 → -179.9` 会沿长边穿过经度 0 走 359.8°,相机绕地球一整圈才到对面;`isSettled()` 用绝对差比较还会把跨 ±180 的极小差值误判成「差 360°、未停稳」,于是持续抽搐;`pivotDistanceMeters()` 同样线性相减,让「松手重锚」在反经线附近永远判成偏差过大而失效。修法:新增 `approachLongitude()`,经度插值统一先取**最短弧增量**(与 `heading` 同一个 `shortestAngleDelta`)再归一化;`lerpPivot`、`isSettled`、`pivotDistanceMeters` 一并改走最短弧。 改动文件:`src/modules/cockpit/domain/camera.js`、`src/modules/cockpit/services/cameraControl.js`、`src/modules/cockpit/services/mapService.js`、`docs/adr/0008-web-cockpit-camera-controls.md`、`verify/t03-camera-contract.mjs`、`verify/t03-camera-walkthrough.mjs`。 提交情况(2026-09-17):方向反转 + 阻尼一次提交,平移惯性修正紧随其后一次(`git log` 里搜「松手有惯性」)。`mapService.js` 里那一行 `__cockpitMap.setCameraDamping` 把手**故意没随这两次提交** —— 该文件当时还带着并发会话「底图/配置」票的未提交改动(引用尚未提交的 `domain/basemap.js`、`services/cockpitConfig.js` 等),单独提交会产出引用缺失模块的树。维护者裁定:**等那一路落地后随它一起提交**(那行改动内容极小,落地前 `setCameraDamping` 只影响 DEV 调参便利性,不影响契约本身)。 ## 进度:100% **已通过维护者人工验收(2026-09-17「T03 确认验收完毕」)。** 实现、逻辑闸门(25/25)与真机走查(10/10)全绿。期间维护者四次复验共提了四条修改意见(方向反转、加阻尼、松手惯性、极点异常旋转)与一条经度最短弧,全部落地并各自带可复现的断言。 ### 交付物 - `src/modules/cockpit/domain/camera.js`:三键的纯逻辑 —— `orbit()`(右键)、`zoomByWheel()`(滚轮)、`metersPerPixel()` + `pivotOffsetMeters()` + `shiftPivot()`(平移)、`flightProgress()` + `lerpPivot()`(换中心动画)、pitch 上下限与 range 上下限、`formatCameraState()`(状态串)。不 import cesium、不碰 DOM。 - `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`: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)。 ### 设计要点 - **平移**按「中心点所在距离上的垂直可视高度」换算像素→米(`2·range·tan(fovY/2)/viewportH`),与 pitch 无关,4K/窗口尺寸变化都不用改灵敏度。 - **平移方向**由 `PAN_FOLLOWS_DRAG` 一处开关决定(当前 `false` = 抓着地图拖,维护者 2026-09-17 裁定);方向语义集中在 `pivotOffsetMeters()`,逻辑与走查都跟着它走。 - **阻尼**是「目标态/实际态」双状态:`target` 跟手、`shown` 收敛;系数 `1 − e^(−dt/τ)` 与帧率无关,帧间隔取自排帧时刻的真实时间戳(不是写死的每帧毫秒)。手感旋钮 `DAMPING_TAU_MS`(默认 110ms)+ DEV 把手 `setCameraDamping()`。 - **pitch 上下限**先给地面视图 `[-85, -5]`;ADR 里「管线视图允许平视或轻微仰视」的放宽(`[-85, 5]`)已在常量里备好,等视图预设那一票接(不属本票)。 - **初始视角不设预设**:本票只管控制语义,挂载时按当前真实相机反读 HPR 对齐状态,因此第一次交互不会跳变;「默认全球视角」观感收口仍是维护者待定项(票 27 遗留里也记着)。 - 中心缺失(看向天空、拾不到椭球)时不动相机,等下一次可拾取再建立中心。 ### 演示路径(三类交互,均可重复) 前置:`yarn dev` → 打开 `/#/cockpit`,引擎就绪后宿主上会有 `data-camera="h=… p=… r=… pivot=… focus=ground drag=none"`。 1. **左键拖拽(平移)**:在中央一张图上按住左键横向拖 —— `pivot` 变化、`h/p/r` 不变,画面随之平移(不再旋转)。 2. **右键拖拽(轨道旋转)**:按住右键横向/纵向拖 —— `h` 随横向像素、`p` 随纵向像素变化,`r` 与 `pivot` 不动;拖拽中松开前不会有任何拾取。 3. **滚轮(弹簧臂)**:滚轮上下 —— 只有 `r` 变化,`h/p/pivot` 不动。 4. **选中换中心**(等监测点/设备详情票接 UI 前可用控制台):`__cockpitMap.setCameraFocus({ lon: 110.396, lat: 28.455 })` —— 中心平滑飞到该点,全程 `h/r` 不变;`__cockpitMap.clearCameraFocus()` 中心不回退。 ### 验收证据 - 逻辑:`node .scratch/cockpit-shell-panel-layout/verify/t03-camera-contract.mjs` → **25/25 通过**(左键只平移且方向为「抓着地图拖」、平移方向在 heading=0/90 两点验证、右键只旋转且 range/中心不动、pitch 夹限、拖拽中锁定中心且松手后才换中心、滚轮只改距离并夹限、中心三规则、换中心 8 帧单调且 h/r 全程不变、**阻尼渐入不超调 87 帧停稳并吸附**、**阻尼与帧率无关(60fps 83.8% vs 120fps 83.8%)**、**平移松手有惯性(松手时滞后 0.0383°,松手后又滑行 0.0329°、85 帧停稳)**、**滚轮与右键同样有余韵(range 滞后 4410m / heading 滞后 48.1°)**、**条件重锚(1% 内才重锚、大偏差不跳画面)**、**极点保护三组(界上朝极点拖落点恒为 12.5000,85.0000;朝极点推进 60 步停在界上且最大经度漂移 0.000000000°;跨 ±180 经线归一)**、**经度最短弧五组(换中心/阻尼/反向/停稳判据/距离判据跨 ±180 都走短弧:换中心总弧长 0.200°、阻尼 0.0197°、反经线 0.2° 距离与赤道同量级 22264m)**、用户操作打断飞行、**阻尼可拖调(τ=40 比 τ=400 跟手 9 倍,τ=0 一帧贴齐)**、DOM 绑定与默认行为抑制、状态串)。 - 真机极点复验:`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。 - **真机走查:`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,维护者裁定暂缓) 维护者指出:GIS 侧还没有可视的 3D Tiles / 地形 / 倾斜摄影,**肉眼无法判断相机运动是否符合预期**。讨论过三层补强: - **数值与解析对照**(零新依赖):用 `camera.positionWC / directionWC / upWC` 加 `SceneTransforms` 断言朝向不变性(平移/滚轮期间朝向恒定)、中心始终钉在屏幕中心(≤1 px)、并用「期望相机位置 = 中心 ENU 系里 `r·(cos p·sin h, cos p·cos h, −sin p)`、期望 up 由 HPR 转出、期望平移位移 = 右手×dx+前方×(−dy)」做闭式解对照 —— 抓符号、坐标系、帧、roll/up 漂移这类错误,**不需要任何可见内容**。 - **DEV-only 相机标定场景**:Cesium 自带的 `GridImageryProvider` / `TileCoordinatesImageryProvider`(无需 Ion token、无需外网)让球面可见,再叠纯 `Entity` 的 1 km 方格、焊在旋转中心上的圆盘与十字、200 m 高度塔、北/东标签与实时 HUD,把「肉眼判断」升级成「地标投影像素断言 + 截图归档」;默认关闭、`import.meta.env.DEV` 守卫、不进生产。 - 维护者裁定:**暂都不做**,底图另起任务添加(肉眼判断待底图到位后再谈)。故本票不扩验证范围。 记下来的原因:底图/真数据到位后,上面两套断言可直接复用来做回归(标定场景与真场景换内容、不换断言);本 effort 里承接正式数据的是票 21(3D Tiles / 地形基线)。 ## 结票 维护者 2026-09-17 确认「T03 验收完毕」,本票关闭。复跑方式(需要 `http://localhost:8000` 上的 `yarn dev`,按项目规则不自动起服务): ``` 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 # 真机反经线专项 ``` 遗留说明(不阻塞结票,已另处跟踪): - `mapService.js` 里 `__cockpitMap.setCameraDamping` 那一行、以及 `cesiumEngine.js` 里 `projectPivot()` 的坐标口径修正(加上画布原点,返回真正的窗口坐标),都按维护者裁定**随「底图/3D Tiles」那一路一起提交**:这两个文件当时带着并发会话的未提交改动(引用尚未提交的 `domain/basemap.js`、`services/cockpitConfig.js` 等),单独提交会产出引用缺失模块的树。两者都只影响 DEV 调参/走查,不影响相机契约本身;真机走查 10/10 是在含这两处修正的工作区上跑出来的。 - 「初始视角不设预设」已由票 30(进入引导 + 主页视角预设:沅陵县城、俯角 -55°、12 km)接手,本票只保证控制语义;两者相接处(引导期间的拖动/滚轮归引导,进入系统后归三键契约)已由本票真机走查覆盖。 - 极点带内不追求严格等距(东西向偏快、朝极点拖会顶住),±85° 远在沅陵之外;若将来要支持极区绕行,需要把中心改为三维笛卡尔点,属结构性改动,单开票。