03-camera-control-contract.md 11 KB

03: 相机控制契约

What to build: 实现驾驶舱三维地图的显式相机控制:左键拖拽平移、右键绕中心旋转、滚轮调整弹簧臂距离,并替换 Cesium 默认交互。

Blocked by: 02, 27

依赖说明(2026-09-16 维护者确认):三键相机契约必须落在真实引擎上,故新增票 27(接入 Cesium 常驻 Viewer)作为本票前置。

Status: claimed Labels: ready-for-agent

契约来源

ADR 0008 与决策地图票 Spring-arm orbit pivot:三键分工、旋转中心 = 选中对象地表点(未选中时 = 屏幕中心的地面拾取点)、右键按下锁定中心、取消选中不回退、只有左键平移才把中心交回地面拾取点、换中心时保持 heading 与 range。弹簧臂原语锁定为 camera.lookAtTransform(中心东北天局部系, HeadingPitchRange),不用 Tres OrbitControls、不用 vue-cesium 的鼠标绑定。

验收

  • 鼠标左键拖拽只执行摄像机平移,不旋转、不缩放。 —— 逻辑 1/2 绿;真机 A2 待跑。
  • 鼠标右键拖拽绕旋转中心执行轨道旋转;右键按下时锁定中心,拖拽中不重新拾取。 —— 逻辑 3/5 绿;真机 A3 待跑。
  • 滚轮只改变摄像机与旋转中心的距离。 —— 逻辑 6 绿;真机 A4 待跑。
  • 选中监测点/设备时中心为该对象地表点;未选中时为地面拾取点;取消选中不回退中心。 —— 逻辑 7 绿;真机 A5/A6/A7/A8 待跑。
  • 从无选中到选中时镜头平滑换中心,保持 heading 与 range。 —— 逻辑 8 绿;真机 A6 待跑。
  • Cesium 默认左键旋转、滚轮缩放等行为被替换。 —— createViewer() 里逐项关闭(enableRotate/Zoom/Tilt/Look/Translate),左键平移/滚轮距离由本票接管;真机 A2/A4 待跑。
  • 相机控制集中在地图服务层。 —— domain/camera.js(纯数学)+ services/cameraControl.js(状态机与 DOM 绑定)+ services/cesiumEngine.js(Cesium 方言翻译)+ services/mapService.js(生命周期与对外 API);组件与外壳未改。
  • 三类交互各有可重复演示路径。 —— 见下面「演示路径」一节。

维护者复验与修改(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 = 不做阻尼、直接跟手),τ 越小越跟手、越大越飘 —— 「丝滑」是主观量,控制台拖调比改代码快。

改动文件: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。

进度: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/窗口尺寸变化都不用改灵敏度。
  • 平移方向由 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 → 14/14 通过(左键只平移且方向为「抓着地图拖」、平移方向在 heading=0/90 两点验证、右键只旋转且 range/中心不动、pitch 夹限、拖拽中锁定中心且松手后才换中心、滚轮只改距离并夹限、中心三规则、换中心 8 帧单调且 h/r 全程不变、阻尼渐入不超调 87 帧停稳并吸附、阻尼与帧率无关(60fps 83.8% vs 120fps 83.8%)、用户操作打断飞行、阻尼可拖调(τ=40 比 τ=400 跟手 9 倍,τ=0 一帧贴齐)、DOM 绑定与默认行为抑制、状态串)。
  • 回归: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 事件被上游吞掉、中心投影偏移),据实回调进度并写明根因。