瀏覽代碼

feat(cockpit): add batched glTF pipe demonstration models (#32)

Kazerin 1 周之前
父節點
當前提交
cca0515ae0
共有 30 個文件被更改,包括 794 次插入 和 10 次删除
  1. 13 2
      .scratch/cockpit-real-data-wayfinding/issues/32-32.md
  2. 37 0
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/implementation-and-acceptance.md
  3. 二進制
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/isolated-types.png
  4. 11 0
      .scratch/cockpit-real-data-wayfinding/verify/t32-delay-model-loader.mjs
  5. 17 0
      .scratch/cockpit-real-data-wayfinding/verify/t32-import-lifecycle.mjs
  6. 26 0
      .scratch/cockpit-real-data-wayfinding/verify/t32-instanced-gltf.mjs
  7. 65 0
      .scratch/cockpit-real-data-wayfinding/verify/t32-model-lifecycle.mjs
  8. 27 0
      .scratch/cockpit-real-data-wayfinding/verify/t32-pipe-model-plan.mjs
  9. 8 0
      CONTEXT.md
  10. 11 0
      public/cockpit/models/pipes/README.md
  11. 二進制
      public/cockpit/models/pipes/branch.bin
  12. 1 0
      public/cockpit/models/pipes/branch.gltf
  13. 二進制
      public/cockpit/models/pipes/cross.bin
  14. 1 0
      public/cockpit/models/pipes/cross.gltf
  15. 二進制
      public/cockpit/models/pipes/elbow.bin
  16. 1 0
      public/cockpit/models/pipes/elbow.gltf
  17. 二進制
      public/cockpit/models/pipes/spline.bin
  18. 1 0
      public/cockpit/models/pipes/spline.gltf
  19. 二進制
      public/cockpit/models/pipes/valve.bin
  20. 1 0
      public/cockpit/models/pipes/valve.gltf
  21. 19 0
      src/modules/cockpit/components/MapInfoPanel.vue
  22. 30 0
      src/modules/cockpit/domain/pipeModels.js
  23. 29 6
      src/modules/cockpit/services/cesiumPipeLayer.js
  24. 186 0
      src/modules/cockpit/services/cesiumPipeModels.js
  25. 20 2
      src/modules/cockpit/services/mapService.js
  26. 34 0
      src/modules/cockpit/services/pipeInstanceGltf.js
  27. 70 0
      src/modules/cockpit/services/pipeModelBatches.js
  28. 14 0
      tools/pipe-models/README.md
  29. 79 0
      tools/pipe-models/acceptance.html
  30. 93 0
      tools/pipe-models/build-assets.mjs

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

@@ -3,7 +3,7 @@
 # 32: 管线构件 glTF 素材与实例化渲染(样条/角/阀门/支管四类)
 
 Type: task
-Status: ready-for-agent
+Status: claimed
 Label: ready-for-agent
 Category: frontend-execution
 
@@ -47,7 +47,18 @@ Category: frontend-execution
 
 ## 进度
 
-<!-- 做到哪一步了,在这里一行一行记 -->
+- 已认领 @me、完整读票并 report。ready-for-agent,无 needs-triage、无阻塞;父 map 允许执行。本轮保留其他任务工作树,提交仅选择本票增量。
+- 用户裁定:低多边形标准示意;地表采样减虚拟埋深2m(非实测);已知管径按mm→m,无径保留折线;路径不平滑/不推断拓扑;未知构件尺寸约1m、固定朝向明示近似;沿用地下视图。授权必要构建/回归/浏览器,4K平均FPS≥30门槛。
+- 已实施:四类五模板(样条、弯头、阀门、三通、十字);EXT_mesh_gpu_instancing、ENU空间批次、稳定外部bin跨批共享几何;readyEvent后替换备用表达,初始/后续资源或地表失败降级、30s冷却后刷新重试;稳定批不重建,跨首次dynamic import和模型异步的清层代次守卫;已有信息面板展示虚拟埋深/近似与降级原因。
+- 对抗式复核修掉:北向镜像、跨批embedded geometry不共享、稍变就全量拆建、失败永久短路、清层期间首次import迟到复活、terrainFailed未发布。相关新回归及既有t24/t33回归通过;最后全部代码的已授权yarn build成功(退出码0,Vite54.43s),临时public验收页已移除。
+- 隔离浏览器证据:6实例/5批,spline2、elbow/valve/branch/cross各1,pending0/failed0、unknown保留1个符号,虚拟埋深与近似说明可读。窄模拟视口证据不能冒充4K。
+- 真实只读核查:5268条LineString正管径数=0;唯一合法Point为窨井YL-YJ-003、pointType=null。真实浏览器读数5269个折线/符号、0个glTF实例,符合不编造管径/类型裁定;不是全量GPU性能证据。
+- 完整实施与证据:[验收记录](../verify/artifacts/t32/implementation-and-acceptance.md);[隔离画面](../verify/artifacts/t32/isolated-types.png);复现工具见 tools/pipe-models/README.md。
+- 尚未完成:4K可见窗口45°/10s镜头运动平均FPS≥30(真实降级与明确标识的GPU压力样例分开)、浏览器404素材缺失恢复符号、修复后的完整驾驶舱显隐与状态说明。
+- 当前障碍:browser工具schema要求同时提供互斥的off/device/尺寸、session/requestId等字段;无法去掉移动模拟或清理会话,后续会话失去注册并达5个上限。snapshot/press/screenshot仅取得部分证据,没有绕过技能用外部进程控制。
+- 下一步:恢复browser工具可选参数/有效会话后按隔离README完成实测,全部达标才close、完成report、map追加具名gist。当前claimed/open,不关闭。
+
+Labels: ready-for-agent
 
 ## Comments
 

+ 37 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/implementation-and-acceptance.md

@@ -0,0 +1,37 @@
+# 管线构件 glTF 实施与验收记录
+
+状态:实施已完成,验收未全部通过,issue 保持 claimed/open。
+
+## 实施边界
+
+- 四类原始低多边形素材:样条管体、弯头、阀门、支管;支管提供三通和十字变体。glTF 模板与外部同名 bin 位于 public/cockpit/models/pipes。
+- Cesium 1.145.0 使用 EXT_mesh_gpu_instancing。按空间/行业/类型分批,批内 GPU 实例化,同类批次复用稳定外部几何 URL;不逐构件加载模型。
+- 使用地表采样高程减虚拟埋深 2m。已知管径 mm→m;无管径保留折线。构件约 1m 标准外形、固定示意朝向,明确非真实安装信息。
+- 就绪事件后替换降级;初始/后续资源失败、地表采样失败保持或恢复备用符号。失败批按 30s 冷却在后续同步中重试。输入不变不重建,稳定批保留,清层跨首次动态 import 和模型加载都作废旧结果。
+
+## 双轴复核与修复
+
+Standards 轴提出批次全量拆建、失败永不恢复、迟到加载浪费资源三项判断;Spec 轴提出坐标北向镜像、跨批不共享 GPU 几何、缺实测等问题。已修复:
+
+1. ENU→glTF 使用 [N,U,E],与当前 Cesium [z,x,y] 校正互逆;回归用独立矩阵验证。
+2. 每类外部 bin 的稳定绝对 URL 供 Cesium GPU 资源缓存跨批复用。
+3. 批级指纹、retain 与冷却重试;template/model await 后检查存活代次。
+4. 复核又发现的首次动态 import 清理竞态已获得红灯复现,再加 Viewer 代次守卫通过回归。
+5. 地表采样失败发布 terrainFailed 并在已有状态面板解释。
+
+## 已获得证据
+
+- 本轮授权后最终 yarn build 成功(退出码0,Vite构建54.43s,包含最后的导入代次守卫与terrainFailed说明,且临时public验收页已移除);仓库既有 bundle/script/Browserslist 警告仍存在。
+- 新回归覆盖管径与原路径、未知类型、三通/十字变体、glTF 单网格实例流、轴转换、真正 ready 前不撤下备用表达、后续资源错误恢复、跨批整条路径就绪、冷却重试、清层后迟到结果不复活。
+- 既有 t24 管线领域、t24 链路、t33 管线差量回归通过,未把并行差量修复混入本票提交。
+- 隔离浏览器:6 实例、5 批,spline=2、elbow/valve/branch/cross 各 1;pending=0、failed=0、未知 regulator 保留 1 个符号。截图见 [isolated-types.png](isolated-types.png)。该截图是窄模拟视口,十字变体位于边缘,不能替代完整 4K 四类画面验收。
+- 真实只读接口:5268 条 LineString 的正管径数为 **0**,唯一合法 Point 是 MANHOLE / YL-YJ-003,pointType=null。因此按已确认规则,真实页面应为 **5269 个折线/符号对象、0 个 glTF 实例**;隔离浏览器读取与此一致。这证明降级诚实,不证明全量 GPU 实例性能。
+
+## 尚未完成
+
+1. 3840×2160 页面可见、固定 45°/10s 镜头运动下平均 FPS≥30,记录 P95/最大帧间隔与环境。要分别测试真实降级数据与明确标记的隔离 GPU 压力样例;不能给真实缺径数据补造尺寸后宣称真实模型完整。
+2. 隔离页 `?missing=valve`(及另一类)实际 404 下备用符号、计数和画面验证。离线 lifecycle error 回归已过,但浏览器故障注入尚未执行。
+3. 完整驾驶舱对现有模式/行业显隐和模型状态说明复验。期间遇到的总览 donutBlock 缺失导入已由其他工作补齐,但本轮尚未拿到修复后的完整页面证据。
+4. 清除本轮验收 Agent Window 的设备模拟并恢复有效工具会话。当前工具 schema 强制提供所有字段,off 与 device/width/height、session 与 requestId 等互斥参数无法省略;observe cursor/maxDepth、debug slowMs 等同样冲突。snapshot/press/screenshot 曾可取得部分证据,后续会话失去注册且达到 5-session 上限,无法继续符合技能规则的浏览器验证。没有改用外部进程控制浏览器。
+
+下一步:恢复浏览器工具的可选参数/会话,按 tools/pipe-models/README.md 复现上述验收;全部达标后发 resolution comment、close、再次 report 并给 map 追加具名 gist。当前不 close,也不把未完成结论写入 Decisions so far。

二進制
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/isolated-types.png


+ 11 - 0
.scratch/cockpit-real-data-wayfinding/verify/t32-delay-model-loader.mjs

@@ -0,0 +1,11 @@
+export async function resolve(specifier, context, nextResolve) {
+    if (specifier.endsWith('/cesiumPipeModels.js') || specifier === './cesiumPipeModels.js') {
+        const code = `await new Promise(resolve => setTimeout(resolve, 40));
+            export function createCesiumPipeModels() {
+                globalThis.t32AdapterCreates = (globalThis.t32AdapterCreates || 0) + 1;
+                return { sync() { globalThis.t32AdapterSyncs = (globalThis.t32AdapterSyncs || 0) + 1 }, destroy() {} };
+            }`
+        return { url: 'data:text/javascript,' + encodeURIComponent(code), shortCircuit: true }
+    }
+    return nextResolve(specifier, context)
+}

+ 17 - 0
.scratch/cockpit-real-data-wayfinding/verify/t32-import-lifecycle.mjs

@@ -0,0 +1,17 @@
+import assert from 'node:assert/strict'
+import { register } from 'node:module'
+register('./t32-delay-model-loader.mjs', import.meta.url)
+const { syncPipeLayer, clearPipeLayer } = await import('../../../src/modules/cockpit/services/cesiumPipeLayer.js')
+const sources = []
+const viewer = { scene:{primitives:{}}, isDestroyed:()=>false, dataSources:{
+    getByName:(name)=>sources.filter(s=>s.name===name),
+    add:async(source)=>{sources.push(source);return source},
+} }
+const network={pipes:[{code:'p',industry:'GAS',diameter:200,positions:[[110.39,28.44],[110.4,28.44]]}],components:[]}
+const pending=syncPipeLayer(viewer,network)
+await new Promise(setImmediate)
+await clearPipeLayer(viewer)
+await pending
+assert.equal(globalThis.t32AdapterCreates || 0,0,'cleared layer must not create a delayed model adapter')
+assert.equal(sources[0].entities.values.length,0)
+console.log('PASS clear during first model import cannot resurrect old objects')

+ 26 - 0
.scratch/cockpit-real-data-wayfinding/verify/t32-instanced-gltf.mjs

@@ -0,0 +1,26 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { enuToGltf, instanceGltf } from '../../../src/modules/cockpit/services/pipeInstanceGltf.js'
+import { Matrix4, Matrix3, Cartesian3 } from 'cesium'
+// Independent literal: installed Cesium default correction maps glTF [x,y,z] -> ENU [z,x,y].
+const correction = Matrix4.fromRotationTranslation(Matrix3.fromArray([0,1,0,0,0,1,1,0,0]))
+const converted = new Cartesian3(...enuToGltf([11,23,37]))
+assert.deepEqual(Matrix4.multiplyByPoint(correction, converted, new Cartesian3()), new Cartesian3(11,23,37))
+const template = JSON.parse(await readFile(new URL('../../../public/cockpit/models/pipes/spline.gltf', import.meta.url)))
+const doc = instanceGltf(template, [
+    { translation: [0,0,0], rotation: [0,0,0,1], scale: [.4,.4,10] },
+    { translation: [1,2,3], rotation: [0,0,0,1], scale: [.4,.4,20] },
+])
+assert.equal(doc.meshes.length, 1)
+assert.equal(doc.meshes[0].primitives.length, 1)
+assert.equal(doc.buffers[0].uri, template.buffers[0].uri, 'shared template geometry not copied per instance')
+const attrs = doc.nodes[0].extensions.EXT_mesh_gpu_instancing.attributes
+assert.equal(doc.accessors[attrs.TRANSLATION].count, 2)
+assert.equal(doc.accessors[attrs.SCALE].count, 2)
+assert.ok(doc.extensionsRequired.includes('EXT_mesh_gpu_instancing'))
+assert.equal(template.nodes[0].extensions, undefined, 'shared template remains immutable')
+const data = Buffer.from(doc.buffers[1].uri.split(',')[1], 'base64')
+assert.equal(data.readFloatLE(12), 1)
+assert.equal(data.readFloatLE(20), 3)
+assert.equal(data.readFloatLE(80-4), 20)
+console.log('PASS two GPU instances reuse one immutable mesh and preserve literal TRS')

+ 65 - 0
.scratch/cockpit-real-data-wayfinding/verify/t32-model-lifecycle.mjs

@@ -0,0 +1,65 @@
+import assert from 'node:assert/strict'
+import { createPipeModelBatches } from '../../../src/modules/cockpit/services/pipeModelBatches.js'
+const pending = [], removed = [], changes = []
+const layer = createPipeModelBatches({
+    load: async (group, events) => { const model = { key: group.key }; pending.push({ group, events, model }); return model },
+    remove: (model) => removed.push(model.key),
+    onChange: (state) => changes.push(state),
+})
+const group = { key: 'tile:valve', instances: [{ id: 'component:v' }] }
+await layer.sync([group])
+assert.deepEqual(layer.state().covered, [], 'await load is NOT model ready')
+pending[0].events.ready()
+assert.deepEqual(layer.state().covered, ['component:v'])
+await layer.sync([structuredClone(group)])
+assert.equal(pending.length, 1, 'unchanged input must not reload model')
+pending[0].events.error(new Error('late buffer error'))
+assert.deepEqual(layer.state().covered, [], 'late error restores fallback')
+assert.deepEqual(removed, ['tile:valve'])
+assert.equal(layer.state().failed, 1)
+await layer.sync([])
+assert.equal(layer.state().failed, 0)
+console.log('PASS readiness, immutable diff, late resource failure and clear')
+
+let finish
+const staleRemoved = []
+const stale = createPipeModelBatches({ load: async (_, events) => {
+    await new Promise((resolve) => { finish = resolve })
+    events.ready()
+    return { id: 'stale' }
+}, remove: (model) => staleRemoved.push(model.id) })
+const waiting = stale.sync([group])
+await stale.clear()
+finish()
+await waiting
+assert.deepEqual(stale.state().covered, [])
+assert.deepEqual(staleRemoved, ['stale'])
+
+const splitEvents = []
+const split = createPipeModelBatches({ load: async (g, e) => { splitEvents.push(e); return {} }, remove: () => {} })
+await split.sync([{ key:'a', instances:[{id:'pipe:p'}] },{key:'b', instances:[{id:'pipe:p'}]}])
+splitEvents[0].ready()
+assert.deepEqual(split.state().covered, [], 'do not hide fallback until ALL path spans are ready')
+splitEvents[1].ready()
+assert.deepEqual(split.state().covered, ['pipe:p'])
+console.log('PASS stale completions cannot resurrect, split path waits for all spans')
+
+let clock = 0
+const attempts = []
+const retry = createPipeModelBatches({ now: () => clock, retryMs: 100, load: async (_, events) => {
+    attempts.push(events)
+    return {}
+}, remove: () => {} })
+await retry.sync([group])
+attempts[0].error()
+await retry.sync([group])
+assert.equal(attempts.length, 1, 'retry must respect cooldown')
+clock = 101
+await retry.sync([group])
+assert.equal(attempts.length, 2, 'same input recovers after temporary failure')
+attempts[1].ready()
+assert.deepEqual(retry.state().covered, ['component:v'])
+const stable = {key:'stable', instances:[{id:'component:s'}]}
+await retry.sync([group, stable])
+assert.equal(attempts.length, 3, 'adding one batch leaves ready batch intact')
+console.log('PASS failed batch retries with cooldown, incremental additions retain ready batches')

+ 27 - 0
.scratch/cockpit-real-data-wayfinding/verify/t32-pipe-model-plan.mjs

@@ -0,0 +1,27 @@
+import assert from 'node:assert/strict'
+import { planPipeModels } from '../../../src/modules/cockpit/domain/pipeModels.js'
+
+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.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')
+
+const parts = planPipeModels({ pipes: [], components: [
+    { code: 'v', kind: 'valve', industry: 'GAS', position: [110.39, 28.44] },
+    { code: 't', kind: 'branch', label: 'tee', position: [110.39, 28.44] },
+    { code: 'x', kind: 'branch', label: 'cross', position: [110.39, 28.44] },
+    { code: 'e', kind: 'elbow', position: [110.39, 28.44] },
+    { code: 'u', kind: 'other', label: 'regulator', position: [110.39, 28.44] },
+] })
+assert.deepEqual(parts.instances.map((row) => row.kind), ['valve', 'branch', 'cross', 'elbow'])
+assert.equal(parts.instances[0].sizeM, 1)
+assert.equal(parts.instances[0].orientationSource, 'fixed-approximate')
+assert.deepEqual(parts.fallbacks, [{ id: 'component:u', reason: '未建模构件,保留原始符号' }])
+console.log('PASS four classes and cross variant; unknown does not masquerade')

+ 8 - 0
CONTEXT.md

@@ -28,6 +28,14 @@ _Avoid_: 设备点位、监测设备(监测点是设备的空间承载对象
 承载城市生命线设施的独立空间业务对象,包括管网、泵站、水厂、水源地、用水户等类型(各类型在后端独立成表);它与监测点同属一张图的可显示对象、同级别,但不计入监测点统计。管线是设施中特殊的一类,以折线几何呈现并后续持久化进场景。
 _Avoid_: 监测点、把设施点并入监测点统计、把聚合 KPI 当设施点
 
+**管道示意模型**:
+依真实管段路径或真实构件类型与位置呈现的体积表达;标准构件外形、无来源的尺寸与安装朝向均为示意,不等于工程竣工模型。
+_Avoid_: 真实安装模型、用示意构件推断实际连接拓扑
+
+**虚拟埋深**:
+为地下可视化明确指定的地表以下距离,不是测量或后端发布的真实管道埋深。
+_Avoid_: 实测埋深、真实高程
+
 **关联监测设备图层**:
 以当前所选监测点为归属、按一级模式显示其关联监测设备的空间图层;它是显示概念,不是独立业务对象,也不改变未关联设备的身份。
 _Avoid_: 设备点位(指监测设备本身时)、把该图层称为新的监测点

+ 11 - 0
public/cockpit/models/pipes/README.md

@@ -0,0 +1,11 @@
+# 管道 glTF 标准示意素材
+
+此目录的五份 glTF 属于四类模型:样条管体、90° 弯头、手轮阀门、支管(三通与十字变体)。素材由仓库自己的生成器制作,无外部下载模型或纹理。按项目 Apache-2.0 许可证分发。
+
+运行 `node tools/pipe-models/build-assets.mjs` 可重建。每类 glTF 引用同名 `.bin` 几何缓冲;运行时只请求一次模板,再生成局部 TRS 实例流。同类空间批次引用稳定的几何 URL,以复用 Cesium 资源缓存,不为每个构件独立加载模型。
+
+管体基准为直径 1m、长度 1m 的圆柱,从 glTF +Z=0 到 +Z=1;运行时横向按后端管径(mm→m)缩放、纵向沿真实路径缩放。为跟随地形,长路径在原折线上细分为最长约 20m 的小段,不平滑拐角、不推断连接拓扑。
+
+构件是标准示意件,最大主体跨度约 1m。它们不代表厂家型号、真实弯曲角度、安装尺寸或真实朝向。构件朝向固定,仅用于类型辨认。模型中心/中心线使用地表采样高程减 **虚拟埋深 2m**;这不是后端实测埋深。
+
+缺少管径、未知构件类型、地表采样失败或素材加载失败时,保留既有折线/符号。只有模型 `readyEvent` 后才撤下备用表达;后续资源错误恢复备用表达。临时失败可在后续刷新时按 30s 冷却重试。地下显示沿用驾驶舱现有管线视图,不额外改变地形遮挡纪律。

二進制
public/cockpit/models/pipes/branch.bin


+ 1 - 0
public/cockpit/models/pipes/branch.gltf

@@ -0,0 +1 @@
+{"asset":{"version":"2.0","generator":"City Lifeline original demonstration pipe meshes"},"scene":0,"scenes":[{"nodes":[0]}],"nodes":[{"mesh":0,"name":"branch"}],"meshes":[{"primitives":[{"attributes":{"POSITION":0,"NORMAL":1},"indices":2,"material":0}]}],"materials":[{"doubleSided":true,"pbrMetallicRoughness":{"baseColorFactor":[0.25,0.9,0.35,1],"metallicFactor":0.15,"roughnessFactor":0.65}}],"buffers":[{"byteLength":2976,"uri":"branch.bin"}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":1200,"target":34962},{"buffer":0,"byteOffset":1200,"byteLength":1200,"target":34962},{"buffer":0,"byteOffset":2400,"byteLength":576,"target":34963}],"accessors":[{"bufferView":0,"componentType":5126,"count":100,"type":"VEC3","min":[-0.12,-0.12,-0.5],"max":[0.5,0.12,0.5]},{"bufferView":1,"componentType":5126,"count":100,"type":"VEC3"},{"bufferView":2,"componentType":5123,"count":288,"type":"SCALAR"}],"extras":{"demonstration":true,"kind":"branch","units":"metres","splineAxis":"glTF +Z, start 0, end 1","installationOrientation":"unknown; fixed illustrative ENU orientation"}}

二進制
public/cockpit/models/pipes/cross.bin


+ 1 - 0
public/cockpit/models/pipes/cross.gltf

@@ -0,0 +1 @@
+{"asset":{"version":"2.0","generator":"City Lifeline original demonstration pipe meshes"},"scene":0,"scenes":[{"nodes":[0]}],"nodes":[{"mesh":0,"name":"cross"}],"meshes":[{"primitives":[{"attributes":{"POSITION":0,"NORMAL":1},"indices":2,"material":0}]}],"materials":[{"doubleSided":true,"pbrMetallicRoughness":{"baseColorFactor":[0.25,0.9,0.35,1],"metallicFactor":0.15,"roughnessFactor":0.65}}],"buffers":[{"byteLength":2976,"uri":"cross.bin"}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":1200,"target":34962},{"buffer":0,"byteOffset":1200,"byteLength":1200,"target":34962},{"buffer":0,"byteOffset":2400,"byteLength":576,"target":34963}],"accessors":[{"bufferView":0,"componentType":5126,"count":100,"type":"VEC3","min":[-0.5,-0.12,-0.5],"max":[0.5,0.12,0.5]},{"bufferView":1,"componentType":5126,"count":100,"type":"VEC3"},{"bufferView":2,"componentType":5123,"count":288,"type":"SCALAR"}],"extras":{"demonstration":true,"kind":"cross","units":"metres","splineAxis":"glTF +Z, start 0, end 1","installationOrientation":"unknown; fixed illustrative ENU orientation"}}

二進制
public/cockpit/models/pipes/elbow.bin


+ 1 - 0
public/cockpit/models/pipes/elbow.gltf

@@ -0,0 +1 @@
+{"asset":{"version":"2.0","generator":"City Lifeline original demonstration pipe meshes"},"scene":0,"scenes":[{"nodes":[0]}],"nodes":[{"mesh":0,"name":"elbow"}],"meshes":[{"primitives":[{"attributes":{"POSITION":0,"NORMAL":1},"indices":2,"material":0}]}],"materials":[{"doubleSided":true,"pbrMetallicRoughness":{"baseColorFactor":[1,0.72,0.18,1],"metallicFactor":0.15,"roughnessFactor":0.65}}],"buffers":[{"byteLength":4512,"uri":"elbow.bin"}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":1608,"target":34962},{"buffer":0,"byteOffset":1608,"byteLength":1608,"target":34962},{"buffer":0,"byteOffset":3216,"byteLength":1296,"target":34963}],"accessors":[{"bufferView":0,"componentType":5126,"count":134,"type":"VEC3","min":[-0.3694221672006636,-0.12000000000000002,-0.26176205683954723],"max":[0.2617620568395473,0.12000000000000002,0.3694221672006636]},{"bufferView":1,"componentType":5126,"count":134,"type":"VEC3"},{"bufferView":2,"componentType":5123,"count":648,"type":"SCALAR"}],"extras":{"demonstration":true,"kind":"elbow","units":"metres","splineAxis":"glTF +Z, start 0, end 1","installationOrientation":"unknown; fixed illustrative ENU orientation"}}

二進制
public/cockpit/models/pipes/spline.bin


+ 1 - 0
public/cockpit/models/pipes/spline.gltf

@@ -0,0 +1 @@
+{"asset":{"version":"2.0","generator":"City Lifeline original demonstration pipe meshes"},"scene":0,"scenes":[{"nodes":[0]}],"nodes":[{"mesh":0,"name":"spline"}],"meshes":[{"primitives":[{"attributes":{"POSITION":0,"NORMAL":1},"indices":2,"material":0}]}],"materials":[{"doubleSided":true,"pbrMetallicRoughness":{"baseColorFactor":[0.9,0.9,0.9,1],"metallicFactor":0.15,"roughnessFactor":0.65}}],"buffers":[{"byteLength":1488,"uri":"spline.bin"}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":600,"target":34962},{"buffer":0,"byteOffset":600,"byteLength":600,"target":34962},{"buffer":0,"byteOffset":1200,"byteLength":288,"target":34963}],"accessors":[{"bufferView":0,"componentType":5126,"count":50,"type":"VEC3","min":[-0.5,-0.5,0],"max":[0.5,0.5,1]},{"bufferView":1,"componentType":5126,"count":50,"type":"VEC3"},{"bufferView":2,"componentType":5123,"count":144,"type":"SCALAR"}],"extras":{"demonstration":true,"kind":"spline","units":"metres","splineAxis":"glTF +Z, start 0, end 1","installationOrientation":"unknown; fixed illustrative ENU orientation"}}

二進制
public/cockpit/models/pipes/valve.bin


+ 1 - 0
public/cockpit/models/pipes/valve.gltf

@@ -0,0 +1 @@
+{"asset":{"version":"2.0","generator":"City Lifeline original demonstration pipe meshes"},"scene":0,"scenes":[{"nodes":[0]}],"nodes":[{"mesh":0,"name":"valve"}],"meshes":[{"primitives":[{"attributes":{"POSITION":0,"NORMAL":1},"indices":2,"material":0}]}],"materials":[{"doubleSided":true,"pbrMetallicRoughness":{"baseColorFactor":[1,0.2,0.2,1],"metallicFactor":0.15,"roughnessFactor":0.65}}],"buffers":[{"byteLength":11808,"uri":"valve.bin"}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":4464,"target":34962},{"buffer":0,"byteOffset":4464,"byteLength":4464,"target":34962},{"buffer":0,"byteOffset":8928,"byteLength":2880,"target":34963}],"accessors":[{"bufferView":0,"componentType":5126,"count":372,"type":"VEC3","min":[-0.255,-0.21,-0.5],"max":[0.255,0.41500000000000004,0.5]},{"bufferView":1,"componentType":5126,"count":372,"type":"VEC3"},{"bufferView":2,"componentType":5123,"count":1440,"type":"SCALAR"}],"extras":{"demonstration":true,"kind":"valve","units":"metres","splineAxis":"glTF +Z, start 0, end 1","installationOrientation":"unknown; fixed illustrative ENU orientation"}}

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

@@ -41,6 +41,15 @@
             实例 {{ instanceId }} · {{ basemapLabel }}
         </p>
 
+        <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 }} 批 ·
+            折线/符号 {{ pipeModelState.fallback }} 对象
+            <template v-if="pipeModelState.planning || pipeModelState.pending"> · 模型加载中(保留降级表达)</template>
+            <template v-if="pipeModelState.failed"> · {{ pipeModelState.failed }} 批素材失败,已降级</template>
+            <template v-if="pipeModelState.terrainFailed"> · 地表高程采样失败,已保留折线/符号,稍后刷新重试</template>
+        </p>
+
         <!-- 坐标准入明细(票 07):原先是压在地图上的独立浮块,现随本内容块一起进入状态面板。 -->
         <MapCoordinateBlocked :overview="coordinateOverview" :embedded="true" />
 
@@ -58,6 +67,12 @@ import { onBeforeUnmount, onMounted, ref } from 'vue'
 import CockpitIcon from './CockpitIcon.vue'
 import MapCoordinateBlocked from './MapCoordinateBlocked.vue'
 
+import { PIPE_MODEL_NOTE } from '../domain/pipeModels.js'
+
+const pipeModelState = ref(null)
+let modelHost = null
+const updatePipeModelState = (event) => { pipeModelState.value = event.detail }
+
 defineProps({
     modeLabel: { type: String, default: '' },
     uiDebug: { type: Boolean, default: false },
@@ -100,11 +115,15 @@ onMounted(() => {
     // 引擎节点是 mapService 异步建的,插槽先空着;用观察器补搬一次即可。
     const host = document.querySelector('.map-canvas')
     if (host) {
+        modelHost = host
+        host.addEventListener('cockpit-pipe-model-state', updatePipeModelState)
         observer = new MutationObserver(relocateEngineNode)
         observer.observe(host, { childList: true })
     }
 })
 onBeforeUnmount(() => {
+    if (modelHost) modelHost.removeEventListener('cockpit-pipe-model-state', updatePipeModelState)
+    modelHost = null
     if (observer) observer.disconnect()
     observer = null
 })

+ 30 - 0
src/modules/cockpit/domain/pipeModels.js

@@ -0,0 +1,30 @@
+/** 管体是沿真实折线路径的体积示意;虚拟埋深不是业务实测值。 */
+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、固定朝向(非真实安装朝向);已知管径按真实值,缺管径/素材/地表采样时保留折线或符号。'
+
+/** 输入只接收 toPipeNetwork 已准入且经模式计划裁剪后的对象,不再推断坐标/拓扑。 */
+export function planPipeModels(network = {}) {
+    const instances = []
+    const fallbacks = []
+    for (const pipe of network.pipes || []) {
+        const id = `pipe:${pipe.code}`
+        if (!(Number.isFinite(pipe.diameter) && pipe.diameter > 0)) {
+            fallbacks.push({ id, reason: '缺管径,保留折线' })
+            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: pipe.diameter / 1000 })
+        }
+    }
+    for (const component of network.components || []) {
+        const id = `component:${component.code}`
+        if (!['elbow', 'valve', 'branch'].includes(component.kind)) {
+            fallbacks.push({ id, reason: '未建模构件,保留原始符号' })
+            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' })
+    }
+    return { instances, fallbacks, virtualDepthM: PIPE_MODEL_POLICY.virtualDepthM }
+}

+ 29 - 6
src/modules/cockpit/services/cesiumPipeLayer.js

@@ -9,15 +9,19 @@
  * 于是 `pipelineSource(viewer)` 那条现成的读取路径直接可用——**常量值必须与引擎一致**,
  * 两边各写一份字面量是已知取舍,等并发会话落地后合并为单一来源。
  *
- * 渲染口径(与 `domain/pipeNetwork.js` 同源,引擎不再做第二次判定):
- *   · 管段:`clampToGround` 贴地折线,管径映射线宽,行业映射颜色;
- *   · 构件:阀门 / 角管道 / 支管管道各用**可辨识符号 + 中文标签**表达。
- *     glTF 预制构件实例化需要素材库,仓库内暂无管线素材,故先用符号表达并保留扩展位——
- *     **不拿临时符号冒充"已建成的管道模型"**。
+ * 渲染口径(与领域建模计划同源,引擎不再做第二次准入判定):
+ *   · 已知管径/类型:独立 Cesium GPU 批次、地表采样减虚拟埋深 2m;
+ *   · 模型未就绪、缺管径/类型/素材/高程:保留贴地折线或符号标签。
+ *     glTF 在 readyEvent 后替代降级表达,示意外形与近似朝向不冒充真实安装信息。
  */
 import { Cartesian2, Cartesian3, Color, CustomDataSource, HeightReference, LabelStyle, VerticalOrigin } from 'cesium'
 import { pipeWidth } from '../domain/pipeNetwork.js'
 
+// Viewer-scoped batches; loaded lazily so symbol-only/headless paths do not load the model adapter.
+const modelLayers = new WeakMap()
+const layerVersions = new WeakMap()
+let modelAdapterPromise = null
+
 /** 与 `cesiumEngine.js` 的 `PIPELINE_SOURCE` 同值。 */
 export const PIPE_SOURCE_NAME = 'cockpit-pipelines'
 
@@ -121,11 +125,14 @@ function componentEntity(code, component) {
  * 每次重建 500+ 实体既慢又会闪。
  * @returns {{pipes:number, components:number, rendered:boolean}} 实际画上去的条数
  */
-export async function syncPipeLayer(viewer, network) {
+export async function syncPipeLayer(viewer, network, { onModelChange = () => {} } = {}) {
     if (!viewer || !viewer.dataSources) return { pipes: 0, components: 0, rendered: false }
+    const version = (layerVersions.get(viewer) || 0) + 1
+    layerVersions.set(viewer, version)
     clearIndex(viewer)
 
     const source = await ensureSource(viewer)
+    if (layerVersions.get(viewer) !== version) return { pipes: 0, components: 0, rendered: false }
     const pipes = (network && Array.isArray(network.pipes)) ? network.pipes : []
     const components = (network && Array.isArray(network.components)) ? network.components : []
     const wanted = new Set()
@@ -167,11 +174,27 @@ export async function syncPipeLayer(viewer, network) {
         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).
+    if (viewer.scene && viewer.scene.primitives && !viewer.isDestroyed()) {
+        modelAdapterPromise ||= import('./cesiumPipeModels.js')
+        const { createCesiumPipeModels } = await modelAdapterPromise
+        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 })
+            modelLayers.set(viewer, layer)
+        }
+        layer.sync({ pipes, components })
+    }
     return { pipes: pipes.length, components: components.length, rendered: true }
 }
 
 /** 引导态/退出驾驶舱时清空并忘记索引(换 Viewer 后索引必须重建,否则张冠李戴)。 */
 export async function clearPipeLayer(viewer) {
+    if (viewer) layerVersions.set(viewer, (layerVersions.get(viewer) || 0) + 1)
+    const layer = viewer && modelLayers.get(viewer)
+    if (layer) { layer.destroy(); modelLayers.delete(viewer) }
     const source = viewer && viewer.dataSources ? viewer.dataSources.getByName(PIPE_SOURCE_NAME)[0] : null
     if (source) source.entities.removeAll()
     entityIndex = new Map()

+ 186 - 0
src/modules/cockpit/services/cesiumPipeModels.js

@@ -0,0 +1,186 @@
+/** Cesium-only adapter: terrain - virtual depth, ENU tiles, glTF GPU instance streams. */
+import { Cartesian3, Cartographic, Color, 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'
+
+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 gltfCoordinates = (point) => enuToGltf([point.x, point.y, point.z])
+
+async function templateOf(kind, base) {
+    const url = `${base}cockpit/models/pipes/${kind}.gltf`
+    if (!templates.has(url)) {
+        const promise = fetch(url).then(async (response) => {
+            if (!response.ok) throw new Error(`管件素材 ${response.status}`)
+            const gltf = await response.json()
+            if (gltf.asset?.version !== '2.0' || gltf.nodes?.length !== 1 || !gltf.meshes?.length) throw new Error('Invalid pipe template')
+            for (const buffer of gltf.buffers || []) {
+                const resource = new URL(buffer.uri, new URL(url, window.location.href))
+                if (resource.origin !== window.location.origin) throw new Error('Pipe buffer must be same-origin')
+                buffer.uri = resource.href
+            }
+            return gltf
+        }).catch((error) => { templates.delete(url); throw error })
+        templates.set(url, promise)
+    }
+    return templates.get(url)
+}
+
+function rotationFromZ(direction) {
+    const length = Cartesian3.magnitude(direction)
+    const unit = Cartesian3.divideByScalar(direction, length, new Cartesian3())
+    const dot = Math.max(-1, Math.min(1, unit.z))
+    if (dot > 0.99999999) return [0,0,0,1]
+    const axis = dot < -0.99999999 ? Cartesian3.UNIT_X : Cartesian3.normalize(Cartesian3.cross(Cartesian3.UNIT_Z, unit, new Cartesian3()), new Cartesian3())
+    const q = Quaternion.fromAxisAngle(axis, Math.acos(dot), new Quaternion())
+    return [q.x,q.y,q.z,q.w]
+}
+
+/** Geographic subspans lie ON the original path; not smoothing and not connection inference. */
+function terrainSpans(instance) {
+    if (instance.kind !== 'spline') return [instance]
+    const a=instance.start,b=instance.end
+    const approximateM=Math.hypot((b[0]-a[0])*Math.cos(a[1]*DEG),(b[1]-a[1]))*111320
+    const count=Math.max(1,Math.ceil(approximateM/20))
+    const point=(t)=>[a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t]
+    return Array.from({length:count},(_,i)=>({...instance,start:point(i/count),end:point((i+1)/count)}))
+}
+
+async function groupsFor(viewer, plan, heights, alive) {
+    const rows=plan.instances.flatMap(terrainSpans)
+    const needed=new Map()
+    const keyOf=(p)=>`${p[0]},${p[1]}`
+    for(const row of rows) for(const point of row.kind==='spline'?[row.start,row.end]:[row.position]) {
+        const key=keyOf(point)
+        if(!heights.has(key)) needed.set(key,point)
+    }
+    const entries=[...needed]
+    for(let i=0;i<entries.length;i+=512) {
+        if(!alive()) return []
+        const chunk=entries.slice(i,i+512)
+        try {
+            const values=await sampleTerrainMostDetailed(viewer.terrainProvider,chunk.map(([,p])=>Cartographic.fromDegrees(p[0],p[1])))
+            if(!alive()) return []
+            for(let j=0;j<chunk.length;j+=1) if(Number.isFinite(values[j]?.height)) heights.set(chunk[j][0],values[j].height)
+        } catch { /* No invented ground elevation: retain fallback. */ }
+    }
+    const world=(p)=>{
+        const height=heights.get(keyOf(p))
+        return Number.isFinite(height)?Cartesian3.fromDegrees(p[0],p[1],height-plan.virtualDepthM):null
+    }
+    const groups=new Map(), missing=new Set()
+    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 origin=[(tileX+.5)*TILE_DEG,(tileY+.5)*TILE_DEG]
+        const matrix=Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(...origin))
+        const inverse=Matrix4.inverseTransformation(matrix,new Matrix4())
+        const a=world(p),b=row.end?world(row.end):null
+        if(!a||(row.end&&!b)){missing.add(row.id);continue}
+        const local=Matrix4.multiplyByPoint(inverse,a,new Cartesian3())
+        let translation=gltfCoordinates(local),rotation=[0,0,0,1],scale=[row.sizeM,row.sizeM,row.sizeM]
+        if(row.kind==='spline') {
+            const end=Matrix4.multiplyByPoint(inverse,b,new Cartesian3()),delta=Cartesian3.subtract(end,local,new Cartesian3())
+            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]
+        }
+        if(!groups.has(key)) groups.set(key,{key,kind:row.kind,industry:row.industry,origin,instances:[]})
+        groups.get(key).instances.push({id:row.id,translation,rotation,scale})
+    }
+    // One missing span keeps its entire business pipe's fallback visible.
+    return { groups:[...groups.values()], missing }
+}
+
+export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=import.meta.env?.BASE_URL||'/' }={}) {
+    const heights=new Map()
+    let active=true,generation=0,fingerprint='',missing=new Set(),allIds=[],task=null,planning=false
+    let lastGroups=[], signatures=new Map(), terrainFailed=false, lastTerrainAttempt=0
+    const publish=(state)=>{
+        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
+        }
+        onChange({...state,covered:[...covered],fallback:allIds.length-covered.size,planning,terrainFailed,note:PIPE_MODEL_NOTE})
+        viewer.scene.requestRender()
+    }
+    const batches=createPipeModelBatches({
+        load:async(group,events)=>{
+            const template=await templateOf(group.kind,base)
+            if (!active || !events.alive() || viewer.isDestroyed()) throw new Error('Cancelled pipe batch')
+            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'}))
+            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})
+                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}
+        },
+        remove:({model,url})=>{
+            URL.revokeObjectURL(url)
+            if(!viewer.isDestroyed()&&!model.isDestroyed()) viewer.scene.primitives.remove(model)
+            else if(!model.isDestroyed()) model.destroy()
+        },
+        onChange:publish,
+    })
+    function sync(network) {
+        if (!active || viewer.isDestroyed()) return Promise.resolve()
+        const plan=planPipeModels(network)
+        const next=JSON.stringify(plan)
+        if(next===fingerprint && !(terrainFailed && Date.now()-lastTerrainAttempt>=30000)) {
+            if (planning) return task
+            task=batches.sync(lastGroups)
+            publish(batches.state())
+            return task
+        }
+        fingerprint=next
+        const token=++generation
+        const previousSignatures=signatures
+        const byId=new Map()
+        for(const row of plan.instances) {
+            if(!byId.has(row.id)) byId.set(row.id,[])
+            byId.get(row.id).push(row)
+        }
+        signatures=new Map([...byId].map(([id,rows])=>[id,JSON.stringify(rows)]))
+        allIds=[...(network.pipes||[]).map((p)=>`pipe:${p.code}`),...(network.components||[]).map((c)=>`component:${c.code}`)]
+        planning=true
+        // Remove only changed/removed batches before awaits. Keep stable geometry visible.
+        batches.retain((group)=>group.instances.every((row)=>signatures.has(row.id)&&signatures.get(row.id)===previousSignatures.get(row.id)))
+        task=(async()=>{
+            const alive=()=>active&&token===generation&&!viewer.isDestroyed()
+            lastTerrainAttempt=Date.now()
+            const result=await groupsFor(viewer,plan,heights,alive)
+            if(!alive()) return
+            missing=result.missing
+            terrainFailed=missing.size>0
+            for(const row of plan.fallbacks) missing.add(row.id)
+            lastGroups=result.groups
+            planning=false
+            await batches.sync(lastGroups)
+            if(alive()) publish(batches.state())
+        })().catch(()=>{if(active&&token===generation){terrainFailed=true;planning=false;publish(batches.state())}})
+        return task
+    }
+    function destroy() {
+        active=false;generation+=1
+        batches.clear()
+        heights.clear()
+    }
+    return {sync,destroy,state:batches.state}
+}

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

@@ -1002,6 +1002,7 @@ export function unmountMap() {
         record.tilesetCulling = null
     }
     if (record.viewer) {
+        if (record.pipeLayer) record.pipeLayer.clearPipeLayer(record.viewer)
         record.engine.destroyViewer(record.viewer)
         record.viewer = null
     }
@@ -2379,9 +2380,26 @@ export async function syncPipeLayerForPlan(plan) {
     }
     if (!instance || !instance.viewer) return false
 
+    const record = instance
     const engine = await import('./cesiumPipeLayer.js')
-    await engine.syncPipeLayer(instance.viewer, layer)
-    const host = instance.host
+    if (instance !== record || !record.viewer) return false
+    record.pipeLayer = engine
+    await engine.syncPipeLayer(record.viewer, layer, { onModelChange: (state) => {
+        if (instance !== record || !record.host) return
+        const host = record.host
+        if (host.dataset) {
+            host.dataset.mapPipeModelBatches = String(state.batches)
+            host.dataset.mapPipeModelInstances = String(state.instances)
+            host.dataset.mapPipeModelFallback = String(state.fallback)
+            host.dataset.mapPipeModelPending = String(state.pending)
+            host.dataset.mapPipeModelFailed = String(state.failed)
+            host.dataset.mapPipeModelKinds = JSON.stringify(state.byKind)
+            host.dataset.mapPipeVirtualDepth = '2'
+        }
+        host.dispatchEvent(new CustomEvent('cockpit-pipe-model-state', { detail: state }))
+    } })
+    if (instance !== record) return false
+    const host = record.host
     if (host && host.dataset) {
         host.dataset.mapPipes = String(layer.pipes.length)
         host.dataset.mapPipeComponents = String(layer.components.length)

+ 34 - 0
src/modules/cockpit/services/pipeInstanceGltf.js

@@ -0,0 +1,34 @@
+/** Inverse of Cesium's default Y-up/Z-forward correction: glTF [x,y,z] -> ENU [z,x,y]. */
+export const enuToGltf = ([east, north, up]) => [north, up, east]
+
+/** Add one GPU instance stream; stable external geometry URLs are shared across batches. */
+export function instanceGltf(template, instances) {
+    if (!instances.length) throw new Error('Empty instance batch')
+    const document = structuredClone(template)
+    const values = new Float32Array(instances.length * 10)
+    let offset = 0
+    for (const field of ['translation', 'rotation', 'scale']) {
+        for (const instance of instances) {
+            values.set(instance[field], offset)
+            offset += instance[field].length
+        }
+    }
+    const bytes = new Uint8Array(values.buffer)
+    let binary = ''
+    for (let i = 0; i < bytes.length; i += 8192) binary += String.fromCharCode(...bytes.subarray(i, i + 8192))
+    const buffer = document.buffers.length
+    document.buffers.push({ byteLength: bytes.length, uri: `data:application/octet-stream;base64,${btoa(binary)}` })
+    const attributes = {}
+    offset = 0
+    for (const [field, size, type] of [['TRANSLATION',3,'VEC3'],['ROTATION',4,'VEC4'],['SCALE',3,'VEC3']]) {
+        const view = document.bufferViews.length
+        document.bufferViews.push({ buffer, byteOffset: offset, byteLength: instances.length * size * 4, target: 34962 })
+        attributes[field] = document.accessors.length
+        document.accessors.push({ bufferView: view, componentType: 5126, count: instances.length, type })
+        offset += instances.length * size * 4
+    }
+    document.nodes[0].extensions = { EXT_mesh_gpu_instancing: { attributes } }
+    document.extensionsUsed = [...new Set([...(document.extensionsUsed || []), 'EXT_mesh_gpu_instancing'])]
+    document.extensionsRequired = [...new Set([...(document.extensionsRequired || []), 'EXT_mesh_gpu_instancing'])]
+    return document
+}

+ 70 - 0
src/modules/cockpit/services/pipeModelBatches.js

@@ -0,0 +1,70 @@
+/** Batch lifecycle boundary. A successful load promise does not imply GPU readiness. */
+export function createPipeModelBatches({ load, remove, onChange = () => {}, now = Date.now, retryMs = 30000 }) {
+    const records = new Map()
+    const state = () => {
+        const covered = new Set(), incomplete = new Set()
+        let pending = 0, failed = 0, batches = 0, instances = 0
+        const byKind = {}
+        for (const record of records.values()) {
+            if (record.status === 'pending') pending += 1
+            if (record.status === 'failed') failed += 1
+            if (record.status !== 'ready') {
+                for (const instance of record.group.instances) incomplete.add(instance.id)
+                continue
+            }
+            batches += 1
+            instances += record.group.instances.length
+            byKind[record.group.kind] = (byKind[record.group.kind] || 0) + record.group.instances.length
+            for (const instance of record.group.instances) covered.add(instance.id)
+        }
+        return { covered: [...covered].filter((id) => !incomplete.has(id)), pending, failed, batches, instances, byKind }
+    }
+    const changed = () => onChange(state())
+    function dispose(record) {
+        record.active = false
+        if (record.model) { remove(record.model); record.model = null }
+    }
+    async function sync(groups) {
+        const wanted = new Set(groups.map((group) => group.key))
+        for (const [key, record] of records) {
+            if (!wanted.has(key)) { dispose(record); records.delete(key) }
+        }
+        const jobs = []
+        for (const group of groups) {
+            const fingerprint = JSON.stringify(group)
+            const previous = records.get(group.key)
+            if (previous && previous.fingerprint === fingerprint && (previous.status !== 'failed' || now() - previous.failedAt < retryMs)) continue
+            if (previous) dispose(previous)
+            const record = { group, fingerprint, active: true, status: 'pending', model: null }
+            records.set(group.key, record)
+            const error = () => {
+                if (!record.active) return
+                record.status = 'failed'
+                record.failedAt = now()
+                if (record.model) { remove(record.model); record.model = null }
+                changed()
+            }
+            jobs.push((async () => {
+                try {
+                    const model = await load(group, {
+                        alive: () => record.active,
+                        ready: () => { if (record.active && record.status === 'pending') { record.status = 'ready'; changed() } },
+                        error,
+                    })
+                    if (!record.active || record.status === 'failed') remove(model)
+                    else record.model = model
+                } catch { error() }
+            })())
+        }
+        changed()
+        await Promise.all(jobs)
+        return state()
+    }
+    function retain(predicate) {
+        for (const [key, record] of records) {
+            if (!predicate(record.group)) { dispose(record); records.delete(key) }
+        }
+        changed()
+    }
+    return { sync, state, retain, clear: () => sync([]) }
+}

+ 14 - 0
tools/pipe-models/README.md

@@ -0,0 +1,14 @@
+# 管道模型验收页
+
+本页是隔离验收工具,不是业务页面。使用当前项目的 `yarn dev` 服务,不另起服务器。Fes 的 HTML fallback 会拦截工具目录路径,需临时把 `acceptance.html` 复制到 `public/cockpit/pipe-acceptance.html`,然后访问 `/cockpit/pipe-acceptance.html`;验完删除临时 public 文件。
+
+Cesium import 使用当前 dev server 的预打包入口 `/.cache/deps/cesium.js`。本工具只能在 dev 模式使用,生产包中没有该入口;不要把验收页作为生产产品发布。
+
+- **四类示意件**:固定隔离坐标上的样条、弯头、阀门、三通、十字,另有未建模类型。界面明确标为样例,不进入后端或业务统计。
+- **真实几何**:只读当前部署 `/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,不得用低分辨率结果冒充达标。

+ 79 - 0
tools/pipe-models/acceptance.html

@@ -0,0 +1,79 @@
+<!doctype html>
+<html lang="zh-CN"><head><meta charset="utf-8"><title>管道模型隔离验收(非业务数据)</title>
+<style>html,body,#map{margin:0;width:100%;height:100%;overflow:hidden;background:#04121f;color:white;font:16px sans-serif}aside{position:absolute;top:8px;left:8px;z-index:5;background:#04121fe8;padding:12px;max-width:90vw}button{margin:4px;padding:8px}pre{white-space:pre-wrap;margin:4px;font:12px monospace;max-height:24vh;overflow:auto}</style></head>
+<body><div id="map"></div><aside><strong>隔离验收:真实数据与构件样例分开,不修改业务接口/统计</strong><br>
+<button id="examples">四类示意件</button><button id="real">真实几何</button><button id="stress">隔离压力样例(虚拟管径)</button><button id="ground">地面视图</button><button id="underground">地下视图</button><button id="fps">采样 10 秒真实渲染 FPS</button><button id="clear">清理</button>
+<pre id="state">加载引擎</pre><pre id="performance"></pre></aside>
+<script type="module">
+window.CESIUM_BASE_URL='/cesium/'
+const missingKind=new URLSearchParams(location.search).get('missing')
+const originalFetch=window.fetch.bind(window)
+if(['spline','elbow','valve','branch','cross'].includes(missingKind)) {
+ window.fetch=(input,options)=>String(input).endsWith(`/models/pipes/${missingKind}.gltf`)?Promise.resolve(new Response('isolated missing asset',{status:404})):originalFetch(input,options)
+}
+const { Cartesian3, HeadingPitchRange, Color }=await import('/.cache/deps/cesium.js')
+const { createViewer, applyTerrain }=await import('/src/modules/cockpit/services/cesiumEngine.js')
+const { syncPipeLayer, clearPipeLayer }=await import('/src/modules/cockpit/services/cesiumPipeLayer.js')
+const { toPipeNetwork }=await import('/src/modules/cockpit/domain/pipeNetwork.js')
+const config=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:config.map.terrain.url,requestVertexNormals:true})
+let network={pipes:[],components:[]}
+viewer.canvas.setAttribute('aria-label','管道模型验收画面')
+viewer.canvas.setAttribute('role','img')
+let focus={lon:config.map.home.lon,lat:config.map.home.lat,height:100,range:config.map.home.range}
+const paint=(state)=>{const {covered=[],...counts}=state;document.querySelector('#state').textContent=JSON.stringify({mode:document.body.dataset.mode,missingAsset:missingKind,width:viewer.canvas.width,height:viewer.canvas.height,pipes:network.pipes.length,components:network.components.length,coveredObjects:covered.length,...counts},null,2);document.body.dataset.instances=state.instances;document.body.dataset.failed=state.failed;document.body.dataset.fallback=state.fallback;document.body.dataset.pending=state.pending}
+const draw=()=>syncPipeLayer(viewer,network,{onModelChange:paint})
+const look=(lon,lat,height,range)=>{focus={lon,lat,height,range};viewer.camera.lookAt(Cartesian3.fromDegrees(lon,lat,height),new HeadingPitchRange(0,-Math.PI/5,range))}
+const underground=()=>{viewer.scene.globe.translucency.enabled=true;viewer.scene.globe.translucency.frontFaceAlpha=.2;viewer.scene.globe.translucency.backFaceAlpha=.2;viewer.scene.requestRender()}
+document.querySelector('#examples').onclick=async()=>{
+ document.body.dataset.mode='isolated-four-classes'
+ const lon=110.39028,lat=28.44368
+ network={pipes:[{code:'DEMO-SPLINE',industry:'GAS',diameter:600,positions:[[lon-.00012,lat],[lon-.00005,lat],[lon,lat+.00005]]}],components:[
+ {code:'DEMO-ELBOW',kind:'elbow',label:'elbow',industry:'GAS',position:[lon+.00004,lat]},
+ {code:'DEMO-VALVE',kind:'valve',label:'valve',industry:'GAS',position:[lon+.00007,lat]},
+ {code:'DEMO-TEE',kind:'branch',label:'tee',industry:'GAS',position:[lon+.0001,lat]},
+ {code:'DEMO-CROSS',kind:'branch',label:'cross',industry:'GAS',position:[lon+.00013,lat]},
+ {code:'DEMO-UNKNOWN',kind:'other',label:'regulator',industry:'GAS',position:[lon+.00017,lat]}]}
+ const {Cartographic,sampleTerrainMostDetailed}=await import('/.cache/deps/cesium.js')
+ const [point]=await sampleTerrainMostDetailed(viewer.terrainProvider,[Cartographic.fromDegrees(lon,lat)])
+ look(lon,lat,point.height,50);underground();await draw()
+}
+document.querySelector('#real').onclick=async()=>{
+ document.body.dataset.mode='real-geometry'
+ const response=await fetch(config.api.baseUrl+'/infrastructure/geometry').then(r=>r.json())
+ network=toPipeNetwork(response.data)
+ look(config.map.home.lon,config.map.home.lat,100,config.map.home.range);underground();await draw()
+}
+document.querySelector('#stress').onclick=async()=>{
+ document.body.dataset.mode='isolated-stress-virtual-400mm'
+ const response=await fetch(config.api.baseUrl+'/infrastructure/geometry').then(r=>r.json())
+ const real=toPipeNetwork(response.data)
+ network={pipes:real.pipes.map((pipe)=>({...pipe,diameter:400})),components:[]}
+ look(config.map.home.lon,config.map.home.lat,100,config.map.home.range);underground();await draw()
+}
+document.querySelector('#ground').onclick=()=>{viewer.scene.globe.translucency.enabled=false;viewer.scene.requestRender()}
+document.querySelector('#underground').onclick=underground
+document.querySelector('#clear').onclick=async()=>{await clearPipeLayer(viewer);paint({instances:0,failed:0,fallback:0,pending:0})}
+document.querySelector('#fps').onclick=()=>{
+ const scene=viewer.scene,old=scene.requestRenderMode
+ scene.requestRenderMode=false
+ const start=performance.now(),stamps=[]
+ const detach=scene.postRender.addEventListener(()=>{
+  const elapsed=performance.now()-start
+  stamps.push(performance.now())
+  if(elapsed<10000){
+    viewer.camera.lookAt(Cartesian3.fromDegrees(focus.lon,focus.lat,focus.height),new HeadingPitchRange(elapsed/10000*Math.PI/4,-Math.PI/5,focus.range))
+    return
+  }
+  detach();scene.requestRenderMode=old
+  const seconds=(stamps.at(-1)-stamps[0])/1000,fps=(stamps.length-1)/seconds
+  const gaps=stamps.slice(1).map((t,i)=>t-stamps[i]).sort((a,b)=>a-b)
+  const result={mode:document.body.dataset.mode,frames:stamps.length,seconds,fps,p95FrameMs:gaps[Math.floor(gaps.length*.95)],maxFrameMs:gaps.at(-1),motion:'45deg heading / 10s',canvas:[viewer.canvas.width,viewer.canvas.height],viewport:[innerWidth,innerHeight],visibility:document.visibilityState,pass:fps>=30}
+  document.querySelector('#performance').textContent=JSON.stringify(result,null,2)
+  document.body.dataset.fps=String(fps)
+ })
+}
+look(config.map.home.lon,config.map.home.lat,100,config.map.home.range)
+document.querySelector('#state').textContent='引擎已就绪;请选择样例或真实几何'
+</script></body></html>

+ 93 - 0
tools/pipe-models/build-assets.mjs

@@ -0,0 +1,93 @@
+/** Original low-poly demonstration meshes; no external assets/dependencies. */
+import { mkdir, writeFile } from 'node:fs/promises'
+import { enuToGltf } from '../../src/modules/cockpit/services/pipeInstanceGltf.js'
+const out = new URL('../../public/cockpit/models/pipes/', import.meta.url)
+await mkdir(out, { recursive: true })
+
+function geometry() {
+    const positions = [], normals = [], indices = []
+    // Canonical ENU xyz -> standard glTF Y-up, Z-forward (Cesium corrects to ENU).
+    const convert = enuToGltf
+    function vertex(p, n) {
+        const index = positions.length / 3
+        positions.push(...convert(p)); normals.push(...convert(n))
+        return index
+    }
+    function tube(path, radius, sides = 12) {
+        const rings = []
+        for (let i = 0; i < path.length; i += 1) {
+            const before = path[Math.max(0, i - 1)], after = path[Math.min(path.length - 1, i + 1)]
+            const d = after.map((v, j) => v - before[j]), length = Math.hypot(...d)
+            const tangent = d.map((v) => v / length)
+            const axis = Math.abs(tangent[2]) < 0.9 ? [0, 0, 1] : [0, 1, 0]
+            const cross = (a, b) => [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]
+            const u0 = cross(tangent, axis), uLength = Math.hypot(...u0), u = u0.map((v) => v/uLength), v = cross(tangent, u)
+            const ring = []
+            for (let j = 0; j < sides; j += 1) {
+                const angle = j * Math.PI * 2 / sides
+                const n = u.map((value, k) => value * Math.cos(angle) + v[k] * Math.sin(angle))
+                ring.push(vertex(path[i].map((value, k) => value + radius*n[k]), n))
+            }
+            rings.push(ring)
+        }
+        for (let i = 1; i < rings.length; i += 1) for (let j = 0; j < sides; j += 1) {
+            const k = (j+1)%sides, a = rings[i-1][j], b = rings[i-1][k], c = rings[i][j], d = rings[i][k]
+            indices.push(a,b,c,b,d,c)
+        }
+        // Flat end caps use separate vertices for correct normals.
+        for (const end of [0, path.length-1]) {
+            const other = end === 0 ? 1 : end-1
+            const delta = path[end].map((value,k) => value-path[other][k]), len = Math.hypot(...delta), normal = delta.map((value)=>value/len)
+            const center = vertex(path[end],normal), ring = []
+            for (const index of rings[end]) {
+                // Reverse conversion of the already stored position.
+                ring.push(vertex([positions[index*3+2],positions[index*3],positions[index*3+1]],normal))
+            }
+            for (let j=0;j<sides;j+=1) indices.push(center, ring[j], ring[(j+1)%sides])
+        }
+    }
+    return { positions, normals, indices, tube }
+}
+
+function document(kind) {
+    const g = geometry()
+    if (kind === 'spline') g.tube([[0,0,0],[1,0,0]],0.5)
+    if (kind === 'elbow') {
+        const path = Array.from({length:9},(_,i)=>{ const a=-Math.PI/2+i*Math.PI/16; return [-.25+.5*Math.cos(a),.25+.5*Math.sin(a),0] })
+        g.tube(path,.12)
+    }
+    if (['branch','cross'].includes(kind)) {
+        g.tube([[-.5,0,0],[.5,0,0]],.12)
+        g.tube([[0,kind==='cross'?-.5:0,0],[0,.5,0]],.12)
+    }
+    if (kind === 'valve') {
+        g.tube([[-.5,0,0],[.5,0,0]],.12)
+        g.tube([[-.16,0,0],[.16,0,0]],.21)
+        g.tube([[0,0,0],[0,0,.38]],.045)
+        const circle = Array.from({length:17},(_,i)=>[.22*Math.cos(i*Math.PI/8),.22*Math.sin(i*Math.PI/8),.38])
+        g.tube(circle,.035,8)
+        g.tube([[-.22,0,.38],[.22,0,.38]],.025,8)
+        g.tube([[0,-.22,.38],[0,.22,.38]],.025,8)
+    }
+    const arrays = [new Float32Array(g.positions),new Float32Array(g.normals),new Uint16Array(g.indices)]
+    const bytes = Buffer.concat(arrays.map((array)=>Buffer.from(array.buffer)))
+    const min=[Infinity,Infinity,Infinity],max=[-Infinity,-Infinity,-Infinity]
+    for(let i=0;i<g.positions.length;i+=1){const k=i%3;min[k]=Math.min(min[k],g.positions[i]);max[k]=Math.max(max[k],g.positions[i])}
+    const colors={spline:[.9,.9,.9,1],elbow:[1,.72,.18,1],valve:[1,.2,.2,1],branch:[.25,.9,.35,1],cross:[.25,.9,.35,1]}
+    return { bytes, gltf: {
+        asset:{version:'2.0',generator:'City Lifeline original demonstration pipe meshes'},
+        scene:0, scenes:[{nodes:[0]}], nodes:[{mesh:0,name:kind}],
+        meshes:[{primitives:[{attributes:{POSITION:0,NORMAL:1},indices:2,material:0}]}],
+        materials:[{doubleSided:true,pbrMetallicRoughness:{baseColorFactor:colors[kind],metallicFactor:.15,roughnessFactor:.65}}],
+        buffers:[{byteLength:bytes.length,uri:`${kind}.bin`}],
+        bufferViews:[{buffer:0,byteOffset:0,byteLength:arrays[0].byteLength,target:34962},{buffer:0,byteOffset:arrays[0].byteLength,byteLength:arrays[1].byteLength,target:34962},{buffer:0,byteOffset:arrays[0].byteLength+arrays[1].byteLength,byteLength:arrays[2].byteLength,target:34963}],
+        accessors:[{bufferView:0,componentType:5126,count:g.positions.length/3,type:'VEC3',min,max},{bufferView:1,componentType:5126,count:g.normals.length/3,type:'VEC3'},{bufferView:2,componentType:5123,count:g.indices.length,type:'SCALAR'}],
+        extras:{demonstration:true,kind,units:'metres',splineAxis:'glTF +Z, start 0, end 1',installationOrientation:'unknown; fixed illustrative ENU orientation'},
+    } }
+}
+for (const kind of ['spline','elbow','valve','branch','cross']) {
+    const { gltf, bytes } = document(kind)
+    await writeFile(new URL(`${kind}.bin`,out),bytes)
+    await writeFile(new URL(`${kind}.gltf`,out),JSON.stringify(gltf))
+    console.log(`${kind}: ${gltf.accessors[0].count} vertices, ${gltf.accessors[2].count/3} triangles`)
+}