Ver Fonte

fix(cockpit): harden cesium pipeline picking

Kazerin há 1 semana atrás
pai
commit
19b03fc441

+ 45 - 0
.scratch/cockpit-shell-panel-layout/implementation/t22/verification.md

@@ -0,0 +1,45 @@
+# Cesium 地理管线渲染 — 本轮验证记录
+
+## 范围与基线
+
+- 基线提交:`09905be058d0d784c7c7a6ba8fe2ba739f2c7291`。工作区已有大量并行未提交改动;本轮修改前快照保存在 `before/`,用于精确归属。
+- 维护者确认:地面/管线两种视图都保留管线;按已发布字段收口。真实埋深暂缓,现有虚拟 2m 不冒充实测;没有管线业务状态契约,不借用监测点/设备词表。
+- 材质为可选透传,缺失为空;源管径与示意插值沿用已有实现,不声称本轮新增真实几何接线或管体素材。
+
+## 本轮新修复
+
+1. `domain/pipeNetwork.js`:折线顶点逐个校验结构与有限数;任一点非法则整条进入无地图位置清单,不跳点重连。后端 renderable 准入仍是前置,未增加第二套地理范围判定。
+2. `services/cesiumEngine.js`:普通拾取前景为本管线数据源实体/管体批次时,使用公开 `Scene.drillPick` 找第一个非管线对象。保留其它对象的前后遮挡与既有设备/聚合优先分派;普通点选不做多层拾取。
+3. 新增 `verify/t22-pipeline-rendering.mjs`,使用真实域函数、Cesium Entity/DataSource 与可控制的 GPU 拾取边界,覆盖12组行为。
+4. 既有测试桩维护:t41 移除将公开 drillPick 误认私有 API 的检查;t45 注入新增拾取依赖,并补齐此前缺失的差量索引/指纹/实体返回值。
+
+## 离线检查(实际执行)
+
+| 检查 | 结果 |
+| --- | --- |
+| t22-pipeline-rendering | 12/12 通过 |
+| t24-pipe-layer | 18/18 通过 |
+| t24-pipe-layer-flow | 17/17 通过 |
+| t33-jitter-diff | 15/15 通过 |
+| t41-point-locate-anchor | 46/46 通过 |
+| t45-selection-continuity | 19/19 通过(修正测试桩后) |
+| t47-overlap-effects | 27/27 通过 |
+| git diff --check(本轮4个既有文件) | 通过;仅提示既有换行转换 |
+
+这些脚本直接以 Node 执行,不调用 package scripts、不启动服务、无需浏览器或外部网络。没有进行全仓测试:存在多会话在制品,本轮只检查直接受影响链路。
+
+### T45 既存假失败的证据
+
+首轮 T45 的「monitoring point creates a visible name label」报 `markerEntityIndex is not defined`,其余18项通过。`verify/t22-t45-baseline.mjs` 使用修改前引擎快照与原桩只读复现同一错误,确认非本轮拾取修复造成。补测试桩依赖后19/19通过,生产标记差量实现没有改动。
+
+## 验收的实际边界
+
+- 新断言覆盖完整路径、坏中间顶点、后端未准入、源/缺失管径、可选材质、行业筛选及被拦清单、四模式与引导态、两视图的透明度计划、管线/构件/管体遮挡点选、其它对象保持遮挡、坐标转换、无多层能力降级、实体差量同步及撤下。
+- 四模式常驻、半透明参照、真实几何既有浏览器证据归真实数据接线票24及地形基线票21,本轮未重复宣称新执行。
+- 遵守 AGENTS.md,已跳过 build/debug 验证:未构建、未启动/重启服务器、未运行浏览器调试或截图。本轮 GPU 遮挡、多层拾取性能与运行态画面没有重新实测。
+- 设备展开后的投影近邻入口在本轮修改前即已优先返回,沿用既有交互规则;新遮挡用例验证普通拾取分支,不能扩展宣称设备投影近邻链的GPU遮挡已实测。
+
+## 两轴审查
+
+- Standards:硬违规0、判断性 smell 0。比较修改前快照与本轮改动,未混入并行基线。
+- Spec:未发现可确认的新增规格违背;既有接线/渲染成果与本轮两处修复分开归属。GPU/浏览器和设备投影近邻分支作为验证边界记录,不虚报整链实测。

