Просмотр исходного кода

fix(cockpit): T03 平移松手恢复惯性(松手不再改写目标)

维护者第二次复验反馈:按住左键拖拽时有阻尼,但**松开后画面直接停住、没有惯性**,
与滚轮缩放、右键旋转的手感不一致。

根因:`endDrag()` 里为了「平移才把中心交回地面拾取点」,把目标直接改写为
`pickGroundAtCenter()`。而阻尼攒下的滞后在恰好等于这段位移时,目标被拽回当前实际位置,
滞后当场清零 —— 于是没有滑行;滚轮与右键没有这一步改写,所以会继续滑到目标。

修法(条件重锚):
- 偏差 ≤ `range × REANCHOR_LIMIT_RATIO`(1%,肉眼不可见)时才把中心重锚到拾取点,
  顺手清掉等距近似的累积误差;
- 偏差过大时保持推算值 —— 宁可留着近似,也不让画面在松手瞬间跳一下;
- 目标不再被无条件改写,惯性由阻尼自然延续(与滚轮/右键一致)。

新增 `pivotDistanceMeters()`(域内粗略地表距离,供重锚判断)与 `REANCHOR_LIMIT_RATIO`。

验证:`verify/t03-camera-contract.mjs` **17/17**,新增四条:
- 平移松手有惯性(松手时滞后 0.0383°,松手后又滑行 0.0329°、85 帧停稳);
- 滚轮与右键同样有余韵(一帧后 range 滞后 4410m / heading 滞后 48.1°)→ 三种交互手感一致;
- 条件重锚:偏差可忽略时重锚且**不动实际态**(不许把画面拽回去);
- 大偏差时不重锚(避免画面瞬移)。
回归 `t01-panel-catalog`、`t06-assets` 15/15。票面进度 95%:实现与逻辑闸门全绿,
待维护者人工验收(其已明确「解决惯性问题后人工验收通过即算本票通过」)。
Kazerin 1 месяц назад
Родитель
Сommit
d0f8869771

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

@@ -31,12 +31,15 @@ Labels: ready-for-agent
 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` = 不做阻尼、直接跟手),τ 越小越跟手、越大越飘 —— 「丝滑」是主观量,控制台拖调比改代码快。
+4. **平移松手有惯性**(2026-09-17 第二次复验反馈):原实现里 `endDrag` 会把目标改写为「屏幕中心的地面拾取点」,而阻尼攒下的滞后正好等于这段位移——目标被拽回当前实际位置,滞后当场清零,表现为**松手即停、没有惯性**;滚轮与右键没有这一步改写,所以会继续滑到目标,三种交互手感不一致。修法:松手只做**条件重锚**——偏差 ≤ `range × REANCHOR_LIMIT_RATIO`(1%,即肉眼不可见)时才把中心重锚到拾取点(顺手清掉等距近似的累积误差),偏差过大就保持推算值,宁可不重锚也不让画面跳。于是松手后目标不再被改写,惯性由阻尼自然延续。
 
 改动文件:`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`。
 
-提交情况(`42200a4`,2026-09-17):除 `mapService.js` 外均已提交。`mapService.js` 里那一行 `__cockpitMap.setCameraDamping` 把手**故意没随本次提交** —— 该文件当时还带着并发会话「底图/配置」票的未提交改动(引用尚未提交的 `domain/basemap.js`、`services/cockpitConfig.js` 等),单独提交会产出引用缺失模块的树。维护者裁定:**等那一路落地后随它一起提交**(那行改动内容极小,落地前 `setCameraDamping` 只影响 DEV 调参便利性,不影响契约本身)。
+提交情况(2026-09-17):方向反转 + 阻尼一次提交,平移惯性修正紧随其后一次(`git log` 里搜「松手有惯性」)。`mapService.js` 里那一行 `__cockpitMap.setCameraDamping` 把手**故意没随这两次提交** —— 该文件当时还带着并发会话「底图/配置」票的未提交改动(引用尚未提交的 `domain/basemap.js`、`services/cockpitConfig.js` 等),单独提交会产出引用缺失模块的树。维护者裁定:**等那一路落地后随它一起提交**(那行改动内容极小,落地前 `setCameraDamping` 只影响 DEV 调参便利性,不影响契约本身)。
 
-## 进度:90%
+## 进度:95%
+
+维护者已明确:**解决平移惯性问题后由其人工验收,验收通过即算本票验收通过**。实现与逻辑闸门(17/17)全绿,待人工验收 + 真机走查。
 
 ### 交付物
 
@@ -67,7 +70,7 @@ Labels: ready-for-agent
 
 ### 验收证据
 
-- 逻辑:`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 绑定与默认行为抑制、状态串)。
+- 逻辑:`node .scratch/cockpit-shell-panel-layout/verify/t03-camera-contract.mjs` → **17/17 通过**(左键只平移且方向为「抓着地图拖」、平移方向在 heading=0/90 两点验证、右键只旋转且 range/中心不动、pitch 夹限、拖拽中锁定中心且松手后才换中心、滚轮只改距离并夹限、中心三规则、换中心 8 帧单调且 h/r 全程不变、**阻尼渐入不超调 87 帧停稳并吸附**、**阻尼与帧率无关(60fps 83.8% vs 120fps 83.8%)**、**平移松手有惯性(松手时滞后 0.0383°,松手后又滑行 0.0329°、85 帧停稳)**、**滚轮与右键同样有余韵(range 滞后 4410m / heading 滞后 48.1°)**、**条件重锚(1% 内才重锚、大偏差不跳画面)**、用户操作打断飞行、**阻尼可拖调(τ=40 比 τ=400 跟手 9 倍,τ=0 一帧贴齐)**、DOM 绑定与默认行为抑制、状态串)。
 - 回归:`t01-panel-catalog.mjs` 通过(`getLifecycleStats` 新增 `camera` 字段,原字段语义未变)、`t06-assets.mjs` 15/15。
 - 真机:**待跑**(见下一步)。脚本已按新方向与阻尼更新:A2 改为断言中心沿**相机左手**方向移动、新增 A10「阻尼在真机上生效」(实际态滞后目标态且缺口单调收敛),并在量几何前统一等 `settled`(有阻尼后手势结束不等于画面停稳)。
 

