Explorar el Código

fix(cockpit): keep ground pipe guides and model missing diameters by category (#32)

Kazerin hace 6 días
padre
commit
aa529db131
Se han modificado 26 ficheros con 363 adiciones y 44 borrados
  1. 7 1
      .scratch/cockpit-real-data-wayfinding/issues/32-32.md
  2. 96 0
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/view-mode-live-diagnosis.json
  3. 38 0
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/view-mode-network-level-fix.md
  4. 2 2
      .scratch/cockpit-real-data-wayfinding/verify/t32-adapter-behavior.mjs
  5. 10 0
      .scratch/cockpit-real-data-wayfinding/verify/t32-cesium-bundle-loader.mjs
  6. 5 5
      .scratch/cockpit-real-data-wayfinding/verify/t32-color-layer.mjs
  7. 1 1
      .scratch/cockpit-real-data-wayfinding/verify/t32-diameter-fill.mjs
  8. 15 0
      .scratch/cockpit-real-data-wayfinding/verify/t32-network-level.mjs
  9. 6 3
      .scratch/cockpit-real-data-wayfinding/verify/t32-pipe-model-plan.mjs
  10. 28 0
      .scratch/cockpit-real-data-wayfinding/verify/t32-pipe-view-mode.mjs
  11. 2 0
      .scratch/cockpit-real-data-wayfinding/verify/t32-use-cesium-bundle.mjs
  12. 45 0
      .scratch/cockpit-real-data-wayfinding/verify/t32-view-layer-flow.mjs
  13. 20 0
      .scratch/cockpit-real-data-wayfinding/verify/t32-view-live-diagnosis.mjs
  14. 5 5
      .scratch/cockpit-real-data-wayfinding/verify/t32-visibility-style.mjs
  15. 20 0
      .scratch/cockpit-real-data-wayfinding/verify/t32-visual-default.mjs
  16. 2 0
      CONTEXT.md
  17. 3 3
      public/cockpit/models/pipes/README.md
  18. 1 0
      src/modules/cockpit/components/MapInfoPanel.vue
  19. 5 0
      src/modules/cockpit/domain/pipeDiameterDisplay.js
  20. 11 5
      src/modules/cockpit/domain/pipeModels.js
  21. 1 0
      src/modules/cockpit/domain/pipeNetwork.js
  22. 8 7
      src/modules/cockpit/domain/pipeVisual.js
  23. 12 3
      src/modules/cockpit/services/cesiumPipeLayer.js
  24. 15 8
      src/modules/cockpit/services/cesiumPipeModels.js
  25. 2 0
      src/modules/cockpit/services/mapService.js
  26. 3 1
      tools/pipe-models/README.md

+ 7 - 1
.scratch/cockpit-real-data-wayfinding/issues/32-32.md

@@ -65,7 +65,13 @@ Category: frontend-execution
 - 实际证据:共用驾驶舱入口3840×2160 viewport、地图canvas3790×1930,真实污水近景通过正式相机控件定位,绿/青/蓝管体清晰;燃气红管体像素差分通过,窨井紫色单独截图可见。隔离页4K canvas隐藏Entities/扣背景绿2369像素通过;严格区分隔离与正式画面,详情见[可见性修复与证据](../verify/artifacts/t32/visibility-fix-and-evidence.md)及[共用近景](../verify/artifacts/t32/visibility-shared-cockpit-near.jpg)。临时开发query挂载和public测试页已撤除。
 - 最终已授权验证:前端build退出0(Vite约65s),颜色/备注/显示粗度/导引对象替换、t32模型/补径/404离线回归、t24/t33、t21相机中心保持、t22拾取校核13组脚本通过;后端remark针对性22/22、Cockpit全量488/488通过。
 - 性能尚未通过:原生rAF未加载网也0.987FPS、全量管体0.988FPS、空场景1.015FPS(visible/focused、RTX4060Ti);只能确认当前测量环境约1Hz,具体来源未知,不能当正常桌面GPU性能或满足30FPS。见[数值记录](../verify/artifacts/t32/visibility-performance.json)。浏览器会话反复失效、stop/off参数互斥仍在,未绕过技能。
-- 下一步:恢复有效正常桌面帧调度,在唯一共用驾驶舱入口完成平均FPS≥30、四类最终示例及浏览器模板404画面,全部达标才close、完成report、map追加具名gist。当前claimed/open,不关闭。
+- 用户新反馈(2026-10-14):太常地面近景2D管线消失、城南地面线正常但地下缺少模型。红灯复现:模型ready在所有视图统一隐藏600m内2D线;缺管径路径原模型计划0实例。只读最新几何名称样本统计太常9047条(源8936/推算10/缺101),城南5551条(源523/推算2/缺5026);名称分组仅诊断,不是正式片区字段。
+- 用户确认缺径显示默认:燃气DN160、供水DN400、污水DN800、雨水DN800。已实现display-only `visual-default`,diameterM仍null、源对象不改、不参与双侧插值;network_level异常但排水用共同DN800灰色占位。
+- 用户最新裁定雨污字段为`network_level`:仅DRAINAGE适用,0/旧空值为污水、1雨水;非排水忽略。后端几何接口新增camelCase `networkLevel`透传/排水缺值投影0,不执行表迁移或UPDATE,等待新数据后续按原字段导入。此前remark雨污分类已被此规则替代。
+- 地面视图始终全距保留2D导引;pipeline视图仅对3D batch ready的近景管隐藏导引(600m外仍显示),缺径视觉默认段也可ready并显示3D。管线模式经独立setPipeViewMode更新Cesium层,latest模式贯穿async import/模型ready/error,切换不重建批次。失效/高程失败仍全距导引。
+- 新增回归已红转绿:等级0/1/空值/非法/行业免疫;缺径默认DN/源径保真;地面与地下近景规则;实际Cesium数据源的异步视图切换、ready、model-error、属性更新替换及清层。相关既有T32补径/模型/资源生命周期、T24/T33领域与渲染回归通过。
+- 后端独立提交`7150d431`;后端几何Service+Controller23/23、Cockpit全量489/489通过;不改旧表数据。共享依赖不完整,`@cesium/engine`、`@babel/parser`、`cross-env`缺失。Cesium adapter通过已有Cesium官方bundle验证;需Vue的回归由parser缺失阻断,`yarn build`因cross-env缺失未启动。未安装依赖/改锁文件/启服务器;统一8000页面和本轮截图验证未执行。
+- 此前4K正常桌面FPS≥30、browser404和四类完整画面仍未验收;票保持claimed/open,满足上述门槛前不关闭/不追加完成gist。详细边界见[修复记录](../verify/artifacts/t32/view-mode-network-level-fix.md)。
 
 Labels: ready-for-agent
 

+ 96 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/view-mode-live-diagnosis.json

@@ -0,0 +1,96 @@
+{
+  "at": "2026-10-14T07:49:51.821Z",
+  "note": "名称分组用于复现,不伪造后端district字段;不能据前缀推断正式片区。",
+  "groups": [
+    {
+      "name": "名称含太常",
+      "count": 9047,
+      "diameterSummary": {
+        "reported": 8936,
+        "interpolated": 10,
+        "missing": 101
+      },
+      "modeledBusinessPipes": 9047,
+      "examples": [
+        {
+          "code": "TCRQ202609080071",
+          "name": "太常片区燃气管线",
+          "diameter": null,
+          "remark": "DXF台账"
+        },
+        {
+          "code": "TCWS202609080003",
+          "name": "太常片区污水管线",
+          "diameter": null,
+          "remark": "DXF台账 | 同类相连管段补值"
+        },
+        {
+          "code": "TCWS202609080005",
+          "name": "太常片区污水管线",
+          "diameter": null,
+          "remark": "DXF台账 | 同类相连管段补值"
+        }
+      ]
+    },
+    {
+      "name": "名称含城南",
+      "count": 5551,
+      "diameterSummary": {
+        "reported": 523,
+        "interpolated": 2,
+        "missing": 5026
+      },
+      "modeledBusinessPipes": 5551,
+      "examples": [
+        {
+          "code": "CNYS202609080001",
+          "name": "城南片区雨水管线",
+          "diameter": null,
+          "remark": "DXF台账"
+        },
+        {
+          "code": "CNYS202609080002",
+          "name": "城南片区雨水管线",
+          "diameter": null,
+          "remark": "DXF台账"
+        },
+        {
+          "code": "CNYS202609080003",
+          "name": "城南片区雨水管线",
+          "diameter": null,
+          "remark": "DXF台账"
+        }
+      ]
+    },
+    {
+      "name": "其它名称",
+      "count": 936,
+      "diameterSummary": {
+        "reported": 0,
+        "interpolated": 0,
+        "missing": 936
+      },
+      "modeledBusinessPipes": 936,
+      "examples": [
+        {
+          "code": "CBWS202609080001",
+          "name": "城北片区污水管线",
+          "diameter": null,
+          "remark": "DXF台账"
+        },
+        {
+          "code": "CBYS202609080001",
+          "name": "城北片区雨水管线",
+          "diameter": null,
+          "remark": "DXF台账"
+        },
+        {
+          "code": "CBYS202609080002",
+          "name": "城北片区雨水管线",
+          "diameter": null,
+          "remark": "DXF台账"
+        }
+      ]
+    }
+  ]
+}

+ 38 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/view-mode-network-level-fix.md

@@ -0,0 +1,38 @@
+# 2026-10-14 模式不一致与network_level修复
+
+## 用户报告与根因
+
+- 太常:模型在地下视图可见,但切回地面视图时模型就绪逻辑将2D导引线设为`near=600m`,地下模型又受地表遮挡,故近景可能为空白。
+- 城南:最新只读几何名称分组为5551条,可靠报告管径523条、双侧插值2条、缺径5026条。原建模计划仅对有源/插值管径段建立 spline,因此大量合法城南路径只有2D线。分组只用于定位现象,不宣称是后端正式行政区字段。
+- 后端模型和接口存在network_level字段,旧Cockpit几何响应此前未透传;旧备注值多为“DXF台账”,不能按备注或名称猜雨/污。
+
+## 修复
+
+- 地面视图所有2D导引线均保留;只有管线视图中3D spline完全就绪后才把该管近景导引线限制至600m。地下模型ready则近景改看体积管,未ready或terrain/model失败保持全距导引。
+- 管线视图切换通过独立`setPipeViewMode`即时同步Cesium模型层,无需重取数据或销毁重建GPU批次。异步Cesium模块首次导入时读取最新模式;异步模型ready/error publish也读取当前模式。
+- 后端在Cockpit GeoJSON原样发布`networkLevel`。仅DRAINAGE使用:0/旧空值污水、1雨水;气/供水/窨井忽略等级,异常值保持灰色未知。服务层只投影默认0,不更新/迁移数据库。将来数据导入时会经原层级字段透传。
+- 用户最终显示默认DN:燃气160mm、供水400mm、污水800mm、雨水800mm。未分类排水也可使用共同DN800作为仅显示占位但保留灰色。显示计划`diameterSource=visual-default`、原`diameterM=null`,独立计数;不参与插值锚点,不回写源数据。已有源径和双侧插值优先。
+
+## 验证
+
+已先见证红灯:ground视图模型ready后导引错误得到near=600;缺径模型计划为0条实例;network_level透传测试报JSON缺字段。
+
+红绿回归通过:
+- 用户分类networkLevel、旧空值、非法等级及非排水免疫。
+- 源径/双侧补径优先、缺径默认display DN但源仍null、默认值不污染补径图、未分类排水灰色。
+- Ground/pipeline图层接线覆盖异步首次导入、切换后ready、元数据替换、模型失败、清层;切换不新增模型。
+- 既有Cesium实体颜色差量、模型readiness/error/404 fallback、import取消/clear、T24、T32 spline/补径、T33差量回归通过。
+- 后端针对Geometry Service/Controller 23/23通过;Cockpit全量489/489通过。原始DB对象网络等级及diameter缺值断言未变,旧表数据未修改。
+- Browser Agent Window旧会话仍为不可用/stop互斥;本轮没有进入正式页截图,不能声称新的视觉/GPU验收。
+
+## 环境验证边界
+
+当前共享工作区node_modules不完整:`@cesium/engine`、`@babel/parser`和`cross-env`缺失。回归通过复用已安装Cesium 1.145官方CJS bundle,但涉及Vue链路的那组回归在导入Vue前被缺失@babel/parser阻断;最终`yarn build`未能启动:`cross-env is not recognized`,不是源码编译结果。没有运行依赖安装、没有更改lockfile,也没有启动/重启服务器。
+
+现有记录:
+- `network-level-full-tests.log`:backend Cockpit 489/489。
+- `network-level-final-tests.log`:最终几何接口23/23(包含非管段null等级JSON断言)。后端独立提交`7150d431`,本地未推送。
+- `network-level-build.log`:build启动失败,缺cross-env。
+- `t32-network-level.mjs`、`t32-pipe-view-mode.mjs`、`t32-view-layer-flow.mjs`、`t32-visual-default.mjs`:领域、适配器验证。
+
+票仍claimed/open;此前4K正常桌面FPS≥30、浏览器404与完整样例验收仍未完成。

+ 2 - 2
.scratch/cockpit-real-data-wayfinding/verify/t32-adapter-behavior.mjs

@@ -25,7 +25,7 @@ const viewer={terrainProvider:{},isDestroyed:()=>false,scene:{requestRender(){},
     add(model){added.push(model)},remove(model){removed.push(model);model.destroy()},
 }}}
 const states=[]
