# 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] 三类交互各有可重复演示路径。 —— 见下面「演示路径」一节。 ## 进度:90% ### 交付物 - `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/窗口尺寸变化都不用改灵敏度。 - **ADR 0008 那句「拖拽方向映射为摄像机移动方向」落地为「相机跟着拖」**(拖右 → 相机右移 → 画面左移)。若维护者要改成「抓着地图拖」的手感,改 `PAN_FOLLOWS_DRAG` 一个常量即可,逻辑与走查都跟着它走。 - **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` → **10/10 通过**(左键只平移并按像素比例移动中心、平移方向 = 相机移动方向、右键只旋转且 range/中心不动、pitch 夹限、拖拽中锁定中心且松手后才换中心、滚轮只改距离并夹限、中心三规则、换中心 10 帧单调且 h/r 全程不变、DOM 绑定与默认行为抑制、状态串)。 - 回归:`t01-panel-catalog.mjs` 通过(`getLifecycleStats` 新增 `camera` 字段,原字段语义未变)、`t06-assets.mjs` 15/15。 - 真机:**待跑**(见下一步)。 ## 下一步(待确认什么) 真机走查 9 项还没跑 —— 需要 `http://localhost:8000` 上的 `yarn dev`(维护者自行起服务,项目规则下我不自动起)。服务起来后执行: ``` node .scratch/cockpit-shell-panel-layout/verify/t03-camera-walkthrough.mjs ``` 把结果补进「验收证据」并把进度提到 100% 再 close。若真机暴露出与逻辑不一致的地方(例如 Cesium 事件被上游吞掉、中心投影偏移),据实回调进度并写明根因。