# 03: 相机控制契约 **What to build:** 实现驾驶舱三维地图的显式相机控制:左键拖拽平移、右键绕中心旋转、滚轮调整弹簧臂距离,并替换 Cesium 默认交互。 Blocked by: 02, 27 > 依赖说明(2026-09-16 维护者确认):三键相机契约必须落在真实引擎上,故新增票 27(接入 Cesium 常驻 Viewer)作为本票前置。 Status: claimed Labels: ready-for-agent ## 契约来源 [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] 相机控制集中在地图服务层。 —— `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()` 再夹一次作第二道防线。代价:极点带内不再严格等距(东西向偏快),但那里不是驾驶舱观察区。 改动文件:`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 调参便利性,不影响契约本身)。 ## 进度:95% 维护者已明确:**解决平移惯性问题后由其人工验收,验收通过即算本票验收通过**。实现与逻辑闸门(17/17)全绿,待人工验收 + 真机走查。 ### 交付物 - `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`:10 项逻辑断言(假引擎)。 - `verify/t03-camera-walkthrough.mjs`:9 项真机断言(真 Cesium + `page.mouse` 真实输入事件)。 ### 设计要点 - **平移**按「中心点所在距离上的垂直可视高度」换算像素→米(`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` → **20/20 通过**(左键只平移且方向为「抓着地图拖」、平移方向在 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 经线归一)**、用户操作打断飞行、**阻尼可拖调(τ=40 比 τ=400 跟手 9 倍,τ=0 一帧贴齐)**、DOM 绑定与默认行为抑制、状态串)。 - 真机极点复验:`node .scratch/cockpit-shell-panel-layout/verify/t03-pole-probe.mjs` → 南极段 12 次向南拖经度恒 0.000、纬度恒 -85.000;北极段单调下降、无经度跳变(修复前经度在 0 与 -180 之间无限来回)。 - 回归:`t01-panel-catalog.mjs` 通过(`getLifecycleStats` 新增 `camera` 字段,原字段语义未变)、`t06-assets.mjs` 15/15。 - 真机:**待跑**(见下一步)。脚本已按新方向与阻尼更新:A2 改为断言中心沿**相机左手**方向移动、新增 A10「阻尼在真机上生效」(实际态滞后目标态且缺口单调收敛),并在量几何前统一等 `settled`(有阻尼后手势结束不等于画面停稳)。 ## 验证方案讨论(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 / 地形基线)。 ## 下一步(待确认什么) 真机走查 9 项还没跑 —— 它**全是数值断言,不依赖可见内容**,所以底图未就位也可以跑。需要 `http://localhost:8000` 上的 `yarn dev`(维护者自行起服务,项目规则下我不自动起)。服务起来后执行: ``` node .scratch/cockpit-shell-panel-layout/verify/t03-camera-walkthrough.mjs ``` 把结果补进「验收证据」并把进度提到 100% 再 close。若真机暴露出与逻辑不一致的地方(例如 Cesium 事件被上游吞掉、中心投影偏移),据实回调进度并写明根因。