-let adapter=createCesiumPipeModels(viewer,{source,onChange:state=>states.push(state),base:'/'})
+let adapter=createCesiumPipeModels(viewer,{source,onChange:state=>states.push(state),base:'/',viewMode:'pipeline'})
 const pipe=(code,lon)=>({code,industry:'GAS',diameter:400,positions:[[lon,28.44],[lon+.00004,28.44]]})
 const network={pipes:[pipe('east',110.391),pipe('west',110.411)],components:[]}
 source.entities.values=network.pipes.map(row=>({id:'pipe:'+row.code,show:true}))
@@ -55,7 +55,7 @@ console.log('PASS real adapter readiness, shared URL, stable metadata, selective
 
 const guide = { id:'pipe:guide', show:true, polyline:{} }
 source.entities.values=[guide]
-adapter=createCesiumPipeModels(viewer,{source,base:'/'})
+adapter=createCesiumPipeModels(viewer,{source,base:'/',viewMode:'pipeline'})
 const guideNetwork={pipes:[pipe('guide',110.451)],components:[]}
 await adapter.sync(guideNetwork)
 added.at(-1).readyEvent.raiseEvent()

+ 10 - 0
.scratch/cockpit-real-data-wayfinding/verify/t32-cesium-bundle-loader.mjs

@@ -0,0 +1,10 @@
+import { createRequire } from 'node:module'
+const require=createRequire(import.meta.url)
+const entry=require.resolve('cesium')
+const cesium=require('cesium')
+const names=Object.keys(cesium).filter(name=>/^[A-Za-z_$][\w$]*$/.test(name))
+const code=`import { createRequire } from 'node:module'; const c=createRequire(${JSON.stringify(import.meta.url)})(${JSON.stringify(entry)});\n`+names.map(name=>`export const ${name}=c.${name};`).join('\n')
+export async function resolve(specifier,context,nextResolve){
+    if(specifier==='cesium')return {url:'data:text/javascript,'+encodeURIComponent(code),shortCircuit:true}
+    return nextResolve(specifier,context)
+}

+ 5 - 5
.scratch/cockpit-real-data-wayfinding/verify/t32-color-layer.mjs

@@ -3,8 +3,8 @@ import { JulianDate } from 'cesium'
 import { syncPipeLayer, clearPipeLayer } from '../../../src/modules/cockpit/services/cesiumPipeLayer.js'
 const sources=[]
 const viewer={dataSources:{getByName:name=>sources.filter(s=>s.name===name),add:async s=>{sources.push(s);return s}}}
-const pipe=(code,industry,remark='')=>({code,industry,remark,diameter:400,positions:[[110.39,28.44],[110.3901,28.44]]})
-const network={pipes:[pipe('gas','GAS'),pipe('water','WATER_SUPPLY'),pipe('sewage','DRAINAGE','污水'),pipe('rain','DRAINAGE','雨水')],components:[{code:'well',industry:'MANHOLE',kind:'other',position:[110.39,28.44],label:''}]}
+const pipe=(code,industry,networkLevel=0)=>({code,industry,networkLevel,diameter:400,positions:[[110.39,28.44],[110.3901,28.44]]})
+const network={pipes:[pipe('gas','GAS',1),pipe('water','WATER_SUPPLY',1),pipe('sewage','DRAINAGE',0),pipe('rain','DRAINAGE',1)],components:[{code:'well',industry:'MANHOLE',kind:'other',position:[110.39,28.44],label:''}]}
 await syncPipeLayer(viewer,network)
 const t=JulianDate.now(),entities=sources[0].entities
 const color=id=>entities.getById(id).polyline.material.color.getValue(t)
@@ -17,9 +17,9 @@ const wellColor=well.point.color.getValue(t)
 assert.ok(wellColor.blue>.8&&wellColor.red>.5&&wellColor.green<.6,'manhole is independent purple')
 assert.equal(well.label.text.getValue(t),'窨井')
 const rainBefore=entities.getById('pipe:rain').polyline
-network.pipes[3]={...network.pipes[3],remark:'污水'}
+network.pipes[3]={...network.pipes[3],networkLevel:0}
 await syncPipeLayer(viewer,network)
-assert.notStrictEqual(entities.getById('pipe:rain').polyline,rainBefore,'remark-only update must recolor')
+assert.notStrictEqual(entities.getById('pipe:rain').polyline,rainBefore,'network-level-only update must recolor')
 assert.ok(color('pipe:rain').green>color('pipe:rain').blue*1.5)
 await clearPipeLayer(viewer)
-console.log('PASS real Cesium source guide colors, special manhole symbol, and remark-only recolor')
+console.log('PASS real Cesium source guide colors, special manhole symbol, and network-level-only recolor')

+ 1 - 1
.scratch/cockpit-real-data-wayfinding/verify/t32-diameter-fill.mjs

@@ -15,7 +15,7 @@ assert.equal(rows[1].diameter,null,'do not overwrite raw business diameter')
 assert.deepEqual(plan.diameterSummary,{reported:2,interpolated:2,missing:0})
 console.log('PASS two-sided linear fill, traceable anchors, immutable raw diameter')
 
-const middle=(pipes,code)=>planPipeModels({pipes,components:[]}).instances.find(row=>row.id==='pipe:'+code)
+const middle=(pipes,code)=>planPipeModels({pipes,components:[]}).instances.find(row=>row.id==='pipe:'+code&&row.diameterSource!=='visual-default')
 const weighted=[pipe('l',0,1,200),pipe('a',1,2,null),pipe('b',2,5,null),pipe('r',5,6,600)]
 assert.ok(Math.abs(middle(weighted,'a').diameterM-.25)<1e-8)
 assert.ok(Math.abs(middle(weighted,'b').diameterM-.45)<1e-8)

+ 15 - 0
.scratch/cockpit-real-data-wayfinding/verify/t32-network-level.mjs

@@ -0,0 +1,15 @@
+import assert from 'node:assert/strict'
+import { toPipeNetwork } from '../../../src/modules/cockpit/domain/pipeNetwork.js'
+import { pipeColorKey } from '../../../src/modules/cockpit/domain/pipeVisual.js'
+import { planPipeModels } from '../../../src/modules/cockpit/domain/pipeModels.js'
+const row=(networkLevel,industry='DRAINAGE',remark='雨水')=>({industry,networkLevel,remark})
+for(const value of [undefined,null,'', '   ',0,'0'])assert.equal(pipeColorKey(row(value)),'SEWAGE','default/zero drainage is sewage even if remark says rain')
+for(const value of [1,'1',' 1 '])assert.equal(pipeColorKey(row(value)),'RAINWATER')
+for(const value of [2,'2',false,true,[],{},'bad'])assert.equal(pipeColorKey(row(value)),'DRAINAGE_UNKNOWN','invalid level must not become sewage/rain')
+for(const industry of ['GAS','WATER_SUPPLY','MANHOLE'])assert.equal(pipeColorKey(row(1,industry)),industry,'level applies to drainage only')
+const feature=(level,alias=false)=>({geometry:{type:'LineString',coordinates:[[110.39,28.44],[110.3901,28.44]]},properties:{code:alias?'snake':'camel',industry:'DRAINAGE',remark:'DXF台账',diameter:null,coordinateValidation:{renderable:true},[alias?'network_level':'networkLevel']:level}})
+const adapted=toPipeNetwork({features:[feature('1'),feature('0',true)]})
+assert.deepEqual(adapted.pipes.map(p=>p.networkLevel),['1','0'])
+assert.deepEqual(planPipeModels(adapted).instances.map(p=>p.colorKey),['RAINWATER','SEWAGE'])
+assert.ok(planPipeModels(adapted).instances.every(p=>p.visualDefaultDiameterM===.8&&p.diameterM===null))
+console.log('PASS network level 0/1, absent default, remark override, non-drainage immunity and API mapping')

+ 6 - 3
.scratch/cockpit-real-data-wayfinding/verify/t32-pipe-model-plan.mjs

@@ -5,13 +5,16 @@ const plan = planPipeModels({ pipes: [
     { code: 'known', industry: 'GAS', diameter: 400, positions: [[110.39, 28.44], [110.3901, 28.44], [110.3901, 28.4401]] },
     { code: 'missing', industry: 'GAS', diameter: null, positions: [[110.39, 28.44], [110.3901, 28.44]] },
 ], components: [] })
-assert.equal(plan.instances.length, 2, 'two original path spans, no invented elbow')
+assert.equal(plan.instances.length, 3, 'original path spans plus missing-diameter display model, no invented elbow')
 assert.deepEqual(plan.instances[0].start, [110.39, 28.44])
 assert.deepEqual(plan.instances[1].end, [110.3901, 28.4401])
 assert.equal(plan.instances[0].diameterM, 0.4)
 assert.equal(plan.virtualDepthM, 2)
-assert.deepEqual(plan.fallbacks, [{ id: 'pipe:missing', reason: '缺管径,保留折线' }])
-console.log('PASS known diameter, exact path and missing diameter fallback')
+assert.deepEqual(plan.fallbacks, [])
+assert.equal(plan.instances[2].diameterM, null)
+assert.equal(plan.instances[2].diameterSource, 'visual-default')
+assert.equal(plan.instances[2].visualDefaultDiameterM, 0.16)
+console.log('PASS known diameter, exact path and missing diameter display placeholder')
 
 const parts = planPipeModels({ pipes: [], components: [
     { code: 'v', kind: 'valve', industry: 'GAS', position: [110.39, 28.44] },

+ 28 - 0
.scratch/cockpit-real-data-wayfinding/verify/t32-pipe-view-mode.mjs

@@ -0,0 +1,28 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { register } from 'node:module'
+register('./t32-adapter-io-loader.mjs',import.meta.url)
+const { createCesiumPipeModels } = await import('../../../src/modules/cockpit/services/cesiumPipeModels.js')
+globalThis.t32Models=[];globalThis.t32TerrainFails=false
+globalThis.window={location:{href:'http://test.invalid/cockpit/',origin:'http://test.invalid'}}
+const nativeFetch=globalThis.fetch
+try {
+    globalThis.fetch=async input=>String(input).startsWith('blob:')?nativeFetch(input):new Response(await readFile(new URL('../../../public/cockpit/models/pipes/spline.gltf',import.meta.url),'utf8'))
+    const source={entities:{values:[{id:'pipe:ready',show:true,polyline:{}},{id:'pipe:no-diameter',show:true,polyline:{}}]}}
+    const viewer={terrainProvider:{},isDestroyed:()=>false,scene:{requestRender(){},primitives:{add(){},remove(model){model.destroy()}}}}
+    const layer=createCesiumPipeModels(viewer,{source,base:'/',viewMode:'ground'})
+    const pipe=(code,diameter)=>({code,diameter,industry:'GAS',positions:[[110.39,28.44],[110.3901,28.44]]})
+    await layer.sync({pipes:[pipe('ready',400),{...pipe('no-diameter',null),industry:'UNKNOWN'}],components:[]})
+    const guides=()=>source.entities.values.map(e=>e.polyline.distanceDisplayCondition.near)
+    assert.deepEqual(guides(),[0,0],'pending objects keep ground guides at all distances')
+    globalThis.t32Models[0].readyEvent.raiseEvent()
+    assert.deepEqual(guides(),[0,0],'ground view must not hide ready pipe guide below 600m')
+    const loads=globalThis.t32Models.length
+    layer.setViewMode('pipeline')
+    assert.deepEqual(guides(),[600,0],'only ready model guides hide close up in underground view')
+    layer.setViewMode('ground')
+    assert.deepEqual(guides(),[0,0],'returning to ground restores all guides immediately')
+    assert.equal(globalThis.t32Models.length,loads,'view changes cannot rebuild GPU batches')
+    layer.destroy()
+    console.log('PASS ground/pipeline guide distances respect current view without model reload')
+} finally {globalThis.fetch=nativeFetch}

+ 2 - 0
.scratch/cockpit-real-data-wayfinding/verify/t32-use-cesium-bundle.mjs

@@ -0,0 +1,2 @@
+import { register } from 'node:module'
+register('./t32-cesium-bundle-loader.mjs',import.meta.url)

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

@@ -0,0 +1,45 @@
+import assert from 'node:assert/strict'
+import { register } from 'node:module'
+import { readFile } from 'node:fs/promises'
+register('./t32-adapter-io-loader.mjs',import.meta.url)
+const { JulianDate }=await import('cesium')
+const {syncPipeLayer,setPipeViewMode,clearPipeLayer}=await import('../../../src/modules/cockpit/services/cesiumPipeLayer.js')
+let modelLoaded
+const modelCreated=new Promise(resolve=>modelLoaded=resolve)
+globalThis.t32Models=[]
+globalThis.t32Models.push=function(model){Array.prototype.push.call(this,model);modelLoaded()}
+globalThis.t32TerrainFails=false
+const nativeFetch=globalThis.fetch
+globalThis.window={location:{href:'http://test.invalid/cockpit/',origin:'http://test.invalid'}}
+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]]}
+ let finish
+ const loaded=new Promise(resolve=>finish=resolve)
+ const states=[]
+ setPipeViewMode(viewer,'pipeline')
+ const syncing=syncPipeLayer(viewer,{pipes:[pipe],components:[]},{onModelChange:s=>{states.push(s);if(s.pending===1)finish()}})
+ setPipeViewMode(viewer,'ground')
+ await syncing;await loaded;await modelCreated;await new Promise(setImmediate)
+ const graphics=()=>sources[0].entities.getById('pipe:async').polyline
+ const near=()=>graphics().distanceDisplayCondition.getValue(JulianDate.now()).near
+ 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)
+ setPipeViewMode(viewer,'pipeline');assert.equal(near(),600)
+ const previous=graphics()
+ await syncPipeLayer(viewer,{pipes:[{...pipe,name:'metadata changed'}],components:[]})
+ assert.notEqual(graphics(),previous)
+ assert.equal(near(),600,'replacement graphics retains underground-only distance rule')
+ setPipeViewMode(viewer,'ground');assert.equal(near(),0)
+ assert.equal(globalThis.t32Models.length,1)
+ globalThis.t32Models[0].errorEvent.raiseEvent(new Error('late asset error'))
+ assert.equal(near(),0)
+ setPipeViewMode(viewer,'pipeline');assert.equal(near(),0,'failed model never suppresses near guide')
+ await clearPipeLayer(viewer)
+ assert.equal(sources[0].entities.values.length,0)
+ console.log('PASS real layer: async mode change, readiness, metadata replacement, failure and clear')
+}finally{globalThis.fetch=nativeFetch}

