소스 검색

fix(cockpit): T03 平移方向反转为「抓着地图拖」,三类交互加阻尼

维护者目视复验后裁定两条修改,均已落地:

- **平移方向反转**:原先按 ADR 0008 字面做成「相机跟着拖」(拖右 → 相机右移、画面左移),
  现改为**抓着地图拖**(拖右 → 画面右移、相机左移)。落地在 `PAN_FOLLOWS_DRAG = false`
  一处开关,方向语义集中在 `pivotOffsetMeters()`。**ADR 0008 同步改写**,
  否则下个会话照旧文读会把方向改回去。
- **阻尼**:三类交互都改成「目标态 / 实际态」双状态 —— 输入立刻写 `target`(不丢输入,
  位移量与方向仍可精确断言),相机 `shown` 按 `1 − e^(−dt/τ)` 朝目标收敛
  (`DAMPING_TAU_MS = 110`),差到 `SETTLE_EPSILON` 内吸附到目标。系数与帧率无关,
  帧间隔取自排帧时刻的真实时间戳(不是写死的每帧毫秒);`shown/target/settled`
  都进了 `getState()` 与 `data-camera`,所以「阻尼真的在生效」可被断言而非只凭手感。
- **手感现场可调**:DEV 把手加 `__cockpitMap.setCameraDamping(毫秒)`(`0` = 不做阻尼)。
  `mapService.js` 里对应的那行改动**未随本次提交**:该文件当前还带着并发会话
  「底图/配置」票的未提交改动(引用尚未提交的 `domain/basemap.js`、`services/cockpitConfig.js` 等),
  提交它会产出引用缺失模块的树。等那一路落地后,这行把手改动随它一起提交。

验证:`verify/t03-camera-contract.mjs` **14/14**(新增阻尼渐入/不超调/停稳吸附、
阻尼与帧率无关 60fps 83.8% vs 120fps 83.8%、用户操作打断飞行、阻尼可拖调三组断言);
回归 `t01-panel-catalog`、`t06-assets` 15/15。真机走查脚本已按新方向与阻尼更新
(A2 改为断言沿相机左手方向、新增 A10 阻尼在真机生效、量几何前统一等 `settled`),
**真机尚未跑**,票面进度保持 90%。
Kazerin 1 개월 전
부모
커밋
42200a4669

+ 14 - 3
.scratch/cockpit-shell-panel-layout/issues/03-camera-control-contract.md

@@ -24,6 +24,16 @@ Labels: ready-for-agent
 - [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` = 不做阻尼、直接跟手),τ 越小越跟手、越大越飘 —— 「丝滑」是主观量,控制台拖调比改代码快。
+
+改动文件:`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%
 
 ### 交付物
@@ -38,7 +48,8 @@ Labels: ready-for-agent
 ### 设计要点
 
 - **平移**按「中心点所在距离上的垂直可视高度」换算像素→米(`2·range·tan(fovY/2)/viewportH`),与 pitch 无关,4K/窗口尺寸变化都不用改灵敏度。
-- **ADR 0008 那句「拖拽方向映射为摄像机移动方向」落地为「相机跟着拖」**(拖右 → 相机右移 → 画面左移)。若维护者要改成「抓着地图拖」的手感,改 `PAN_FOLLOWS_DRAG` 一个常量即可,逻辑与走查都跟着它走。
+- **平移方向**由 `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 遗留里也记着)。
 - 中心缺失(看向天空、拾不到椭球)时不动相机,等下一次可拾取再建立中心。
@@ -54,9 +65,9 @@ Labels: ready-for-agent
 
 ### 验收证据
 
-- 逻辑:`node .scratch/cockpit-shell-panel-layout/verify/t03-camera-contract.mjs` → **10/10 通过**(左键只平移并按像素比例移动中心、平移方向 = 相机移动方向、右键只旋转且 range/中心不动、pitch 夹限、拖拽中锁定中心且松手后才换中心、滚轮只改距离并夹限、中心三规则、换中心 10 帧单调且 h/r 全程不变、DOM 绑定与默认行为抑制、状态串)。
+- 逻辑:`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,维护者裁定暂缓)
 

+ 189 - 64
.scratch/cockpit-shell-panel-layout/verify/t03-camera-contract.mjs

@@ -1,20 +1,22 @@
 /**
- * T03 逻辑检查:三键相机契约与旋转中心规则(票 03 / ADR 0008)。
+ * T03 逻辑检查:三键相机契约、旋转中心规则与阻尼(票 03 / ADR 0008)。
  *
  * 仓库没有测试运行器,且项目约定不为验证引入新框架,所以这里用零依赖 Node 断言脚本:
  * 相机语义全在 `domain/camera.js`(纯数学)与 `services/cameraControl.js`(状态迁移,引擎依赖注入),
  * 因此可以用**假引擎**驱动整套交互,不需要浏览器、不需要 cesium。
- * 真机行为(Cesium 默认输入确实被替换、中心确实落在屏幕中心)由 t03-camera-walkthrough.mjs 覆盖。
+ * 真机行为(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 { DAMPING_TAU_MS, 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 } // 沅陵某监测点
+const FRAME_MS = 1000 / 60
 
 function createFakeEngine({ viewport = { width: 1680, height: 900 }, fovY = Math.PI / 3, ground = GROUND } = {}) {
     const applied = []
@@ -51,9 +53,6 @@ function createFakeEngine({ viewport = { width: 1680, height: 900 }, fovY = Math
             requestFrame(callback) {
                 pendingFrame = callback
             },
-            cancelFrame() {
-                pendingFrame = null
-            },
         },
         get pendingFrame() {
             return pendingFrame
@@ -61,12 +60,13 @@ function createFakeEngine({ viewport = { width: 1680, height: 900 }, fovY = Math
         advance(ms) {
             clock += ms
         },
-        runFrame() {
+        /** 跑一帧:假的 rAF 不带时间戳,控制器会退回 `engine.now()`(真机用真时间戳)。 */
+        runFrame(dtMs = FRAME_MS) {
             const callback = pendingFrame
             pendingFrame = null
+            clock += dtMs
             if (callback) callback()
-        },
-        last() {
+        },        last() {
             return applied[applied.length - 1]
         },
     }
