Procházet zdrojové kódy

fix(cockpit): make 3D pipes visible with semantic colors (#32)

Kazerin před 6 dny
rodič
revize
e518ba0f4c
22 změnil soubory, kde provedl 515 přidání a 58 odebrání
  1. 32 1
      .scratch/cockpit-real-data-wayfinding/issues/32-32.md
  2. binární
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/visibility-4k-real-pipes.jpg
  3. 43 0
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/visibility-fix-and-evidence.md
  4. 34 0
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/visibility-performance.json
  5. binární
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/visibility-shared-cockpit-near.jpg
  6. binární
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/visibility-shared-manhole-purple.jpg
  7. 13 0
      .scratch/cockpit-real-data-wayfinding/verify/t32-adapter-behavior.mjs
  8. 25 0
      .scratch/cockpit-real-data-wayfinding/verify/t32-color-layer.mjs
  9. 18 0
      .scratch/cockpit-real-data-wayfinding/verify/t32-visibility-style.mjs
  10. 4 0
      CONTEXT.md
  11. 5 1
      public/cockpit/models/pipes/README.md
  12. 11 0
      src/modules/cockpit/components/MapInfoPanel.vue
  13. 4 3
      src/modules/cockpit/domain/pipeModels.js
  14. 1 0
      src/modules/cockpit/domain/pipeNetwork.js
  15. 25 0
      src/modules/cockpit/domain/pipeVisual.js
  16. 8 8
      src/modules/cockpit/domain/viewModes.js
  17. 17 24
      src/modules/cockpit/services/cesiumPipeLayer.js
  18. 26 11
      src/modules/cockpit/services/cesiumPipeModels.js
  19. 16 0
      src/modules/cockpit/services/pipeModelShader.js
  20. 16 10
      tools/pipe-models/README.md
  21. 126 0
      tools/pipe-models/cockpitAcceptance.js
  22. 91 0
      tools/pipe-models/visibility.html

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

@@ -59,10 +59,41 @@ Category: frontend-execution
 - 当前障碍:browser工具schema要求同时提供互斥的off/device/尺寸、session/requestId等字段;无法去掉移动模拟或清理会话,后续会话失去注册并达5个上限。snapshot/press/screenshot仅取得部分证据,没有绕过技能用外部进程控制。
 - 自动续跑Round1:提交`5f091eb`,真实Cesium数学/事件加IO替身的adapter回归通过(404保留符号、地表失败原因、模板只取一次/跨批共享URL、metadata零重建、改径仅换一批、late error及destroy)。此为离线证据,不替代浏览器。
 - 自动续跑Round2:工具重载后stop仍报session/requestId互斥;静态确认插件只将action设required,模型收到的工具契约却要求全部字段。已形成[可复现诊断](../verify/artifacts/t32/browser-tool-parameter-conflict.md),未修改插件/绕过浏览器技能;剩余4K/FPS及故障画面条件不变。
-- 下一步:恢复browser工具可选参数/有效会话后按隔离README完成实测,全部达标才close、完成report、map追加具名gist。当前claimed/open,不关闭。
+- 用户可见性反馈(2026-10-14):指定燃气红/供水蓝/污水绿/雨水青/窨井紫,允许比例放大、可读性优先;雨污严格通过remark备注区分,不用名称或编码。后端`1d1461a5`原样透传remark/显式null(本地未推送;分支分叉提醒保留),真实备注为2526雨水+1209污水,无未分类排水。
+- 本轮可见性已实施:横向显示粗度8倍、限制1.5–3m,源/推算径和路径长度不变,中心线仍虚拟埋深2m;明亮UNLIT语义材质,sRGB转线性后法线明暗;600m外同色4px导引,近景体积,未就绪/失败全距导引;管线视图pitch-35、range×0.2、地形/建筑alpha0.18。窨井独立紫色符号/标签,信息面板加入五色图例和示意说明。
+- 对抗式复核修掉:metadata更新替换PolylineGraphics后600m距离限制丢失;CSS sRGB直接写线性材质颜色漂白。另实测GLSL1 varying在WebGL2导致调色shader停止渲染,改in恢复,提交只选此行,其他建筑/图标改动保留。
+- 实际证据:共用驾驶舱入口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,不关闭。
 
 Labels: ready-for-agent
 
 ## Comments
 
+### local — 2026-10-14T04:10:23.357Z
+> *This was generated by AI during triage.*
+
+**提醒一句(来自票 35 会话,2026-10-14):后端 `a96091ce` 仍未推送,且本分支基底已变**
+
+本会话在做[票 35 后端修复](../../cockpit-real-data-wayfinding/issues/35-createdat.md)时动到了同一个后端仓库 `E:\pipe-ner-server`,只此一件事需要你们知道(**未代你们提交或推送任何东西**):
+
+**现状**
+
+| 项 | 值 |
+| --- | --- |
+| 后端 `local master` | **`a96091ce`**(= 你们那条「透传管径原值/显式 null」,见本票进度第 5 条) |
+| 后端 `origin/master` | **`05c23774`**(我的票 35 提交,落在远端**较新**基底 `5a821872` 上) |
+| 两者关系 | **已分叉**:`a96091ce` 与 `origin/master` 均非对方祖先,`local master` 落后远端 5 个提交 |
+
+**要点**
+
+1. **什么都没丢**:`a96091ce` 仍是有效对象(`git cat-file -t` = commit);你们那 4 个几何源文件 + `.idea` 的**未提交在制品完好无损**(我按字节校验过,`git hash-object` 与暂存时逐一致)。
+2. **但不要直接 `git push`**:现在从 `local master` 直推会被拒(non-fast-forward)。请先 `git fetch`,再以 `origin/master` 为基变基(或 `reset --hard origin/master` 后重新 cherry-pick `a96091ce`),**保留你们的在制品**再推。
+3. **`a96091ce` 仍只存在于本机**:它不在任何远端分支上(与我会话开始时一致)——所以「远端缺管径透传」这件事仍未解决,随你们推送才会解决。
+4. **期间我做的唯一分支操作**:为把票 35 推到远端,我 `rebase` 时它曾把 `a96091ce` 一并带上;我随即 `reset` 回 `origin/master` 只 cherry-pick 我自己的提交,再把分支 tip **复位成会话开始时的 `a96091ce`** —— 即 `local master` 与我会话开始时**完全一致**,未改动你们的分支语义。
+
+**注意**:远端基底在我会话前就已前进(含「移动端-工单管理」等提交),所以分叉**不是我造成的**;我只是在远端新基底上多加了 1 个提交。你们推 `a96091ce` 时本来也需要先变基。
+
+
 ## Answer

binární
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/visibility-4k-real-pipes.jpg


+ 43 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/visibility-fix-and-evidence.md

@@ -0,0 +1,43 @@
+# 管段可见性修复与实际证据
+
+状态:用户“看不见、看不清楚”的近景问题已取得修复后的真实驾驶舱画面;原票要求的平均FPS≥30、四类最终样例和浏览器模板404仍未全部完成,票保持claimed/open。
+
+## 修复
+
+- 五类语义色:燃气红、供水蓝、污水绿、雨水青、窨井紫。雨污严格读取用户指定的 `remark`;缺失/同时包含两类时为灰色,不按名称或编码猜。
+- 后端几何响应原样透传 `remark`,显式null,不写数据库、不改几何准入。真实排水3735条备注已加载,雨水2526、污水1209。
+- 源/双侧推算管径保持原值;只将显示粗度放大8倍并限制在1.5–3m,轴向长度与坐标不变,中心线仍地表采样减虚拟埋深2m。
+- 管体用UNLIT自定义材质、表面法线明暗;CSS sRGB先转线性RGB,避免双重提亮导致颜色漂白。材质随模型释放。
+- 600m外保留4px同色地表导引线,近景以已就绪体积模型为主;缺素材/地表失败仍全距离保留折线/符号。PolylineGraphics被metadata更新替换时,距离限制重新恢复,相关红灯回归已修复。
+- 管线视图默认pitch-35°、距离×0.2、地形/建筑alpha0.18;地面视图仍恢复不透明,中心与heading保持。
+- 另实测发现scene-grade shader使用GLSL1 `varying` 使WebGL2停止渲染,改为 `in` 后共用页正常渲染。此文件有其他会话建筑/图标在制品,提交仅选择该一行。
+
+## 画面与像素(分清来源)
+
+项目共用入口由最新 AGENTS.md 规定,后续只使用该入口,不另起服务器。本轮临时开发验收控件挂在同一驾驶舱,测试后从生产服务代码撤除。
+
+| 证据 | 实际范围 |
+|---|---|
+| [visibility-4k-real-pipes.jpg](visibility-4k-real-pipes.jpg) | **更新共用入口规则前**的独立隔离Viewer,真实全量管线4480实例/24语义色批,3840×2160 canvas;隐藏Entities并扣除模型关闭时背景,污水green2369、rain/cyan162015,像素通过。图片为截图工具自动下采样预览,不能用于重新统计4K像素。 |
+| [visibility-shared-cockpit-near.jpg](visibility-shared-cockpit-near.jpg) | **共用驾驶舱正式Viewer**、viewport3840×2160,实际地图canvas3790×1930;镜头走正式cameraControl.applyViewPreset,因此不会被生产循环拉回。截图真实污水近景绿、雨水青、供水蓝三维管体清晰;材质已修正sRGB。 |
+| 共用页污水像素 | 最初直接写Viewer的临时控件被正式镜头覆盖,结果green491虽然检测到管体,但不能当近景定位成功。随后改用正式相机接口并拿到上述近景截图。 |
+| 共用页燃气像素 | 目标TCRQ202609080008(remark燃气),正式相机近景,隐藏导引与背景差分,GAS106654;全视野其它管体也参与统计,所以这是红色真实管体存在/可见证据,不是仅目标实例的尺寸测量。未保存同一目标截图,不能把后续窨井截图称为燃气截图。 |
+| [visibility-shared-manhole-purple.jpg](visibility-shared-manhole-purple.jpg) | 实际目标YL-YJ-003的独立紫色窨井点符号与“窨井”标签。该对象不是glTF,之前残留的燃气像素读数不适用于窨井;紫色只按这张实际画面和real-Cesium符号回归核验。 |
+
+## 性能实测与未完成
+
+- 隔离4K管体+真实地形运动45°/10s:约0.99 FPS;隐藏模型/符号并关闭地形后的空場景约1.015 FPS;页面visible/focused未丢焦点。
+- 同一环境原生requestAnimationFrame(未加载管网)6帧/5.0667s,约0.987 FPS。由此可确定浏览器帧调度本身只有约1Hz,不能把这一数值解释为管体GPU瓶颈,也不能宣布性能达标。
+- 环境GPU字符串:ANGLE/NVIDIA GeForce RTX 4060 Ti/Direct3D11,hardwareConcurrency20。工具强制带设备preset,虽然width/height覆盖后actual viewport和canvas为4K,userAgent仍为iPad、devicePixelRatio约2。
+- 恢复正常桌面帧调度后应在共用正式Viewer重新测平均FPS≥30、P95/最大帧间隔,记录focus/visibility/viewport/canvas。当前未通过,不降低门槛。
+- 四类glTF隔离样例与模板404浏览器画面仍待完整验收;离线adapter 404/error恢复符号与真实四色显示已分别通过,不混为全票通过。
+
+## 验证
+
+- 新颜色/备注/显示粗度/窨井/仅备注重绘回归通过;t32-adapter新增“PolylineGraphics替换后600m限制仍恢复”通过。
+- 既有t32补径、ready/error/清理、t24/t33和t21视图中心保持回归继续通过。
+- 后端remark回归先因缺键红灯,再最小透传修复后几何22/22通过;最终Cockpit全量488/488通过。备注透传提交`1d1461a5`,仍本地未推送;后端分支与origin/master分叉的提醒保留,不进行未经要求的push/重置。
+- 本次明确授权下最终前端构建退出0(Vite约65s),临时验收挂载与public测试页已撤除,13组直接相关回归脚本全部通过。未另起新的共用服务,保留用户管理的8000入口。
+- 数值证据见[visibility-performance.json](visibility-performance.json),acceptancePassed=false;约1Hz调度的具体来源尚未确定,不声称已证明浏览器限帧设置。
+
+下一步:恢复正常桌面browser_*会话和帧调度,完成性能/404/四类最终验收,达标才close并追加map gist。所有临时注入都从正式代码移除,其他会话工作树与暂存文件保留。

+ 34 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/visibility-performance.json

@@ -0,0 +1,34 @@
+{
+  "measurementScope": "BrowserSkill Agent Window; isolated real-data viewer before shared-entry rule update",
+  "viewport": [3840,2160],
+  "canvas": [3840,2160],
+  "motion": "45deg heading / 10s",
+  "fullSceneFps": 0.9877323640386219,
+  "fullSceneP95FrameMs": 1066.7999999998137,
+  "emptyScene": {
+    "frames": 10,
+    "seconds": 8.8675,
+    "fps": 1.0149422046800114,
+    "p95FrameMs": 1020.1999999997206,
+    "visibility": "visible",
+    "focusStart": true,
+    "focusLost": false,
+    "hidden": false
+  },
+  "nativeRafBeforeNetworkLoad": {
+    "frames": 6,
+    "seconds": 5.066699999999982,
+    "fps": 0.9868356129236027,
+    "visibility": "visible",
+    "focused": true
+  },
+  "renderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 4060 Ti (0x00002803) Direct3D11 vs_5_0 ps_5_0, D3D11)",
+  "hardwareConcurrency": 20,
+  "devicePixelRatio": 2.0000000298023224,
+  "userAgentWasEmulated": true,
+  "targetFps": 30,
+  "acceptancePassed": false,
+  "interpretation": "Observed approximately 1Hz native frame scheduling and empty-scene rendering. The source of throttling was not established; these measurements do not demonstrate normal-desktop GPU performance or satisfy the FPS target.",
+  "sharedCockpitFpsVerified": false,
+  "browser404Verified": false
+}

binární
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/visibility-shared-cockpit-near.jpg


binární
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/visibility-shared-manhole-purple.jpg


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

@@ -53,6 +53,19 @@ adapter.destroy()
 assert.ok(added.every(model=>model.destroyed))
 console.log('PASS real adapter readiness, shared URL, stable metadata, selective update, late error and cleanup')
 
+const guide = { id:'pipe:guide', show:true, polyline:{} }
+source.entities.values=[guide]
+adapter=createCesiumPipeModels(viewer,{source,base:'/'})
+const guideNetwork={pipes:[pipe('guide',110.451)],components:[]}
+await adapter.sync(guideNetwork)
+added.at(-1).readyEvent.raiseEvent()
+assert.equal(guide.polyline.distanceDisplayCondition.near,600)
+guide.polyline={}
+await adapter.sync({pipes:[{...guideNetwork.pipes[0],name:'metadata-only'}],components:[]})
+assert.equal(guide.polyline.distanceDisplayCondition?.near,600,'replaced polyline must retain near-model/far-guide rule')
+adapter.destroy()
+console.log('PASS replacement guide graphics receive distance restriction again')
+
 const terrainStates=[]
 globalThis.t32TerrainFails=true
 source.entities.values=[{id:'pipe:terrain',show:true}]

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

@@ -0,0 +1,25 @@
+import assert from 'node:assert/strict'
+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:''}]}
+await syncPipeLayer(viewer,network)
+const t=JulianDate.now(),entities=sources[0].entities
+const color=id=>entities.getById(id).polyline.material.color.getValue(t)
+assert.ok(color('pipe:gas').red>color('pipe:gas').green*2,'gas visible guide must be red')
+assert.ok(color('pipe:water').blue>color('pipe:water').red*2)
+assert.ok(color('pipe:sewage').green>color('pipe:sewage').blue*1.5)
+assert.ok(color('pipe:rain').blue>.8&&color('pipe:rain').green>.8)
+const well=entities.getById('component:well')
+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:'污水'}
+await syncPipeLayer(viewer,network)
+assert.notStrictEqual(entities.getById('pipe:rain').polyline,rainBefore,'remark-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')

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