+ 44 - 12
.scratch/cockpit-shell-panel-layout/issues/22-cesium-pipeline-rendering.md

@@ -1,20 +1,52 @@
 # 22: Cesium 地理管线渲染
 
-**What to build:** 使用 Cesium 渲染后端发布的管线折线、管径、埋深和材质属性。管线作为地理主图的一部分,与监测点交互保持正确层级。
+**What to build:** 使用 Cesium 渲染真实几何读模型提供的管线折线;消费已提供的管径、材质(缺失保持未知),与监测点及设备点位保持正确交互层级。
 
 Blocked by: 21
-
-Status: ready-for-agent
+Status: resolved
+Assignee: codex
 Labels: ready-for-agent
 
-- [ ] 管线折线、管径、埋深和材质由适配层提供。
-- [ ] Cesium 渲染地理管线,支持管线视图;管线在管线视图下可见,在地面视图下隐藏。
-- [ ] 管线视图下地形与 3D Tiles 为半透明参照,不完全隐藏。
-- [ ] 管线可按行业和状态筛选。
-- [ ] 管线交互优先级低于监测点和设备点位。
-- [ ] 无效坐标管线不上图,但保留在列表或详情中。
-- [ ] 有管线数据映射和筛选单测。
+## 验收标准(维护者本轮确认后修订)
+
+- [x] 管线折线、源管径和可选材质由适配层提供,缺失不伪造;管径示意插值沿用已决策的管径规则。
+- [x] 管线进入系统后在四个一级模式、地面/管线两种视图中保留;视图切换改变相机与地表/瓦片透明度。
+- [x] 管线视图下地形与 3D Tiles 为半透明参照,不完全隐藏。
+- [x] 管线可按行业筛选;业务状态筛选不在本轮范围(几何契约尚未发布状态及跨行业词表)。
+- [x] 管线交互优先级低于监测点和设备点位,不吞掉普通拾取路径中被管线覆盖的点选;展开设备保留既有投影近邻优先规则。
+- [x] 无效坐标管线不上图,但保留在「无地图位置」清单中;不得删除非法顶点后重连剩余点。
+- [x] 有管线数据映射和筛选单测,以及覆盖管线遮挡点选的回归用例。
+
+## 本轮范围裁定
+
+维护者确认采用当前领域决策:两种视图都保留管线;按已发布字段收口。真实埋深目前无法取得,暂不处理。现有虚拟埋深 2m 保持明确的非实测语义,不计作后端埋深。状态筛选不借用监测点/设备状态,不为此在本轮追加后端实施。材质仅作可选透传,不声称真实端点已完整发布材质。
+
+## 原有实现归属
+
+原「未动工」记录已过时。现有真实几何接线由[设施点位与管线真实几何图层接线](../../cockpit-real-data-wayfinding/issues/24-24.md)交付;管体与构件、示意管径、虚拟埋深属于[管线构件素材与实例化渲染](../../cockpit-real-data-wayfinding/issues/32-32.md)的已有成果,本票不重复认领。半透明参照与视图相机既有验收归[3D Tiles 与地形基线](21-3d-tiles-terrain-baseline.md)。
+
+## 本轮实施与证据
+
+1. `domain/pipeNetwork.js`:`pipePositions` 不再过滤坏顶点后重连;任一顶点结构非法或非有限数,整条进入「无地图位置」清单,保留编码与原因。后端 `renderable` 准入规则保持为前置。
+2. `services/cesiumEngine.js`:前景为本管线图层实体或管体批次时,用公开 `Scene.drillPick` 穿过管线;首个非管线对象仍按正常遮挡处理。设备/监测点共用入口,普通点选不执行多层拾取;展开设备保留原有投影近邻入口。
+3. 新增[管线行为回归](../verify/t22-pipeline-rendering.mjs):真实领域函数与 Cesium Entity/DataSource、受控场景拾取边界,覆盖12组行为。
+4. 修复既有验证桩:T41 不再误禁公开 drillPick;T45 补齐差量索引/指纹依赖和新拾取函数。首轮 T45 的 `markerEntityIndex is not defined` 已用修改前快照复现为既存问题,补桩后19/19通过。
+
+实际离线结果:T22 12/12、T24映射18/18、T24链路17/17、T33差量15/15、T41锚定46/46、T45连续性19/19、T47特效27/27,共154项通过。两轴审查:Standards硬违规0/判断0;Spec未发现可确认的新增违背。详细记录见[实施验证记录](../implementation/t22/verification.md)。
+
+## 进度
+
+实施完成并按维护者修订后的交付范围收口。直接相关离线回归共154项通过,两轴静态审查无新增阻断项;构建、服务器、浏览器与GPU验证依项目规则跳过。真实埋深暂缓、管线状态无发布契约,均不计作已完成。完整证据和验证边界见下文及[实施验证记录](../implementation/t22/verification.md)。
+
+## Comments
+
+### local — 2026-10-14T02:41:55.883Z
+按用户指派认领,先核验完整票据、父图、前置关系和已有实施;无 needs-triage,前置「3D Tiles 与地形基线」已关闭。通过 grilling 由维护者确认常驻显隐与按已发布字段收口。
+
+### local — 2026-10-14T03:51:00.436Z
+Resolution:按维护者确认的常驻管线与已发布字段范围关闭。补齐坏顶点整条拦下、仅穿过管线的普通点位拾取;154项相关离线回归通过,Standards 0、Spec无新增阻断项。未进行build/debug/browser/GPU复验;真实埋深暂缓,状态无契约。完整验收、已有成果归属和限制已写入正文及实施验证记录,所属map已追加gist。
+
 