+ 20 - 0
.scratch/cockpit-real-data-wayfinding/verify/t32-view-live-diagnosis.mjs

@@ -0,0 +1,20 @@
+import { writeFile } from 'node:fs/promises'
+import assert from 'node:assert/strict'
+import { toPipeNetwork } from '../../../src/modules/cockpit/domain/pipeNetwork.js'
+import { planPipeModels } from '../../../src/modules/cockpit/domain/pipeModels.js'
+const response=await fetch('http://192.168.110.96:8301/api/cockpit/v1/infrastructure/geometry')
+const payload=await response.json();assert.equal(response.status,200);assert.equal(payload.code,200)
+const network=toPipeNetwork(payload.data)
+const groups=new Map()
+for(const pipe of network.pipes){
+    const group=pipe.name.includes('城南')?'名称含城南':pipe.name.includes('黔中')?'名称含黔中':pipe.name.includes('太常')?'名称含太常':'其它名称'
+    if(!groups.has(group))groups.set(group,[])
+    groups.get(group).push(pipe)
+}
+const result={at:new Date().toISOString(),note:'名称分组用于复现,不伪造后端district字段;不能据前缀推断正式片区。',groups:[]}
+for(const [name,pipes]of groups){
+    const plan=planPipeModels({pipes,components:[]})
+    result.groups.push({name,count:pipes.length,diameterSummary:plan.diameterSummary,modeledBusinessPipes:new Set(plan.instances.filter(i=>i.kind==='spline').map(i=>i.id)).size,examples:pipes.filter(p=>!Number.isFinite(p.diameter)||p.diameter<=0).slice(0,3).map(p=>({code:p.code,name:p.name,diameter:p.diameter,remark:p.remark}))})
+}
+await writeFile(new URL('./artifacts/t32/view-mode-live-diagnosis.json',import.meta.url),JSON.stringify(result,null,2)+'\n')
+console.log(JSON.stringify(result,null,2))

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

