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