-## 进度:0%
+## Answer
 
-未动工。等待按 Blocked by 依赖顺序实施。
+按已发布字段完成 Cesium 管线交付面核验,补齐坏顶点不得重连和管线不吞点选两处缺口;154项相关离线回归通过,规范/规格双轴无新增阻断项。真实埋深暂缓、状态筛选未有契约,保持当前虚拟埋深明确非实测语义。验证边界如进度区所述。

+ 3 - 1
.scratch/cockpit-shell-panel-layout/map.md

@@ -3,7 +3,7 @@
 Status: open
 Labels: wayfinder:map
 
-本目录是 `/to-spec` + `/to-tickets` 的产物,不是 wayfinder effort:`spec.md` 是规格,`issues/01`–`23` 是实现票(01、02、03、04、05、06、07、08、09、10、11、12、**14 已关闭**、**15 已关闭**、**16 已关闭**、**17 已关闭**、**18 已关闭**、**19 已关闭**、**13 已关闭**、**20 已关闭(2026-09-29)**)—— **至此 01–23 的实现票全部关闭,16 块二级面板全部接上真实视图**(`PanelPlaceholder` 成为未注册 panelId 的兜底)。`issues/24`–`30` 是调研、待人工处理项与后续新增票(24 调研已关闭;25 待人工;26 watcher 崩溃已修复;**27 接入 Cesium 常驻 Viewer**,是票 03 的前置,已关闭;28 外壳填满容器已关闭;29 底图影像(配置驱动)已关闭;30 进入引导与视角预设已关闭)。
+本目录是 `/to-spec` + `/to-tickets` 的产物,不是 wayfinder effort:`spec.md` 是规格,`issues/01`–`23` 是实现票(01、02、03、04、05、06、07、08、09、10、11、12、**14 已关闭**、**15 已关闭**、**16 已关闭**、**17 已关闭**、**18 已关闭**、**19 已关闭**、**13 已关闭**、**20 已关闭(2026-09-29)**)—— **16 块二级面板已接上真实视图;Cesium 管线渲染已按修订范围关闭,Three.js 管线增强仍未关闭,完整状态以各票为准**(`PanelPlaceholder` 成为未注册 panelId 的兜底)。`issues/24`–`30` 是调研、待人工处理项与后续新增票(24 调研已关闭;25 待人工;26 watcher 崩溃已修复;**27 接入 Cesium 常驻 Viewer**,是票 03 的前置,已关闭;28 外壳填满容器已关闭;29 底图影像(配置驱动)已关闭;30 进入引导与视角预设已关闭)。
 本 `map.md` 的唯一作用是把这个 effort 暴露给 issue tracker 面板——面板只枚举含 `map.md` 的 `.scratch/<effort>/`,没有 `map.md` 的目录连同它的票都不会被列出。
 `Labels: wayfinder:map` 在这里只用于让面板按**地图视图**渲染本 effort 的子票状态、进度与依赖分层;不代表本 effort 由 `/wayfinder` 驱动——推进仍走 `/implement`。
 
