Sfoglia il codice sorgente

feat(cockpit): T03 三键相机契约(左键平移 / 右键轨道旋转 / 滚轮弹簧臂)

- `domain/camera.js`:三键纯逻辑 —— 平移像素→米按「中心点所在距离上的垂直可视高度」
  换算、轨道旋转与 pitch 夹限、滚轮指数改距离并夹限、换中心缓动插值、状态串;
  不 import cesium、不碰 DOM,因此能脱离浏览器断言。
- `services/cameraControl.js`:中心/弹簧臂/拖拽态/锁定中心/飞行的状态机,加
  `attachCameraInput()` 事件绑定(左右键 pointer、滚轮 `passive:false` + preventDefault、
  右键菜单 preventDefault、setPointerCapture);引擎以接口注入。
- `services/cesiumEngine.js`:新增 `createCameraEngine()`,把契约翻译成 Cesium 方言
  (`lookAtTransform` + `HeadingPitchRange`、`readView` 反读、`pickGroundAtCenter` 椭球求交、
  `projectPivot` 投回画布、viewport/fovY/帧调度);本文件仍是全仓唯一 `import 'cesium'` 之处。
- `services/mapService.js`:相机归服务层 —— 引擎就绪后挂控制、卸载时先摘事件再销毁实例;
  对外只暴露 `setCameraFocus/clearCameraFocus/getCameraState`;宿主写 `data-camera` 状态串;
  DEV 下挂 `window.__cockpitMap` 把手供演示与走查(生产构建不含)。
- 契约口径来自 ADR 0008 + 决策地图票 04:选中对象地表点优先、未选中为屏幕中心地面拾取点、
  右键按下锁定中心、取消选中不回退、只有左键平移才把中心交回地面、换中心保持 heading 与 range。
- 证据:`verify/t03-camera-contract.mjs` 10/10;回归 `t01-panel-catalog` 通过(`getLifecycleStats`
  新增 `camera` 字段,原字段语义不变)、`t06-assets` 15/15。真机走查
  `verify/t03-camera-walkthrough.mjs`(9 项)未跑,票面进度 90% 并写明待办。

注:ADR「拖拽方向映射为摄像机移动方向」落地为「相机跟着拖」(`PAN_FOLLOWS_DRAG` 一处常量
可翻转);初始视角不设预设(属视图预设票),挂载时按真实相机反读 HPR 以免首次交互跳变。
Kazerin 1 mese fa
parent
commit
71c55bcc30

+ 57 - 11
.scratch/cockpit-shell-panel-layout/issues/03-camera-control-contract.md

@@ -6,18 +6,64 @@ Blocked by: 02, 27
 
 > 依赖说明(2026-09-16 维护者确认):三键相机契约必须落在真实引擎上,故新增票 27(接入 Cesium 常驻 Viewer)作为本票前置。
 
-Status: ready-for-agent
+Status: claimed
 Labels: ready-for-agent
 
-- [ ] 鼠标左键拖拽只执行摄像机平移,不旋转、不缩放。
-- [ ] 鼠标右键拖拽绕旋转中心执行轨道旋转;右键按下时锁定中心,拖拽中不重新拾取。
-- [ ] 滚轮只改变摄像机与旋转中心的距离。
-- [ ] 选中监测点/设备时中心为该对象地表点;未选中时为地面拾取点;取消选中不回退中心。
-- [ ] 从无选中到选中时镜头平滑换中心,保持 heading 与 range。
-- [ ] Cesium 默认左键旋转、滚轮缩放等行为被替换。
-- [ ] 相机控制集中在地图服务层。
-- [ ] 三类交互各有可重复演示路径。
+## 契约来源
 