+ 103 - 2
.scratch/cockpit-shell-panel-layout/verify/t03-camera-contract.mjs

@@ -22,6 +22,7 @@ function createFakeEngine({ viewport = { width: 1680, height: 900 }, fovY = Math
     const applied = []
     let clock = 1000
     let pendingFrame = null
+    let groundAt = { ...ground }
 
     return {
         applied,
@@ -33,7 +34,7 @@ function createFakeEngine({ viewport = { width: 1680, height: 900 }, fovY = Math
                 return { heading: 0, pitch: -60, range: 10000 }
             },
             pickGroundAtCenter() {
-                return { ...ground }
+                return { ...groundAt }
             },
             projectPivot() {
                 return { x: viewport.width / 2, y: viewport.height / 2 }
@@ -54,6 +55,10 @@ function createFakeEngine({ viewport = { width: 1680, height: 900 }, fovY = Math
                 pendingFrame = callback
             },
         },
+        /** 换掉「屏幕中心拾取到哪」——用来验证松手时的重锚规则。 */
+        setGround(next) {
+            groundAt = { lon: next.lon, lat: next.lat, height: next.height || 0 }
+        },
         get pendingFrame() {
             return pendingFrame
         },
@@ -269,8 +274,13 @@ const degreesLonFor = (meters, lat) => meters / (111320 * Math.cos((lat * Math.P
 
     control.beginDrag({ button: 0, x: 0, y: 0 })
     control.moveDrag({ x: 10, y: 0 })
+    // 真实引擎里「屏幕中心拾取」与平移推算值应当几乎重合(lookAt 保证中心射线落在中心点上),
+    // 所以这里让拾取点跟上推算值,验证「平移把中心交回地面拾取点」这条语义仍然成立。
+    const panned = control.getState().pivot
+    fake.setGround(panned)
     control.endDrag()
-    assert.deepEqual(control.getState().pivot, GROUND, '左键平移后中心回到地面拾取点')
+    assert.deepEqual(control.getState().pivot, panned, '左键平移后中心应是地面拾取点(不再是选中对象)')
+    assert.notDeepEqual(control.getState().pivot, XIANGLING, '平移后中心不再跟随选中对象')
     console.log('PASS  7 中心规则:选中优先 → 取消不回退 → 平移才更新为地面拾取点')
 }
 
@@ -362,6 +372,97 @@ const degreesLonFor = (meters, lat) => meters / (111320 * Math.cos((lat * Math.P
     console.log(`PASS  9b 阻尼与帧率无关(60fps ${(at60 * 100).toFixed(1)}% vs 120fps ${(at120 * 100).toFixed(1)}%)`)
 }
 
+// ---------- 9c. 松手有惯性:平移与滚轮/右键手感一致(维护者 2026-09-17 复验反馈) ----------
+{
+    // 平移:拖拽中一直有滞后(实际态追不上目标态),松手后必须**继续滑行**到目标。
+    const { fake, control } = newControl()
+    control.beginDrag({ button: 0, x: 0, y: 0 })
+    for (let i = 1; i <= 6; i += 1) {
+        control.moveDrag({ x: i * 80, y: 0 })
+        fake.runFrame(FRAME_MS)
+    }
+    const atRelease = control.getState()
+    const gapAtRelease = Math.abs(atRelease.pivot.lon - atRelease.shownPivot.lon)
+    assert.ok(gapAtRelease > 0, '快速拖动时实际态应落后于目标态(这正是惯性来源)')
+
+    control.endDrag()
+    fake.runFrame(FRAME_MS)
+    const justAfter = control.getState()
+    const gapAfterOneFrame = Math.abs(justAfter.pivot.lon - justAfter.shownPivot.lon)
+    assert.ok(
+        gapAfterOneFrame > gapAtRelease * 0.5,
+        `松手后目标不许被改写(否则滞后被抹平、惯性消失):松手前缺口 ${gapAtRelease},一帧后 ${gapAfterOneFrame}`,
+    )
+
+    // 继续滑行:实际态继续朝目标推进,直到吸附。
+    const before = justAfter.shownPivot.lon
+    let frames = 1
+    while (!control.getState().settled && frames < 400) {
+        fake.runFrame(FRAME_MS)
+        frames += 1
+    }
+    const glided = Math.abs(control.getState().shownPivot.lon - before)
+    assert.ok(glided > 0, `松手后应继续滑行一段(实际滑了 ${glided})`)
+    assert.deepEqual(control.getState().shownPivot, control.getState().pivot, '滑行结束后吸附到目标')
+    console.log(
+        `PASS  9c 平移松手有惯性(松手时滞后 ${gapAtRelease.toFixed(6)}°,之后又滑行 ${glided.toFixed(6)}°、${frames} 帧停稳)`,
+    )
+
+    // 滚轮与右键:滚完/松手后同样继续收敛 —— 三种交互手感一致(按各自动的维度量)。
+    const gliding = (act) => {
+        const { fake: f, control: c } = newControl()
+        const read = () => c.getState()
+        const lagOf = (s) => (act === 'wheel' ? Math.abs(s.hpr.range - s.shownHpr.range) : Math.abs(s.hpr.heading - s.shownHpr.heading))
+
+        if (act === 'wheel') {
+            c.wheel({ deltaY: -600 })
+        } else {
+            c.beginDrag({ button: 2, x: 0, y: 0 })
+            c.moveDrag({ x: 200, y: 0 })
+            c.endDrag()
+        }
+        f.runFrame(FRAME_MS)
+        const lag = lagOf(read())
+        let frames = 1
+        while (!c.getState().settled && frames < 400) {
+            f.runFrame(FRAME_MS)
+            frames += 1
+        }
+        return { lag, frames }
+    }
+    const wheel = gliding('wheel')
+    const orbit = gliding('orbit')
+    assert.ok(wheel.lag > 0, `滚轮滚完应有余韵(range 滞后 ${wheel.lag})`)
+    assert.ok(orbit.lag > 0, `右键松手应有余韵(heading 滞后 ${orbit.lag})`)
+    console.log(
+        `PASS  9c2 滚轮与右键同样有余韵(一帧后 range 滞后 ${wheel.lag.toFixed(1)}m / heading 滞后 ${orbit.lag.toFixed(3)}°)`,
+    )
+}
+
+// ---------- 9d. 松手重锚只清微小误差,绝不把画面拽回去 ----------
+{
+    // 偏差可忽略(比 range 的 1% 小得多):允许重锚,中心落到真实地表拾取点。
+    const { fake, control } = newControl()
+    control.beginDrag({ button: 0, x: 0, y: 0 })
+    control.moveDrag({ x: 200, y: 0 })
+    const target = control.getState().pivot
+    fake.setGround({ lon: target.lon + 1e-6, lat: target.lat }) // 偏差 ≈ 0.1m,远小于阈值
+    const shownBefore = control.getState().shownPivot.lon
+    control.endDrag()
+    near(control.getState().pivot.lon, target.lon + 1e-6, 1e-12, '偏差可忽略时应重锚到拾取点')
+    assert.equal(control.getState().shownPivot.lon, shownBefore, '重锚不得动实际态(不许把画面拽回去)')
+
+    // 偏差过大(等距近似在该尺度已失效):宁可不重锚,也不让画面跳。
+    const { fake: f2, control: c2 } = newControl()
+    c2.beginDrag({ button: 0, x: 0, y: 0 })
+    c2.moveDrag({ x: 200, y: 0 })
+    const before2 = c2.getState().pivot
+    f2.setGround({ lon: before2.lon + 5, lat: before2.lat }) // 偏差几百公里 ≫ range*1%
+    c2.endDrag()
+    assert.deepEqual(c2.getState().pivot, before2, '偏差过大时不得重锚(避免画面瞬移)')
+    console.log('PASS  9d 松手重锚只在偏差可忽略时发生(大偏差保持推算值,不跳画面)')
+}
+
 // ---------- 10. 用户操作打断换中心飞行 ----------
 {
     const { fake, control } = newControl()

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

@@ -104,6 +104,18 @@ export function shiftPivot(pivot, { east = 0, north = 0 }) {
     }
 }
 
+/**
+ * 两个中心点之间的粗略地表距离(米)。用于「平移后要不要重锚中心」这类小尺度判断:
+ * 等距近似在公里尺度足够准,全球尺度会明显偏小 —— 所以调用方要按 range 设阈值,别当精算用。
+ */
+export function pivotDistanceMeters(a, b) {
+    if (!a || !b) return Infinity
+    const latAvg = ((a.lat + b.lat) / 2) * DEG
+    const dx = (b.lon - a.lon) * 111320 * Math.cos(latAvg)
+    const dy = (b.lat - a.lat) * METERS_PER_DEG_LAT
+    return Math.hypot(dx, dy)
+}
+
 /**
  * 阻尼时间常数(毫秒):相机实际态朝目标态收敛的特征时间。
  * 越大越「飘/丝滑」,越小越跟手。手感要更跟手就调小、要更重就调大 —— 只此一处。
@@ -113,6 +125,15 @@ export const DAMPING_TAU_MS = 110
 /** 单帧 dt 上限:切标签页回来时别让相机一次跳半张地图。 */
 export const MAX_FRAME_MS = 120
 
+/**
+ * 平移松手后「重锚中心」的偏差上限(占当前 range 的比例)。
+ * 平移用等距近似推算中心,长时间/大尺度拖动会积累误差;松手时按屏幕中心重新拾取一次地面点
+ * 可以把误差清掉。但这个拾取**不能直接改写目标**——那会把阻尼攒下的滞后一次抹平,松手即停、
+ * 没有惯性(维护者 2026-09-17 复验反馈)。所以只在偏差很小(看不出来)时才重锚,
+ * 偏差过大说明等距近似在该尺度已失效,宁可留着推算值也不让画面跳。
+ */
+export const REANCHOR_LIMIT_RATIO = 0.01
+
 /** 收口阈值:实际态与目标态差到这么小即视为停稳(贴齐目标,避免无限逼近)。 */
 export const SETTLE_EPSILON = {
     headingDeg: 0.01,

+ 10 - 1
src/modules/cockpit/services/cameraControl.js

@@ -27,6 +27,7 @@ import {
     DAMPING_TAU_MS,
     FOCUS_FLIGHT_MS,
     PITCH_LIMITS,
+    REANCHOR_LIMIT_RATIO,
     approachHpr,
     approachPivot,
     clampPitch,
@@ -36,6 +37,7 @@ import {
     lerpPivot,
     metersPerPixel,
     orbit,
+    pivotDistanceMeters,
     pivotOffsetMeters,
     shiftPivot,
     zoomByWheel,
@@ -248,8 +250,15 @@ export function createCameraControl({
 
         if (mode === 'pan') {
             // ADR 0008:只有左键平移才把中心更新为地面拾取点;平移后相机中心不再跟随选中对象。
+            // 但松手时**不能**把目标直接改写成拾取点 —— 那会一次抹平阻尼攒下的滞后,
+            // 表现为「松手即停、没有惯性」,与滚轮/右键手感不一致(维护者 2026-09-17 反馈)。
+            // 改为:偏差小到看不出来时重锚(顺手清掉等距近似的累积误差),偏差大就不动目标。
             const picked = engine.pickGroundAtCenter()
-            if (picked) state.target.pivot = picked
+            if (picked) {
+                const drift = pivotDistanceMeters(state.target.pivot, picked)
+                const limit = state.target.hpr.range * REANCHOR_LIMIT_RATIO
+                if (drift <= limit) state.target.pivot = picked
+            }
             state.focus = null
             schedule()
             return