@@ -0,0 +1,18 @@
+import assert from 'node:assert/strict'
+import { planPipeModels } from '../../../src/modules/cockpit/domain/pipeModels.js'
+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 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')
+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')

+ 4 - 0
CONTEXT.md

@@ -32,6 +32,10 @@ _Avoid_: 监测点、把设施点并入监测点统计、把聚合 KPI 当设施
 依真实管段路径或真实构件类型与位置呈现的体积表达;标准构件外形、无来源的尺寸与安装朝向均为示意,不等于工程竣工模型。
 _Avoid_: 真实安装模型、用示意构件推断实际连接拓扑
 
+**显示粗度**:
+为使管道示意模型清晰可辨而放大的可视横截面尺寸,与源数据管径、推算管径分别保留;不代表工程尺寸,也不改变管段路径。
+_Avoid_: 真实管径、用显示尺寸回写业务管径
+
 **推算管径**:
 根据同业、端点完全重合且无分叉的连续链两侧已有管径,按缺失段路径中点距离插值得到的示意尺寸;保留两侧来源,不覆盖源数据或声称实测。
 _Avoid_: 实测管径、默认管径、用推算尺寸证明安装拓扑

+ 5 - 1
public/cockpit/models/pipes/README.md

@@ -4,7 +4,11 @@
 
 运行 `node tools/pipe-models/build-assets.mjs` 可重建。每类 glTF 引用同名 `.bin` 几何缓冲;运行时只请求一次模板,再生成局部 TRS 实例流。同类空间批次引用稳定的几何 URL,以复用 Cesium 资源缓存,不为每个构件独立加载模型。
 
