Browse Source

fix(cockpit): sync scene device icon tone and industry layer with map markers

ChenJZ 15 giờ trước cách đây
mục cha
commit
ba5041aed0

+ 19 - 1
.scratch/cockpit-shell-panel-layout/verify/t52-device-pick.mjs

@@ -2,7 +2,7 @@ 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'
+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]] },
 ] })
@@ -84,3 +84,21 @@ 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 场景图标颜色状态/内层类型图标/母版形状与主图同表(设备五态 + 未知态)')

+ 36 - 6
src/modules/cockpit/services/threePipeEnhancement.js

@@ -8,6 +8,7 @@ import { attachPointPick } from './pointPick.js'
 import { pipeSurfaceMaterial, outlineSelectedPipe } from './pipeSurfaceMaterial.js'
 import { sourceDiameterM, pipeSurface } from '../domain/pipePhysical.js'
 import { planMarker } from '../domain/markerPlan.js'
+import { deviceVisualForStatus } from '../domain/deviceStatus.js'
 import { renderMarker } from './markerAssets.js'
 
 /**
@@ -21,6 +22,9 @@ const TONE_FALLBACK = Object.freeze({
     lv3: '#01abea',
     lv4: '#27d17a',
     neutral: '#5680b0',
+    'point-offline': '#94a3b8',
+    'point-unknown': '#a970ff',
+    'point-unassociated': '#a3e635',
 })
 
 /**
@@ -37,18 +41,44 @@ function toneHex(tone) {
     return value && value.trim() ? value.trim() : fallback
 }
 
+/**
+ * 设备 → 主图 marker 合成计划(**语义同步的唯一裁决点**)。
+ *
+ * 维护者 2026-10-22:「已确认使用了场景同款设备图标,但是颜色状态及中间的类型图标还是没有同步。」
+ * 两处不一致的根因都在这里,必须与主图**逐项对齐**:
+ *
+ *   1. **颜色**:主图给 `planMarker` 传的是**设备状态表**的色键
+ *      (`deviceVisualForStatus()`:ALARM→lv1 / WARNING→lv2 / WORK_ORDER→lv3 /
+ *      NORMAL→lv4 / OFFLINE→`point-offline` / 未知→`point-unknown`)。
+ *      不传 tone 会落到 `markerPlan` 的**点位聚合态**默认表 —— 那里 OFFLINE/UNKNOWN
+ *      是 `neutral`,于是场景里的离线设备与主图不同色。所以必须显式传 `tone`。
+ *   2. **类型图标**:内层图标按 `industry` 选(gas/water/drainage/manhole)。
+ *      设备卡本身常无 `industry`,主图的规则是**继承所属点位行业**;设备侧已在
+ *      `domain/enhancementDevices.js` 按同一规则落好(`device.industry || point.industry`),
+ *      这里直接消费,不再二次推导。
+ *   3. **未关联设备**:主图对「没有监测点归属」的设备走镂空母版(业务异常时不降饱和)。
+ *      判据就是有没有 `pointCode` —— 与主图 `unassociated` 同一含义。
+ */
+export function deviceMarkerPlan(device) {
+    const visual = deviceVisualForStatus(device.status)
+    return planMarker({
+        kind: 'monitoring-device',
+        status: device.status,
+        industry: device.industry || '',
+        tone: visual.tone,
+        unassociated: !device.pointCode,
+    })
+}
+
 /**
  * 设备图标的**唯一来源**:复用主图 marker 的合成链路(`planMarker` + `renderMarker`),
  * 因此地下管线详情视图里的设备图标与一张图上的**同款**,不是另画一套。
- *
- * 维护者 2026-10-22:「场景设备图标没有按照要求使用地图中的同款。要保持图标一致性。」
- * 所以这里不再自绘任何图标;`renderMarker` 的产物(水滴母版 + 行业内层 + 白描边)
- * 直接作为 Sprite 贴图,尺寸也沿用它的 `draw`(含描边余量)。
+ * 母版形状、状态色、内层行业图标、白描边、尺寸全部由 `deviceMarkerPlan` 决定。
  */
 async function defaultDeviceIcon(device) {
-    const plan = planMarker({ kind: 'monitoring-device', status: device.status, industry: device.industry })
+    const plan = deviceMarkerPlan(device)
     const url = await renderMarker(plan, { tintHex: toneHex(plan.tint) })
-    return { url, width: plan.draw.width, height: plan.draw.height }
+    return { url, width: plan.draw.width, height: plan.draw.height, tone: plan.tint }
 }
 
 function defaultRendererFactory({ canvas }) {