Browse Source

feat(cockpit): use source pipe dimensions and materials

ChenJZ 4 days ago
parent
commit
713cb2b605
23 changed files with 403 additions and 107 deletions
  1. 2 2
      .scratch/cockpit-real-data-wayfinding/verify/t32-view-layer-flow.mjs
  2. 2 3
      .scratch/cockpit-real-data-wayfinding/verify/t32-visibility-style.mjs
  3. 11 12
      .scratch/cockpit-real-data-wayfinding/verify/t32-visual-default.mjs
  4. 43 0
      .scratch/cockpit-shell-panel-layout/issues/54-untitled.md
  5. 1 1
      .scratch/cockpit-shell-panel-layout/verify/t23-threejs-enhancement.mjs
  6. 1 1
      .scratch/cockpit-shell-panel-layout/verify/t23-visual-input.mjs
  7. 4 3
      .scratch/cockpit-shell-panel-layout/verify/t50-pipe-detail.mjs
  8. 1 1
      .scratch/cockpit-shell-panel-layout/verify/t52-device-pick.mjs
  9. 5 2
      .scratch/cockpit-shell-panel-layout/verify/t53-manhole.mjs
  10. 45 0
      .scratch/cockpit-shell-panel-layout/verify/t54-main-map-material.mjs
  11. 53 0
      .scratch/cockpit-shell-panel-layout/verify/t54-physical-details.mjs
  12. 10 4
      CONTEXT.md
  13. 13 6
      src/modules/cockpit/components/PipeEnhancement.vue
  14. 7 2
      src/modules/cockpit/domain/pipeEnhancement.js
  15. 12 15
      src/modules/cockpit/domain/pipeModels.js
  16. 3 2
      src/modules/cockpit/domain/pipeNetwork.js
  17. 38 0
      src/modules/cockpit/domain/pipePhysical.js
  18. 44 3
      src/modules/cockpit/domain/pipeVisual.js
  19. 3 3
      src/modules/cockpit/services/cesiumPipeModels.js
  20. 13 16
      src/modules/cockpit/services/pipeDetailGeometry.js
  21. 13 6
      src/modules/cockpit/services/pipeModelShader.js
  22. 36 0
      src/modules/cockpit/services/pipeSurfaceMaterial.js
  23. 43 25
      src/modules/cockpit/services/threePipeEnhancement.js

+ 2 - 2
.scratch/cockpit-real-data-wayfinding/verify/t32-view-layer-flow.mjs

@@ -15,7 +15,7 @@ const sources=[]
 const viewer={terrainProvider:{},isDestroyed:()=>false,scene:{requestRender(){},primitives:{add(){},remove(m){m.destroy()}}},dataSources:{getByName:n=>sources.filter(s=>s.name===n),add:async s=>{sources.push(s);return s}}}
 try{
  globalThis.fetch=async url=>String(url).startsWith('blob:')?nativeFetch(url):new Response(await readFile(new URL('../../../public/cockpit/models/pipes/spline.gltf',import.meta.url),'utf8'))
- const pipe={code:'async',industry:'DRAINAGE',networkLevel:1,diameter:null,positions:[[110.39,28.44],[110.3901,28.44]]}
+ const pipe={code:'async',industry:'DRAINAGE',networkLevel:1,diameter:400,positions:[[110.39,28.44],[110.3901,28.44]]}
  let finish
  const loaded=new Promise(resolve=>finish=resolve)
  const states=[]
@@ -28,7 +28,7 @@ try{
  assert.equal(near(),0)
  globalThis.t32Models[0].readyEvent.raiseEvent()
  assert.equal(near(),0,'late readiness must use latest ground mode')
- assert.equal(states.at(-1).visualDefaultCount,1)
+ assert.equal(states.at(-1).visualDefaultCount,0)
  setPipeViewMode(viewer,'pipeline');assert.equal(near(),600)
  const previous=graphics()
  await syncPipeLayer(viewer,{pipes:[{...pipe,name:'metadata changed'}],components:[]})

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

@@ -6,9 +6,8 @@ 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.5),'all narrow real diameters need legible demonstration thickness')
-assert.ok(plan.instances.every(row=>row.diameterM===.1),'source diameter is NOT visual size')
-assert.ok(plan.instances.every(row=>row.displayDiameterM<=3),'entire body stays beneath terrain at 2m centre depth')
+assert.ok(plan.instances.every(row=>row.displayDiameterM===.1),'physical body diameter follows source metres without exaggeration')
+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)
 assert.equal(adapted.pipes[0].remark,'污水')

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

@@ -3,18 +3,17 @@ import { planPipeModels } from '../../../src/modules/cockpit/domain/pipeModels.j
 const make=(industry,remark='',diameter=null)=>({code:industry+remark,industry,remark,diameter,positions:[[110.39,28.44],[110.3901,28.44]]})
 const pipes=[make('GAS'),make('WATER_SUPPLY'),make('DRAINAGE','污水'),make('DRAINAGE','雨水')]
 const before=JSON.stringify(pipes),plan=planPipeModels({pipes,components:[]})
-assert.equal(plan.instances.length,4,'missing diameters with known categories must have display models')
-assert.deepEqual(plan.instances.map(i=>i.visualDefaultDiameterM),[.16,.4,.8,.8])
-assert.ok(plan.instances.every(i=>i.diameterM===null&&i.diameterSource==='visual-default'))
-assert.ok(plan.instances.every(i=>i.diameterAnchors.length===0))
+assert.equal(plan.instances.length,0,'unknown source diameters must not create default-diameter physical bodies')
+assert.equal(plan.fallbacks.length,4)
 assert.equal(plan.diameterSummary.missing,4)
-assert.equal(plan.visualDefaultCount,4)
-assert.equal(JSON.stringify(pipes),before,'source stays missing')
+assert.equal(plan.visualDefaultCount,0)
+assert.equal(JSON.stringify(pipes),before)
 const unknown=planPipeModels({pipes:[{...make('DRAINAGE','雨水 污水'),networkLevel:2}],components:[]})
-assert.equal(unknown.instances[0].visualDefaultDiameterM,.8)
-assert.equal(unknown.instances[0].colorKey,'DRAINAGE_UNKNOWN')
-assert.equal(unknown.instances[0].diameterM,null)
-assert.equal(planPipeModels({pipes:[make('UNKNOWN')],components:[]}).fallbacks.length,1)
+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.diameterSource,'reported');assert.equal(reported.visualDefaultDiameterM,null)
-console.log('PASS explicit per-category display defaults, missing provenance, source priority and unknown fallback')
+assert.equal(reported.diameterM,.15)
+assert.equal(reported.displayDiameterM,.15)
+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')

+ 43 - 0
.scratch/cockpit-shell-panel-layout/issues/54-untitled.md