-管体基准为直径 1m、长度 1m 的圆柱,从 glTF +Z=0 到 +Z=1;运行时横向按后端管径(mm→m)缩放、纵向沿真实路径缩放。为跟随地形,长路径在原折线上细分为最长约 20m 的小段,不平滑拐角、不推断连接拓扑。
+管体基准为直径1m、长度1m的圆柱,从glTF +Z=0到+Z=1。源/双侧推算管径仍以mm→m保存;显示横截面放大8倍并限制在1.5–3m,这只是可读性示意,长度严格沿原路径。为跟随地形,长路径在原折线上细分为最长约20m的小段,不平滑拐角、不推断安装拓扑。
+
+颜色只遵循统一语义:燃气红、供水蓝、污水绿、雨水青、窨井紫。雨污仅使用后端原样透传的remark备注,缺失或同时含雨水/污水时为灰色未分类。模型材质不依赖太阳光,仍用表面法线提供立体明暗;CSS颜色先转线性RGB再交给Cesium材质,避免被重复提亮。
+
+600m外保留4px同色地表导引线,以免远景管体低于像素尺寸后整条路径消失;已建模近景主要显示体积管体。模型失败或尚未就绪时导引线全距离保留。管线视图默认地形和建筑alpha0.18、pitch-35°、距离系数0.2;地面视图仍恢复不透明。
 
 构件是标准示意件,最大主体跨度约 1m。它们不代表厂家型号、真实弯曲角度、安装尺寸或真实朝向。构件朝向固定,仅用于类型辨认。模型中心/中心线使用地表采样高程减 **虚拟埋深 2m**;这不是后端实测埋深。
 

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

@@ -41,6 +41,9 @@
             实例 {{ instanceId }} · {{ basemapLabel }}
         </p>
 
+        <div class="map-info__pipe-colors" aria-label="管道颜色语义" data-pipe-color-legend>
+            <span v-for="key in PIPE_COLOR_KEYS" :key="key" :style="{ color: PIPE_PALETTE[key].color }">● {{ PIPE_PALETTE[key].label }}</span>
+        </div>
         <p class="map-info__note" data-pipe-model-note>{{ PIPE_MODEL_NOTE }}</p>
         <p v-if="pipeModelState" class="map-info__line" data-pipe-model-status role="status">
             管道模型 {{ pipeModelState.instances }} 实例 / {{ pipeModelState.batches }} 批 ·
@@ -71,6 +74,8 @@ import CockpitIcon from './CockpitIcon.vue'
 import MapCoordinateBlocked from './MapCoordinateBlocked.vue'
 
 import { PIPE_MODEL_NOTE } from '../domain/pipeModels.js'
+import { PIPE_PALETTE } from '../domain/pipeVisual.js'
+const PIPE_COLOR_KEYS = ['GAS','WATER_SUPPLY','SEWAGE','RAINWATER','MANHOLE','DRAINAGE_UNKNOWN']
 
 const pipeModelState = ref(null)
 let modelHost = null
@@ -159,6 +164,12 @@ onBeforeUnmount(() => {
     max-width: 100%;
 }
 
+.map-info__pipe-colors {
+    display: flex;
+    flex-wrap: wrap;
+    gap: calc(8 * var(--u, 1px));
+}
+
 .map-info__layers {
     display: flex;
     flex-wrap: wrap;

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

@@ -1,9 +1,10 @@
 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 = '管道模型为标准示意:虚拟埋深 2m(非实测);构件尺寸 1m、固定朝向(非真实安装朝向);缺管径仅在同业、端点完全重合的无分叉链上按两侧源数据和路径长度推算(非实测),单侧/无锚点不补;缺素材/地表采样时保留折线或符号。'
+export const PIPE_MODEL_NOTE = '管体显示粗度为源/推算管径×8,限制1.5–3m(示意放大,非工程尺寸),600m外显示彩色导引;虚拟埋深2m(非实测);燃气红、供水蓝、污水绿、雨水青、窨井紫,雨污只认备注,缺失/冲突为灰色;构件固定示意朝向;缺管径仅按同业精确端点无分叉链的双侧源数据和路径长度推算,单侧/无锚点不补;素材/地表失败保留折线或符号。'
 
 /** 输入只接收 toPipeNetwork 已准入且经模式计划裁剪后的对象,不再推断坐标/拓扑。 */
 export function planPipeModels(network = {}) {
@@ -19,7 +20,7 @@ export function planPipeModels(network = {}) {
             continue
         }
         for (let i = 1; i < pipe.positions.length; i += 1) {
-            instances.push({ id, kind: 'spline', industry: pipe.industry, start: pipe.positions[i - 1], end: pipe.positions[i], diameterM: diameter.diameterMm / 1000, diameterSource:diameter.source, diameterAnchors:diameter.anchors })
+            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 })
         }
     }
     for (const component of network.components || []) {
@@ -29,7 +30,7 @@ export function planPipeModels(network = {}) {
             continue
         }
         const kind = component.kind === 'branch' && component.label === 'cross' ? 'cross' : component.kind
-        instances.push({ id, kind, industry: component.industry, position: component.position, sizeM: PIPE_MODEL_POLICY.componentSizeM, orientationSource: 'fixed-approximate' })
+        instances.push({ id, kind, industry: component.industry, colorKey:pipeColorKey(component), position: component.position, sizeM: PIPE_MODEL_POLICY.componentSizeM, orientationSource: 'fixed-approximate' })
     }
     const diameterSummary = { reported:0, interpolated:0, missing:0 }
     for (const pipe of pipes) {

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

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

+ 25 - 0
src/modules/cockpit/domain/pipeVisual.js

@@ -0,0 +1,25 @@
+/** Visual semantics only; original industry, diameter and remark remain business data. */
+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:'排水(备注未明确雨污)' },
+    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'
+}
+
+/** Transverse enlargement only; max 3m keeps the body under a virtual 2m centre depth. */
+export function displayPipeDiameter(diameterM) {
+    return Math.min(PIPE_DISPLAY.maxDiameterM, Math.max(PIPE_DISPLAY.minDiameterM, diameterM * PIPE_DISPLAY.diameterScale))
+}

