Browse Source

feat(cockpit): fill missing pipe diameters from both anchored sides (#32)

Kazerin 1 week ago
parent
commit
99d9cc2270

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

@@ -48,11 +48,12 @@ Category: frontend-execution
 ## 进度
 
 - 已认领 @me、完整读票并 report。ready-for-agent,无 needs-triage、无阻塞;父 map 允许执行。本轮保留其他任务工作树,提交仅选择本票增量。
-- 用户裁定:低多边形标准示意;地表采样减虚拟埋深2m(非实测);已知管径按mm→m,无径保留折线;路径不平滑/不推断拓扑;未知构件尺寸约1m、固定朝向明示近似;沿用地下视图。授权必要构建/回归/浏览器,4K平均FPS≥30门槛。
+- 用户裁定(2026-10-13补充):低多边形标准示意;地表采样减虚拟埋深2m(非实测);已知管径按mm→m。缺管径允许在同业、端点完全重合且无分叉的连续链上,以两侧不同已知管段为锚点,按缺失段路径中点距离线性插值,保留两侧code/diameterMm、标记interpolated;单侧/无锚点/同一锚点闭环/分叉不补,不吸附邻近端点、不回写。路径不平滑/不生成安装拓扑;未知构件尺寸约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性能证据。
+- 真实只读核查更正:旧响应5268条LineString均**没有diameter键**,不能据此断言数据库全缺管径。后端已有毫米BigDecimal,读模型未透传;本轮提交`a96091ce`透传原值/显式null。现网5268条均有diameter键,3332条正源管径、18条双侧推算、1918条不补;唯一合法Point仍为窨井YL-YJ-003、pointType=null,另3条不可上图几何保持拦截。真实驾驶舱读到4480管体实例/19批、1919折线/符号,与源3332/推18/未补1918一致,均未覆盖源数据。
+- 管径补全验证:新插值回归与t32/t24/t33既有回归通过;后端针对性21/21、Cockpit全量487/487通过;前端最终构建退出0(Vite55.65s)。复核先复现再修复闭合支路漏算分叉、异常JSON形成伪锚点;覆盖路径长度权重、独立反向、环、多顶点、行业隔离/邻近不吸附等。详情与证据见[管径实施记录](../verify/artifacts/t32/diameter-implementation.md)、[真实补全结果](../verify/artifacts/t32/diameter-live-result.json)、[驾驶舱计数截图](../verify/artifacts/t32/diameter-cockpit-status.png)。窄模拟视口计数证据不代替4K运动帧率。
 - 完整实施与证据:[验收记录](../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仅取得部分证据,没有绕过技能用外部进程控制。

BIN
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/diameter-cockpit-status.png


+ 48 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/diameter-implementation.md

@@ -0,0 +1,48 @@
+# 管径双侧补全:实施与真实验证
+
+用户已确认的规则:后端透传原始毫米管径;前端仅在同一行业、端点坐标完全重合且无分叉的连续链上,按缺失段路径中点距离进行双侧线性插值。两侧同值直接沿用。分叉、单侧、无锚点及一个锚点围成的环不补。不吸附附近端点,不生成新的安装拓扑,不回写原始管径。
+
+## 纠正此前事实表述
+
+此前 geometry 响应没有 `diameter` 键,不能因此断言数据库所有管径为空。源码 PipeNetworkBase 和 GasPipePoint 均已有 BigDecimal diameter(毫米);问题是驾驶舱几何读模型未透传。实际有同业完全重合的端点(本次只读计数:1498 个二度端点、456 个度数>2端点),旧说明“彼此不共享端点”也过强。
+
+## 后端
+
+提交 `a96091ce`(E:/pipe-ner-server,当前分支 master):仅四处几何读模型/测试文件,未包含另一个任务的 IDE 编码改动。Properties.diameter 显式可空,原始 BigDecimal 不换算、不过滤、不补值。管段与燃气点各透传自身 diameter,窨井返回 null;无数据写、无鉴权或准入变更。
+
+验证:先新增测试捕获缺字段红灯(21项中1失败),实施后针对性21/21;驾驶舱全量487/487通过。扩充0、燃气点和窨井null以及MVC毫米数值断言后,针对性21/21仍通过。提交未推送;本机运行实例已实际返回新字段(不代表其他环境已部署)。
+
+## 前端
+
+- `domain/pipeDiameter.js` 构建仅用于示意的同业精确端点邻接,双侧查找沿无分叉链停止。推算结果保留两侧code/diameterMm,原数据对象不被修改。
+- `planPipeModels` 按源数据或推算管径建模,传出 reported/interpolated 来源及按业务段计的 diameterSummary。
+- 现有状态面板显示源数据/双侧推算/未补三类计数和“非实测”说明。构件示意尺寸与虚拟埋深2m政策不变。
+- `toPipeNetwork` 只接受数字或非空数字字符串,拒绝布尔/数组/对象;缺键/null保持null。0/负值可保留为原始数值,但不当成锚点。
+- 对抗式复核修复:闭合/退化支路仍计入端点度数,防止忽略分叉;异常JSON不得形成伪已知管径。
+
+## 真实数据结果
+
+见 [diameter-live-result.json](diameter-live-result.json)(2026-10-13 07:27:52Z)。
+
+| 项目 | 结果 |
+|---|---:|
+| 真实LineString | 5268 |
+| 响应具有diameter键 | 5268 |
+| 正管径源数据 | 3332 |
+| 双侧推算 | 18 |
+| 仍无可靠双侧锚点 | 1918 |
+| 合法构件 | 1(窨井) |
+| 不可上图几何 | 3 |
+
+只读探针确认管径/坐标原对象未改,全部推算值均位于两侧锚点值之间。
+
+真实驾驶舱使用该契约,既有状态面板读到4480管体实例/19批、1919折线/符号对象(1918缺径管段+1窨井),源数据3332/双侧推算18/未补1918与探针一致。见 [diameter-cockpit-status.png](diameter-cockpit-status.png)。这是744×1133 CSS模拟视口的就绪与说明证据,截图的地球视角也不能当作地下近景视觉或4K FPS验收。
+
+## 验证和剩余项
+
+- 新回归通过:双侧等长/不等长、同值、输入与方向独立反转、多顶点路径长度、同一锚点环/双锚点环、闭合支路、单侧/无锚点、分叉、跨行业、UNKNOWN行业、近邻不吸附、0/负径、JSON布尔/数组拒绝、null/缺键/数字字符串适配、来源与原值不变。
+- 原t32计划/adapter/首次import清理及t24领域/链路、t33差量回归通过。
+- 前端全部边界修复后已授权构建通过(Vite55.65s、退出码0;仓库既有分包/外部脚本/Browserslist警告仍在)。
+- 4K平均FPS≥30、完整地下画面、浏览器模板404及四类构件验收仍按原票要求保留。工具仍强制off与device/尺寸同时出现,desktop模拟清除失败;本次新会话可snapshot/press/screenshot,随后又失去注册。没有绕过技能用外部进程操作。
+
+本次管径规则实施与真实计数已完成;原票验收尚未全部达到,保持claimed/open,不给map添加完成gist。

+ 140 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t32/diameter-live-result.json

@@ -0,0 +1,140 @@
+{
+  "checkedAt": "2026-10-13T07:27:52.870Z",
+  "endpoint": "http://192.168.110.96:8301/api/cockpit/v1/infrastructure/geometry",
+  "httpStatus": 200,
+  "businessCode": 200,
+  "rawLines": 5268,
+  "diameterFieldCount": 5268,
+  "diameterSummary": {
+    "reported": 3332,
+    "interpolated": 18,
+    "missing": 1918
+  },
+  "components": 1,
+  "blocked": 3,
+  "inferredExample": [
+    {
+      "code": "TCWS202609080004",
+      "diameterMm": 202.7582003177938,
+      "source": "interpolated",
+      "anchors": [
+        {
+          "code": "TCWS202609080007",
+          "diameterMm": 800
+        },
+        {
+          "code": "TCWS202609080018",
+          "diameterMm": 56
+        }
+      ]
+    },
+    {
+      "code": "TCWS202609080005",
+      "diameterMm": 375.0305216528901,
+      "source": "interpolated",
+      "anchors": [
+        {
+          "code": "TCWS202609080007",
+          "diameterMm": 800
+        },
+        {
+          "code": "TCWS202609080018",
+          "diameterMm": 56
+        }
+      ]
+    },
+    {
+      "code": "TCWS202609080006",
+      "diameterMm": 600.2723213350963,
+      "source": "interpolated",
+      "anchors": [
+        {
+          "code": "TCWS202609080007",
+          "diameterMm": 800
+        },
+        {
+          "code": "TCWS202609080018",
+          "diameterMm": 56
+        }
+      ]
+    },
+    {
+      "code": "TCWS202609080008",
+      "diameterMm": 450,
+      "source": "interpolated",
+      "anchors": [
+        {
+          "code": "TCWS202609080007",
+          "diameterMm": 800
+        },
+        {
+          "code": "TCWS202609080009",
+          "diameterMm": 100
+        }
+      ]
+    },
+    {
+      "code": "TCRQ202609080058",
+      "diameterMm": 1155.5097115141027,
+      "source": "interpolated",
+      "anchors": [
+        {
+          "code": "TCRQ202609080057",
+          "diameterMm": 1200
+        },
+        {
+          "code": "TCRQ202609080072",
+          "diameterMm": 42
+        }
+      ]
+    },
+    {
+      "code": "TCRQ202609080059",
+      "diameterMm": 1066.5206802742841,
+      "source": "interpolated",
+      "anchors": [
+        {
+          "code": "TCRQ202609080057",
+          "diameterMm": 1200
+        },
+        {
+          "code": "TCRQ202609080072",
+          "diameterMm": 42
+        }
+      ]
+    },
+    {
+      "code": "TCRQ202609080060",
+      "diameterMm": 977.5152924686669,
+      "source": "interpolated",
+      "anchors": [
+        {
+          "code": "TCRQ202609080057",
+          "diameterMm": 1200
+        },
+        {
+          "code": "TCRQ202609080072",
+          "diameterMm": 42
+        }
+      ]
+    },
+    {
+      "code": "TCRQ202609080061",
+      "diameterMm": 897.5493813544981,
+      "source": "interpolated",
+      "anchors": [
+        {
+          "code": "TCRQ202609080057",
+          "diameterMm": 1200
+        },
+        {
+          "code": "TCRQ202609080072",
+          "diameterMm": 42
+        }
+      ]
+    }
+  ],
+  "rawDataUnchanged": true,
+  "browserVerified": false,
+  "renderingPerformanceVerified": false
+}

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

@@ -2,6 +2,8 @@
 
 状态:实施已完成,验收未全部通过,issue 保持 claimed/open。
 
+**2026-10-13管径规则补充与事实更正**:用户已确认双侧按长度插值,见[管径补全实施](diameter-implementation.md)。下文“真实接口正管径0”的旧观察只表示当时API没有diameter字段,不能证明数据库全缺径;已修复后端透传并实测3332源数据/18推算/1918未补。最新驾驶舱4480实例/19批。原4K与浏览器故障验收仍待完成。
+
 ## 实施边界
 
 - 四类原始低多边形素材:样条管体、弯头、阀门、支管;支管提供三通和十字变体。glTF 模板与外部同名 bin 位于 public/cockpit/models/pipes。

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

@@ -0,0 +1,65 @@
+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=(code,x1,x2,diameter,industry='GAS')=>({code,industry,diameter,positions:[[110+x1*.0001,28.44],[110+x2*.0001,28.44]]})
+const rows=[pipe('left',0,1,300),pipe('gap1',1,2,null),pipe('gap2',2,3,null),pipe('right',3,4,600)]
+const plan=planPipeModels({pipes:rows,components:[]})
+const a=plan.instances.find(row=>row.id==='pipe:gap1')
+const b=plan.instances.find(row=>row.id==='pipe:gap2')
+assert.ok(a&&b,'double-sided anchored gap produces models')
+assert.ok(Math.abs(a.diameterM-.375)<1e-8)
+assert.ok(Math.abs(b.diameterM-.525)<1e-8)
+assert.equal(a.diameterSource,'interpolated')
+assert.deepEqual(a.diameterAnchors.map(row=>row.code),['left','right'])
+assert.equal(rows[1].diameter,null,'do not overwrite raw business diameter')
+assert.deepEqual(plan.diameterSummary,{reported:2,interpolated:2,missing:0})
+console.log('PASS two-sided linear fill, traceable anchors, immutable raw diameter')
+
+const middle=(pipes,code)=>planPipeModels({pipes,components:[]}).instances.find(row=>row.id==='pipe:'+code)
+const weighted=[pipe('l',0,1,200),pipe('a',1,2,null),pipe('b',2,5,null),pipe('r',5,6,600)]
+assert.ok(Math.abs(middle(weighted,'a').diameterM-.25)<1e-8)
+assert.ok(Math.abs(middle(weighted,'b').diameterM-.45)<1e-8)
+const reversed=weighted.slice().reverse().map(row=>({...row,positions:row.positions.slice().reverse()}))
+assert.ok(Math.abs(middle(reversed,'a').diameterM-.25)<1e-8,'independent of input and path direction')
+assert.equal(middle([pipe('l',0,1,400),pipe('a',1,2,null),pipe('r',2,3,400)],'a').diameterM,.4)
+assert.equal(middle([pipe('l',0,1,400),pipe('a',1,2,null)],'a'),undefined,'one-sided does not fill')
+assert.equal(middle([pipe('a',0,1,null),pipe('b',1,2,null)],'a'),undefined,'no source means no invented diameter')
+assert.equal(middle([pipe('l',0,1,400),pipe('a',1,2,null),pipe('r',2,3,600,'WATER_SUPPLY')],'a'),undefined,'do not cross industries')
+assert.equal(middle([...rows,pipe('fork',2,8,800)],'gap1'),undefined,'stop before branch node')
+const almost=pipe('near',2,3,600);almost.positions[0][0]+=1e-10
+assert.equal(middle([pipe('l',0,1,300),pipe('gap',1,2,null),almost],'gap'),undefined,'do not snap nearly coincident endpoints')
+assert.equal(middle([pipe('l',0,1,300),pipe('bad',1,2,0),pipe('r',2,3,600)],'bad').diameterSource,'interpolated')
+console.log('PASS length weighting, reversed paths, equal anchors, no one-sided/branch/industry/nearby guessing')
+
+const payload={features:rows.map((row,i)=>({geometry:{type:'LineString',coordinates:row.positions},properties:{code:row.code,industry:'GAS',diameter:i===0?'300.0':row.diameter,coordinateValidation:{renderable:true}}}))}
+const adapted=toPipeNetwork(payload)
+assert.equal(adapted.pipes[1].diameter,null)
+assert.equal(adapted.pipes[0].diameter,300)
+assert.deepEqual(planPipeModels(adapted).diameterSummary,{reported:2,interpolated:2,missing:0})
+delete payload.features[0].properties.diameter
+assert.equal(toPipeNetwork(payload).pipes[0].diameter,null)
+console.log('PASS API raw diameter parsing preserves null/absent and feeds interpolation')
+
+const loop={...pipe('loop',1,1,null),positions:[[110.0001,28.44],[110.0008,28.44],[110.0001,28.44]]}
+assert.equal(middle([pipe('l',0,1,300),pipe('gap',1,2,null),pipe('r',2,3,600),loop],'gap'),undefined,'closed branch still counts toward junction degree')
+console.log('PASS closed side loop prevents ambiguous fill')
+
+for(const value of [true,false,[],[300],{},' ','no-diameter']) {
+    payload.features[0].properties.diameter=value
+    assert.equal(toPipeNetwork(payload).pipes[0].diameter,null,'reject non numeric diameter '+JSON.stringify(value))
+}
+console.log('PASS nonnumeric JSON cannot become reported anchors')
+
+assert.ok(Math.abs(middle(weighted.slice().reverse(),'a').diameterM-.25)<1e-8)
+assert.ok(Math.abs(middle(weighted.map(row=>({...row,positions:row.positions.slice().reverse()})),'a').diameterM-.25)<1e-8)
+const closed=[pipe('only-anchor',0,1,300),pipe('ca',1,2,null),pipe('cb',2,0,null)]
+assert.equal(middle(closed,'ca'),undefined,'same reported pipe reached from both sides is not two anchors')
+assert.equal(middle(closed,'cb'),undefined)
+const ring=[pipe('ra',0,1,300),pipe('rg1',1,2,null),pipe('rb',2,3,600),pipe('rg2',3,0,null)]
+assert.equal(middle(ring,'rg1').diameterM,.45)
+assert.equal(middle(ring,'rg2').diameterM,.45)
+const multi={...pipe('poly',1,3,null),positions:[[110.0001,28.44],[110.0002,28.44],[110.0003,28.44]]}
+assert.ok(Math.abs(middle([pipe('l',0,1,200),multi,pipe('b',3,4,null),pipe('r',4,5,500)],'poly').diameterM-.3)<1e-8)
+assert.equal(middle([pipe('l',0,1,-300),pipe('gap',1,2,null),pipe('r',2,3,600)],'gap'),undefined)
+assert.equal(middle([pipe('l',0,1,300,'UNKNOWN'),pipe('gap',1,2,null,'UNKNOWN'),pipe('r',2,3,600,'UNKNOWN')],'gap'),undefined)
+console.log('PASS independent reversals, one/two-anchor cycles, polyline lengths, negative and unknown-industry anchors')

+ 32 - 0
.scratch/cockpit-real-data-wayfinding/verify/t32-diameter-live.mjs

@@ -0,0 +1,32 @@
+import assert from 'node:assert/strict'
+import { writeFile } from 'node:fs/promises'
+import { toPipeNetwork } from '../../../src/modules/cockpit/domain/pipeNetwork.js'
+import { planPipeModels } from '../../../src/modules/cockpit/domain/pipeModels.js'
+const endpoint='http://192.168.110.96:8301/api/cockpit/v1/infrastructure/geometry'
+const response=await fetch(endpoint)
+assert.equal(response.status,200)
+const envelope=await response.json()
+assert.equal(envelope.code,200)
+const payload=envelope.data
+const rawLines=payload.features.filter(row=>row.geometry?.type==='LineString')
+assert.ok(rawLines.every(row=>Object.hasOwn(row.properties,'diameter')),'deployed backend diameter field missing')
+const network=toPipeNetwork(payload)
+const original=JSON.stringify(network.pipes)
+const plan=planPipeModels(network)
+assert.equal(JSON.stringify(network.pipes),original,'raw diameters/coordinates must remain unchanged')
+const byId=new Map(plan.instances.filter(row=>row.kind==='spline').map(row=>[row.id,row]))
+const interpolated=[...byId.values()].filter(row=>row.diameterSource==='interpolated')
+for(const row of interpolated){
+    assert.equal(row.diameterAnchors.length,2)
+    assert.notEqual(row.diameterAnchors[0].code,row.diameterAnchors[1].code)
+    const values=row.diameterAnchors.map(anchor=>anchor.diameterMm/1000)
+    assert.ok(row.diameterM>=Math.min(...values)&&row.diameterM<=Math.max(...values))
+}
+assert.equal(plan.diameterSummary.reported+plan.diameterSummary.interpolated+plan.diameterSummary.missing,network.pipes.length)
+const result={checkedAt:new Date().toISOString(),endpoint,httpStatus:response.status,businessCode:envelope.code,
+    rawLines:rawLines.length,diameterFieldCount:rawLines.filter(row=>Object.hasOwn(row.properties,'diameter')).length,
+    diameterSummary:plan.diameterSummary,components:network.components.length,blocked:network.blocked.length,
+    inferredExample:interpolated.slice(0,8).map(row=>({code:row.id.slice(5),diameterMm:row.diameterM*1000,source:row.diameterSource,anchors:row.diameterAnchors})),
+    rawDataUnchanged:true,browserVerified:false,renderingPerformanceVerified:false}
+await writeFile(new URL('./artifacts/t32/diameter-live-result.json',import.meta.url),JSON.stringify(result,null,2)+'\n')
+console.log(JSON.stringify(result,null,2))

+ 4 - 0
CONTEXT.md

@@ -32,6 +32,10 @@ _Avoid_: 监测点、把设施点并入监测点统计、把聚合 KPI 当设施
 依真实管段路径或真实构件类型与位置呈现的体积表达;标准构件外形、无来源的尺寸与安装朝向均为示意,不等于工程竣工模型。
 _Avoid_: 真实安装模型、用示意构件推断实际连接拓扑
 
+**推算管径**:
+根据同业、端点完全重合且无分叉的连续链两侧已有管径,按缺失段路径中点距离插值得到的示意尺寸;保留两侧来源,不覆盖源数据或声称实测。
+_Avoid_: 实测管径、默认管径、用推算尺寸证明安装拓扑
+
 **虚拟埋深**:
 为地下可视化明确指定的地表以下距离,不是测量或后端发布的真实管道埋深。
 _Avoid_: 实测埋深、真实高程

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

@@ -8,4 +8,6 @@
 
 构件是标准示意件,最大主体跨度约 1m。它们不代表厂家型号、真实弯曲角度、安装尺寸或真实朝向。构件朝向固定,仅用于类型辨认。模型中心/中心线使用地表采样高程减 **虚拟埋深 2m**;这不是后端实测埋深。
 
-缺少管径、未知构件类型、地表采样失败或素材加载失败时,保留既有折线/符号。只有模型 `readyEvent` 后才撤下备用表达;后续资源错误恢复备用表达。临时失败可在后续刷新时按 30s 冷却重试。地下显示沿用驾驶舱现有管线视图,不额外改变地形遮挡纪律。
+缺少管径时,先在同一行业、端点坐标完全重合且无分叉的连续链上找两侧已有管径;在两个锚点之间按缺失段路径中点的长度比例插值(同值锚点直接沿用)。这属于推算管径,仅用于模型示意,保留两侧编码/管径来源,不覆盖后端源数据;不是安装拓扑的证明。分叉、只有单侧锚点、闭环只有同一个锚点、无锚点或端点仅邻近时均不补,保留折线。
+
+未知构件类型、地表采样失败或素材加载失败时,保留既有折线/符号。只有模型 `readyEvent` 后才撤下备用表达;后续资源错误恢复备用表达。临时失败可在后续刷新时按 30s 冷却重试。地下显示沿用驾驶舱现有管线视图,不额外改变地形遮挡纪律。

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

@@ -45,6 +45,9 @@
         <p v-if="pipeModelState" class="map-info__line" data-pipe-model-status role="status">
             管道模型 {{ pipeModelState.instances }} 实例 / {{ pipeModelState.batches }} 批 ·
             折线/符号 {{ pipeModelState.fallback }} 对象
+            <template v-if="pipeModelState.diameterSummary">
+                · 管径:源数据 {{ pipeModelState.diameterSummary.reported }} 段 / 双侧推算 {{ pipeModelState.diameterSummary.interpolated }} 段 / 未补 {{ pipeModelState.diameterSummary.missing }} 段
+            </template>
             <template v-if="pipeModelState.planning || pipeModelState.pending"> · 模型加载中(保留降级表达)</template>
             <template v-if="pipeModelState.failed"> · {{ pipeModelState.failed }} 批素材失败,已降级</template>
             <template v-if="pipeModelState.terrainFailed"> · 地表高程采样失败,已保留折线/符号,稍后刷新重试</template>

+ 67 - 0
src/modules/cockpit/domain/pipeDiameter.js

@@ -0,0 +1,67 @@
+/** Rendering-only estimates. Exact same-industry endpoints are a schematic adjacency, not surveyed topology. */
+const INDUSTRIES = new Set(['GAS', 'WATER_SUPPLY', 'DRAINAGE'])
+const positive = (value) => Number.isFinite(value) && value > 0
+
+function pathLength(positions) {
+    let length = 0
+    const rad = Math.PI / 180
+    for (let i = 1; i < positions.length; i += 1) {
+        const [lon1,lat1] = positions[i-1], [lon2,lat2] = positions[i]
+        const h = Math.sin((lat2-lat1)*rad/2)**2 + Math.cos(lat1*rad)*Math.cos(lat2*rad)*Math.sin((lon2-lon1)*rad/2)**2
+        length += 12742017.6 * Math.asin(Math.sqrt(Math.min(1,h)))
+    }
+    return length
+}
+
+/** Preserve reported values; fill only missing chains bounded by TWO distinct known pipes. */
+export function resolvePipeDiameters(pipes) {
+    const resolved = new Map()
+    const nodes = new Map(), edges = []
+    for (const pipe of pipes) {
+        if (positive(pipe.diameter)) resolved.set(pipe, { diameterMm:pipe.diameter, source:'reported', anchors:[] })
+        if (!INDUSTRIES.has(pipe.industry) || !pipe.positions?.length) continue
+        const key = (p) => JSON.stringify([pipe.industry,p[0],p[1]])
+        const ends = [key(pipe.positions[0]),key(pipe.positions.at(-1))]
+        const length = pathLength(pipe.positions)
+        const edge = { pipe, ends, length }
+        if (ends[0] !== ends[1] && positive(length)) edges.push(edge)
+        // Closed/degenerate lines still occupy their endpoints and must block ambiguous propagation.
+        for (const end of ends) {
+            if (!nodes.has(end)) nodes.set(end,[])
+            nodes.get(end).push(edge)
+        }
+    }
+    const visited = new Set()
+    for (const seed of edges) {
+        if (positive(seed.pipe.diameter) || visited.has(seed)) continue
+        const seen = new Set([seed])
+        const walk = (node) => {
+            let current = seed
+            const missing = []
+            while (nodes.get(node)?.length === 2) {
+                const next = nodes.get(node).find(edge => edge !== current)
+                if (positive(next.pipe.diameter)) return { missing, anchor:next }
+                if (seen.has(next)) break
+                seen.add(next)
+                missing.push(next)
+                node = next.ends[0] === node ? next.ends[1] : next.ends[0]
+                current = next
+            }
+            return { missing, anchor:null }
+        }
+        const left = walk(seed.ends[0]), right = walk(seed.ends[1])
+        const chain = [...left.missing.reverse(),seed,...right.missing]
+        for (const edge of chain) visited.add(edge)
+        if (!left.anchor || !right.anchor || left.anchor === right.anchor) continue
+        const total = chain.reduce((sum,edge) => sum+edge.length,0)
+        const a = left.anchor.pipe, b = right.anchor.pipe
+        const anchors = [a,b].map(pipe => ({code:pipe.code,diameterMm:pipe.diameter})).sort((x,y)=>x.code.localeCompare(y.code))
+        let distance = 0
+        for (const edge of chain) {
+            const fraction = (distance+edge.length/2)/total
+            resolved.set(edge.pipe, { diameterMm:a.diameter+(b.diameter-a.diameter)*fraction, source:'interpolated', anchors })
+            distance += edge.length
+        }
+    }
+    return resolved
+}

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

@@ -1,20 +1,25 @@
+import { resolvePipeDiameters } from './pipeDiameter.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 = '管道模型为标准示意:虚拟埋深 2m(非实测);构件尺寸 1m、固定朝向(非真实安装朝向);缺管径仅在同业、端点完全重合的无分叉链上按两侧源数据和路径长度推算(非实测),单侧/无锚点不补;缺素材/地表采样时保留折线或符号。'
 
 /** 输入只接收 toPipeNetwork 已准入且经模式计划裁剪后的对象,不再推断坐标/拓扑。 */
 export function planPipeModels(network = {}) {
     const instances = []
     const fallbacks = []
-    for (const pipe of network.pipes || []) {
+    const pipes = network.pipes || []
+    const diameters = resolvePipeDiameters(pipes)
+    for (const pipe of pipes) {
         const id = `pipe:${pipe.code}`
-        if (!(Number.isFinite(pipe.diameter) && pipe.diameter > 0)) {
+        const diameter = diameters.get(pipe)
+        if (!diameter) {
             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 })
+            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 })
         }
     }
     for (const component of network.components || []) {
@@ -26,5 +31,10 @@ export function planPipeModels(network = {}) {
         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 }
+    const diameterSummary = { reported:0, interpolated:0, missing:0 }
+    for (const pipe of pipes) {
+        const diameter = diameters.get(pipe)
+        diameterSummary[diameter ? diameter.source : 'missing'] += 1
+    }
+    return { instances, fallbacks, diameterSummary, virtualDepthM: PIPE_MODEL_POLICY.virtualDepthM }
 }

+ 12 - 4
src/modules/cockpit/domain/pipeNetwork.js

@@ -8,9 +8,10 @@
  * 两条不可让步的纪律(沿用票 07 / 决策票 07):
  *   1. **准��只认后端 `coordinateValidation.renderable`**,本文件不重算经纬度范围,
  *      更不纠偏、不补默认坐标 —— 与 `isRenderable()` 同一立场;
- *   2. **不得臆造拓扑**。管段目前每条只有两个端点、彼此不共享节点,
- *      所以角/支管构件只能来自后端给的**真实构件属性**(燃气 `pointType`),
- *      绝不按端点邻近度推断连接关系(那是编造拓扑)。
+ *   2. **不得臆造安装拓扑**。管段只有坐标、无正式端节点ID,
+ *      所以角/支管构件只能来自后端给的真实构件属性(燃气 pointType)。
+ *      经用户确认,完全重合的同业端点可仅用于无分叉链的管径示意插值,
+ *      不据此生成角/支管构件、不把候选连接关系当工程拓扑。
  *
  * 行业覆盖如实呈现:哪个行业有几何、哪个没有,由后端 `coverage` 表达,
  * 前端只转述,不替后端猜(缺数据就说缺数据,不画空图层冒充"这里没有管线")。
@@ -102,6 +103,13 @@ function isFiniteNumber(value) {
     return typeof value === 'number' && Number.isFinite(value)
 }
 
+function diameterOf(value) {
+    if (typeof value !== 'number' && typeof value !== 'string') return null
+    if (typeof value === 'string' && !value.trim()) return null
+    const number = Number(value)
+    return Number.isFinite(number) ? number : null
+}
+
 /** 管段几何:两点 `[lon, lat]`;点数不足 2 一律不可上图(不补点、不截断成一点)。 */
 function pipePositions(geometry) {
     const raw = geometry && geometry.type === 'LineString' && Array.isArray(geometry.coordinates) ? geometry.coordinates : []
@@ -170,7 +178,7 @@ export function toPipeNetwork(payload) {
                 name: String(properties.name || ''),
                 industry,
                 material: String(properties.material || ''),
-                diameter: isFiniteNumber(Number(properties.diameter)) ? Number(properties.diameter) : null,
+                diameter: diameterOf(properties.diameter),
                 length: isFiniteNumber(Number(properties.length)) ? Number(properties.length) : null,
                 positions,
             })

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

@@ -101,6 +101,7 @@ export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=i
     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
+    let diameterSummary={reported:0,interpolated:0,missing:0}
     const publish=(state)=>{
         if(!active) return
         const covered=new Set(state.covered.filter((id)=>!missing.has(id)))
@@ -108,7 +109,7 @@ export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=i
             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})
+        onChange({...state,covered:[...covered],fallback:allIds.length-covered.size,planning,terrainFailed,diameterSummary,note:PIPE_MODEL_NOTE})
         viewer.scene.requestRender()
     }
     const batches=createPipeModelBatches({
@@ -142,6 +143,7 @@ export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=i
     function sync(network) {
         if (!active || viewer.isDestroyed()) return Promise.resolve()
         const plan=planPipeModels(network)
+        diameterSummary=plan.diameterSummary
         const next=JSON.stringify(plan)
         if(next===fingerprint && !(terrainFailed && Date.now()-lastTerrainAttempt>=30000)) {
             if (planning) return task