@@ -74,12 +74,23 @@ function createFakeEngine({ viewport = { width: 1680, height: 900 }, fovY = Math
 
 function newControl(options) {
     const fake = createFakeEngine(options)
-    const control = createCameraControl({ engine: fake.engine })
+    const control = createCameraControl({ engine: fake.engine, ...(options && options.control) })
     control.syncFromEngine()
     fake.applied.length = 0
     return { fake, control }
 }
 
+/** 一直跑到停稳(阻尼 + 换中心飞行都收敛),返回跑过的帧数。 */
+function settle(fake, control, maxFrames = 400) {
+    let frames = 0
+    while (!control.getState().settled && frames < maxFrames) {
+        fake.runFrame()
+        frames += 1
+    }
+    assert.ok(frames < maxFrames, `阻尼/飞行应在有限帧内停稳(已跑 ${frames} 帧仍未 settled)`)
+    return frames
+}
+
 /** 假元素:记录监听器,供 attachCameraInput 的绑定/解绑断言。 */
 function createFakeElement() {
     const handlers = new Map()
@@ -113,6 +124,11 @@ function createFakeElement() {
 const near = (a, b, tolerance, message) =>
     assert.ok(Math.abs(a - b) <= tolerance, `${message}:期望 ≈${b}(±${tolerance}),实际 ${a}`)
 
+const round3 = (n) => Number(n.toFixed(3))
+
+const metersPerPxAt = (range) => camera.metersPerPixel({ range, fovYRad: Math.PI / 3, viewportHeight: 900 })
+const degreesLonFor = (meters, lat) => meters / (111320 * Math.cos((lat * Math.PI) / 180))
+
 // ---------- 1. 左键拖拽只平移 ----------
 {
     const { fake, control } = newControl()
@@ -122,29 +138,33 @@ const near = (a, b, tolerance, message) =>
 
     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 时右拖应把中心按像素比例移到正东')
+    // 维护者 2026-09-17 裁定:**抓着地图拖** —— 拖右 → 相机左移、画面右移,所以中心往西(lon 减小)。
+    const expectedLon = before.pivot.lon - degreesLonFor(100 * metersPerPxAt(before.hpr.range), GROUND.lat)
+    near(after.pivot.lon, expectedLon, Math.abs(expectedLon - before.pivot.lon) * 0.02, '拖右应把中心按像素比例西移(相机左移)')
     near(after.pivot.lat, before.pivot.lat, 1e-6, 'heading=0 时右拖不应产生南北位移')
-    console.log('PASS  1 左键拖拽只平移(heading/pitch/range 原样,中心按像素比例东移)')
+
+    settle(fake, control)
+    near(fake.last().pivot.lon, expectedLon, 1e-9, '阻尼收敛后下发给相机的中心应等于目标')
+    console.log('PASS  1 左键拖拽只平移(heading/pitch/range 原样,中心按像素比例随「抓地图」方向西移)')
 }
 
-// ---------- 2. 平移方向 = 相机移动方向(PAN_FOLLOWS_DRAG 的语义钉子) ----------
+// ---------- 2. 平移方向 = 抓着地图拖(维护者裁定的语义钉子) ----------
 {
-    const { control } = newControl()
+    const { fake, 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)}`)
+    assert.ok(
+        down.pivot.lat > GROUND.lat,
+        `heading=0 时下拖应把中心往北带(画面下移、相机上移):${JSON.stringify(down.pivot)}`,
+    )
 
-    const { control: control2 } = newControl()
+    const { fake: fake2, control: control2 } = newControl()
     control2.beginDrag({ button: 2, x: 0, y: 0 })
-    // 先把 heading 拖到 90°(向东看):像素数按灵敏度反算,不写死倍数。
-    control2.moveDrag({ x: 90 / camera.ORBIT_SENSITIVITY.heading, y: 0 })
+    control2.moveDrag({ x: 90 / camera.ORBIT_SENSITIVITY.heading, y: 0 }) // 先把 heading 拖到 90°(向东看)
     control2.endDrag()
+    settle(fake2, control2)
     near(control2.getState().hpr.heading, 90, 1e-6, '轨道旋转应把 heading 拖到 90°')
 
     const beforePan = control2.getState()
@@ -152,11 +172,11 @@ const near = (a, b, tolerance, message) =>
     control2.moveDrag({ x: 100, y: 0 })
     const afterPan = control2.getState()
     assert.ok(
-        afterPan.pivot.lat < beforePan.pivot.lat,
-        `heading=90°(向东看)时右拖应把中心往南带(相机的右手方向):${JSON.stringify(afterPan.pivot)}`,
+        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 两点验证)')
+    console.log('PASS  2 平移方向 = 抓着地图拖(拖右→画面右移/相机左移,heading=0/90 两点验证)')
 }
 
 // ---------- 3. 右键拖拽只做轨道旋转 ----------
@@ -171,18 +191,23 @@ const near = (a, b, tolerance, message) =>
     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 也必须原样')
+
+    settle(fake, control)
+    near(fake.last().hpr.range, before.hpr.range, 1e-9, '阻尼收敛后下发的 range 仍应原样')
     console.log('PASS  3 右键拖拽只做轨道旋转(heading/pitch 按像素变化,range 与中心不动)')
 }
 
 // ---------- 4. pitch 夹在上下限内 ----------
 {
-    const { control } = newControl()
+    const { fake, 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, '一直上拖应夹在上限')
+
+    settle(fake, control)
+    near(fake.last().hpr.pitch, PITCH_LIMITS.ground.max, 1e-9, '阻尼收敛后下发的 pitch 也应在限内')
     console.log(`PASS  4 pitch 夹在 [${PITCH_LIMITS.ground.min}, ${PITCH_LIMITS.ground.max}](地面视图)`)
 }
 
@@ -195,24 +220,20 @@ const near = (a, b, tolerance, message) =>
     // 拖拽进行中来了「选中某对象」:中心不许被改,飞行也不许插进来。
     control.setFocus(XIANGLING)
     control.moveDrag({ x: 40, y: 0 })
-    assert.deepEqual(fake.last().pivot, locked, '拖拽中下发的中心必须仍是按下时锁定的那个')
+    assert.deepEqual(control.getState().pivot, locked, '拖拽中下发的中心必须仍是按下时锁定的那个')
 
     control.endDrag()
     assert.equal(control.getState().flying, true, '松手后应开始平滑换中心')
-    let guard = 0
-    while (control.getState().flying && guard++ < 200) {
-        fake.advance(50)
-        fake.runFrame()
-    }
+    settle(fake, control)
     const final = control.getState().pivot
-    near(final.lon, XIANGLING.lon, 1e-6, '飞行结束后中心应为选中对象')
-    near(final.lat, XIANGLING.lat, 1e-6, '飞行结束后中心应为选中对象')
+    near(final.lon, XIANGLING.lon, 1e-9, '飞行结束后中心应为选中对象')
+    near(final.lat, XIANGLING.lat, 1e-9, '飞行结束后中心应为选中对象')
     console.log('PASS  5 右键按下锁定中心(拖拽中的选中不插队;松手后才开始换中心)')
 }
 
 // ---------- 6. 滚轮只改弹簧臂长度 ----------
 {
-    const { control } = newControl()
+    const { fake, control } = newControl()
     const before = control.getState()
     const handled = control.wheel({ deltaY: -100 })
     const after = control.getState()
@@ -227,6 +248,9 @@ const near = (a, b, tolerance, message) =>
     near(control.getState().hpr.range, RANGE_LIMITS.min, 1e-9, '拉近到极限应夹住下限')
     control.wheel({ deltaY: 1e6 })
     near(control.getState().hpr.range, RANGE_LIMITS.max, 1e-9, '推远到极限应夹住上限')
+
+    settle(fake, control)
+    near(fake.last().hpr.range, RANGE_LIMITS.max, 1e-9, '阻尼收敛后下发的 range 应等于目标')
     console.log(`PASS  6 滚轮只改弹簧臂长度(并夹在 [${RANGE_LIMITS.min}, ${RANGE_LIMITS.max}] 米)`)
 }
 
@@ -236,11 +260,7 @@ const near = (a, b, tolerance, message) =>
     assert.deepEqual(control.getState().pivot, GROUND, '未选中时中心 = 屏幕中心的地面拾取点')
 
     control.setFocus(XIANGLING)
-    let guard = 0
-    while (control.getState().flying && guard++ < 200) {
-        fake.advance(60)
-        fake.runFrame()
-    }
+    settle(fake, control)
     assert.deepEqual(control.getState().pivot, XIANGLING, '选中后中心 = 对象地表点')
 
     control.setFocus(null)
@@ -261,13 +281,13 @@ const near = (a, b, tolerance, message) =>
     control.setFocus(XIANGLING)
 
     // 起手第一帧(elapsed = 0)必须仍停在原中心 —— 换中心不许瞬移。
-    near(fake.last().pivot.lon, before.pivot.lon, 1e-12, '换中心第一帧应从原中心出发')
+    fake.runFrame(0)
+    near(control.getState().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()
+    let frames = 0
+    while (control.getState().flying && frames++ < 200) {
+        fake.runFrame(FOCUS_FLIGHT_MS / 8)
         const now = control.getState()
         assert.equal(now.hpr.heading, before.hpr.heading, '换中心过程中 heading 必须保持')
         assert.equal(now.hpr.range, before.hpr.range, '换中心过程中 range 必须保持')
@@ -275,17 +295,122 @@ const near = (a, b, tolerance, message) =>
     }
 
     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(
+        samples.every((lon, i) => i === 0 || lon >= samples[i - 1] - 1e-12),
+        true,
+        `中心应朝目标单调推进:${samples.join(' → ')}`,
+    )
+    settle(fake, control)
+    near(control.getState().pivot.lon, XIANGLING.lon, 1e-9, '末帧应到达目标中心')
     assert.equal(control.getState().flying, false, '到达后应结束飞行')
     console.log(`PASS  8 换中心平滑(${samples.length} 帧单调推进、heading/range 全程不变)`)
 }
 
-// ---------- 9. DOM 绑定:三类输入都接上,右键菜单与滚轮默认行为被吃掉 ----------
+// ---------- 9. 阻尼:渐入、不超调、停稳、且与帧率无关 ----------
 {
-    const { control } = newControl()
+    const { fake, control } = newControl()
+    const before = control.getState()
+    control.beginDrag({ button: 0, x: 0, y: 0 })
+    control.moveDrag({ x: 400, y: 0 })
+    const target = control.getState()
+
+    // 目标立刻到位(输入不丢),实际态还在起点附近 —— 这就是「丝滑」的来源。
+    assert.notDeepEqual(target.pivot, before.pivot, '目标态应当立刻跟上输入')
+    assert.deepEqual(control.getState().shownPivot, before.pivot, '首个阻尼帧之前,实际态还在起点')
+
+    const targetShift = target.pivot.lon - before.pivot.lon
+    fake.runFrame(FRAME_MS)
+    const oneFrame = control.getState()
+    const oneFrameShift = oneFrame.shownPivot.lon - before.pivot.lon
+    assert.ok(
+        Math.abs(oneFrameShift) > 0 && Math.abs(oneFrameShift) < Math.abs(targetShift) * 0.6,
+        `一帧内应只走一小部分(实际 ${oneFrameShift} / 目标 ${targetShift})`,
+    )
+    assert.equal(oneFrame.settled, false, '一帧后还没停稳')
+
+    // 逼近过程不得超调(单调朝目标,不越过)。
+    let previous = Math.abs(target.pivot.lon - oneFrame.shownPivot.lon)
+    let frames = 1
+    while (!control.getState().settled && frames < 400) {
+        fake.runFrame(FRAME_MS)
+        const gap = Math.abs(target.pivot.lon - control.getState().shownPivot.lon)
+        assert.ok(gap <= previous + 1e-12, `阻尼不得超调:缺口从 ${previous} 变成 ${gap}`)
+        previous = gap
+        frames += 1
+    }
+    assert.equal(control.getState().settled, true, '有限帧内应停稳')
+    assert.deepEqual(control.getState().shownPivot, target.pivot, '停稳后实际态应吸附到目标')
+    console.log(`PASS  9a 阻尼渐入、不超调、${frames} 帧内停稳并吸附(τ=${DAMPING_TAU_MS}ms)`)
+
+    // 帧率无关:同样的真实时长,60fps 与 120fps 收敛到同一个位置(±1%)。
+    const walk = (dtMs, totalMs) => {
+        const { fake: f, control: c } = newControl()
+        const start = c.getState()
+        c.beginDrag({ button: 0, x: 0, y: 0 })
+        c.moveDrag({ x: 400, y: 0 })
+        // 固定帧数,别用浮点累加判停:累加误差会多跑一帧,把「帧率无关」测成假的偏差。
+        const frames = Math.round(totalMs / dtMs)
+        for (let i = 0; i < frames; i += 1) f.runFrame(dtMs)
+        const shift = c.getState().shownPivot.lon - start.pivot.lon
+        const full = c.getState().pivot.lon - start.pivot.lon
+        return shift / full
+    }
+    const at60 = walk(1000 / 60, 200)
+    const at120 = walk(1000 / 120, 200)
+    near(at60, at120, 0.01, '同样 200ms 内 60fps 与 120fps 的收敛比例应一致(±1%)')
+    assert.ok(at60 > 0.6 && at60 < 0.95, `200ms(≈1.8τ)应走掉大半但未走完:实际 ${at60}`)
+    console.log(`PASS  9b 阻尼与帧率无关(60fps ${(at60 * 100).toFixed(1)}% vs 120fps ${(at120 * 100).toFixed(1)}%)`)
+}
+
+// ---------- 10. 用户操作打断换中心飞行 ----------
+{
+    const { fake, control } = newControl()
+    control.setFocus(XIANGLING)
+    assert.equal(control.getState().flying, true, '应处于换中心飞行中')
+
+    control.beginDrag({ button: 0, x: 0, y: 0 })
+    assert.equal(control.getState().flying, false, '一上手就应打断飞行(输入永远优先)')
+    console.log('PASS  10 用户操作打断换中心飞行')
+}
+
+// ---------- 10b. 阻尼可现场拖调(手感是主观的,维护者要能在控制台试) ----------
+{
+    const { fake, control } = newControl()
+    assert.equal(control.getState().dampingTauMs, DAMPING_TAU_MS, '默认应取 domain 里的 τ')
+
+    // 调大 τ:同样一帧走得更少(更飘)。
+    const shiftWith = (tau) => {
+        const { fake: f, control: c } = newControl()
+        c.setDampingTau(tau)
+        const start = c.getState().pivot.lon
+        c.beginDrag({ button: 0, x: 0, y: 0 })
+        c.moveDrag({ x: 400, y: 0 })
+        f.runFrame(FRAME_MS)
+        return Math.abs(c.getState().shownPivot.lon - start)
+    }
+    const loose = shiftWith(400)
+    const tight = shiftWith(40)
+    assert.ok(tight > loose * 1.5, `τ 越小越跟手:τ=40 走 ${tight},τ=400 只走 ${loose}`)
+
+    // τ = 0:不做阻尼(一帧内直接贴到目标),给「要完全即时手感」的场合留的档。
+    const { fake: f2, control: c2 } = newControl()
+    c2.setDampingTau(0)
+    c2.beginDrag({ button: 0, x: 0, y: 0 })
+    c2.moveDrag({ x: 400, y: 0 })
+    f2.runFrame(FRAME_MS)
+    assert.deepEqual(c2.getState().shownPivot, c2.getState().pivot, 'τ=0 时一帧内实际态应贴到目标态')
+    assert.equal(c2.getState().settled, true, 'τ=0 时下完指令一帧即停稳')
+    assert.equal(f2.pendingFrame, null, 'τ=0 停稳后不应再排帧')
+
+    // 非法值不生效(守住旋钮)。
+    assert.equal(control.setDampingTau(-5), DAMPING_TAU_MS, '负数应被拒绝并保持原值')
+    assert.equal(control.setDampingTau('abc'), DAMPING_TAU_MS, '非数字应被拒绝并保持原值')
+    console.log(`PASS  10b 阻尼可拖调(τ=40 走 ${round3(tight)}° > τ=400 走 ${round3(loose)}°;τ=0 直接跟手)`)
+}
+
+// ---------- 11. DOM 绑定:三类输入都接上,右键菜单与滚轮默认行为被吃掉 ----------
+{
+    const { fake, control } = newControl()
     const element = createFakeElement()
     const detach = attachCameraInput(element, control)
 
@@ -295,13 +420,11 @@ const near = (a, b, tolerance, message) =>
         '应绑定左右键拖拽与滚轮(并吃掉右键菜单)',
     )
 
-    const menu = element.fire('contextmenu')
-    assert.equal(menu.defaultPrevented, true, '右键菜单必须被 preventDefault')
+    assert.equal(element.fire('contextmenu').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', '左键按下后应处于平移态')
+    assert.equal(control.getState().dragging, 'pan', '左键按下后应处于平移态')
     element.fire('pointerup', { pointerId: 1, clientX: 560, clientY: 400 })
     assert.equal(control.getState().dragging, null, '松手后应结束拖拽')
 
@@ -310,24 +433,26 @@ const near = (a, b, tolerance, message) =>
     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, '滚轮默认行为(页面滚动)必须被吃掉')
+    assert.equal(element.fire('wheel', { deltaY: -120 }).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 绑定:三类输入接上、右键菜单与滚轮默认行为被吃掉、可完整摘除')
+    console.log('PASS  11 DOM 绑定:三类输入接上、右键菜单与滚轮默认行为被吃掉、可完整摘除')
 }
 
-// ---------- 10. 状态串(走查与人工排查读的那一行) ----------
+// ---------- 12. 状态串(走查与人工排查读的那一行) ----------
 {
     const { control } = newControl()
     const text = camera.formatCameraState(control.getState())
-    ;['h=', 'p=', 'r=', 'pivot=', 'focus=ground', 'drag=none'].forEach((part) =>
+    ;['h=', 'p=', 'r=', 'pivot=', 'focus=ground', 'drag=none', 'settled='].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}`)
+    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  12 状态串可读:${text}`)
 }
 
-console.log('\nT03 逻辑检查通过:三键契约、中心规则、换中心动画、DOM 绑定均符合 ADR 0008。')
+console.log('\nT03 逻辑检查通过:三键契约、中心规则、换中心动画、阻尼(帧率无关)、DOM 绑定均符合契约与维护者裁定。')

+ 59 - 4
.scratch/cockpit-shell-panel-layout/verify/t03-camera-walkthrough.mjs

@@ -77,6 +77,14 @@ try {
         return { x: st.canvas.x + st.canvas.w / 2, y: st.canvas.y + st.canvas.h / 2 }
     }
 
+    /**
+     * 等阻尼收敛(实际相机跟上目标)。有了阻尼之后,手势结束不等于画面停稳,
+     * 量「中心是否落在画布中心」这类**实际态**几何之前必须先等 settled。
+     */
+    const waitSettled = async (timeout = 4000) => {
+        await page.waitForFunction(() => window.__cockpitMap.getCameraState().settled === true, { timeout }).catch(() => {})
+    }
+
     // ---------- A1 就绪与状态可读 ----------
     const base = await readState()
     check(
@@ -98,29 +106,32 @@ try {
     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)
+    await sleep(150)
+    await waitSettled()
     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)。
+        // 维护者 2026-09-17 裁定:**抓着地图拖** —— 拖右 → 画面右移、相机左移,
+        // 所以中心沿相机右手的**反方向**移动(alongRight 应为负)。
         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 不变,中心沿相机右手方向移动(默认左键旋转已被替换)',
+            '左键拖拽只平移: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,
+                alongRight < 0,
             JSON.stringify({
                 hBefore: round(a2before.hpr.heading),
                 hAfter: round(a2after.hpr.heading),
                 pivotBefore: a2before.pivot,
                 pivotAfter: a2after.pivot,
-                rightSign: alongRight > 0 ? '沿右手方向' : '反向',
+                direction: alongRight < 0 ? '沿相机左手方向(画面右移)' : '方向反了',
             }),
         )
     }
@@ -134,6 +145,7 @@ try {
     await page.mouse.move(c3.x + 120, c3.y - 40, { steps: 8 })
     await page.mouse.up({ button: 'right' })
     await sleep(120)
+    await waitSettled()
     const a3after = await readState()
 
     {
@@ -179,6 +191,7 @@ try {
     )
 
     // ---------- A5 未选中时中心 = 屏幕中心的地面拾取点 ----------
+    await waitSettled()
     const a5 = await readState()
     {
         const cx = a5.canvas.x + a5.canvas.w / 2
@@ -251,9 +264,11 @@ try {
     }
 
     // ---------- A7 取消选中不回退中心 ----------
+    await waitSettled()
     const a7before = await readState()
     await page.evaluate(() => window.__cockpitMap.clearCameraFocus())
     await sleep(200)
+    await waitSettled()
     const a7after = await readState()
 
     check(
@@ -272,6 +287,7 @@ try {
     await page.mouse.move(c8.x + 40, c8.y + 20, { steps: 5 })
     await page.mouse.up({ button: 'left' })
     await sleep(150)
+    await waitSettled()
     const a8 = await readState()
     check(
         'A8-pan-returns-pivot-to-ground',
@@ -283,6 +299,45 @@ try {
         JSON.stringify({ pivot: a8.pivot, projection: a8.projection }),
     )
 
+    // ---------- A10 阻尼在真机上生效(目标立刻跟手、实际态渐入收敛) ----------
+    const c10 = await center()
+    const damping = await page.evaluate(async (point) => {
+        const read = () => window.__cockpitMap.getCameraState()
+        window.__cockpitMap.setCameraFocus(point)
+        await new Promise((r) => setTimeout(r, 40))
+        const start = read()
+
+        const samples = []
+        const began = performance.now()
+        while (performance.now() - began < 2000) {
+            const s = read()
+            samples.push({ t: Math.round(performance.now() - began), settled: s.settled, flying: s.flying, lon: s.pivot.lon, shownLon: s.shownPivot ? s.shownPivot.lon : null })
+            if (s.settled) break
+            await new Promise((r) => setTimeout(r, 30))
+        }
+        return { start, samples }
+    }, XIANGLING)
+
+    {
+        const s = damping.samples
+        const last = s[s.length - 1]
+        // 实际态必须**滞后于**目标态:中途至少采样到一次 shown != target(否则就是瞬移,没有阻尼)。
+        const lagged = s.some((x) => x.shownLon !== null && Math.abs(x.shownLon - x.lon) > 1e-9)
+        // 且滞后是收敛的:缺口单调缩小到 0。
+        const gaps = s.filter((x) => x.shownLon !== null).map((x) => Math.abs(x.lon - x.shownLon))
+        const shrinking = gaps.every((g, i) => i === 0 || g <= gaps[i - 1] + 1e-12)
+        check(
+            'A10-damping-is-live',
+            '阻尼在真机上生效:实际相机滞后目标态、缺口单调收敛,最终 settled 且吸附到目标',
+            lagged &&
+                gaps.length >= 2 &&
+                shrinking &&
+                last.settled === true &&
+                Math.abs(last.lon - XIANGLING.lon) < 1e-6,
+            JSON.stringify({ frames: s.length, lagged, firstGap: round(gaps[0] || 0, 6), lastGap: round(gaps[gaps.length - 1] || 0, 6) }),
+        )
+    }
+
     check('A9-no-page-errors', '走查期间无页面级报错', pageErrors.length === 0, JSON.stringify(pageErrors.slice(0, 3)))
 } finally {
     await browser.close()

+ 2 - 1
docs/adr/0008-web-cockpit-camera-controls.md

@@ -6,9 +6,10 @@ status: accepted
 
 驾驶舱三维地图使用显式相机控制契约:
 
-- 鼠标左键拖拽:在三维场景中执行摄像机平移;拖拽方向映射为摄像机移动方向,不旋转、不缩放。
+- 鼠标左键拖拽:在三维场景中执行摄像机平移;**抓着地图拖** —— 拖右则画面右移、相机左移(与“相机跟着拖”相反,2026-09-17 维护者目视验收后裁定反转),不旋转、不缩放。
 - 鼠标右键拖拽:摄像机围绕“弹簧臂根部/旋转中心”做轨道旋转;旋转过程中不平移、不缩放。
 - 鼠标滚轮:只改变摄像机与旋转中心的距离,即弹簧臂长度;靠近或远离场景目标,不改变中心点,不旋转。
+- 三类交互都 SHALL 带**阻尼**(2026-09-17 维护者追加):输入先落到“目标态”,相机实际态按帧率无关的指数收敛(`1 − e^(−dt/τ)`,τ 见 `domain/camera.js` 的 `DAMPING_TAU_MS`)逐步跟上,起步渐入、松手有余韵,停稳后吸附到目标;阻尼只改变到达过程的形状,不改变方向与位移量。
 - Cesium 默认左键旋转、滚轮缩放等行为 SHALL 被替换;相机控制应集中在地图服务层,而不是散落在页面组件中。
 
 旋转中心(弹簧臂根部)的定义:

+ 73 - 4
src/modules/cockpit/domain/camera.js

@@ -29,10 +29,10 @@ export const WHEEL_SENSITIVITY = 0.0012
 export const FOCUS_FLIGHT_MS = 700
 
 /**
- * ADR 0008:「拖拽方向映射为摄像机移动方向」→ 相机跟着拖拽方向走(拖右 → 相机右移、画面左移)。
- * 若维护者要改成「抓着地图拖」(画面跟着手指、相机反向),把这里取反即可 —— 只此一处。
+ * 平移方向(维护者 2026-09-17 裁定):**抓着地图拖** —— 拖右 → 相机左移、画面右移。
+ * 取 `true` 则是相反手感(相机跟着拖:拖右 → 相机右移、画面左移)。只此一处开关。
  */
-export const PAN_FOLLOWS_DRAG = true
+export const PAN_FOLLOWS_DRAG = false
 
 const DEG = Math.PI / 180
 const METERS_PER_DEG_LAT = 110540
@@ -104,6 +104,74 @@ export function shiftPivot(pivot, { east = 0, north = 0 }) {
     }
 }
 
+/**
+ * 阻尼时间常数(毫秒):相机实际态朝目标态收敛的特征时间。
+ * 越大越「飘/丝滑」,越小越跟手。手感要更跟手就调小、要更重就调大 —— 只此一处。
+ */
+export const DAMPING_TAU_MS = 110
+
+/** 单帧 dt 上限:切标签页回来时别让相机一次跳半张地图。 */
+export const MAX_FRAME_MS = 120
+
+/** 收口阈值:实际态与目标态差到这么小即视为停稳(贴齐目标,避免无限逼近)。 */
+export const SETTLE_EPSILON = {
+    headingDeg: 0.01,
+    pitchDeg: 0.01,
+    rangeMeters: 0.5,
+    lonLatDeg: 1e-7,
+}
+
+/**
+ * 帧率无关的阻尼系数:`1 - e^(-dt/τ)`。
+ * 同一段真实时间,30fps 与 144fps 得到同样的收敛比例 —— 手感不随机器性能变。
+ */
+export function dampFactor(dtMs, tauMs = DAMPING_TAU_MS) {
+    if (!(tauMs > 0)) return 1
+    return 1 - Math.exp(-Math.min(MAX_FRAME_MS, Math.max(0, dtMs)) / tauMs)
+}
+
+/** heading 走最短弧:350° → 10° 应是 +20°,不是 −340°(否则画面会绕远路转一大圈)。 */
+export function shortestAngleDelta(fromDeg, toDeg) {
+    return ((toDeg - fromDeg + 540) % 360) - 180
+}
+
+export function approachScalar(from, to, factor) {
+    return from + (to - from) * factor
+}
+
+/** 让实际弹簧臂参数朝目标推进一步(heading 走最短弧)。 */
+export function approachHpr(from, to, factor) {
+    const delta = shortestAngleDelta(from.heading, to.heading)
+    return {
+        heading: normalizeHeading(approachScalar(from.heading, from.heading + delta, factor)),
+        pitch: approachScalar(from.pitch, to.pitch, factor),
+        range: approachScalar(from.range, to.range, factor),
+    }
+}
+
+/** 让实际中心朝目标中心推进一步。 */
+export function approachPivot(from, to, factor) {
+    return {
+        lon: approachScalar(from.lon, to.lon, factor),
+        lat: approachScalar(from.lat, to.lat, factor),
+        height: approachScalar(from.height || 0, to.height || 0, factor),
+    }
+}
+
+/** 实际态是否已跟上目标态(差到阈值内即停稳)。 */
+export function isSettled(shown, target, epsilon = SETTLE_EPSILON) {
+    if (!shown || !target) return true
+    if (!shown.pivot || !target.pivot) return !shown.pivot && !target.pivot
+    return (
+        Math.abs(shortestAngleDelta(shown.hpr.heading, target.hpr.heading)) <= epsilon.headingDeg &&
+        Math.abs(shown.hpr.pitch - target.hpr.pitch) <= epsilon.pitchDeg &&
+        Math.abs(shown.hpr.range - target.hpr.range) <= epsilon.rangeMeters &&
+        Math.abs(shown.pivot.lon - target.pivot.lon) <= epsilon.lonLatDeg &&
+        Math.abs(shown.pivot.lat - target.pivot.lat) <= epsilon.lonLatDeg
+    )
+}
+
+/** 缓动:起步与收尾都慢,中段快 —— 换中心飞行用。 */
 export function easeInOutCubic(t) {
     return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
 }
@@ -123,7 +191,7 @@ export function lerpPivot(from, to, t) {
 }
 
 /** 把当前相机状态压成一行写进宿主 dataset:走查、演示与人工排查都读它。 */
-export function formatCameraState({ pivot, hpr, focus, dragging }) {
+export function formatCameraState({ pivot, hpr, focus, dragging, settled }) {
     if (!pivot) {
         return `h=${hpr.heading.toFixed(1)} p=${hpr.pitch.toFixed(1)} r=${Math.round(hpr.range)} pivot=none`
     }
@@ -135,5 +203,6 @@ export function formatCameraState({ pivot, hpr, focus, dragging }) {
         `pivot=${pivot.lon.toFixed(5)},${pivot.lat.toFixed(5)}`,
         `focus=${focusText}`,
         `drag=${dragging || 'none'}`,
+        `settled=${settled ? 'yes' : 'no'}`,
     ].join(' ')
 }

+ 178 - 69
src/modules/cockpit/services/cameraControl.js

@@ -1,25 +1,38 @@
 /**
  * 相机控制(票 03 / ADR 0008):把三键输入迁移成相机状态,并下发给引擎。
  *
+ * 两层状态(维护者 2026-09-17 要求「移动与旋转更丝滑」):
+ *   · `target` —— 输入意图:拖拽/滚轮直接写它,永远跟手、不丢输入;
+ *   · `shown`  —— 实际下发给相机的状态:按 `1 − e^(−dt/τ)` 朝 target 收敛,
+ *     τ 见 `DAMPING_TAU_MS`。这个系数与帧率无关,所以 30fps 与 144fps 手感一致;
+ *     差到 `SETTLE_EPSILON` 以内就吸附到 target,避免无限逼近。
+ * 因此「拖右画面右移」这类方向、位移量仍由 target 精确决定(可断言),
+ * 阻尼只改变**到达过程的形状**(起步渐入、松手后有一点余韵)。
+ *
  * 为什么独立于 `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()   动画帧
+ *   applyView(pivot, hpr)          把「旋转中心 + 弹簧臂」下发给相机
+ *   readView(pivot)                反读当前相机相对该中心的 { heading, pitch, range }
+ *   pickGroundAtCenter()           屏幕中心的地面拾取点(无地形时按椭球),拾不到返回 null
+ *   projectPivot(pivot)            中心点在画布上的落点(走查断言「中心 = 屏幕中心」用)
+ *   inputTarget()                  事件绑定目标(画布)
+ *   viewport() / fovY()            画布尺寸与垂直视场角(平移像素→米的换算)
+ *   now() / requestFrame()         动画帧
  */
 import {
+    DAMPING_TAU_MS,
     FOCUS_FLIGHT_MS,
     PITCH_LIMITS,
+    approachHpr,
+    approachPivot,
     clampPitch,
+    dampFactor,
     flightProgress,
+    isSettled,
     lerpPivot,
     metersPerPixel,
     orbit,
@@ -35,63 +48,141 @@ const INITIAL_HPR = { heading: 0, pitch: -60, range: 20000000 }
  * @param {object} options
  * @param {object} options.engine 上面的引擎接口
  * @param {object} [options.limits] pitch 上下限(地面视图 / 管线视图)
+ * @param {number} [options.dampingTauMs] 阻尼时间常数,越大越丝滑
  * @param {(state: object) => void} [options.onStateChange] 状态变化回调(写 dataset 用)
  */
-export function createCameraControl({ engine, limits = PITCH_LIMITS.ground, onStateChange = null }) {
+export function createCameraControl({
+    engine,
+    limits = PITCH_LIMITS.ground,
+    dampingTauMs = DAMPING_TAU_MS,
+    onStateChange = null,
+}) {
     const state = {
-        pivot: null,
-        hpr: { ...INITIAL_HPR },
+        target: { pivot: null, hpr: { ...INITIAL_HPR } },
+        shown: { pivot: null, hpr: { ...INITIAL_HPR } },
         focus: null,
         drag: null, // null | 'pan' | 'orbit'
         lockedPivot: null,
         flight: null, // { from, to, startedAt }
         pendingFocus: null, // 拖拽中来的选中,等松手再飞
         last: null, // 上一次指针位置
+        lastFrameAt: 0,
+        loopActive: false,
+        tau: dampingTauMs,
+    }
+
+    function snapshotTarget() {
+        return {
+            pivot: state.target.pivot ? { ...state.target.pivot } : null,
+            hpr: { ...state.target.hpr },
+        }
+    }
+
+    function snapshotShown() {
+        return {
+            pivot: state.shown.pivot ? { ...state.shown.pivot } : null,
+            hpr: { ...state.shown.hpr },
+        }
+    }
+
+    function settled() {
+        return isSettled(snapshotShown(), snapshotTarget())
     }
 
     function notify() {
         if (onStateChange) onStateChange(getState())
     }
 
-    /** 下发到相机并通知观察者。 */
-    function apply() {
-        if (state.pivot) engine.applyView(state.pivot, state.hpr)
+    /** 把 shown 下发给引擎并通知观察者。 */
+    function render() {
+        if (!state.shown.pivot) {
+            notify()
+            return
+        }
+        engine.applyView(state.shown.pivot, state.shown.hpr)
         notify()
     }
 
-    /** 只通知、不下发:挂载时用实读状态刷新 dataset,但不许把默认视角瞬间挪走。 */
-    function notifyOnly() {
-        notify()
+    function schedule() {
+        if (state.loopActive) return
+        state.loopActive = true
+        // 以「排帧时刻」为基准:帧间隔由真实时间戳决定,而不是写死每帧多少毫秒
+        // —— 这是阻尼与帧率无关的前提(30fps 与 144fps 走同样的比例)。
+        state.lastFrameAt = engine.now()
+        engine.requestFrame(loop)
+    }
+
+    /** 唯一的动画循环:推进换中心飞行 + 推进阻尼 + 下发,未停稳就继续排帧。 */
+    function loop(frameTime) {
+        state.loopActive = false
+        // 真机 rAF 会带时间戳;假引擎(逻辑断言)不带,退回引擎时钟。
+        const now = typeof frameTime === 'number' ? frameTime : engine.now()
+        // 下限 1ms:时间戳相同(同毫秒内两次回调)时也要推进,否则会空转排帧。
+        const dt = Math.max(1, now - state.lastFrameAt)
+        state.lastFrameAt = now
+
+        advanceFlight(now)
+        advanceDamping(dt)
+        render()
+
+        if (state.flight || !settled()) schedule()
+    }
+
+    /** 换中心:target 的中心从 from 平滑推到 to(保持 heading / pitch / range 不动)。 */
+    function advanceFlight(now) {
+        if (!state.flight) return
+        const { from, to, startedAt } = state.flight
+        const progress = flightProgress(now - startedAt, FOCUS_FLIGHT_MS)
+        state.target.pivot = lerpPivot(from, to, progress)
+        if (progress >= 1) state.flight = null
+    }
+
+    /** 阻尼:shown 朝 target 收敛一步;已停稳就吸附,避免无限逼近。 */
+    function advanceDamping(dtMs) {
+        const factor = dampFactor(dtMs, state.tau)
+        state.shown.hpr = approachHpr(state.shown.hpr, state.target.hpr, factor)
+
+        if (state.target.pivot && !state.shown.pivot) {
+            state.shown.pivot = { ...state.target.pivot }
+        } else if (state.target.pivot) {
+            state.shown.pivot = approachPivot(state.shown.pivot, state.target.pivot, factor)
+        }
+
+        if (settled()) {
+            state.shown.hpr = { ...state.target.hpr }
+            state.shown.pivot = state.target.pivot ? { ...state.target.pivot } : null
+        }
     }
 
     /** 中心点缺失时现取一次(默认全球视角下屏幕中心多半能拾到椭球交点)。 */
     function ensurePivot() {
-        if (!state.pivot) {
-            state.pivot = engine.pickGroundAtCenter()
+        if (!state.target.pivot) {
+            const picked = engine.pickGroundAtCenter()
+            state.target.pivot = picked
+            if (picked && !state.shown.pivot) state.shown.pivot = { ...picked }
         }
-        return state.pivot
+        return state.target.pivot
     }
 
+    /** 用户开始新操作时打断换中心飞行(阻尼循环由各输入入口自己排帧)。 */
     function cancelFlight() {
-        if (state.flight) {
-            state.flight = null
-            engine.cancelFrame()
-        }
+        state.flight = null
     }
 
     /** 挂载后调用:中心 = 屏幕中心地面拾取点,弹簧臂参数按当前真实相机反读,避免首次交互跳变。 */
     function syncFromEngine() {
         const pivot = engine.pickGroundAtCenter()
         if (pivot) {
-            state.pivot = pivot
             const read = engine.readView(pivot)
-            state.hpr = {
+            const hpr = {
                 heading: read.heading,
                 pitch: clampPitch(read.pitch, limits),
                 range: read.range,
             }
+            state.target = { pivot: { ...pivot }, hpr: { ...hpr } }
+            state.shown = { pivot: { ...pivot }, hpr: { ...hpr } }
         }
-        notifyOnly()
+        notify()
         return getState()
     }
 
@@ -103,13 +194,13 @@ export function createCameraControl({ engine, limits = PITCH_LIMITS.ground, onSt
         if (button === 2) {
             // ADR 0008:右键按下时锁定当时的中心,拖拽中不重新拾取。
             ensurePivot()
-            state.lockedPivot = state.pivot ? { ...state.pivot } : null
+            state.lockedPivot = state.target.pivot ? { ...state.target.pivot } : null
             state.drag = state.lockedPivot ? 'orbit' : null
             return
         }
         if (button === 0) {
             ensurePivot()
-            state.drag = state.pivot ? 'pan' : null
+            state.drag = state.target.pivot ? 'pan' : null
             return
         }
         state.drag = null
@@ -126,22 +217,22 @@ export function createCameraControl({ engine, limits = PITCH_LIMITS.ground, onSt
         if (state.drag === 'pan') {
             const viewport = engine.viewport()
             const metersPerPx = metersPerPixel({
-                range: state.hpr.range,
+                range: state.target.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()
+            const offset = pivotOffsetMeters({ headingDeg: state.target.hpr.heading, dxPx, dyPx, metersPerPx })
+            // 平移只移动中心:heading / pitch / range 原样带走;方向见 domain/camera.js 的 PAN_FOLLOWS_DRAG。
+            state.target.pivot = shiftPivot(state.target.pivot, offset)
+            schedule()
             return
         }
 
         if (state.drag === 'orbit') {
-            state.hpr = orbit(state.hpr, { dxPx, dyPx }, limits)
+            state.target.hpr = orbit(state.target.hpr, { dxPx, dyPx }, limits)
             // 中心保持按下时锁定的那个点,range 不变。
-            state.pivot = state.lockedPivot
-            apply()
+            state.target.pivot = state.lockedPivot
+            schedule()
         }
     }
 
@@ -158,22 +249,22 @@ export function createCameraControl({ engine, limits = PITCH_LIMITS.ground, onSt
         if (mode === 'pan') {
             // ADR 0008:只有左键平移才把中心更新为地面拾取点;平移后相机中心不再跟随选中对象。
             const picked = engine.pickGroundAtCenter()
-            if (picked) state.pivot = picked
+            if (picked) state.target.pivot = picked
             state.focus = null
-            apply()
+            schedule()
             return
         }
 
-        apply()
         // 轨道旋转期间来的选中,等松手后再平滑换中心(拖拽中中心一直是锁定的那个)。
         if (pending) startFlight(pending)
+        schedule()
     }
 
     function wheel({ deltaY }) {
         cancelFlight()
         if (!ensurePivot()) return false
-        state.hpr = zoomByWheel(state.hpr, deltaY)
-        apply()
+        state.target.hpr = zoomByWheel(state.target.hpr, deltaY)
+        schedule()
         return true
     }
 
@@ -203,54 +294,72 @@ export function createCameraControl({ engine, limits = PITCH_LIMITS.ground, onSt
 
     /** 平滑换中心:保持 heading / pitch / range,只把中心从当前位置推到目标。 */
     function startFlight(target) {
-        if (!state.pivot) {
-            state.pivot = target
-            apply()
+        if (!state.target.pivot) {
+            state.target.pivot = target
+            state.shown.pivot = { ...target }
+            render()
             return true
         }
-        if (state.pivot.lon === target.lon && state.pivot.lat === target.lat) {
-            apply()
+        if (state.target.pivot.lon === target.lon && state.target.pivot.lat === target.lat) {
             return true
         }
 
-        state.flight = { from: { ...state.pivot }, to: target, startedAt: engine.now() }
-        tick()
+        state.flight = { from: { ...state.target.pivot }, to: target, startedAt: engine.now() }
+        schedule()
         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)
-    }
-
+    /**
+     * 返回相机状态。`pivot`/`hpr` 保持「输入意图」(target,位移量与方向可精确断言),
+     * `shown`/`shownPivot`/`shownHpr` 是阻尼后真正下发给相机的状态;
+     * `settled` = 既没有待推进的换中心飞行、阻尼也已跟上目标。
+     */
     function getState() {
+        const target = snapshotTarget()
+        const shown = snapshotShown()
         return {
-            pivot: state.pivot ? { ...state.pivot } : null,
-            hpr: { ...state.hpr },
+            pivot: target.pivot,
+            hpr: target.hpr,
+            target,
+            shown,
+            shownPivot: shown.pivot,
+            shownHpr: shown.hpr,
+            settled: !state.flight && isSettled(shown, target),
             focus: state.focus ? { ...state.focus } : null,
             dragging: state.drag,
             flying: !!state.flight,
+            /** 当前阻尼时间常数(毫秒);0 = 不做阻尼。 */
+            dampingTauMs: state.tau,
         }
     }
 
     function projectPivot() {
-        return state.pivot ? engine.projectPivot(state.pivot) : null
+        const pivot = state.shown.pivot || state.target.pivot
+        return pivot ? engine.projectPivot(pivot) : null
+    }
+
+    /**
+     * 调阻尼时间常数(毫秒):手感是主观的,所以在 DEV 把手/控制台里能现场拖调
+     * (`__cockpitMap.setCameraDamping(240)`),不用改代码再看。0 表示不做阻尼(直接跟手)。
+     */
+    function setDampingTau(tauMs) {
+        const next = Number(tauMs)
+        if (!Number.isFinite(next) || next < 0) return state.tau
+        state.tau = next
+        if (next === 0) {
+            state.shown = snapshotTarget()
+            render()
+        } else {
+            schedule()
+        }
+        return state.tau
     }
 
     function dispose() {
-        cancelFlight()
+        state.flight = null
         state.drag = null
         state.lockedPivot = null
+        state.pendingFocus = null
         state.last = null
     }
 
@@ -261,9 +370,9 @@ export function createCameraControl({ engine, limits = PITCH_LIMITS.ground, onSt
         endDrag,
         wheel,
         setFocus,
-        tick,
         getState,
         projectPivot,
+        setDampingTau,
         dispose,
     }
 }