@@ -3,7 +3,7 @@ import { planPipeModels } from '../../../src/modules/cockpit/domain/pipeModels.j
 import { toPipeNetwork } from '../../../src/modules/cockpit/domain/pipeNetwork.js'
 
 const pipe=(industry,remark,diameter=100)=>({code:industry+remark,industry,remark,diameter,positions:[[110.39,28.44],[110.3901,28.44]]})
-const input=[pipe('GAS',''),pipe('WATER_SUPPLY',''),pipe('DRAINAGE','污水'),pipe('DRAINAGE','雨水')]
+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')
@@ -12,7 +12,7 @@ assert.ok(plan.instances.every(row=>row.displayDiameterM<=3),'entire body stays
 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,'污水')
-assert.equal(planPipeModels(adapted).instances[0].colorKey,'SEWAGE','remark governs even when name conflicts')
-assert.equal(planPipeModels({pipes:[pipe('DRAINAGE','雨水 污水')],components:[]}).instances[0].colorKey,'DRAINAGE_UNKNOWN')
-assert.equal(planPipeModels({pipes:[{...pipe('DRAINAGE',''),name:'雨水管'}],components:[]}).instances[0].colorKey,'DRAINAGE_UNKNOWN','never guess from name')
-console.log('PASS five-class semantics from remark, explicit unknown, thick display distinct from source diameter')
+assert.equal(planPipeModels(adapted).instances[0].colorKey,'SEWAGE','missing level defaults to sewage regardless of remark/name')
+assert.equal(planPipeModels({pipes:[{...pipe('DRAINAGE','雨水 污水'),networkLevel:2}],components:[]}).instances[0].colorKey,'DRAINAGE_UNKNOWN')
+assert.equal(planPipeModels({pipes:[{...pipe('DRAINAGE',''),name:'雨水管'}],components:[]}).instances[0].colorKey,'SEWAGE','missing level uses zero, never infer rain from name')
+console.log('PASS five-class semantics from network level, invalid unknown, source diameter separate from display')

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

