Pārlūkot izejas kodu

feat(cockpit): add visible device icons to pipe detail view

ChenJZ 2 dienas atpakaļ
vecāks
revīzija
2ff20d77e6

+ 37 - 0
.scratch/cockpit-shell-panel-layout/issues/58-untitled.md

@@ -0,0 +1,37 @@
+<!-- DSH-IDEMPOTENCY-KEY: underground-detail-scene-device-icon-sprite-v1 -->
+<!-- parentKey: 00 -->
+<!-- created: 2026-10-19T09:03:48.422Z -->
+# 地下管线详情视图场景设备图标与直接点选
+
+## What to build
+
+维护者要求地下管线详情视图直接展示清晰设备图标,点击图标打开已有设备详情。仅清单设备无可准入位置时,不造图标或安装点。
+
+## Acceptance criteria
+
+- [x] 每个可渲染设备均有独立billboard Sprite徽标,而非只用几何体代替图标。
+- [x] Sprite屏幕尺寸随相机距离补偿;颜色沿用设备行业识别色,通用白色测量符号区分流量/压力/未知类型,不伪造状态/行业。
+- [x] 真实Sprite射线点击直接派发设备身份并打开内部详情;管壁在图标前时可见标记仍响应,不准入设备不创建Sprite。
+- [x] DataTexture与Sprite材质随场景释放;清单聚焦与场景点击共用同一详情面板。
+- [x] Three Sprite像素/射线/遮挡/拖动/销毁与Vue详情/关闭/列表/管道交互回归通过。
+- [ ] 正式入口现场图标大小、材质、密集视图视觉验收;本轮按AGENTS未运行build/dev/browser/GPU。
+
+## 进度
+
+Sprite图标已实现:设备中心圆形行业色徽标,内部绘程序化计量条符号(流量计有变化曲线、压力计阶梯、未知类型泛型刻度)。带pixel-distance compensation,保持约48px屏幕尺寸,图标射线优先身份命中。清单点击聚焦相同模型并打开真实设备详情;无坐标仅清单项不造sprite。
+
+验证:t52-device-pick断言DataTexture图案、Sprite身份、像素尺寸、聚焦前后图标屏幕尺度、图标盖于管体仍可点、拖动不误点及销毁隔离;t23-click-flow真实Vue设备按钮→聚焦→详情→关闭仍留列表;T55 8/8,T50 9/9,T23 Three 9/9,鼠标/颜色19/19,SFC5/5、T22 12/12、T56真实设备/指标用例通过。未做现场GPU验收。
+
+## Comments
+
+### local — 2026-10-19
+用户反馈“还有一个问题,场景中显示设备图标未实现”。先建立t52-device-pick缺icon Sprite的红测,再添加billboard Sprite+data texture,并扩展screen-size和icon射线边界。
+
+## Answer
+代码与离线验证完成,现场视觉待维护者确认。
+
+Status: ready-for-human
+Assignee: codex
+Type: task
+Blocked by:
+Labels: ready-for-human, wayfinder:task

+ 3 - 1
.scratch/cockpit-shell-panel-layout/map.md

@@ -29,7 +29,9 @@ Labels: wayfinder:map
 - **`issues/41`–`48` 是监测点定位/设备联动决策地图收口后的执行链**:41 定位与面板锚定只依赖已完成的 37,可立即认领(**2026-09-29 状态:实施完成 + 纯逻辑 37/37、变异 15/15,待真机走查,未 close** —— 走查是收口唯一缺口,详见票面);完成后 42 关联设备临时图层;42 完成后 43 面板/三维双向对应、44 设备位置符号与静态状态词表、46 模式图层计划可并行;45 重叠呈现依赖 43/44/46;47 动态状态特效依赖 38 雾气、44 静态词表、45 重叠呈现及 46 模式计划;48 端到端验收依赖 40 主场景区质量收口和本链全部实现。规格来源分别是 `cockpit-point-locate-and-device-link/issues/14`–`18`、票 03/04/07/12 的已决策结论;后端 #69 已发布,设备坐标无可信校核时必须清单-only 降级。
 - [重做沅陵县城体块模型](issues/35-city-massing-redo.md) 的历史2026-09-30阶段记录(95%,非当前验收状态):找到权威建筑来源——本地 1:500 测绘 CAD(`沅陵2024*.dwg` 的 JMD 居民地图层),经 ArcGIS arcpy 转出 **主场景区内 60,744 个房屋轮廓**,1:1 影像对照确认精确贴合屋顶(保留 L 形/天井/组合体量),取代影像分割。BIM 管线(足印→ENU→DEM 座落→Blender 空间 LOD)已跑通,**9 个批次全部通过 `verify-lod-district.mjs`,全场景去重导出 48,021 / 48,425 = 99.2%**(城区 29,755 栋 100%、乡村 18,266 栋;LOD0 合计约 923MB;`select-cad-bim-batch.mjs` 确定性切批并跨批去重)。主场景区外 0。仍未完成:层高校准(测绘注记无层数)、运行态性能实测与维护者视觉验收。
 