+ 8 - 8
src/modules/cockpit/domain/viewModes.js

@@ -23,20 +23,20 @@ export const DEFAULT_VIEW_MODE = VIEW_MODES.GROUND
 export const GROUND_PITCH = -55
 
 /**
- * 管线视图的相机预设:pitch 抬到接近平视(-18°),弹簧臂压到当前长度的 35%(并夹在合理区间)。
- * 「压低相机 + 加大 pitch」= 贴着地面侧看,管线才有纵深感;不采用剖面/掀盖方案(属票 23)。
+ * 管线视图采用 -35° 俯角和 20% 距离,既看得到管体横截面,也保留地下纵深。
+ * 中心/heading 保持原值;显示可读性优先,源坐标和虚拟埋深不变。
  */
 export const PIPELINE_CAMERA = {
-    pitch: -18,
-    rangeFactor: 0.35,
-    minRange: 400,
-    maxRange: 6000,
+    pitch: -35,
+    rangeFactor: 0.2,
+    minRange: 120,
+    maxRange: 3000,
 }
 
 /** 管线视图下地形与 3D Tiles 的半透明程度(保留空间参照,不完全隐藏)。 */
 export const PIPELINE_ALPHA = {
-    terrain: 0.45,
-    tileset: 0.4,
+    terrain: 0.18,
+    tileset: 0.18,
 }
 
 /** 地面视图下的透明度(完全不透明 = 还原)。 */

+ 17 - 24
src/modules/cockpit/services/cesiumPipeLayer.js

@@ -15,7 +15,7 @@
  *     glTF 在 readyEvent 后替代降级表达,示意外形与近似朝向不冒充真实安装信息。
  */
 import { Cartesian2, Cartesian3, Color, CustomDataSource, HeightReference, LabelStyle, VerticalOrigin } from 'cesium'
-import { pipeWidth } from '../domain/pipeNetwork.js'
+import { PIPE_DISPLAY, PIPE_PALETTE, pipeColorKey } from '../domain/pipeVisual.js'
 
 // Viewer-scoped batches; loaded lazily so symbol-only/headless paths do not load the model adapter.
 const modelLayers = new WeakMap()
@@ -25,25 +25,16 @@ let modelAdapterPromise = null
 /** 与 `cesiumEngine.js` 的 `PIPELINE_SOURCE` 同值。 */
 export const PIPE_SOURCE_NAME = 'cockpit-pipelines'
 
-/** 行业色(与面板 token 同源的四行业);线宽按管径分档,不是每段一个随机宽度。 */
-const INDUSTRY_COLOR = {
-    GAS: Color.fromCssColorString('#ff9d4d'),
-    WATER_SUPPLY: Color.fromCssColorString('#4db8ff'),
-    DRAINAGE: Color.fromCssColorString('#7c6cff'),
-    MANHOLE: Color.fromCssColorString('#8fa3ad'),
+function visualColor(row) {
+    return Color.fromCssColorString(PIPE_PALETTE[pipeColorKey(row)].color)
 }
-const NEUTRAL_COLOR = Color.fromCssColorString('#8fa3ad')
 
-/** 构件符号色与标签(与领域层 `COMPONENT_TEXT` 同词)。 */
+/** 构件只决定形状尺寸与标签;颜色统一由行业/备注决定。 */
 const COMPONENT_STYLE = {
-    valve: { color: '#ff5d5d', label: '阀门', size: 9 },
-    elbow: { color: '#ffd166', label: '角管道', size: 7 },
-    branch: { color: '#7ee787', label: '支管管道', size: 8 },
-    other: { color: '#c9d1d9', label: '其它构件', size: 6 },
-}
-
-function industryColor(code) {
-    return Object.prototype.hasOwnProperty.call(INDUSTRY_COLOR, code) ? INDUSTRY_COLOR[code] : NEUTRAL_COLOR
+    valve: { label: '阀门', size: 9 },
+    elbow: { label: '角管道', size: 7 },
+    branch: { label: '支管管道', size: 8 },
+    other: { label: '其它构件', size: 6 },
 }
 
 /** 一个瓦片数据源内的实体索引:`${kind}:${code}` → entity。跨次调用保留,避免每次全量重建。 */
@@ -69,13 +60,13 @@ async function ensureSource(viewer) {
 function pipeEntity(code, pipe) {
     const flat = []
     for (const pair of pipe.positions) flat.push(pair[0], pair[1])
-    const color = industryColor(pipe.industry)
+    const color = visualColor(pipe)
     return {
         id: `pipe:${code}`,
         polyline: {
             positions: Cartesian3.fromDegreesArray(flat),
-            width: pipeWidth(pipe.diameter),
-            material: color.withAlpha(0.85),
+            width: PIPE_DISPLAY.guideWidthPx,
+            material: color.withAlpha(1),
             clampToGround: true,
         },
         properties: { kind: 'pipe', code, industry: pipe.industry, material: pipe.material || '', name: pipe.name || '' },
@@ -84,13 +75,15 @@ function pipeEntity(code, pipe) {
 
 /** 单个构件 → 实体。构件是**设施构件**,不是监测点,拾取身份只有 code。 */
 function componentEntity(code, component) {
-    const style = Object.prototype.hasOwnProperty.call(COMPONENT_STYLE, component.kind) ? COMPONENT_STYLE[component.kind] : COMPONENT_STYLE.other
+    const style = component.industry === 'MANHOLE'
+        ? { label:'窨井', size:12 }
+        : (Object.prototype.hasOwnProperty.call(COMPONENT_STYLE, component.kind) ? COMPONENT_STYLE[component.kind] : COMPONENT_STYLE.other)
     return {
         id: `component:${code}`,
         position: Cartesian3.fromDegrees(component.position[0], component.position[1]),
         point: {
             pixelSize: style.size,
-            color: Color.fromCssColorString(style.color),
+            color: visualColor(component),
             outlineColor: Color.BLACK,
             outlineWidth: 1,
             heightReference: HeightReference.CLAMP_TO_GROUND,
@@ -174,8 +167,8 @@ export async function syncPipeLayer(viewer, network, { onModelChange = () => {}
         entityIndex.delete(id)
     }
 
-    // Keep fallback entities until ALL model spans for that business object signal ready.
-    // The model adapter alone controls entity.show; unchanged geometries still stay untouched (ticket 33).
+    // Pending/failed objects keep guides; ready pipes retain only distant guides and show close-up volume.
+    // The model adapter controls visibility/distance without reassigning unchanged geometry.
     if (viewer.scene && viewer.scene.primitives && !viewer.isDestroyed()) {
         modelAdapterPromise ||= import('./cesiumPipeModels.js')
         const { createCesiumPipeModels } = await modelAdapterPromise

+ 26 - 11
src/modules/cockpit/services/cesiumPipeModels.js

@@ -1,13 +1,16 @@
 /** Cesium-only adapter: terrain - virtual depth, ENU tiles, glTF GPU instance streams. */
-import { Cartesian3, Cartographic, Color, Matrix4, Model, Quaternion, Transforms, sampleTerrainMostDetailed } from 'cesium'
+import { Cartesian3, Cartographic, DistanceDisplayCondition, Matrix4, Model, Quaternion, Transforms, sampleTerrainMostDetailed } from 'cesium'
 import { planPipeModels, PIPE_MODEL_NOTE } from '../domain/pipeModels.js'
 import { createPipeModelBatches } from './pipeModelBatches.js'
 import { enuToGltf, instanceGltf } from './pipeInstanceGltf.js'
+import { PIPE_DISPLAY } from '../domain/pipeVisual.js'
+import { createPipeModelShader } from './pipeModelShader.js'
 
 const templates = new Map()
 const DEG = Math.PI / 180
 const TILE_DEG = 0.01
-const COLORS = { GAS:'#ff9d4d', WATER_SUPPLY:'#4db8ff', DRAINAGE:'#7c6cff', MANHOLE:'#8fa3ad' }
+const FAR_GUIDE = new DistanceDisplayCondition(PIPE_DISPLAY.guideFromM, Number.MAX_VALUE)
+const ALL_DISTANCES = new DistanceDisplayCondition(0, Number.MAX_VALUE)
 const gltfCoordinates = (point) => enuToGltf([point.x, point.y, point.z])
 
 async function templateOf(kind, base) {
@@ -75,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.industry||'unknown'}`
+        const key=`${tileX}:${tileY}:${row.kind}:${row.colorKey||'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())
@@ -88,9 +91,9 @@ async function groupsFor(viewer, plan, heights, alive) {
             const length=Cartesian3.magnitude(delta)
             if(length<0.001){missing.add(row.id);continue}
             rotation=rotationFromZ(new Cartesian3(...gltfCoordinates(delta)))
-            scale=[row.diameterM,row.diameterM,length]
+            scale=[row.displayDiameterM,row.displayDiameterM,length]
         }
-        if(!groups.has(key)) groups.set(key,{key,kind:row.kind,industry:row.industry,origin,instances:[]})
+        if(!groups.has(key)) groups.set(key,{key,kind:row.kind,industry:row.industry,colorKey:row.colorKey||'UNKNOWN',origin,instances:[]})
         groups.get(key).instances.push({id:row.id,translation,rotation,scale})
     }
     // One missing span keeps its entire business pipe's fallback visible.
@@ -99,6 +102,7 @@ async function groupsFor(viewer, plan, heights, alive) {
 
 export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=import.meta.env?.BASE_URL||'/' }={}) {
     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}
@@ -106,8 +110,17 @@ export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=i
         if(!active) return
         const covered=new Set(state.covered.filter((id)=>!missing.has(id)))
         for(const entity of source.entities.values) {
-            const show=!covered.has(entity.id)
-            if(entity.show!==show) entity.show=show
+            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)
+                }
+            } else {
+                const show=!modeled
+                if(entity.show!==show) entity.show=show
+            }
         }
         onChange({...state,covered:[...covered],fallback:allIds.length-covered.size,planning,terrainFailed,diameterSummary,note:PIPE_MODEL_NOTE})
         viewer.scene.requestRender()
@@ -119,24 +132,26 @@ 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)
             let model
             try {
                 model=await Model.fromGltfAsync({url,modelMatrix:Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(...group.origin)),
                     id:{kind:'pipe-model-batch',key:group.key,instances:group.instances.map((row)=>row.id)},incrementallyLoadTextures:false,
-                    color:group.kind==='spline'?Color.fromCssColorString(COLORS[group.industry]||'#8fa3ad'):Color.WHITE})
+                    customShader:shader})
                 const release=()=>{ URL.revokeObjectURL(url) }
                 model.readyEvent.addEventListener(()=>{release();events.ready()})
                 model.errorEvent.addEventListener(()=>{release();events.error()})
                 if(!active || !events.alive() || viewer.isDestroyed()){model.destroy();throw new Error('Disposed pipe batch')}
                 viewer.scene.primitives.add(model)
                 viewer.scene.requestRender()
-                return {model,url}
-            } catch(error){URL.revokeObjectURL(url);throw error}
+                return {model,url,shader}
+            } catch(error){URL.revokeObjectURL(url);shader.destroy();throw error}
         },
