t03-camera-contract.mjs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715
  1. /**
  2. * T03 逻辑检查:三键相机契约、旋转中心规则与阻尼(票 03 / ADR 0008)。
  3. *
  4. * 仓库没有测试运行器,且项目约定不为验证引入新框架,所以这里用零依赖 Node 断言脚本:
  5. * 相机语义全在 `domain/camera.js`(纯数学)与 `services/cameraControl.js`(状态迁移,引擎依赖注入),
  6. * 因此可以用**假引擎**驱动整套交互,不需要浏览器、不需要 cesium。
  7. * 真机行为(Cesium 默认输入确实被替换、中心确实落在屏幕中心、阻尼真的在真机上生效)
  8. * 由 t03-camera-walkthrough.mjs 覆盖。
  9. *
  10. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t03-camera-contract.mjs
  11. */
  12. import assert from 'node:assert/strict'
  13. import { DAMPING_TAU_MS, FOCUS_FLIGHT_MS, PITCH_LIMITS, RANGE_LIMITS } from '../../../src/modules/cockpit/domain/camera.js'
  14. import * as camera from '../../../src/modules/cockpit/domain/camera.js'
  15. import { attachCameraInput, createCameraControl } from '../../../src/modules/cockpit/services/cameraControl.js'
  16. const POLAR_LAT_LIMIT = camera.POLAR_LAT_LIMIT
  17. const GROUND = { lon: 110.39, lat: 28.46, height: 0 }
  18. const XIANGLING = { lon: 110.396, lat: 28.455, height: 0 } // 沅陵某监测点
  19. const FRAME_MS = 1000 / 60
  20. function createFakeEngine({ viewport = { width: 1680, height: 900 }, fovY = Math.PI / 3, ground = GROUND } = {}) {
  21. const applied = []
  22. let clock = 1000
  23. let pendingFrame = null
  24. let groundAt = { ...ground }
  25. return {
  26. applied,
  27. engine: {
  28. applyView(pivot, hpr) {
  29. applied.push({ pivot: { ...pivot }, hpr: { ...hpr } })
  30. },
  31. readView() {
  32. return { heading: 0, pitch: -60, range: 10000 }
  33. },
  34. pickGroundAtCenter() {
  35. return { ...groundAt }
  36. },
  37. projectPivot() {
  38. return { x: viewport.width / 2, y: viewport.height / 2 }
  39. },
  40. inputTarget() {
  41. return null
  42. },
  43. viewport() {
  44. return { ...viewport }
  45. },
  46. fovY() {
  47. return fovY
  48. },
  49. now() {
  50. return clock
  51. },
  52. requestFrame(callback) {
  53. pendingFrame = callback
  54. },
  55. },
  56. /** 换掉「屏幕中心拾取到哪」——用来验证松手时的重锚规则。 */
  57. setGround(next) {
  58. groundAt = { lon: next.lon, lat: next.lat, height: next.height || 0 }
  59. },
  60. get pendingFrame() {
  61. return pendingFrame
  62. },
  63. advance(ms) {
  64. clock += ms
  65. },
  66. /** 跑一帧:假的 rAF 不带时间戳,控制器会退回 `engine.now()`(真机用真时间戳)。 */
  67. runFrame(dtMs = FRAME_MS) {
  68. const callback = pendingFrame
  69. pendingFrame = null
  70. clock += dtMs
  71. if (callback) callback()
  72. }, last() {
  73. return applied[applied.length - 1]
  74. },
  75. }
  76. }
  77. function newControl(options) {
  78. const fake = createFakeEngine(options)
  79. const control = createCameraControl({ engine: fake.engine, ...(options && options.control) })
  80. control.syncFromEngine()
  81. fake.applied.length = 0
  82. return { fake, control }
  83. }
  84. /** 一直跑到停稳(阻尼 + 换中心飞行都收敛),返回跑过的帧数。 */
  85. function settle(fake, control, maxFrames = 400) {
  86. let frames = 0
  87. while (!control.getState().settled && frames < maxFrames) {
  88. fake.runFrame()
  89. frames += 1
  90. }
  91. assert.ok(frames < maxFrames, `阻尼/飞行应在有限帧内停稳(已跑 ${frames} 帧仍未 settled)`)
  92. return frames
  93. }
  94. /** 假元素:记录监听器,供 attachCameraInput 的绑定/解绑断言。 */
  95. function createFakeElement() {
  96. const handlers = new Map()
  97. return {
  98. handlers,
  99. defaultPrevented: 0,
  100. addEventListener(type, fn) {
  101. if (!handlers.has(type)) handlers.set(type, new Set())
  102. handlers.get(type).add(fn)
  103. },
  104. removeEventListener(type, fn) {
  105. if (handlers.has(type)) handlers.get(type).delete(fn)
  106. },
  107. setPointerCapture() {},
  108. releasePointerCapture() {},
  109. fire(type, payload = {}) {
  110. const set = handlers.get(type)
  111. if (!set) return
  112. const event = {
  113. preventDefault() {
  114. this.defaultPrevented = true
  115. },
  116. ...payload,
  117. }
  118. set.forEach((fn) => fn(event))
  119. return event
  120. },
  121. }
  122. }
  123. const near = (a, b, tolerance, message) =>
  124. assert.ok(Math.abs(a - b) <= tolerance, `${message}:期望 ≈${b}(±${tolerance}),实际 ${a}`)
  125. const round3 = (n) => Number(n.toFixed(3))
  126. /**
  127. * 两个中心点是否同一个点(容差比较)。
  128. * `sanitizePivot` / `normalizeLon` 会引入 1e-14 量级的浮点差,语义相同但不能用严格相等。
  129. */
  130. const samePoint = (a, b, tolerance = 1e-9) =>
  131. !!a && !!b && Math.abs(a.lon - b.lon) <= tolerance && Math.abs(a.lat - b.lat) <= tolerance
  132. const metersPerPxAt = (range) => camera.metersPerPixel({ range, fovYRad: Math.PI / 3, viewportHeight: 900 })
  133. // ---------- 1. 左键拖拽只平移 ----------
  134. {
  135. const { fake, control } = newControl()
  136. const before = control.getState()
  137. control.beginDrag({ button: 0, x: 0, y: 0 })
  138. control.moveDrag({ x: 100, y: 0 })
  139. const after = control.getState()
  140. assert.deepEqual(after.hpr, before.hpr, '平移不得改 heading / pitch / range')
  141. // 维护者 2026-09-17 裁定:**抓着地图拖** —— 拖右 → 相机左移、画面右移,所以中心往西(lon 减小)。
  142. const offset = camera.pivotOffsetMeters({
  143. headingDeg: before.hpr.heading,
  144. dxPx: 100,
  145. dyPx: 0,
  146. metersPerPx: metersPerPxAt(before.hpr.range),
  147. })
  148. const expected = camera.shiftPivot(before.pivot, offset)
  149. // 期望值直接取 domain 的球面平移结果:这里验的是「控制器确实按契约把像素换算成位移并落到中心上」。
  150. near(after.pivot.lon, expected.lon, 1e-12, '拖右应把中心按球面公式西移(相机左移)')
  151. near(after.pivot.lat, expected.lat, 1e-12, 'heading=0 时右拖不应产生南北位移')
  152. // 位移量级仍应与「像素×米每像素」一致(球面 vs 等距近似的差异只在纬度方向,量级同一档)。
  153. const roughMeters = Math.abs(100 * metersPerPxAt(before.hpr.range))
  154. const actualMeters = camera.pivotDistanceMeters(before.pivot, after.pivot)
  155. near(actualMeters, roughMeters, roughMeters * 0.05, '位移量级应与像素换算一致(±5%)')
  156. assert.ok(after.pivot.lon < before.pivot.lon, '方向:拖右 → 中心西移(画面右移)')
  157. settle(fake, control)
  158. near(fake.last().pivot.lon, after.pivot.lon, 1e-9, '阻尼收敛后下发给相机的中心应等于目标')
  159. console.log(
  160. `PASS 1 左键拖拽只平移(heading/pitch/range 原样;100px ≈ ${Math.round(actualMeters)}m 西移,方向与像素换算一致)`,
  161. )
  162. }
  163. // ---------- 2. 平移方向 = 抓着地图拖(维护者裁定的语义钉子) ----------
  164. {
  165. const { fake, control } = newControl()
  166. control.beginDrag({ button: 0, x: 0, y: 0 })
  167. control.moveDrag({ x: 0, y: 100 })
  168. const down = control.getState()
  169. assert.ok(
  170. down.pivot.lat > GROUND.lat,
  171. `heading=0 时下拖应把中心往北带(画面下移、相机上移):${JSON.stringify(down.pivot)}`,
  172. )
  173. const { fake: fake2, control: control2 } = newControl()
  174. control2.beginDrag({ button: 2, x: 0, y: 0 })
  175. control2.moveDrag({ x: 90 / camera.ORBIT_SENSITIVITY.heading, y: 0 }) // 先把 heading 拖到 90°(向东看)
  176. control2.endDrag()
  177. settle(fake2, control2)
  178. near(control2.getState().hpr.heading, 90, 1e-6, '轨道旋转应把 heading 拖到 90°')
  179. const beforePan = control2.getState()
  180. control2.beginDrag({ button: 0, x: 0, y: 0 })
  181. control2.moveDrag({ x: 100, y: 0 })
  182. const afterPan = control2.getState()
  183. assert.ok(
  184. afterPan.pivot.lat > beforePan.pivot.lat,
  185. `heading=90°(向东看)时拖右应把中心往北带(画面右移=相机往左=北):${JSON.stringify(afterPan.pivot)}`,
  186. )
  187. near(afterPan.pivot.lon, beforePan.pivot.lon, 1e-6, 'heading=90° 时右拖不应产生东西位移')
  188. console.log('PASS 2 平移方向 = 抓着地图拖(拖右→画面右移/相机左移,heading=0/90 两点验证)')
  189. }
  190. // ---------- 3. 右键拖拽只做轨道旋转 ----------
  191. {
  192. const { fake, control } = newControl()
  193. const before = control.getState()
  194. control.beginDrag({ button: 2, x: 0, y: 0 })
  195. control.moveDrag({ x: 100, y: 50 })
  196. const after = control.getState()
  197. near(after.hpr.heading, 28, 1e-6, 'heading 变化 = dx × 灵敏度')
  198. near(after.hpr.pitch, -60 - 50 * 0.22, 1e-6, 'pitch 变化 = -dy × 灵敏度')
  199. assert.equal(after.hpr.range, before.hpr.range, '轨道旋转不得改弹簧臂长度')
  200. assert.deepEqual(after.pivot, before.pivot, '轨道旋转不得移动中心')
  201. settle(fake, control)
  202. near(fake.last().hpr.range, before.hpr.range, 1e-9, '阻尼收敛后下发的 range 仍应原样')
  203. console.log('PASS 3 右键拖拽只做轨道旋转(heading/pitch 按像素变化,range 与中心不动)')
  204. }
  205. // ---------- 4. pitch 夹在上下限内 ----------
  206. {
  207. const { fake, control } = newControl()
  208. control.beginDrag({ button: 2, x: 0, y: 0 })
  209. control.moveDrag({ x: 0, y: 5000 })
  210. near(control.getState().hpr.pitch, PITCH_LIMITS.ground.min, 1e-9, '一直下拖应夹在下限')
  211. control.moveDrag({ x: 0, y: -10000 })
  212. near(control.getState().hpr.pitch, PITCH_LIMITS.ground.max, 1e-9, '一直上拖应夹在上限')
  213. settle(fake, control)
  214. near(fake.last().hpr.pitch, PITCH_LIMITS.ground.max, 1e-9, '阻尼收敛后下发的 pitch 也应在限内')
  215. console.log(`PASS 4 pitch 夹在 [${PITCH_LIMITS.ground.min}, ${PITCH_LIMITS.ground.max}](地面视图)`)
  216. }
  217. // ---------- 5. 右键按下锁定中心:拖拽中不重新拾取 ----------
  218. {
  219. const { fake, control } = newControl()
  220. const locked = control.getState().pivot
  221. control.beginDrag({ button: 2, x: 0, y: 0 })
  222. // 拖拽进行中来了「选中某对象」:中心不许被改,飞行也不许插进来。
  223. control.setFocus(XIANGLING)
  224. control.moveDrag({ x: 40, y: 0 })
  225. assert.deepEqual(control.getState().pivot, locked, '拖拽中下发的中心必须仍是按下时锁定的那个')
  226. control.endDrag()
  227. assert.equal(control.getState().flying, true, '松手后应开始平滑换中心')
  228. settle(fake, control)
  229. const final = control.getState().pivot
  230. near(final.lon, XIANGLING.lon, 1e-9, '飞行结束后中心应为选中对象')
  231. near(final.lat, XIANGLING.lat, 1e-9, '飞行结束后中心应为选中对象')
  232. console.log('PASS 5 右键按下锁定中心(拖拽中的选中不插队;松手后才开始换中心)')
  233. }
  234. // ---------- 6. 滚轮只改弹簧臂长度 ----------
  235. {
  236. const { fake, control } = newControl()
  237. const before = control.getState()
  238. const handled = control.wheel({ deltaY: -100 })
  239. const after = control.getState()
  240. assert.equal(handled, true, '滚轮应被相机契约接管(调用方据此 preventDefault)')
  241. near(after.hpr.range, 10000 * Math.exp(-100 * 0.0012), 1e-6, '上滚拉近:range 按指数缩')
  242. assert.equal(after.hpr.heading, before.hpr.heading, '滚轮不得转 heading')
  243. assert.equal(after.hpr.pitch, before.hpr.pitch, '滚轮不得改 pitch')
  244. assert.deepEqual(after.pivot, before.pivot, '滚轮不得移动中心')
  245. control.wheel({ deltaY: -1e6 })
  246. near(control.getState().hpr.range, RANGE_LIMITS.min, 1e-9, '拉近到极限应夹住下限')
  247. control.wheel({ deltaY: 1e6 })
  248. near(control.getState().hpr.range, RANGE_LIMITS.max, 1e-9, '推远到极限应夹住上限')
  249. settle(fake, control)
  250. near(fake.last().hpr.range, RANGE_LIMITS.max, 1e-9, '阻尼收敛后下发的 range 应等于目标')
  251. console.log(`PASS 6 滚轮只改弹簧臂长度(并夹在 [${RANGE_LIMITS.min}, ${RANGE_LIMITS.max}] 米)`)
  252. }
  253. // ---------- 7. 中心规则:选中优先 / 取消不回退 / 平移才回退 ----------
  254. {
  255. const { fake, control } = newControl()
  256. assert.ok(samePoint(control.getState().pivot, GROUND), '未选中时中心 = 屏幕中心的地面拾取点')
  257. control.setFocus(XIANGLING)
  258. settle(fake, control)
  259. assert.ok(samePoint(control.getState().pivot, XIANGLING), '选中后中心 = 对象地表点')
  260. control.setFocus(null)
  261. assert.ok(samePoint(control.getState().pivot, XIANGLING), '取消选中不得回退中心')
  262. assert.equal(control.getState().focus, null, '取消选中应清掉选中态')
  263. control.beginDrag({ button: 0, x: 0, y: 0 })
  264. control.moveDrag({ x: 10, y: 0 })
  265. // 真实引擎里「屏幕中心拾取」与平移推算值应当几乎重合(lookAt 保证中心射线落在中心点上),
  266. // 所以这里让拾取点跟上推算值,验证「平移把中心交回地面拾取点」这条语义仍然成立。
  267. const panned = control.getState().pivot
  268. fake.setGround(panned)
  269. control.endDrag()
  270. assert.ok(samePoint(control.getState().pivot, panned), '左键平移后中心应是地面拾取点(不再是选中对象)')
  271. assert.ok(!samePoint(control.getState().pivot, XIANGLING), '平移后中心不再跟随选中对象')
  272. console.log('PASS 7 中心规则:选中优先 → 取消不回退 → 平移才更新为地面拾取点')
  273. }
  274. // ---------- 8. 换中心平滑、且全程保持 heading 与 range ----------
  275. {
  276. const { fake, control } = newControl()
  277. const before = control.getState()
  278. control.setFocus(XIANGLING)
  279. // 起手第一帧(elapsed = 0)必须仍停在原中心 —— 换中心不许瞬移。
  280. fake.runFrame(0)
  281. near(control.getState().pivot.lon, before.pivot.lon, 1e-12, '换中心第一帧应从原中心出发')
  282. const samples = []
  283. let frames = 0
  284. while (control.getState().flying && frames++ < 200) {
  285. fake.runFrame(FOCUS_FLIGHT_MS / 8)
  286. const now = control.getState()
  287. assert.equal(now.hpr.heading, before.hpr.heading, '换中心过程中 heading 必须保持')
  288. assert.equal(now.hpr.range, before.hpr.range, '换中心过程中 range 必须保持')
  289. samples.push(now.pivot.lon)
  290. }
  291. assert.ok(samples.length >= 4, `换中心应有多帧(实际 ${samples.length})`)
  292. assert.equal(
  293. samples.every((lon, i) => i === 0 || lon >= samples[i - 1] - 1e-12),
  294. true,
  295. `中心应朝目标单调推进:${samples.join(' → ')}`,
  296. )
  297. settle(fake, control)
  298. near(control.getState().pivot.lon, XIANGLING.lon, 1e-9, '末帧应到达目标中心')
  299. assert.equal(control.getState().flying, false, '到达后应结束飞行')
  300. console.log(`PASS 8 换中心平滑(${samples.length} 帧单调推进、heading/range 全程不变)`)
  301. }
  302. // ---------- 9. 阻尼:渐入、不超调、停稳、且与帧率无关 ----------
  303. {
  304. const { fake, control } = newControl()
  305. const before = control.getState()
  306. control.beginDrag({ button: 0, x: 0, y: 0 })
  307. control.moveDrag({ x: 400, y: 0 })
  308. const target = control.getState()
  309. // 目标立刻到位(输入不丢),实际态还在起点附近 —— 这就是「丝滑」的来源。
  310. assert.notDeepEqual(target.pivot, before.pivot, '目标态应当立刻跟上输入')
  311. assert.deepEqual(control.getState().shownPivot, before.pivot, '首个阻尼帧之前,实际态还在起点')
  312. const targetShift = target.pivot.lon - before.pivot.lon
  313. fake.runFrame(FRAME_MS)
  314. const oneFrame = control.getState()
  315. const oneFrameShift = oneFrame.shownPivot.lon - before.pivot.lon
  316. assert.ok(
  317. Math.abs(oneFrameShift) > 0 && Math.abs(oneFrameShift) < Math.abs(targetShift) * 0.6,
  318. `一帧内应只走一小部分(实际 ${oneFrameShift} / 目标 ${targetShift})`,
  319. )
  320. assert.equal(oneFrame.settled, false, '一帧后还没停稳')
  321. // 逼近过程不得超调(单调朝目标,不越过)。
  322. let previous = Math.abs(target.pivot.lon - oneFrame.shownPivot.lon)
  323. let frames = 1
  324. while (!control.getState().settled && frames < 400) {
  325. fake.runFrame(FRAME_MS)
  326. const gap = Math.abs(target.pivot.lon - control.getState().shownPivot.lon)
  327. assert.ok(gap <= previous + 1e-12, `阻尼不得超调:缺口从 ${previous} 变成 ${gap}`)
  328. previous = gap
  329. frames += 1
  330. }
  331. assert.equal(control.getState().settled, true, '有限帧内应停稳')
  332. assert.deepEqual(control.getState().shownPivot, target.pivot, '停稳后实际态应吸附到目标')
  333. console.log(`PASS 9a 阻尼渐入、不超调、${frames} 帧内停稳并吸附(τ=${DAMPING_TAU_MS}ms)`)
  334. // 帧率无关:同样的真实时长,60fps 与 120fps 收敛到同一个位置(±1%)。
  335. const walk = (dtMs, totalMs) => {
  336. const { fake: f, control: c } = newControl()
  337. const start = c.getState()
  338. c.beginDrag({ button: 0, x: 0, y: 0 })
  339. c.moveDrag({ x: 400, y: 0 })
  340. // 固定帧数,别用浮点累加判停:累加误差会多跑一帧,把「帧率无关」测成假的偏差。
  341. const frames = Math.round(totalMs / dtMs)
  342. for (let i = 0; i < frames; i += 1) f.runFrame(dtMs)
  343. const shift = c.getState().shownPivot.lon - start.pivot.lon
  344. const full = c.getState().pivot.lon - start.pivot.lon
  345. return shift / full
  346. }
  347. const at60 = walk(1000 / 60, 200)
  348. const at120 = walk(1000 / 120, 200)
  349. near(at60, at120, 0.01, '同样 200ms 内 60fps 与 120fps 的收敛比例应一致(±1%)')
  350. assert.ok(at60 > 0.6 && at60 < 0.95, `200ms(≈1.8τ)应走掉大半但未走完:实际 ${at60}`)
  351. console.log(`PASS 9b 阻尼与帧率无关(60fps ${(at60 * 100).toFixed(1)}% vs 120fps ${(at120 * 100).toFixed(1)}%)`)
  352. }
  353. // ---------- 9c. 松手有惯性:平移与滚轮/右键手感一致(维护者 2026-09-17 复验反馈) ----------
  354. {
  355. // 平移:拖拽中一直有滞后(实际态追不上目标态),松手后必须**继续滑行**到目标。
  356. const { fake, control } = newControl()
  357. control.beginDrag({ button: 0, x: 0, y: 0 })
  358. for (let i = 1; i <= 6; i += 1) {
  359. control.moveDrag({ x: i * 80, y: 0 })
  360. fake.runFrame(FRAME_MS)
  361. }
  362. const atRelease = control.getState()
  363. const gapAtRelease = Math.abs(atRelease.pivot.lon - atRelease.shownPivot.lon)
  364. assert.ok(gapAtRelease > 0, '快速拖动时实际态应落后于目标态(这正是惯性来源)')
  365. control.endDrag()
  366. fake.runFrame(FRAME_MS)
  367. const justAfter = control.getState()
  368. const gapAfterOneFrame = Math.abs(justAfter.pivot.lon - justAfter.shownPivot.lon)
  369. assert.ok(
  370. gapAfterOneFrame > gapAtRelease * 0.5,
  371. `松手后目标不许被改写(否则滞后被抹平、惯性消失):松手前缺口 ${gapAtRelease},一帧后 ${gapAfterOneFrame}`,
  372. )
  373. // 继续滑行:实际态继续朝目标推进,直到吸附。
  374. const before = justAfter.shownPivot.lon
  375. let frames = 1
  376. while (!control.getState().settled && frames < 400) {
  377. fake.runFrame(FRAME_MS)
  378. frames += 1
  379. }
  380. const glided = Math.abs(control.getState().shownPivot.lon - before)
  381. assert.ok(glided > 0, `松手后应继续滑行一段(实际滑了 ${glided})`)
  382. assert.deepEqual(control.getState().shownPivot, control.getState().pivot, '滑行结束后吸附到目标')
  383. console.log(
  384. `PASS 9c 平移松手有惯性(松手时滞后 ${gapAtRelease.toFixed(6)}°,之后又滑行 ${glided.toFixed(6)}°、${frames} 帧停稳)`,
  385. )
  386. // 滚轮与右键:滚完/松手后同样继续收敛 —— 三种交互手感一致(按各自动的维度量)。
  387. const gliding = (act) => {
  388. const { fake: f, control: c } = newControl()
  389. const read = () => c.getState()
  390. const lagOf = (s) => (act === 'wheel' ? Math.abs(s.hpr.range - s.shownHpr.range) : Math.abs(s.hpr.heading - s.shownHpr.heading))
  391. if (act === 'wheel') {
  392. c.wheel({ deltaY: -600 })
  393. } else {
  394. c.beginDrag({ button: 2, x: 0, y: 0 })
  395. c.moveDrag({ x: 200, y: 0 })
  396. c.endDrag()
  397. }
  398. f.runFrame(FRAME_MS)
  399. const lag = lagOf(read())
  400. let frames = 1
  401. while (!c.getState().settled && frames < 400) {
  402. f.runFrame(FRAME_MS)
  403. frames += 1
  404. }
  405. return { lag, frames }
  406. }
  407. const wheel = gliding('wheel')
  408. const orbit = gliding('orbit')
  409. assert.ok(wheel.lag > 0, `滚轮滚完应有余韵(range 滞后 ${wheel.lag})`)
  410. assert.ok(orbit.lag > 0, `右键松手应有余韵(heading 滞后 ${orbit.lag})`)
  411. console.log(
  412. `PASS 9c2 滚轮与右键同样有余韵(一帧后 range 滞后 ${wheel.lag.toFixed(1)}m / heading 滞后 ${orbit.lag.toFixed(3)}°)`,
  413. )
  414. }
  415. // ---------- 9d. 松手重锚只清微小误差,绝不把画面拽回去 ----------
  416. {
  417. // 偏差可忽略(比 range 的 1% 小得多):允许重锚,中心落到真实地表拾取点。
  418. const { fake, control } = newControl()
  419. control.beginDrag({ button: 0, x: 0, y: 0 })
  420. control.moveDrag({ x: 200, y: 0 })
  421. const target = control.getState().pivot
  422. fake.setGround({ lon: target.lon + 1e-6, lat: target.lat }) // 偏差 ≈ 0.1m,远小于阈值
  423. const shownBefore = control.getState().shownPivot.lon
  424. control.endDrag()
  425. near(control.getState().pivot.lon, target.lon + 1e-6, 1e-12, '偏差可忽略时应重锚到拾取点')
  426. assert.equal(control.getState().shownPivot.lon, shownBefore, '重锚不得动实际态(不许把画面拽回去)')
  427. // 偏差过大(等距近似在该尺度已失效):宁可不重锚,也不让画面跳。
  428. const { fake: f2, control: c2 } = newControl()
  429. c2.beginDrag({ button: 0, x: 0, y: 0 })
  430. c2.moveDrag({ x: 200, y: 0 })
  431. const before2 = c2.getState().pivot
  432. f2.setGround({ lon: before2.lon + 5, lat: before2.lat }) // 偏差几百公里 ≫ range*1%
  433. c2.endDrag()
  434. assert.ok(samePoint(c2.getState().pivot, before2), '偏差过大时不得重锚(避免画面瞬移)')
  435. console.log('PASS 9d 松手重锚只在偏差可忽略时发生(大偏差保持推算值,不跳画面)')
  436. }
  437. // ---------- 9e. 极点保护:中心永不进入极点带(维护者反馈:极点附近整个地球异常旋转) ----------
  438. // 依据:纬线在极点收敛,「东/西」在极点失去意义(维护者指出)。中心一旦进极点带,
  439. // 大圆公式会把经度翻转 180°(从对面经线接着走),逐帧跨越就在两侧翻面 → 画面乱转。
  440. // 方案:中心永不进入 ±POLAR_LAT_LIMIT 以内 —— 越界的那一步**整步拒绝**(中心不动)。
  441. {
  442. // 1) 从北界上继续朝极点拖:整步拒绝/夹住,中心恒在界上(没有 2 周期环、没有经度翻转)。
  443. // 符号:dy > 0 → north > 0 → 向北(朝北极)。已用 t03-pan-direction-probe 实测确认。
  444. const { fake, control } = newControl()
  445. control.setFocus({ lon: 12.5, lat: POLAR_LAT_LIMIT, height: 0 })
  446. settle(fake, control)
  447. control.beginDrag({ button: 0, x: 0, y: 0 })
  448. const seen = []
  449. for (let i = 1; i <= 8; i += 1) {
  450. control.moveDrag({ x: 0, y: i * 400 })
  451. const s = control.getState()
  452. seen.push(`${s.pivot.lon.toFixed(4)},${s.pivot.lat.toFixed(4)}`)
  453. }
  454. const stuck = control.getState().pivot
  455. assert.ok(seen.every((s) => s === seen[0]), `界上继续朝极点拖应始终停在界上(落点序列:${seen.join(' → ')})`)
  456. assert.ok(Math.abs(stuck.lat - POLAR_LAT_LIMIT) < 1e-9, `纬度应钉在界上,实际 ${stuck.lat}`)
  457. console.log(`PASS 9e-1 界上朝极点拖钉在界上(8 次拖拽落点恒为 ${seen[0]})`)
  458. // 2) 从界内侧朝极点推进:一定停在界上,且经度全程不翻转、不漂移。
  459. // 直接驱动 domain(不经交互层),避免「松手重锚」等交互语义干扰这条纯几何断言。
  460. {
  461. // 每步推进约 2.5°,60 步足够从 82° 走到界上并验证「停在界上不动」。
  462. const metersPerPx = camera.metersPerPixel({ range: 10000, fovYRad: Math.PI / 3, viewportHeight: 900 })
  463. const stepMeters = 2.5 * 110540
  464. const dyPx = stepMeters / metersPerPx
  465. let point = { lon: -170, lat: POLAR_LAT_LIMIT - 3, height: 0 }
  466. const lons = []
  467. for (let i = 0; i < 60; i += 1) {
  468. const offset = camera.pivotOffsetMeters({ headingDeg: 0, dxPx: 0, dyPx, metersPerPx })
  469. point = camera.shiftPivot(point, offset)
  470. lons.push(point.lon)
  471. assert.ok(Math.abs(point.lat) <= POLAR_LAT_LIMIT + 1e-9, `第 ${i + 1} 步纬度越界:${point.lat}`)
  472. }
  473. const lonDrift = Math.max(...lons.map((l) => Math.abs(l - lons[0])))
  474. assert.ok(lonDrift < 1e-6, `朝极点推进时经度不得翻转/漂移(最大漂移 ${lonDrift}°)`)
  475. assert.ok(
  476. Math.abs(point.lat - POLAR_LAT_LIMIT) < 1e-9,
  477. `一直朝极点推进应停在界上,实际 ${point.lat}`,
  478. )
  479. console.log(`PASS 9e-2 朝极点推进停在界上、经度不漂移(60 步,最大经度漂移 ${lonDrift.toFixed(9)}°)`)
  480. }
  481. // 3) 东西向平移在界上仍有定义(绕极轴),且经度归一。
  482. const { fake: f3, control: c3 } = newControl()
  483. c3.setFocus({ lon: 179.5, lat: POLAR_LAT_LIMIT, height: 0 })
  484. settle(f3, c3)
  485. c3.beginDrag({ button: 0, x: 0, y: 0 })
  486. c3.moveDrag({ x: 100000, y: 0 })
  487. const wrapped = c3.getState().pivot
  488. assert.ok(Math.abs(wrapped.lat) <= POLAR_LAT_LIMIT + 1e-9, `东西向巨量拖拽后纬度仍应合法,实际 ${wrapped.lat}`)
  489. assert.ok(wrapped.lon >= -180 && wrapped.lon < 180, `经度应归一到 [-180,180),实际 ${wrapped.lon}`)
  490. console.log(`PASS 9e-3 跨 ±180 经线经度归一(落点 ${wrapped.lon.toFixed(3)},${wrapped.lat.toFixed(3)})`)
  491. }
  492. // ---------- 9f. 经度最短弧:跨 ±180 不得绕地球一圈(维护者第三次复验反馈) ----------
  493. // 经度是圆周量,但 [-180,180) 的归一化掩盖了它:线性插值 179.9 → -179.9 会穿过 0 走 359.8°,
  494. // 相机就绕地球一整圈才到对面 —— 维护者看到的「到达经度 0 区域时抽搐后旋转一周到达另一边」。
  495. // 把经度序列按最短弧解缠后累加,就能区分「走 0.2°」与「绕 359.8°」。
  496. {
  497. const unwrap = (lons) => {
  498. let acc = 0
  499. const out = [0]
  500. for (let i = 1; i < lons.length; i += 1) {
  501. acc += camera.shortestAngleDelta(lons[i - 1], lons[i])
  502. out.push(acc)
  503. }
  504. return out
  505. }
  506. // 1) 换中心飞行:跨 ±180 应走短弧,总弧长 ≈ 0.2°(绕一圈则是 ±359.8°)。
  507. const across = []
  508. for (let i = 0; i <= 10; i += 1) {
  509. across.push(camera.lerpPivot({ lon: 179.9, lat: 10, height: 0 }, { lon: -179.9, lat: 10, height: 0 }, i / 10).lon)
  510. }
  511. const arc = unwrap(across)
  512. const total = arc[arc.length - 1]
  513. assert.ok(Math.abs(Math.abs(total) - 0.2) < 1e-6, `跨 ±180 换中心应走 0.2° 短弧,实际 ${total}°`)
  514. assert.ok(arc.every((v, i) => i === 0 || v >= arc[i - 1] - 1e-12), '换中心应沿短弧单调推进')
  515. console.log(`PASS 9f-1 换中心跨 ±180 走短弧(总弧长 ${total.toFixed(3)}°,非绕地球一圈)`)
  516. // 2) 阻尼:同样走短弧。
  517. let shown = { lon: 179.99, lat: 10, height: 0 }
  518. const dampSeq = [shown.lon]
  519. for (let i = 0; i < 12; i += 1) {
  520. shown = camera.approachPivot(shown, { lon: -179.99, lat: 10, height: 0 }, 0.3)
  521. dampSeq.push(shown.lon)
  522. }
  523. const dampArc = unwrap(dampSeq)
  524. const dampTotal = dampArc[dampArc.length - 1]
  525. assert.ok(Math.abs(Math.abs(dampTotal) - 0.02) < 1e-3, `跨 ±180 阻尼应收敛 0.02° 短弧,实际 ${dampTotal}°`)
  526. console.log(`PASS 9f-2 阻尼跨 ±180 走短弧(总弧长 ${dampTotal.toFixed(4)}°)`)
  527. // 3) 反方向对称。
  528. const back = []
  529. for (let i = 0; i <= 10; i += 1) {
  530. back.push(camera.lerpPivot({ lon: -179.9, lat: 0, height: 0 }, { lon: 179.9, lat: 0, height: 0 }, i / 10).lon)
  531. }
  532. const backTotal = unwrap(back)[back.length - 1]
  533. assert.ok(Math.abs(Math.abs(backTotal) - 0.2) < 1e-6, `反方向也应走短弧,实际 ${backTotal}°`)
  534. console.log(`PASS 9f-3 反向跨 ±180 同样走短弧(总弧长 ${backTotal.toFixed(3)}°)`)
  535. // 4) 停稳判据也要按最短弧:跨 ±180 的极小差值不得被误判成「未停稳」而持续抽搐。
  536. const hpr = { heading: 0, pitch: -60, range: 1000 }
  537. const settledAcross = camera.isSettled(
  538. { hpr, pivot: { lon: 179.9999999, lat: 0 } },
  539. { hpr, pivot: { lon: -179.9999999, lat: 0 } },
  540. )
  541. // 2e-7° 的差略大于默认阈值 1e-7,所以这里显式给一个宽松阈值验证「按最短弧比较」这件事本身。
  542. const settledWithEps = camera.isSettled(
  543. { hpr, pivot: { lon: 179.9999999, lat: 0 } },
  544. { hpr, pivot: { lon: -179.9999999, lat: 0 } },
  545. { headingDeg: 0.01, pitchDeg: 0.01, rangeMeters: 0.5, lonLatDeg: 1e-6 },
  546. )
  547. assert.equal(settledAcross, false, '2e-7° 差在默认 1e-7 阈值下仍算未停稳(阈值语义不变)')
  548. assert.equal(settledWithEps, true, '跨 ±180 的 2e-7° 差应按最短弧判为已停稳,而不是差 360°')
  549. console.log('PASS 9f-4 停稳判据按最短弧比较(跨 ±180 的 2e-7° 差判为已停稳,不再误判成差 360°)')
  550. // 5) 距离判据也要走最短弧:否则「松手重锚」在反经线附近永远判成偏差过大而失效。
  551. const acrossDist = camera.pivotDistanceMeters({ lon: 179.9, lat: 0 }, { lon: -179.9, lat: 0 })
  552. const equatorish = camera.pivotDistanceMeters({ lon: 0, lat: 0 }, { lon: 0.2, lat: 0 })
  553. assert.ok(
  554. Math.abs(acrossDist - equatorish) < 1,
  555. `跨 ±180 的 0.2° 应与赤道上 0.2° 距离相当(跨反经线 ${Math.round(acrossDist)}m vs ${Math.round(equatorish)}m)`,
  556. )
  557. assert.ok(acrossDist < 30000, `跨 ±180 的距离不得算成 4 万公里(实际 ${Math.round(acrossDist)}m)`)
  558. console.log(`PASS 9f-5 跨 ±180 距离按最短弧(${Math.round(acrossDist)}m,与赤道 0.2° 的 ${Math.round(equatorish)}m 同量级)`)
  559. }
  560. // ---------- 10. 用户操作打断换中心飞行 ----------
  561. {
  562. const { fake, control } = newControl()
  563. control.setFocus(XIANGLING)
  564. assert.equal(control.getState().flying, true, '应处于换中心飞行中')
  565. control.beginDrag({ button: 0, x: 0, y: 0 })
  566. assert.equal(control.getState().flying, false, '一上手就应打断飞行(输入永远优先)')
  567. console.log('PASS 10 用户操作打断换中心飞行')
  568. }
  569. // ---------- 10b. 阻尼可现场拖调(手感是主观的,维护者要能在控制台试) ----------
  570. {
  571. const { fake, control } = newControl()
  572. assert.equal(control.getState().dampingTauMs, DAMPING_TAU_MS, '默认应取 domain 里的 τ')
  573. // 调大 τ:同样一帧走得更少(更飘)。
  574. const shiftWith = (tau) => {
  575. const { fake: f, control: c } = newControl()
  576. c.setDampingTau(tau)
  577. const start = c.getState().pivot.lon
  578. c.beginDrag({ button: 0, x: 0, y: 0 })
  579. c.moveDrag({ x: 400, y: 0 })
  580. f.runFrame(FRAME_MS)
  581. return Math.abs(c.getState().shownPivot.lon - start)
  582. }
  583. const loose = shiftWith(400)
  584. const tight = shiftWith(40)
  585. assert.ok(tight > loose * 1.5, `τ 越小越跟手:τ=40 走 ${tight},τ=400 只走 ${loose}`)
  586. // τ = 0:不做阻尼(一帧内直接贴到目标),给「要完全即时手感」的场合留的档。
  587. const { fake: f2, control: c2 } = newControl()
  588. c2.setDampingTau(0)
  589. c2.beginDrag({ button: 0, x: 0, y: 0 })
  590. c2.moveDrag({ x: 400, y: 0 })
  591. f2.runFrame(FRAME_MS)
  592. assert.deepEqual(c2.getState().shownPivot, c2.getState().pivot, 'τ=0 时一帧内实际态应贴到目标态')
  593. assert.equal(c2.getState().settled, true, 'τ=0 时下完指令一帧即停稳')
  594. assert.equal(f2.pendingFrame, null, 'τ=0 停稳后不应再排帧')
  595. // 非法值不生效(守住旋钮)。
  596. assert.equal(control.setDampingTau(-5), DAMPING_TAU_MS, '负数应被拒绝并保持原值')
  597. assert.equal(control.setDampingTau('abc'), DAMPING_TAU_MS, '非数字应被拒绝并保持原值')
  598. console.log(`PASS 10b 阻尼可拖调(τ=40 走 ${round3(tight)}° > τ=400 走 ${round3(loose)}°;τ=0 直接跟手)`)
  599. }
  600. // ---------- 11. DOM 绑定:三类输入都接上,右键菜单与滚轮默认行为被吃掉 ----------
  601. {
  602. const { fake, control } = newControl()
  603. const element = createFakeElement()
  604. const detach = attachCameraInput(element, control)
  605. assert.deepEqual(
  606. [...element.handlers.keys()].sort(),
  607. ['contextmenu', 'pointercancel', 'pointerdown', 'pointermove', 'pointerup', 'wheel'],
  608. '应绑定左右键拖拽与滚轮(并吃掉右键菜单)',
  609. )
  610. assert.equal(element.fire('contextmenu').defaultPrevented, true, '右键菜单必须被 preventDefault')
  611. element.fire('pointerdown', { button: 0, pointerId: 1, clientX: 500, clientY: 400 })
  612. element.fire('pointermove', { pointerId: 1, clientX: 560, clientY: 400 })
  613. assert.equal(control.getState().dragging, 'pan', '左键按下后应处于平移态')
  614. element.fire('pointerup', { pointerId: 1, clientX: 560, clientY: 400 })
  615. assert.equal(control.getState().dragging, null, '松手后应结束拖拽')
  616. element.fire('pointerdown', { button: 2, pointerId: 2, clientX: 500, clientY: 400 })
  617. element.fire('pointermove', { pointerId: 2, clientX: 540, clientY: 400 })
  618. assert.equal(control.getState().dragging, 'orbit', '右键按下后应处于轨道旋转态')
  619. element.fire('pointerup', { pointerId: 2, clientX: 540, clientY: 400 })
  620. assert.equal(element.fire('wheel', { deltaY: -120 }).defaultPrevented, true, '滚轮默认行为(页面滚动)必须被吃掉')
  621. detach()
  622. assert.equal(element.handlers.get('pointerdown').size, 0, 'detach 应摘掉监听器')
  623. assert.equal(element.handlers.get('wheel').size, 0, 'detach 应摘掉滚轮监听器')
  624. console.log('PASS 11 DOM 绑定:三类输入接上、右键菜单与滚轮默认行为被吃掉、可完整摘除')
  625. }
  626. // ---------- 12. 状态串(走查与人工排查读的那一行) ----------
  627. {
  628. const { control } = newControl()
  629. const text = camera.formatCameraState(control.getState())
  630. ;['h=', 'p=', 'r=', 'pivot=', 'focus=ground', 'drag=none', 'settled='].forEach((part) =>
  631. assert.ok(text.includes(part), `状态串应含 ${part}:${text}`),
  632. )
  633. assert.equal(
  634. camera.formatCameraState({ hpr: { heading: 0, pitch: -60, range: 1 }, pivot: null }),
  635. 'h=0.0 p=-60.0 r=1 pivot=none',
  636. )
  637. console.log(`PASS 12 状态串可读:${text}`)
  638. }
  639. console.log('\nT03 逻辑检查通过:三键契约、中心规则、换中心动画、阻尼(帧率无关)、DOM 绑定均符合契约与维护者裁定。')