Ver código fonte

fix(cockpit): restore readable pipe details and nearby device inspection

ChenJZ 2 dias atrás
pai
commit
c88296e669
25 arquivos alterados com 397 adições e 80 exclusões
  1. 1 1
      .scratch/cockpit-real-data-wayfinding/verify/t32-visibility-style.mjs
  2. 1 1
      .scratch/cockpit-real-data-wayfinding/verify/t32-visual-default.mjs
  3. 46 0
      .scratch/cockpit-shell-panel-layout/issues/55-8.md
  4. 8 0
      .scratch/cockpit-shell-panel-layout/local-network-enhancement-spec.md
  5. 3 1
      .scratch/cockpit-shell-panel-layout/map.md
  6. 15 5
      .scratch/cockpit-shell-panel-layout/verify/t23-click-flow.mjs
  7. 6 4
      .scratch/cockpit-shell-panel-layout/verify/t50-pipe-detail.mjs
  8. 1 1
      .scratch/cockpit-shell-panel-layout/verify/t52-device-plan.mjs
  9. 2 2
      .scratch/cockpit-shell-panel-layout/verify/t54-main-map-material.mjs
  10. 3 3
      .scratch/cockpit-shell-panel-layout/verify/t54-physical-details.mjs
  11. 92 0
      .scratch/cockpit-shell-panel-layout/verify/t55-detail-usability.mjs
  12. 25 0
      .scratch/cockpit-shell-panel-layout/verify/t55-device-loading.mjs
  13. 3 3
      CONTEXT.md
  14. 1 0
      src/modules/cockpit/CockpitShell.vue
  15. 20 9
      src/modules/cockpit/components/PipeEnhancement.vue
  16. 22 6
      src/modules/cockpit/domain/enhancementDevices.js
  17. 1 0
      src/modules/cockpit/domain/mapSummary.js
  18. 2 1
      src/modules/cockpit/domain/pipeEnhancement.js
  19. 3 3
      src/modules/cockpit/domain/pipeModels.js
  20. 3 3
      src/modules/cockpit/domain/pipeVisual.js
  21. 20 9
      src/modules/cockpit/services/enhancementDevices.js
  22. 17 3
      src/modules/cockpit/services/pipeModelShader.js
  23. 37 2
      src/modules/cockpit/services/pipeSurfaceMaterial.js
  24. 62 22
      src/modules/cockpit/services/threePipeEnhancement.js
  25. 3 1
      src/modules/cockpit/stores/enhancementDevicesStore.js

+ 1 - 1
.scratch/cockpit-real-data-wayfinding/verify/t32-visibility-style.mjs