-        remove:({model,url})=>{
+        remove:({model,url,shader})=>{
             URL.revokeObjectURL(url)
             if(!viewer.isDestroyed()&&!model.isDestroyed()) viewer.scene.primitives.remove(model)
             else if(!model.isDestroyed()) model.destroy()
+            if(!shader.isDestroyed()) shader.destroy()
         },
         onChange:publish,
     })

+ 16 - 0
src/modules/cockpit/services/pipeModelShader.js

@@ -0,0 +1,16 @@
+import { Cartesian3, Color, CustomShader, LightingModel, UniformType } from 'cesium'
+import { PIPE_PALETTE } from '../domain/pipeVisual.js'
+
+/** Bright industry colors with view-normal shading; independent of sun/night lighting. */
+export function createPipeModelShader(colorKey) {
+    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) } },
+        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.alpha = 1.0;
+        }`,
+    })
+}

+ 16 - 10
tools/pipe-models/README.md

@@ -1,14 +1,20 @@
-# 管道模型验收页
+# 管道模型开发验收
 
-本页是隔离验收工具,不是业务页面。使用当前项目的 `yarn dev` 服务,不另起服务器。Fes 的 HTML fallback 会拦截工具目录路径,需临时把 `acceptance.html` 复制到 `public/cockpit/pipe-acceptance.html`,然后访问 `/cockpit/pipe-acceptance.html`;验完删除临时 public 文件。
+运行环境只以项目根 AGENTS.md 的“运行环境”一节为准。当前验收必须复用该节的共用驾驶舱入口,不另起端口、服务器或新的验收产品页。是否启动/构建/调试仍需要当前对话的明确授权。
 
-Cesium import 使用当前 dev server 的预打包入口 `/.cache/deps/cesium.js`。本工具只能在 dev 模式使用,生产包中没有该入口;不要把验收页作为生产产品发布。
+`cockpitAcceptance.js` 是验收工具,不是生产模块。需要复验时可在 `mapService.publishDevHook()` 临时使用 DEV + `pipeVerify=1` 查询条件加载它,将现有Viewer、host、setViewMode和正式applyViewPreset接口传入。所有操作均在同一驾驶舱路由;验完撤掉临时挂载,不把工具控件作为产品交付。必须走正式相机预设接口:直接写 Viewer.camera.lookAt 会被相机控制循环覆盖,使“目标已选”与实际画面不一致。
 
-- **四类示意件**:固定隔离坐标上的样条、弯头、阀门、三通、十字,另有未建模类型。界面明确标为样例,不进入后端或业务统计。
-- **真实几何**:只读当前部署 `/infrastructure/geometry`,按真实属性渲染/降级。
-- **隔离压力样例(虚拟管径)**:复制真实路径到隔离内存,明确赋予虚拟 400mm 管径。只用于 GPU 实例压力验证,不能宣称真实管径完整,也不写入业务接口。
-- **地面/地下视图**:比较真实地形遮挡与半透明地下呈现。
-- **FPS**:10s、45°固定相机 heading 运动,统计实际 `postRender` 帧,记录平均 FPS、P95/最大帧间隔、canvas 和 viewport 尺寸及 visibility。仅 3840×2160、页面可见、真实几何与大量 GPU 实例的读数可作为约定的 4K 门槛证据。
-- **素材缺失**:以 `?missing=valve`(或 spline/elbow/branch/cross)重新打开隔离页,页面自身仅将指定模板请求返回 404;确认该类型仍显示符号且 failed/fallback 增加。没有修改服务器素材或真实业务请求。
+验收项目:
 
-浏览器控制遵循 browser-skill 注入工具。若工具无法清除设备模拟或配置 4K viewport,不得用低分辨率结果冒充达标。
+- 按真实备注区分管体颜色;燃气红、供水蓝、污水绿、雨水青。窨井作为独立紫色符号核验,不把上一个管体的像素统计归到窨井。
+- 近景:显示粗度是源/推算管径×8、限制1.5–3m,非工程尺寸。保存实际近景截图。只看ready/batch计数不能证明可见。
+- 像素:关闭Entity导引线,再比较glTF模型隐藏/显示前后的同一视角帧,扣除背景;确认目标色像素。记录viewport和实际地图canvas,两者可能不同。
+- 远景:600m外出现同色4px导引线;元数据变化导致PolylineGraphics替换后,距离限制应恢复。
+- 四类隔离样例:工具可在同一个Viewer的独立数据源加载样条、弯头、阀门、三通/十字;显式标记隔离,不进入后端或业务统计。原始管网可见计数不应被解释为这批合成样例。
+- 模板缺失:用临时查询 `pipeMissing=valve`(或elbow/branch)使工具仅为指定模板返回404;验证实例失败后符号仍可见。生产fetch/源文件/后端不改。须以新页面realm开始,避免成功模板缓存掩盖故障。
+- 性能:页面真实可见且聚焦,10s固定45°相机运动,按postRender计帧,报告平均FPS、P95和最大帧间隔、GPU、viewport与canvas。目标为4K平均FPS≥30,静止时按需渲染读数不作通过证据。
+- 先测原生requestAnimationFrame基线。若未加载管网的浏览器rAF与空场景都只有约1Hz,说明测量环境受限;不要把它归为管体性能结论,也不宣称达标。
+
+`visibility.html` / `acceptance.html` 保留早期隔离反馈工具源码,仅供理解历史截图/像素证据;最新规则要求使用同一共用驾驶舱路由,它们不再作为可另行访问的验收入口,不能复制进public产品目录。
+
+浏览器控制一律使用browser-skill注入工具,不以另一个进程控制浏览器,不改Automation设置绕过许可。工具参数/会话异常如实记入票据,不无限重复失败调用。

+ 126 - 0
tools/pipe-models/cockpitAcceptance.js

@@ -0,0 +1,126 @@
+// Temporary QA overlay mounted only for ?pipeVerify=1 on the shared cockpit route.
+// It exercises the actual scene; it is removed from mapService before delivery.
+import { Cartesian3, Cartographic, HeadingPitchRange, sampleTerrainMostDetailed } from 'cesium'
+import { toPipeNetwork } from '../../src/modules/cockpit/domain/pipeNetwork.js'
+import { pipeColorKey } from '../../src/modules/cockpit/domain/pipeVisual.js'
+
+export function mountPipeAcceptance({ viewer, host, setViewMode, applyViewPreset }) {
+    const params=new URLSearchParams(location.search)
+    const originalFetch=window.fetch.bind(window),missingKind=params.get('pipeMissing')
+    if (['valve','elbow','branch'].includes(missingKind)) {
+        window.fetch=(input,options)=>String(input).endsWith(`/models/pipes/${missingKind}.gltf`)
+            ? Promise.resolve(new Response('isolated missing template',{status:404})) : originalFetch(input,options)
+    }
+    const panel = document.createElement('aside')
+    panel.id = 'pipe-acceptance-overlay'
+    panel.style.cssText = 'position:fixed;left:25%;bottom:12px;z-index:1200;max-width:55vw;max-height:36vh;overflow:auto;padding:10px;background:#04121feb;color:white;font:13px monospace;border:1px solid #32c5ff'
+    panel.innerHTML = '<b>管体验收(开发隔离控件)</b><div></div><pre style="white-space:pre-wrap"></pre>'
+    const buttons = panel.querySelector('div'), output = panel.querySelector('pre')
+    let network, target, range = 160, heading = 0
+    const results = {}
+    const emit = (key, value) => { results[key] = value; output.textContent = JSON.stringify(results, null, 2) }
+    const add = (name, action) => {
+        const button = document.createElement('button')
+        button.textContent = name; button.style.cssText = 'margin:3px;padding:5px;color:#fff;background:#12426a;border:1px solid #328abc'
+        button.onclick = () => Promise.resolve(action()).catch(error => emit('error', String(error.message || error)))
+        buttons.appendChild(button)
+    }
+    const pipes = async () => {
+        if (!network) {
+            const config = await fetch('/cockpit/config.json').then(r => r.json())
+            const payload = await fetch(config.api.baseUrl + '/infrastructure/geometry').then(r => r.json())
+            if (payload.code !== 200) throw new Error('geometry business error')
+            network = toPipeNetwork(payload.data)
+        }
+        return network
+    }
+    const aim = async (key) => {
+        const data = await pipes()
+        const row = key === 'MANHOLE' ? data.components.find(p => p.industry === 'MANHOLE') : data.pipes.find(p => p.diameter > 0 && pipeColorKey(p) === key)
+        if (!row) throw new Error('No real target ' + key)
+        const a = row.position || row.positions[0], b = row.position || row.positions.at(-1)
+        const lon = (a[0]+b[0])/2, lat = (a[1]+b[1])/2
+        const [point] = await sampleTerrainMostDetailed(viewer.terrainProvider, [Cartographic.fromDegrees(lon,lat)])
+        target = {code:row.code,key,lon,lat,height:point.height}
+        setViewMode('pipeline'); heading=0
+        applyViewPreset({lon,lat,height:point.height},{heading:0,pitch:-45,range},{immediate:true})
+        viewer.scene.requestRender()
+        emit('target', {...target,range,remark:row.remark||'',canvas:[viewer.canvas.width,viewer.canvas.height]})
+    }
+    const frame = () => new Promise(resolve => {
+        const detach = viewer.scene.postRender.addEventListener(() => { detach(); resolve() })
+        viewer.scene.requestRender()
+    })
+    const capture = () => {
+        const canvas=document.createElement('canvas');canvas.width=viewer.canvas.width;canvas.height=viewer.canvas.height
+        const ctx=canvas.getContext('2d',{willReadFrequently:true});ctx.drawImage(viewer.canvas,0,0)
+        return ctx.getImageData(0,0,canvas.width,canvas.height).data
+    }
+    const modelList = () => {
+        const models=[]
+        for(let i=0;i<viewer.scene.primitives.length;i++){const p=viewer.scene.primitives.get(i);if(p.id?.kind==='pipe-model-batch')models.push(p)}
+        return models
+    }
+    add('真实污水近景',()=>aim('SEWAGE'))
+    add('真实雨水近景',()=>aim('RAINWATER'))
+    add('真实燃气近景',()=>aim('GAS'))
+    add('真实供水近景',()=>aim('WATER_SUPPLY'))
+    add('窨井紫色',()=>aim('MANHOLE'))
+    add('远景800m',async()=>{range=800;await aim(target?.key||'SEWAGE')})
+    add('近景160m',async()=>{range=160;await aim(target?.key||'SEWAGE')})
+    add('测管体像素',async()=>{
+        if(!target)throw new Error('Choose target')
+        const source=viewer.dataSources.getByName('cockpit-pipelines')[0],shown=source.show
+        const models=modelList(),states=models.map(m=>m.show)
+        source.show=false;models.forEach(m=>m.show=false)
+        try{
+            await frame();await frame();const before=capture()
+            models.forEach((m,i)=>m.show=states[i]);await frame();await frame();const after=capture()
+            const count={GAS:0,WATER_SUPPLY:0,SEWAGE:0,RAINWATER:0};let changed=0
+            for(let i=0;i<after.length;i+=4){const r=after[i],g=after[i+1],b=after[i+2];if(Math.abs(r-before[i])+Math.abs(g-before[i+1])+Math.abs(b-before[i+2])<35)continue;changed++
+                if(r>120&&r>g*1.7&&r>b*1.35)count.GAS++
+                if(b>120&&b>r*1.7&&b>g*1.15)count.WATER_SUPPLY++
+                if(g>120&&g>r*1.7&&g>b*1.3)count.SEWAGE++
+                if(g>120&&b>120&&r<100&&Math.abs(g-b)<90)count.RAINWATER++}
+            emit('pixels',{modelOnly:true,backgroundSubtracted:true,target:target.code,color:target.key,counts:count,changed,pass:(count[target.key]||0)>=80,canvas:[viewer.canvas.width,viewer.canvas.height]})
+        }finally{models.forEach((m,i)=>m.show=states[i]);source.show=shown;viewer.scene.requestRender()}
+    })
+    add('原生rAF',()=>{
+        const start=performance.now(),times=[]
+        const next=t=>{times.push(t);if(t-start<5000){requestAnimationFrame(next);return}const secs=(times.at(-1)-times[0])/1000;emit('raf',{frames:times.length,seconds:secs,fps:(times.length-1)/secs,visible:document.visibilityState,focused:document.hasFocus()})}
+        requestAnimationFrame(next)
+    })
+    add('4K运动FPS',()=>{
+        if(!target)throw new Error('Choose target')
+        const scene=viewer.scene,old=scene.requestRenderMode,start=performance.now(),times=[];let hidden=false,lostFocus=false
+        scene.requestRenderMode=false
+        const detach=scene.postRender.addEventListener(()=>{
+            const now=performance.now();times.push(now);hidden ||= document.visibilityState!=='visible';lostFocus ||= !document.hasFocus()
+            if(now-start<10000){heading=(now-start)/10000*Math.PI/4;applyViewPreset({lon:target.lon,lat:target.lat,height:target.height},{heading:heading*180/Math.PI,pitch:-45,range},{immediate:true});return}
+            detach();scene.requestRenderMode=old
+            const gaps=times.slice(1).map((t,i)=>t-times[i]).sort((a,b)=>a-b),secs=(times.at(-1)-times[0])/1000
+            const gl=viewer.canvas.getContext('webgl2')||viewer.canvas.getContext('webgl'),debug=gl.getExtension('WEBGL_debug_renderer_info')
+            emit('fps',{frames:times.length,seconds:secs,fps:(times.length-1)/secs,p95:gaps[Math.floor(gaps.length*.95)],max:gaps.at(-1),canvas:[viewer.canvas.width,viewer.canvas.height],hidden,lostFocus,renderer:debug?gl.getParameter(debug.UNMASKED_RENDERER_WEBGL):gl.getParameter(gl.RENDERER),pass:(times.length-1)/secs>=30})
+        })
+    })
+    add('隔离四类样例',async()=>{
+        const [{CustomDataSource,Color,HeightReference},{createCesiumPipeModels}]=await Promise.all([import('cesium'),import('../../src/modules/cockpit/services/cesiumPipeModels.js')])
+        const lon=110.39028,lat=28.44368
+        const [ground]=await sampleTerrainMostDetailed(viewer.terrainProvider,[Cartographic.fromDegrees(lon,lat)])
+        const example={pipes:[{code:'ISOLATED-SPLINE',industry:'GAS',diameter:300,positions:[[lon-.00012,lat],[lon-.00005,lat],[lon,lat+.00005]]}],components:[
+            {code:'ISOLATED-ELBOW',kind:'elbow',industry:'GAS',position:[lon+.00004,lat]},
+            {code:'ISOLATED-VALVE',kind:'valve',industry:'GAS',position:[lon+.00007,lat]},
+            {code:'ISOLATED-TEE',kind:'branch',label:'tee',industry:'GAS',position:[lon+.0001,lat]},
+            {code:'ISOLATED-CROSS',kind:'branch',label:'cross',industry:'GAS',position:[lon+.00013,lat]}]}
+        const source=new CustomDataSource('pipe-isolated-acceptance')
+        for(const row of example.components)source.entities.add({id:'component:'+row.code,position:Cartesian3.fromDegrees(...row.position),point:{pixelSize:14,color:Color.RED,heightReference:HeightReference.CLAMP_TO_GROUND,disableDepthTestDistance:Infinity},label:{text:row.kind+'(隔离样例)',heightReference:HeightReference.CLAMP_TO_GROUND,disableDepthTestDistance:Infinity}})
+        await viewer.dataSources.add(source)
+        const layer=createCesiumPipeModels(viewer,{source,onChange:s=>emit('examples',{isolated:true,missingKind,instances:s.instances,batches:s.batches,pending:s.pending,failed:s.failed,fallback:s.fallback,visibleSymbols:source.entities.values.filter(e=>e.show).length,byKind:s.byKind})})
+        setViewMode('pipeline');applyViewPreset({lon,lat,height:ground.height},{heading:0,pitch:-45,range:60},{immediate:true})
+        layer.sync(example)
+        results.isolated=true
+    })
+    add('收起诊断',()=>panel.hidden=true)
+    host.appendChild(panel)
+    return {destroy:()=>panel.remove()}
+}

+ 91 - 0
tools/pipe-models/visibility.html

@@ -0,0 +1,91 @@
+<!doctype html>
+<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>管段可见性验证</title><style>html,body,#map{margin:0;width:100%;height:100%;overflow:hidden;background:#04121f;color:white;font:14px sans-serif}aside{position:absolute;top:8px;left:8px;z-index:5;background:#04121fe8;padding:10px;max-width:90vw}button{padding:8px;margin:3px}pre{font:12px monospace;white-space:pre-wrap;max-height:24vh;overflow:auto}</style></head><body><div id="map"></div><aside><strong>真实管段可见性:颜色像素与管体就绪分开核验</strong><br><button id="load">加载真实管段近景</button><button id="scan">测量管体颜色像素</button><button id="ground">地面遮挡</button><button id="pipeline">地下透视</button><button id="fps">10秒运动FPS</button><button id="mapfps">仅地图FPS(隐藏管段)</button><button id="emptyfps">空场景FPS</button><button id="raf">浏览器帧调度</button><button id="far">远景800m</button><pre id="state">准备</pre><pre id="pixels"></pre><pre id="perf"></pre></aside><script type="module">
+window.CESIUM_BASE_URL='/cesium/'
+const transformed=await fetch('/src/modules/cockpit/services/cesiumPipeModels.js').then(r=>r.text())
+const cesiumUrl=transformed.match(/from ["']([^"']*\/cesium\.js[^"']*)["']/)?.[1]||'/.cache/deps/cesium.js'
+const {Cartesian3,Cartographic,HeadingPitchRange,sampleTerrainMostDetailed,TileMapServiceImageryProvider,ImageryLayer,UrlTemplateImageryProvider}=await import(cesiumUrl)
+const {createViewer,applyTerrain,applyViewModeLayers}=await import('/src/modules/cockpit/services/cesiumEngine.js')
+const {planViewMode}=await import('/src/modules/cockpit/domain/viewModes.js')
+const {syncPipeLayer,clearPipeLayer,PIPE_SOURCE_NAME}=await import('/src/modules/cockpit/services/cesiumPipeLayer.js')
+const {toPipeNetwork}=await import('/src/modules/cockpit/domain/pipeNetwork.js')
+const cfg=await fetch('/cockpit/config.json').then(r=>r.json())
+const viewer=createViewer(document.querySelector('#map'),{ready:true,kind:'tms',url:'Assets/Textures/NaturalEarthII'})
+await applyTerrain(viewer,{ready:true,url:cfg.map.terrain.url,requestVertexNormals:true})
+for(const overlay of cfg.map.overlays||[]) viewer.imageryLayers.add(new ImageryLayer(new UrlTemplateImageryProvider({url:overlay.url})))
+let target=null,latest=null,network=null,range=160
+const renderState=()=>{const {covered=[],...counts}=latest||{};document.querySelector('#state').textContent=JSON.stringify({target:target?.code,range,canvas:[viewer.canvas.width,viewer.canvas.height],...counts,covered:covered.length},null,0)}
+const look=(heading=0)=>{if(target)viewer.camera.lookAt(Cartesian3.fromDegrees(target.lon,target.lat,target.height),new HeadingPitchRange(heading,-Math.PI/4,range));viewer.scene.requestRender()}
+const mode=(value)=>{applyViewModeLayers(viewer,[],planViewMode(value));viewer.scene.requestRender()}
+document.querySelector('#load').onclick=async()=>{
+ const envelope=await fetch(cfg.api.baseUrl+'/infrastructure/geometry').then(r=>r.json())
+ network=toPipeNetwork(envelope.data)
+ const pipe=network.pipes.find(p=>p.diameter>0&&p.industry==='DRAINAGE'&&/污水/.test(p.remark)&&!/雨水/.test(p.remark))
+ const a=pipe.positions[0],b=pipe.positions.at(-1)
+ const lon=(a[0]+b[0])/2,lat=(a[1]+b[1])/2
+ const [ground]=await sampleTerrainMostDetailed(viewer.terrainProvider,[Cartographic.fromDegrees(lon,lat)])
+ target={code:pipe.code,lon,lat,height:ground.height}
+ look();mode('pipeline')
+ await syncPipeLayer(viewer,network,{onModelChange:s=>{latest=s;renderState()}})
+}
+document.querySelector('#far').onclick=()=>{range=800;look();renderState()}
+document.querySelector('#ground').onclick=()=>mode('ground')
+document.querySelector('#pipeline').onclick=()=>mode('pipeline')
+document.querySelector('#scan').onclick=()=>{
+ const source=viewer.dataSources.getByName(PIPE_SOURCE_NAME)[0]
+ const wasShown=source.show;source.show=false
+ const models=[]
+ for(let i=0;i<viewer.scene.primitives.length;i++){const p=viewer.scene.primitives.get(i);if(p.id?.kind==='pipe-model-batch')models.push(p)}
+ const prior=models.map(m=>m.show)
+ const readPixels=()=>{const canvas=document.createElement('canvas');canvas.width=viewer.canvas.width;canvas.height=viewer.canvas.height;const ctx=canvas.getContext('2d',{willReadFrequently:true});ctx.drawImage(viewer.canvas,0,0);return ctx.getImageData(0,0,canvas.width,canvas.height).data}
+ let frames=0,baseline=null
+ for(const m of models)m.show=false
+ const detach=viewer.scene.postRender.addEventListener(()=>{
+  if(++frames<3){viewer.scene.requestRender();return}
+  if(!baseline){baseline=readPixels();models.forEach((m,i)=>m.show=prior[i]);frames=0;viewer.scene.requestRender();return}
+  const data=readPixels(),counts={red:0,blue:0,green:0,cyan:0,purple:0};let changed=0
+  for(let i=0;i<data.length;i+=4){
+   const r=data[i],g=data[i+1],b=data[i+2]
+   if(Math.abs(r-baseline[i])+Math.abs(g-baseline[i+1])+Math.abs(b-baseline[i+2])<35)continue
+   changed++
+   if(r>130&&r>g*1.7&&r>b*1.4)counts.red++
+   if(b>130&&b>r*1.7&&b>g*1.15)counts.blue++
+   if(g>130&&g>r*1.7&&g>b*1.4)counts.green++
+   if(g>130&&b>130&&r<90&&Math.abs(g-b)<80)counts.cyan++
+   if(r>100&&b>130&&g<100&&b>g*1.4)counts.purple++
+  }
+  const result={target:target.code,modelOnly:true,backgroundSubtracted:true,canvas:[viewer.canvas.width,viewer.canvas.height],range,terrainAlpha:viewer.scene.globe.translucency.frontFaceAlpha,counts,changed,pass:counts.green>=80,expected:'sewage glTF green difference pixels >=80; entities/background excluded',visible:document.visibilityState}
+  document.querySelector('#pixels').textContent=JSON.stringify(result,null,2)
+  document.body.dataset.pixelPass=result.pass
+  source.show=wasShown;detach();viewer.scene.requestRender()
+ });viewer.scene.requestRender()
+}
+function benchmark(testMode='full') {
+ const gl=viewer.canvas.getContext('webgl2')||viewer.canvas.getContext('webgl'),debug=gl.getExtension('WEBGL_debug_renderer_info')
+ const environment={renderer:debug?gl.getParameter(debug.UNMASKED_RENDERER_WEBGL):gl.getParameter(gl.RENDERER),vendor:debug?gl.getParameter(debug.UNMASKED_VENDOR_WEBGL):gl.getParameter(gl.VENDOR),userAgent:navigator.userAgent,devicePixelRatio,focused:document.hasFocus(),hardwareConcurrency:navigator.hardwareConcurrency}
+ const old=viewer.scene.requestRenderMode,globeShow=viewer.scene.globe.show,models=[],source=viewer.dataSources.getByName(PIPE_SOURCE_NAME)[0],sourceShow=source?.show
+ for(let i=0;i<viewer.scene.primitives.length;i++){const p=viewer.scene.primitives.get(i);if(p.id?.kind==='pipe-model-batch')models.push([p,p.show])}
+ if(testMode!=='full'){models.forEach(([p])=>p.show=false);if(source)source.show=false}
+ if(testMode==='empty')viewer.scene.globe.show=false
+ viewer.scene.requestRenderMode=false
+ const times=[],start=performance.now(),focusStart=document.hasFocus();let hidden=false,focusLost=false
+ const detach=viewer.scene.postRender.addEventListener(()=>{
+  const now=performance.now();times.push(now);hidden ||= document.visibilityState!=='visible';focusLost ||= !document.hasFocus()
+  if(now-start<10000){look((now-start)/10000*Math.PI/4);return}
+  detach();viewer.scene.requestRenderMode=old;viewer.scene.globe.show=globeShow;models.forEach(([p,show])=>p.show=show);if(source)source.show=sourceShow
+  const gaps=times.slice(1).map((t,i)=>t-times[i]).sort((a,b)=>a-b),seconds=(times.at(-1)-times[0])/1000
+  document.querySelector('#perf').textContent=JSON.stringify({testMode,frames:times.length,seconds,fps:(times.length-1)/seconds,p95FrameMs:gaps[Math.floor(gaps.length*.95)],maxFrameMs:gaps.at(-1),canvas:[viewer.canvas.width,viewer.canvas.height],viewport:[innerWidth,innerHeight],visible:document.visibilityState,hidden,focusStart,focusLost,environment,motion:'45deg/10s'},null,2)
+  viewer.scene.requestRender()
+ })
+}
+document.querySelector('#fps').onclick=()=>benchmark('full')
+document.querySelector('#mapfps').onclick=()=>benchmark('map-only')
+document.querySelector('#emptyfps').onclick=()=>benchmark('empty')
+document.querySelector('#raf').onclick=()=>{
+ const start=performance.now(),times=[]
+ function frame(now){times.push(now);if(now-start<5000){requestAnimationFrame(frame);return}
+ const span=(times.at(-1)-times[0])/1000
+ document.querySelector('#perf').textContent=JSON.stringify({testMode:'native-rAF',frames:times.length,seconds:span,fps:(times.length-1)/span,visible:document.visibilityState,focused:document.hasFocus(),canvas:[viewer.canvas.width,viewer.canvas.height]},null,2)}
+ requestAnimationFrame(frame)
+}
+document.querySelector('#state').textContent='引擎就绪;选择加载真实管段近景'
+</script></body></html>