t52-device-pick.mjs 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  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, deviceMarkerPlan } 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, [{ code: 'D', name: '真实供水设备', industry: 'WATER_SUPPLY', pointCode: 'MP', longitude: 110.0005, latitude: 28, coordinate: { renderable: true } }]))
  10. const canvas = new EventTarget()
  11. canvas.getBoundingClientRect = () => ({ left: 0, top: 0, width: 900, height: 500 })
  12. let scene, camera, picked
  13. let resolvedDevice = null
  14. let textureUrl = ''
  15. const host = { clientWidth: 900, clientHeight: 500, appendChild(n) { n.parentNode = this }, removeChild(n) { n.parentNode = null } }
  16. const viewer = mountPipeEnhancement(host, plan, {
  17. onDevicePick: (d) => { picked = d },
  18. rendererFactory: () => ({ domElement: canvas, setSize() {}, dispose() {}, render(s, c) { scene = s; camera = c } }),
  19. // 离线 seam:图标必须来自主图同款合成产物(`renderMarker`),这里注入一个可观测替代,
  20. // 并注入同步假贴图加载器(Node 无 DOM Image,真 TextureLoader 不可用)。
  21. iconResolver: async (device) => {
  22. resolvedDevice = device
  23. return { url: `mock-marker://${device.code}`, width: 26.7, height: 33.5 }
  24. },
  25. loadTexture: (url, onLoad) => {
  26. textureUrl = url
  27. const texture = new THREE.Texture()
  28. texture.image = { data: new Uint8Array([255, 255, 255, 255]), width: 1, height: 1 }
  29. texture.userData = { seeded: true }
  30. onLoad(texture)
  31. },
  32. })
  33. await new Promise((resolve) => setImmediate(resolve))
  34. scene.updateMatrixWorld(true)
  35. camera.updateMatrixWorld()
  36. const device = scene.getObjectByName('device:D')
  37. assert.ok(device)
  38. const icon = device.getObjectByName('device-icon')
  39. assert.ok(icon?.isSprite, '每个可显示设备必须有朝向镜头的点击图标')
  40. assert.equal(icon.material.depthTest, false, '设备图标必须在放大管体前可见')
  41. assert.equal(resolvedDevice?.code, 'D', '图标必须由主图同款合成链路按设备身份生成')
  42. assert.equal(textureUrl, 'mock-marker://D', 'Sprite 贴图必须采用合成产物 URL,而不是自绘图元')
  43. assert.ok(icon.material.map?.userData?.seeded, '贴图必须挂到 Sprite 材质上')
  44. assert.equal(icon.visible, true)
  45. assert.equal(icon.center.y, 0, '水滴尖端锚定:Sprite 底部中心为原点')
  46. const initialWorldSize = icon.getWorldScale(new THREE.Vector3()).y
  47. const initialDistance = camera.position.distanceTo(icon.getWorldPosition(new THREE.Vector3()))
  48. const screenSize = initialWorldSize / initialDistance
  49. assert.ok(screenSize > 0)
  50. assert.equal(viewer.focusDevice('D'), true)
  51. // focusDevice 改了相机姿态;假渲染器不会替我们刷新矩阵,投影前必须显式更新。
  52. scene.updateMatrixWorld(true)
  53. camera.updateMatrixWorld()
  54. const focusedWorldSize = icon.getWorldScale(new THREE.Vector3()).y
  55. const focusedDistance = camera.position.distanceTo(icon.getWorldPosition(new THREE.Vector3()))
  56. assert.ok(Math.abs(focusedWorldSize / focusedDistance - screenSize) / screenSize < 0.03, '图标保持固定屏幕尺寸')
  57. // 点击点取「图标可见区域中心」——锚点是水滴尖端(Sprite 底部),直接点锚点会落在图形之外。
  58. const clickPoint = new THREE.Box3().setFromObject(icon).getCenter(new THREE.Vector3())
  59. const screen = clickPoint.project(camera)
  60. const clientX = (screen.x + 1) * 450
  61. const clientY = (1 - screen.y) * 250
  62. function pointer(type, x = clientX) {
  63. const event = new Event(type, { cancelable: true })
  64. Object.assign(event, { button: 0, pointerId: 1, clientX: x, clientY })
  65. canvas.dispatchEvent(event)
  66. }
  67. pointer('pointerdown'); pointer('pointerup')
  68. assert.equal(picked.code, 'D')
  69. assert.equal(picked.pointCode, 'MP')
  70. assert.equal(picked.placementText, '邻近设备 · 示意安装')
  71. picked = null
  72. const root = scene.getObjectByName('pipe-enhancement:P')
  73. const blocker = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 0.5), new THREE.MeshBasicMaterial())
  74. blocker.position.copy(root.worldToLocal(camera.position.clone().lerp(device.getWorldPosition(new THREE.Vector3()), 0.5)))
  75. root.add(blocker)
  76. root.updateMatrixWorld(true)
  77. pointer('pointerdown'); pointer('pointerup')
  78. assert.equal(picked.code, 'D', '可见的浮动设备图标即使覆盖管壁也可直接点开详情')
  79. root.remove(blocker); blocker.geometry.dispose(); blocker.material.dispose()
  80. picked = null
  81. pointer('pointerdown'); pointer('pointermove', clientX + 30); pointer('pointerup', clientX + 30)
  82. assert.equal(picked, null)
  83. viewer.destroy()
  84. pointer('pointerdown'); pointer('pointerup')
  85. assert.equal(picked, null)
  86. console.log('PASS 真实Three设备模型射线点选派发业务身份;拖拽和销毁后不误派发')
  87. // ---- 语义同步:颜色状态与中间类型图标必须与主图同表(维护者 2026-10-22 第二次反馈)----
  88. assert.equal(deviceMarkerPlan({ status: 'ALARM', industry: 'GAS', pointCode: 'MP' }).tint, 'lv1', '报警→lv1')
  89. assert.equal(deviceMarkerPlan({ status: 'WARNING', industry: 'GAS', pointCode: 'MP' }).tint, 'lv2', '预警→lv2')
  90. assert.equal(deviceMarkerPlan({ status: 'WORK_ORDER', industry: 'GAS', pointCode: 'MP' }).tint, 'lv3', '工单处置→lv3')
  91. assert.equal(deviceMarkerPlan({ status: 'NORMAL', industry: 'GAS', pointCode: 'MP' }).tint, 'lv4', '正常→lv4')
  92. assert.equal(deviceMarkerPlan({ status: 'OFFLINE', industry: 'GAS', pointCode: 'MP' }).tint, 'point-offline', '离线必须用设备表的 point-offline,不能落点位表的 neutral')
  93. assert.equal(deviceMarkerPlan({ status: '未知态', industry: 'GAS', pointCode: 'MP' }).tint, 'point-unknown', '认不出必须 point-unknown,不能猜正常')
  94. const gasPlan = deviceMarkerPlan({ status: 'ALARM', industry: 'GAS', pointCode: 'MP' })
  95. assert.equal(gasPlan.inner.enabled, true, '有行业必须有内层类型图标')
  96. assert.equal(gasPlan.inner.icon, 'gas')
  97. assert.equal(deviceMarkerPlan({ status: 'ALARM', industry: 'WATER_SUPPLY', pointCode: 'MP' }).inner.icon, 'water')
  98. assert.equal(deviceMarkerPlan({ status: 'ALARM', industry: 'DRAINAGE', pointCode: 'MP' }).inner.icon, 'drainage')
  99. assert.equal(deviceMarkerPlan({ status: 'ALARM', industry: 'MANHOLE', pointCode: 'MP' }).inner.icon, 'manhole')
  100. assert.equal(deviceMarkerPlan({ status: 'ALARM', industry: '', pointCode: 'MP' }).inner.enabled, false, '无行业不造类型图标')
  101. assert.equal(gasPlan.master, 'solid', '有点位归属=实心母版')
  102. assert.equal(deviceMarkerPlan({ status: 'NORMAL', industry: 'GAS' }).master, 'hollow', '未关联设备=镂空母版')
  103. console.log('PASS 场景图标颜色状态/内层类型图标/母版形状与主图同表(设备五态 + 未知态)')