-## 进度:0%
+[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 的鼠标绑定。
 
-未动工。等待按 Blocked by 依赖顺序实施。
+## 验收
+
+- [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 事件被上游吞掉、中心投影偏移),据实回调进度并写明根因。

+ 333 - 0
.scratch/cockpit-shell-panel-layout/verify/t03-camera-contract.mjs

@@ -0,0 +1,333 @@
+/**
+ * T03 逻辑检查:三键相机契约与旋转中心规则(票 03 / ADR 0008)。
+ *
+ * 仓库没有测试运行器,且项目约定不为验证引入新框架,所以这里用零依赖 Node 断言脚本:
+ * 相机语义全在 `domain/camera.js`(纯数学)与 `services/cameraControl.js`(状态迁移,引擎依赖注入),
+ * 因此可以用**假引擎**驱动整套交互,不需要浏览器、不需要 cesium。
+ * 真机行为(Cesium 默认输入确实被替换、中心确实落在屏幕中心)由 t03-camera-walkthrough.mjs 覆盖。
+ *
+ * 运行:node .scratch/cockpit-shell-panel-layout/verify/t03-camera-contract.mjs
+ */
+import assert from 'node:assert/strict'
+import { FOCUS_FLIGHT_MS, PITCH_LIMITS, RANGE_LIMITS } from '../../../src/modules/cockpit/domain/camera.js'
+import * as camera from '../../../src/modules/cockpit/domain/camera.js'
+import { attachCameraInput, createCameraControl } from '../../../src/modules/cockpit/services/cameraControl.js'
+
+const GROUND = { lon: 110.39, lat: 28.46, height: 0 }
+const XIANGLING = { lon: 110.396, lat: 28.455, height: 0 } // 沅陵某监测点
+
+function createFakeEngine({ viewport = { width: 1680, height: 900 }, fovY = Math.PI / 3, ground = GROUND } = {}) {
+    const applied = []
+    let clock = 1000
+    let pendingFrame = null
+
+    return {
+        applied,
+        engine: {
+            applyView(pivot, hpr) {
+                applied.push({ pivot: { ...pivot }, hpr: { ...hpr } })
+            },
+            readView() {
+                return { heading: 0, pitch: -60, range: 10000 }
+            },
+            pickGroundAtCenter() {
+                return { ...ground }
+            },
+            projectPivot() {
+                return { x: viewport.width / 2, y: viewport.height / 2 }
+            },
+            inputTarget() {
+                return null
+            },
+            viewport() {
+                return { ...viewport }
+            },
+            fovY() {
+                return fovY
+            },
+            now() {
+                return clock
+            },
+            requestFrame(callback) {
+                pendingFrame = callback
+            },
+            cancelFrame() {
+                pendingFrame = null
+            },
+        },
+        get pendingFrame() {
+            return pendingFrame
+        },
+        advance(ms) {
+            clock += ms
+        },
+        runFrame() {
+            const callback = pendingFrame
+            pendingFrame = null
+            if (callback) callback()
+        },
+        last() {
+            return applied[applied.length - 1]
+        },
+    }
+}
+
+function newControl(options) {
+    const fake = createFakeEngine(options)
+    const control = createCameraControl({ engine: fake.engine })
+    control.syncFromEngine()
+    fake.applied.length = 0
+    return { fake, control }
+}
+
+/** 假元素:记录监听器,供 attachCameraInput 的绑定/解绑断言。 */
+function createFakeElement() {
+    const handlers = new Map()
+    return {
+        handlers,
+        defaultPrevented: 0,
+        addEventListener(type, fn) {
+            if (!handlers.has(type)) handlers.set(type, new Set())
+            handlers.get(type).add(fn)
+        },
+        removeEventListener(type, fn) {
+            if (handlers.has(type)) handlers.get(type).delete(fn)
+        },
+        setPointerCapture() {},
+        releasePointerCapture() {},
+        fire(type, payload = {}) {
+            const set = handlers.get(type)
+            if (!set) return
+            const event = {
+                preventDefault() {
+                    this.defaultPrevented = true
+                },
+                ...payload,
+            }
+            set.forEach((fn) => fn(event))
+            return event
+        },
+    }
+}
+
+const near = (a, b, tolerance, message) =>
+    assert.ok(Math.abs(a - b) <= tolerance, `${message}:期望 ≈${b}(±${tolerance}),实际 ${a}`)
+
+// ---------- 1. 左键拖拽只平移 ----------
+{
+    const { fake, control } = newControl()
+    const before = control.getState()
+    control.beginDrag({ button: 0, x: 0, y: 0 })
+    control.moveDrag({ x: 100, y: 0 })
+
+    const after = control.getState()
+    assert.deepEqual(after.hpr, before.hpr, '平移不得改 heading / pitch / range')
+    assert.equal(fake.last().hpr.range, before.hpr.range, '平移不得改弹簧臂长度')
+
+    // 期望位移:range*tan(fov/2)*2/viewportH 米/像素 × 100 像素,heading=0 时应落在正东。
+    const metersPerPx = camera.metersPerPixel({ range: 10000, fovYRad: Math.PI / 3, viewportHeight: 900 })
+    const expectedLon = before.pivot.lon + (100 * metersPerPx) / (111320 * Math.cos((GROUND.lat * Math.PI) / 180))
+    near(after.pivot.lon, expectedLon, Math.abs(expectedLon - before.pivot.lon) * 0.02, 'heading=0 时右拖应把中心按像素比例移到正东')
+    near(after.pivot.lat, before.pivot.lat, 1e-6, 'heading=0 时右拖不应产生南北位移')
+    console.log('PASS  1 左键拖拽只平移(heading/pitch/range 原样,中心按像素比例东移)')
+}
+
+// ---------- 2. 平移方向 = 相机移动方向(PAN_FOLLOWS_DRAG 的语义钉子) ----------
+{
+    const { control } = newControl()
+    control.beginDrag({ button: 0, x: 0, y: 0 })
+    control.moveDrag({ x: 0, y: 100 })
+    const down = control.getState()
+    assert.ok(down.pivot.lat < GROUND.lat, `heading=0 时下拖应把中心往南带(朝相机方向):${JSON.stringify(down.pivot)}`)
+
+    const { control: control2 } = newControl()
+    control2.beginDrag({ button: 2, x: 0, y: 0 })
+    // 先把 heading 拖到 90°(向东看):像素数按灵敏度反算,不写死倍数。
+    control2.moveDrag({ x: 90 / camera.ORBIT_SENSITIVITY.heading, y: 0 })
+    control2.endDrag()
+    near(control2.getState().hpr.heading, 90, 1e-6, '轨道旋转应把 heading 拖到 90°')
+
+    const beforePan = control2.getState()
+    control2.beginDrag({ button: 0, x: 0, y: 0 })
+    control2.moveDrag({ x: 100, y: 0 })
+    const afterPan = control2.getState()
+    assert.ok(
+        afterPan.pivot.lat < beforePan.pivot.lat,
+        `heading=90°(向东看)时右拖应把中心往南带(相机的右手方向):${JSON.stringify(afterPan.pivot)}`,
+    )
+    near(afterPan.pivot.lon, beforePan.pivot.lon, 1e-6, 'heading=90° 时右拖不应产生东西位移')
+    console.log('PASS  2 平移方向 = 相机移动方向(ADR 0008 的「拖拽方向映射为摄像机移动方向」,heading=0/90 两点验证)')
+}
+
+// ---------- 3. 右键拖拽只做轨道旋转 ----------
+{
+    const { fake, control } = newControl()
+    const before = control.getState()
+    control.beginDrag({ button: 2, x: 0, y: 0 })
+    control.moveDrag({ x: 100, y: 50 })
+    const after = control.getState()
+
+    near(after.hpr.heading, 28, 1e-6, 'heading 变化 = dx × 灵敏度')
+    near(after.hpr.pitch, -60 - 50 * 0.22, 1e-6, 'pitch 变化 = -dy × 灵敏度')
+    assert.equal(after.hpr.range, before.hpr.range, '轨道旋转不得改弹簧臂长度')
+    assert.deepEqual(after.pivot, before.pivot, '轨道旋转不得移动中心')
+    assert.equal(fake.last().hpr.range, before.hpr.range, '下发给引擎的 range 也必须原样')
+    console.log('PASS  3 右键拖拽只做轨道旋转(heading/pitch 按像素变化,range 与中心不动)')
+}
+
+// ---------- 4. pitch 夹在上下限内 ----------
+{
+    const { control } = newControl()
+    control.beginDrag({ button: 2, x: 0, y: 0 })
+    control.moveDrag({ x: 0, y: 5000 })
+    near(control.getState().hpr.pitch, PITCH_LIMITS.ground.min, 1e-9, '一直下拖应夹在下限')
+    control.moveDrag({ x: 0, y: -10000 })
+    near(control.getState().hpr.pitch, PITCH_LIMITS.ground.max, 1e-9, '一直上拖应夹在上限')
+    console.log(`PASS  4 pitch 夹在 [${PITCH_LIMITS.ground.min}, ${PITCH_LIMITS.ground.max}](地面视图)`)
+}
+
+// ---------- 5. 右键按下锁定中心:拖拽中不重新拾取 ----------
+{
+    const { fake, control } = newControl()
+    const locked = control.getState().pivot
+    control.beginDrag({ button: 2, x: 0, y: 0 })
+
+    // 拖拽进行中来了「选中某对象」:中心不许被改,飞行也不许插进来。
+    control.setFocus(XIANGLING)
+    control.moveDrag({ x: 40, y: 0 })
+    assert.deepEqual(fake.last().pivot, locked, '拖拽中下发的中心必须仍是按下时锁定的那个')
+
+    control.endDrag()
+    assert.equal(control.getState().flying, true, '松手后应开始平滑换中心')
+    let guard = 0
+    while (control.getState().flying && guard++ < 200) {
+        fake.advance(50)
+        fake.runFrame()
+    }
+    const final = control.getState().pivot
+    near(final.lon, XIANGLING.lon, 1e-6, '飞行结束后中心应为选中对象')
+    near(final.lat, XIANGLING.lat, 1e-6, '飞行结束后中心应为选中对象')
+    console.log('PASS  5 右键按下锁定中心(拖拽中的选中不插队;松手后才开始换中心)')
+}
+
+// ---------- 6. 滚轮只改弹簧臂长度 ----------
+{
+    const { control } = newControl()
+    const before = control.getState()
+    const handled = control.wheel({ deltaY: -100 })
+    const after = control.getState()
+
+    assert.equal(handled, true, '滚轮应被相机契约接管(调用方据此 preventDefault)')
+    near(after.hpr.range, 10000 * Math.exp(-100 * 0.0012), 1e-6, '上滚拉近:range 按指数缩')
+    assert.equal(after.hpr.heading, before.hpr.heading, '滚轮不得转 heading')
+    assert.equal(after.hpr.pitch, before.hpr.pitch, '滚轮不得改 pitch')
+    assert.deepEqual(after.pivot, before.pivot, '滚轮不得移动中心')
+
+    control.wheel({ deltaY: -1e6 })
+    near(control.getState().hpr.range, RANGE_LIMITS.min, 1e-9, '拉近到极限应夹住下限')
+    control.wheel({ deltaY: 1e6 })
+    near(control.getState().hpr.range, RANGE_LIMITS.max, 1e-9, '推远到极限应夹住上限')
+    console.log(`PASS  6 滚轮只改弹簧臂长度(并夹在 [${RANGE_LIMITS.min}, ${RANGE_LIMITS.max}] 米)`)
+}
+
+// ---------- 7. 中心规则:选中优先 / 取消不回退 / 平移才回退 ----------
+{
+    const { fake, control } = newControl()
+    assert.deepEqual(control.getState().pivot, GROUND, '未选中时中心 = 屏幕中心的地面拾取点')
+
+    control.setFocus(XIANGLING)
+    let guard = 0
+    while (control.getState().flying && guard++ < 200) {
+        fake.advance(60)
+        fake.runFrame()
+    }
+    assert.deepEqual(control.getState().pivot, XIANGLING, '选中后中心 = 对象地表点')
+
+    control.setFocus(null)
+    assert.deepEqual(control.getState().pivot, XIANGLING, '取消选中不得回退中心')
+    assert.equal(control.getState().focus, null, '取消选中应清掉选中态')
+
+    control.beginDrag({ button: 0, x: 0, y: 0 })
+    control.moveDrag({ x: 10, y: 0 })
+    control.endDrag()
+    assert.deepEqual(control.getState().pivot, GROUND, '左键平移后中心回到地面拾取点')
+    console.log('PASS  7 中心规则:选中优先 → 取消不回退 → 平移才更新为地面拾取点')
+}
+
+// ---------- 8. 换中心平滑、且全程保持 heading 与 range ----------
+{
+    const { fake, control } = newControl()
+    const before = control.getState()
+    control.setFocus(XIANGLING)
+
+    // 起手第一帧(elapsed = 0)必须仍停在原中心 —— 换中心不许瞬移。
+    near(fake.last().pivot.lon, before.pivot.lon, 1e-12, '换中心第一帧应从原中心出发')
+
+    const samples = []
+    let guard = 0
+    while (control.getState().flying && guard++ < 200) {
+        fake.advance(FOCUS_FLIGHT_MS / 10)
+        fake.runFrame()
+        const now = control.getState()
+        assert.equal(now.hpr.heading, before.hpr.heading, '换中心过程中 heading 必须保持')
+        assert.equal(now.hpr.range, before.hpr.range, '换中心过程中 range 必须保持')
+        samples.push(now.pivot.lon)
+    }
+
+    assert.ok(samples.length >= 4, `换中心应有多帧(实际 ${samples.length})`)
+    const monotonic = samples.every((lon, i) => i === 0 || lon >= samples[i - 1] - 1e-12)
+    assert.equal(monotonic, true, `中心应朝目标单调推进:${samples.join(' → ')}`)
+    assert.ok(samples[0] > before.pivot.lon && samples[0] < XIANGLING.lon, '第一帧应落在起止之间')
+    near(samples[samples.length - 1], XIANGLING.lon, 1e-6, '末帧应到达目标中心')
+    assert.equal(control.getState().flying, false, '到达后应结束飞行')
+    console.log(`PASS  8 换中心平滑(${samples.length} 帧单调推进、heading/range 全程不变)`)
+}
+
+// ---------- 9. DOM 绑定:三类输入都接上,右键菜单与滚轮默认行为被吃掉 ----------
+{
+    const { control } = newControl()
+    const element = createFakeElement()
+    const detach = attachCameraInput(element, control)
+
+    assert.deepEqual(
+        [...element.handlers.keys()].sort(),
+        ['contextmenu', 'pointercancel', 'pointerdown', 'pointermove', 'pointerup', 'wheel'],
+        '应绑定左右键拖拽与滚轮(并吃掉右键菜单)',
+    )
+
+    const menu = element.fire('contextmenu')
+    assert.equal(menu.defaultPrevented, true, '右键菜单必须被 preventDefault')
+
+    element.fire('pointerdown', { button: 0, pointerId: 1, clientX: 500, clientY: 400 })
+    element.fire('pointermove', { pointerId: 1, clientX: 560, clientY: 400 })
+    const dragging = control.getState()
+    assert.equal(dragging.dragging, 'pan', '左键按下后应处于平移态')
+    element.fire('pointerup', { pointerId: 1, clientX: 560, clientY: 400 })
+    assert.equal(control.getState().dragging, null, '松手后应结束拖拽')
+
+    element.fire('pointerdown', { button: 2, pointerId: 2, clientX: 500, clientY: 400 })
+    element.fire('pointermove', { pointerId: 2, clientX: 540, clientY: 400 })
+    assert.equal(control.getState().dragging, 'orbit', '右键按下后应处于轨道旋转态')
+    element.fire('pointerup', { pointerId: 2, clientX: 540, clientY: 400 })
+
+    const wheelEvent = element.fire('wheel', { deltaY: -120 })
+    assert.equal(wheelEvent.defaultPrevented, true, '滚轮默认行为(页面滚动)必须被吃掉')
+
+    detach()
+    assert.equal(element.handlers.get('pointerdown').size, 0, 'detach 应摘掉监听器')
+    assert.equal(element.handlers.get('wheel').size, 0, 'detach 应摘掉滚轮监听器')
+    console.log('PASS  9 DOM 绑定:三类输入接上、右键菜单与滚轮默认行为被吃掉、可完整摘除')
+}
+
+// ---------- 10. 状态串(走查与人工排查读的那一行) ----------
+{
+    const { control } = newControl()
+    const text = camera.formatCameraState(control.getState())
+    ;['h=', 'p=', 'r=', 'pivot=', 'focus=ground', 'drag=none'].forEach((part) =>
+        assert.ok(text.includes(part), `状态串应含 ${part}:${text}`),
+    )
+    assert.equal(camera.formatCameraState({ hpr: { heading: 0, pitch: -60, range: 1 }, pivot: null }), 'h=0.0 p=-60.0 r=1 pivot=none')
+    console.log(`PASS  10 状态串可读:${text}`)
+}
+
+console.log('\nT03 逻辑检查通过:三键契约、中心规则、换中心动画、DOM 绑定均符合 ADR 0008。')

+ 295 - 0
.scratch/cockpit-shell-panel-layout/verify/t03-camera-walkthrough.mjs

@@ -0,0 +1,295 @@
+/**
+ * T03 真机走查:三键相机契约落在真实 Cesium 上的行为(票 03 / ADR 0008)。
+ *
+ * 逻辑脚本(t03-camera-contract.mjs)用假引擎把语义测透了,这里只补浏览器里才成立的部分:
+ *   · Cesium 默认左键旋转 / 滚轮缩放确实被替换(左键只平移、滚轮只改距离);
+ *   · 三种交互在真机上确实按契约改变相机;
+ *   · 未选中时中心就是屏幕中心的地面拾取点(用中心点在画布上的投点位置断言);
+ *   · 选中换中心平滑且保持 heading 与 range,取消选中不回退。
+ *
+ * 走查用 page.mouse 派发**真实**输入事件(不是合成 DOM 事件),读的是开发态把手
+ * `window.__cockpitMap`(见 mapService 的 publishDevHook)与宿主上的 `data-camera`。
+ *
+ * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/):
+ *   node .scratch/cockpit-shell-panel-layout/verify/t03-camera-walkthrough.mjs
+ */
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+
+const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
+const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
+const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
+
+/** 沅陵县某监测点(只用于「选中换中心」这条,跟真实数据无关)。 */
+const XIANGLING = { lon: 110.396, lat: 28.455, height: 0 }
+
+const results = []
+function check(id, desc, pass, evidence) {
+    results.push({ id, desc, pass })
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${id}  ${desc}${evidence ? `  — ${evidence}` : ''}`)
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+const round = (n, digits = 3) => Number(n.toFixed(digits))
+
+const puppeteer = (await import(PUPPETEER)).default
+const browser = await puppeteer.launch({
+    executablePath: CHROME,
+    headless: true,
+    userDataDir: join(tmpdir(), 'cockpit-t03-profile'),
+    defaultViewport: { width: 1680, height: 950 },
+    args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
+})
+console.log(`Chrome: ${await browser.version()}`)
+
+try {
+    const page = await browser.newPage()
+    const pageErrors = []
+    page.on('pageerror', (e) => pageErrors.push(String(e && e.message ? e.message : e)))
+
+    await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
+    await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
+    await page
+        .waitForFunction(
+            () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready',
+            { timeout: 60000 },
+        )
+        .catch(() => {})
+    await page.waitForFunction(() => !!window.__cockpitMap, { timeout: 20000 })
+    await sleep(800)
+
+    const readState = () =>
+        page.evaluate(() => {
+            const state = window.__cockpitMap.getCameraState()
+            const canvas = document.querySelector('.cockpit-shell__map .map-canvas canvas')
+            const rect = canvas ? canvas.getBoundingClientRect() : null
+            return {
+                ...state,
+                dataAttr: document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.camera || '',
+                canvas: rect ? { x: rect.x, y: rect.y, w: rect.width, h: rect.height } : null,
+                projection: window.__cockpitMap.projectCameraPivot(),
+            }
+        })
+
+    const center = async () => {
+        const st = await readState()
+        return { x: st.canvas.x + st.canvas.w / 2, y: st.canvas.y + st.canvas.h / 2 }
+    }
+
+    // ---------- A1 就绪与状态可读 ----------
+    const base = await readState()
+    check(
+        'A1-camera-ready',
+        '引擎就绪后相机状态可读:弹簧臂参数与中心都有值,宿主 data-camera 同步',
+        !!base &&
+            !!base.pivot &&
+            base.pivot.lon > -180 &&
+            base.hpr.range > 0 &&
+            base.dataAttr.includes('pivot=') &&
+            base.dataAttr.includes('h='),
+        JSON.stringify({ hpr: base.hpr, pivot: base.pivot, data: base.dataAttr }),
+    )
+
+    // ---------- A2 左键拖拽只平移(Cesium 默认左键旋转已被替换) ----------
+    const a2before = await readState()
+    const c2 = await center()
+    await page.mouse.move(c2.x, c2.y)
+    await page.mouse.down({ button: 'left' })
+    await page.mouse.move(c2.x + 160, c2.y, { steps: 8 })
+    await page.mouse.up({ button: 'left' })
+    await sleep(120)
+    const a2after = await readState()
+
+    {
+        const dEast = a2after.pivot.lon - a2before.pivot.lon
+        const dNorth = a2after.pivot.lat - a2before.pivot.lat
+        // 相机右手方向(东北分量):heading 上「前方」=(sin h, cos h),右手 = (cos h, -sin h)。
+        const h = (a2before.hpr.heading * Math.PI) / 180
+        const alongRight = dEast * Math.cos(h) + dNorth * -Math.sin(h)
+        check(
+            'A2-left-drag-pans-only',
+            '左键拖拽只平移:heading/pitch/range 不变,中心沿相机右手方向移动(默认左键旋转已被替换)',
+            Math.abs(a2after.hpr.heading - a2before.hpr.heading) < 0.05 &&
+                Math.abs(a2after.hpr.pitch - a2before.hpr.pitch) < 0.05 &&
+                Math.abs(a2after.hpr.range - a2before.hpr.range) < 1 &&
+                Math.abs(dEast) + Math.abs(dNorth) > 1e-6 &&
+                alongRight > 0,
+            JSON.stringify({
+                hBefore: round(a2before.hpr.heading),
+                hAfter: round(a2after.hpr.heading),
+                pivotBefore: a2before.pivot,
+                pivotAfter: a2after.pivot,
+                rightSign: alongRight > 0 ? '沿右手方向' : '反向',
+            }),
+        )
+    }
+
+    // ---------- A3 右键拖拽只做轨道旋转 ----------
+    // 注意方向:默认全球视角的 pitch 已在下限附近,向下拖会被夹住,所以这里向上拖(pitch 变大)。
+    const a3before = await readState()
+    const c3 = await center()
+    await page.mouse.move(c3.x, c3.y)
+    await page.mouse.down({ button: 'right' })
+    await page.mouse.move(c3.x + 120, c3.y - 40, { steps: 8 })
+    await page.mouse.up({ button: 'right' })
+    await sleep(120)
+    const a3after = await readState()
+
+    {
+        const dHeading = ((a3after.hpr.heading - a3before.hpr.heading + 540) % 360) - 180
+        const dPitch = a3after.hpr.pitch - a3before.hpr.pitch
+        const dPivot = Math.abs(a3after.pivot.lon - a3before.pivot.lon) + Math.abs(a3after.pivot.lat - a3before.pivot.lat)
+        check(
+            'A3-right-drag-orbits-only',
+            '右键拖拽只做轨道旋转:heading 随 dx、pitch 随 dy 变化,range 与中心不动',
+            dHeading > 20 &&
+                dHeading < 45 &&
+                dPitch > 5 &&
+                Math.abs(a3after.hpr.range - a3before.hpr.range) < 1 &&
+                dPivot < 1e-9,
+            JSON.stringify({
+                dHeading: round(dHeading),
+                dPitch: round(dPitch),
+                pitchBefore: round(a3before.hpr.pitch),
+                pitchAfter: round(a3after.hpr.pitch),
+                range: round(a3after.hpr.range),
+                dPivot,
+            }),
+        )
+    }
+
+    // ---------- A4 滚轮只改弹簧臂长度 ----------
+    const a4before = await readState()
+    const c4 = await center()
+    await page.mouse.move(c4.x, c4.y)
+    await page.mouse.wheel({ deltaY: -240 })
+    await sleep(150)
+    const a4after = await readState()
+
+    check(
+        'A4-wheel-changes-range-only',
+        '滚轮只改弹簧臂长度:上滚拉近,heading/pitch/中心不动(默认滚轮缩放已被替换)',
+        a4after.hpr.range < a4before.hpr.range * 0.95 &&
+            Math.abs(a4after.hpr.heading - a4before.hpr.heading) < 0.05 &&
+            Math.abs(a4after.hpr.pitch - a4before.hpr.pitch) < 0.05 &&
+            Math.abs(a4after.pivot.lon - a4before.pivot.lon) < 1e-9 &&
+            Math.abs(a4after.pivot.lat - a4before.pivot.lat) < 1e-9,
+        JSON.stringify({ before: round(a4before.hpr.range), after: round(a4after.hpr.range), pivot: a4after.pivot }),
+    )
+
+    // ---------- A5 未选中时中心 = 屏幕中心的地面拾取点 ----------
+    const a5 = await readState()
+    {
+        const cx = a5.canvas.x + a5.canvas.w / 2
+        const cy = a5.canvas.y + a5.canvas.h / 2
+        const dx = a5.projection ? Math.abs(a5.projection.x - cx) : Infinity
+        const dy = a5.projection ? Math.abs(a5.projection.y - cy) : Infinity
+        check(
+            'A5-pivot-is-ground-pick',
+            '未选中时中心 = 屏幕中心的地面拾取点(中心点投回画布即画布中心)',
+            dx <= 2 && dy <= 2,
+            JSON.stringify({ projection: a5.projection, canvasCenter: { x: round(cx, 1), y: round(cy, 1) }, dx: round(dx, 2), dy: round(dy, 2) }),
+        )
+    }
+
+    // ---------- A6 选中换中心:平滑、保持 heading 与 range ----------
+    const a6before = await readState()
+    const sampled = await page.evaluate(async (target) => {
+        const samples = []
+        window.__cockpitMap.setCameraFocus(target)
+        const started = performance.now()
+        while (performance.now() - started < 1400) {
+            const state = window.__cockpitMap.getCameraState()
+            samples.push({
+                t: Math.round(performance.now() - started),
+                heading: state.hpr.heading,
+                range: state.hpr.range,
+                lon: state.pivot.lon,
+                lat: state.pivot.lat,
+                flying: state.flying,
+            })
+            if (!state.flying) break
+            await new Promise((r) => setTimeout(r, 40))
+        }
+        return samples
+    }, XIANGLING)
+
+    {
+        const headingFixed = sampled.every((s) => Math.abs(s.heading - a6before.hpr.heading) < 1e-9)
+        const rangeFixed = sampled.every((s) => Math.abs(s.range - a6before.hpr.range) < 1e-9)
+        const flyingFrames = sampled.filter((s) => s.flying).length
+        const last = sampled[sampled.length - 1]
+        const startLon = a6before.pivot.lon
+        const dir = Math.sign(XIANGLING.lon - startLon) || 1
+        const toward = sampled.map((s) => (s.lon - startLon) * dir)
+        const monotonic = toward.every((v, i) => i === 0 || v >= toward[i - 1] - 1e-12)
+        // 首帧不许瞬移:它离目标仍差大半个行程(相对起止跨度判断,与尺度无关)。
+        const span = Math.abs(XIANGLING.lon - startLon)
+        const firstGap = Math.abs(XIANGLING.lon - sampled[0].lon)
+        check(
+            'A6-focus-flies-to-ground-point',
+            '选中对象:中心平滑飞到该对象地表点,全程保持 heading 与 range(首帧不瞬移)',
+            sampled.length >= 3 &&
+                flyingFrames >= 1 &&
+                headingFixed &&
+                rangeFixed &&
+                monotonic &&
+                Math.abs(last.lon - XIANGLING.lon) < 1e-6 &&
+                Math.abs(last.lat - XIANGLING.lat) < 1e-6 &&
+                firstGap > span * 0.2,
+            JSON.stringify({
+                frames: sampled.length,
+                flyingFrames,
+                heading: round(last.heading),
+                range: round(last.range),
+                pivot: { lon: round(last.lon, 6), lat: round(last.lat, 6) },
+                spanDeg: round(span),
+                firstGapDeg: round(firstGap),
+            }),
+        )
+    }
+
+    // ---------- A7 取消选中不回退中心 ----------
+    const a7before = await readState()
+    await page.evaluate(() => window.__cockpitMap.clearCameraFocus())
+    await sleep(200)
+    const a7after = await readState()
+
+    check(
+        'A7-clear-focus-keeps-pivot',
+        '取消选中不立即回退中心(中心仍在刚选中的对象上,focus 标记清空)',
+        Math.abs(a7after.pivot.lon - a7before.pivot.lon) < 1e-9 &&
+            Math.abs(a7after.pivot.lat - a7before.pivot.lat) < 1e-9 &&
+            a7after.focus === null,
+        JSON.stringify({ pivot: a7after.pivot, focus: a7after.focus }),
+    )
+
+    // ---------- A8 平移把中心交回地面拾取点 ----------
+    const c8 = await center()
+    await page.mouse.move(c8.x, c8.y)
+    await page.mouse.down({ button: 'left' })
+    await page.mouse.move(c8.x + 40, c8.y + 20, { steps: 5 })
+    await page.mouse.up({ button: 'left' })
+    await sleep(150)
+    const a8 = await readState()
+    check(
+        'A8-pan-returns-pivot-to-ground',
+        '左键平移后中心回到地面拾取点(不再跟随选中对象),且仍落在画布中心',
+        !!a8.projection &&
+            Math.abs(a8.projection.x - (a8.canvas.x + a8.canvas.w / 2)) <= 2 &&
+            Math.abs(a8.projection.y - (a8.canvas.y + a8.canvas.h / 2)) <= 2 &&
+            Math.abs(a8.pivot.lon - XIANGLING.lon) > 1e-6,
+        JSON.stringify({ pivot: a8.pivot, projection: a8.projection }),
+    )
+
+    check('A9-no-page-errors', '走查期间无页面级报错', pageErrors.length === 0, JSON.stringify(pageErrors.slice(0, 3)))
+} finally {
+    await browser.close()
+}
+
+const failed = results.filter((r) => !r.pass)
+console.log(
+    `\nT03 浏览器走查:${results.length - failed.length}/${results.length} 项通过${failed.length ? `,未通过:${failed.map((r) => r.id).join(', ')}` : ''}`,
+)
+process.exit(failed.length ? 1 : 0)

+ 139 - 0
src/modules/cockpit/domain/camera.js

@@ -0,0 +1,139 @@
+/**
+ * 相机契约的纯逻辑层(票 03 / ADR 0008 / 决策地图票 04)。
+ *
+ * 这里只有数学与状态迁移,**不 import cesium、不碰 DOM**,所以能脱离浏览器直接断言
+ * (见 verify/t03-camera-contract.mjs)。三键语义:
+ *   · 左键拖拽 = 平移,只移动旋转中心,heading/pitch/range 不变;
+ *   · 右键拖拽 = 绕锁定中心做轨道旋转,只改 heading/pitch(并夹住 pitch 上下限);
+ *   · 滚轮 = 只改弹簧臂长度 range。
+ * 旋转中心 = 选中对象的地表点;未选中时是地面拾取点;右键按下时锁定,拖拽中不重新拾取。
+ *
+ * 坐标口径:中心点用 `{ lon, lat, height }`(度、度、米),heading 为北起顺时针角度、
+ * pitch 与 Cesium `HeadingPitchRange` 一致(-90 = 正上方俯视,0 = 平视)。
+ */
+
+/** pitch 上下限:地面视图从上往下看;管线视图允许平视或轻微仰视(视图预设归另一票,这里先备好)。 */
+export const PITCH_LIMITS = {
+    ground: { min: -85, max: -5 },
+    pipeline: { min: -85, max: 5 },
+}
+
+/** 弹簧臂长度范围(米)。下限够贴近设备看,上限覆盖当前默认的全球视角以免首帧被夹。 */
+export const RANGE_LIMITS = { min: 120, max: 30000000 }
+
+/** 右键拖拽灵敏度(度/像素);滚轮每单位 deltaY 的距离倍率指数。 */
+export const ORBIT_SENSITIVITY = { heading: 0.28, pitch: 0.22 }
+export const WHEEL_SENSITIVITY = 0.0012
+
+/** 从无选中到选中时镜头平滑换中心的时长(毫秒)。 */
+export const FOCUS_FLIGHT_MS = 700
+
+/**
+ * ADR 0008:「拖拽方向映射为摄像机移动方向」→ 相机跟着拖拽方向走(拖右 → 相机右移、画面左移)。
+ * 若维护者要改成「抓着地图拖」(画面跟着手指、相机反向),把这里取反即可 —— 只此一处。
+ */
+export const PAN_FOLLOWS_DRAG = true
+
+const DEG = Math.PI / 180
+const METERS_PER_DEG_LAT = 110540
+
+export function clamp(value, min, max) {
+    return Math.min(max, Math.max(min, value))
+}
+
+/** heading 归一到 [0, 360)。 */
+export function normalizeHeading(deg) {
+    const wrapped = deg % 360
+    return wrapped < 0 ? wrapped + 360 : wrapped
+}
+
+export function clampPitch(pitch, limits = PITCH_LIMITS.ground) {
+    return clamp(pitch, limits.min, limits.max)
+}
+
+/**
+ * 右键轨道旋转:只改 heading 与 pitch(pitch 夹在上下限内),range 与中心不动。
+ * Cesium 的 pitch 越接近 -90 越像俯视,所以「向下拖 = 抬高机位看更正」:dy > 0 时 pitch 减小。
+ */
+export function orbit(hpr, { dxPx = 0, dyPx = 0 } = {}, limits = PITCH_LIMITS.ground) {
+    return {
+        heading: normalizeHeading(hpr.heading + dxPx * ORBIT_SENSITIVITY.heading),
+        pitch: clampPitch(hpr.pitch - dyPx * ORBIT_SENSITIVITY.pitch, limits),
+        range: hpr.range,
+    }
+}
+
+/** 滚轮:只改弹簧臂长度(上滚拉近、下滚推远),heading/pitch/中心不动。 */
+export function zoomByWheel(hpr, deltaY, limits = RANGE_LIMITS) {
+    const scaled = hpr.range * Math.exp(deltaY * WHEEL_SENSITIVITY)
+    return { ...hpr, range: clamp(scaled, limits.min, limits.max) }
+}
+
+/**
+ * 屏幕像素对应的地面米数:按「中心点所在距离」上的垂直可视高度算,
+ * 不需要相机离地高度,也不受 pitch 影响(中心在弹簧臂末端)。
+ */
+export function metersPerPixel({ range, fovYRad, viewportHeight }) {
+    if (!(viewportHeight > 0) || !(range > 0)) return 0
+    return (2 * range * Math.tan(fovYRad / 2)) / viewportHeight
+}
+
+/**
+ * 平移偏移(米):把屏幕拖拽量映射到中心点所在的地面切平面上。
+ * 拖右 → 相机右移 → 中心也跟着右移(沿相机右手方向);拖下 → 中心朝相机方向回来。
+ */
+export function pivotOffsetMeters({ headingDeg, dxPx, dyPx, metersPerPx }) {
+    const sign = PAN_FOLLOWS_DRAG ? 1 : -1
+    const dx = sign * dxPx * metersPerPx
+    const dy = sign * dyPx * metersPerPx
+    const h = headingDeg * DEG
+    // heading 上「相机前方」= (sin h, cos h),右手方向 = (cos h, -sin h)(东北分量)
+    return {
+        east: dx * Math.cos(h) - dy * Math.sin(h),
+        north: -dx * Math.sin(h) - dy * Math.cos(h),
+    }
+}
+
+/** 局部等距近似:把东北向偏移加到中心点上(平移是屏幕尺度的小位移,够用)。 */
+export function shiftPivot(pivot, { east = 0, north = 0 }) {
+    const metersPerDegLon = Math.max(1, 111320 * Math.cos(pivot.lat * DEG))
+    return {
+        lon: pivot.lon + east / metersPerDegLon,
+        lat: pivot.lat + north / METERS_PER_DEG_LAT,
+        height: pivot.height || 0,
+    }
+}
+
+export function easeInOutCubic(t) {
+    return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
+}
+
+/** 换中心动画的进度(0→1,带缓动);超时即到 1。 */
+export function flightProgress(elapsedMs, durationMs = FOCUS_FLIGHT_MS) {
+    if (!(durationMs > 0)) return 1
+    return easeInOutCubic(clamp(elapsedMs / durationMs, 0, 1))
+}
+
+export function lerpPivot(from, to, t) {
+    return {
+        lon: from.lon + (to.lon - from.lon) * t,
+        lat: from.lat + (to.lat - from.lat) * t,
+        height: (from.height || 0) + ((to.height || 0) - (from.height || 0)) * t,
+    }
+}
+
+/** 把当前相机状态压成一行写进宿主 dataset:走查、演示与人工排查都读它。 */
+export function formatCameraState({ pivot, hpr, focus, dragging }) {
+    if (!pivot) {
+        return `h=${hpr.heading.toFixed(1)} p=${hpr.pitch.toFixed(1)} r=${Math.round(hpr.range)} pivot=none`
+    }
+    const focusText = focus ? `${focus.lon.toFixed(5)},${focus.lat.toFixed(5)}` : 'ground'
+    return [
+        `h=${hpr.heading.toFixed(1)}`,
+        `p=${hpr.pitch.toFixed(1)}`,
+        `r=${Math.round(hpr.range)}`,
+        `pivot=${pivot.lon.toFixed(5)},${pivot.lat.toFixed(5)}`,
+        `focus=${focusText}`,
+        `drag=${dragging || 'none'}`,
+    ].join(' ')
+}

+ 333 - 0
src/modules/cockpit/services/cameraControl.js

@@ -0,0 +1,333 @@
+/**
+ * 相机控制(票 03 / ADR 0008):把三键输入迁移成相机状态,并下发给引擎。
+ *
+ * 为什么独立于 `cesiumEngine.js`:本模块**不 import cesium、不读 DOM**,
+ * 只依赖一个「引擎接口」对象,因此三键语义与中心规则可以脱离浏览器直接断言
+ * (见 verify/t03-camera-contract.mjs);DOM 事件绑定单独放在 `attachCameraInput()`,
+ * Cesium 适配放在 `cesiumEngine.createCameraEngine()`。三者都在服务层,组件不碰相机。
+ *
+ * 引擎接口:
+ *   applyView(pivot, hpr)    把「旋转中心 + 弹簧臂」下发给相机(Cesium: lookAtTransform + HeadingPitchRange)
+ *   readView(pivot)          反读当前相机相对该中心的 { heading, pitch, range }
+ *   pickGroundAtCenter()     屏幕中心的地面拾取点(无地形时按椭球),拾不到返回 null
+ *   projectPivot(pivot)      中心点在画布上的落点(走查断言「中心 = 屏幕中心」用)
+ *   inputTarget()            事件绑定目标(画布)
+ *   viewport() / fovY()      画布尺寸与垂直视场角(平移像素→米的换算)
+ *   now() / requestFrame() / cancelFrame()   动画帧
+ */
+import {
+    FOCUS_FLIGHT_MS,
+    PITCH_LIMITS,
+    clampPitch,
+    flightProgress,
+    lerpPivot,
+    metersPerPixel,
+    orbit,
+    pivotOffsetMeters,
+    shiftPivot,
+    zoomByWheel,
+} from '../domain/camera.js'
+
+/** 首帧状态:真实相机没接上前的占位,`syncFromEngine()` 会用实读值覆盖。 */
+const INITIAL_HPR = { heading: 0, pitch: -60, range: 20000000 }
+
+/**
+ * @param {object} options
+ * @param {object} options.engine 上面的引擎接口
+ * @param {object} [options.limits] pitch 上下限(地面视图 / 管线视图)
+ * @param {(state: object) => void} [options.onStateChange] 状态变化回调(写 dataset 用)
+ */
+export function createCameraControl({ engine, limits = PITCH_LIMITS.ground, onStateChange = null }) {
+    const state = {
+        pivot: null,
+        hpr: { ...INITIAL_HPR },
+        focus: null,
+        drag: null, // null | 'pan' | 'orbit'
+        lockedPivot: null,
+        flight: null, // { from, to, startedAt }
+        pendingFocus: null, // 拖拽中来的选中,等松手再飞
+        last: null, // 上一次指针位置
+    }
+
+    function notify() {
+        if (onStateChange) onStateChange(getState())
+    }
+
+    /** 下发到相机并通知观察者。 */
+    function apply() {
+        if (state.pivot) engine.applyView(state.pivot, state.hpr)
+        notify()
+    }
+
+    /** 只通知、不下发:挂载时用实读状态刷新 dataset,但不许把默认视角瞬间挪走。 */
+    function notifyOnly() {
+        notify()
+    }
+
+    /** 中心点缺失时现取一次(默认全球视角下屏幕中心多半能拾到椭球交点)。 */
+    function ensurePivot() {
+        if (!state.pivot) {
+            state.pivot = engine.pickGroundAtCenter()
+        }
+        return state.pivot
+    }
+
+    function cancelFlight() {
+        if (state.flight) {
+            state.flight = null
+            engine.cancelFrame()
+        }
+    }
+
+    /** 挂载后调用:中心 = 屏幕中心地面拾取点,弹簧臂参数按当前真实相机反读,避免首次交互跳变。 */
+    function syncFromEngine() {
+        const pivot = engine.pickGroundAtCenter()
+        if (pivot) {
+            state.pivot = pivot
+            const read = engine.readView(pivot)
+            state.hpr = {
+                heading: read.heading,
+                pitch: clampPitch(read.pitch, limits),
+                range: read.range,
+            }
+        }
+        notifyOnly()
+        return getState()
+    }
+
+    function beginDrag({ button, x, y }) {
+        cancelFlight()
+        state.pendingFocus = null
+        state.last = { x, y }
+
+        if (button === 2) {
+            // ADR 0008:右键按下时锁定当时的中心,拖拽中不重新拾取。
+            ensurePivot()
+            state.lockedPivot = state.pivot ? { ...state.pivot } : null
+            state.drag = state.lockedPivot ? 'orbit' : null
+            return
+        }
+        if (button === 0) {
+            ensurePivot()
+            state.drag = state.pivot ? 'pan' : null
+            return
+        }
+        state.drag = null
+    }
+
+    function moveDrag({ x, y }) {
+        if (!state.drag || !state.last) return
+
+        const dxPx = x - state.last.x
+        const dyPx = y - state.last.y
+        state.last = { x, y }
+        if (dxPx === 0 && dyPx === 0) return
+
+        if (state.drag === 'pan') {
+            const viewport = engine.viewport()
+            const metersPerPx = metersPerPixel({
+                range: state.hpr.range,
+                fovYRad: engine.fovY(),
+                viewportHeight: viewport.height,
+            })
+            const offset = pivotOffsetMeters({ headingDeg: state.hpr.heading, dxPx, dyPx, metersPerPx })
+            state.pivot = shiftPivot(state.pivot, offset)
+            // 平移只移动中心:heading / pitch / range 原样带走。
+            apply()
+            return
+        }
+
+        if (state.drag === 'orbit') {
+            state.hpr = orbit(state.hpr, { dxPx, dyPx }, limits)
+            // 中心保持按下时锁定的那个点,range 不变。
+            state.pivot = state.lockedPivot
+            apply()
+        }
+    }
+
+    function endDrag() {
+        if (!state.drag) return
+        const mode = state.drag
+        state.drag = null
+        state.lockedPivot = null
+        state.last = null
+
+        const pending = state.pendingFocus
+        state.pendingFocus = null
+
+        if (mode === 'pan') {
+            // ADR 0008:只有左键平移才把中心更新为地面拾取点;平移后相机中心不再跟随选中对象。
+            const picked = engine.pickGroundAtCenter()
+            if (picked) state.pivot = picked
+            state.focus = null
+            apply()
+            return
+        }
+
+        apply()
+        // 轨道旋转期间来的选中,等松手后再平滑换中心(拖拽中中心一直是锁定的那个)。
+        if (pending) startFlight(pending)
+    }
+
+    function wheel({ deltaY }) {
+        cancelFlight()
+        if (!ensurePivot()) return false
+        state.hpr = zoomByWheel(state.hpr, deltaY)
+        apply()
+        return true
+    }
+
+    /**
+     * 选中/取消选中一个对象(监测点、设备详情调用)。
+     * @param {{lon: number, lat: number, height?: number}|null} point 对象地表点;null = 取消选中
+     */
+    function setFocus(point) {
+        if (!point) {
+            // ADR 0008:取消选中不立即回退中心。
+            state.focus = null
+            state.pendingFocus = null
+            notify()
+            return false
+        }
+
+        const target = { lon: point.lon, lat: point.lat, height: 0 }
+        state.focus = target
+
+        if (state.drag) {
+            // 拖拽中(尤其右键轨道旋转)中心已锁定:先记下,松手后再换中心,绝不插队。
+            state.pendingFocus = target
+            return true
+        }
+        return startFlight(target)
+    }
+
+    /** 平滑换中心:保持 heading / pitch / range,只把中心从当前位置推到目标。 */
+    function startFlight(target) {
+        if (!state.pivot) {
+            state.pivot = target
+            apply()
+            return true
+        }
+        if (state.pivot.lon === target.lon && state.pivot.lat === target.lat) {
+            apply()
+            return true
+        }
+
+        state.flight = { from: { ...state.pivot }, to: target, startedAt: engine.now() }
+        tick()
+        return true
+    }
+
+    /** 动画帧:把中心从 from 平滑推到 to,弹簧臂参数全程不变。 */
+    function tick() {
+        if (!state.flight) return
+        const { from, to, startedAt } = state.flight
+        const progress = flightProgress(engine.now() - startedAt, FOCUS_FLIGHT_MS)
+        state.pivot = lerpPivot(from, to, progress)
+        apply()
+
+        if (progress >= 1) {
+            state.flight = null
+            return
+        }
+        engine.requestFrame(tick)
+    }
+
+    function getState() {
+        return {
+            pivot: state.pivot ? { ...state.pivot } : null,
+            hpr: { ...state.hpr },
+            focus: state.focus ? { ...state.focus } : null,
+            dragging: state.drag,
+            flying: !!state.flight,
+        }
+    }
+
+    function projectPivot() {
+        return state.pivot ? engine.projectPivot(state.pivot) : null
+    }
+
+    function dispose() {
+        cancelFlight()
+        state.drag = null
+        state.lockedPivot = null
+        state.last = null
+    }
+
+    return {
+        syncFromEngine,
+        beginDrag,
+        moveDrag,
+        endDrag,
+        wheel,
+        setFocus,
+        tick,
+        getState,
+        projectPivot,
+        dispose,
+    }
+}
+
+/**
+ * DOM 事件绑定:左键/右键拖拽 + 滚轮;右键菜单与滚轮默认行为都要吃掉,
+ * 否则浏览器菜单 / 页面滚动会插进三键契约里。
+ * @returns {() => void} detach
+ */
+export function attachCameraInput(element, control) {
+    if (!element || typeof element.addEventListener !== 'function') {
+        return () => {}
+    }
+
+    const onPointerDown = (event) => {
+        if (event.button !== 0 && event.button !== 2) return
+        if (typeof element.setPointerCapture === 'function') {
+            try {
+                element.setPointerCapture(event.pointerId)
+            } catch (error) {
+                // 某些合成事件没有真实 pointerId,捕获失败不影响后续 move/up。
+            }
+        }
+        control.beginDrag({ button: event.button, x: event.clientX, y: event.clientY })
+    }
+
+    const onPointerMove = (event) => {
+        control.moveDrag({ x: event.clientX, y: event.clientY })
+    }
+
+    const onPointerUp = (event) => {
+        if (typeof element.releasePointerCapture === 'function') {
+            try {
+                element.releasePointerCapture(event.pointerId)
+            } catch (error) {
+                // 同上:捕获不存在时忽略。
+            }
+        }
+        control.endDrag()
+    }
+
+    const onWheel = (event) => {
+        if (control.wheel({ deltaY: event.deltaY })) {
+            event.preventDefault()
+        }
+    }
+
+    const onContextMenu = (event) => {
+        event.preventDefault()
+    }
+
+    element.addEventListener('pointerdown', onPointerDown)
+    element.addEventListener('pointermove', onPointerMove)
+    element.addEventListener('pointerup', onPointerUp)
+    element.addEventListener('pointercancel', onPointerUp)
+    element.addEventListener('wheel', onWheel, { passive: false })
+    element.addEventListener('contextmenu', onContextMenu)
+
+    return () => {
+        element.removeEventListener('pointerdown', onPointerDown)
+        element.removeEventListener('pointermove', onPointerMove)
+        element.removeEventListener('pointerup', onPointerUp)
+        element.removeEventListener('pointercancel', onPointerUp)
+        element.removeEventListener('wheel', onWheel)
+        element.removeEventListener('contextmenu', onContextMenu)
+    }
+}

+ 103 - 9
src/modules/cockpit/services/cesiumEngine.js

@@ -4,18 +4,30 @@
  * 只在 `mapService.mountMap()` 完成 `window.CESIUM_BASE_URL` 注入之后被动态 import,
  * 因此静态 import 提升不会踩到时序(见 services/cesiumAssets.js)。
  *
- * 本票只负责「把引擎接到宿主上」:
- *   · 唯一 `Viewer`,建在宿主要素内;
- *   · 关掉自带控件与默认相机输入 —— 默认左键旋转 / 滚轮缩放会与票 03 的三键契约打架
- *     (`enableInputs` 是临时开关,必须逐项关);
- *   · 不碰任何需要账号的能力:Ion token、`Terrain.fromWorldTerrain`、`createOsmBuildings`
- *     都在票 06 明令禁止;
- *   · 不配置影像底图(`baseLayer: false`):底图未配置就不伪造地理配准,
- *     正式底图来源见票 21。
+ * 本模块负责两件事:
+ *   1. 「把引擎接到宿主上」(票 27):唯一 `Viewer`、自带控件与默认相机输入全关、
+ *      不碰需要账号的能力、不配置底图;
+ *   2. 「把相机契约接到引擎上」(票 03):`createCameraEngine()` 把三键契约需要的原语
+ *      (弹簧臂 `lookAtTransform(东北天局部系, HeadingPitchRange)`、屏幕中心地面拾取、
+ *      中心点投影)包成 services/cameraControl.js 的引擎接口 —— 相机语义本身不在本文件,
+ *      这里只做 Cesium 方言的翻译。
  */
-import { Color, Viewer } from 'cesium'
+import {
+    Cartesian2,
+    Cartesian3,
+    Color,
+    Ellipsoid,
+    HeadingPitchRange,
+    Math as CesiumMath,
+    Matrix4,
+    SceneTransforms,
+    Transforms,
+    Viewer,
+} from 'cesium'
 import 'cesium/Build/Cesium/Widgets/widgets.css'
 
+const DEG = Math.PI / 180
+
 const VIEWER_OPTIONS = {
     // 无底图 → 不往某个假坐标范围里塞临时影像。
     baseLayer: false,
@@ -61,3 +73,85 @@ export function resizeViewer(viewer) {
 export function destroyViewer(viewer) {
     viewer.destroy()
 }
+
+/**
+ * 相机契约的 Cesium 适配(票 03)。返回 services/cameraControl.js 期望的引擎接口;
+ * 相机语义(三键怎么算、中心是谁)在 domain/camera.js 与 cameraControl.js,本函数只翻译方言。
+ * @param {Viewer} viewer
+ */
+export function createCameraEngine(viewer) {
+    const scene = viewer.scene
+    const camera = viewer.camera
+    let frameHandle = null
+
+    /** 经纬高 → 该点东北天局部系(弹簧臂原语要求中心是局部地表点)。 */
+    function frameAt(pivot) {
+        return Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(pivot.lon, pivot.lat, pivot.height || 0))
+    }
+
+    return {
+        applyView(pivot, hpr) {
+            camera.lookAtTransform(
+                frameAt(pivot),
+                new HeadingPitchRange(hpr.heading * DEG, hpr.pitch * DEG, hpr.range),
+            )
+        },
+
+        /** 反读当前相机相对该中心的弹簧臂参数,用于挂载时对齐真实相机(避免首次交互跳变)。 */
+        readView(pivot) {
+            const inverse = Matrix4.inverseTransformation(frameAt(pivot), new Matrix4())
+            const local = Matrix4.multiplyByPoint(inverse, camera.positionWC, new Cartesian3())
+            const range = Cartesian3.magnitude(local)
+            if (!(range > 0)) return { heading: 0, pitch: -60, range: 1 }
+
+            const heading = CesiumMath.toDegrees(Math.atan2(local.x, local.y))
+            const pitch = -CesiumMath.toDegrees(Math.asin(Math.min(1, Math.max(-1, local.z / range))))
+            return { heading: (heading + 360) % 360, pitch, range }
+        },
+
+        /** 屏幕中心的地面拾取点:本票不配地形(票 21),按椭球求交;拾不到(看向天空)返回 null。 */
+        pickGroundAtCenter() {
+            const canvas = scene.canvas
+            const at = new Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2)
+            const ellipsoid = (scene.globe && scene.globe.ellipsoid) || Ellipsoid.WGS84
+            const hit = camera.pickEllipsoid(at, ellipsoid)
+            if (!hit) return null
+
+            const carto = ellipsoid.cartesianToCartographic(hit)
+            return { lon: CesiumMath.toDegrees(carto.longitude), lat: CesiumMath.toDegrees(carto.latitude), height: 0 }
+        },
+
+        /** 中心点在画布上的落点:走查用它断言「未选中时中心就是屏幕中心的地面拾取点」。 */
+        projectPivot(pivot) {
+            const at = SceneTransforms.worldToWindowCoordinates(scene, Cartesian3.fromDegrees(pivot.lon, pivot.lat, pivot.height || 0))
+            return at ? { x: at.x, y: at.y } : null
+        },
+
+        inputTarget() {
+            return viewer.canvas
+        },
+
+        viewport() {
+            return { width: scene.canvas.clientWidth, height: scene.canvas.clientHeight }
+        },
+
+        fovY() {
+            return camera.frustum.fovy || 60 * DEG
+        },
+
+        now() {
+            return typeof performance !== 'undefined' ? performance.now() : Date.now()
+        },
+
+        requestFrame(callback) {
+            frameHandle = requestAnimationFrame(callback)
+        },
+
+        cancelFrame() {
+            if (frameHandle !== null) {
+                cancelAnimationFrame(frameHandle)
+                frameHandle = null
+            }
+        },
+    }
+}

+ 86 - 0
src/modules/cockpit/services/mapService.js

@@ -11,12 +11,17 @@
  *   · 引擎没加载完就销毁 / 销毁后才加载完,都不会泄漏实例;
  *   · 底图未配置时如实标注「底图未配置」,引擎起不来时标注失败 —— 组件里的临时影像继续兜底,
  *     页面不白屏。
+ * 票 03 起相机也归本层:三键契约的实现(domain/camera.js + cameraControl.js)在引擎就绪后挂上,
+ * 对外只暴露 `setCameraFocus()` / `clearCameraFocus()` / `getCameraState()`,
+ * 组件与列表不需要知道 Cesium 方言;契约见 ADR 0008。
  * 接入配方见决策地图调研:`.scratch/web-cockpit-decision-map/research/cesium-fes-vite.md`。
  *
  * 本模块不依赖 Vue;对宿主元素的 DOM 操作都有 `isDomHost` 守卫,
  * 因此仍可脱离浏览器直接断言(见 verify/t01-panel-catalog.mjs)。
  */
 import { ensureCesiumBaseUrl } from './cesiumAssets.js'
+import { attachCameraInput, createCameraControl } from './cameraControl.js'
+import { formatCameraState } from '../domain/camera.js'
 
 /** 本票不做真底图(票 21 负责天地图/甲方瓦片);这里只保证「不伪造」。 */
 const BASEMAP_STATE = 'not-configured'
@@ -91,6 +96,34 @@ function setEngineState(record, state, error = '') {
     paintEngineState(record)
 }
 
+/** 相机状态写进宿主 dataset:走查、演示与人工排查都读这一行(票 03 要求交互可重复演示)。 */
+function paintCameraState(record, state) {
+    if (!isDomHost(record.host)) return
+    record.host.dataset.camera = formatCameraState(state)
+}
+
+/**
+ * 开发态把手:三类交互与「选中换中心」都能在控制台/走查里复现,不必点着鼠标盲试。
+ * 只在 DEV 且浏览器里挂,离开 /cockpit 即摘掉;生产构建不含它。
+ */
+function publishDevHook() {
+    const env = import.meta.env
+    if (!env || !env.DEV || typeof window === 'undefined') return
+    window.__cockpitMap = {
+        getCameraState,
+        setCameraFocus,
+        clearCameraFocus,
+        getLifecycleStats,
+        projectCameraPivot: () => (instance && instance.cameraControl ? instance.cameraControl.projectPivot() : null),
+    }
+}
+
+function clearDevHook() {
+    if (typeof window !== 'undefined' && window.__cockpitMap) {
+        delete window.__cockpitMap
+    }
+}
+
 /**
  * 异步加载引擎。全部逻辑都在 try 内,避免出现未处理的 Promise 拒绝(那会算页面级报错)。
  * @param {object} record mountMap 建立的实例记录
@@ -121,7 +154,18 @@ async function bootEngine(record) {
             engine.resizeViewer(viewer)
         }
 
+        // 票 03:相机契约落在服务层 —— 引擎就绪后才挂,默认输入已在 createViewer 里全关。
+        const cameraEngine = engine.createCameraEngine(viewer)
+        record.cameraControl = createCameraControl({
+            engine: cameraEngine,
+            onStateChange: (state) => paintCameraState(record, state),
+        })
+        record.detachCameraInput = attachCameraInput(cameraEngine.inputTarget(), record.cameraControl)
+        // 用当前真实相机反读弹簧臂参数,避免第一次交互时相机跳变(本票不设初始视角预设)。
+        record.cameraControl.syncFromEngine()
+
         setEngineState(record, 'ready')
+        publishDevHook()
     } catch (error) {
         if (instance !== record) return
         setEngineState(record, 'failed', String((error && error.message) || error))
@@ -147,6 +191,8 @@ export function mountMap(host) {
         viewer: null,
         statusNode: null,
         resizePending: false,
+        cameraControl: null,
+        detachCameraInput: null,
     }
 
     const record = instance
@@ -193,10 +239,24 @@ export function unmountMap() {
     instance = null
     destroyedCount += 1
 
+    clearDevHook()
+
     if (record.statusNode) {
         record.statusNode.remove()
         record.statusNode = null
     }
+    // 相机:先摘事件绑定再销毁实例,避免销毁后仍有输入打进来。
+    if (record.detachCameraInput) {
+        record.detachCameraInput()
+        record.detachCameraInput = null
+    }
+    if (record.cameraControl) {
+        record.cameraControl.dispose()
+        record.cameraControl = null
+    }
+    if (isDomHost(record.host)) {
+        delete record.host.dataset.camera
+    }
     if (record.viewer) {
         record.engine.destroyViewer(record.viewer)
         record.viewer = null
@@ -206,6 +266,25 @@ export function unmountMap() {
     engineError = ''
 }
 
+/**
+ * 选中/取消选中相机中心(票 03 验收第 4、5 条):从无选中到选中时平滑换中心并保持 heading 与 range;
+ * 传 null 表示取消选中 —— 中心不回退,只有左键平移才把中心更新为地面拾取点。
+ * @param {{lon: number, lat: number, height?: number}|null} point 对象地表点
+ */
+export function setCameraFocus(point) {
+    if (!instance || !instance.cameraControl) return false
+    return instance.cameraControl.setFocus(point || null)
+}
+
+export function clearCameraFocus() {
+    return setCameraFocus(null)
+}
+
+/** 当前相机状态快照(中心、弹簧臂参数、是否在拖动/飞行);引擎未就绪时为 null。 */
+export function getCameraState() {
+    return instance && instance.cameraControl ? instance.cameraControl.getState() : null
+}
+
 /** 可观测的生命周期计数:走查与单测据此断言「只创建一次、未重建」。 */
 export function getLifecycleStats() {
     return {
@@ -219,11 +298,18 @@ export function getLifecycleStats() {
         engine: engineState,
         engineError,
         basemap: BASEMAP_STATE,
+        // 票 03 追加:相机契约是否已挂上(三键输入、中心规则都在 cameraControl 内)。
+        camera: instance && instance.cameraControl ? 'attached' : 'none',
     }
 }
 
 /** 仅测试/走查用:重置模块内计数,避免同一进程内多次断言互相污染。 */
 export function resetLifecycleStatsForTest() {
+    if (instance) {
+        if (instance.detachCameraInput) instance.detachCameraInput()
+        if (instance.cameraControl) instance.cameraControl.dispose()
+        clearDevHook()
+    }
     instance = null
     sequence = 0
     createdCount = 0