@@ -6,7 +6,7 @@ const pipe=(industry,remark,diameter=100)=>({code:industry+remark,industry,remar
 const input=[pipe('GAS',''),pipe('WATER_SUPPLY',''),pipe('DRAINAGE','污水'),{...pipe('DRAINAGE','雨水'),networkLevel:1}]
 const plan=planPipeModels({pipes:input,components:[]})
 assert.deepEqual(plan.instances.map(row=>row.colorKey),['GAS','WATER_SUPPLY','SEWAGE','RAINWATER'])
-assert.ok(plan.instances.every(row=>row.displayDiameterM===.1),'physical body diameter follows source metres without exaggeration')
+assert.ok(plan.instances.every(row=>row.displayDiameterM===.8),'display diameter is 8x source metres for readability')
 assert.ok(plan.instances.every(row=>row.diameterM===.1),'source millimetres are converted once to metres')
 const payload={features:[{geometry:{type:'LineString',coordinates:[[110.39,28.44],[110.3901,28.44]]},properties:{code:'remark-wins',industry:'DRAINAGE',name:'雨水管',remark:'污水',diameter:400,coordinateValidation:{renderable:true}}}]}
 const adapted=toPipeNetwork(payload)

+ 1 - 1
.scratch/cockpit-real-data-wayfinding/verify/t32-visual-default.mjs

@@ -13,7 +13,7 @@ assert.equal(unknown.instances.length,0)
 assert.equal(unknown.fallbacks.length,1)
 const reported=planPipeModels({pipes:[make('GAS','',150)],components:[]}).instances[0]
 assert.equal(reported.diameterM,.15)
-assert.equal(reported.displayDiameterM,.15)
+assert.equal(reported.displayDiameterM,1.2)
 assert.equal(reported.diameterSource,'reported')
 assert.equal(reported.visualDefaultDiameterM,null)
 console.log('PASS source-sized bodies only; missing/unknown diameters retain explicit guide fallback, no defaults')

+ 46 - 0
.scratch/cockpit-shell-panel-layout/issues/55-8.md

@@ -0,0 +1,46 @@
+<!-- DSH-IDEMPOTENCY-KEY: underground-detail-usability-five-feedback-v1 -->
+<!-- parentKey: 00 -->
+<!-- created: 2026-10-18T10:00:59.691Z -->
+# 地下管线详情视图:8倍显示、设备可见与近距交互
+
+## What to build
+
+维护者新反馈:恢复8倍场景管径显示;地下管线详情附近设备仍不可见;近距缩放更近且平移随鼠标位置视距减速;详情内可点其它管段;材质更明显。
+
+## Decisions
+
+源管径不变,显示直径=源径×8(取消先前无放大要求,以最新用户反馈为准),缺源径不造实体;地理路径长度保持。详情是设施检查视图:基础设施模式不能套用主图全隐设备规则;其它设备级筛选/预警模式保留。设备挂载和显示大小随8倍显示管径适配,真实身份和示意标注保留。近距上限不随整个场景长度放大;平移取命中表面的视距,缩放可围绕鼠标命中点。点击邻管经真实射线到共享选择/详情,拖拽不触发。材质增加可辨的纹理凹凸/反射(非实测纹理),保留行业色及未知语义。
+
+## Acceptance criteria
+
+- [x] 显示径×8一致贯穿主图/详情精粗,源径单独保留。
+- [x] 基础设施详情有真实附近设备模型/清单与详情入口,模型不埋管;加载覆盖透明。
+- [x] 可厘米级接近目标,近距左拖随命中距离减速,不因长场景快速漂移。
+- [x] 场景邻管点选打开其完整领域详情/高亮,拖拽不选,原主图上下文保持。
+- [x] 源材质有纹理/凹凸和反射差别,资源关闭后释放。
+- [x] t55真实几何/设备/相机/射线材质回归先0/5红,修复后通过;Vue事件与既有回归复验。
+
+## 进度
+
+代码完成,待维护者现场画面验收。t55-detail-usability先运行0通过5失败;修复后及审查补例8/8通过。确认原因:详情沿用infrastructure主图显隐使全部设备被过滤;缺失deviceCodes被当显式空;等待全部点详情才发布;最近距离=场景半径×0.05;射线只派发设备;材质仅有反射参数。
+
+修复:8倍横向显示但保留源径;详情基础设施模式保留附近设备;缺清单与显式空分开,逐点渐进发布;设备按显示管壁半径及邻管避让高度挂载并可清单聚焦;编码稳定槽位不随渐进数量重排;设备到达/范围变化sync复用镜头;最小视距0.02m、沿鼠标命中射线缩放、平移按近处命中视距;真实邻管射线与Vue object-pick→共享选择→完整主段/标题/属性及高亮切换;程序拉丝/颗粒/塑料纹理凹凸与侧光,主图材质纹理遵从Cesium实际字段(保留已有金属度兼容修复)。纹理为材质类型示意,非实测。
+
+验证:t55-detail-usability 8/8、t55-device-loading通过(缺失vs显式空、慢点不阻塞);t50-53-local-enhancement全部12脚本通过,包含19/19颜色/三键/焦点、9/9原增强、9/9精模、5/5 SFC、邻域/设备/窨井/点击关闭与布局;t23-click-flow新增邻管点选、真实Vue标题改变及sync不重建;t54物理与主图材质流/更新、t32显示/缺径/异步图层通过。本轮工作区markerOverlay导入已恢复,完整交互19/19实际重跑通过,旧阻断不再适用。
+
+独立审查提出渐进槽位跳动、异径邻管包住设备和偏心缩放;均补例修复。最终只读复核未发现剩余P1/P2。按AGENTS未运行build/dev/browser/GPU,离线结果不能证明实际画面。真实坐标准入失败且无设施关联者继续仅清单,不借父点坐标造模型;显式筛空设备清单继续尊重,不用详情绕过。
+
+- [ ] 正式入口现场确认8倍粗度、设备可辨性、近距操控和材质效果。
+
+
+Status: ready-for-human
+Assignee: codex
+Type: task
+Blocked by:
+Labels: ready-for-human, wayfinder:task
+
+## Comments
+
+
+## Answer
+已恢复8倍显示并完成设备、近距操控、邻管点选、材质增强;待实际画面验收。

+ 8 - 0
.scratch/cockpit-shell-panel-layout/local-network-enhancement-spec.md

@@ -64,6 +64,14 @@ Status: ready-for-agent
 - 未提供依据的混凝土支墩、强行接管、虚构传感器或假读数。
 - 修改后端业务数据或设备绑定关系。
 
+## 维护者后续修订(票54、55)
+
+正式名称为“地下管线详情视图”。精粗和主图管体均由源管径驱动,但按最新反馈恢复8倍横向可视放大(替代54的无放大要求),源管径/显示管径分别保存;长度全部保持地理路径,后端length只保留原值,不拉伸源坐标。主选精模描边、邻管模型或列表可点选切换完整详情。源材质用可辨的程序纹理/凹凸/反射示意,保留行业色,未知码不猜。
+
+详情基础设施模式中必须能查看附近设备,不继承主图设备全隐规则;摘要缺设备清单与显式空清单要分开。逐点返回即呈现已有设备,稳定槽位不随数量重排,示意模型避让8倍显示后的附近管壁;坐标不准入且无关联者仅清单,点击有模型的设备可聚焦。渐进设备/半径变化不重建相机实例。最近视距独立于整场景长度,滚轮沿鼠标命中射线接近,左拖速度依当前和命中视距降低。
+
+执行证据与现场验收边界见[54](issues/54-untitled.md)和[55](issues/55-8.md);离线通过不代表GPU画面已验收。
+
 ## Further Notes
 
 方案经8项设计问答、最终方案及4张票粒度/依赖与测试边界确认。执行顺序:[50 管道外观/去环](issues/50-untitled.md);[51 邻域管网](issues/51-untitled.md);[52 设备及详情](issues/52-untitled.md)(依赖51);[53 窨井](issues/53-untitled.md)(依赖51、52)。原增强票仍保留现场验收缺口,新需求分票记录,不覆盖其历史。

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

@@ -31,7 +31,9 @@ Labels: wayfinder:map
 
 ## Decisions so far
 
-- **局部邻域管网与窨井增强(维护者确认)**:[规格](local-network-enhancement-spec.md);[50 管道细节/去环](issues/50-untitled.md)、[51 邻域裁切](issues/51-untitled.md)、[52 设备示意挂载/内部详情](issues/52-untitled.md)、[53 窨井剖切](issues/53-untitled.md)。依赖52←51、53←51/52。代码及12个针对性离线脚本完成,四票转待人工/真实数据画面验收,未关闭。50为中空壁/法兰螺栓/接缝、无独立定位环;51沿整段25/50/100米(默认50)邻域,主段完整邻近裁切;52读当前快照全部点位设备、保留缺口并复用真实详情,固定槽位明确示意,取景/遮挡/模式/刷新取消已回归;53井盖/剖切井筒+邻近管道/设备,缺接管或状态不推造。未运行build/dev/browser/GPU。正式设施关系全集读取契约仍未提供,按确认规则降级,不宣称真实井内安装。
+- [55 地下管线详情视图可用性修订](issues/55-8.md):按最新反馈恢复源径×8横向显示(替代54的无放大规则,源径和地理长度仍独立保留);修复基础设施详情隐藏设备、缺编码清单丢设备和慢点加载屏障,逐批呈现/稳定挂载/邻管避让/清单聚焦;最近视距2cm、鼠标射线接近及近处平移减速;视图内邻管可点选切完整详情;程序纹理凹凸和侧光增强材质。新回归8/8、渐进来源通过,原12脚本及19/19输入、Vue邻管标题/共享选择与SFC通过;最终只读复核无剩余P1/P2。代码完成待人工画面验收,未build/dev/browser/GPU。
+
+- **局部邻域管网与窨井增强(维护者确认)**:[规格](local-network-enhancement-spec.md);[50 管道细节/去环](issues/50-untitled.md)、[51 邻域裁切](issues/51-untitled.md)、[52 设备示意挂载/内部详情](issues/52-untitled.md)、[53 窨井剖切](issues/53-untitled.md)、[54 地下管线详情视图尺寸/材质/描边](issues/54-untitled.md)。依赖52←51、53←51/52;54建立在50—53能力上。50—54代码及针对性离线脚本完成,票均转待人工/真实数据画面验收,未关闭。50为中空壁/法兰螺栓/接缝、无独立定位环;51沿整段25/50/100米(默认50)邻域,主段完整邻近裁切;52读当前快照全部点位设备、保留缺口并复用真实详情,固定槽位明确示意,取景/遮挡/模式/刷新取消已回归;53井盖/剖切井筒+邻近管道/设备,缺接管或状态不推造;54精粗/主图使用源管径和源材质,地理路径优先、主选精模描边、正式名称统一。未运行build/dev/browser/GPU。正式设施关系全集读取契约仍未提供,按确认规则降级,不宣称真实井内安装。
 
 - [Cesium 地理管线渲染](issues/22-cesium-pipeline-rendering.md) — 按维护者确认的常驻图层及已发布字段范围关闭:补齐坏顶点整条拦下与管线不吞点选,154项离线回归通过;真实埋深暂缓、状态无契约,build/debug与GPU复验未执行。
 - [23: Three.js 管线增强](issues/23-threejs-pipeline-enhancement.md) — 保持打开,待现场验收:增强窗移至外壳交互层;按维护者反馈放大为1100×760px居中覆盖地图,共用主图雨污/供水色表,主图和局部视口聚焦并抑制右键菜单,局部操作统一为左键平移/右键旋转/滚轮缩放。颜色与输入行为19/19、布局/点击关闭连通性、SFC5/5、T23 9/9离线通过;未做本轮build/dev/browser/GPU验证。

+ 15 - 5
.scratch/cockpit-shell-panel-layout/verify/t23-click-flow.mjs

@@ -76,13 +76,15 @@ function click(x = 120, y = 230, upX = x) {
 const enhancement = parse(src('components/PipeEnhancement.vue')).descriptor
 let mounts = 0
 let releases = 0
-let devicePick
+let devicePick, objectPick
+let syncs = 0
 const script = compileScript(enhancement, { id: 't23-click', inlineTemplate: true }).content
 const imports = { vue: Vue, './EquipmentDetailSections.vue': { default: { props: ['deviceCode', 'aggregate'], render() { return Vue.h('div', { 'data-device-detail': this.deviceCode }) } } }, '../services/threePipeEnhancement.js': { mountPipeEnhancement(_host, next, options) {
     devicePick = options.onDevicePick
+    objectPick = options.onObjectPick
     assert.equal(next.object.code, 'P-2')
     mounts += 1
-    return { destroy() { releases += 1 } }
+    return { sync() { syncs += 1 }, destroy() { releases += 1 } }
 } } }
 let compiled = script
 for (const node of parseJS(script, { sourceType: 'module' }).program.body.filter((n) => n.type === 'ImportDeclaration').reverse()) {
@@ -95,7 +97,7 @@ const renderer = Vue.createRenderer({
     createElement: element, createText: (text) => ({ text }), createComment: (text) => ({ comment: text }),
     setText(n, text) { n.text = text }, setElementText(n, text) { n.text = text },
     parentNode: (n) => n.parent, nextSibling: () => null,
-    insert(n, parent, anchor) { if (n.parent) this.remove(n); n.parent = parent; const i = parent.children.indexOf(anchor); parent.children.splice(i < 0 ? parent.children.length : i, 0, n) },
+    insert(n, parent, anchor) { if (n.parent) n.parent.children.splice(n.parent.children.indexOf(n), 1); n.parent = parent; const i = parent.children.indexOf(anchor); parent.children.splice(i < 0 ? parent.children.length : i, 0, n) },
     remove(n) { if (n.parent) n.parent.children.splice(n.parent.children.indexOf(n), 1); n.parent = null },
     patchProp(n, key, _old, next) { n.props[key] = next },
 })
@@ -104,6 +106,7 @@ const deviceStore = useEnhancementDevicesStore()
 const app = renderer.createApp({ setup: () => () => Vue.h(Component, {
     plan: plan.value, deviceState: deviceStore, onRadiusChange: (radius) => store.setRadius(radius),
     onClose: () => { store.clear(); deviceStore.clear() },
+    onObjectPick: (hit) => selectContext.select(hit),
     onDevicePick: (device) => deviceStore.open(device, { fetchDetail: async (code) => ({ state: 'success', data: { deviceCode: code, sections: {} } }) }),
 }) })
 app.mount(root)
@@ -129,11 +132,18 @@ findTag(root, 'select').props.onChange({ target: { value: '100' } })
 await Vue.nextTick(); await Vue.nextTick()
 assert.equal(store.radiusM, 100)
 assert.equal(plan.value.radiusM, 100)
-assert.equal(mounts, 2, '范围切换后重绘邻域')
+assert.equal(mounts, 1, '范围切换复用视口与相机')
+assert.ok(syncs > 0)
+objectPick({ objectKind: 'pipe', code: 'P-1' })
+await Vue.nextTick(); await Vue.nextTick()
+assert.equal(store.selected.code, 'P-1')
+assert.equal(plan.value.object.code, 'P-1')
+assert.equal(find(root, 'data-pipe-enhancement-title').text, '重点管段 P-1')
+assert.equal(mounts, 1, '邻管点选通过sync切换完整详情,不重建画布')
 find(root, 'data-pipe-enhancement-close').props.onClick()
 await Vue.nextTick()
 assert.equal(store.selected, null)
-assert.equal(releases, 2)
+assert.equal(releases, 1)
 assert.equal(find(root, 'data-pipe-enhancement'), undefined)
 click(120, 230, 150)
 assert.equal(store.selected, null, 'dragging the map must not select a pipe')

+ 6 - 4
.scratch/cockpit-shell-panel-layout/verify/t50-pipe-detail.mjs

@@ -72,14 +72,16 @@ check('换选移除旧模型并释放全部几何与材质', () => {
     assert.equal(root.parent, null)
     assertInitialReleased()
 })
-check('短管段保持真实长径比例,不为可见性拉长或加粗', () => {
+check('短管段地理长度不拉伸,横向按源径8倍显示', () => {
     const tiny = { ...plan, object: { ...plan.object, path: [[110, 28], [110.00001, 28]], position: [110, 28] } }
     viewer.sync(tiny)
     const next = scene.children.find((n) => n.name === `pipe-enhancement:${source.code}`)
     const box = new THREE.Box3().setFromObject(next)
-    const size = box.getSize(new THREE.Vector3())
-    assert.ok(size.x > 0.98 && size.x < 1.02)
-    assert.ok(size.y > 0.4 && size.y < 0.6)
+    const wall = next.getObjectByName('pipe-wall')
+    wall.geometry.computeBoundingBox()
+    const size = wall.geometry.boundingBox.getSize(new THREE.Vector3())
+    assert.ok(size.y > 0.98 && size.y < 1.0)
+    assert.ok(Math.abs(size.x - 3.2) < 0.000001)
     assert.ok(box.getCenter(new THREE.Vector3()).length() < 1e-6)
 })
 check('纵向管段法兰随路径定向,轴向管腔不封死', () => {

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

@@ -41,7 +41,7 @@ assert.equal(store.aggregate, null)
 store.invalidate()
 assert.equal(store.source.loading, true)
 assert.deepEqual(store.source.rows, [])
-assert.equal(planEnhancementDevices(plan, rows, { mode: 'infrastructure' }).devices.length, 0)
+assert.equal(planEnhancementDevices(plan, rows, { mode: 'infrastructure' }).devices.length, result.devices.length)
 const alertRows = [{ ...base, code: 'ok', status: 'NORMAL' }, { ...base, code: 'alarm', status: 'ALARM' }]
 const warnings = planEnhancementDevices(plan, alertRows, { mode: 'warningResponse', abnormalDevices: [{ code: 'alarm', status: 'ALARM' }] })
 assert.deepEqual(warnings.devices.map((d) => d.code), ['alarm'])

+ 2 - 2
.scratch/cockpit-shell-panel-layout/verify/t54-main-map-material.mjs

@@ -31,8 +31,8 @@ try {
     await adapter.sync({ pipes })
     assert.equal(globalThis.t32Models.length, 2, '同业不同材质必须拆为不同着色批次')
     const [pe, steel] = globalThis.t32Models
-    assert.ok(Math.abs(values(pe, 'SCALE')[0] - 0.4) < 1e-6)
-    assert.ok(Math.abs(values(steel, 'SCALE')[0] - 0.2) < 1e-6)
+    assert.ok(Math.abs(values(pe, 'SCALE')[0] - 3.2) < 1e-6)
+    assert.ok(Math.abs(values(steel, 'SCALE')[0] - 1.6) < 1e-6)
     assert.ok(values(pe, 'SCALE')[2] > 9 && values(pe, 'SCALE')[2] < 11, '轴向长度保持地理路径')
     const shader = createPipeModelShader('WATER_SUPPLY', pipeSurface('PE'))
     assert.equal(shader.lightingModel, LightingModel.PBR)

+ 3 - 3
.scratch/cockpit-shell-panel-layout/verify/t54-physical-details.mjs

@@ -20,7 +20,7 @@ assert.equal(plan.object.lengthM, 70)
 assert.ok(plan.object.geographicLengthM > 98 && plan.object.geographicLengthM < 99)
 assert.equal(plan.object.surface.kind, 'pe')
 const map = planPipeModels({ pipes })
-assert.equal(map.instances.find((i) => i.id === 'pipe:fine').displayDiameterM, 0.4)
+assert.equal(map.instances.find((i) => i.id === 'pipe:fine').displayDiameterM, 3.2)
 assert.equal(map.instances.find((i) => i.id === 'pipe:coarse').surface.kind, 'steel')
 assert.ok(!map.instances.some((i) => i.id === 'pipe:missing'))
 let scene, camera
@@ -32,8 +32,8 @@ const body = scene.getObjectByName('pipe-wall')
 const coarse = scene.getObjectByName('neighbor-pipe-wall')
 assert.ok(body && coarse)
 body.geometry.computeBoundingBox()
-assert.ok(Math.abs(body.geometry.boundingBox.max.x - 0.2) < 1e-6)
-assert.equal(coarse.geometry.parameters.radiusTop, 0.1)
+assert.ok(Math.abs(body.geometry.boundingBox.max.x - 1.6) < 1e-6)
+assert.equal(coarse.geometry.parameters.radiusTop, 0.8)
 assert.ok(Math.abs(body.getWorldScale(new THREE.Vector3()).x - 1) < 1e-8)
 assert.ok(Math.abs(body.geometry.boundingBox.max.y - body.geometry.boundingBox.min.y - plan.object.geographicLengthM) < 1e-4)
 assert.equal(body.material.metalness, 0)

+ 92 - 0
.scratch/cockpit-shell-panel-layout/verify/t55-detail-usability.mjs

@@ -0,0 +1,92 @@
+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 { planPipeModels } from '../../../src/modules/cockpit/domain/pipeModels.js'
+import { mountPipeEnhancement } from '../../../src/modules/cockpit/services/threePipeEnhancement.js'
+import { pipeSurface } from '../../../src/modules/cockpit/domain/pipePhysical.js'
+import { pipeSurfaceMaterial } from '../../../src/modules/cockpit/services/pipeSurfaceMaterial.js'
+const row = { code: 'P', industry: 'WATER_SUPPLY', diameter: 400, material: 'PE', positions: [[110, 28], [110.001, 28]] }
+const next = { ...row, code: 'N', material: '钢', positions: [[110, 28.00015], [110.001, 28.00015]] }
+const plan = planPipeEnhancement({ selected: { kind: 'pipe', code: 'P' }, visiblePipes: [row, next] })
+function fixture(plan, options = {}) {
+    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 scene, camera
+    const viewer = mountPipeEnhancement(host, plan, { ...options, rendererFactory: () => ({ domElement: canvas, setSize() {}, dispose() {}, render(s, c) { scene = s; camera = c; s.updateMatrixWorld(true); c.updateMatrixWorld() } }) })
+    return { viewer, canvas, scene: () => scene, camera: () => camera }
+}
+function event(canvas, type, props = {}) { const e = new Event(type, { cancelable: true }); Object.assign(e, props); canvas.dispatchEvent(e) }
+let failed = 0, passed = 0
+function check(name, action) { try { action(); passed++; console.log(`PASS ${name}`) } catch (error) { failed++; console.error(`FAIL ${name}: ${error.message}`) } }
+check('主图与详情恢复8倍显示直径但保留0.4米源径', () => {
+    const model = planPipeModels({ pipes: [row] }).instances[0]
+    assert.equal(model.diameterM, 0.4)
+    assert.equal(model.displayDiameterM, 3.2)
+    const f = fixture(plan)
+    try { const wall = f.scene().getObjectByName('pipe-wall'); wall.geometry.computeBoundingBox(); assert.ok(Math.abs(wall.geometry.boundingBox.max.x - 1.6) < 1e-6) }
+    finally { f.viewer.destroy() }
+})
+check('基础设施模式的管道详情仍可查看真实附近设备,设备位于放大管壁之外', () => {
+    const devices = planEnhancementDevices(plan, [{ code: 'D', name: '压力设备', industry: 'WATER_SUPPLY', coordinate: { renderable: true }, longitude: 110.0005, latitude: 28 }], { mode: 'infrastructure' })
+    assert.equal(devices.devices.length, 1)
+    const f = fixture({ ...plan, ...devices })
+    try { const device = f.scene().getObjectByName('device:D'); const pipe = f.scene().getObjectByName('pipe-wall'); const world = device.getWorldPosition(new THREE.Vector3()); const box = new THREE.Box3().setFromObject(pipe); assert.ok(world.y > box.max.y + 0.15) }
+    finally { f.viewer.destroy() }
+})
+check('100米管段可缩放到厘米级,近处同像素拖拽速度随视距降低', () => {
+    const f = fixture(plan)
+    try {
+        const pan = () => { const before = f.camera().position.clone(); event(f.canvas, 'pointerdown', { button: 0, pointerId: 1, clientX: -100, clientY: -100 }); event(f.canvas, 'pointermove', { pointerId: 1, clientX: -90, clientY: -100 }); event(f.canvas, 'pointerup', { pointerId: 1 }); return f.camera().position.distanceTo(before) }
+        const farPan = pan()
+        // Fresh viewer, so pan offset is not mistaken for the camera's orbit range.
+        f.viewer.destroy()
+        const near = fixture(plan)
+        try { for (let i = 0; i < 18; i++) event(near.canvas, 'wheel', { deltaY: -1000, clientX: -100, clientY: -100 }); assert.ok(near.camera().position.distanceTo(new THREE.Box3().setFromObject(near.scene().getObjectByName('pipe-enhancement:P')).getCenter(new THREE.Vector3())) < 0.1, '最小视距不能随整个场景半径放大到米级'); const before = near.camera().position.clone(); event(near.canvas, 'pointerdown', { button: 0, pointerId: 1, clientX: -100, clientY: -100 }); event(near.canvas, 'pointermove', { pointerId: 1, clientX: -90, clientY: -100 }); event(near.canvas, 'pointerup', { pointerId: 1 }); assert.ok(near.camera().position.distanceTo(before) < farPan / 100) }
+        finally { near.viewer.destroy() }
+    } finally { f.viewer.destroy() }
+})
+check('真实射线可点选邻近管道身份,拖拽不触发', () => {
+    let selected
+    const f = fixture(plan, { onObjectPick: (hit) => { selected = hit } })
+    try {
+        const mesh = f.scene().getObjectByName('neighbor-pipe-wall')
+        const point = mesh.getWorldPosition(new THREE.Vector3()).project(f.camera())
+        const x = (point.x + 1) * 450, y = (1 - point.y) * 250
+        event(f.canvas, 'pointerdown', { button: 0, pointerId: 1, clientX: x, clientY: y }); event(f.canvas, 'pointerup', { button: 0, pointerId: 1, clientX: x, clientY: y })
+        assert.equal(selected?.code, 'N'); assert.equal(selected?.objectKind, 'pipe')
+        selected = null
+        event(f.canvas, 'pointerdown', { button: 0, pointerId: 1, clientX: x, clientY: y }); event(f.canvas, 'pointermove', { pointerId: 1, clientX: x + 40, clientY: y }); event(f.canvas, 'pointerup', { button: 0, pointerId: 1, clientX: x + 40, clientY: y }); assert.equal(selected, null)
+    } finally { f.viewer.destroy() }
+})
+check('材质有不同表面纹理/凹凸,钢与PE及混凝土不能仅靠微小反射差异', () => {
+    const values = ['钢', 'PE', '混凝土'].map((s) => pipeSurfaceMaterial('#168bff', pipeSurface(s)))
+    try { assert.ok(values.every((m) => m.bumpMap || m.normalMap)); assert.notEqual(values[0].bumpMap, values[1].bumpMap); assert.notEqual(values[1].bumpScale, values[2].bumpScale) }
+    finally { for (const m of values) { m.bumpMap?.dispose(); m.dispose() } }
+})
+check('新设备渐进返回不挪动已聚焦设备,且可从清单聚焦模型', () => {
+    const device = { code: 'D', industry: 'WATER_SUPPLY', coordinate: { renderable: true }, longitude: 110.0005, latitude: 28 }
+    const first = planEnhancementDevices(plan, [device])
+    const second = planEnhancementDevices(plan, [device, { ...device, code: 'later' }])
+    assert.deepEqual(first.devices[0].position, second.devices.find((d) => d.code === 'D').position)
+    const f = fixture({ ...plan, ...first })
+    try { assert.equal(f.viewer.focusDevice('D'), true); const before = f.camera().position.clone(); f.viewer.sync({ ...plan, ...second }); assert.ok(f.camera().position.distanceTo(before) < 1e-6); assert.equal(f.viewer.focusDevice('not-loaded'), false) }
+    finally { f.viewer.destroy() }
+})
+check('细管设备不被8倍相邻粗管包住', () => {
+    const small = { ...row, code: 'small', diameter: 50, networkId: 'small-id' }
+    const large = { ...row, code: 'large', positions: row.positions.map((p) => [p[0], p[1] + 0.5 / 111320]) }
+    const local = planPipeEnhancement({ selected: { kind: 'pipe', code: 'small' }, visiblePipes: [small, large] })
+    const layout = planEnhancementDevices(local, [{ code: 'D', industry: 'WATER_SUPPLY', networkId: 'small-id' }])
+    const f = fixture({ ...local, ...layout })
+    try { const device = f.scene().getObjectByName('device:D'); const pipe = f.scene().getObjectByName('neighbor-pipe-wall'); const deviceBox = new THREE.Box3().setFromObject(device.getObjectByName('device-body')); const pipeBox = new THREE.Box3().setFromObject(pipe); assert.ok(deviceBox.min.y > pipeBox.max.y) }
+    finally { f.viewer.destroy() }
+})
+check('偏心邻管滚轮沿鼠标射线接近、朝向不变', () => {
+    const f = fixture(plan)
+    try { const pipe = f.scene().getObjectByName('neighbor-pipe-wall'); const point = pipe.localToWorld(new THREE.Vector3(0, 20, 0)); const before = f.camera().position.clone(); const orientation = f.camera().quaternion.clone(); const projected = point.clone().project(f.camera()); event(f.canvas, 'wheel', { deltaY: -120, clientX: (projected.x + 1) * 450, clientY: (1 - projected.y) * 250 }); const movement = f.camera().position.clone().sub(before).normalize(); const expected = point.sub(before).normalize(); assert.ok(movement.dot(expected) > 0.9999); assert.ok(f.camera().quaternion.angleTo(orientation) < 1e-6) }
+    finally { f.viewer.destroy() }
+})
+console.log(`T55 local usability: ${passed} passed, ${failed} failed`)
+if (failed) process.exitCode = 1

+ 25 - 0
.scratch/cockpit-shell-panel-layout/verify/t55-device-loading.mjs

@@ -0,0 +1,25 @@
+import assert from 'node:assert/strict'
+import { toMonitoringPoint } from '../../../src/modules/cockpit/domain/mapSummary.js'
+import { loadEnhancementDevices } from '../../../src/modules/cockpit/services/enhancementDevices.js'
+const point = toMonitoringPoint({ pointCode: 'no-list', pointName: '有真实设备的点', longitude: 110, latitude: 28 })
+const card = { code: 'D', longitude: 110, latitude: 28, coordinate: { renderable: true } }
+const missing = await loadEnhancementDevices({ phase: 'complete', objects: [point] }, { fetchPoint: async () => ({ state: 'success', data: { code: point.code, devices: [card] } }) })
+assert.equal(missing.rows.length, 1, '缺摘要编码清单不能把真实点详情设备全部丢弃')
+assert.equal(missing.partial, true, '仍须提示缺少快照设备清单,不能冒称完整')
+const explicit = await loadEnhancementDevices({ phase: 'complete', objects: [{ ...point, deviceCodesAvailable: true, deviceCodes: [] }] }, { fetchPoint: async () => ({ state: 'success', data: { code: point.code, devices: [card] } }) })
+assert.equal(explicit.rows.length, 0, '显式筛空清单不能被点详情绕过')
+let release
+const progress = []
+const loading = loadEnhancementDevices({ phase: 'complete', objects: [
+    { kind: 'unassociated-equipment', code: 'standalone' },
+    { kind: 'monitoring-point', code: 'fast', deviceCodes: ['fast-device'] },
+    { kind: 'monitoring-point', code: 'slow', deviceCodes: [] },
+] }, { onProgress: (state) => progress.push(state), fetchPoint: async (code) => code === 'fast'
+    ? { state: 'success', data: { code, devices: [{ ...card, code: 'fast-device' }] } }
+    : new Promise((resolve) => { release = () => resolve({ state: 'success', data: { code, devices: [] } }) }) })
+await Promise.resolve(); await Promise.resolve()
+assert.ok(progress.some((state) => state.rows.some((row) => row.code === 'standalone')))
+assert.ok(progress.some((state) => state.rows.some((row) => row.code === 'fast-device')), '慢点不应阻止先返回设备呈现')
+assert.ok(progress.every((state) => state.partial))
+release(); await loading
+console.log('PASS 缺失/显式空设备清单区别,独立和已返回设备渐进呈现,慢点不阻塞')

+ 3 - 3
CONTEXT.md

@@ -45,15 +45,15 @@ _Avoid_: 真实安装位置(无安装资料时)、管道所属设备(仅
 _Avoid_: 井下实测结构(无资料时)、从附近管道推断接管关系
 
 **地下管线详情视图**:
-围绕选中管段或窨井查看附近地下管道、构件及真实监测设备的局部场景;主选精模描边突出,精粗管体均使用后端源管径并保持地理路径长度,材质由源字段决定。
+围绕选中管段或窨井查看附近地下管道、构件及真实监测设备的局部场景;主选精模描边突出,精粗管体均由后端源管径驱动并以8倍横向显示、保持地理路径长度,材质由源字段决定。此视图在基础设施模式仍呈现附近设备,不继承主图全隐设备规则;预警模式和设备自身过滤继续生效。点邻管切换其完整详情;滚轮围绕鼠标命中接近、左拖速度跟随近处视距;设备渐进到达不重置镜头。
 _Avoid_: 局部增强视图、用固定粗度或默认DN冒充源管径
 
 **管体尺寸**:
-横截面按后端源管径毫米换算米,不放大、不夹最小粗度;长度依真实地理路径,后端管长原值单独保留,冲突时不拉伸坐标。缺径只呈现无尺寸导引,壁厚、法兰及安装朝向缺资料时仍是示意。
+横截面按后端源管径毫米换算米,另以8倍横向显示便于辨认,显示尺寸不回写源径;长度依真实地理路径,后端管长原值单独保留,冲突时不拉伸坐标。缺径只呈现无尺寸导引,壁厚、法兰及安装朝向缺资料时仍是示意。
 _Avoid_: 把显示粗度称为真实管径、默认管径实体、推算值冒充源尺寸
 
 **显示粗度**:
-为辨认地理路径而使用的屏幕导引线宽,不是实体管径;实体管体的显示放大与行业默认DN占位规则已由源尺寸规则取代。
+管体显示直径=源直径×8,长度仍为真实地理路径;与源数据管径分别保留,非工程尺寸。主图导引线宽仅用于辨认路径。设备模型按显示管壁外侧示意挂载,尺寸适度放大以便识别。
 _Avoid_: 真实管径、用导引线宽回写管径
 
 **推算管径**:

+ 1 - 0
src/modules/cockpit/CockpitShell.vue

@@ -353,6 +353,7 @@
                 class="cockpit-shell__enhancement"
                 :plan="pipeEnhancementPlan"
                 :device-state="enhancementDevicesStore"
+                @object-pick="selectPipeEnhancement"
                 @device-pick="enhancementDevicesStore.open"
                 @devices-reload="enhancementDevicesStore.load(mapStore.summary, { force: true })"
                 @device-close="enhancementDevicesStore.closeDetail"

+ 20 - 9
src/modules/cockpit/components/PipeEnhancement.vue

@@ -22,18 +22,19 @@
                 <section v-if="localPipeStates.length" aria-label="附近管道状态">
                     <h3>附近管道状态</h3>
                     <p v-for="pipe in localPipeStates" :key="pipe.code">
-                        {{ pipe.title }}({{ pipe.code }}):{{ pipe.stateText || '状态未提供' }}
+                        <button type="button" class="pipe-enhancement__pipe" :aria-pressed="plan.object.sourceKind === 'pipe' && plan.object.code === pipe.code" @click="$emit('object-pick', { objectKind: 'pipe', code: pipe.code })">{{ pipe.title }}({{ pipe.code }})</button>:{{ pipe.stateText || '状态未提供' }}
                         <small class="pipe-enhancement__dimensions">源管径 {{ pipe.diameterM ? pipe.diameter + ' mm' : '未提供(仅导引)' }} · 源材质 {{ pipe.material || '未提供' }}({{ pipe.surface?.label || '材质未识别' }})</small>
                         <small class="pipe-enhancement__dimensions">{{ pipe.clipped ? '当前裁片' : '地理路径' }} {{ pipe.geographicLengthM?.toFixed(2) }} m · 后端管长 {{ pipe.lengthM ?? '未提供' }}{{ pipe.lengthM !== null ? '(源值)' : '' }}</small>
                     </p>
                 </section>
                 <h3>监测设备</h3>
-                <p v-if="plan.deviceCoverage?.loading">设备加载中</p>
-                <p v-else-if="plan.deviceCoverage?.partial">设备覆盖不完整:快照未齐、设备待确认或部分点位读取失败。</p>
+                <p v-if="plan.deviceCoverage?.loading">设备读取中({{ plan.deviceCoverage.loadedPoints || 0 }}/{{ plan.deviceCoverage.totalPoints || 0 }} 点),已返回设备可先查看。</p>
+                <p>可见设备模型 {{ plan.devices?.length || 0 }} 个 · 清单 {{ plan.deviceList?.length || 0 }} 台(含仅清单项)</p>
+                <p v-if="!plan.deviceCoverage?.loading && plan.deviceCoverage?.partial">设备覆盖不完整:快照未齐、设备待确认或部分点位读取失败。</p>
                 <button v-if="plan.deviceCoverage?.partial && !plan.deviceCoverage?.loading" type="button" @click="$emit('devices-reload')">重新读取设备清单</button>
                 <p v-if="plan.deviceCoverage?.stalePoints">部分设备为上次保留数据,非实时。</p>
                 <p v-if="!plan.deviceList?.length && !plan.deviceCoverage?.loading">当前可用数据中没有附近设备。</p>
-                <button v-for="device in plan.deviceList || []" :key="device.code" class="pipe-enhancement__device" type="button" @click="$emit('device-pick', device)">
+                <button v-for="device in plan.deviceList || []" :key="device.code" class="pipe-enhancement__device" type="button" @click="selectDevice(device)">
                     {{ device.name || device.code }}<small>{{ device.placementText }}{{ device.retained ? ' · 过期数据' : '' }}</small>
                 </button>
                 <section v-if="deviceState.selected" data-enhancement-device-detail>
@@ -55,7 +56,7 @@
             <div><dt>状态</dt><dd>{{ plan.object.stateText || '状态未提供' }}</dd></div>
             <div v-if="plan.object.material"><dt>材质</dt><dd>{{ plan.object.material }}</dd></div>
         </dl>
-        <p class="pipe-enhancement__note" data-pipe-enhancement-note>{{ plan.disclaimer }} · {{ plan.connectionNote }} · 管体按源管径;长度保持地理路径,后端管长源值不用于拉伸。</p>
+        <p class="pipe-enhancement__note" data-pipe-enhancement-note>{{ plan.disclaimer }} · {{ plan.connectionNote }} · 管径横向8倍显示,源管径独立保留;长度保持地理路径,后端管长源值不用于拉伸。材质纹理与设备尺寸为显示示意。</p>
     </aside>
 </template>
 
@@ -68,7 +69,7 @@ const props = defineProps({
     plan: { type: Object, required: true },
     deviceState: { type: Object, default: () => ({ selected: null, aggregate: null, loadingDetail: false }) },
 })
-const emit = defineEmits(['close', 'radius-change', 'device-pick', 'device-close', 'device-retry', 'devices-reload'])
+const emit = defineEmits(['close', 'radius-change', 'device-pick', 'device-close', 'device-retry', 'devices-reload', 'object-pick'])
 const viewport = ref(null)
 const localPipeStates = computed(() => [...new Map((props.plan.objects || [])
     .filter((object) => object.sourceKind === 'pipe').map((object) => [object.code, object])).values()])
@@ -76,6 +77,11 @@ let viewer = null
 let mountGeneration = 0
 const open = ref(false)
 
+function selectDevice(device) {
+    if (device.placement !== 'list-only') viewer?.focusDevice?.(device.code)
+    emit('device-pick', device)
+}
+
 function disposeViewer() {
     if (viewer) viewer.destroy()
     viewer = null
@@ -83,12 +89,15 @@ function disposeViewer() {
 
 async function mount() {
     const generation = ++mountGeneration
-    disposeViewer()
     open.value = props.plan?.visible === true
-    if (!open.value) return
+    if (!open.value) { disposeViewer(); return }
+    if (viewer?.sync) { viewer.sync(props.plan); return }
     await nextTick()
     if (generation !== mountGeneration || !open.value || !viewport.value) return
-    viewer = mountPipeEnhancement(viewport.value, props.plan, { onDevicePick: (device) => emit('device-pick', device) })
+    viewer = mountPipeEnhancement(viewport.value, props.plan, {
+        onDevicePick: (device) => emit('device-pick', device),
+        onObjectPick: (hit) => emit('object-pick', hit),
+    })
 }
 
 watch(
@@ -132,6 +141,8 @@ onBeforeUnmount(() => {
 .pipe-enhancement__close:hover, .pipe-enhancement__close:focus-visible { border-color: var(--cyan, #01abea); color: #fff; outline: none; }
 .pipe-enhancement__workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 32%); gap: 12px; min-height: 0; }
 .pipe-enhancement__devices { min-height: 0; overflow-y: auto; border-left: 1px solid #174969; padding-left: 12px; font-size: 12px; }
+.pipe-enhancement__pipe { padding: 3px 5px; border: 1px solid #226286; background: #072039; color: #d9f7ff; cursor: pointer; text-align: left; }
+.pipe-enhancement__pipe[aria-pressed="true"] { border-color: #d8fbff; box-shadow: inset 0 0 4px #38bac9; }
 .pipe-enhancement__dimensions { display: block; margin-top: 4px; color: #a7c4d9; }
 .pipe-enhancement__devices h3 { margin: 0 0 8px; }
 .pipe-enhancement__devices button { color: #e4f4ff; background: #08213a; border: 1px solid #21618a; padding: 7px; cursor: pointer; }

+ 22 - 6
src/modules/cockpit/domain/enhancementDevices.js

@@ -21,18 +21,25 @@ function nearest(point, path) {
     return best
 }
 
+function deviceSlot(code) {
+    let hash = 2166136261
+    for (const character of String(code)) hash = Math.imul(hash ^ character.charCodeAt(0), 16777619)
+    hash = Math.imul(hash ^ (hash >>> 16), 0x85ebca6b)
+    return (hash ^ (hash >>> 13)) >>> 0
+}
+
 export function planEnhancementDevices(plan, rows = [], { mode = 'overview', abnormalDevices = null } = {}) {
     if (!plan.visible) return { devices: [], deviceList: [] }
     const corridor = createNeighborhood(plan.object.path, plan.radiusM || 50)
     const pipes = (plan.objects || [plan.object]).filter((o) => o.sourceKind === 'pipe')
     const devices = []
     const deviceList = []
-    const used = new Map()
     const seen = new Set()
     for (const row of [...rows].sort((a, b) => String(a.code).localeCompare(String(b.code)))) {
         if (!row.code || seen.has(row.code)) continue
         const abnormal = Array.isArray(abnormalDevices) ? abnormalDevices.find((d) => d.code === row.code) : null
-        if (!isAssociatedDeviceVisible(mode, abnormal?.status || row.status)
+        const detailMode = mode === 'infrastructure' ? 'overview' : mode
+        if (!isAssociatedDeviceVisible(detailMode, abnormal?.status || row.status)
             || (mode === 'warningResponse' && !abnormal)) continue
         seen.add(row.code)
         // Only compare actual source identifiers; never compare a display code to networkId.
@@ -48,12 +55,12 @@ export function planEnhancementDevices(plan, rows = [], { mode = 'overview', abn
             if (at && (!chosen || at.distance < chosen.distance)) chosen = { ...at, pipe }
         }
         const slotKey = chosen ? `${chosen.pipe.code}|${chosen.pipe.piece ?? 0}` : row.code
-        const slot = used.get(slotKey) || 0
-        used.set(slotKey, slot + 1)
+        const slot = deviceSlot(`${slotKey}|${row.code}`)
         const colorKey = chosen?.pipe.colorKey || pipeColorKey(row)
         const device = { ...row, position: chosen?.position || position, colorKey,
             depthM: chosen?.pipe.depthM ?? pipeVirtualDepthM(colorKey), pipeCode: chosen?.pipe.code || '',
             pipePiece: chosen?.pipe.piece ?? 0, slot,
+            pipeDisplayRadiusM: (chosen?.pipe.displayDiameterM || 0) / 2,
             placement: linked ? 'linked' : chosen ? 'nearby' : 'standalone',
             placementText: linked ? '关联设备 · 示意安装' : chosen ? '邻近设备 · 示意安装' : '设备原位置 · 无同业管段',
             retained: row.retained === true }
@@ -71,8 +78,8 @@ export function planEnhancementDevices(plan, rows = [], { mode = 'overview', abn
         const path = pipes.find((p) => p.code === list[0].pipeCode && (p.piece ?? 0) === list[0].pipePiece).path
         const lengths = path.slice(1).map((p, i) => Math.hypot((p[0] - path[i][0]) * Math.cos(p[1] * Math.PI / 180), p[1] - path[i][1]))
         const total = lengths.reduce((a, b) => a + b, 0)
-        list.forEach((device, slot) => {
-            let remaining = total * (slot + 1) / (list.length + 1)
+        list.forEach((device) => {
+            let remaining = total * (0.08 + 0.84 * device.slot / 0xffffffff)
             for (let i = 0; i < lengths.length; i += 1) {
                 if (remaining <= lengths[i] || i === lengths.length - 1) {
                     const t = lengths[i] ? Math.min(1, remaining / lengths[i]) : 0
@@ -83,5 +90,14 @@ export function planEnhancementDevices(plan, rows = [], { mode = 'overview', abn
             }
         })
     }
+    for (const device of devices) {
+        device.mountHeightM = device.pipeDisplayRadiusM || 0
+        for (const pipe of pipes) {
+            const radius = (pipe.displayDiameterM || 0) / 2
+            const at = nearest(device.position, pipe.path)
+            if (!at || !radius || at.distance > radius + 0.6) continue
+            device.mountHeightM = Math.max(device.mountHeightM, device.depthM - pipe.depthM + radius)
+        }
+    }
     return { devices, deviceList }
 }

+ 1 - 0
src/modules/cockpit/domain/mapSummary.js

@@ -64,6 +64,7 @@ export function toMonitoringPoint(raw) {
         statusReasons: reader.optionalList('statusReasons').map((item) => String(item)),
         // 事件与设备定位优先按设备编码回联;缺失时再退到 monitoringPointCode。
         deviceCodes: reader.optionalList('deviceCodes').map((item) => String(item)),
+        deviceCodesAvailable: Array.isArray(raw?.deviceCodes),
         // 批次 3:内层行业图标按后端行业枚举选型(缺失/未知行业 = 无内层,不猜)。
         industry: reader.optionalStr('industry'),
         longitude,

+ 2 - 1
src/modules/cockpit/domain/pipeEnhancement.js

@@ -5,7 +5,7 @@
  * Three.js 局部查看窗所需的最小领域数据。计划不创建渲染器,也不猜工程拓扑。
  */
 
-import { pipeColorKey, pipeVirtualDepthM } from './pipeVisual.js'
+import { displayPipeDiameter, pipeColorKey, pipeVirtualDepthM } from './pipeVisual.js'
 import { createNeighborhood, neighborhoodRadius } from './pipeNeighborhood.js'
 import { sourceDiameterM, pipeSurface, geographicPipeLength } from './pipePhysical.js'
 
@@ -58,6 +58,7 @@ function pipeView(item) {
         material: item.material || '',
         diameter: Number.isFinite(item.diameter) ? item.diameter : null,
         diameterM: sourceDiameterM(item.diameter),
+        displayDiameterM: displayPipeDiameter(sourceDiameterM(item.diameter)),
         lengthM: Number.isFinite(item.length) ? item.length : null,
         geographicLengthM: geographicPipeLength(item.positions),
         surface: pipeSurface(item.material),

+ 3 - 3
src/modules/cockpit/domain/pipeModels.js

@@ -1,10 +1,10 @@
 import { sourceDiameterM, pipeSurface } from './pipePhysical.js'
-import { pipeColorKey, pipeVirtualDepthM, PIPE_VIRTUAL_DEPTH_M } from './pipeVisual.js'
+import { displayPipeDiameter, pipeColorKey, pipeVirtualDepthM, PIPE_VIRTUAL_DEPTH_M } from './pipeVisual.js'
 
 /** 管体按源管径和真实地理路径体积表达;虚拟埋深仍是示意。 */
 export const PIPE_MODEL_POLICY = Object.freeze({ virtualDepthM: 2, componentSizeM: 1 })
 export const PIPE_MODEL_KINDS = ['spline', 'elbow', 'valve', 'branch', 'cross']
-export const PIPE_MODEL_NOTE = '管体按后端源管径毫米换算米,不放大、不设粗度下限;长度保持地理路径,后端管长仅作属性,差异不拉伸坐标。缺径只保留无尺寸导引。材质按源字段,未知不猜;行业色保留。虚拟埋深与无尺寸构件仍是示意,不代表实测安装。'
+export const PIPE_MODEL_NOTE = '管体按后端源管径毫米换算米,横向8倍显示(非工程尺寸),源管径单独保留;长度保持地理路径,后端管长仅作属性,差异不拉伸坐标。缺径只保留无尺寸导引。材质按源字段,未知不猜;行业色保留。虚拟埋深与无尺寸构件仍是示意,不代表实测安装。'
 
 /** 输入只接收 toPipeNetwork 已准入且经模式计划裁剪后的对象,不再推断坐标/拓扑。 */
 export function planPipeModels(network = {}) {
@@ -24,7 +24,7 @@ export function planPipeModels(network = {}) {
         for (let i = 1; i < pipe.positions.length; i += 1) {
             instances.push({ id, kind: 'spline', industry: pipe.industry, colorKey, surface,
                 virtualDepthM: pipeVirtualDepthM(colorKey), start: pipe.positions[i - 1], end: pipe.positions[i],
-                diameterM, displayDiameterM: diameterM, diameterSource: 'reported', diameterAnchors: [], visualDefaultDiameterM: null })
+                diameterM, displayDiameterM: displayPipeDiameter(diameterM), diameterSource: 'reported', diameterAnchors: [], visualDefaultDiameterM: null })
         }
     }
     for (const component of network.components || []) {

+ 3 - 3
src/modules/cockpit/domain/pipeVisual.js

@@ -8,7 +8,7 @@ export const PIPE_PALETTE = Object.freeze({
     DRAINAGE_UNKNOWN: { color:'#aab7c4', label:'排水(雨污等级未识别)' },
     UNKNOWN: { color:'#aab7c4', label:'未分类' },
 })
-export const PIPE_DISPLAY = Object.freeze({ diameterScale:1, minDiameterM:0, maxDiameterM:Infinity, guideFromM:600, guideWidthPx:4 })
+export const PIPE_DISPLAY = Object.freeze({ diameterScale:8, minDiameterM:0, maxDiameterM:Infinity, guideFromM:600, guideWidthPx:4 })
 export const PIPE_VIRTUAL_DEPTH_M = Object.freeze({ GAS:1.5, WATER_SUPPLY:4, SEWAGE:8, RAINWATER:10, DRAINAGE_UNKNOWN:8 })
 /**
  * 管线颜色图例(票 48):地图实际会画出的**全部**颜色桶的展示顺序。
@@ -65,7 +65,7 @@ export function pipeColorKey(row) {
     return 'DRAINAGE_UNKNOWN'
 }
 
-/** Compatibility seam: the rendered diameter is the positive source diameter in metres. */
+/** Source metres stay unchanged; 8x transverse display makes underground pipes readable. */
 export function displayPipeDiameter(diameterM) {
-    return Number.isFinite(diameterM) && diameterM > 0 ? diameterM : null
+    return Number.isFinite(diameterM) && diameterM > 0 ? diameterM * PIPE_DISPLAY.diameterScale : null
 }

+ 20 - 9
src/modules/cockpit/services/enhancementDevices.js

@@ -1,7 +1,7 @@
 import { fetchMonitoringPointDetail } from './detailApi.js'
 
 /** Read the current snapshot, not just the selected point. Never borrow a parent's position. */
-export async function loadEnhancementDevices(summary, { fetchPoint = fetchMonitoringPointDetail, alive = () => true } = {}) {
+export async function loadEnhancementDevices(summary, { fetchPoint = fetchMonitoringPointDetail, alive = () => true, onProgress = () => {} } = {}) {
     const objects = summary?.objects || []
     const points = objects.filter((o) => o.kind === 'monitoring-point')
     const rows = new Map(objects.filter((o) => o.kind === 'unassociated-equipment').map((o) => [o.code, { ...o, pointCode: '' }]))
@@ -10,30 +10,41 @@ export async function loadEnhancementDevices(summary, { fetchPoint = fetchMonito
     let missingDevices = 0
     let extraDevices = 0
     let stalePoints = 0
+    let missingLists = 0
+    let loadedPoints = 0
+    const state = (loading) => ({ rows: [...rows.values()], failedPoints, missingDevices, extraDevices, stalePoints,
+        missingLists, loadedPoints, totalPoints: points.length,
+        partial: loading || !['complete', 'empty'].includes(summary?.phase) || !!summary?.truncated
+            || (Number.isFinite(summary?.total) && summary.total > objects.length)
+            || failedPoints > 0 || missingDevices > 0 || extraDevices > 0 || missingLists > 0 || !alive(),
+        relationNote: '仅使用已提供的设施关联;其余为邻近示意,非实测安装位置' })
+    const publish = () => { if (alive()) onProgress(state(true)) }
+    publish()
     async function worker() {
         while (cursor < points.length && alive()) {
             const point = points[cursor++]
             let result
-            try { result = await fetchPoint(point.code) } catch { failedPoints += 1; continue }
+            try { result = await fetchPoint(point.code) } catch { failedPoints += 1; loadedPoints += 1; publish(); continue }
             if (!alive()) return
             if (!['success', 'empty', 'stale'].includes(result?.state) || !Array.isArray(result?.data?.devices)
-                || result.data.code !== point.code) { failedPoints += 1; continue }
+                || result.data.code !== point.code) { failedPoints += 1; loadedPoints += 1; publish(); continue }
             if (result.state === 'stale') stalePoints += 1
+            const listAvailable = point.deviceCodesAvailable ?? Array.isArray(point.deviceCodes)
+            if (!listAvailable) missingLists += 1
             const expected = new Set(point.deviceCodes || [])
             const found = new Set()
             for (const device of result.data.devices) {
-                if (!expected.has(device.code)) { extraDevices += 1; continue }
+                if (!device?.code) continue
+                if (listAvailable && !expected.has(device.code)) { extraDevices += 1; continue }
                 found.add(device.code)
                 rows.set(device.code, { ...device, pointCode: point.code, industry: device.industry || point.industry,
                     retained: result.state === 'stale' })
             }
             missingDevices += [...expected].filter((code) => !found.has(code)).length
+            loadedPoints += 1
+            publish()
         }
     }
     await Promise.all(Array.from({ length: Math.min(4, points.length) }, worker))
-    return { rows: [...rows.values()], failedPoints, missingDevices, extraDevices, stalePoints,
-        partial: !['complete', 'empty'].includes(summary?.phase) || !!summary?.truncated
-            || (Number.isFinite(summary?.total) && summary.total > objects.length)
-            || failedPoints > 0 || missingDevices > 0 || extraDevices > 0 || !alive(),
-        relationNote: '仅使用已提供的设施关联;其余为邻近示意,非实测安装位置' }
+    return state(false)
 }

+ 17 - 3
src/modules/cockpit/services/pipeModelShader.js

@@ -11,12 +11,26 @@ export function createPipeModelShader(colorKey, surface = pipeSurface('')) {
             u_pipeColor: { type:UniformType.VEC3, value:new Cartesian3(color.red,color.green,color.blue) },
             u_pipeMetalness: { type:UniformType.FLOAT, value:surface.metalness },
             u_pipeRoughness: { type:UniformType.FLOAT, value:surface.roughness },
+            u_pipeGrain: { type:UniformType.FLOAT, value:['concrete', 'iron'].includes(surface.kind) ? 0.32 : ['steel', 'copper', 'frp'].includes(surface.kind) ? 0.18 : surface.kind === 'unknown' ? 0 : 0.04 },
+            u_pipeCoarse: { type:UniformType.FLOAT, value:['concrete', 'iron'].includes(surface.kind) ? 1 : 0 },
         },
+        // Cesium 1.145 的 czm_modelMaterial 没有 metallic 字段(glTF 的 metallic 在采样阶段
+        // 就被折进 diffuse/specular),写它会报 `'metallic' : no such field in structure`
+        // 并让整个 3D 场景 "Rendering has stopped"。金属度按引擎自身口径摊到这两个字段:
+        //   diffuse  = mix(color, black, m)(金属无漫反射)
+        //   specular = mix(0.04, color, m)(0.04 = glTF 介质 F0,同 czm_dielectricSpecularF0)
         fragmentShaderText: `void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
-            material.diffuse = czm_srgbToLinear(u_pipeColor);
-            material.metallic = u_pipeMetalness;
+            vec3 p = fsInput.attributes.positionMC;
+            float particles = fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453);
+            float brushed = 0.5 + 0.5 * sin(p.z * 150.0);
+            float grain = mix(brushed, particles, u_pipeCoarse);
+            vec3 linearColor = czm_srgbToLinear(u_pipeColor) * (1.0 - u_pipeGrain * grain);
+            float metalness = clamp(u_pipeMetalness, 0.0, 1.0);
+            material.baseColor = vec4(linearColor, 1.0);
+            material.diffuse = mix(linearColor, vec3(0.0), metalness);
+            material.specular = mix(vec3(0.04), linearColor, metalness);
             material.roughness = u_pipeRoughness;
-            material.emissive = czm_srgbToLinear(u_pipeColor) * 0.08;
+            material.emissive = linearColor * 0.08;
             material.alpha = 1.0;
         }`,
     })

+ 37 - 2
src/modules/cockpit/services/pipeSurfaceMaterial.js

@@ -1,10 +1,45 @@
 import * as THREE from 'three'
 import { pipeSurface } from '../domain/pipePhysical.js'
 
-/** Industry tint communicates service; reflectance communicates the reported material. */
+const textures = new Map()
+function surfaceTexture(kind) {
+    if (textures.has(kind)) return textures.get(kind)
+    const size = 64, data = new Uint8Array(size * size * 4)
+    for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
+        const noise = ((x * 137 + y * 211 + x * y * 17) % 97) / 96
+        const grain = kind === 'concrete' ? 0.52 + noise * 0.48
+            : kind === 'iron' ? 0.62 + noise * 0.38
+            : kind === 'steel' || kind === 'copper' ? 0.7 + (Math.sin(y * 2.7) + 1) * 0.1 + noise * 0.1
+            : kind === 'frp' ? 0.76 + Math.sin(x * 0.8 + y * 0.8) * 0.12
+            : kind === 'pe' || kind === 'pvc' ? 0.9 + Math.sin(y * 0.4) * 0.06 : 1
+        const at = (y * size + x) * 4
+        data[at] = data[at + 1] = data[at + 2] = Math.round(grain * 255)
+        data[at + 3] = 255
+    }
+    const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat)
+    texture.wrapS = texture.wrapT = THREE.RepeatWrapping
+    texture.magFilter = THREE.LinearFilter
+    texture.minFilter = THREE.LinearMipmapLinearFilter
+    texture.generateMipmaps = true
+    texture.needsUpdate = true
+    const record = { texture, references: 0 }
+    textures.set(kind, record)
+    return record
+}
+
+/** Industry tint + reported material reflectance and illustrative surface grain. */
 export function pipeSurfaceMaterial(color, surface = pipeSurface('')) {
-    const material = new THREE.MeshStandardMaterial({ color, metalness: surface.metalness, roughness: surface.roughness, side: THREE.DoubleSide })
+    const record = surfaceTexture(surface.kind)
+    record.references += 1
+    const bumpScale = surface.kind === 'concrete' ? 0.07 : surface.kind === 'iron' ? 0.035
+        : surface.kind === 'steel' || surface.kind === 'copper' ? 0.008 : 0.003
+    const material = new THREE.MeshStandardMaterial({ color, metalness: surface.metalness, roughness: surface.roughness,
+        map: record.texture, bumpMap: record.texture, bumpScale, side: THREE.DoubleSide,
+        emissive: color, emissiveIntensity: 0.045 })
     material.userData.pipeSurface = surface.kind
+    material.addEventListener('dispose', () => {
+        if (--record.references === 0) { record.texture.dispose(); textures.delete(surface.kind) }
+    })
     return material
 }
 

+ 62 - 22
src/modules/cockpit/services/threePipeEnhancement.js

@@ -1,7 +1,7 @@
 /** Three.js adapter for a selected object and its bounded local underground context. */
 import * as THREE from 'three'
 import { enhancementLabel } from '../domain/pipeEnhancement.js'
-import { PIPE_PALETTE } from '../domain/pipeVisual.js'
+import { displayPipeDiameter, PIPE_PALETTE } from '../domain/pipeVisual.js'
 import { attachSceneFocus } from './sceneInputFocus.js'
 import { addDetailedPipeSegment } from './pipeDetailGeometry.js'
 import { attachPointPick } from './pointPick.js'
@@ -50,7 +50,7 @@ function buildObject(plan, frame = null) {
     const color = (PIPE_PALETTE[object.colorKey] || PIPE_PALETTE.UNKNOWN).color
 
     if (object.renderKind === 'pipe') {
-        const diameterM = object.diameterM ?? sourceDiameterM(object.diameter)
+        const diameterM = object.displayDiameterM ?? displayPipeDiameter(object.diameterM ?? sourceDiameterM(object.diameter))
         const surface = object.surface || pipeSurface(object.material)
         if (diameterM === null) {
             const guide = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), new THREE.LineDashedMaterial({ color, dashSize: 1, gapSize: 0.6 }))
@@ -112,7 +112,7 @@ function buildObject(plan, frame = null) {
 function buildNeighborhood(plan) {
     if ((!plan.objects || plan.objects.length <= 1) && !plan.devices?.length) return buildObject(plan)
     const objects = plan.objects || [{ ...plan.object, primary: true, depthM: 0 }]
-    const path = [...objects.flatMap((object) => object.path), ...(plan.devices || []).map((device) => device.position)]
+    const path = plan.object.path
     const center = [0, 1].map((axis) => {
         const [min, max] = path.reduce(([lo, hi], p) => [Math.min(lo, p[axis]), Math.max(hi, p[axis])], [Infinity, -Infinity])
         return (min + max) / 2
@@ -126,7 +126,10 @@ function buildNeighborhood(plan) {
         group.name = `device:${device.code}`
         group.userData.device = device
         group.position.copy(project(device.position, center, frame.scale))
-        group.position.y = ((frame.depthM || 0) - device.depthM) * frame.scale + 0.35
+        const radius = device.pipeDisplayRadiusM || 0
+        const deviceScale = Math.max(2, Math.min(6, radius * 2))
+        group.scale.setScalar(deviceScale)
+        group.position.y = ((frame.depthM || 0) - device.depthM) * frame.scale + Math.max(radius * 1.06, device.mountHeightM || 0) + deviceScale * 0.35
         const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.25, 8), material('#b9c5d0'))
         stem.position.y = -0.16
         group.add(stem)
@@ -135,12 +138,15 @@ function buildNeighborhood(plan) {
             : device.industry === 'WATER_SUPPLY' ? new THREE.CylinderGeometry(0.15, 0.15, 0.28, 12)
             : device.industry === 'MANHOLE' ? new THREE.BoxGeometry(0.3, 0.1, 0.22)
             : new THREE.ConeGeometry(0.17, 0.3, 12)
-        group.add(new THREE.Mesh(shape, material(color)))
+        const body = new THREE.Mesh(shape, new THREE.MeshStandardMaterial({ color, metalness: 0.25, roughness: 0.3,
+            emissive: color, emissiveIntensity: 0.28 }))
+        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)
+        group.add(indicator)
         root.add(group)
     }
-    root.updateMatrixWorld(true)
-    const box = new THREE.Box3().setFromObject(root)
-    root.position.sub(box.getCenter(new THREE.Vector3()))
     return root
 }
 
@@ -148,7 +154,7 @@ function buildNeighborhood(plan) {
  * Mount a bounded local viewer. The optional rendererFactory is a system-boundary seam
  * for offline tests; production uses WebGLRenderer.
  */
-export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRendererFactory, pixelRatio = 1, onDevicePick = () => {} } = {}) {
+export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRendererFactory, pixelRatio = 1, onDevicePick = () => {}, onObjectPick = () => {} } = {}) {
     if (!host || !plan || plan.visible !== true || !plan.object) {
         return { status: 'unavailable', sync: () => {}, destroy: () => {}, getState: () => ({ status: 'unavailable' }) }
     }
@@ -162,6 +168,12 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
     const key = new THREE.DirectionalLight(0xffffff, 2.4)
     key.position.set(3, 6, 4)
     scene.add(key)
+    const fill = new THREE.DirectionalLight(0x89baff, 1.8)
+    fill.position.set(-4, 2, -3)
+    scene.add(fill)
+    const rim = new THREE.DirectionalLight(0xffe2bb, 2)
+    rim.position.set(1, -1, -4)
+    scene.add(rim)
 
     let renderer
     try {
@@ -209,6 +221,7 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
         renderer.render?.(scene, camera)
     }
     const sync = (next) => {
+        const preserveView = root && current.object.code === next.object.code && current.object.sourceKind === next.object.sourceKind
         current = next
         if (root) {
             scene.remove(root)
@@ -221,9 +234,11 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
         const sphere = box.getBoundingSphere(new THREE.Sphere())
         if (!box.isEmpty()) {
             sceneRadius = Math.max(0.01, sphere.radius)
-            target.copy(sphere.center)
-            const halfFov = Math.atan(Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * Math.min(1, camera.aspect))
-            distance = sceneRadius / Math.sin(halfFov) * 1.12
+            if (!preserveView) {
+                target.copy(sphere.center)
+                const halfFov = Math.atan(Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * Math.min(1, camera.aspect))
+                distance = sceneRadius / Math.sin(halfFov) * 1.12
+            }
             camera.near = Math.max(0.0001, sceneRadius / 10000)
             camera.far = Math.max(100, sceneRadius * 100)
             camera.updateProjectionMatrix()
@@ -232,21 +247,27 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
     }
     const detachFocus = attachSceneFocus(host)
     const raycaster = new THREE.Raycaster()
-    const detachPick = attachPointPick(canvas, ({ x, y }) => {
-        if (!root || !canvas.getBoundingClientRect) return
+    const hitAt = (x, y) => {
+        if (!root || !canvas.getBoundingClientRect || !Number.isFinite(x) || !Number.isFinite(y)) return null
         const rect = canvas.getBoundingClientRect()
-        if (!rect.width || !rect.height) return
+        if (!rect.width || !rect.height || x < rect.left || x > rect.left + rect.width || y < rect.top || y > rect.top + rect.height) return null
         camera.updateMatrixWorld()
         root.updateMatrixWorld(true)
         raycaster.setFromCamera(new THREE.Vector2((x - rect.left) / rect.width * 2 - 1, 1 - (y - rect.top) / rect.height * 2), camera)
-        const hit = raycaster.intersectObject(root, true)[0]
-        let node = hit?.object
-        while (node && !node.userData.device) node = node.parent
-        if (node?.userData.device) onDevicePick(node.userData.device)
+        return raycaster.intersectObject(root, true)[0] || null
+    }
+    const detachPick = attachPointPick(canvas, ({ x, y }) => {
+        let node = hitAt(x, y)?.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 }
+            node = node.parent
+        }
     })
     const onDown = (event) => {
         if (drag || (event.button !== 0 && event.button !== 2)) return
-        drag = { button: event.button, id: event.pointerId, x: event.clientX, y: event.clientY }
+        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 }
         event.preventDefault?.()
         try { canvas.setPointerCapture?.(event.pointerId) } catch { /* 合成事件可能没有可捕获指针。 */ }
     }
@@ -256,7 +277,7 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
         const dy = event.clientY - drag.y
         if (drag.button === 0) {
             // 将像素位移换算为目标平面距离,保持“抓住场景拖动”的方向和速度。
-            const unitsPerPixel = 2 * distance * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) / (host.clientHeight || 220)
+            const unitsPerPixel = 2 * Math.min(distance, drag.panDistance) * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) / (host.clientHeight || 220)
             const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion)
             const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion)
             target.addScaledVector(right, -dx * unitsPerPixel).addScaledVector(up, dy * unitsPerPixel)
@@ -279,7 +300,16 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
     }
     const onWheel = (event) => {
         event.preventDefault?.()
-        distance = Math.max(sceneRadius * 0.05, Math.min(sceneRadius * 50, distance * Math.exp(Math.max(-1000, Math.min(1000, event.deltaY)) * 0.001)))
+        const hit = hitAt(event.clientX, event.clientY)
+        const factor = Math.exp(Math.max(-1000, Math.min(1000, event.deltaY)) * 0.001)
+        const limit = Math.max(sceneRadius * 50, 10)
+        if (hit) {
+            const forward = camera.getWorldDirection(new THREE.Vector3())
+            const nextDistance = Math.max(0.02, Math.min(limit, hit.distance * factor))
+            const nextPosition = camera.position.clone().addScaledVector(raycaster.ray.direction, hit.distance - nextDistance)
+            distance = nextDistance
+            target.copy(nextPosition).addScaledVector(forward, distance)
+        } else distance = Math.max(0.02, Math.min(limit, distance * factor))
         render()
     }
     canvas.addEventListener?.('pointerdown', onDown)
@@ -299,6 +329,16 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
         title: enhancementLabel(plan.object.sourceKind),
         sync(next) { if (!destroyed && next?.visible && next.object) sync(next) },
         resize,
+        focusDevice(code) {
+            if (destroyed) return false
+            const device = root?.getObjectByName(`device:${code}`)
+            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()
+            return true
+        },
         getState: () => ({ status: destroyed ? 'destroyed' : 'ready', code: current.object.code, title: enhancementLabel(current.object.sourceKind) }),
         destroy() {
             if (destroyed) return

+ 3 - 1
src/modules/cockpit/stores/enhancementDevicesStore.js

@@ -19,7 +19,9 @@ export const useEnhancementDevicesStore = defineStore('enhancementDevices', () =
         const token = ++sourceToken
         source.value = { rows: [], partial: true, loading: true }
         closeDetail()
-        const result = await loadEnhancementDevices(summary, { ...options, alive: () => token === sourceToken })
+        const result = await loadEnhancementDevices(summary, { ...options, alive: () => token === sourceToken,
+            onProgress: (state) => { if (token === sourceToken) source.value = { ...state, loading: true } },
+        })
         if (token === sourceToken) source.value = { ...result, loading: false }
     }
     async function open(device, options = {}) {