@@ -30,6 +30,8 @@ Labels: wayfinder:map
 
 ## Decisions so far
 
+- [Cesium 地理管线渲染](issues/22-cesium-pipeline-rendering.md) — 按维护者确认的常驻图层及已发布字段范围关闭:补齐坏顶点整条拦下与管线不吞点选,154项离线回归通过;真实埋深暂缓、状态无契约,build/debug与GPU复验未执行。
+
 - [spec.md](spec.md) — 二级面板改为同屏左右两栏加标题栏展开/收起,替代 `mode/subview` 切换。
 - [决策地图](../web-cockpit-decision-map/map.md) — `mode`/`panelId` 英文词典、三键相机契约、地面与管线视图合成、Cesium 集成方式、无登录页鉴权。
 - [ADR 0003](../../docs/adr/0003-web-page-information-architecture.md) — 页面信息架构与 `mode`/`panelId` 词典。

+ 149 - 0
.scratch/cockpit-shell-panel-layout/verify/t22-pipeline-rendering.mjs

@@ -0,0 +1,149 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import vm from 'node:vm'
+import * as Cesium from 'cesium'
+import { toPipeNetwork, filterPipeNetwork } from '../../../src/modules/cockpit/domain/pipeNetwork.js'
+import { planModeLayers } from '../../../src/modules/cockpit/domain/modeLayerPlan.js'
+import { planViewMode } from '../../../src/modules/cockpit/domain/viewModes.js'
+import { syncPipeLayer, clearPipeLayer } from '../../../src/modules/cockpit/services/cesiumPipeLayer.js'
+
+// Offline only: real domain functions and Cesium entities; GPU picking returns controlled hits.
+// No network, server, browser, file mutations, or in-place mutation testing.
+const source = readFileSync(new URL('../../../src/modules/cockpit/services/cesiumEngine.js', import.meta.url), 'utf8')
+const code = source.replace(/^import\s+[^]*?from\s+['"][^'"\n]+['"][ \t]*$/gm, '')
+    .replace(/^import\s+['"][^'"\n]+['"][ \t]*$/gm, '')
+    .replace(/^export /gm, '')
+const context = vm.createContext({ ...Cesium, CesiumMath: Cesium.Math })
+vm.runInContext(`${code}\nglobalThis.engine = { pickPointMarkerAt, pickDeviceMarkerAt };`, context)
+const engine = context.engine
+
+let failed = 0
+let passed = 0
+async function check(name, action) {
+    try { await action(); passed += 1; console.log(`PASS ${name}`) }
+    catch (error) { failed += 1; console.error(`FAIL ${name}: ${error.message}`) }
+}
+const path = [[110.39, 28.44], [110.395, 28.445], [110.40, 28.45]]
+function feature(coordinates = path, properties = {}) {
+    return { type: 'Feature', id: 'P-1', geometry: { type: 'LineString', coordinates }, properties: {
+        code: 'P-1', industry: 'GAS', diameter: '400', material: '钢',
+        coordinateValidation: { renderable: true }, ...properties,
+    } }
+}
+const network = toPipeNetwork({ features: [feature()] })
+await check('完整路径、源管径和可选材质原样映射', () => {
+    assert.deepEqual(network.pipes[0].positions, path)
+    assert.equal(network.pipes[0].diameter, 400)
+    assert.equal(network.pipes[0].material, '钢')
+    const missing = toPipeNetwork({ features: [feature(path, { diameter: null, material: null })] }).pipes[0]
+    assert.equal(missing.diameter, null)
+    assert.equal(missing.material, '')
+})
+await check('任一坏顶点整条不上图,保留清单,不跨点重连', () => {
+    for (const bad of [null, [], ['110.395', 28.445], [NaN, 28.445], [110.395, Infinity]]) {
+        const result = toPipeNetwork({ features: [feature([path[0], bad, path[2]])] })
+        assert.equal(result.pipes.length, 0)
+        assert.equal(result.blocked.length, 1)
+        assert.equal(result.blocked[0].code, 'P-1')
+        assert.equal(result.blocked[0].reasonText, '无地图位置')
+    }
+})
+await check('后端未准入即不上图,不用合法结构替代后端校核', () => {
+    const result = toPipeNetwork({ features: [feature(path, { coordinateValidation: { renderable: false, reason: 'UNVERIFIED_COORDINATE' } })] })
+    assert.equal(result.pipes.length, 0)
+    assert.equal(result.blocked[0].reasonText, '坐标未经校验')
+})
+await check('行业筛选同样裁剪无地图位置清单,不修改原始网络', () => {
+    const all = toPipeNetwork({ features: [feature(), feature(path, { code: 'W', industry: 'WATER_SUPPLY' }), feature([path[0]], { code: 'X', industry: 'WATER_SUPPLY' })] })
+    assert.deepEqual(filterPipeNetwork(all, { industry: 'gas' }).pipes.map((p) => p.code), ['P-1'])
+    assert.equal(filterPipeNetwork(all, { industry: 'gas' }).blocked.length, 0)
+    const water = filterPipeNetwork(all, { industry: 'water' })
+    assert.deepEqual(water.pipes.map((p) => p.code), ['W'])
+    assert.equal(water.blocked.length, 1)
+    assert.equal(filterPipeNetwork(all, { industry: 'all' }).pipes.length, 2)
+    assert.equal(all.blocked.length, 1)
+})
+await check('四种模式均保留管线,引导态隐藏,切视图不裁管线', () => {
+    for (const mode of ['overview', 'infrastructure', 'monitoring', 'warningResponse']) {
+        const input = { mode, entered: true, objects: [], facilities: [], pipelines: network.pipes }
+        assert.equal(planModeLayers(input).pipelines, network.pipes)
+        assert.equal(planModeLayers({ ...input, entered: false }).pipelines.length, 0)
+    }
+    for (const mode of ['ground', 'pipeline']) {
+        const plan = planViewMode(mode)
+        assert.ok(plan.terrainAlpha > 0 && plan.tilesetAlpha > 0)
+        assert.equal(plan.terrainAlpha < 1, mode === 'pipeline')
+        assert.equal(plan.tilesetAlpha < 1, mode === 'pipeline')
+    }
+})
+
+const pipes = new Cesium.CustomDataSource('cockpit-pipelines')
+const devices = new Cesium.CustomDataSource('cockpit-associated-device-markers')
+const pipe = pipes.entities.add({ id: 'pipe:P-1', properties: { kind: 'pipe', code: 'P-1' } })
+const component = pipes.entities.add({ id: 'component:C-1', properties: { kind: 'component', code: 'C-1' } })
+const point = new Cesium.Entity({ id: 'point:M', properties: { pointCode: 'M', pointKind: 'monitoring-point', pointStatus: 'NORMAL' } })
+const device = new Cesium.Entity({ id: 'device:D', properties: { deviceCode: 'D', monitoringPointCode: 'M', deviceStatus: 'NORMAL' } })
+const model = { kind: 'pipe-model-batch', key: 'batch', instances: ['pipe:P-1'] }
+const building = { id: 'building' }
+let hits = []
+let drills = 0
+let pickedPosition = null
+const viewer = {
+    dataSources: { getByName: (name) => name === pipes.name ? [pipes] : name === devices.name ? [devices] : [] },
+    canvas: { getBoundingClientRect: () => ({ left: 20, top: 30 }) },
+    scene: {
+        pick(position) { pickedPosition = position; return hits[0] },
+        drillPick() { drills += 1; return hits },
+    },
+}
+await check('普通点选不触发多层拾取,窗口坐标转画布坐标', () => {
+    hits = [{ id: point }]; drills = 0
+    assert.equal(engine.pickPointMarkerAt(viewer, 120, 230).code, 'M')
+    assert.equal(drills, 0)
+    assert.equal(pickedPosition.x, 100)
+    assert.equal(pickedPosition.y, 200)
+})
+await check('管线、构件、模型都不能吞掉监测点', () => {
+    for (const foreground of [pipe, component, model]) {
+        hits = [{ id: foreground }, { id: point }]
+        assert.equal(engine.pickPointMarkerAt(viewer, 120, 230).code, 'M')
+    }
+})
+await check('管线遮挡设备时仍回设备身份与父点位', () => {
+    hits = [{ id: model }, { id: pipe }, { id: device }]
+    const hit = engine.pickDeviceMarkerAt(viewer, 120, 230)
+    assert.equal(hit.code, 'D')
+    assert.equal(hit.pointCode, 'M')
+})
+await check('不穿透建筑/其它对象,也不把单独管线当监测点或设备', () => {
+    for (const list of [[{ id: building }, { id: point }], [{ id: pipe }, { id: building }, { id: point }], [{ id: model }]]) {
+        hits = list
+        assert.equal(engine.pickPointMarkerAt(viewer, 120, 230), null)
+        assert.equal(engine.pickDeviceMarkerAt(viewer, 120, 230), null)
+    }
+})
+await check('名字相似但不属于管线源的对象不能被穿透', () => {
+    hits = [{ id: new Cesium.Entity({ id: 'pipe:foreign' }) }, { id: point }]
+    assert.equal(engine.pickPointMarkerAt(viewer, 120, 230), null)
+})
+await check('无多层拾取能力时不伪造命中', () => {
+    hits = [{ id: pipe }, { id: point }]
+    const limited = { ...viewer, scene: { pick: viewer.scene.pick } }
+    assert.equal(engine.pickPointMarkerAt(limited, 120, 230), null)
+})
+await check('真实Cesium折线写入且相同输入不重赋值,筛空后移除', async () => {
+    const target = { dataSources: new Cesium.DataSourceCollection() }
+    const first = await syncPipeLayer(target, network)
+    assert.equal(first.pipes, 1)
+    const entity = target.dataSources.getByName(pipes.name)[0].entities.getById('pipe:P-1')
+    assert.equal(entity.polyline.positions.getValue().length, path.length)
+    const positions = entity.polyline.positions
+    const second = await syncPipeLayer(target, network)
+    assert.equal(second.unchanged, 1)
+    assert.equal(entity.polyline.positions, positions)
+    const empty = await syncPipeLayer(target, { pipes: [], components: [] })
+    assert.equal(empty.removed, 1)
+    await clearPipeLayer(target)
+})
+console.log(`T22: ${passed} passed, ${failed} failed`)
+process.exitCode = failed ? 1 : 0

