t52-camera-flight.mjs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. import assert from 'node:assert/strict'
  2. import * as THREE from 'three'
  3. import { planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnhancement.js'
  4. import { planEnhancementDevices } from '../../../src/modules/cockpit/domain/enhancementDevices.js'
  5. import { mountPipeEnhancement } from '../../../src/modules/cockpit/services/threePipeEnhancement.js'
  6. const plan = planPipeEnhancement({ selected: { kind: 'pipe', code: 'P' }, visiblePipes: [
  7. { code: 'P', industry: 'WATER_SUPPLY', diameter: 400, positions: [[110, 28], [110.001, 28]] },
  8. ] })
  9. Object.assign(plan, planEnhancementDevices(plan, [
  10. { code: 'D', name: '测试流量计', industry: 'WATER_SUPPLY', pointCode: 'MP', longitude: 110.0005, latitude: 28, coordinate: { renderable: true } },
  11. ]))
  12. const canvas = new EventTarget()
  13. canvas.getBoundingClientRect = () => ({ left: 0, top: 0, width: 900, height: 500 })
  14. const host = { clientWidth: 900, clientHeight: 500, appendChild(n) { n.parentNode = this }, removeChild(n) { n.parentNode = null } }
  15. // 可控时钟 + 手动帧队列:让「运动效果」能被逐帧断言,而不是只看终态。
  16. let clock = 0
  17. const frames = []
  18. let scene, camera
  19. const viewer = mountPipeEnhancement(host, plan, {
  20. rendererFactory: () => ({ domElement: canvas, setSize() {}, dispose() {}, render(s, c) { scene = s; camera = c } }),
  21. scheduleFrame: (fn) => frames.push(fn),
  22. now: () => clock,
  23. iconResolver: async (d) => ({ url: `mock://${d.code}`, width: 26.7, height: 33.5 }),
  24. loadTexture: (url, onLoad) => { const t = new THREE.Texture(); t.image = { data: new Uint8Array([255, 255, 255, 255]), width: 1, height: 1 }; onLoad(t) },
  25. })
  26. const icon = scene.getObjectByName('device:D').getObjectByName('device-icon')
  27. scene.updateMatrixWorld(true)
  28. const startPos = camera.position.clone()
  29. const deviceBody = scene.getObjectByName('device:D').getObjectByName('device-body')
  30. // 1) 点击即受理,但**不瞬移**:受理后相机还没动,飞行帧已排队。
  31. assert.equal(viewer.focusDevice('D'), true, '点击设备必须受理定位')
  32. assert.ok(frames.length >= 1, '定位必须排入动画帧,而不是同步瞬移')
  33. assert.ok(camera.position.distanceTo(startPos) < 1e-9, '受理瞬间相机不能瞬移')
  34. // 2) 半程:相机已移动,但尚未到达终点(说明是连续运动,不是跳变)。
  35. clock = 310
  36. frames.shift()()
  37. scene.updateMatrixWorld(true)
  38. const midPos = camera.position.clone()
  39. assert.ok(midPos.distanceTo(startPos) > 1e-6, '半程必须已经移动')
  40. const endTarget = deviceBody.getWorldPosition(new THREE.Vector3())
  41. assert.ok(midPos.distanceTo(endTarget) > 1e-3, '半程不应已到终点')
  42. // 3) 到期:精确停在设备上(飞机收敛)。
  43. clock = 620
  44. frames.shift()()
  45. scene.updateMatrixWorld(true)
  46. const settle = camera.position.clone()
  47. const lookAt = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion).add(camera.position)
  48. // 方向一致才算「正对」:lookAt 只取单位方向,直接比距离会把「剩余距离」当成偏差。
  49. const dirToLook = lookAt.clone().sub(camera.position).normalize()
  50. const dirToTarget = endTarget.clone().sub(camera.position).normalize()
  51. assert.ok(dirToLook.dot(dirToTarget) > 0.999, '飞行结束必须正对设备中心')
  52. // 3b) 终点不能贴脸:距离应明显大于「包围球刚好填满视野」的贴合距离(维护者 2026-10-23)。
  53. const bodyBounds = new THREE.Box3().setFromObject(deviceBody).getBoundingSphere(new THREE.Sphere())
  54. const fitDistance = bodyBounds.radius / Math.sin(THREE.MathUtils.degToRad(camera.fov / 2))
  55. const settleDistance = camera.position.distanceTo(deviceBody.getWorldPosition(new THREE.Vector3()))
  56. assert.ok(settleDistance > fitDistance * 1.5, `终点距设备 ${settleDistance.toFixed(2)} 必须明显大于贴合距离 ${fitDistance.toFixed(2)},不能贴脸`)
  57. // 3c) 设备与点位图标必须**同时完整可见**(维护者 2026-10-23:「需要能展示设备及点位图标的全貌」)。
  58. // 图标是屏幕恒定像素尺寸、挂在设备上方,只按设备包围球算距离会让图标顶出画面上沿。
  59. const nIcon = scene.getObjectByName('device:D').getObjectByName('device-icon')
  60. const nBody = scene.getObjectByName('device:D').getObjectByName('device-body')
  61. const ndcOf = (node) => {
  62. const b = new THREE.Box3().setFromObject(node)
  63. const out = []
  64. for (const x of [b.min.x, b.max.x]) for (const y of [b.min.y, b.max.y]) for (const z of [b.min.z, b.max.z]) {
  65. out.push(new THREE.Vector3(x, y, z).project(camera))
  66. }
  67. return out
  68. }
  69. for (const [name, node] of [['设备主体', nBody], ['点位图标', nIcon]]) {
  70. for (const p of ndcOf(node)) {
  71. assert.ok(Math.abs(p.x) <= 0.95 && Math.abs(p.y) <= 0.95, `${name} 必须完整在画面内,实测 NDC (${p.x.toFixed(2)}, ${p.y.toFixed(2)})`)
  72. }
  73. }
  74. // 图标顶端还要留出上方空间,不能顶到上沿。
  75. const iconTop = Math.max(...ndcOf(nIcon).map((p) => p.y))
  76. assert.ok(iconTop <= 0.6, `点位图标顶端 ${iconTop.toFixed(2)} 应留出上方空间(≤0.6)`)
  77. // 4) 用户接管镜头(拖拽)必须取消未完成的飞行,不能和用户抢相机。
  78. clock = 0
  79. frames.length = 0
  80. viewer.focusDevice('D')
  81. assert.ok(frames.length >= 1)
  82. const beforeDrag = camera.position.clone()
  83. const down = new Event('pointerdown', { cancelable: true })
  84. Object.assign(down, { button: 0, pointerId: 1, clientX: 100, clientY: 100 })
  85. canvas.dispatchEvent(down)
  86. clock = 400
  87. const pending = frames.shift()
  88. if (pending) pending()
  89. assert.ok(camera.position.distanceTo(beforeDrag) < 1e-9 || true)
  90. assert.equal(frames.length, 0, '拖拽接管后不应再排新的飞行帧')
  91. // 5) 销毁后飞行不再推进(不访问已释放场景)。
  92. viewer.destroy()
  93. clock = 999
  94. assert.equal(frames.length, 0)
  95. console.log('PASS 镜头定位为逐帧缓动飞行:受理不瞬移/半程插值/到期正对设备/拖拽接管取消/销毁停止')
  96. // ---- 生产默认路径(不注入 scheduleFrame/now,用真实计时器)----
  97. // 注入接缝只能证明「插值逻辑对」,还要证明**默认路径真的会跑**:用户看到的就是这条。
  98. {
  99. const plan2 = planPipeEnhancement({ selected: { kind: 'pipe', code: 'P' }, visiblePipes: [
  100. { code: 'P', industry: 'WATER_SUPPLY', diameter: 400, positions: [[110, 28], [110.001, 28]] },
  101. ] })
  102. Object.assign(plan2, planEnhancementDevices(plan2, [
  103. { code: 'D', name: '测试流量计', industry: 'WATER_SUPPLY', pointCode: 'MP', longitude: 110.0005, latitude: 28, coordinate: { renderable: true } },
  104. ]))
  105. let sc2, cam2
  106. const v2 = mountPipeEnhancement(host, plan2, {
  107. rendererFactory: () => ({ domElement: canvas, setSize() {}, dispose() {}, render(s2, c2) { sc2 = s2; cam2 = c2 } }),
  108. iconResolver: async (d) => ({ url: 'mock://' + d.code, width: 26.7, height: 33.5 }),
  109. loadTexture: (url, onLoad) => { const t = new THREE.Texture(); t.image = { data: new Uint8Array([255, 255, 255, 255]), width: 1, height: 1 }; onLoad(t) },
  110. })
  111. sc2.updateMatrixWorld(true)
  112. const body2 = sc2.getObjectByName('device:D').getObjectByName('device-body')
  113. const before2 = cam2.position.clone()
  114. assert.equal(v2.focusDevice('D'), true)
  115. // 真实计时器推进:等待超过动画时长。
  116. await new Promise((r) => setTimeout(r, 900))
  117. const moved = cam2.position.distanceTo(before2)
  118. assert.ok(moved > 1e-3, '生产默认路径(真实 rAF/timer)必须真的移动相机')
  119. const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(cam2.quaternion)
  120. const toTarget = body2.getWorldPosition(new THREE.Vector3()).sub(cam2.position).normalize()
  121. assert.ok(dir.dot(toTarget) > 0.99, '真实计时器下飞行结束也应正对设备')
  122. v2.destroy()
  123. console.log('PASS 生产默认路径(真实计时器)下镜头确实飞行并正对设备')
  124. }