t03-camera-contract.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. /**
  2. * T03 逻辑检查:三键相机契约与旋转中心规则(票 03 / ADR 0008)。
  3. *
  4. * 仓库没有测试运行器,且项目约定不为验证引入新框架,所以这里用零依赖 Node 断言脚本:
  5. * 相机语义全在 `domain/camera.js`(纯数学)与 `services/cameraControl.js`(状态迁移,引擎依赖注入),
  6. * 因此可以用**假引擎**驱动整套交互,不需要浏览器、不需要 cesium。
  7. * 真机行为(Cesium 默认输入确实被替换、中心确实落在屏幕中心)由 t03-camera-walkthrough.mjs 覆盖。
  8. *
  9. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t03-camera-contract.mjs
  10. */
  11. import assert from 'node:assert/strict'
  12. import { FOCUS_FLIGHT_MS, PITCH_LIMITS, RANGE_LIMITS } from '../../../src/modules/cockpit/domain/camera.js'
  13. import * as camera from '../../../src/modules/cockpit/domain/camera.js'
  14. import { attachCameraInput, createCameraControl } from '../../../src/modules/cockpit/services/cameraControl.js'
  15. const GROUND = { lon: 110.39, lat: 28.46, height: 0 }
  16. const XIANGLING = { lon: 110.396, lat: 28.455, height: 0 } // 沅陵某监测点
  17. function createFakeEngine({ viewport = { width: 1680, height: 900 }, fovY = Math.PI / 3, ground = GROUND } = {}) {
  18. const applied = []
  19. let clock = 1000
  20. let pendingFrame = null
  21. return {
  22. applied,
  23. engine: {
  24. applyView(pivot, hpr) {
  25. applied.push({ pivot: { ...pivot }, hpr: { ...hpr } })
  26. },
  27. readView() {
  28. return { heading: 0, pitch: -60, range: 10000 }
  29. },
  30. pickGroundAtCenter() {
  31. return { ...ground }
  32. },
  33. projectPivot() {
  34. return { x: viewport.width / 2, y: viewport.height / 2 }
  35. },
  36. inputTarget() {
  37. return null
  38. },
  39. viewport() {
  40. return { ...viewport }
  41. },
  42. fovY() {
  43. return fovY
  44. },
  45. now() {
  46. return clock
  47. },
  48. requestFrame(callback) {
  49. pendingFrame = callback
  50. },
  51. cancelFrame() {
  52. pendingFrame = null
  53. },
  54. },
  55. get pendingFrame() {
  56. return pendingFrame
  57. },
  58. advance(ms) {
  59. clock += ms
  60. },
  61. runFrame() {
  62. const callback = pendingFrame
  63. pendingFrame = null
  64. if (callback) callback()
  65. },
  66. last() {
  67. return applied[applied.length - 1]
  68. },
  69. }
  70. }
  71. function newControl(options) {
  72. const fake = createFakeEngine(options)
  73. const control = createCameraControl({ engine: fake.engine })
  74. control.syncFromEngine()
  75. fake.applied.length = 0
  76. return { fake, control }
  77. }
  78. /** 假元素:记录监听器,供 attachCameraInput 的绑定/解绑断言。 */
  79. function createFakeElement() {
  80. const handlers = new Map()
  81. return {
  82. handlers,
  83. defaultPrevented: 0,
  84. addEventListener(type, fn) {
  85. if (!handlers.has(type)) handlers.set(type, new Set())
  86. handlers.get(type).add(fn)
  87. },
  88. removeEventListener(type, fn) {
  89. if (handlers.has(type)) handlers.get(type).delete(fn)
  90. },
  91. setPointerCapture() {},
  92. releasePointerCapture() {},
  93. fire(type, payload = {}) {
  94. const set = handlers.get(type)
  95. if (!set) return
  96. const event = {
  97. preventDefault() {
  98. this.defaultPrevented = true
  99. },
  100. ...payload,
  101. }
  102. set.forEach((fn) => fn(event))
  103. return event
  104. },
  105. }
  106. }
  107. const near = (a, b, tolerance, message) =>
  108. assert.ok(Math.abs(a - b) <= tolerance, `${message}:期望 ≈${b}(±${tolerance}),实际 ${a}`)
  109. // ---------- 1. 左键拖拽只平移 ----------
  110. {
  111. const { fake, control } = newControl()
  112. const before = control.getState()
  113. control.beginDrag({ button: 0, x: 0, y: 0 })
  114. control.moveDrag({ x: 100, y: 0 })
  115. const after = control.getState()
  116. assert.deepEqual(after.hpr, before.hpr, '平移不得改 heading / pitch / range')
  117. assert.equal(fake.last().hpr.range, before.hpr.range, '平移不得改弹簧臂长度')
  118. // 期望位移:range*tan(fov/2)*2/viewportH 米/像素 × 100 像素,heading=0 时应落在正东。
  119. const metersPerPx = camera.metersPerPixel({ range: 10000, fovYRad: Math.PI / 3, viewportHeight: 900 })
  120. const expectedLon = before.pivot.lon + (100 * metersPerPx) / (111320 * Math.cos((GROUND.lat * Math.PI) / 180))
  121. near(after.pivot.lon, expectedLon, Math.abs(expectedLon - before.pivot.lon) * 0.02, 'heading=0 时右拖应把中心按像素比例移到正东')
  122. near(after.pivot.lat, before.pivot.lat, 1e-6, 'heading=0 时右拖不应产生南北位移')
  123. console.log('PASS 1 左键拖拽只平移(heading/pitch/range 原样,中心按像素比例东移)')
  124. }
  125. // ---------- 2. 平移方向 = 相机移动方向(PAN_FOLLOWS_DRAG 的语义钉子) ----------
  126. {
  127. const { control } = newControl()
  128. control.beginDrag({ button: 0, x: 0, y: 0 })
  129. control.moveDrag({ x: 0, y: 100 })
  130. const down = control.getState()
  131. assert.ok(down.pivot.lat < GROUND.lat, `heading=0 时下拖应把中心往南带(朝相机方向):${JSON.stringify(down.pivot)}`)
  132. const { control: control2 } = newControl()
  133. control2.beginDrag({ button: 2, x: 0, y: 0 })
  134. // 先把 heading 拖到 90°(向东看):像素数按灵敏度反算,不写死倍数。
  135. control2.moveDrag({ x: 90 / camera.ORBIT_SENSITIVITY.heading, y: 0 })
  136. control2.endDrag()
  137. near(control2.getState().hpr.heading, 90, 1e-6, '轨道旋转应把 heading 拖到 90°')
  138. const beforePan = control2.getState()
  139. control2.beginDrag({ button: 0, x: 0, y: 0 })
  140. control2.moveDrag({ x: 100, y: 0 })
  141. const afterPan = control2.getState()
  142. assert.ok(
  143. afterPan.pivot.lat < beforePan.pivot.lat,
  144. `heading=90°(向东看)时右拖应把中心往南带(相机的右手方向):${JSON.stringify(afterPan.pivot)}`,
  145. )
  146. near(afterPan.pivot.lon, beforePan.pivot.lon, 1e-6, 'heading=90° 时右拖不应产生东西位移')
  147. console.log('PASS 2 平移方向 = 相机移动方向(ADR 0008 的「拖拽方向映射为摄像机移动方向」,heading=0/90 两点验证)')
  148. }
  149. // ---------- 3. 右键拖拽只做轨道旋转 ----------
  150. {
  151. const { fake, control } = newControl()
  152. const before = control.getState()
  153. control.beginDrag({ button: 2, x: 0, y: 0 })
  154. control.moveDrag({ x: 100, y: 50 })
  155. const after = control.getState()
  156. near(after.hpr.heading, 28, 1e-6, 'heading 变化 = dx × 灵敏度')
  157. near(after.hpr.pitch, -60 - 50 * 0.22, 1e-6, 'pitch 变化 = -dy × 灵敏度')
  158. assert.equal(after.hpr.range, before.hpr.range, '轨道旋转不得改弹簧臂长度')
  159. assert.deepEqual(after.pivot, before.pivot, '轨道旋转不得移动中心')
  160. assert.equal(fake.last().hpr.range, before.hpr.range, '下发给引擎的 range 也必须原样')
  161. console.log('PASS 3 右键拖拽只做轨道旋转(heading/pitch 按像素变化,range 与中心不动)')
  162. }
  163. // ---------- 4. pitch 夹在上下限内 ----------
  164. {
  165. const { control } = newControl()
  166. control.beginDrag({ button: 2, x: 0, y: 0 })
  167. control.moveDrag({ x: 0, y: 5000 })
  168. near(control.getState().hpr.pitch, PITCH_LIMITS.ground.min, 1e-9, '一直下拖应夹在下限')
  169. control.moveDrag({ x: 0, y: -10000 })
  170. near(control.getState().hpr.pitch, PITCH_LIMITS.ground.max, 1e-9, '一直上拖应夹在上限')
  171. console.log(`PASS 4 pitch 夹在 [${PITCH_LIMITS.ground.min}, ${PITCH_LIMITS.ground.max}](地面视图)`)
  172. }
  173. // ---------- 5. 右键按下锁定中心:拖拽中不重新拾取 ----------
  174. {
  175. const { fake, control } = newControl()
  176. const locked = control.getState().pivot
  177. control.beginDrag({ button: 2, x: 0, y: 0 })
  178. // 拖拽进行中来了「选中某对象」:中心不许被改,飞行也不许插进来。
  179. control.setFocus(XIANGLING)
  180. control.moveDrag({ x: 40, y: 0 })
  181. assert.deepEqual(fake.last().pivot, locked, '拖拽中下发的中心必须仍是按下时锁定的那个')
  182. control.endDrag()
  183. assert.equal(control.getState().flying, true, '松手后应开始平滑换中心')
  184. let guard = 0
  185. while (control.getState().flying && guard++ < 200) {
  186. fake.advance(50)
  187. fake.runFrame()
  188. }
  189. const final = control.getState().pivot
  190. near(final.lon, XIANGLING.lon, 1e-6, '飞行结束后中心应为选中对象')
  191. near(final.lat, XIANGLING.lat, 1e-6, '飞行结束后中心应为选中对象')
  192. console.log('PASS 5 右键按下锁定中心(拖拽中的选中不插队;松手后才开始换中心)')
  193. }
  194. // ---------- 6. 滚轮只改弹簧臂长度 ----------
  195. {
  196. const { control } = newControl()
  197. const before = control.getState()
  198. const handled = control.wheel({ deltaY: -100 })
  199. const after = control.getState()
  200. assert.equal(handled, true, '滚轮应被相机契约接管(调用方据此 preventDefault)')
  201. near(after.hpr.range, 10000 * Math.exp(-100 * 0.0012), 1e-6, '上滚拉近:range 按指数缩')
  202. assert.equal(after.hpr.heading, before.hpr.heading, '滚轮不得转 heading')
  203. assert.equal(after.hpr.pitch, before.hpr.pitch, '滚轮不得改 pitch')
  204. assert.deepEqual(after.pivot, before.pivot, '滚轮不得移动中心')
  205. control.wheel({ deltaY: -1e6 })
  206. near(control.getState().hpr.range, RANGE_LIMITS.min, 1e-9, '拉近到极限应夹住下限')
  207. control.wheel({ deltaY: 1e6 })
  208. near(control.getState().hpr.range, RANGE_LIMITS.max, 1e-9, '推远到极限应夹住上限')
  209. console.log(`PASS 6 滚轮只改弹簧臂长度(并夹在 [${RANGE_LIMITS.min}, ${RANGE_LIMITS.max}] 米)`)
  210. }
  211. // ---------- 7. 中心规则:选中优先 / 取消不回退 / 平移才回退 ----------
  212. {
  213. const { fake, control } = newControl()
  214. assert.deepEqual(control.getState().pivot, GROUND, '未选中时中心 = 屏幕中心的地面拾取点')
  215. control.setFocus(XIANGLING)
  216. let guard = 0
  217. while (control.getState().flying && guard++ < 200) {
  218. fake.advance(60)
  219. fake.runFrame()
  220. }
  221. assert.deepEqual(control.getState().pivot, XIANGLING, '选中后中心 = 对象地表点')
  222. control.setFocus(null)
  223. assert.deepEqual(control.getState().pivot, XIANGLING, '取消选中不得回退中心')
  224. assert.equal(control.getState().focus, null, '取消选中应清掉选中态')
  225. control.beginDrag({ button: 0, x: 0, y: 0 })
  226. control.moveDrag({ x: 10, y: 0 })
  227. control.endDrag()
  228. assert.deepEqual(control.getState().pivot, GROUND, '左键平移后中心回到地面拾取点')
  229. console.log('PASS 7 中心规则:选中优先 → 取消不回退 → 平移才更新为地面拾取点')
  230. }
  231. // ---------- 8. 换中心平滑、且全程保持 heading 与 range ----------
  232. {
  233. const { fake, control } = newControl()
  234. const before = control.getState()
  235. control.setFocus(XIANGLING)
  236. // 起手第一帧(elapsed = 0)必须仍停在原中心 —— 换中心不许瞬移。
  237. near(fake.last().pivot.lon, before.pivot.lon, 1e-12, '换中心第一帧应从原中心出发')
  238. const samples = []
  239. let guard = 0
  240. while (control.getState().flying && guard++ < 200) {
  241. fake.advance(FOCUS_FLIGHT_MS / 10)
  242. fake.runFrame()
  243. const now = control.getState()
  244. assert.equal(now.hpr.heading, before.hpr.heading, '换中心过程中 heading 必须保持')
  245. assert.equal(now.hpr.range, before.hpr.range, '换中心过程中 range 必须保持')
  246. samples.push(now.pivot.lon)
  247. }
  248. assert.ok(samples.length >= 4, `换中心应有多帧(实际 ${samples.length})`)
  249. const monotonic = samples.every((lon, i) => i === 0 || lon >= samples[i - 1] - 1e-12)
  250. assert.equal(monotonic, true, `中心应朝目标单调推进:${samples.join(' → ')}`)
  251. assert.ok(samples[0] > before.pivot.lon && samples[0] < XIANGLING.lon, '第一帧应落在起止之间')
  252. near(samples[samples.length - 1], XIANGLING.lon, 1e-6, '末帧应到达目标中心')
  253. assert.equal(control.getState().flying, false, '到达后应结束飞行')
  254. console.log(`PASS 8 换中心平滑(${samples.length} 帧单调推进、heading/range 全程不变)`)
  255. }
  256. // ---------- 9. DOM 绑定:三类输入都接上,右键菜单与滚轮默认行为被吃掉 ----------
  257. {
  258. const { control } = newControl()
  259. const element = createFakeElement()
  260. const detach = attachCameraInput(element, control)
  261. assert.deepEqual(
  262. [...element.handlers.keys()].sort(),
  263. ['contextmenu', 'pointercancel', 'pointerdown', 'pointermove', 'pointerup', 'wheel'],
  264. '应绑定左右键拖拽与滚轮(并吃掉右键菜单)',
  265. )
  266. const menu = element.fire('contextmenu')
  267. assert.equal(menu.defaultPrevented, true, '右键菜单必须被 preventDefault')
  268. element.fire('pointerdown', { button: 0, pointerId: 1, clientX: 500, clientY: 400 })
  269. element.fire('pointermove', { pointerId: 1, clientX: 560, clientY: 400 })
  270. const dragging = control.getState()
  271. assert.equal(dragging.dragging, 'pan', '左键按下后应处于平移态')
  272. element.fire('pointerup', { pointerId: 1, clientX: 560, clientY: 400 })
  273. assert.equal(control.getState().dragging, null, '松手后应结束拖拽')
  274. element.fire('pointerdown', { button: 2, pointerId: 2, clientX: 500, clientY: 400 })
  275. element.fire('pointermove', { pointerId: 2, clientX: 540, clientY: 400 })
  276. assert.equal(control.getState().dragging, 'orbit', '右键按下后应处于轨道旋转态')
  277. element.fire('pointerup', { pointerId: 2, clientX: 540, clientY: 400 })
  278. const wheelEvent = element.fire('wheel', { deltaY: -120 })
  279. assert.equal(wheelEvent.defaultPrevented, true, '滚轮默认行为(页面滚动)必须被吃掉')
  280. detach()
  281. assert.equal(element.handlers.get('pointerdown').size, 0, 'detach 应摘掉监听器')
  282. assert.equal(element.handlers.get('wheel').size, 0, 'detach 应摘掉滚轮监听器')
  283. console.log('PASS 9 DOM 绑定:三类输入接上、右键菜单与滚轮默认行为被吃掉、可完整摘除')
  284. }
  285. // ---------- 10. 状态串(走查与人工排查读的那一行) ----------
  286. {
  287. const { control } = newControl()
  288. const text = camera.formatCameraState(control.getState())
  289. ;['h=', 'p=', 'r=', 'pivot=', 'focus=ground', 'drag=none'].forEach((part) =>
  290. assert.ok(text.includes(part), `状态串应含 ${part}:${text}`),
  291. )
  292. assert.equal(camera.formatCameraState({ hpr: { heading: 0, pitch: -60, range: 1 }, pivot: null }), 'h=0.0 p=-60.0 r=1 pivot=none')
  293. console.log(`PASS 10 状态串可读:${text}`)
  294. }
  295. console.log('\nT03 逻辑检查通过:三键契约、中心规则、换中心动画、DOM 绑定均符合 ADR 0008。')