+ 1 - 1
.scratch/cockpit-shell-panel-layout/verify/t41-point-locate-anchor.mjs

@@ -247,7 +247,7 @@ const CANVAS = { left: 0, top: 0, right: 1600, bottom: 900 }
 
     // 验收第 6 条:禁止读取 Cesium 私有实体。
     const engine = read('services/cesiumEngine.js')
-    const privateRead = /\._entity|\._billboard|\._position|drillPick|\._id\b|\._show\b/.test(engine)
+    const privateRead = /\._entity|\._billboard|\._position|\._id\b|\._show\b/.test(engine)
     check('E4-no-private-cesium-members', !privateRead, '不读 Cesium 私有成员(只用公开实体/属性与公开拾取)')
 
     // 锚点的高度回退:**瓦片未加载时按椭球面**,不能因为"配了地形却取不到高度"就判标记离屏。

+ 3 - 2
.scratch/cockpit-shell-panel-layout/verify/t45-selection-continuity.mjs

@@ -49,11 +49,12 @@ check('aggregate symbol carries readable quantity and separate identity', () =>
 })
 check('monitoring point creates a visible name label', () => {
     const created = []
-    const pctx=vm.createContext({pointMarkerSource:()=>({entities:{removeAll(){},add(e){created.push(e)}}}),Cartesian3:{fromDegrees(){}},Cartesian2:class{},HeightReference:{},VerticalOrigin:{},Color:{WHITE:{},BLACK:{withAlpha:(alpha)=>({alpha})}},LabelStyle:{},viewer:{}})
+    const pctx=vm.createContext({pointMarkerSource:()=>({entities:{removeAll(){},add(e){created.push(e);return e}}}),markerEntityIndex:new WeakMap(),Cartesian3:{fromDegrees(){}},Cartesian2:class{},HeightReference:{},VerticalOrigin:{},Color:{WHITE:{},BLACK:{withAlpha:(alpha)=>({alpha})}},LabelStyle:{},viewer:{}})
     pctx.MAP_LABEL_STYLE = MAP_LABEL_STYLE
     pctx.mapLabelText = mapLabelText
     pctx.Color.fromCssColorString = () => ({ withAlpha: (alpha) => ({ alpha }) })
     vm.runInContext(extract(engine,'mapTextLabel'),pctx)
+    vm.runInContext(extract(engine,'markerFingerprint'),pctx)
     vm.runInContext(extract(engine,'syncPointMarkers'),pctx)
     vm.runInContext("syncPointMarkers(viewer,[{key:'monitoring-point|P',code:'P',kind:'monitoring-point',name:'城南监测点',longitude:1,latitude:2}],()=>'color',()=>'image')",pctx)
     assert.equal(created[0].label?.text,'⟦ 城南监测点 ⟧')
@@ -213,7 +214,7 @@ const pickContext = vm.createContext({
     Cartesian2: class { constructor(x, y) { this.x = x; this.y = y } },
     viewer: { scene: { pick: () => ({ id: pickedEntity }) }, canvas: { getBoundingClientRect: () => ({ left: 0, top: 0 }) } },
 })
-for (const name of ['readOverlapTargets', 'overlapTargetAt', 'pickDeviceMarkerAt']) {
+for (const name of ['readOverlapTargets', 'overlapTargetAt', 'pipelineSource', 'pickThroughPipelines', 'pickDeviceMarkerAt']) {
     vm.runInContext(extract(engine, name), pickContext)
 }
 check('projected selected-entry pick retains device and group identity', () => {

+ 8 - 5
src/modules/cockpit/domain/pipeNetwork.js

@@ -110,13 +110,16 @@ function diameterOf(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 : []
-    const positions = raw
-        .filter((pair) => Array.isArray(pair) && isFiniteNumber(pair[0]) && isFiniteNumber(pair[1]))
-        .map((pair) => [pair[0], pair[1]])
-    return positions.length >= 2 ? positions : []
+    if (raw.length < 2) return []
+    const positions = []
+    for (const pair of raw) {
+        if (!Array.isArray(pair) || !isFiniteNumber(pair[0]) || !isFiniteNumber(pair[1])) return []
+        positions.push([pair[0], pair[1]])
+    }
+    return positions
 }
 
 /** 构件几何:点 `[lon, lat]`。 */

+ 17 - 2
src/modules/cockpit/services/cesiumEngine.js

@@ -1330,6 +1330,21 @@ function overlapTargetAt(viewer, windowX, windowY) {
     return null
 }
 
+/** 仅穿过本管线图层;其它场景对象仍按 Cesium 的前后顺序遮挡。 */
+function pickThroughPipelines(viewer, position) {
+    const scene = viewer.scene
+    const picked = scene.pick(position)
+    const source = pipelineSource(viewer)
+    const isPipeline = (hit) => {
+        const id = hit && hit.id
+        return Boolean(id && (id.kind === 'pipe-model-batch' || (source && source.entities.contains(id))))
+    }
+    if (!isPipeline(picked)) return picked
+    // 普通拾取不受影响;只有最上层是管线时才执行多层拾取。
+    if (typeof scene.drillPick !== 'function') return null
+    return scene.drillPick(position).find((hit) => !isPipeline(hit)) || null
+}
+
 export function pickDeviceMarkerAt(viewer, windowX, windowY) {
     if (!viewer || !viewer.scene || !viewer.canvas || typeof viewer.scene.pick !== 'function') return null
 
@@ -1339,7 +1354,7 @@ export function pickDeviceMarkerAt(viewer, windowX, windowY) {
     if (projectedHit) return projectedHit
 
     const rect = viewer.canvas.getBoundingClientRect()
-    const picked = viewer.scene.pick(new Cartesian2(windowX - rect.left, windowY - rect.top))
+    const picked = pickThroughPipelines(viewer, new Cartesian2(windowX - rect.left, windowY - rect.top))
     const entity = picked && picked.id
     const properties = entity && entity.properties
     if (properties) {
@@ -1976,7 +1991,7 @@ export function pickPointMarkerAt(viewer, windowX, windowY) {
     if (!viewer || !viewer.scene || !viewer.canvas || typeof viewer.scene.pick !== 'function') return null
 
     const rect = viewer.canvas.getBoundingClientRect()
-    const picked = viewer.scene.pick(new Cartesian2(windowX - rect.left, windowY - rect.top))
+    const picked = pickThroughPipelines(viewer, new Cartesian2(windowX - rect.left, windowY - rect.top))
     const entity = picked && picked.id
     const properties = entity && entity.properties
     if (!properties || !properties.pointCode) return null