-## Decisions so far
+- [58 地下管线详情视图场景设备图标](issues/58-untitled.md):从几何小体块升级为相机朝向Sprite圆形行业色徽标+设备计量符号,按镜头距离补偿固定屏显尺寸;图标射线直接派发设备身份,列表/details共用同一详情。DataTexture随场景销毁释放;t52-device-pick覆盖图标存在/屏幕尺寸/遮挡命中/拖拽/销毁;t23-click-flow覆盖Vue列表与内部详情。全套离线回归通过,未运行build/dev/browser/GPU;待正式入口现场验收。
+
+(issues/56-untitled.md):已核对实时 `TCGC202609080910` 与 `TEST-TCLLJ002215`(约1m邻近、合法坐标、设备类型字段为空);详情模型对无类型设备强可见,缺清单/渐进/稳定挂载保留;聚合通用指标EMPTY时按档案类型/名称路由流量、压力、漏失专用源,401转不可用不伪造读数。t56、t55及既有回归通过,待后端专用接口权限和现场画面验收。
 
 - [55 地下管线详情视图可用性修订](issues/55-8.md):按最新反馈恢复源径×8横向显示(替代54的无放大规则,源径和地理长度仍独立保留);修复基础设施详情隐藏设备、缺编码清单丢设备和慢点加载屏障,逐批呈现/稳定挂载/邻管避让/清单聚焦;最近视距2cm、鼠标射线接近及近处平移减速;视图内邻管可点选切完整详情;程序纹理凹凸和侧光增强材质。新回归8/8、渐进来源通过,原12脚本及19/19输入、Vue邻管标题/共享选择与SFC通过;最终只读复核无剩余P1/P2。代码完成待人工画面验收,未build/dev/browser/GPU。
 

+ 15 - 2
.scratch/cockpit-shell-panel-layout/verify/t52-device-pick.mjs

@@ -18,7 +18,19 @@ scene.updateMatrixWorld(true)
 camera.updateMatrixWorld()
 const device = scene.getObjectByName('device:D')
 assert.ok(device)
-const screen = device.getWorldPosition(new THREE.Vector3()).project(camera)
+const icon = device.getObjectByName('device-icon')
+assert.ok(icon?.isSprite, '每个可显示设备必须有朝向镜头的点击图标')
+assert.equal(icon.material.depthTest, false, '设备图标必须在放大管体前可见')
+assert.ok(icon.material.map?.image?.data?.some((value) => value > 0), '设备图标必须有可渲染图案')
+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)
+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, '图标保持固定屏幕尺寸')
+const screen = icon.getWorldPosition(new THREE.Vector3()).project(camera)
 const clientX = (screen.x + 1) * 450
 const clientY = (1 - screen.y) * 250
 function pointer(type, x = clientX) {
@@ -37,8 +49,9 @@ blocker.position.copy(root.worldToLocal(camera.position.clone().lerp(device.getW
 root.add(blocker)
 root.updateMatrixWorld(true)
 pointer('pointerdown'); pointer('pointerup')
-assert.equal(picked, null, '不能穿过前方不透明表面拾取后方设备')
+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()

+ 72 - 8
src/modules/cockpit/services/threePipeEnhancement.js

@@ -21,11 +21,53 @@ function project([lon, lat], center, scale) {
     )
 }
 
+function makeIconTexture(kind = 'meter', glyphOnly = false) {
+    const size = 64
+    const data = new Uint8Array(size * size * 4)
+    const put = (x, y, alpha = 255) => {
+        if (x < 0 || y < 0 || x >= size || y >= size) return
+        const i = (y * size + x) * 4
+        data[i] = data[i + 1] = data[i + 2] = 255
+        data[i + 3] = alpha
+    }
+    if (!glyphOnly) {
+        for (let y = 3; y < 61; y++) for (let x = 3; x < 61; x++) {
+            const d = Math.hypot(x - 32, y - 32)
+            if (d < 29) put(x, y, d > 24 ? 255 : 210)
+        }
+        for (let y = 14; y <= 49; y++) for (let x = 14; x <= 49; x++) {
+            const d = Math.hypot(x - 32, y - 32)
+            if (d >= 13 && d <= 16) put(x, y)
+        }
+    }
+    const bars = kind === 'flow' ? [[22, 37, 27], [29, 34, 36], [36, 30, 42], [43, 25, 45]]
+        : kind === 'pressure' ? [[22, 36, 43], [29, 31, 43], [36, 26, 43], [43, 21, 43]]
+        : [[22, 33, 43], [29, 29, 43], [36, 34, 43], [43, 27, 43]]
+    for (const [x, top, bottom] of bars) for (let y = top; y <= bottom; y++) for (let dx = -1; dx <= 1; dx++) put(x + dx, y)
+    const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat)
+    texture.colorSpace = THREE.SRGBColorSpace
+    texture.magFilter = THREE.LinearFilter
+    texture.minFilter = THREE.LinearMipmapLinearFilter
+    texture.generateMipmaps = true
+    texture.needsUpdate = true
+    return texture
+}
+
+function metricIconKind(device) {
+    const type = `${device.typeName || ''} ${device.name || ''}`
+    if (/流量|flow/i.test(type)) return 'flow'
+    if (/压力|pressure/i.test(type)) return 'pressure'
+    return 'meter'
+}
+
 function disposeObject(root) {
     const resources = new Set()
     root.traverse((node) => {
         if (node.geometry) resources.add(node.geometry)
-        if (node.material) for (const value of [].concat(node.material)) resources.add(value)
+        if (node.material) for (const value of [].concat(node.material)) {
+            resources.add(value)
+            if (value.map) resources.add(value.map)
+        }
     })
     for (const resource of resources) resource.dispose()
 }
@@ -146,12 +188,22 @@ function buildNeighborhood(plan) {
             emissive: color, emissiveIntensity: 0.28, depthTest: false, depthWrite: false }))
         body.name = 'device-body'
         group.add(body)