@@ -0,0 +1,43 @@
+<!-- DSH-IDEMPOTENCY-KEY: underground-details-source-dimensions-material-outline-v1 -->
+<!-- parentKey: 00 -->
+# 地下管线详情视图:真实管径、数据材质与精模描边
+
+## What to build
+
+维护者追加四项修改:管道精模与粗模尺寸遵循真实数据;材质与源材质相符;地下详情视图主选精模有模型描边;正式名称“地下管线详情视图”。
+
+已确认长度冲突决策:全部保持真实地理路径长度,后端length保留并显示差异,不拉伸坐标去凑属性长度。邻域裁切保持原地理片段。管径按后端mm转m,不放大、不夹粗度、不用行业默认/推算值冒充真实管径;缺径保留明确的无尺寸导引。
+
+## Acceptance criteria
+
+- [x] 详情精模、邻近粗模和主图管体使用后端源管径;比例与地理米制一致,不再固定半径或8倍放大/下限。
+- [x] 源length、地理路径长度与差异透明呈现,不修改源路径或用全长替代裁片。
+- [x] 源材质分类映射金属/铸铁/塑料/混凝土等可辨的表面参数;钢、玻璃钢和未知编码有明确边界,不从行业猜材质。
+- [x] 精粗管体共用材质语义,行业识别色保留,精模单独描边高亮,不改变真实尺寸和设备拾取。
+- [x] 名称、无障碍标签、详情状态标题和运行信息入口统一为“地下管线详情视图”。
+- [x] 实际Three/Cesium计划与材质、几何边界、描边释放、既有交互离线回归通过。
+- [ ] 维护者正式入口现场视觉/GPU验收;本轮未运行浏览器和build/debug。
+
+## 进度
+
+代码完成,待现场视觉验收。真实尺寸、材质、描边、命名与异常length边界已通过独立回归;完整输入回归受并发 `markerOverlay.js` 导入错误 `FX_CLASS is not defined` 阻断,该错误不属于本票改动。
+
+验证:t54-physical-details、t54-main-map-material、t50-pipe-detail、t52-device-pick、t53-manhole、t23-threejs-enhancement 9/9、t23-sfc-compile 5/5、t23-popup-placement通过。另有t23-visual-input因并发markerOverlay导入错误未能启动。按AGENTS跳过build/dev/browser/GPU。
+
+## Context
+
+规格与前置邻域/设备/窨井能力见[局部邻域管网与窨井规格](local-network-enhancement-spec.md)。源材质编码没有完整后端权威字典,未知编码保持“材质未识别”并使用低金属度表面;地理路径长度优先,后端length仅原值展示。
+
+## Comments
+
+### local — 2026-10-16
+源管径mm→m贯通精模/邻近粗模/主图;按材质拆Cesium批次并使用PBR参数;精模描边共享几何但独立材质、随分辨率更新并释放;正式名称已统一。
+
+## Answer
+代码完成,待维护者现场视觉确认。
+
+Status: ready-for-human
+Assignee: codex
+Type: task
+Blocked by:
+Labels: ready-for-human, wayfinder:task

+ 1 - 1
.scratch/cockpit-shell-panel-layout/verify/t23-threejs-enhancement.mjs

