Bläddra i källkod

feat(cockpit): animate camera flight when locating a device

ChenJZ 5 timmar sedan
förälder
incheckning
0d844bbcfc

+ 78 - 0
.scratch/cockpit-shell-panel-layout/verify/t52-camera-flight.mjs

@@ -0,0 +1,78 @@
+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 镜头定位为逐帧缓动飞行:受理不瞬移/半程插值/到期正对设备/拖拽接管取消/销毁停止')

+ 53 - 5
src/modules/cockpit/services/threePipeEnhancement.js

@@ -252,7 +252,7 @@ function buildNeighborhood(plan) {
  * Mount a bounded local viewer. The optional rendererFactory is a system-boundary seam
  * Mount a bounded local viewer. The optional rendererFactory is a system-boundary seam
  * for offline tests; production uses WebGLRenderer.
  * for offline tests; production uses WebGLRenderer.
  */
  */
-export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRendererFactory, pixelRatio = 1, onDevicePick = () => {}, onObjectPick = () => {}, iconResolver = defaultDeviceIcon, loadTexture = null } = {}) {
+export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRendererFactory, pixelRatio = 1, onDevicePick = () => {}, onObjectPick = () => {}, iconResolver = defaultDeviceIcon, loadTexture = null, scheduleFrame = null, now = null } = {}) {
     if (!host || !plan || plan.visible !== true || !plan.object) {
     if (!host || !plan || plan.visible !== true || !plan.object) {
         return { status: 'unavailable', sync: () => {}, destroy: () => {}, getState: () => ({ status: 'unavailable' }) }
         return { status: 'unavailable', sync: () => {}, destroy: () => {}, getState: () => ({ status: 'unavailable' }) }
     }
     }
@@ -302,6 +302,48 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
         renderer.setSize?.(width, height, false)
         renderer.setSize?.(width, height, false)
         render()
         render()
     }
     }
+    /**
+     * 镜头定位飞行(维护者 2026-10-22:「点击点位后的镜头定位运动效果也需要实现。」)
+     *
+     * 点击设备时不再瞬移:从当前 `target`/`distance` 在 `FLIGHT_MS` 内缓动到目标,
+     * 每帧重绘。缓动用 ease-in-out cubic(两端慢、中间快),与主图相机飞行的观感一致。
+     *
+     * 两个可注入接缝(`scheduleFrame` / `now`)让脱浏览器回归能逐帧断言「起步不是终点」
+     * 与「到期精确到位」,而不是只能验证终态。
+     */
+    const FLIGHT_MS = 620
+    const nowImpl = typeof now === 'function'
+        ? now
+        : () => (typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now())
+    const scheduleFrameImpl = typeof scheduleFrame === 'function'
+        ? scheduleFrame
+        : (fn) => (typeof requestAnimationFrame === 'function' ? requestAnimationFrame(fn) : setTimeout(fn, 16))
+    let flight = null
+    const easeInOutCubic = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2)
+    const cancelFlight = () => { flight = null }
+    const stepFlight = () => {
+        if (!flight || destroyed) return
+        const elapsed = nowImpl() - flight.startedAt
+        const progress = Math.min(1, Math.max(0, elapsed / flight.duration))
+        const eased = easeInOutCubic(progress)
+        target.lerpVectors(flight.fromTarget, flight.toTarget, eased)
+        distance = flight.fromDistance + (flight.toDistance - flight.fromDistance) * eased
+        render()
+        if (progress >= 1) { flight = null; return }
+        scheduleFrameImpl(stepFlight)
+    }
+    const startFlight = (toTarget, toDistance, duration = FLIGHT_MS) => {
+        flight = {
+            fromTarget: target.clone(),
+            fromDistance: distance,
+            toTarget: toTarget.clone(),
+            toDistance,
+            startedAt: nowImpl(),
+            duration: Math.max(1, duration),
+        }
+        scheduleFrameImpl(stepFlight)
+    }
+
     const render = () => {
     const render = () => {
         const horizontal = Math.cos(pitch) * distance
         const horizontal = Math.cos(pitch) * distance
         camera.position.set(
         camera.position.set(
@@ -440,6 +482,7 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
     })
     })
     const onDown = (event) => {
     const onDown = (event) => {
         if (drag || (event.button !== 0 && event.button !== 2)) return
         if (drag || (event.button !== 0 && event.button !== 2)) return
+        cancelFlight()
         const panDistance = Math.max(0.02, Math.min(distance, hitAt(event.clientX, event.clientY)?.distance ?? distance))
         const panDistance = Math.max(0.02, Math.min(distance, hitAt(event.clientX, event.clientY)?.distance ?? distance))
         drag = { button: event.button, id: event.pointerId, x: event.clientX, y: event.clientY, panDistance }
         drag = { button: event.button, id: event.pointerId, x: event.clientX, y: event.clientY, panDistance }
         event.preventDefault?.()
         event.preventDefault?.()
@@ -474,6 +517,7 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
     }
     }
     const onWheel = (event) => {
     const onWheel = (event) => {
         event.preventDefault?.()
         event.preventDefault?.()
+        cancelFlight()
         const hit = hitAt(event.clientX, event.clientY)
         const hit = hitAt(event.clientX, event.clientY)
         const factor = Math.exp(Math.max(-1000, Math.min(1000, event.deltaY)) * 0.001)
         const factor = Math.exp(Math.max(-1000, Math.min(1000, event.deltaY)) * 0.001)
         const limit = Math.max(sceneRadius * 50, 10)
         const limit = Math.max(sceneRadius * 50, 10)
@@ -507,16 +551,20 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
             if (destroyed) return false
             if (destroyed) return false
             const device = root?.getObjectByName(`device:${code}`)
             const device = root?.getObjectByName(`device:${code}`)
             if (!device) return false
             if (!device) return false
-            const bounds = new THREE.Box3().setFromObject(device).getBoundingSphere(new THREE.Sphere())
-            target.copy(bounds.center)
-            distance = Math.max(0.5, bounds.radius / Math.sin(THREE.MathUtils.degToRad(camera.fov / 2)) * 1.3)
-            render()
+            // 定位目标取**稳定几何**(设备主体/支杆),不能用含图标的整体包围盒:
+            // 图标按屏幕像素恒定尺寸缩放,飞行途中会变大变小,用整体盒算中心会让镜头「飘」。
+            const anchor = device.getObjectByName('device-body') || device
+            const bounds = new THREE.Box3().setFromObject(anchor).getBoundingSphere(new THREE.Sphere())
+            const toDistance = Math.max(0.5, bounds.radius / Math.sin(THREE.MathUtils.degToRad(camera.fov / 2)) * 1.3)
+            // 平滑飞行定位(不再瞬移);返回 true 表示已受理,飞行在后续帧推进。
+            startFlight(bounds.center, toDistance)
             return true
             return true
         },
         },
         getState: () => ({ status: destroyed ? 'destroyed' : 'ready', code: current.object.code, title: enhancementLabel(current.object.sourceKind) }),
         getState: () => ({ status: destroyed ? 'destroyed' : 'ready', code: current.object.code, title: enhancementLabel(current.object.sourceKind) }),
         destroy() {
         destroy() {
             if (destroyed) return
             if (destroyed) return
             destroyed = true
             destroyed = true
+            cancelFlight()
             onUp()
             onUp()
             observer?.disconnect()
             observer?.disconnect()
             detachFocus()
             detachFocus()