@@ -0,0 +1,20 @@
+import assert from 'node:assert/strict'
+import { planPipeModels } from '../../../src/modules/cockpit/domain/pipeModels.js'
+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.diameterSummary.missing,4)
+assert.equal(plan.visualDefaultCount,4)
+assert.equal(JSON.stringify(pipes),before,'source stays missing')
+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)
+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')

+ 2 - 0
CONTEXT.md

@@ -36,6 +36,8 @@ _Avoid_: 真实安装模型、用示意构件推断实际连接拓扑
 为使管道示意模型清晰可辨而放大的可视横截面尺寸,与源数据管径、推算管径分别保留;不代表工程尺寸,也不改变管段路径。
 _Avoid_: 真实管径、用显示尺寸回写业务管径
 
+缺少可靠源/推算管径的3D显示可使用DN160燃气/DN400供水/DN800排水作为可读性占位;以visual-default标记,不是测量、推算管径,不参与插值锚点,也不得回写源数据。排水雨污仅按network_level:0污水、1雨水,旧空值按0;其它等级未知时可使用同一DN800几何占位但保持灰色未分类,不猜颜色。
+
 **推算管径**:
 根据同业、端点完全重合且无分叉的连续链两侧已有管径,按缺失段路径中点距离插值得到的示意尺寸;保留两侧来源,不覆盖源数据或声称实测。
 _Avoid_: 实测管径、默认管径、用推算尺寸证明安装拓扑