-        const indicator = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.07, 0.035), new THREE.MeshBasicMaterial({ color: '#edffff' }))
-        indicator.position.set(0, 0.05, 0.16)
-        indicator.material.depthTest = false
-        indicator.material.depthWrite = false
-        indicator.renderOrder = 22
-        group.add(indicator)
+        const iconTexture = makeIconTexture(metricIconKind(device))
+        const icon = new THREE.Sprite(new THREE.SpriteMaterial({ map: iconTexture, color, transparent: true,
+            depthTest: false, depthWrite: false, sizeAttenuation: true }))
+        icon.name = 'device-icon'
+        icon.position.set(0, 0.72, 0)
+        icon.renderOrder = 30
+        icon.userData.device = device
+        group.add(icon)
+        const glyphTexture = makeIconTexture(metricIconKind(device), true)
+        const iconGlyph = new THREE.Sprite(new THREE.SpriteMaterial({ map: glyphTexture, color: '#ffffff', transparent: true,
+            depthTest: false, depthWrite: false, sizeAttenuation: true }))
+        iconGlyph.name = 'device-icon-glyph'
+        iconGlyph.position.copy(icon.position)
+        iconGlyph.renderOrder = 31
+        iconGlyph.scale.setScalar(0.68)
+        group.add(iconGlyph)
         root.add(group)
     }
     return root
@@ -220,6 +272,16 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
         )
         root?.traverse((node) => {
             if (node.name === 'selected-pipe-outline') node.material.uniforms.resolution.value.set(host.clientWidth || 320, host.clientHeight || 220)
+            if (node.name === 'device-icon') {
+                root.updateMatrixWorld(true)
+                const world = 2 * camera.position.distanceTo(node.getWorldPosition(new THREE.Vector3()))
+                    * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * 48 / (host.clientHeight || 220)
+                const parentScale = Math.max(0.0001, node.parent.getWorldScale(new THREE.Vector3()).x)
+                node.scale.setScalar(world / parentScale)
+            } else if (node.name === 'device-icon-glyph') {
+                const badge = node.parent.getObjectByName('device-icon')
+                node.scale.copy(badge.scale).multiplyScalar(0.68)
+            }
         })
         camera.near = Math.max(0.0001, distance > sceneRadius * 1.05 ? distance - sceneRadius * 1.05 : distance / 1000)
         camera.far = Math.max(camera.near + 1, distance + sceneRadius * 2)
@@ -264,7 +326,9 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
         return raycaster.intersectObject(root, true)[0] || null
     }
     const detachPick = attachPointPick(canvas, ({ x, y }) => {
-        let node = hitAt(x, y)?.object
+        const intersections = raycaster.intersectObject(root, true)
+        const hit = intersections.find((item) => item.object.name === 'device-icon' || item.object.name === 'device-icon-glyph') || intersections[0]
+        let node = hit?.object
         while (node) {
             if (node.userData.device) { onDevicePick(node.userData.device); return }
             if (node.userData.sourceKind) { onObjectPick({ objectKind: node.userData.sourceKind, code: node.userData.code }); return }