@@ -75,7 +75,7 @@ check('渲染服务支持相同编码但不同对象类型的替换', () => {
     const sameCodeComponent = { ...component, code: 'P-001' }
     const second = planPipeEnhancement({ visiblePipes: [pipe], visibleComponents: [sameCodeComponent], selected: enhancementSelection('component', 'P-001') })
     viewer.sync(second)
-    assert.equal(viewer.getState().title, '重点构件示意')
+    assert.equal(viewer.getState().title, '地下管线详情视图 · 构件')
     assert.equal(viewer.getState().code, 'P-001')
     viewer.destroy()
     assert.equal(viewer.getState().status, 'destroyed')

+ 1 - 1
.scratch/cockpit-shell-panel-layout/verify/t23-visual-input.mjs

@@ -33,7 +33,7 @@ function fixture() {
     return { doc, host, rendererFactory, scene: () => scene, camera: () => camera }
 }
 function selected(row, component = false) {
-    const item = { code: 'P', positions: [[110.1, 28.4], [110.101, 28.401]], position: [110.1, 28.4], kind: 'valve', ...row }
+    const item = { code: 'P', diameter: 400, positions: [[110.1, 28.4], [110.101, 28.401]], position: [110.1, 28.4], kind: 'valve', ...row }
     return planPipeEnhancement({ selected: { kind: component ? 'component' : 'pipe', code: 'P' }, visiblePipes: [item], visibleComponents: [item] })
 }
 function event(target, type, extras = {}) {

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

@@ -3,7 +3,7 @@ import * as THREE from 'three'
 import { planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnhancement.js'
 import { mountPipeEnhancement } from '../../../src/modules/cockpit/services/threePipeEnhancement.js'
 
-const source = { code: 'hollow-pipe', industry: 'WATER_SUPPLY', positions: [[110, 28], [110.001, 28]] }
+const source = { code: 'hollow-pipe', industry: 'WATER_SUPPLY', diameter: 400, material: 'PE', positions: [[110, 28], [110.001, 28]] }
 const original = JSON.stringify(source)
 const plan = planPipeEnhancement({ selected: { kind: 'pipe', code: source.code }, visiblePipes: [source] })
 class Canvas extends EventTarget {}
@@ -72,13 +72,14 @@ check('换选移除旧模型并释放全部几何与材质', () => {
     assert.equal(root.parent, null)
     assertInitialReleased()
 })
-check('短管段自适应居中,仍保留可辨的管体长宽比', () => {
+check('短管段保持真实长径比例,不为可见性拉长或加粗', () => {
     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 / size.y > 4, '短路径也应读成管段而不是厚圆饼')
+    assert.ok(size.x > 0.98 && size.x < 1.02)
+    assert.ok(size.y > 0.4 && size.y < 0.6)
     assert.ok(box.getCenter(new THREE.Vector3()).length() < 1e-6)
 })
 check('纵向管段法兰随路径定向,轴向管腔不封死', () => {

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

@@ -4,7 +4,7 @@ import { planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnh
 import { planEnhancementDevices } from '../../../src/modules/cockpit/domain/enhancementDevices.js'
 import { mountPipeEnhancement } from '../../../src/modules/cockpit/services/threePipeEnhancement.js'
 const plan = planPipeEnhancement({ selected: { kind: 'pipe', code: 'P' }, visiblePipes: [
-    { code: 'P', industry: 'WATER_SUPPLY', positions: [[110, 28], [110.001, 28]] },
+    { 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()

+ 5 - 2
.scratch/cockpit-shell-panel-layout/verify/t53-manhole.mjs

@@ -22,9 +22,12 @@ viewer.destroy()
 const isolated = planPipeEnhancement({ selected: { kind: 'component', code: 'MH-1' }, visibleComponents: [manhole] })
 for (const position of [[110.0002, 28], [110, 28]]) {
     isolated.devices = [{ code: 'standalone', industry: 'WATER_SUPPLY', colorKey: 'WATER_SUPPLY', depthM: 4, slot: 0, position }]
-    const next = mountPipeEnhancement(host, isolated, { rendererFactory: () => ({ domElement: canvas, setSize() {}, dispose() {}, render(s) { scene = s } }) })
+    let isolatedCamera
+    const next = mountPipeEnhancement(host, isolated, { rendererFactory: () => ({ domElement: canvas, setSize() {}, dispose() {}, render(s, c) { scene = s; isolatedCamera = c } }) })
     const device = scene.getObjectByName('device:standalone')
-    assert.ok(device.position.length() < 6, '井边独立设备须纳入相机可见范围')
+    scene.updateMatrixWorld(true); isolatedCamera.updateMatrixWorld()
+    const screen = device.getWorldPosition(new THREE.Vector3()).project(isolatedCamera)
+    assert.ok(Math.abs(screen.x) < 1 && Math.abs(screen.y) < 1 && screen.z < 1, '井边独立设备须纳入相机可见范围')
     next.destroy()
 }
 console.log('PASS 无管线源时独立设备坐标及深度参与取景,设备模型不会落到远裁面外')

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

@@ -0,0 +1,45 @@
+import assert from 'node:assert/strict'
+import { register } from 'node:module'
+import { readFile } from 'node:fs/promises'
+import { pipeSurface } from '../../../src/modules/cockpit/domain/pipePhysical.js'
+register('../../cockpit-real-data-wayfinding/verify/t32-adapter-io-loader.mjs', import.meta.url)
+const { createCesiumPipeModels } = await import('../../../src/modules/cockpit/services/cesiumPipeModels.js')
+const { createPipeModelShader } = await import('../../../src/modules/cockpit/services/pipeModelShader.js')
+const { LightingModel } = await import('cesium')
+globalThis.t32Models = []
+globalThis.t32TerrainFails = false
+globalThis.window = { location: { href: 'http://test.invalid/cockpit/', origin: 'http://test.invalid' } }
+const nativeFetch = globalThis.fetch
+const source = { entities: { values: [] } }
+const viewer = { terrainProvider: {}, isDestroyed: () => false, scene: { requestRender() {}, primitives: { add() {}, remove(m) { m.destroy() } } } }
+const pipes = [
+    { code: 'pe', industry: 'WATER_SUPPLY', diameter: 400, material: 'PE', positions: [[110.39, 28.44], [110.3901, 28.44]] },
+    { code: 'steel', industry: 'WATER_SUPPLY', diameter: 200, material: '钢管', positions: [[110.39, 28.4401], [110.3901, 28.4401]] },
+]
+function values(model, key) {
+    const accessor = model.gltf.accessors[model.gltf.nodes[0].extensions.EXT_mesh_gpu_instancing.attributes[key]]
+    const view = model.gltf.bufferViews[accessor.bufferView]
+    const data = model.gltf.buffers[view.buffer].uri.split(',')[1]
+    const bytes = Buffer.from(data, 'base64')
+    return new Float32Array(bytes.buffer, bytes.byteOffset + (view.byteOffset || 0), accessor.count * 3)
+}
+let adapter
+try {
+    globalThis.fetch = async (url) => String(url).startsWith('blob:') ? nativeFetch(url)
+        : new Response(await readFile(new URL('../../../public/cockpit/models/pipes/spline.gltf', import.meta.url), 'utf8'))
+    adapter = createCesiumPipeModels(viewer, { source })
+    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(values(pe, 'SCALE')[2] > 9 && values(pe, 'SCALE')[2] < 11, '轴向长度保持地理路径')
+    const shader = createPipeModelShader('WATER_SUPPLY', pipeSurface('PE'))
+    assert.equal(shader.lightingModel, LightingModel.PBR)
+    assert.equal(shader.uniforms.u_pipeMetalness.value, 0)
+    shader.destroy()
+    await adapter.sync({ pipes: [{ ...pipes[0], material: '钢管' }, pipes[1]] })
+    assert.ok(globalThis.t32Models.length > 2, '仅材质变化也触发3D更新')
+    assert.equal(pe.isDestroyed(), true)
+    console.log('PASS 主图GPU实例源直径和地理长度、按材质分组/PBR参数、仅材质更新与旧批次释放')
+} finally { adapter?.destroy(); globalThis.fetch = nativeFetch }

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

@@ -0,0 +1,53 @@
+import assert from 'node:assert/strict'
+import * as THREE from 'three'
+import { planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnhancement.js'
+import { planPipeModels } from '../../../src/modules/cockpit/domain/pipeModels.js'
+import { pipeSurface } from '../../../src/modules/cockpit/domain/pipePhysical.js'
+import { mountPipeEnhancement } from '../../../src/modules/cockpit/services/threePipeEnhancement.js'
+const surfaces = [['钢', 'steel'], ['钢管', 'steel'], ['玻璃钢管', 'frp'], ['未知编码', 'unknown']]
+for (const [value, expected] of surfaces) assert.equal(pipeSurface(value).kind, expected)
+const anomaly = planPipeEnhancement({ selected: { kind: 'pipe', code: 'zero' }, visiblePipes: [{ code: 'zero', diameter: 400, length: 0, material: '钢', positions: [[110, 28], [110.001, 28]] }] })
+assert.equal(anomaly.object.lengthM, 0)
+const pipes = [
+    { code: 'fine', industry: 'WATER_SUPPLY', diameter: 400, length: 70, material: 'PE', positions: [[110, 28], [110.001, 28]] },
+    { code: 'coarse', industry: 'WATER_SUPPLY', diameter: 200, length: 45, material: '钢管', positions: [[110, 28.0001], [110.001, 28.0001]] },
+    { code: 'missing', industry: 'WATER_SUPPLY', diameter: null, positions: [[110, 28.0002], [110.001, 28.0002]] },
+]
+const input = { selected: { kind: 'pipe', code: 'fine' }, visiblePipes: pipes }
+const plan = planPipeEnhancement(input)
+assert.equal(plan.object.diameterM, 0.4)
+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:coarse').surface.kind, 'steel')
+assert.ok(!map.instances.some((i) => i.id === 'pipe:missing'))
+let scene, camera
+const canvas = new EventTarget()
+const host = { clientWidth: 900, clientHeight: 500, appendChild(n) { n.parentNode = this }, removeChild(n) { n.parentNode = null } }
+const viewer = mountPipeEnhancement(host, plan, { rendererFactory: () => ({ domElement: canvas, setSize() {}, dispose() {}, render(s, c) { scene = s; camera = c } }) })
+scene.updateMatrixWorld(true)
+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.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)
+assert.ok(coarse.material.metalness > 0.7)
+const outlines = []
+scene.traverse((n) => { if (n.name === 'selected-pipe-outline') outlines.push(n) })
+assert.ok(outlines.length)
+assert.ok(!outlines.some((n) => n.parent.name === 'neighbor-pipe-wall'))
+const outline = outlines[0]
+assert.equal(outline.material.uniforms.resolution.value.x, 900)
+assert.equal(outline.material.depthTest, true)
+let released = false
+outline.material.addEventListener('dispose', () => { released = true })
+assert.ok(camera.far > camera.position.length())
+viewer.destroy()
+assert.equal(released, true)
+console.log('PASS 精粗源管径/地理长度/数据材质一致;缺径不造管体;精模描边及释放')

+ 10 - 4
CONTEXT.md

@@ -44,11 +44,17 @@ _Avoid_: 真实安装位置(无安装资料时)、管道所属设备(仅
 以井盖与剖切井筒示意模型查看窨井附近地下管道、监测设备及已有状态;未提供接管关系时仅说明空间邻近,不强行将附近管道接入井筒。
 _Avoid_: 井下实测结构(无资料时)、从附近管道推断接管关系
 
-**显示粗度**:
-为使管道示意模型清晰可辨而放大的可视横截面尺寸,与源数据管径、推算管径分别保留;不代表工程尺寸,也不改变管段路径。
-_Avoid_: 真实管径、用显示尺寸回写业务管径
+**地下管线详情视图**:
+围绕选中管段或窨井查看附近地下管道、构件及真实监测设备的局部场景;主选精模描边突出,精粗管体均使用后端源管径并保持地理路径长度,材质由源字段决定。
+_Avoid_: 局部增强视图、用固定粗度或默认DN冒充源管径
+
+**管体尺寸**:
+横截面按后端源管径毫米换算米,不放大、不夹最小粗度;长度依真实地理路径,后端管长原值单独保留,冲突时不拉伸坐标。缺径只呈现无尺寸导引,壁厚、法兰及安装朝向缺资料时仍是示意。
+_Avoid_: 把显示粗度称为真实管径、默认管径实体、推算值冒充源尺寸
 
-缺少可靠源/推算管径的3D显示可使用DN160燃气/DN400供水/DN800排水作为可读性占位;以visual-default标记,不是测量、推算管径,不参与插值锚点,也不得回写源数据。排水雨污仅按network_level:0污水、1雨水,旧空值按0;其它等级未知时可使用同一DN800几何占位但保持灰色未分类,不猜颜色。
+**显示粗度**:
+为辨认地理路径而使用的屏幕导引线宽,不是实体管径;实体管体的显示放大与行业默认DN占位规则已由源尺寸规则取代。
+_Avoid_: 真实管径、用导引线宽回写管径
 
 **推算管径**:
 根据同业、端点完全重合且无分叉的连续链两侧已有管径,按缺失段路径中点距离插值得到的示意尺寸;保留两侧来源,不覆盖源数据或声称实测。

+ 13 - 6
src/modules/cockpit/components/PipeEnhancement.vue

@@ -1,12 +1,12 @@
 <template>
-    <aside v-if="open" class="pipe-enhancement" data-pipe-enhancement role="dialog" aria-label="重点对象局部增强" @keydown.esc.stop.prevent="$emit('close')">
+    <aside v-if="open" class="pipe-enhancement" data-pipe-enhancement role="dialog" aria-label="地下管线详情视图" @keydown.esc.stop.prevent="$emit('close')">
         <header class="pipe-enhancement__header">
             <div>
-                <span class="pipe-enhancement__eyebrow">Three.js 局部增强</span>
+                <span class="pipe-enhancement__eyebrow">地下管线详情视图</span>
                 <h2 data-pipe-enhancement-title>{{ plan.object.title }}</h2>
                 <p class="pipe-enhancement__controls">左键拖动平移 · 右键旋转 · 滚轮缩放 · Esc 关闭</p>
             </div>
-            <button type="button" class="pipe-enhancement__close" aria-label="关闭局部增强" title="关闭局部增强" data-pipe-enhancement-close @click="$emit('close')">×</button>
+            <button type="button" class="pipe-enhancement__close" aria-label="关闭地下管线详情视图" title="关闭地下管线详情视图" data-pipe-enhancement-close @click="$emit('close')">×</button>
         </header>
         <div class="pipe-enhancement__scope" v-if="plan.coverage">
             <label>附近范围
@@ -17,11 +17,15 @@
             <span>管道 {{ plan.coverage.pipeCount }} 条 · 构件 {{ plan.coverage.componentCount }} 个 · 邻近管道按范围裁切</span>
         </div>
         <div class="pipe-enhancement__workspace">
-            <div ref="viewport" class="pipe-enhancement__viewport" data-pipe-enhancement-viewport role="region" aria-label="管道局部三维视口"></div>
+            <div ref="viewport" class="pipe-enhancement__viewport" data-pipe-enhancement-viewport role="region" aria-label="地下管线详情视图三维场景"></div>
             <aside class="pipe-enhancement__devices" aria-label="附近设备与详情">
                 <section v-if="localPipeStates.length" aria-label="附近管道状态">
                     <h3>附近管道状态</h3>
-                    <p v-for="pipe in localPipeStates" :key="pipe.code">{{ pipe.title }}({{ pipe.code }}):{{ pipe.stateText || '状态未提供' }}</p>
+                    <p v-for="pipe in localPipeStates" :key="pipe.code">
+                        {{ pipe.title }}({{ pipe.code }}):{{ 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>
@@ -46,10 +50,12 @@
             <div><dt>对象</dt><dd>{{ plan.object.renderKind === 'manhole' ? '窨井剖切' : plan.object.sourceKind === 'pipe' ? '重点管段' : '重点构件' }}</dd></div>
             <div><dt>编码</dt><dd>{{ plan.object.code }}</dd></div>
             <div v-if="plan.object.diameter !== null"><dt>源管径</dt><dd>{{ plan.object.diameter }} mm</dd></div>
+            <div v-if="plan.object.sourceKind === 'pipe'"><dt>地理路径长度</dt><dd>{{ plan.object.geographicLengthM?.toFixed(2) }} m</dd></div>
+            <div v-if="plan.object.sourceKind === 'pipe'"><dt>后端管长(源值)</dt><dd>{{ plan.object.lengthM ?? '未提供' }}</dd></div>
             <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 }} · 管体按源管径;长度保持地理路径,后端管长源值不用于拉伸。</p>
     </aside>
 </template>
 
@@ -126,6 +132,7 @@ 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__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; }
 .pipe-enhancement__device { display: block; width: 100%; margin-bottom: 6px; text-align: left; }

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

@@ -7,6 +7,7 @@
 
 import { pipeColorKey, pipeVirtualDepthM } from './pipeVisual.js'
 import { createNeighborhood, neighborhoodRadius } from './pipeNeighborhood.js'
+import { sourceDiameterM, pipeSurface, geographicPipeLength } from './pipePhysical.js'
 
 export const PIPE_ENHANCEMENT_KIND = Object.freeze({
     PIPE: 'pipe',
@@ -56,6 +57,10 @@ function pipeView(item) {
         stateText: item.statusLabel || item.status || '状态未提供',
         material: item.material || '',
         diameter: Number.isFinite(item.diameter) ? item.diameter : null,
+        diameterM: sourceDiameterM(item.diameter),
+        lengthM: Number.isFinite(item.length) ? item.length : null,
+        geographicLengthM: geographicPipeLength(item.positions),
+        surface: pipeSurface(item.material),
         path: item.positions.map((pair) => [Number(pair[0]), Number(pair[1])]),
         position: item.positions[0],
     }
@@ -104,7 +109,7 @@ export function planPipeEnhancement({ selected = null, visiblePipes = [], visibl
         if (!validPipe(pipe) || (selected.kind === 'pipe' && pipe.code === selected.code)) continue
         const view = pipeView(pipe)
         const pieces = corridor.clip(view.path)
-        pieces.forEach((path, index) => objects.push({ ...view, path, piece: index, primary: false,
+        pieces.forEach((path, index) => objects.push({ ...view, path, geographicLengthM: geographicPipeLength(path), piece: index, primary: false,
             clipped: pieces.length !== 1 || JSON.stringify(path) !== JSON.stringify(view.path),
             depthM: pipeVirtualDepthM(view.colorKey) }))
     }
@@ -137,5 +142,5 @@ export function enhancementSelection(kind, code) {
 }
 
 export function enhancementLabel(kind) {
-    return kind === PIPE_ENHANCEMENT_KIND.PIPE ? '重点管段剖面示意' : '重点构件示意'
+    return kind === PIPE_ENHANCEMENT_KIND.PIPE ? '地下管线详情视图 · 管段' : '地下管线详情视图 · 构件'
 }

+ 12 - 15
src/modules/cockpit/domain/pipeModels.js

@@ -1,32 +1,30 @@
-import { displayDiameterFor } from './pipeDiameterDisplay.js'
-import { resolvePipeDiameters } from './pipeDiameter.js'
-import { displayPipeDiameter, pipeColorKey, pipeVirtualDepthM, PIPE_VIRTUAL_DEPTH_M } from './pipeVisual.js'
+import { sourceDiameterM, pipeSurface } from './pipePhysical.js'
+import { 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 = '管体示意放大8倍、限制1.5–3m(非工程尺寸);虚拟埋深按类别:燃气1.5m、供水4m、污水8m、雨水10m(均非实测);源/双侧推算优先,缺径仅显示默认DN160/400/800/800,不回写源管径。地面视图所有距离保留彩色导引,管线视图已建模段600m外保留导引;燃气红、供水蓝、污水绿、雨水青、窨井紫,雨污按network_level:0污水、1雨水、缺失按0,其它值未分类;构件固定示意朝向,素材/地表失败保留折线或符号。'
+export const PIPE_MODEL_NOTE = '管体按后端源管径毫米换算米,不放大、不设粗度下限;长度保持地理路径,后端管长仅作属性,差异不拉伸坐标。缺径只保留无尺寸导引。材质按源字段,未知不猜;行业色保留。虚拟埋深与无尺寸构件仍是示意,不代表实测安装。'
 
 /** 输入只接收 toPipeNetwork 已准入且经模式计划裁剪后的对象,不再推断坐标/拓扑。 */
 export function planPipeModels(network = {}) {
     const instances = []
     const fallbacks = []
     const pipes = network.pipes || []
-    const diameters = resolvePipeDiameters(pipes)
     let visualDefaultCount = 0
     for (const pipe of pipes) {
         const id = `pipe:${pipe.code}`
-        const diameter = diameters.get(pipe)
+        const diameterM = sourceDiameterM(pipe.diameter)
         const colorKey = pipeColorKey(pipe)
-        const visualDefaultDiameterM = diameter ? null : displayDiameterFor(colorKey)
-        if (!diameter && visualDefaultDiameterM === null) {
-            fallbacks.push({ id, reason: '缺管径且分类未明确,保留折线' })
+        if (diameterM === null) {
+            fallbacks.push({ id, reason: '源管径未提供,保留无尺寸导引' })
             continue
         }
-        if (!diameter) visualDefaultCount += 1
-        const diameterM = diameter ? diameter.diameterMm / 1000 : null
+        const surface = pipeSurface(pipe.material)
         for (let i = 1; i < pipe.positions.length; i += 1) {
-            instances.push({ id, kind: 'spline', industry: pipe.industry, colorKey, virtualDepthM:pipeVirtualDepthM(colorKey), start: pipe.positions[i - 1], end: pipe.positions[i], diameterM, displayDiameterM:displayPipeDiameter(diameterM ?? visualDefaultDiameterM), diameterSource:diameter?.source || 'visual-default', diameterAnchors:diameter?.anchors || [], visualDefaultDiameterM })
+            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 })
         }
     }
     for (const component of network.components || []) {
@@ -41,8 +39,7 @@ export function planPipeModels(network = {}) {
     }
     const diameterSummary = { reported:0, interpolated:0, missing:0 }
     for (const pipe of pipes) {
-        const diameter = diameters.get(pipe)
-        diameterSummary[diameter ? diameter.source : 'missing'] += 1
+        diameterSummary[sourceDiameterM(pipe.diameter) !== null ? 'reported' : 'missing'] += 1
     }
     return { instances, fallbacks, diameterSummary, visualDefaultCount, virtualDepthM: null, virtualDepthByColor: PIPE_VIRTUAL_DEPTH_M }
 }

+ 3 - 2
src/modules/cockpit/domain/pipeNetwork.js

@@ -186,9 +186,10 @@ export function toPipeNetwork(payload) {
                 remark: typeof properties.remark === 'string' ? properties.remark : '',
                 networkLevel: properties.networkLevel ?? properties.network_level ?? 0,
                 industry,
-                material: String(properties.material || ''),
+                material: String(properties.material ?? ''),
                 diameter: diameterOf(properties.diameter),
-                length: isFiniteNumber(Number(properties.length)) ? Number(properties.length) : null,
+                length: properties.length == null || String(properties.length).trim() === '' ? null
+                    : isFiniteNumber(Number(properties.length)) ? Number(properties.length) : null,
                 positions,
             })
             continue

+ 38 - 0
src/modules/cockpit/domain/pipePhysical.js

@@ -0,0 +1,38 @@
+/** Source material semantics shared by map and detail renderers; unknown codes are never guessed. */
+const SURFACES = Object.freeze({
+    steel: { kind: 'steel', label: '钢管', metalness: 0.82, roughness: 0.32 },
+    iron: { kind: 'iron', label: '铸铁', metalness: 0.65, roughness: 0.7 },
+    pe: { kind: 'pe', label: 'PE塑料', metalness: 0, roughness: 0.5 },
+    pvc: { kind: 'pvc', label: 'PVC塑料', metalness: 0, roughness: 0.28 },
+    frp: { kind: 'frp', label: '玻璃钢复合材料', metalness: 0, roughness: 0.4 },
+    concrete: { kind: 'concrete', label: '混凝土', metalness: 0, roughness: 0.96 },
+    copper: { kind: 'copper', label: '铜管', metalness: 0.92, roughness: 0.26 },
+    unknown: { kind: 'unknown', label: '材质未识别', metalness: 0, roughness: 0.8 },
+})
+export function pipeSurface(material) {
+    const text = typeof material === 'string' ? material.trim().toUpperCase() : ''
+    let kind = 'unknown'
+    if (/玻璃钢|^(FRP|GRP|GFRP)$/.test(text)) kind = 'frp'
+    else if (/不锈钢|钢管|碳钢|镀锌|无缝钢|^(钢|STEEL|STAINLESS STEEL|SS|Q235|Q345)$/.test(text)) kind = 'steel'
+    else if (/铸铁|球墨|^(DI|CI|DUCTILE IRON|CAST IRON)$/.test(text)) kind = 'iron'
+    else if (/聚乙烯|^(HDPE|MDPE|PE)([-\s]?\d+)?$/.test(text)) kind = 'pe'
+    else if (/聚氯乙烯|^(U?PVC|PVC[- ]?U)$/.test(text)) kind = 'pvc'
+    else if (/混凝土|水泥|^(CONCRETE|RCP|RC)$/.test(text)) kind = 'concrete'
+    else if (/铜管|^(COPPER|CU)$/.test(text)) kind = 'copper'
+    return { ...SURFACES[kind], source: typeof material === 'string' ? material : '' }
+}
+
+export function sourceDiameterM(value) {
+    return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value / 1000 : null
+}
+
+/** Same local tangent-plane metric as the detail projection. Source paths are never stretched. */
+export function geographicPipeLength(path) {
+    if (!Array.isArray(path) || path.length < 2) return 0
+    const cosLat = Math.cos(path[0][1] * Math.PI / 180)
+    let length = 0
+    for (let i = 1; i < path.length; i += 1) {
+        length += Math.hypot((path[i][0] - path[i - 1][0]) * 111320 * cosLat, (path[i][1] - path[i - 1][1]) * 111320)
+    }
+    return length
+}

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

@@ -8,8 +8,49 @@ export const PIPE_PALETTE = Object.freeze({
     DRAINAGE_UNKNOWN: { color:'#aab7c4', label:'排水(雨污等级未识别)' },
     UNKNOWN: { color:'#aab7c4', label:'未分类' },
 })
-export const PIPE_DISPLAY = Object.freeze({ diameterScale:8, minDiameterM:1.5, maxDiameterM:3, guideFromM:600, guideWidthPx:4 })
+export const PIPE_DISPLAY = Object.freeze({ diameterScale:1, 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):地图实际会画出的**全部**颜色桶的展示顺序。
+ *
+ * 维护者点名五个(GAS/WATER_SUPPLY/SEWAGE/RAINWATER/MANHOLE);`pipeColorKey()` 对
+ * 雨污等级未识别的排水管与脏行业码还会给出 DRAINAGE_UNKNOWN / UNKNOWN 两桶,
+ * 地图上真的会画出这两种灰(见 `pipeColorKey` 的兜底分支)——按「图例必须覆盖
+ * 地图实际会画出的颜色」判据一并列出,用户看到灰线时含义可直接查到,不静默丢桶。
+ * 顺序表必须与 `PIPE_PALETTE` 的键集合一致(闸门 t48 锁定):调色板加桶而这里没跟上是缺陷。
+ */
+export const PIPE_LEGEND_ORDER = Object.freeze(['GAS','WATER_SUPPLY','SEWAGE','RAINWATER','MANHOLE','DRAINAGE_UNKNOWN','UNKNOWN'])
+
+/** 图例行:色值与词条**引用 `PIPE_PALETTE` 本体**(同一对象),图例与地图逐字节同源,不另抄十六进制。 */
+export function pipeLegendEntries() {
+    return PIPE_LEGEND_ORDER.map((key) => ({ key, color: PIPE_PALETTE[key].color, label: PIPE_PALETTE[key].label }))
+}
+
+/**
+ * 按颜色桶计数(票 48 图例的计数来源)。
+ *
+ * `network` 形状不完整(还没取数 / 取数失败)→ `{ available:false }`,调用方**不得画 0**——
+ * 「后端没给」与「确实是 0」是两件事(票 05 的纪律);计数 0 是真实值(该桶确实没有对象),照常显示。
+ * @param {{pipes?:Array, components?:Array}} [network] `domain/pipeNetwork.js` 的图层形状
+ * @returns {{available: boolean, byKey: object}} `byKey` 只含 `PIPE_LEGEND_ORDER` 的键
+ */
+export function pipeColorCounts(network) {
+    const pipes = network && Array.isArray(network.pipes) ? network.pipes : null
+    const components = network && Array.isArray(network.components) ? network.components : null
+    if (!pipes || !components) return { available: false, byKey: {} }
+    const byKey = {}
+    for (const key of PIPE_LEGEND_ORDER) byKey[key] = 0
+    for (const row of pipes) {
+        const key = pipeColorKey(row)
+        if (Object.prototype.hasOwnProperty.call(byKey, key)) byKey[key] += 1
+    }
+    for (const row of components) {
+        const key = pipeColorKey(row)
+        if (Object.prototype.hasOwnProperty.call(byKey, key)) byKey[key] += 1
+    }
+    return { available: true, byKey }
+}
+
 export function pipeVirtualDepthM(colorKey) {
     return PIPE_VIRTUAL_DEPTH_M[colorKey] ?? 2
 }
@@ -24,7 +65,7 @@ export function pipeColorKey(row) {
     return 'DRAINAGE_UNKNOWN'
 }
 
-/** Transverse enlargement only; max 3m keeps the body under a virtual 2m centre depth. */
+/** Compatibility seam: the rendered diameter is the positive source diameter in metres. */
 export function displayPipeDiameter(diameterM) {
-    return Math.min(PIPE_DISPLAY.maxDiameterM, Math.max(PIPE_DISPLAY.minDiameterM, diameterM * PIPE_DISPLAY.diameterScale))
+    return Number.isFinite(diameterM) && diameterM > 0 ? diameterM : null
 }

+ 3 - 3
src/modules/cockpit/services/cesiumPipeModels.js

@@ -78,7 +78,7 @@ async function groupsFor(viewer, plan, heights, alive) {
     for(const row of rows) {
         const p=row.start||row.position
         const tileX=Math.floor(p[0]/TILE_DEG),tileY=Math.floor(p[1]/TILE_DEG)
-        const key=`${tileX}:${tileY}:${row.kind}:${row.colorKey||'UNKNOWN'}`
+        const key=`${tileX}:${tileY}:${row.kind}:${row.colorKey||'UNKNOWN'}:${row.surface?.kind || 'unknown'}`
         const origin=[(tileX+.5)*TILE_DEG,(tileY+.5)*TILE_DEG]
         const matrix=Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(...origin))
         const inverse=Matrix4.inverseTransformation(matrix,new Matrix4())
@@ -93,7 +93,7 @@ async function groupsFor(viewer, plan, heights, alive) {
             rotation=rotationFromZ(new Cartesian3(...gltfCoordinates(delta)))
             scale=[row.displayDiameterM,row.displayDiameterM,length]
         }
-        if(!groups.has(key)) groups.set(key,{key,kind:row.kind,industry:row.industry,colorKey:row.colorKey||'UNKNOWN',origin,instances:[]})
+        if(!groups.has(key)) groups.set(key,{key,kind:row.kind,industry:row.industry,colorKey:row.colorKey||'UNKNOWN',surface:row.surface,origin,instances:[]})
         groups.get(key).instances.push({id:row.id,translation,rotation,scale})
     }
     // One missing span keeps its entire business pipe's fallback visible.
@@ -134,7 +134,7 @@ export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=i
             const gltf=instanceGltf(template,group.instances)
             // Use a public URL/Resource entry, not Cesium's private options.gltf JSON hook.
             const url=URL.createObjectURL(new Blob([JSON.stringify(gltf)],{type:'model/gltf+json'}))
-            const shader=createPipeModelShader(group.colorKey)
+            const shader=createPipeModelShader(group.colorKey, group.surface)
             let model
             try {
                 model=await Model.fromGltfAsync({url,modelMatrix:Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(...group.origin)),

+ 13 - 16
src/modules/cockpit/services/pipeDetailGeometry.js

@@ -1,7 +1,6 @@
 import * as THREE from 'three'
+import { pipeSurfaceMaterial } from './pipeSurfaceMaterial.js'
 
-const RADIUS = 0.23
-const WALL = 0.035
 const AXIS = new THREE.Vector3(0, 1, 0)
 
 /** Closed annular cross-section revolved around Y: open bore, solid wall and end rims. */
@@ -14,12 +13,8 @@ function sleeve(outer, inner, length) {
     ], 32)
 }
 
-function metal(color, roughness = 0.48) {
-    return new THREE.MeshStandardMaterial({ color, roughness, metalness: 0.42, side: THREE.DoubleSide })
-}
-
-function addMesh(group, name, geometry, color, position = new THREE.Vector3()) {
-    const mesh = new THREE.Mesh(geometry, metal(color))
+function addMesh(group, name, geometry, color, position = new THREE.Vector3(), surface) {
+    const mesh = new THREE.Mesh(geometry, pipeSurfaceMaterial(color, surface))
     mesh.name = name
     mesh.position.copy(position)
     group.add(mesh)
@@ -27,33 +22,35 @@ function addMesh(group, name, geometry, color, position = new THREE.Vector3()) {
 }
 
 /** Decorative dimensions only. The pipe centreline still follows the admitted source path. */
-export function addDetailedPipeSegment(root, start, end, color, { startFlange = true, endFlange = true } = {}) {
+export function addDetailedPipeSegment(root, start, end, color, { startFlange = true, endFlange = true, radius, surface } = {}) {
     const direction = new THREE.Vector3().subVectors(end, start)
     const length = direction.length()
-    if (!Number.isFinite(length) || length < 0.001) return
+    if (!Number.isFinite(length) || length < 0.000001 || !Number.isFinite(radius) || radius <= 0) return
+    const RADIUS = radius
+    const WALL = radius * 0.12
     const segment = new THREE.Group()
     segment.name = 'pipe-segment'
     segment.position.copy(start).add(end).multiplyScalar(0.5)
     segment.quaternion.setFromUnitVectors(AXIS, direction.normalize())
     const inner = RADIUS - WALL
-    addMesh(segment, 'pipe-wall', sleeve(RADIUS, inner, length), color)
+    addMesh(segment, 'pipe-wall', sleeve(RADIUS, inner, length), color, new THREE.Vector3(), surface)
 
-    const flangeDepth = Math.min(0.07, length * 0.15)
+    const flangeDepth = Math.min(radius * 0.3, length * 0.15)
     for (const sign of [-1, 1]) {
         if ((sign === -1 && !startFlange) || (sign === 1 && !endFlange)) continue
         const y = sign * (length / 2 - flangeDepth / 2)
-        addMesh(segment, 'pipe-flange', sleeve(RADIUS * 1.38, inner, flangeDepth), color, new THREE.Vector3(0, y, 0))
+        addMesh(segment, 'pipe-flange', sleeve(RADIUS * 1.38, inner, flangeDepth), color, new THREE.Vector3(0, y, 0), surface)
         for (let i = 0; i < 8; i += 1) {
             const angle = i * Math.PI / 4
-            addMesh(segment, 'pipe-bolt', new THREE.CylinderGeometry(0.025, 0.025, flangeDepth * 1.45, 6),
+            addMesh(segment, 'pipe-bolt', new THREE.CylinderGeometry(radius * 0.1, radius * 0.1, flangeDepth * 1.45, 6),
                 '#b9c5d0', new THREE.Vector3(Math.cos(angle) * RADIUS * 1.17, y, Math.sin(angle) * RADIUS * 1.17))
         }
     }
     const sections = Math.min(8, Math.max(1, Math.ceil(length / 1.2)))
-    const seamDepth = Math.min(0.025, length * 0.04)
+    const seamDepth = Math.min(radius * 0.1, length * 0.04)
     for (let i = 1; i < sections; i += 1) {
         addMesh(segment, 'pipe-seam', sleeve(RADIUS * 1.035, RADIUS, seamDepth), color,
-            new THREE.Vector3(0, -length / 2 + length * i / sections, 0))
+            new THREE.Vector3(0, -length / 2 + length * i / sections, 0), surface)
     }
     root.add(segment)
 }

+ 13 - 6
src/modules/cockpit/services/pipeModelShader.js

@@ -1,15 +1,22 @@
 import { Cartesian3, Color, CustomShader, LightingModel, UniformType } from 'cesium'
 import { PIPE_PALETTE } from '../domain/pipeVisual.js'
+import { pipeSurface } from '../domain/pipePhysical.js'
 
-/** Bright industry colors with view-normal shading; independent of sun/night lighting. */
-export function createPipeModelShader(colorKey) {
+/** Shared source-material reflectance with the established industry tint. */
+export function createPipeModelShader(colorKey, surface = pipeSurface('')) {
     const color = Color.fromCssColorString((PIPE_PALETTE[colorKey] || PIPE_PALETTE.UNKNOWN).color)
     return new CustomShader({
-        lightingModel: LightingModel.UNLIT,
-        uniforms: { u_pipeColor: { type:UniformType.VEC3, value:new Cartesian3(color.red,color.green,color.blue) } },
+        lightingModel: LightingModel.PBR,
+        uniforms: {
+            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 },
+        },
         fragmentShaderText: `void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
-            float shade = 0.72 + 0.28 * abs(normalize(fsInput.attributes.normalEC).z);
-            material.diffuse = czm_srgbToLinear(u_pipeColor) * shade;
+            material.diffuse = czm_srgbToLinear(u_pipeColor);
+            material.metallic = u_pipeMetalness;
+            material.roughness = u_pipeRoughness;
+            material.emissive = czm_srgbToLinear(u_pipeColor) * 0.08;
             material.alpha = 1.0;
         }`,
     })

+ 36 - 0
src/modules/cockpit/services/pipeSurfaceMaterial.js

@@ -0,0 +1,36 @@
+import * as THREE from 'three'
+import { pipeSurface } from '../domain/pipePhysical.js'
+
+/** Industry tint communicates service; reflectance communicates the reported material. */
+export function pipeSurfaceMaterial(color, surface = pipeSurface('')) {
+    const material = new THREE.MeshStandardMaterial({ color, metalness: surface.metalness, roughness: surface.roughness, side: THREE.DoubleSide })
+    material.userData.pipeSurface = surface.kind
+    return material
+}
+
+/** Screen-space inverted hull: highlights the selected model without changing its physical geometry. */
+export function outlineSelectedPipe(root) {
+    const meshes = []
+    root.traverse((node) => { if (node.isMesh) meshes.push(node) })
+    if (!meshes.length) return
+    const material = new THREE.ShaderMaterial({
+        uniforms: { resolution: { value: new THREE.Vector2(1, 1) }, widthPx: { value: 2 }, color: { value: new THREE.Color('#e3fbff') } },
+        side: THREE.BackSide, depthWrite: false, depthTest: true,
+        vertexShader: `uniform vec2 resolution; uniform float widthPx;
+            void main() {
+                vec4 clip = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+                vec2 direction = (projectionMatrix * vec4(normalize(normalMatrix * normal), 0.0)).xy;
+                float magnitude = length(direction);
+                if (magnitude > 0.00001) clip.xy += direction / magnitude * widthPx * 2.0 / resolution * clip.w;
+                gl_Position = clip;
+            }`,
+        fragmentShader: `uniform vec3 color; void main() { gl_FragColor = vec4(color, 1.0); }`,
+    })
+    for (const mesh of meshes) {
+        const outline = new THREE.Mesh(mesh.geometry, material)
+        outline.name = 'selected-pipe-outline'
+        outline.raycast = () => {}
+        outline.renderOrder = 1
+        mesh.add(outline)
+    }
+}

+ 43 - 25
src/modules/cockpit/services/threePipeEnhancement.js

@@ -5,6 +5,8 @@ import { PIPE_PALETTE } from '../domain/pipeVisual.js'
 import { attachSceneFocus } from './sceneInputFocus.js'
 import { addDetailedPipeSegment } from './pipeDetailGeometry.js'
 import { attachPointPick } from './pointPick.js'
+import { pipeSurfaceMaterial, outlineSelectedPipe } from './pipeSurfaceMaterial.js'
+import { sourceDiameterM, pipeSurface } from '../domain/pipePhysical.js'
 
 function defaultRendererFactory({ canvas }) {
     return new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true })
@@ -19,25 +21,13 @@ function project([lon, lat], center, scale) {
     )
 }
 
-function fitScale(path, center) {
-    let extent = 0.01
-    const cosLat = Math.cos((center[1] * Math.PI) / 180)
-    for (const point of path) {
-        const x = Math.abs((point[0] - center[0]) * 111320 * cosLat)
-        const z = Math.abs((point[1] - center[1]) * 111320)
-        extent = Math.max(extent, x, z)
-    }
-    return 2.5 / extent
-}
-
 function disposeObject(root) {
+    const resources = new Set()
     root.traverse((node) => {
-        if (node.geometry) node.geometry.dispose()
-        if (node.material) {
-            const materials = Array.isArray(node.material) ? node.material : [node.material]
-            materials.forEach((material) => material.dispose())
-        }
+        if (node.geometry) resources.add(node.geometry)
+        if (node.material) for (const value of [].concat(node.material)) resources.add(value)
     })
+    for (const resource of resources) resource.dispose()
 }
 
 function material(color, opacity = 1) {
@@ -55,17 +45,25 @@ function buildObject(plan, frame = null) {
             return (min + max) / 2
         })
         : object.position || object.path[0]
-    const scale = frame?.scale ?? fitScale(object.path, center)
+    const scale = 1
     const points = object.path.map((point) => project(point, frame?.center || center, scale))
     const color = (PIPE_PALETTE[object.colorKey] || PIPE_PALETTE.UNKNOWN).color
 
     if (object.renderKind === 'pipe') {
-        for (let i = 1; i < points.length; i += 1) {
+        const diameterM = 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 }))
+            guide.name = 'missing-diameter-guide'
+            guide.computeLineDistances()
+            root.add(guide)
+        }
+        for (let i = 1; diameterM !== null && i < points.length; i += 1) {
             if (object.primary === false) {
                 const delta = new THREE.Vector3().subVectors(points[i], points[i - 1])
                 const length = delta.length()
-                if (length < 0.001) continue
-                const mesh = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.1, length, 12, 1, true), material(color))
+                if (length < 0.000001) continue
+                const mesh = new THREE.Mesh(new THREE.CylinderGeometry(diameterM / 2, diameterM / 2, length, 16, 1, true), pipeSurfaceMaterial(color, surface))
                 mesh.name = 'neighbor-pipe-wall'
                 mesh.position.copy(points[i - 1]).add(points[i]).multiplyScalar(0.5)
                 mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize())
@@ -73,6 +71,8 @@ function buildObject(plan, frame = null) {
             } else addDetailedPipeSegment(root, points[i - 1], points[i], color, {
                 startFlange: i === 1,
                 endFlange: i === points.length - 1,
+                radius: diameterM / 2,
+                surface,
             })
         }
     } else if (object.renderKind === 'manhole') {
@@ -103,6 +103,7 @@ function buildObject(plan, frame = null) {
         if (object.renderKind === 'elbow') mesh.rotation.y = Math.PI / 2
         root.add(mesh)
     }
+    if (object.primary !== false) outlineSelectedPipe(root)
     root.position.y = frame ? ((frame.depthM || 0) - (object.depthM || 0)) * scale : 0
     root.userData = { code: object.code, sourceKind: object.sourceKind, primary: object.primary !== false, clipped: object.clipped === true }
     return root
@@ -116,9 +117,7 @@ function buildNeighborhood(plan) {
         const [min, max] = path.reduce(([lo, hi], p) => [Math.min(lo, p[axis]), Math.max(hi, p[axis])], [Infinity, -Infinity])
         return (min + max) / 2
     })
-    const depths = [...objects, ...(plan.devices || [])].map((item) => item.depthM || 0)
-    const depthSpread = Math.max(5, ...depths.map((depth) => Math.abs(depth - depths[0])))
-    const frame = { center, scale: Math.min(fitScale(path, center), 2.5 / depthSpread), depthM: objects[0].depthM }
+    const frame = { center, scale: 1, depthM: objects[0].depthM }
     const root = new THREE.Group()
     root.name = `pipe-enhancement:${plan.object.code}`
     for (const object of objects) root.add(buildObject({ object }, frame))
@@ -180,6 +179,7 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
     let yaw = 0
     let pitch = Math.atan2(3.8, 7.2)
     let distance = Math.hypot(3.8, 7.2)
+    let sceneRadius = 1
     let drag = null
     const canvas = renderer.domElement || host
     let destroyed = false
@@ -190,7 +190,7 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
         camera.aspect = width / Math.max(1, height)
         camera.updateProjectionMatrix()
         renderer.setSize?.(width, height, false)
-        renderer.render?.(scene, camera)
+        render()
     }
     const render = () => {
         const horizontal = Math.cos(pitch) * distance
@@ -199,6 +199,12 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
             target.y + Math.sin(pitch) * distance,
             target.z + Math.cos(yaw) * horizontal,
         )
+        root?.traverse((node) => {
+            if (node.name === 'selected-pipe-outline') node.material.uniforms.resolution.value.set(host.clientWidth || 320, host.clientHeight || 220)
+        })
+        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)
+        camera.updateProjectionMatrix()
         camera.lookAt(target)
         renderer.render?.(scene, camera)
     }
@@ -210,6 +216,18 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
         }
         root = buildNeighborhood(next)
         scene.add(root)
+        root.updateMatrixWorld(true)
+        const box = new THREE.Box3().setFromObject(root)
+        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
+            camera.near = Math.max(0.0001, sceneRadius / 10000)
+            camera.far = Math.max(100, sceneRadius * 100)
+            camera.updateProjectionMatrix()
+        }
         render()
     }
     const detachFocus = attachSceneFocus(host)
@@ -261,7 +279,7 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
     }
     const onWheel = (event) => {
         event.preventDefault?.()
-        distance = Math.max(3.5, Math.min(12, distance + event.deltaY * 0.004))
+        distance = Math.max(sceneRadius * 0.05, Math.min(sceneRadius * 50, distance * Math.exp(Math.max(-1000, Math.min(1000, event.deltaY)) * 0.001)))
         render()
     }
     canvas.addEventListener?.('pointerdown', onDown)