+ 3 - 3
public/cockpit/models/pipes/README.md

@@ -6,12 +6,12 @@
 
 管体基准为直径1m、长度1m的圆柱,从glTF +Z=0到+Z=1。源/双侧推算管径仍以mm→m保存;显示横截面放大8倍并限制在1.5–3m,这只是可读性示意,长度严格沿原路径。为跟随地形,长路径在原折线上细分为最长约20m的小段,不平滑拐角、不推断安装拓扑。
 
-颜色只遵循统一语义:燃气红、供水蓝、污水绿、雨水青、窨井紫。雨污仅使用后端原样透传的remark备注,缺失或同时含雨水/污水时为灰色未分类。模型材质不依赖太阳光,仍用表面法线提供立体明暗;CSS颜色先转线性RGB再交给Cesium材质,避免被重复提亮。
+颜色只遵循统一语义:燃气红、供水蓝、污水绿、雨水青、窨井紫。雨污仅对排水管按后端networkLevel(表字段network_level)区分:0为污水、1为雨水,缺失/空值默认0;其它值为灰色未分类。燃气、供水和窨井不受此字段影响;名称和备注不再用于雨污推断。模型材质不依赖太阳光,仍用表面法线提供立体明暗;CSS颜色先转线性RGB再交给Cesium材质,避免被重复提亮。
 
-600m外保留4px同色地表导引线,以免远景管体低于像素尺寸后整条路径消失;已建模近景主要显示体积管体。模型失败或尚未就绪时导引线全距离保留。管线视图默认地形和建筑alpha0.18、pitch-35°、距离系数0.2;地面视图仍恢复不透明。
+以视图模式控制遮挡:地面视图在所有距离始终保留4px同色2D导引线;管线视图只有3D实例就绪后才对近景导引线施加600m下限,远景保留导引。未就绪或失败时导引全距离显示。视图切换即时更新可见性,不重建模型批次。管线视图默认地形和建筑alpha0.18、pitch-35°、距离系数0.2;地面视图仍恢复不透明。
 
 构件是标准示意件,最大主体跨度约 1m。它们不代表厂家型号、真实弯曲角度、安装尺寸或真实朝向。构件朝向固定,仅用于类型辨认。模型中心/中心线使用地表采样高程减 **虚拟埋深 2m**;这不是后端实测埋深。
 
-缺少管径时,先在同一行业、端点坐标完全重合且无分叉的连续链上找两侧已有管径;在两个锚点之间按缺失段路径中点的长度比例插值(同值锚点直接沿用)。这属于推算管径,仅用于模型示意,保留两侧编码/管径来源,不覆盖后端源数据;不是安装拓扑的证明。分叉、只有单侧锚点、闭环只有同一个锚点、无锚点或端点仅邻近时均不补,保留折线。
+缺少管径时,先在同一行业、端点坐标完全重合且无分叉的连续链上找两侧已有管径;在两个锚点之间按缺失段路径中点的长度比例插值(同值锚点直接沿用)。这属于推算管径,仅用于模型示意,保留两侧编码/管径来源,不覆盖后端源数据;不是安装拓扑的证明。分叉、只有单侧锚点、闭环只有同一个锚点、无锚点或端点仅邻近时均不补可靠管径。此时仅为显示使用分类默认:燃气DN160、供水DN400、污水/雨水DN800;非法等级排水也使用共同DN800但保持灰色。`diameterM`仍为null、来源标记`visual-default`,单独保留`visualDefaultDiameterM`,再按现有8倍/1.5–3m规则得到最终显示粗度。默认尺寸不参与插值锚点,也不回写数据库。未知行业不猜,继续保留折线。
 
 未知构件类型、地表采样失败或素材加载失败时,保留既有折线/符号。只有模型 `readyEvent` 后才撤下备用表达;后续资源错误恢复备用表达。临时失败可在后续刷新时按 30s 冷却重试。地下显示沿用驾驶舱现有管线视图,不额外改变地形遮挡纪律。

+ 1 - 0
src/modules/cockpit/components/MapInfoPanel.vue

@@ -50,6 +50,7 @@
             折线/符号 {{ pipeModelState.fallback }} 对象
             <template v-if="pipeModelState.diameterSummary">
                 · 管径:源数据 {{ pipeModelState.diameterSummary.reported }} 段 / 双侧推算 {{ pipeModelState.diameterSummary.interpolated }} 段 / 未补 {{ pipeModelState.diameterSummary.missing }} 段
+                · 其中显示默认 {{ pipeModelState.visualDefaultCount || 0 }} 段(源管径仍未知)
             </template>
             <template v-if="pipeModelState.planning || pipeModelState.pending"> · 模型加载中(保留降级表达)</template>
             <template v-if="pipeModelState.failed"> · {{ pipeModelState.failed }} 批素材失败,已降级</template>

+ 5 - 0
src/modules/cockpit/domain/pipeDiameterDisplay.js

@@ -0,0 +1,5 @@
+/** Nominal metres for display only; never a source or interpolated diameter. */
+export const DEFAULT_PIPE_DIAMETER_M = Object.freeze({ GAS:0.16, WATER_SUPPLY:0.4, SEWAGE:0.8, RAINWATER:0.8, DRAINAGE_UNKNOWN:0.8 })
+export function displayDiameterFor(colorKey) {
+    return DEFAULT_PIPE_DIAMETER_M[colorKey] ?? null
+}

+ 11 - 5
src/modules/cockpit/domain/pipeModels.js

@@ -1,10 +1,11 @@
+import { displayDiameterFor } from './pipeDiameterDisplay.js'
 import { resolvePipeDiameters } from './pipeDiameter.js'
 import { displayPipeDiameter, pipeColorKey } 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(示意放大,非工程尺寸),600m外显示彩色导引;虚拟埋深2m(非实测);燃气红、供水蓝、污水绿、雨水青、窨井紫,雨污只认备注,缺失/冲突为灰色;构件固定示意朝向;缺管径仅按同业精确端点无分叉链的双侧源数据和路径长度推算,单侧/无锚点不补;素材/地表失败保留折线或符号。'
