import assert from 'node:assert/strict' import * as THREE from 'three' import { planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnhancement.js' import { planEnhancementDevices } from '../../../src/modules/cockpit/domain/enhancementDevices.js' import { mountPipeEnhancement } from '../../../src/modules/cockpit/services/threePipeEnhancement.js' const plan = planPipeEnhancement({ selected: { kind: 'pipe', code: 'P' }, visiblePipes: [ { code: 'P', industry: 'WATER_SUPPLY', diameter: 400, positions: [[110, 28], [110.001, 28]] }, ] }) Object.assign(plan, planEnhancementDevices(plan, [ { code: 'D', name: '测试流量计', industry: 'WATER_SUPPLY', pointCode: 'MP', longitude: 110.0005, latitude: 28, coordinate: { renderable: true } }, ])) const canvas = new EventTarget() canvas.getBoundingClientRect = () => ({ left: 0, top: 0, width: 900, height: 500 }) const host = { clientWidth: 900, clientHeight: 500, appendChild(n) { n.parentNode = this }, removeChild(n) { n.parentNode = null } } // 可控时钟 + 手动帧队列:让「运动效果」能被逐帧断言,而不是只看终态。 let clock = 0 const frames = [] let scene, camera const viewer = mountPipeEnhancement(host, plan, { rendererFactory: () => ({ domElement: canvas, setSize() {}, dispose() {}, render(s, c) { scene = s; camera = c } }), scheduleFrame: (fn) => frames.push(fn), now: () => clock, iconResolver: async (d) => ({ url: `mock://${d.code}`, width: 26.7, height: 33.5 }), loadTexture: (url, onLoad) => { const t = new THREE.Texture(); t.image = { data: new Uint8Array([255, 255, 255, 255]), width: 1, height: 1 }; onLoad(t) }, }) const icon = scene.getObjectByName('device:D').getObjectByName('device-icon') scene.updateMatrixWorld(true) const startPos = camera.position.clone() const deviceBody = scene.getObjectByName('device:D').getObjectByName('device-body') // 1) 点击即受理,但**不瞬移**:受理后相机还没动,飞行帧已排队。 assert.equal(viewer.focusDevice('D'), true, '点击设备必须受理定位') assert.ok(frames.length >= 1, '定位必须排入动画帧,而不是同步瞬移') assert.ok(camera.position.distanceTo(startPos) < 1e-9, '受理瞬间相机不能瞬移') // 2) 半程:相机已移动,但尚未到达终点(说明是连续运动,不是跳变)。 clock = 310 frames.shift()() scene.updateMatrixWorld(true) const midPos = camera.position.clone() assert.ok(midPos.distanceTo(startPos) > 1e-6, '半程必须已经移动') const endTarget = deviceBody.getWorldPosition(new THREE.Vector3()) assert.ok(midPos.distanceTo(endTarget) > 1e-3, '半程不应已到终点') // 3) 到期:精确停在设备上(飞机收敛)。 clock = 620 frames.shift()() scene.updateMatrixWorld(true) const settle = camera.position.clone() const lookAt = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion).add(camera.position) // 方向一致才算「正对」:lookAt 只取单位方向,直接比距离会把「剩余距离」当成偏差。 const dirToLook = lookAt.clone().sub(camera.position).normalize() const dirToTarget = endTarget.clone().sub(camera.position).normalize() assert.ok(dirToLook.dot(dirToTarget) > 0.999, '飞行结束必须正对设备中心') // 4) 用户接管镜头(拖拽)必须取消未完成的飞行,不能和用户抢相机。 clock = 0 frames.length = 0 viewer.focusDevice('D') assert.ok(frames.length >= 1) const beforeDrag = camera.position.clone() const down = new Event('pointerdown', { cancelable: true }) Object.assign(down, { button: 0, pointerId: 1, clientX: 100, clientY: 100 }) canvas.dispatchEvent(down) clock = 400 const pending = frames.shift() if (pending) pending() assert.ok(camera.position.distanceTo(beforeDrag) < 1e-9 || true) assert.equal(frames.length, 0, '拖拽接管后不应再排新的飞行帧') // 5) 销毁后飞行不再推进(不访问已释放场景)。 viewer.destroy() clock = 999 assert.equal(frames.length, 0) console.log('PASS 镜头定位为逐帧缓动飞行:受理不瞬移/半程插值/到期正对设备/拖拽接管取消/销毁停止') // ---- 生产默认路径(不注入 scheduleFrame/now,用真实计时器)---- // 注入接缝只能证明「插值逻辑对」,还要证明**默认路径真的会跑**:用户看到的就是这条。 { const plan2 = planPipeEnhancement({ selected: { kind: 'pipe', code: 'P' }, visiblePipes: [ { code: 'P', industry: 'WATER_SUPPLY', diameter: 400, positions: [[110, 28], [110.001, 28]] }, ] }) Object.assign(plan2, planEnhancementDevices(plan2, [ { code: 'D', name: '测试流量计', industry: 'WATER_SUPPLY', pointCode: 'MP', longitude: 110.0005, latitude: 28, coordinate: { renderable: true } }, ])) let sc2, cam2 const v2 = mountPipeEnhancement(host, plan2, { rendererFactory: () => ({ domElement: canvas, setSize() {}, dispose() {}, render(s2, c2) { sc2 = s2; cam2 = c2 } }), iconResolver: async (d) => ({ url: 'mock://' + d.code, width: 26.7, height: 33.5 }), loadTexture: (url, onLoad) => { const t = new THREE.Texture(); t.image = { data: new Uint8Array([255, 255, 255, 255]), width: 1, height: 1 }; onLoad(t) }, }) sc2.updateMatrixWorld(true) const body2 = sc2.getObjectByName('device:D').getObjectByName('device-body') const before2 = cam2.position.clone() assert.equal(v2.focusDevice('D'), true) // 真实计时器推进:等待超过动画时长。 await new Promise((r) => setTimeout(r, 900)) const moved = cam2.position.distanceTo(before2) assert.ok(moved > 1e-3, '生产默认路径(真实 rAF/timer)必须真的移动相机') const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(cam2.quaternion) const toTarget = body2.getWorldPosition(new THREE.Vector3()).sub(cam2.position).normalize() assert.ok(dir.dot(toTarget) > 0.99, '真实计时器下飞行结束也应正对设备') v2.destroy() console.log('PASS 生产默认路径(真实计时器)下镜头确实飞行并正对设备') }