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 与决策地图票 Spring-arm orbit pivot:三键分工、旋转中心 = 选中对象地表点(未选中时 = 屏幕中心的地面拾取点)、右键按下锁定中心、取消选中不回退、只有左键平移才把中心交回地面拾取点、换中心时保持 heading 与 range。弹簧臂原语锁定为 camera.lookAtTransform(中心东北天局部系, HeadingPitchRange),不用 Tres OrbitControls、不用 vue-cesium 的鼠标绑定。
createViewer() 里逐项关闭(enableRotate/Zoom/Tilt/Look/Translate),左键平移/滚轮距离由本票接管;真机 A2/A4 绿。domain/camera.js(纯数学)+ services/cameraControl.js(状态机与 DOM 绑定)+ services/cesiumEngine.js(Cesium 方言翻译)+ services/mapService.js(生命周期与对外 API);组件与外壳未改。维护者目视复验后给出两条修改意见,均已落地:
domain/camera.js 的 PAN_FOLLOWS_DRAG = false 一处开关,方向语义集中在 pivotOffsetMeters()。ADR 0008 已同步改写,否则下个会话照旧文读会把方向改回去。target(不丢输入、位移量与方向仍可精确断言),相机 shown 按 1 − e^(−dt/τ) 朝目标收敛(DAMPING_TAU_MS = 110),差到 SETTLE_EPSILON 以内吸附到目标。这个系数与帧率无关(同样真实时长,60fps 与 144fps 收敛比例一致),起点也不是写死的每帧毫秒而是排帧时刻的真实时间戳。shown/target/settled 都进了 getState() 与 data-camera,因此「阻尼是否真的在生效」可被断言而不只是手感描述。__cockpitMap.setCameraDamping(毫秒)(0 = 不做阻尼、直接跟手),τ 越小越跟手、越大越飘 —— 「丝滑」是主观量,控制台拖调比改代码快。平移松手有惯性(2026-09-17 第二次复验反馈):原实现里 endDrag 会把目标改写为「屏幕中心的地面拾取点」,而阻尼攒下的滞后正好等于这段位移——目标被拽回当前实际位置,滞后当场清零,表现为松手即停、没有惯性;滚轮与右键没有这一步改写,所以会继续滑到目标,三种交互手感不一致。修法:松手只做条件重锚——偏差 ≤ range × REANCHOR_LIMIT_RATIO(1%,即肉眼不可见)时才把中心重锚到拾取点(顺手清掉等距近似的累积误差),偏差过大就保持推算值,宁可不重锚也不让画面跳。于是松手后目标不再被改写,惯性由阻尼自然延续。
极点保护(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() 再夹一次作第二道防线。代价:极点带内不再严格等距(东西向偏快),但那里不是驾驶舱观察区。
经度最短弧(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 调参便利性,不影响契约本身)。
已通过维护者人工验收(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()。[-85, -5];ADR 里「管线视图允许平视或轻微仰视」的放宽([-85, 5])已在常量里备好,等视图预设那一票接(不属本票)。前置:yarn dev → 打开 /#/cockpit,引擎就绪后宿主上会有 data-camera="h=… p=… r=… pivot=… focus=ground drag=none"。
pivot 变化、h/p/r 不变,画面随之平移(不再旋转)。h 随横向像素、p 随纵向像素变化,r 与 pivot 不动;拖拽中松开前不会有任何拾取。r 变化,h/p/pivot 不动。__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 引入「进入引导」全屏遮罩后,走查做了三处适配,都记在这里以免后人误读成回归:
projectPivot() 用 Cesium 的 worldToWindowCoordinates 时,实测返回的是相对画布左上角的值,与 getBoundingClientRect() 差一个画布原点偏移(走查里表现为「中心点没落在画布中心」)。现在显式加上画布原点,返回真正的窗口坐标。维护者指出: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 漂移这类错误,不需要任何可见内容。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 是在含这两处修正的工作区上跑出来的。