+export const PIPE_MODEL_NOTE = '管体示意放大8倍、限制1.5–3m(非工程尺寸);源/双侧推算优先,仍缺管径时仅显示默认:燃气DN160、供水DN400、污水/雨水及雨污未分类排水DN800,未知行业不猜;源管径不回写。地面视图所有距离保留彩色导引,管线视图已建模段600m外保留导引;虚拟埋深2m(非实测);燃气红、供水蓝、污水绿、雨水青、窨井紫,雨污按network_level:0污水、1雨水、缺失按0,其它值未分类;构件固定示意朝向,素材/地表失败保留折线或符号。'
 
 /** 输入只接收 toPipeNetwork 已准入且经模式计划裁剪后的对象,不再推断坐标/拓扑。 */
 export function planPipeModels(network = {}) {
@@ -12,15 +13,20 @@ export function planPipeModels(network = {}) {
     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)
-        if (!diameter) {
-            fallbacks.push({ id, reason: '缺管径,保留折线' })
+        const colorKey = pipeColorKey(pipe)
+        const visualDefaultDiameterM = diameter ? null : displayDiameterFor(colorKey)
+        if (!diameter && visualDefaultDiameterM === null) {
+            fallbacks.push({ id, reason: '缺管径且分类未明确,保留折线' })
             continue
         }
+        if (!diameter) visualDefaultCount += 1
+        const diameterM = diameter ? diameter.diameterMm / 1000 : null
         for (let i = 1; i < pipe.positions.length; i += 1) {
-            instances.push({ id, kind: 'spline', industry: pipe.industry, colorKey:pipeColorKey(pipe), start: pipe.positions[i - 1], end: pipe.positions[i], diameterM: diameter.diameterMm / 1000, displayDiameterM:displayPipeDiameter(diameter.diameterMm / 1000), diameterSource:diameter.source, diameterAnchors:diameter.anchors })
+            instances.push({ id, kind: 'spline', industry: pipe.industry, colorKey, start: pipe.positions[i - 1], end: pipe.positions[i], diameterM, displayDiameterM:displayPipeDiameter(diameterM ?? visualDefaultDiameterM), diameterSource:diameter?.source || 'visual-default', diameterAnchors:diameter?.anchors || [], visualDefaultDiameterM })
         }
     }
     for (const component of network.components || []) {
@@ -37,5 +43,5 @@ export function planPipeModels(network = {}) {
         const diameter = diameters.get(pipe)
         diameterSummary[diameter ? diameter.source : 'missing'] += 1
     }
-    return { instances, fallbacks, diameterSummary, virtualDepthM: PIPE_MODEL_POLICY.virtualDepthM }
+    return { instances, fallbacks, diameterSummary, visualDefaultCount, virtualDepthM: PIPE_MODEL_POLICY.virtualDepthM }
 }

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

@@ -180,6 +180,7 @@ export function toPipeNetwork(payload) {
                 code: id,
                 name: String(properties.name || ''),
                 remark: typeof properties.remark === 'string' ? properties.remark : '',
+                networkLevel: properties.networkLevel ?? properties.network_level ?? 0,
                 industry,
                 material: String(properties.material || ''),
                 diameter: diameterOf(properties.diameter),

+ 8 - 7
src/modules/cockpit/domain/pipeVisual.js

@@ -1,22 +1,23 @@
-/** Visual semantics only; original industry, diameter and remark remain business data. */
+/** Display semantics; original industry, diameter, networkLevel and remark remain unchanged. */
 export const PIPE_PALETTE = Object.freeze({
     GAS: { color:'#ff3b52', label:'燃气' },
     WATER_SUPPLY: { color:'#168bff', label:'供水' },
     SEWAGE: { color:'#29e36c', label:'污水' },
     RAINWATER: { color:'#00e5ff', label:'雨水' },
     MANHOLE: { color:'#b36bff', label:'窨井' },
-    DRAINAGE_UNKNOWN: { color:'#aab7c4', label:'排水(备注未明确雨污)' },
+    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 function pipeColorKey(row) {
     if (row.industry !== 'DRAINAGE') return ['GAS','WATER_SUPPLY','MANHOLE'].includes(row.industry) ? row.industry : 'UNKNOWN'
-    const remark = typeof row.remark === 'string' ? row.remark.trim() : ''
-    const rain = /雨水/i.test(remark)
-    const sewage = /污水/i.test(remark)
-    if (rain === sewage) return 'DRAINAGE_UNKNOWN'
-    return rain ? 'RAINWATER' : 'SEWAGE'
+    const raw = row.networkLevel
+    const level = raw == null || (typeof raw === 'string' && raw.trim() === '') ? '0'
+        : typeof raw === 'number' || typeof raw === 'string' ? String(raw).trim() : ''
+    if (level === '0') return 'SEWAGE'
+    if (level === '1') return 'RAINWATER'
+    return 'DRAINAGE_UNKNOWN'
 }
 
 /** Transverse enlargement only; max 3m keeps the body under a virtual 2m centre depth. */

+ 12 - 3
src/modules/cockpit/services/cesiumPipeLayer.js

@@ -20,6 +20,7 @@ import { PIPE_DISPLAY, PIPE_PALETTE, pipeColorKey } from '../domain/pipeVisual.j
 // Viewer-scoped batches; loaded lazily so symbol-only/headless paths do not load the model adapter.
 const modelLayers = new WeakMap()
 const layerVersions = new WeakMap()
+const pipeViewModes = new WeakMap()
 let modelAdapterPromise = null
 
 /** 与 `cesiumEngine.js` 的 `PIPELINE_SOURCE` 同值。 */
@@ -29,7 +30,7 @@ function visualColor(row) {
     return Color.fromCssColorString(PIPE_PALETTE[pipeColorKey(row)].color)
 }
 
-/** 构件只决定形状尺寸与标签;颜色统一由行业/备注决定。 */
+/** 构件只决定形状尺寸与标签;颜色统一由行业/networkLevel决定。 */
 const COMPONENT_STYLE = {
     valve: { label: '阀门', size: 9 },
     elbow: { label: '角管道', size: 7 },
@@ -167,7 +168,7 @@ export async function syncPipeLayer(viewer, network, { onModelChange = () => {}
         entityIndex.delete(id)
     }
 
-    // Pending/failed objects keep guides; ready pipes retain only distant guides and show close-up volume.
+    // Ground keeps guides at all distances; only ready pipeline-view models use distant guides.
     // The model adapter controls visibility/distance without reassigning unchanged geometry.
     if (viewer.scene && viewer.scene.primitives && !viewer.isDestroyed()) {
         modelAdapterPromise ||= import('./cesiumPipeModels.js')
@@ -175,7 +176,7 @@ export async function syncPipeLayer(viewer, network, { onModelChange = () => {}
         if (viewer.isDestroyed() || layerVersions.get(viewer) !== version) return { pipes: 0, components: 0, rendered: false }
         let layer = modelLayers.get(viewer)
         if (!layer) {
-            layer = createCesiumPipeModels(viewer, { source, onChange: onModelChange })
+            layer = createCesiumPipeModels(viewer, { source, onChange: onModelChange, viewMode:pipeViewModes.get(viewer)||'ground' })
             modelLayers.set(viewer, layer)
         }
         layer.sync({ pipes, components })
@@ -183,6 +184,14 @@ export async function syncPipeLayer(viewer, network, { onModelChange = () => {}
     return { pipes: pipes.length, components: components.length, rendered: true }
 }
 
+/** View state is retained even before the asynchronous model adapter finishes loading. */
+export function setPipeViewMode(viewer, mode) {
+    if (!viewer) return
+    const next=mode==='pipeline'?'pipeline':'ground'
+    pipeViewModes.set(viewer,next)
+    modelLayers.get(viewer)?.setViewMode(next)
+}
+
 /** 引导态/退出驾驶舱时清空并忘记索引(换 Viewer 后索引必须重建,否则张冠李戴)。 */
 export async function clearPipeLayer(viewer) {
     if (viewer) layerVersions.set(viewer, (layerVersions.get(viewer) || 0) + 1)

+ 15 - 8
src/modules/cockpit/services/cesiumPipeModels.js

@@ -100,12 +100,13 @@ async function groupsFor(viewer, plan, heights, alive) {
     return { groups:[...groups.values()], missing }
 }
 
-export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=import.meta.env?.BASE_URL||'/' }={}) {
+export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=import.meta.env?.BASE_URL||'/', viewMode='ground' }={}) {
     const heights=new Map()
     const guideStates=new WeakMap()
     let active=true,generation=0,fingerprint='',missing=new Set(),allIds=[],task=null,planning=false
     let lastGroups=[], signatures=new Map(), terrainFailed=false, lastTerrainAttempt=0
-    let diameterSummary={reported:0,interpolated:0,missing:0}
+    let diameterSummary={reported:0,interpolated:0,missing:0}, visualDefaultCount=0
+    let pipelineView=viewMode==='pipeline'
     const publish=(state)=>{
         if(!active) return
         const covered=new Set(state.covered.filter((id)=>!missing.has(id)))
@@ -113,16 +114,17 @@ export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=i
             const modeled=covered.has(entity.id)
             if(entity.polyline) {
                 if(entity.show!==true) entity.show=true
-                if(guideStates.get(entity.polyline)!==modeled) {
-                    entity.polyline.distanceDisplayCondition=modeled?FAR_GUIDE:ALL_DISTANCES
-                    guideStates.set(entity.polyline,modeled)
+                const distantOnly=pipelineView&&modeled
+                if(guideStates.get(entity.polyline)!==distantOnly) {
+                    entity.polyline.distanceDisplayCondition=distantOnly?FAR_GUIDE:ALL_DISTANCES
+                    guideStates.set(entity.polyline,distantOnly)
                 }
             } else {
-                const show=!modeled
+                const show=!pipelineView||!modeled
                 if(entity.show!==show) entity.show=show
             }
         }
-        onChange({...state,covered:[...covered],fallback:allIds.length-covered.size,planning,terrainFailed,diameterSummary,note:PIPE_MODEL_NOTE})
+        onChange({...state,covered:[...covered],fallback:allIds.length-covered.size,planning,terrainFailed,diameterSummary,visualDefaultCount,viewMode:pipelineView?'pipeline':'ground',note:PIPE_MODEL_NOTE})
         viewer.scene.requestRender()
     }
     const batches=createPipeModelBatches({
@@ -159,6 +161,7 @@ export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=i
         if (!active || viewer.isDestroyed()) return Promise.resolve()
         const plan=planPipeModels(network)
         diameterSummary=plan.diameterSummary
+        visualDefaultCount=plan.visualDefaultCount
         const next=JSON.stringify(plan)
         if(next===fingerprint && !(terrainFailed && Date.now()-lastTerrainAttempt>=30000)) {
             if (planning) return task
@@ -199,5 +202,9 @@ export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=i
         batches.clear()
         heights.clear()
     }
-    return {sync,destroy,state:batches.state}
+    function setViewMode(mode) {
+        pipelineView=mode==='pipeline'
+        publish(batches.state())
+    }
+    return {sync,setViewMode,destroy,state:batches.state}
 }

+ 2 - 0
src/modules/cockpit/services/mapService.js

@@ -1204,6 +1204,7 @@ export function setViewMode(mode, overrides = {}) {
 
     if (instance.engine && instance.viewer) {
         instance.engine.applyViewModeLayers(instance.viewer, instance.tilesetInstances || [], plan)
+        instance.pipeLayer?.setPipeViewMode(instance.viewer, plan.mode)
     }
     if (isDomHost(instance.host)) {
         instance.host.dataset.mapViewMode = plan.mode
@@ -2446,6 +2447,7 @@ export async function syncPipeLayerForPlan(plan) {
     const engine = await import('./cesiumPipeLayer.js')
     if (instance !== record || !record.viewer) return false
     record.pipeLayer = engine
+    engine.setPipeViewMode(record.viewer, viewMode)
     await engine.syncPipeLayer(record.viewer, layer, { onModelChange: (state) => {
         if (instance !== record || !record.host) return
         const host = record.host

+ 3 - 1
tools/pipe-models/README.md

@@ -6,7 +6,9 @@
 
 验收项目:
 
-- 按真实备注区分管体颜色;燃气红、供水蓝、污水绿、雨水青。窨井作为独立紫色符号核验,不把上一个管体的像素统计归到窨井。
+- 对排水管按networkLevel分类:0/缺失为污水、1为雨水,非法值为灰色未知;备注/名称不参与。燃气红、供水蓝、污水绿、雨水青,非排水行业不受等级影响。窨井作为独立紫色符号核验,不把上一个管体的像素统计归到窨井。
+- 地面视图靠近时必须保留全部2D线;只在管线视图模型完整就绪后对其近景导引设600m下限。返回地面、失败或加载中立即恢复全距离;切换不重新创建GPU批次。
+- 缺源/可靠插值管径的显示默认:燃气DN160、供水DN400、排水DN800。来源为visual-default、diameterM仍null,源径缺失和显示占位计数分别保留。
 - 近景:显示粗度是源/推算管径×8、限制1.5–3m,非工程尺寸。保存实际近景截图。只看ready/batch计数不能证明可见。
 - 像素:关闭Entity导引线,再比较glTF模型隐藏/显示前后的同一视角帧,扣除背景;确认目标色像素。记录viewport和实际地图canvas,两者可能不同。
 - 远景:600m外出现同色4px导引线;元数据变化导致PolylineGraphics替换后,距离限制应恢复。