Ver Fonte

feat(cockpit): add local pipe neighborhood equipment and manhole cutaway

ChenJZ há 4 dias atrás
pai
commit
e5f65ad58f
36 ficheiros alterados com 1970 adições e 81 exclusões
  1. 44 0
      .scratch/cockpit-shell-panel-layout/issues/50-untitled.md
  2. 49 0
      .scratch/cockpit-shell-panel-layout/issues/51-untitled.md
  3. 44 0
      .scratch/cockpit-shell-panel-layout/issues/52-untitled.md
  4. 43 0
      .scratch/cockpit-shell-panel-layout/issues/53-untitled.md
  5. 69 0
      .scratch/cockpit-shell-panel-layout/local-network-enhancement-spec.md
  6. 4 1
      .scratch/cockpit-shell-panel-layout/map.md
  7. 142 0
      .scratch/cockpit-shell-panel-layout/verify/t23-click-flow.mjs
  8. 36 0
      .scratch/cockpit-shell-panel-layout/verify/t23-popup-placement.mjs
  9. 31 0
      .scratch/cockpit-shell-panel-layout/verify/t23-sfc-compile.mjs
  10. 115 0
      .scratch/cockpit-shell-panel-layout/verify/t23-threejs-enhancement.mjs
  11. 228 0
      .scratch/cockpit-shell-panel-layout/verify/t23-visual-input.mjs
  12. 13 0
      .scratch/cockpit-shell-panel-layout/verify/t50-53-local-enhancement.mjs
  13. 108 0
      .scratch/cockpit-shell-panel-layout/verify/t50-pipe-detail.mjs
  14. 58 0
      .scratch/cockpit-shell-panel-layout/verify/t51-neighborhood.mjs
  15. 47 0
      .scratch/cockpit-shell-panel-layout/verify/t52-device-pick.mjs
  16. 51 0
      .scratch/cockpit-shell-panel-layout/verify/t52-device-plan.mjs
  17. 32 0
      .scratch/cockpit-shell-panel-layout/verify/t52-device-source.mjs
  18. 42 0
      .scratch/cockpit-shell-panel-layout/verify/t53-manhole.mjs
  19. 12 0
      CONTEXT.md
  20. 76 5
      src/modules/cockpit/CockpitShell.vue
  21. 3 0
      src/modules/cockpit/components/MapCanvas.vue
  22. 88 19
      src/modules/cockpit/components/PipeEnhancement.vue
  23. 87 0
      src/modules/cockpit/domain/enhancementDevices.js
  24. 1 0
      src/modules/cockpit/domain/mapSummary.js
  25. 52 7
      src/modules/cockpit/domain/pipeEnhancement.js
  26. 88 0
      src/modules/cockpit/domain/pipeNeighborhood.js
  27. 8 1
      src/modules/cockpit/domain/pipeNetwork.js
  28. 2 0
      src/modules/cockpit/domain/pointDetail.js
  29. 2 2
      src/modules/cockpit/services/cesiumEngine.js
  30. 39 0
      src/modules/cockpit/services/enhancementDevices.js
  31. 5 0
      src/modules/cockpit/services/mapService.js
  32. 59 0
      src/modules/cockpit/services/pipeDetailGeometry.js
  33. 39 0
      src/modules/cockpit/services/sceneInputFocus.js
  34. 169 46
      src/modules/cockpit/services/threePipeEnhancement.js
  35. 58 0
      src/modules/cockpit/stores/enhancementDevicesStore.js
  36. 26 0
      src/modules/cockpit/stores/pipeEnhancementStore.js

+ 44 - 0
.scratch/cockpit-shell-panel-layout/issues/50-untitled.md

@@ -0,0 +1,44 @@
+<!-- DSH-IDEMPOTENCY-KEY: local-network-2026-pipe-detail-v1 -->
+<!-- parentKey: 00 -->
+# 局部管道示意模型细节与中心定位环移除
+
+## What to build
+
+按已确认局部邻域管网规格,选中管段进入增强窗后呈现中空管壁、端部法兰及螺栓、分节接缝;删除独立中心定位环,继续使用主图行业配色。邻近管段后续使用简化细节。不是工程竣工模型,不添加参考图中无依据的支墩。
+
+## Acceptance criteria
+
+- [x] 中空管壁、端部法兰/螺栓与接缝几何已实现,端部管腔开放;现场美术效果待验。
+- [x] 没有独立定位环;法兰位于整条路径两端,不在共享折点叠加。
+- [x] 主图行业色、源路径、示意免责声明和三键操作保留。
+- [x] 换选/关闭释放全部几何材质,不残留旧模型,逐资源监听回归通过。
+- [x] 公开场景渲染/领域计划回归先红后绿;离线与现场证据分开。
+- [ ] 维护者确认实际画面观感;本轮按项目规则跳过build/debug及浏览器。
+
+## Context
+
+已确认采用中空壁+法兰螺栓+分节接缝,删除中心环。完整方案见[局部邻域管网与窨井规格](../local-network-enhancement-spec.md)。[51邻域管网](51-untitled.md)、[52设备](52-untitled.md)、[53窨井](53-untitled.md)另票推进;不把新需求或23的现场验收记成本票已经完成。
+
+## 验证与审查
+
+T50模型回归9/9(轴向通腔、壁命中、法兰/螺栓、接缝、去环、短段居中、纵向与折线、几何材质逐个释放);颜色与交互19/19;旧增强9/9;点击关闭连通性、SFC5/5、布局通过。规范审查无硬违规;规格审查发现内部折点双法兰和释放测试不足,新增用例先红后绿并修订;fitScale命名更正。未现场视觉验收。
+
+## 进度
+
+代码完成,离线通过;待现场视觉验收。准备继续51邻域管网。
+
+Status: ready-for-human
+Assignee: codex
+Type: task
+Blocked by:
+Labels: ready-for-human, wayfinder:task
+
+## Comments
+
+
+### local — 2026-10-16T07:57:00.375Z
+票50代码与离线回归已实现:中空管壁、端部法兰螺栓、分节接缝、删除独立中心环、短管段自适应居中。独立规范审查无硬违规;规格审查提出内部折点双法兰和材质释放测试缺口,均补测修订。T50 9/9,颜色输入19/19、旧T23 9/9、点击关闭、SFC5/5、布局回归通过。按AGENTS跳过build/dev/browser,尚无现场视觉验收,保持打开。
+
+
+## Answer
+代码完成,待维护者现场视觉确认。

+ 49 - 0
.scratch/cockpit-shell-panel-layout/issues/51-untitled.md

@@ -0,0 +1,49 @@
+<!-- DSH-IDEMPOTENCY-KEY: local-network-2026-neighborhood-v1 -->
+<!-- parentKey: 00 -->
+# 局部增强可调范围邻域地下管网
+
+## What to build
+
+局部查看选中管段周围的地下邻域管网,默认50米,可切换25/50/100米。距离按整条选中折线计算,主选段完整、邻近管道仅范围内片段。保留当前行业/模式筛选,主选突出、邻近简化,不重建全市。
+
+## Acceptance criteria
+
+- [x] 以整条管段为邻域对象,25/50/100米选择与标注一致。
+- [x] 正确裁切穿越边界的长折线及多段片段,不把显示截断当真实断口。
+- [x] 行业色及虚拟埋深层次与主图同源,不伪造地理/工程高程。
+- [x] 当前筛选、换选、空集、准入不合格及过期结果正确处理。
+- [x] 保留sourceId/networkId真实设施主键,不能以code冒充关联主键。
+- [x] 密集场景有可解释的加载/覆盖状态,不能静默删数据冒称完整。
+- [x] 公开领域米制样例、实际渲染输出、Vue范围切换离线回归。
+
+## Blocked by
+
+None
+
+## 待现场验证
+
+- [ ] 正式入口下邻域密度、尺寸和设备模型视觉验收(本轮未运行浏览器)。
+
+## Context
+
+依据本effort local-network-enhancement-spec.md及已确认8项问答。后续设备和窨井共用邻域能力;不是工程连通拓扑。外观票50可独立推进,本票不被其视觉验收阻塞。
+
+## 进度
+
+代码与离线回归完成,待维护者现场画面验收。25/50/100米按整条选中折线构建走廊,主选完整、邻近裁切且保留多片段;主图配色与虚拟层位同源。保留真实sourceId/networkId,界面计数针对当前可用/筛选后的管网;没有静默复杂度截断。Vue范围选择→Pinia→领域计划→场景重绘通过,缺准入/零长度管段拒绝。
+
+验证:t51-neighborhood领域米制样例、穿越/多片裁切、范围/筛选、真实Three对象和层位、Pinia范围清理通过;t23-click-flow已扩展到真实Vue范围change与内部设备详情、换实例释放。整套t50-53-local-enhancement离线12个脚本通过。审查发现的设备跨裁片槽位、范围外详情残留、刷新旧源和模式过滤问题在52共用能力修复。按AGENTS跳过build/dev/browser/GPU,未现场验收。
+
+规格:[局部邻域管网与窨井剖切](../local-network-enhancement-spec.md)。
+
+Status: ready-for-human
+Assignee: codex
+Type: task
+Blocked by:
+Labels: ready-for-human, wayfinder:task
+
+## Comments
+
+
+## Answer
+代码完成,待维护者现场视觉确认。

+ 44 - 0
.scratch/cockpit-shell-panel-layout/issues/52-untitled.md

@@ -0,0 +1,44 @@
+<!-- DSH-IDEMPOTENCY-KEY: local-network-2026-devices-v1 -->
+<!-- parentKey: 00 -->
+# 邻域设备示意挂载与增强面板内部详情
+
+## What to build
+
+在邻域增强场景显示真实监测设备模型,按行业在管道固定示意槽位摆放并可点击查看内部设备详情。真实设施关系优先;无关系但合法坐标的邻近设备可挂同业最近管段,标“邻近设备·示意安装”。无合适管段独立显示,无坐标无关联仅列清单。
+
+## Acceptance criteria
+
+- [ ] 核验并补齐邻域设备读取来源及设施/井关系;不能把所选监测点设备当附近全部设备。
+- [ ] 真实设备ID与父监测点身份保留,真实关联优先于空间近邻,networkId按真实主键匹配。
+- [ ] 同业固定槽位示意布局、无位置清单、关系/布局提示,绝不创建设备或改绑定/源坐标。
+- [ ] 设备模型点选打开增强面板内部详情,复用真实详情接口;场景和相机保持。
+- [ ] 在线、告警及读数真实显示,缺失/失败/过期/重试/换选取消正确;不能从附近设备推导管道健康。
+- [ ] 同业近邻、悬空关系、无坐标、缺来源/部分覆盖、设备点击至详情和销毁行为回归。
+
+## Blocked by
+
+51
+
+## Context
+
+完整已确认方案为本effort local-network-enhancement-spec.md。历史gas_pipe_point_device_rel、manhole_device_rel不等于equipment_point_rel;当前前端适配未完整暴露设施关系,实施需核验契约,缺关联按用户允许的示意降级,不得伪报完整覆盖。
+
+## 进度
+
+代码与离线回归完成,待真实数据和画面验收。加载当前快照全部监测点详情(4并发)及独立设备,保留真实父点/ID;不把所选点设备当全部。摘要未齐、缓存截断、详情失败/缺/额外设备均报partial并可重试。当前cockpit读取契约没有正式设施关系全集端点;仅消费载荷明确提供的networkId,缺失则使用维护者批准的同业邻近示意,不宣称真实绑定。
+
+固定槽位按管段/裁片稳定分组,模型不改源坐标,未准入且无关系仅清单。点Three模型通过真实raycast派发设备身份,尊重首交点遮挡;内部复用EquipmentDetailSections和真实detail-aggregate接口,打开详情不重建场景。范围移除reconcile、刷新开始sync invalidate、模式可见性与异常集合、旧详情/source token隔离已实现。
+
+验证:t52-device-source/plan/pick、t23-click-flow内部详情DOM与不重建断言通过。独立审查发现设备在取景外、穿透拾取、刷新旧源、范围外详情、模式过滤及额外设备静默漏计,已补测修复;完整三维包围盒居中及浅设备投影测试通过。整套离线12脚本通过;依AGENTS未build/dev/browser/GPU。
+
+Status: ready-for-human
+Assignee: codex
+Type: task
+Blocked by: #51
+Labels: ready-for-human, wayfinder:task
+
+## Comments
+
+
+## Answer
+代码完成;缺少正式设施关系时按已确认示意降级,待真实数据与现场验收。

+ 43 - 0
.scratch/cockpit-shell-panel-layout/issues/53-untitled.md

@@ -0,0 +1,43 @@
+<!-- DSH-IDEMPOTENCY-KEY: local-network-2026-manhole-v1 -->
+<!-- parentKey: 00 -->
+# 窨井剖切与邻近管网监测设备查看
+
+## What to build
+
+点击窨井进入井盖+剖切井筒示意场景,显示25/50/100米范围内附近管道和真实设备并支持内部设备详情。保留主图上下文与行业配色、聚焦和三键交互。设备/接管/状态缺资料时如实降级。
+
+## Acceptance criteria
+
+- [ ] 窨井独立身份点选进入井下查看,不再将MANHOLE渲染成other方块。
+- [ ] 井盖与剖切井筒、附近管道具有可辨模型,尺寸朝向均标为示意。
+- [ ] 邻域范围可调,设备模型可点并显示真实详情,主图相机和筛选保留。
+- [ ] 有真实关联才作井内接管;缺关联标“附近管道,接管关系未提供”,不强接。
+- [ ] 管道状态和设备读数取已有字段,缺状态“未提供”;无设备不虚构,无关系不推断健康。
+- [ ] 缺位置/悬空ID/无管线源/部分覆盖/切换关闭取消与资源释放用例。
+- [ ] 入口到场景到设备详情离线集成证据,现场画面单独记录。
+
+## Blocked by
+
+51, 52
+
+## Context
+
+维护者经ask-matt/grilling确认采用剖切井筒+附近管道设备,新执行票;完整方案为本effort local-network-enhancement-spec.md。窨井网络ID历史数据可能悬空,不能当必有正式连接;共用前置邻域与设备能力。
+
+## 进度
+
+代码与离线回归完成,待维护者实际窨井场景验收。正式MANHOLE行业映射独立manhole模型,通过既有Cesium构件identity→共享选择进入大面板。井盖、开口剖切井筒和底座为示意;共用25/50/100米邻域、管道状态清单、设备模型与内部详情。缺状态写未提供;缺正式关系明确附近管道非实证接管,不造连接或设备。
+
+验证:t53-manhole覆盖窨井计划/井盖井筒/邻近管道/悬空关联、无管线源独立设备取景、深雨水与浅燃气设备真实NDC在初始视锥;t52实际设备raycast/遮挡、内部详情及旧请求隔离共用通过。t23-click-flow实际Vue窗口/设备详情/范围change回归与原三键/颜色/释放回归通过。12个针对性离线脚本通过。依AGENTS未build/dev/browser/GPU,未实际井下安装资料或现场画面核验。
+
+Status: ready-for-human
+Assignee: codex
+Type: task
+Blocked by: #51, #52
+Labels: ready-for-human, wayfinder:task
+
+## Comments
+
+
+## Answer
+窨井剖切代码与离线验证完成;不作真实井内安装声明,待现场视觉确认。

+ 69 - 0
.scratch/cockpit-shell-panel-layout/local-network-enhancement-spec.md

@@ -0,0 +1,69 @@
+# 局部邻域管网与窨井剖切查看
+
+Status: ready-for-agent
+
+## Problem Statement
+
+现有局部增强只显示一条圆柱状管段,缺少管壁、接头等形态细节,独立中心定位环容易被误认为设施。用户需要在局部场景内理解附近地下管网、真实监测设备及其信息,并从窨井入口查看井下场景。
+
+## Solution
+
+主选管段采用中空管壁、端部法兰/螺栓与分节接缝的行业色示意模型,删除独立中心环。围绕整条选中管段呈现默认50米邻域,可切换25/50/100米;主选段完整、邻近段裁切。设备优先按真实关联示意挂载,无关联但具合法坐标的邻近设备可按同业最近管段固定槽位示意摆放,明确标签。设备点击在增强面板内部展示真实详情。新增窨井剖切入口及井盖/井筒/附近管道设备场景。
+
+## User Stories
+
+1. 作为值班人员,我希望看见中空管壁和接头细节,以便辨认管道形态而非单根圆柱。
+2. 作为值班人员,我希望删除场景中心定位环,以免将其误认为实际设施。
+3. 作为值班人员,我希望主选管段有更精细的细节,以便从周围管网中辨认目标。
+4. 作为值班人员,我希望行业颜色与主图一致,以便区分燃气、供水、污水、雨水、窨井及未知分类。
+5. 作为值班人员,我希望查看整条选中管段周围50米的地下管道,以便理解局部空间环境。
+6. 作为值班人员,我希望切换25/50/100米范围,以便平衡局部细节和周围环境。
+7. 作为值班人员,我希望主选管段完整显示、邻近管道在范围边缘裁切,以免长管道拉远相机。
+8. 作为值班人员,我希望邻域尊重当前行业筛选,以便主图与局部场景一致。
+9. 作为值班人员,我希望看见真实附近设备的示意模型,以便找到可查看信息的设备。
+10. 作为值班人员,我希望明确区分真实关联设备和仅空间邻近的设备,以免把示意挂载当实际安装资料。
+11. 作为值班人员,我希望无坐标且无关联的设备留在清单,不伪造安装位置。
+12. 作为值班人员,我希望在局部场景点击设备后由内部详情栏显示档案、状态和已有读数,以便保留当前场景视角。
+13. 作为值班人员,我希望缺数据时显示未知/未提供或失败与重试,以免误读为正常或零值。
+14. 作为值班人员,我希望点击窨井查看井盖与剖切井筒,以便观察井下管网和设备。
+15. 作为值班人员,我希望窨井缺接管关系时仍可查看附近管道,但明确不是井内接管。
+16. 作为值班人员,我希望管道状态仅来自已有后端字段,不由设备正常推导健康。
+17. 作为值班人员,我希望大面板、左键平移、右键旋转、滚轮缩放和视口右键菜单抑制保持一致。
+18. 作为值班人员,我希望关闭/换选/筛选/离开时取消旧请求并释放资源,以便没有旧模型和旧设备详情残留。
+
+## Implementation Decisions
+
+- 已确认扩展局部增强为有界邻域场景,Cesium继续承担全市主图;Three不重建全城。
+- 半径口径是到整条主选管段的空间距离;默认50米,选项25/50/100米。窨井使用井位置为中心。
+- 主选管段完整;邻近折线仅保留邻域内片段。裁切端是显示边界,不标成事故断口或真实节点。
+- 领域计划负责准入、邻域选择/裁切、真实ID、关系类型、示意槽位和语义标签;服务层负责取数、模型、拾取、资源生命周期;Pinia持有选中对象与范围。
+- 管壁/法兰/螺栓/接缝是外观示意,不冒充真实安装尺寸、工程连接、实测埋深。图片仅作形态参考,不添加无依据的支墩。
+- 设备使用真实身份和真实详情数据。已有监测点设备图层不是邻域全部设备来源,必须核对并补齐可用读取契约。
+- 设施关联以真实主键和正式关系为准;保留管段sourceId/networkId,不将code误当关联主键。空间近邻不证明实际连接。
+- 无明确关联但坐标合法的邻近设备可按同业最近管段固定槽位示意挂载,显示“邻近设备·示意安装”;没有合适管段按真实位置独立呈现。无坐标无关联仅列清单。
+- 点击设备在增强面板内打开详情,复用现有设备详情读取接口,不依赖被覆盖的主图详情浮层;保留真实父监测点身份。
+- 窨井场景独立执行票,剖切井筒、井盖和附近管网设备共享邻域/取数能力;缺关系不强行接管。
+- 状态与行业颜色分开。状态、读数只来自真实字段,缺管道状态显示“状态未提供”,不默认流动、满水、堵塞或由设备推导健康。
+- 保持已交付的大面板、共享配色、场景聚焦、左平移/右旋转/滚轮缩放、关闭与Esc语义。
+- 密集邻域需要复杂度/加载状态与实际覆盖提示;不能静默截断后声称完整。具体性能阈值由实施测量记录,不对未知性能作保证。
+
+## Testing Decisions
+
+- 用户确认公开领域计划、实际渲染场景对象/材质/输入事件与外壳Vue交互边界;优先完整用户流而非匹配源码字符串。
+- 外观:实际Three几何输出检查中空壁、法兰/螺栓与行业色,确认无独立定位环及销毁释放。
+- 邻域:已知米制样例、范围边界、折线穿越、主选完整、邻近裁切、多片段、行业筛选、无准入/坏坐标/空集合、数据更新不改源对象。
+- 设备:真实ID关联优先、同业邻近示意标签、固定槽位不重叠、无位置清单、不创设备、不借父点坐标;点击真实设备请求并显示内部详情、失败/未知/过期取消。
+- 窨井:入口身份、剖切模型、附近与接管区别、缺设备/缺状态/关系悬空、管道设备点击及返回。
+- 保持颜色、焦点菜单边界、三键相机行为、关闭/替换/卸载资源释放回归。
+- 本仓已有直接Node离线领域、Three renderer注入、Cesium受控拾取、Vue编译/渲染回归可复用。
+- 依项目规则不自动build/dev/browser/GPU。离线证据与维护者画面验收分开,不能由对象断言冒称真实视觉验收。
+
+## Out of Scope
+
+- Three重建全市、替换Cesium、真实安装/BIM建模、高精工程尺寸、猜测连接或健康状态。
+- 未提供依据的混凝土支墩、强行接管、虚构传感器或假读数。
+- 修改后端业务数据或设备绑定关系。
+
+## Further Notes
+
+方案经8项设计问答、最终方案及4张票粒度/依赖与测试边界确认。执行顺序:[50 管道外观/去环](issues/50-untitled.md);[51 邻域管网](issues/51-untitled.md);[52 设备及详情](issues/52-untitled.md)(依赖51);[53 窨井](issues/53-untitled.md)(依赖51、52)。原增强票仍保留现场验收缺口,新需求分票记录,不覆盖其历史。

+ 4 - 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)**)—— **16 块二级面板已接上真实视图;Cesium 管线渲染已按修订范围关闭,Three.js 管线增强仍未关闭,完整状态以各票为准**(`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`。
 
@@ -31,7 +31,10 @@ Labels: wayfinder:map
 
 ## Decisions so far
 
+- **局部邻域管网与窨井增强(维护者确认)**:[规格](local-network-enhancement-spec.md);[50 管道细节/去环](issues/50-untitled.md)、[51 邻域裁切](issues/51-untitled.md)、[52 设备示意挂载/内部详情](issues/52-untitled.md)、[53 窨井剖切](issues/53-untitled.md)。依赖52←51、53←51/52。代码及12个针对性离线脚本完成,四票转待人工/真实数据画面验收,未关闭。50为中空壁/法兰螺栓/接缝、无独立定位环;51沿整段25/50/100米(默认50)邻域,主段完整邻近裁切;52读当前快照全部点位设备、保留缺口并复用真实详情,固定槽位明确示意,取景/遮挡/模式/刷新取消已回归;53井盖/剖切井筒+邻近管道/设备,缺接管或状态不推造。未运行build/dev/browser/GPU。正式设施关系全集读取契约仍未提供,按确认规则降级,不宣称真实井内安装。
+
 - [Cesium 地理管线渲染](issues/22-cesium-pipeline-rendering.md) — 按维护者确认的常驻图层及已发布字段范围关闭:补齐坏顶点整条拦下与管线不吞点选,154项离线回归通过;真实埋深暂缓、状态无契约,build/debug与GPU复验未执行。
+- [23: Three.js 管线增强](issues/23-threejs-pipeline-enhancement.md) — 保持打开,待现场验收:增强窗移至外壳交互层;按维护者反馈放大为1100×760px居中覆盖地图,共用主图雨污/供水色表,主图和局部视口聚焦并抑制右键菜单,局部操作统一为左键平移/右键旋转/滚轮缩放。颜色与输入行为19/19、布局/点击关闭连通性、SFC5/5、T23 9/9离线通过;未做本轮build/dev/browser/GPU验证。
 
 - [恢复驾驶舱3D渲染:调色与雾着色器的GLSL兼容性](issues/49-3d-glsl.md) — 已关闭:调色/雾片元输入改为安装版Cesium的GLSL3契约,正式入口全景、缩放与管道往返恢复绘制;建筑视觉与性能验收仍归模型票。
 

+ 142 - 0
.scratch/cockpit-shell-panel-layout/verify/t23-click-flow.mjs

@@ -0,0 +1,142 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import vm from 'node:vm'
+import { parse as parseJS } from '@babel/parser'
+import { parse, compileScript } from '@vue/compiler-sfc'
+import * as Vue from 'vue'
+import * as Cesium from 'cesium'
+import { createPinia, setActivePinia } from 'pinia'
+import { attachPointPick } from '../../../src/modules/cockpit/services/pointPick.js'
+import { resolveOverlapPick } from '../../../src/modules/cockpit/domain/overlapDevices.js'
+import { PIPE_ENHANCEMENT_KIND, planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnhancement.js'
+import { usePipeEnhancementStore } from '../../../src/modules/cockpit/stores/pipeEnhancementStore.js'
+import { useEnhancementDevicesStore } from '../../../src/modules/cockpit/stores/enhancementDevicesStore.js'
+
+const src = (name) => readFileSync(new URL(`../../../src/modules/cockpit/${name}`, import.meta.url), 'utf8')
+const service = src('services/mapService.js')
+const shell = parse(src('CockpitShell.vue')).descriptor
+function nodes(source) {
+    return parseJS(source, { sourceType: 'module' }).program.body.map((n) => n.declaration || n)
+}
+function functions(source, names) {
+    return nodes(source).filter((n) => n.type === 'FunctionDeclaration' && names.includes(n.id.name))
+        .map((n) => source.slice(n.start, n.end)).join('\n')
+}
+const engineCode = src('services/cesiumEngine.js')
+    .replace(/^import\s+[^]*?from\s+['"][^'"\n]+['"][ \t]*$/gm, '')
+    .replace(/^import\s+['"][^'"\n]+['"][ \t]*$/gm, '').replace(/^export /gm, '')
+const engineContext = vm.createContext({ ...Cesium, CesiumMath: Cesium.Math })
+vm.runInContext(`${engineCode}\nglobalThis.engine = { pickDeviceMarkerAt, pickPointMarkerAt, pickPipeObjectAt };`, engineContext)
+const source = new Cesium.CustomDataSource('cockpit-pipelines')
+source.entities.add({ id: 'pipe:P-2', properties: { kind: 'pipe', code: 'P-2' } })
+const batch = { kind: 'pipe-model-batch', instances: ['pipe:P-1', 'pipe:P-2'] }
+let hits = [{ id: batch, instanceId: 1 }]
+const devices = new Cesium.CustomDataSource('cockpit-associated-device-markers')
+const viewer = {
+    dataSources: { getByName: (name) => name === source.name ? [source] : name === devices.name ? [devices] : [] },
+    canvas: { getBoundingClientRect: () => ({ left: 20, top: 30 }) },
+    scene: { pick: () => hits[0], drillPick: () => hits },
+}
+setActivePinia(createPinia())
+const store = usePipeEnhancementStore()
+const pipes = ['P-1', 'P-2'].map((code) => ({ code, positions: [[110.1, 28.4], [110.101, 28.401]] }))
+const plan = Vue.computed(() => planPipeEnhancement({ selected: store.selected, visiblePipes: pipes, radiusM: store.radiusM }))
+const selectContext = vm.createContext({ PIPE_ENHANCEMENT_KIND, pipeEnhancementStore: store })
+vm.runInContext(`${functions(shell.scriptSetup.content, ['selectPipeEnhancement'])}\nglobalThis.select = selectPipeEnhancement`, selectContext)
+const context = vm.createContext({
+    instance: { engine: engineContext.engine, viewer }, resolveOverlapPick,
+    pipelinePickHandlers: new Set(), pointPickHandlers: new Set(),
+    overlapExpandedGroup: '', overlapPreviewGroup: '', lastPicked: null,
+    collapseOverlap() {}, paintPicked() {}, expandOverlap() {},
+})
+vm.runInContext(`${functions(service, ['onPipelinePick', 'pickPipelineAt', 'pickDeviceOrPointAt'])}\nglobalThis.api = { onPipelinePick, pickPipelineAt, pickDeviceOrPointAt };`, context)
+// Execute the actual service registration, preserving its device/point-first branch.
+const registration = service.match(/record\.detachPickInput = attachPointPick\(record\.host,[\s\S]*?\n        \}\)/)?.[0]
+assert.ok(registration, 'map must attach its pointer input')
+const host = new EventTarget()
+Object.assign(context, { record: { host }, attachPointPick })
+vm.runInContext(registration, context)
+// Execute the actual MapCanvas forwarding expression, followed by the real shell action.
+const forwarding = src('components/MapCanvas.vue').match(/onPipelinePick\(\(hit\) => emit\('pipeline-pick', hit\)\)/)?.[0]
+assert.ok(forwarding)
+Object.assign(context, { onPipelinePick: context.api.onPipelinePick, emit(event, hit) {
+    assert.equal(event, 'pipeline-pick')
+    selectContext.select(hit)
+} })
+vm.runInContext(forwarding, context)
+function click(x = 120, y = 230, upX = x) {
+    for (const [type, clientX] of [['pointerdown', x], ['pointerup', upX]]) {
+        const event = new Event(type)
+        Object.assign(event, { button: 0, pointerId: 1, clientX, clientY: y })
+        host.dispatchEvent(event)
+    }
+}
+
+// Real Vue render/watch/close path; WebGL is replaced only at its renderer boundary.
+const enhancement = parse(src('components/PipeEnhancement.vue')).descriptor
+let mounts = 0
+let releases = 0
+let devicePick
+const script = compileScript(enhancement, { id: 't23-click', inlineTemplate: true }).content
+const imports = { vue: Vue, './EquipmentDetailSections.vue': { default: { props: ['deviceCode', 'aggregate'], render() { return Vue.h('div', { 'data-device-detail': this.deviceCode }) } } }, '../services/threePipeEnhancement.js': { mountPipeEnhancement(_host, next, options) {
+    devicePick = options.onDevicePick
+    assert.equal(next.object.code, 'P-2')
+    mounts += 1
+    return { destroy() { releases += 1 } }
+} } }
+let compiled = script
+for (const node of parseJS(script, { sourceType: 'module' }).program.body.filter((n) => n.type === 'ImportDeclaration').reverse()) {
+    const declarations = node.specifiers.map((s) => `const ${s.local.name} = imports[${JSON.stringify(node.source.value)}][${JSON.stringify(s.type === 'ImportDefaultSpecifier' ? 'default' : s.imported.name)}];`).join('\n')
+    compiled = compiled.slice(0, node.start) + declarations + compiled.slice(node.end)
+}
+const Component = new Function('imports', `${compiled.replace('export default', 'return')}`)(imports)
+const element = (tag) => ({ tag, children: [], props: {}, parent: null })
+const renderer = Vue.createRenderer({
+    createElement: element, createText: (text) => ({ text }), createComment: (text) => ({ comment: text }),
+    setText(n, text) { n.text = text }, setElementText(n, text) { n.text = text },
+    parentNode: (n) => n.parent, nextSibling: () => null,
+    insert(n, parent, anchor) { if (n.parent) this.remove(n); n.parent = parent; const i = parent.children.indexOf(anchor); parent.children.splice(i < 0 ? parent.children.length : i, 0, n) },
+    remove(n) { if (n.parent) n.parent.children.splice(n.parent.children.indexOf(n), 1); n.parent = null },
+    patchProp(n, key, _old, next) { n.props[key] = next },
+})
+const root = element('root')
+const deviceStore = useEnhancementDevicesStore()
+const app = renderer.createApp({ setup: () => () => Vue.h(Component, {
+    plan: plan.value, deviceState: deviceStore, onRadiusChange: (radius) => store.setRadius(radius),
+    onClose: () => { store.clear(); deviceStore.clear() },
+    onDevicePick: (device) => deviceStore.open(device, { fetchDetail: async (code) => ({ state: 'success', data: { deviceCode: code, sections: {} } }) }),
+}) })
+app.mount(root)
+function find(node, key) {
+    if (Object.hasOwn(node.props || {}, key)) return node
+    return (node.children || []).map((n) => find(n, key)).find(Boolean)
+}
+assert.equal(find(root, 'data-pipe-enhancement'), undefined)
+click()
+await Vue.nextTick(); await Vue.nextTick()
+assert.ok(store.selected, `点击未派发:device/point=${JSON.stringify(context.api.pickDeviceOrPointAt(120, 230))}, pipe=${JSON.stringify(engineContext.engine.pickPipeObjectAt(viewer, 120, 230))}, subscribers=${context.pipelinePickHandlers.size}`)
+assert.equal(store.selected.code, 'P-2')
+assert.equal(mounts, 1)
+assert.ok(find(root, 'data-pipe-enhancement'), 'pointer hit must render the enhancement dialog')
+devicePick({ code: 'D-real', pointCode: 'MP-real', name: '真实设备', placementText: '邻近设备 · 示意安装' })
+await Vue.nextTick(); await Vue.nextTick()
+assert.equal(deviceStore.aggregate.data.deviceCode, 'D-real')
+assert.equal(find(root, 'data-device-detail').props['data-device-detail'], 'D-real')
+assert.equal(mounts, 1, '打开设备详情不能重建三维场景')
+assert.equal(store.selected.code, 'P-2')
+function findTag(node, tag) { return node.tag === tag ? node : (node.children || []).map((child) => findTag(child, tag)).find(Boolean) }
+findTag(root, 'select').props.onChange({ target: { value: '100' } })
+await Vue.nextTick(); await Vue.nextTick()
+assert.equal(store.radiusM, 100)
+assert.equal(plan.value.radiusM, 100)
+assert.equal(mounts, 2, '范围切换后重绘邻域')
+find(root, 'data-pipe-enhancement-close').props.onClick()
+await Vue.nextTick()
+assert.equal(store.selected, null)
+assert.equal(releases, 2)
+assert.equal(find(root, 'data-pipe-enhancement'), undefined)
+click(120, 230, 150)
+assert.equal(store.selected, null, 'dragging the map must not select a pipe')
+console.log('PASS pointer → 设备/点位优先链 → GPU instanceId=1 → MapCanvas emit → Pinia → Vue 弹窗 → 关闭释放;拖拽不选中')
+app.unmount()
+context.record.detachPickInput()

+ 36 - 0
.scratch/cockpit-shell-panel-layout/verify/t23-popup-placement.mjs

@@ -0,0 +1,36 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { parse } from '@vue/compiler-sfc'
+import { baseParse } from '@vue/compiler-dom'
+
+// Layout regression: a popup inside the map stacking context cannot rise above
+// the sibling columns. Inspect the real SFC tree, not a copied fixture.
+const file = new URL('../../../src/modules/cockpit/CockpitShell.vue', import.meta.url)
+const { descriptor } = parse(readFileSync(file, 'utf8'))
+const root = baseParse(descriptor.template.content)
+function find(node, predicate) {
+    if (predicate(node)) return node
+    for (const child of node.children || []) {
+        const result = find(child, predicate)
+        if (result) return result
+    }
+    return null
+}
+const main = find(root, (n) => n.type === 1 && n.tag === 'main')
+const map = find(main, (n) => n.type === 1 && n.tag === 'section')
+const popup = find(main, (n) => n.type === 1 && n.tag === 'PipeEnhancement')
+assert.ok(popup, 'selection must have a popup consumer')
+assert.equal(find(map, (n) => n === popup), null,
+    '局部增强窗不能放在 z-map=0 内,否则被右侧 z-columns=2 面板遮挡')
+assert.ok(main.children.includes(popup), 'popup must share the main overlay stacking context with the columns')
+const styles = descriptor.styles.map((s) => s.content).join('\n')
+assert.match(styles, /\.cockpit-shell__enhancement\s*\{[^}]*width:\s*min\(1100px/,
+    'enhancement popup should use the larger map overlay width')
+assert.match(styles, /\.cockpit-shell__enhancement\s*\{[^}]*z-index:\s*calc\(var\(--z-columns\) \+ 1\)/,
+    'enhancement overlay must remain above the columns')
+const panelFile = new URL('../../../src/modules/cockpit/components/PipeEnhancement.vue', import.meta.url)
+const panel = parse(readFileSync(panelFile, 'utf8')).descriptor.styles.map((s) => s.content).join('\n')
+assert.match(panel, /left: 50%/)
+assert.match(panel, /top: 50%/)
+assert.match(panel, /grid-template-rows: auto auto minmax\(0, 1fr\) auto auto/)
+console.log('PASS 局部增强窗居中放大、画布自适应,脱离地图低层级并高于业务栏')

+ 31 - 0
.scratch/cockpit-shell-panel-layout/verify/t23-sfc-compile.mjs

@@ -0,0 +1,31 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
+
+const files = [
+    'src/modules/cockpit/components/MapCanvas.vue',
+    'src/modules/cockpit/components/PipeEnhancement.vue',
+    'src/modules/cockpit/CockpitShell.vue',
+]
+for (const file of files) {
+    const source = readFileSync(file, 'utf8')
+    const { descriptor } = parse(source, { filename: file })
+    const script = compileScript(descriptor, { id: `t23-${file}` })
+    const template = compileTemplate({ source: descriptor.template.content, filename: file, id: `t23-${file}`, compilerOptions: { bindingMetadata: script.bindings } })
+    assert.deepEqual(template.errors, [], `${file} template errors`)
+    console.log(`PASS ${file}`)
+}
+const shellSource = readFileSync('src/modules/cockpit/CockpitShell.vue', 'utf8')
+assert.match(shellSource, /@pipeline-pick="selectPipeEnhancement"/)
+assert.match(shellSource, /<PipeEnhancement\b[^>]*:plan="pipeEnhancementPlan"[^>]*@close="closePipeEnhancement"\s*\/>/)
+assert.match(shellSource, /visiblePipes: modeLayerPlan\.value\.pipelines/)
+assert.match(shellSource, /visibleComponents: modeLayerPlan\.value\.facilities/)
+assert.match(shellSource, /usePipeEnhancementStore/)
+assert.match(shellSource, /pipeEnhancementStore\.clear\(\)/)
+console.log('PASS CockpitShell Three.js and Pinia public seam')
+const enhancementSource = readFileSync('src/modules/cockpit/components/PipeEnhancement.vue', 'utf8')
+assert.match(enhancementSource, /mountGeneration/)
+assert.match(enhancementSource, /generation !== mountGeneration/)
+assert.match(enhancementSource, /JSON\.stringify\(\[props\.plan\.object/)
+console.log('PASS Pinia selection and async mount guards')
+console.log(`T23 SFC compile: ${files.length + 2}/${files.length + 2}`)

+ 115 - 0
.scratch/cockpit-shell-panel-layout/verify/t23-threejs-enhancement.mjs

@@ -0,0 +1,115 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import vm from 'node:vm'
+import * as Cesium from 'cesium'
+import { enhancementSelection, PIPE_ENHANCEMENT_KIND, planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnhancement.js'
+import { mountPipeEnhancement } from '../../../src/modules/cockpit/services/threePipeEnhancement.js'
+import { createPinia, setActivePinia } from 'pinia'
+import { usePipeEnhancementStore } from '../../../src/modules/cockpit/stores/pipeEnhancementStore.js'
+
+setActivePinia(createPinia())
+const enhancementStore = usePipeEnhancementStore()
+
+const pipe = { code: 'P-001', name: '城南燃气管段', industry: 'GAS', diameter: 160, material: '钢', positions: [[110.1, 28.4], [110.101, 28.401]] }
+const component = { code: 'V-001', name: '城南阀门', industry: 'GAS', kind: 'valve', position: [110.1, 28.4] }
+const base = { visiblePipes: [pipe], visibleComponents: [component] }
+let checks = 0
+function check(name, fn) { checks += 1; fn(); console.log(`PASS ${name}`) }
+
+check('Pinia store 持有并清理局部增强选择', () => {
+    enhancementStore.select(PIPE_ENHANCEMENT_KIND.PIPE, 'P-001')
+    assert.equal(enhancementStore.selected.kind, PIPE_ENHANCEMENT_KIND.PIPE)
+    assert.equal(enhancementStore.selected.code, 'P-001')
+    enhancementStore.clear()
+    assert.equal(enhancementStore.selected, null)
+})
+
+check('没有选中对象时不创建增强计划', () => {
+    assert.equal(planPipeEnhancement(base).visible, false)
+    assert.equal(planPipeEnhancement(base).reason, 'no-selection')
+})
+check('局部计划只保留当前被选中的管段', () => {
+    const plan = planPipeEnhancement({ ...base, selected: enhancementSelection(PIPE_ENHANCEMENT_KIND.PIPE, 'P-001') })
+    assert.equal(plan.visible, true)
+    assert.equal(plan.object.code, 'P-001')
+    assert.equal(plan.object.path.length, 2)
+    assert.match(plan.disclaimer, /不代表工程竣工模型/)
+})
+check('筛选移除对象时增强视图自动不可见', () => {
+    const plan = planPipeEnhancement({ selected: enhancementSelection('pipe', 'P-001'), visiblePipes: [] })
+    assert.equal(plan.visible, false)
+    assert.equal(plan.reason, 'object-not-visible')
+})
+check('构件计划保留构件类型但不伪造管径', () => {
+    const plan = planPipeEnhancement({ ...base, selected: enhancementSelection(PIPE_ENHANCEMENT_KIND.COMPONENT, 'V-001') })
+    assert.equal(plan.visible, true)
+    assert.equal(plan.object.renderKind, 'valve')
+    assert.equal(plan.object.diameter, null)
+})
+
+check('未知选择类型被拒绝而不是默认成管段', () => {
+    const plan = planPipeEnhancement({ ...base, selected: { kind: 'foreign', code: 'P-001' } })
+    assert.equal(plan.visible, false)
+    assert.equal(plan.reason, 'invalid-selection')
+})
+
+class Canvas {
+    constructor() { this.listeners = new Map(); this.parentNode = null }
+    addEventListener(name, fn) { this.listeners.set(name, fn) }
+    removeEventListener(name) { this.listeners.delete(name) }
+}
+class Host {
+    constructor() { this.clientWidth = 320; this.clientHeight = 220; this.children = []; this.ownerDocument = { createElement: () => new Canvas() } }
+    appendChild(node) { node.parentNode = this; this.children.push(node) }
+    removeChild(node) { this.children = this.children.filter((item) => item !== node); node.parentNode = null }
+}
+function rendererFactory({ canvas }) {
+    const domElement = canvas || new Canvas()
+    return { domElement, setPixelRatio() {}, setSize() {}, render() {}, dispose() {} }
+}
+
+check('渲染服务支持相同编码但不同对象类型的替换', () => {
+    const host = new Host()
+    const first = planPipeEnhancement({ ...base, selected: enhancementSelection('pipe', 'P-001') })
+    const viewer = mountPipeEnhancement(host, first, { rendererFactory })
+    const sameCodeComponent = { ...component, code: 'P-001' }
+    const second = planPipeEnhancement({ visiblePipes: [pipe], visibleComponents: [sameCodeComponent], selected: enhancementSelection('component', 'P-001') })
+    viewer.sync(second)
+    assert.equal(viewer.getState().title, '重点构件示意')
+    assert.equal(viewer.getState().code, 'P-001')
+    viewer.destroy()
+    assert.equal(viewer.getState().status, 'destroyed')
+})
+
+const engineSource = readFileSync(new URL('../../../src/modules/cockpit/services/cesiumEngine.js', import.meta.url), 'utf8')
+const engineCode = engineSource
+    .replace(/^import\s+[^]*?from\s+['"][^'"\n]+['"][ \t]*$/gm, '')
+    .replace(/^import\s+['"][^'"\n]+['"][ \t]*$/gm, '')
+    .replace(/^export /gm, '')
+const engineContext = vm.createContext({ ...Cesium, CesiumMath: Cesium.Math })
+vm.runInContext(`${engineCode}\nglobalThis.pipePicker = pickPipeObjectAt;`, engineContext)
+const pipeSource = new Cesium.CustomDataSource('cockpit-pipelines')
+const entity = pipeSource.entities.add({ id: 'pipe:P-001', properties: { kind: 'pipe', code: 'P-001' } })
+let picked = { id: entity }
+const pickViewer = {
+    dataSources: { getByName: (name) => name === 'cockpit-pipelines' ? [pipeSource] : [] },
+    canvas: { getBoundingClientRect: () => ({ left: 20, top: 30 }) },
+    scene: { pick: () => picked },
+}
+check('Cesium entity pick returns the selected pipeline identity', () => {
+    const hit = engineContext.pipePicker(pickViewer, 120, 230)
+    assert.equal(hit.kind, 'pipeline')
+    assert.equal(hit.code, 'P-001')
+    assert.equal(hit.objectKind, 'pipe')
+})
+check('GPU instance pick resolves instanceId instead of batch first item', () => {
+    picked = { id: { kind: 'pipe-model-batch', instances: ['pipe:P-001', 'component:V-001'] }, instanceId: 1 }
+    const hit = engineContext.pipePicker(pickViewer, 120, 230)
+    assert.equal(hit.kind, 'pipeline')
+    assert.equal(hit.code, 'V-001')
+    assert.equal(hit.objectKind, 'component')
+    picked = { id: { kind: 'pipe-model-batch', instances: ['pipe:P-001'] }, instanceId: 7 }
+    assert.equal(engineContext.pipePicker(pickViewer, 120, 230), null)
+})
+
+console.log(`T23 Three.js enhancement: ${checks}/${checks}`)

+ 228 - 0
.scratch/cockpit-shell-panel-layout/verify/t23-visual-input.mjs

@@ -0,0 +1,228 @@
+import assert from 'node:assert/strict'
+import { planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnhancement.js'
+import { mountPipeEnhancement } from '../../../src/modules/cockpit/services/threePipeEnhancement.js'
+import { mountMap, unmountMap } from '../../../src/modules/cockpit/services/mapService.js'
+import { attachSceneFocus } from '../../../src/modules/cockpit/services/sceneInputFocus.js'
+
+let failures = 0
+let passed = 0
+function check(name, fn) {
+    try { fn(); passed += 1; console.log(`PASS ${name}`) }
+    catch (error) { failures += 1; console.error(`FAIL ${name}: ${error.message}`) }
+}
+class Element extends EventTarget {
+    constructor(doc) {
+        super(); this.ownerDocument = doc; this.attributes = new Map(); this.dataset = {}
+        this.clientWidth = 900; this.clientHeight = 500; this.children = []; this.parentNode = null
+    }
+    getAttribute(name) { return this.attributes.get(name) ?? null }
+    setAttribute(name, value) { this.attributes.set(name, String(value)) }
+    removeAttribute(name) { this.attributes.delete(name) }
+    focus() { this.ownerDocument.activeElement = this; this.dispatchEvent(new Event('focusin')) }
+    contains(node) { return node === this || this.children.some((child) => child.contains(node)) }
+    appendChild(node) { node.parentNode = this; this.children.push(node) }
+    removeChild(node) { this.children.splice(this.children.indexOf(node), 1); node.parentNode = null }
+}
+function fixture() {
+    const doc = { activeElement: null, createElement() { return new Element(doc) } }
+    const host = new Element(doc)
+    let scene
+    let camera
+    const rendererFactory = ({ canvas }) => ({ domElement: canvas, setPixelRatio() {}, setSize() {},
+        render(value, view) { scene = value; camera = view; camera.updateMatrixWorld() }, dispose() {} })
+    return { doc, host, rendererFactory, scene: () => scene, camera: () => camera }
+}
+function selected(row, component = false) {
+    const item = { code: 'P', positions: [[110.1, 28.4], [110.101, 28.401]], position: [110.1, 28.4], kind: 'valve', ...row }
+    return planPipeEnhancement({ selected: { kind: component ? 'component' : 'pipe', code: 'P' }, visiblePipes: [item], visibleComponents: [item] })
+}
+function event(target, type, extras = {}) {
+    const value = new Event(type, { cancelable: true, bubbles: true })
+    Object.assign(value, extras)
+    target.dispatchEvent(value)
+    return value
+}
+for (const [industry, networkLevel, expected] of [
+    ['GAS', undefined, 'ff3b52'], ['WATER_SUPPLY', undefined, '168bff'],
+    ['DRAINAGE', 0, '29e36c'], ['DRAINAGE', 1, '00e5ff'],
+    ['DRAINAGE', ' 1 ', '00e5ff'], ['DRAINAGE', null, '29e36c'],
+    ['DRAINAGE', 7, 'aab7c4'], ['MANHOLE', undefined, 'b36bff'], ['unknown', undefined, 'aab7c4'],
+]) {
+    check(`${industry}/${networkLevel}: actual Three mesh uses #${expected}`, () => {
+        const f = fixture()
+        const viewer = mountPipeEnhancement(f.host, selected({ industry, networkLevel }), f)
+        try {
+            const root = f.scene().children.find((node) => node.name === 'pipe-enhancement:P')
+            let mesh
+            root.traverse((node) => { if (node.name === 'pipe-wall') mesh ||= node })
+            assert.ok(mesh, '管壁材质必须存在')
+            assert.equal(mesh.material.color.getHexString(), expected)
+        } finally { viewer.destroy() }
+    })
+}
+check('雨水设施构件同样使用青色', () => {
+    const f = fixture()
+    const viewer = mountPipeEnhancement(f.host, selected({ industry: 'DRAINAGE', networkLevel: 1 }, true), f)
+    try {
+        const root = f.scene().children.find((node) => node.name === 'pipe-enhancement:P')
+        let mesh
+        root.traverse((node) => { if (node.isMesh && node.parent.userData?.sourceKind === 'component') mesh ||= node })
+        assert.ok(mesh)
+        assert.equal(mesh.material.color.getHexString(), '00e5ff')
+    } finally { viewer.destroy() }
+})
+check('局部视口右键取消浏览器菜单并获得焦点,销毁后移除监听', () => {
+    const f = fixture()
+    const viewer = mountPipeEnhancement(f.host, selected({ industry: 'WATER_SUPPLY' }), f)
+    try {
+        event(f.host, 'pointerdown', { button: 2 })
+        assert.equal(f.doc.activeElement, f.host)
+        assert.equal(event(f.host, 'contextmenu').defaultPrevented, true)
+    } finally { viewer.destroy() }
+    assert.equal(event(f.host, 'contextmenu').defaultPrevented, false)
+    assert.equal(f.host.getAttribute('tabindex'), null)
+})
+check('局部缩放取消页面滚动,销毁后恢复默认事件', () => {
+    const f = fixture()
+    const viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f)
+    const canvas = f.host.children[0]
+    assert.equal(event(canvas, 'wheel', { deltaY: 100 }).defaultPrevented, true)
+    viewer.destroy()
+    assert.equal(event(canvas, 'wheel', { deltaY: 100 }).defaultPrevented, false)
+})
+check('主图宿主统一抑制右键菜单并聚焦,卸载后恢复', () => {
+    const f = fixture()
+    mountMap(f.host)
+    try {
+        event(f.host, 'pointerdown', { button: 2 })
+        assert.equal(f.doc.activeElement, f.host)
+        assert.equal(event(f.host, 'contextmenu').defaultPrevented, true)
+    } finally { unmountMap() }
+    assert.equal(event(f.host, 'contextmenu').defaultPrevented, false)
+    assert.equal(f.host.getAttribute('tabindex'), null)
+})
+check('左键拖动平移,朝向不变,场景随鼠标移动', () => {
+    const f = fixture()
+    const viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f)
+    try {
+        const canvas = f.host.children[0]
+        const camera = f.camera()
+        const beforeRotation = camera.quaternion.clone()
+        const beforePosition = camera.position.clone()
+        const point = camera.position.clone().set(0, 0, 0)
+        const beforeScreen = point.clone().project(camera)
+        event(canvas, 'pointerdown', { button: 0, pointerId: 1, clientX: 100, clientY: 100 })
+        event(canvas, 'pointermove', { pointerId: 1, clientX: 160, clientY: 130 })
+        const afterScreen = point.clone().project(camera)
+        assert.ok(camera.quaternion.angleTo(beforeRotation) < 1e-7, '左键不能改变相机朝向')
+        assert.ok(camera.position.distanceTo(beforePosition) > 0, '左键必须平移')
+        assert.ok(Math.abs((afterScreen.x - beforeScreen.x) * f.host.clientWidth / 2 - 60) < 0.01)
+        assert.ok(Math.abs(-(afterScreen.y - beforeScreen.y) * f.host.clientHeight / 2 - 30) < 0.01)
+    } finally { viewer.destroy() }
+})
+check('右键绕中心旋转而不缩放,松手后停止', () => {
+    const f = fixture()
+    const viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f)
+    try {
+        const canvas = f.host.children[0]
+        const camera = f.camera()
+        const distance = camera.position.length()
+        const rotation = camera.quaternion.clone()
+        event(canvas, 'pointerdown', { button: 2, pointerId: 2, clientX: 100, clientY: 100 })
+        event(canvas, 'pointermove', { pointerId: 2, clientX: 160, clientY: 140 })
+        assert.ok(camera.quaternion.angleTo(rotation) > 0.01)
+        assert.ok(Math.abs(camera.position.length() - distance) < 1e-7, '旋转不能改变半径')
+        event(canvas, 'pointerup', { pointerId: 2 })
+        const stopped = camera.position.clone()
+        event(canvas, 'pointermove', { pointerId: 2, clientX: 200, clientY: 200 })
+        assert.ok(camera.position.equals(stopped))
+    } finally { viewer.destroy() }
+})
+check('滚轮向上拉近、向下拉远,缩放不改变朝向', () => {
+    const f = fixture()
+    const viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f)
+    try {
+        const canvas = f.host.children[0]
+        const camera = f.camera()
+        const distance = camera.position.length()
+        const rotation = camera.quaternion.clone()
+        event(canvas, 'wheel', { deltaY: -100 })
+        assert.ok(camera.position.length() < distance)
+        assert.ok(camera.quaternion.angleTo(rotation) < 1e-7)
+        const near = camera.position.length()
+        event(canvas, 'wheel', { deltaY: 100 })
+        assert.ok(camera.position.length() > near)
+    } finally { viewer.destroy() }
+})
+check('非当前指针和取消后的移动不能继续拖拽', () => {
+    const f = fixture()
+    const viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f)
+    try {
+        const canvas = f.host.children[0]
+        event(canvas, 'pointerdown', { button: 2, pointerId: 4, clientX: 100, clientY: 100 })
+        const initial = f.camera().position.clone()
+        event(canvas, 'pointermove', { pointerId: 5, clientX: 150, clientY: 150 })
+        assert.ok(f.camera().position.equals(initial))
+        event(canvas, 'pointercancel', { pointerId: 4 })
+        event(canvas, 'pointermove', { pointerId: 4, clientX: 180, clientY: 180 })
+        assert.ok(f.camera().position.equals(initial))
+    } finally { viewer.destroy() }
+})
+check('聚焦仅影响场景,输入框和场景外保留默认菜单,焦点离开可退出', () => {
+    const f = fixture()
+    f.host.setAttribute('tabindex', '3')
+    const detach = attachSceneFocus(f.host)
+    try {
+        event(f.host, 'pointerdown', { button: 0 })
+        assert.equal(f.host.getAttribute('data-scene-focused'), 'true')
+        const input = new Element(f.doc)
+        input.closest = () => input
+        const menu = new Event('contextmenu', { cancelable: true })
+        Object.defineProperty(menu, 'target', { value: input })
+        f.host.dispatchEvent(menu)
+        assert.equal(menu.defaultPrevented, false)
+        const outside = new Element(f.doc)
+        assert.equal(event(outside, 'contextmenu').defaultPrevented, false)
+        event(f.host, 'focusout', { relatedTarget: outside })
+        assert.equal(f.host.getAttribute('data-scene-focused'), null)
+    } finally { detach() }
+    assert.equal(f.host.getAttribute('tabindex'), '3')
+})
+check('面板尺寸变化重设画布,相机比例正确,销毁后停止观察和渲染', () => {
+    const original = globalThis.ResizeObserver
+    let callback
+    let disconnected = false
+    globalThis.ResizeObserver = class {
+        constructor(fn) { callback = fn }
+        observe() {}
+        disconnect() { disconnected = true }
+    }
+    const f = fixture()
+    let renders = 0
+    const factory = f.rendererFactory
+    f.rendererFactory = (options) => {
+        const renderer = factory(options)
+        const render = renderer.render
+        renderer.render = (...args) => { renders += 1; render(...args) }
+        return renderer
+    }
+    let viewer
+    try {
+        viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f)
+        f.host.clientWidth = 1000
+        f.host.clientHeight = 400
+        callback()
+        assert.equal(f.camera().aspect, 2.5)
+        viewer.destroy()
+        assert.equal(disconnected, true)
+        const previous = renders
+        callback()
+        assert.equal(renders, previous)
+    } finally {
+        viewer?.destroy()
+        if (original === undefined) delete globalThis.ResizeObserver
+        else globalThis.ResizeObserver = original
+    }
+})
+console.log(`T23 visual/input: ${passed} passed, ${failures} failed`)
+if (failures) process.exitCode = 1

+ 13 - 0
.scratch/cockpit-shell-panel-layout/verify/t50-53-local-enhancement.mjs

@@ -0,0 +1,13 @@
+import { spawnSync } from 'node:child_process'
+const files = [
+    't50-pipe-detail.mjs', 't51-neighborhood.mjs', 't52-device-source.mjs',
+    't52-device-plan.mjs', 't52-device-pick.mjs', 't53-manhole.mjs',
+    't23-threejs-enhancement.mjs', 't23-visual-input.mjs', 't23-click-flow.mjs',
+    't23-sfc-compile.mjs', 't23-popup-placement.mjs', 't22-pipeline-rendering.mjs',
+]
+for (const file of files) {
+    const result = spawnSync(process.execPath, [new URL(file, import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')], { stdio: 'inherit' })
+    if (result.error) throw result.error
+    if (result.status !== 0) process.exit(result.status || 1)
+}
+console.log(`Local enhancement regression scripts: ${files.length}/${files.length}`)

+ 108 - 0
.scratch/cockpit-shell-panel-layout/verify/t50-pipe-detail.mjs

@@ -0,0 +1,108 @@
+import assert from 'node:assert/strict'
+import * as THREE from 'three'
+import { planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnhancement.js'
+import { mountPipeEnhancement } from '../../../src/modules/cockpit/services/threePipeEnhancement.js'
+
+const source = { code: 'hollow-pipe', industry: 'WATER_SUPPLY', positions: [[110, 28], [110.001, 28]] }
+const original = JSON.stringify(source)
+const plan = planPipeEnhancement({ selected: { kind: 'pipe', code: source.code }, visiblePipes: [source] })
+class Canvas extends EventTarget {}
+const host = { clientWidth: 1000, clientHeight: 600,
+    ownerDocument: { createElement: () => new Canvas() }, children: [],
+    appendChild(node) { node.parentNode = this; this.children.push(node) },
+    removeChild(node) { this.children.splice(this.children.indexOf(node), 1); node.parentNode = null } }
+let scene
+const viewer = mountPipeEnhancement(host, plan, { rendererFactory: ({ canvas }) => ({
+    domElement: canvas, setPixelRatio() {}, setSize() {}, render(value) { scene = value; scene.updateMatrixWorld(true) }, dispose() {},
+}) })
+const root = scene.children.find((n) => n.name === `pipe-enhancement:${source.code}`)
+const meshes = []
+root.traverse((node) => { if (node.isMesh) meshes.push(node) })
+const bounds = new THREE.Box3().setFromObject(root)
+const ray = new THREE.Raycaster(new THREE.Vector3(bounds.min.x - 1, 0, 0), new THREE.Vector3(1, 0, 0))
+let failures = 0
+let passes = 0
+function check(name, action) {
+    try { action(); passes += 1; console.log(`PASS ${name}`) }
+    catch (error) { failures += 1; console.error(`FAIL ${name}: ${error.message}`) }
+}
+check('轴向穿过中空管腔,不被端盖封死', () => {
+    assert.equal(ray.intersectObject(root, true).length, 0)
+    ray.set(new THREE.Vector3(bounds.getCenter(new THREE.Vector3()).x, 2, 0), new THREE.Vector3(0, -1, 0))
+    assert.ok(ray.intersectObject(root, true).length > 0, '管壁须有实体表面')
+})
+check('端部有法兰与一圈螺栓,沿管轴附着', () => {
+    const flanges = meshes.filter((m) => m.name === 'pipe-flange')
+    const bolts = meshes.filter((m) => m.name === 'pipe-bolt')
+    assert.equal(flanges.length, 2)
+    assert.equal(bolts.length, 16)
+    for (const flange of flanges) {
+        const p = flange.getWorldPosition(new THREE.Vector3())
+        assert.ok(Math.abs(p.y) < 1e-7 && Math.abs(p.z) < 1e-7)
+    }
+})
+check('有分节接缝,没有独立定位环', () => {
+    assert.ok(meshes.some((m) => m.name === 'pipe-seam'))
+    assert.ok(!meshes.some((m) => m.geometry.type === 'TorusGeometry'), '旧中心定位环必须移除')
+})
+check('管体保持供水行业色,不改源路径与示意说明', () => {
+    const body = meshes.find((m) => m.name === 'pipe-wall')
+    assert.ok(body)
+    assert.equal(body.material.color.getHexString(), '168bff')
+    assert.equal(JSON.stringify(source), original)
+    assert.match(plan.disclaimer, /不代表工程竣工模型/)
+})
+function trackResources(model) {
+    const resources = new Map()
+    model.traverse((node) => {
+        for (const resource of [node.geometry, ...[].concat(node.material || [])].filter(Boolean)) {
+            if (resources.has(resource)) continue
+            resources.set(resource, 0)
+            resource.addEventListener('dispose', () => resources.set(resource, resources.get(resource) + 1))
+        }
+    })
+    return () => {
+        assert.ok(resources.size > 0)
+        for (const count of resources.values()) assert.ok(count > 0, '每个几何与材质都须释放')
+    }
+}
+const assertInitialReleased = trackResources(root)
+viewer.sync({ ...plan, object: { ...plan.object, code: 'replacement' } })
+check('换选移除旧模型并释放全部几何与材质', () => {
+    assert.equal(root.parent, null)
+    assertInitialReleased()
+})
+check('短管段自适应居中,仍保留可辨的管体长宽比', () => {
+    const tiny = { ...plan, object: { ...plan.object, path: [[110, 28], [110.00001, 28]], position: [110, 28] } }
+    viewer.sync(tiny)
+    const next = scene.children.find((n) => n.name === `pipe-enhancement:${source.code}`)
+    const box = new THREE.Box3().setFromObject(next)
+    const size = box.getSize(new THREE.Vector3())
+    assert.ok(size.x / size.y > 4, '短路径也应读成管段而不是厚圆饼')
+    assert.ok(box.getCenter(new THREE.Vector3()).length() < 1e-6)
+})
+check('纵向管段法兰随路径定向,轴向管腔不封死', () => {
+    viewer.sync({ ...plan, object: { ...plan.object, path: [[110, 28], [110, 28.001]] } })
+    const next = scene.children.find((n) => n.name === `pipe-enhancement:${source.code}`)
+    const box = new THREE.Box3().setFromObject(next)
+    ray.set(new THREE.Vector3(0, 0, box.min.z - 1), new THREE.Vector3(0, 0, 1))
+    assert.equal(ray.intersectObject(next, true).length, 0)
+})
+check('折线共享拐角不堆叠法兰,仅真实两端有法兰', () => {
+    viewer.sync({ ...plan, object: { ...plan.object, path: [[110, 28], [110.001, 28], [110.001, 28.001]] } })
+    const next = scene.children.find((n) => n.name === `pipe-enhancement:${source.code}`)
+    const flanges = []
+    next.traverse((n) => { if (n.name === 'pipe-flange') flanges.push(n) })
+    assert.equal(flanges.length, 2, '折点不能额外生成一对法兰')
+})
+check('关闭释放当前模型全部几何材质、移除画布并可重复销毁', () => {
+    const currentRoot = scene.children.find((n) => n.name === `pipe-enhancement:${source.code}`)
+    const assertCurrentReleased = trackResources(currentRoot)
+    viewer.destroy()
+    assertCurrentReleased()
+    viewer.destroy()
+    assert.equal(host.children.length, 0)
+    assert.equal(viewer.getState().status, 'destroyed')
+})
+console.log(`T50 pipe detail: ${passes} passed, ${failures} failed`)
+if (failures) process.exitCode = 1

+ 58 - 0
.scratch/cockpit-shell-panel-layout/verify/t51-neighborhood.mjs

@@ -0,0 +1,58 @@
+import assert from 'node:assert/strict'
+import { planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnhancement.js'
+import { mountPipeEnhancement } from '../../../src/modules/cockpit/services/threePipeEnhancement.js'
+import { createPinia, setActivePinia } from 'pinia'
+import { usePipeEnhancementStore } from '../../../src/modules/cockpit/stores/pipeEnhancementStore.js'
+const ll = (x, y) => [110 + x / (111320 * Math.cos(28 * Math.PI / 180)), 28 + y / 111320]
+const main = { code: 'main', industry: 'WATER_SUPPLY', networkId: 'true-id', sourceId: 'source-id', positions: [ll(-100, 0), ll(100, 0)] }
+const cross = { code: 'cross', industry: 'DRAINAGE', networkLevel: 1, positions: [ll(0, -200), ll(0, 200)] }
+const nearEnd = { code: 'end', industry: 'GAS', positions: [ll(120, -10), ll(120, 10)] }
+const far = { code: 'far', industry: 'GAS', positions: [ll(-100, 130), ll(100, 130)] }
+const frozen = JSON.stringify([main, cross, nearEnd, far])
+const input = { selected: { kind: 'pipe', code: 'main' }, visiblePipes: [main, cross, nearEnd, far] }
+const plan = planPipeEnhancement(input)
+assert.equal(plan.radiusM, 50)
+assert.equal(plan.objects.length, 3)
+assert.equal(plan.objects[0].code, 'main')
+assert.equal(plan.objects[0].path.length, 2)
+assert.equal(plan.objects[0].networkId, 'true-id')
+assert.equal(plan.objects[0].sourceId, 'source-id')
+const clipped = plan.objects.find((p) => p.code === 'cross')
+assert.ok(clipped.clipped)
+assert.ok(Math.abs((clipped.path[0][1] - 28) * 111320 + 50) < 0.01)
+assert.ok(Math.abs((clipped.path.at(-1)[1] - 28) * 111320 - 50) < 0.01)
+assert.ok(plan.objects.some((p) => p.code === 'end'), '沿整条管段取邻域,而不是中点圆')
+assert.equal(JSON.stringify([main, cross, nearEnd, far]), frozen)
+console.log('PASS 默认50米整条路径邻域、穿越裁切、真实ID与源不变')
+const small = planPipeEnhancement({ ...input, radiusM: 25 })
+assert.equal(small.radiusM, 25)
+assert.ok(Math.abs((small.objects.find((p) => p.code === 'cross').path[0][1] - 28) * 111320 + 25) < 0.01)
+assert.equal(planPipeEnhancement({ ...input, radiusM: -1 }).radiusM, 50)
+const filtered = planPipeEnhancement({ ...input, visiblePipes: [main] })
+assert.equal(filtered.objects.length, 1)
+assert.equal(planPipeEnhancement({ ...input, visiblePipes: [] }).visible, false)
+console.log('PASS 范围切换、非法范围回默认与筛选移除')
+const reenter = { code: 'reenter', industry: 'GAS', positions: [ll(-60, 0), ll(-60, 100), ll(60, 100), ll(60, 0)] }
+const many = planPipeEnhancement({ ...input, visiblePipes: [main, reenter] })
+assert.equal(many.objects.filter((p) => p.code === 'reenter').length, 2, '离开再进入必须拆两条,不跨空白连接')
+console.log('PASS 多段裁切不跨越邻域外空白')
+const canvas = new EventTarget()
+const host = { clientWidth: 900, clientHeight: 500, appendChild(node) { node.parentNode = this }, removeChild(node) { node.parentNode = null } }
+let scene
+const viewer = mountPipeEnhancement(host, plan, { rendererFactory: () => ({ domElement: canvas, setSize() {}, render(s) { scene = s }, dispose() {} }) })
+const root = scene.children.find((n) => n.name === 'pipe-enhancement:main')
+assert.equal(root.children.length, 3)
+assert.ok(root.children.find((n) => n.userData.code === 'cross').position.y < 0, '雨水虚拟层位于供水以下')
+assert.ok(root.children.find((n) => n.userData.code === 'end').position.y > 0, '燃气虚拟层位于供水以上')
+viewer.destroy()
+setActivePinia(createPinia())
+const store = usePipeEnhancementStore()
+store.select('pipe', 'main')
+store.setRadius(100)
+assert.equal(store.radiusM, 100)
+store.setRadius('25')
+assert.equal(store.radiusM, 25)
+store.clear()
+assert.equal(store.radiusM, 50)
+assert.equal(store.selected, null)
+console.log('PASS 邻域真实渲染对象、虚拟深度层次及Pinia范围切换/清理')

+ 47 - 0
.scratch/cockpit-shell-panel-layout/verify/t52-device-pick.mjs

@@ -0,0 +1,47 @@
+import assert from 'node:assert/strict'
+import * as THREE from 'three'
+import { planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnhancement.js'
+import { planEnhancementDevices } from '../../../src/modules/cockpit/domain/enhancementDevices.js'
+import { mountPipeEnhancement } from '../../../src/modules/cockpit/services/threePipeEnhancement.js'
+const plan = planPipeEnhancement({ selected: { kind: 'pipe', code: 'P' }, visiblePipes: [
+    { code: 'P', industry: 'WATER_SUPPLY', positions: [[110, 28], [110.001, 28]] },
+] })
+Object.assign(plan, planEnhancementDevices(plan, [{ code: 'D', name: '真实供水设备', industry: 'WATER_SUPPLY', pointCode: 'MP', longitude: 110.0005, latitude: 28, coordinate: { renderable: true } }]))
+const canvas = new EventTarget()
+canvas.getBoundingClientRect = () => ({ left: 0, top: 0, width: 900, height: 500 })
+let scene, camera, picked
+const host = { clientWidth: 900, clientHeight: 500, appendChild(n) { n.parentNode = this }, removeChild(n) { n.parentNode = null } }
+const viewer = mountPipeEnhancement(host, plan, {
+    onDevicePick: (d) => { picked = d }, rendererFactory: () => ({ domElement: canvas, setSize() {}, dispose() {}, render(s, c) { scene = s; camera = c } }),
+})
+scene.updateMatrixWorld(true)
+camera.updateMatrixWorld()
+const device = scene.getObjectByName('device:D')
+assert.ok(device)
+const screen = device.getWorldPosition(new THREE.Vector3()).project(camera)
+const clientX = (screen.x + 1) * 450
+const clientY = (1 - screen.y) * 250
+function pointer(type, x = clientX) {
+    const event = new Event(type, { cancelable: true })
+    Object.assign(event, { button: 0, pointerId: 1, clientX: x, clientY })
+    canvas.dispatchEvent(event)
+}
+pointer('pointerdown'); pointer('pointerup')
+assert.equal(picked.code, 'D')
+assert.equal(picked.pointCode, 'MP')
+assert.equal(picked.placementText, '邻近设备 · 示意安装')
+picked = null
+const root = scene.getObjectByName('pipe-enhancement:P')
+const blocker = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 0.5), new THREE.MeshBasicMaterial())
+blocker.position.copy(root.worldToLocal(camera.position.clone().lerp(device.getWorldPosition(new THREE.Vector3()), 0.5)))
+root.add(blocker)
+root.updateMatrixWorld(true)
+pointer('pointerdown'); pointer('pointerup')
+assert.equal(picked, null, '不能穿过前方不透明表面拾取后方设备')
+root.remove(blocker); blocker.geometry.dispose(); blocker.material.dispose()
+pointer('pointerdown'); pointer('pointermove', clientX + 30); pointer('pointerup', clientX + 30)
+assert.equal(picked, null)
+viewer.destroy()
+pointer('pointerdown'); pointer('pointerup')
+assert.equal(picked, null)
+console.log('PASS 真实Three设备模型射线点选派发业务身份;拖拽和销毁后不误派发')

+ 51 - 0
.scratch/cockpit-shell-panel-layout/verify/t52-device-plan.mjs

@@ -0,0 +1,51 @@
+import assert from 'node:assert/strict'
+import { createPinia, setActivePinia } from 'pinia'
+import { planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnhancement.js'
+import { planEnhancementDevices } from '../../../src/modules/cockpit/domain/enhancementDevices.js'
+import { useEnhancementDevicesStore } from '../../../src/modules/cockpit/stores/enhancementDevicesStore.js'
+const pipe = { code: 'P', networkId: 'real-id', industry: 'WATER_SUPPLY', positions: [[110, 28], [110.001, 28]] }
+const plan = planPipeEnhancement({ selected: { kind: 'pipe', code: 'P' }, visiblePipes: [pipe] })
+const base = { industry: 'WATER_SUPPLY', longitude: 110.0005, latitude: 28.0001, coordinate: { renderable: true } }
+const rows = [{ ...base, code: 'near' }, { ...base, code: 'linked', networkId: 'real-id' },
+    { ...base, code: 'wrong-id', networkId: 'P' }, { ...base, code: 'no-position', longitude: null },
+    { ...base, code: 'gas', industry: 'GAS' }, { ...base, code: 'denied', coordinate: { renderable: false } }]
+const original = JSON.stringify(rows)
+const result = planEnhancementDevices(plan, rows)
+assert.equal(result.devices.find((d) => d.code === 'linked').placement, 'linked')
+assert.equal(result.devices.find((d) => d.code === 'wrong-id').placement, 'nearby')
+assert.equal(result.devices.find((d) => d.code === 'near').placementText, '邻近设备 · 示意安装')
+assert.equal(result.devices.find((d) => d.code === 'gas').placement, 'standalone')
+assert.equal(result.deviceList.find((d) => d.code === 'no-position').placement, 'list-only')
+assert.ok(!result.devices.some((d) => d.code === 'denied'))
+assert.equal(new Set(result.devices.filter((d) => d.pipeCode === 'P').map((d) => d.slot)).size, 3)
+assert.equal(JSON.stringify(rows), original)
+setActivePinia(createPinia())
+const store = useEnhancementDevicesStore()
+let finishA
+const a = store.open({ code: 'A' }, { fetchDetail: () => new Promise((resolve) => { finishA = resolve }) })
+await store.open({ code: 'B' }, { fetchDetail: async () => ({ state: 'success', data: { deviceCode: 'B', sections: {} } }) })
+finishA({ state: 'success', data: { deviceCode: 'A', sections: {} } })
+await a
+assert.equal(store.aggregate.data.deviceCode, 'B')
+assert.equal(store.selected.code, 'B')
+store.clear()
+assert.equal(store.aggregate, null)
+assert.equal(store.selected, null)
+let finishOutside
+const pending = store.open({ code: 'outside' }, { fetchDetail: () => new Promise((resolve) => { finishOutside = resolve }) })
+store.reconcile([])
+finishOutside({ state: 'success', data: { deviceCode: 'outside' } })
+await pending
+assert.equal(store.selected, null)
+assert.equal(store.aggregate, null)
+store.invalidate()
+assert.equal(store.source.loading, true)
+assert.deepEqual(store.source.rows, [])
+assert.equal(planEnhancementDevices(plan, rows, { mode: 'infrastructure' }).devices.length, 0)
+const alertRows = [{ ...base, code: 'ok', status: 'NORMAL' }, { ...base, code: 'alarm', status: 'ALARM' }]
+const warnings = planEnhancementDevices(plan, alertRows, { mode: 'warningResponse', abnormalDevices: [{ code: 'alarm', status: 'ALARM' }] })
+assert.deepEqual(warnings.devices.map((d) => d.code), ['alarm'])
+const linkedManhole = planEnhancementDevices(plan, [{ ...base, industry: 'MANHOLE', code: 'MH-device', networkId: 'real-id', longitude: null }])
+assert.equal(linkedManhole.devices[0].placement, 'linked')
+console.log('PASS 范围移除取消详情/在途响应、刷新失效、模式过滤及真实跨行业关联')
+console.log('PASS 真实ID关联优先、邻近示意、无位置清单、行业分离、不改源及内部详情旧响应隔离')

+ 32 - 0
.scratch/cockpit-shell-panel-layout/verify/t52-device-source.mjs

@@ -0,0 +1,32 @@
+import assert from 'node:assert/strict'
+import { loadEnhancementDevices } from '../../../src/modules/cockpit/services/enhancementDevices.js'
+const points = [
+    { kind: 'monitoring-point', code: 'A', industry: 'WATER_SUPPLY', deviceCodes: ['D1'] },
+    { kind: 'monitoring-point', code: 'B', industry: 'GAS', deviceCodes: ['D2'] },
+    { kind: 'unassociated-equipment', code: 'D3', industry: 'GAS', longitude: 110, latitude: 28, coordinate: { renderable: true } },
+]
+const calls = []
+const result = await loadEnhancementDevices({ objects: points, phase: 'complete', truncated: false }, {
+    fetchPoint: async (code) => { calls.push(code); return { state: 'success', data: { code,
+        devices: [{ code: code === 'A' ? 'D1' : 'D2', longitude: 110, latitude: 28, coordinate: { renderable: true } }] } } },
+})
+assert.deepEqual(calls.sort(), ['A', 'B'])
+assert.equal(result.rows.length, 3)
+assert.equal(result.rows.find((d) => d.code === 'D2').industry, 'GAS')
+assert.equal(result.rows.find((d) => d.code === 'D1').pointCode, 'A')
+assert.equal(result.partial, false)
+const partial = await loadEnhancementDevices({ objects: points, phase: 'loading' }, {
+    fetchPoint: async () => ({ state: 'failed', data: null }),
+})
+assert.equal(partial.partial, true)
+assert.equal(partial.failedPoints, 2)
+assert.equal(partial.rows.length, 1)
+const extra = await loadEnhancementDevices({ objects: [{ kind: 'monitoring-point', code: 'A', deviceCodes: [] }], phase: 'complete' }, {
+    fetchPoint: async () => ({ state: 'success', data: { code: 'A', devices: [{ code: 'unexpected' }] } }),
+})
+assert.equal(extra.extraDevices, 1)
+assert.equal(extra.partial, true)
+const clipped = await loadEnhancementDevices({ objects: [], total: 10, phase: 'complete' })
+assert.equal(clipped.partial, true)
+console.log('PASS 缺摘要清单、详情集合不一致与缓存截断均不冒称完整覆盖')
+console.log('PASS 读取所有当前快照点位设备和独立设备,保留父点与行业;失败/未完成覆盖不冒称完整')

+ 42 - 0
.scratch/cockpit-shell-panel-layout/verify/t53-manhole.mjs

@@ -0,0 +1,42 @@
+import assert from 'node:assert/strict'
+import * as THREE from 'three'
+import { planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnhancement.js'
+import { mountPipeEnhancement } from '../../../src/modules/cockpit/services/threePipeEnhancement.js'
+const manhole = { code: 'MH-1', sourceId: 'source-manhole', networkId: 'unknown-ref', industry: 'MANHOLE', kind: 'other', position: [110, 28], name: '真实窨井' }
+const nearby = { code: 'P', industry: 'DRAINAGE', networkLevel: 0, positions: [[109.9999, 28], [110.0001, 28]] }
+const plan = planPipeEnhancement({ selected: { kind: 'component', code: 'MH-1' }, visiblePipes: [nearby], visibleComponents: [manhole] })
+assert.equal(plan.object.renderKind, 'manhole')
+assert.equal(plan.object.title, '真实窨井')
+assert.match(plan.connectionNote, /接管关系未提供/)
+assert.equal(plan.objects.length, 2)
+assert.equal(plan.object.stateText, '状态未提供')
+let scene
+const canvas = new EventTarget()
+const host = { clientWidth: 900, clientHeight: 500, appendChild(n) { n.parentNode = this }, removeChild(n) { n.parentNode = null } }
+const viewer = mountPipeEnhancement(host, plan, { rendererFactory: () => ({ domElement: canvas, setSize() {}, dispose() {}, render(s) { scene = s } }) })
+assert.ok(scene.getObjectByName('manhole-shaft'))
+assert.ok(scene.getObjectByName('manhole-cover'))
+assert.ok(!scene.getObjectByName('invented-connection'))
+assert.ok(scene.getObjectByName('pipe-enhancement:P'))
+viewer.destroy()
+const isolated = planPipeEnhancement({ selected: { kind: 'component', code: 'MH-1' }, visibleComponents: [manhole] })
+for (const position of [[110.0002, 28], [110, 28]]) {
+    isolated.devices = [{ code: 'standalone', industry: 'WATER_SUPPLY', colorKey: 'WATER_SUPPLY', depthM: 4, slot: 0, position }]
+    const next = mountPipeEnhancement(host, isolated, { rendererFactory: () => ({ domElement: canvas, setSize() {}, dispose() {}, render(s) { scene = s } }) })
+    const device = scene.getObjectByName('device:standalone')
+    assert.ok(device.position.length() < 6, '井边独立设备须纳入相机可见范围')
+    next.destroy()
+}
+console.log('PASS 无管线源时独立设备坐标及深度参与取景,设备模型不会落到远裁面外')
+const deep = planPipeEnhancement({ selected: { kind: 'pipe', code: 'deep' }, visiblePipes: [
+    { code: 'deep', industry: 'DRAINAGE', networkLevel: 1, positions: [[110, 28], [110.00001, 28]] },
+] })
+deep.devices = [{ code: 'shallow', industry: 'GAS', colorKey: 'GAS', depthM: 1.5, slot: 0, position: [110, 28] }]
+let view
+const deepViewer = mountPipeEnhancement(host, deep, { rendererFactory: () => ({ domElement: canvas, setSize() {}, dispose() {}, render(s, camera) { scene = s; view = camera } }) })
+scene.updateMatrixWorld(true); view.updateMatrixWorld()
+const projected = scene.getObjectByName('device:shallow').getWorldPosition(new THREE.Vector3()).project(view)
+assert.ok(Math.abs(projected.x) < 1 && Math.abs(projected.y) < 1 && projected.z > -1 && projected.z < 1, '浅层设备必须在初始视锥内')
+deepViewer.destroy()
+console.log('PASS 深层雨水管与浅层独立燃气设备三维居中,设备初始投影在视锥内')
+console.log('PASS 窨井身份计划 → 剖切井筒和井盖+邻近管道,不造接管/状态')

+ 12 - 0
CONTEXT.md

@@ -32,6 +32,18 @@ _Avoid_: 监测点、把设施点并入监测点统计、把聚合 KPI 当设施
 依真实管段路径或真实构件类型与位置呈现的体积表达;标准构件外形、无来源的尺寸与安装朝向均为示意,不等于工程竣工模型。
 _Avoid_: 真实安装模型、用示意构件推断实际连接拓扑
 
+**邻域管网**:
+围绕选中管段或窨井、在指定空间范围内呈现的地下管道与设施集合;空间邻近不表示管道真实连接或接入该窨井。
+_Avoid_: 连通管网(无连接依据时)、井内接管(仅空间邻近时)
+
+**设备示意挂载**:
+将真实设备按所属行业放置在管道上的固定示意位置;有明确设施关联时按关联展示,没有关联时允许对合法坐标的邻近同业设备做示意摆放,并明确区别于实际安装位置。
+_Avoid_: 真实安装位置(无安装资料时)、管道所属设备(仅邻近时)、虚构设备
+
+**窨井剖切查看**:
+以井盖与剖切井筒示意模型查看窨井附近地下管道、监测设备及已有状态;未提供接管关系时仅说明空间邻近,不强行将附近管道接入井筒。
+_Avoid_: 井下实测结构(无资料时)、从附近管道推断接管关系
+
 **显示粗度**:
 为使管道示意模型清晰可辨而放大的可视横截面尺寸,与源数据管径、推算管径分别保留;不代表工程尺寸,也不改变管段路径。
 _Avoid_: 真实管径、用显示尺寸回写业务管径

+ 76 - 5
src/modules/cockpit/CockpitShell.vue

@@ -347,9 +347,20 @@
                     :plan="modeLayerPlan"
                     @pipeline-pick="selectPipeEnhancement"
                 />
-                <PipeEnhancement :plan="pipeEnhancementPlan" @close="closePipeEnhancement" />
             </section>
 
+            <PipeEnhancement
+                class="cockpit-shell__enhancement"
+                :plan="pipeEnhancementPlan"
+                :device-state="enhancementDevicesStore"
+                @device-pick="enhancementDevicesStore.open"
+                @devices-reload="enhancementDevicesStore.load(mapStore.summary, { force: true })"
+                @device-close="enhancementDevicesStore.closeDetail"
+                @device-retry="enhancementDevicesStore.open(enhancementDevicesStore.selected)"
+                @radius-change="pipeEnhancementStore.setRadius"
+                @close="closePipeEnhancement"
+            />
+
             <aside class="cockpit-shell__column is-left" aria-label="左栏">
                 <CockpitPanel
                     v-for="panelId in columns.left"
@@ -516,7 +527,10 @@ import { STARTUP_ACTION, planCockpitStartup } from './domain/startupPlan.js'
 import { selectMonitoringPoint } from './pointSelection.js'
 // 票 46:模式图层计划(唯一裁决点)+ 模式切换的选择态语义判定。
 import { planModeLayers, planModeSelectionTransition } from './domain/modeLayerPlan.js'
-import { enhancementSelection, PIPE_ENHANCEMENT_KIND, planPipeEnhancement } from './domain/pipeEnhancement.js'
+import { PIPE_ENHANCEMENT_KIND, planPipeEnhancement } from './domain/pipeEnhancement.js'
+import { usePipeEnhancementStore } from './stores/pipeEnhancementStore.js'
+import { useEnhancementDevicesStore } from './stores/enhancementDevicesStore.js'
+import { planEnhancementDevices } from './domain/enhancementDevices.js'
 import { useDetailStore } from './stores/detailStore.js'
 import { useDeviceLayerStore } from './stores/deviceLayerStore.js'
 import { useInfrastructureStore } from './stores/infrastructureStore.js'
@@ -858,6 +872,14 @@ const modeAbnormalDevices = computed(() => {
     return data && Array.isArray(data.abnormalEquipment) ? data.abnormalEquipment : null
 })
 
+// ---- 进入引导与视角预设(票 30)——声明上移至此 ----
+// 原位置在下方「进入引导与视角预设」分节;因管线增强 watch(票 24/47)在 setup 期
+// 急切读取 `modeLayerPlan`(其 computed 引用 `introVisible`),声明晚于该 watch 会触发
+// TDZ(ReferenceError: Cannot access 'introVisible' before initialization,整壳挂掉)。
+// 声明只依赖 `getIntroConfig()`(顶部 import),上移安全;分节的其余内容仍在原处。
+const intro = ref(getIntroConfig())
+const introVisible = ref(intro.value.enabled)
+
 /**
  * 票 46:**唯一**的模式图层计划(规格 18:引擎和组件只消费计划,不做第二次过滤)。
  *
@@ -867,6 +889,9 @@ const modeAbnormalDevices = computed(() => {
  *   · `selection.visible` → 详情浮层的**暂停/恢复**(目标模式不显示所选项时隐藏但保留选择);
  *   · `degradedNotes` → 「设施/管线/异常设备数据未就绪」的如实说明。
  */
+const pipeEnhancementStore = usePipeEnhancementStore()
+const enhancementDevicesStore = useEnhancementDevicesStore()
+const { selected: pipeEnhancementSelection, radiusM: pipeEnhancementRadius } = storeToRefs(pipeEnhancementStore)
 const modeLayerPlan = computed(() => planModeLayers({
     mode: mode.value,
     objects: mapStore.summary.objects,
@@ -885,7 +910,44 @@ const modeLayerPlan = computed(() => planModeLayers({
     entered: !introVisible.value,
 }))
 
-/** 详情浮层要不要显示(规格 18 的选择态暂停:目标模式不显示所选项 → 暂停但保留选择身份)。 */
+const pipeEnhancementPlan = computed(() => {
+    const plan = planPipeEnhancement({
+        selected: pipeEnhancementSelection.value,
+        radiusM: pipeEnhancementRadius.value,
+        visiblePipes: modeLayerPlan.value.pipelines,
+        visibleComponents: modeLayerPlan.value.facilities,
+    })
+    return { ...plan, ...planEnhancementDevices(plan, enhancementDevicesStore.source.rows, { mode: mode.value, abnormalDevices: modeAbnormalDevices.value }),
+        deviceCoverage: enhancementDevicesStore.source }
+})
+watch(pipeEnhancementSelection, () => enhancementDevicesStore.closeDetail())
+watch(() => pipeEnhancementPlan.value.deviceList, (list) => enhancementDevicesStore.reconcile(list))
+watch(() => mapStore.refreshing, (refreshing) => {
+    if (refreshing) enhancementDevicesStore.invalidate()
+    else if (pipeEnhancementPlan.value.visible) void enhancementDevicesStore.load(mapStore.summary)
+}, { flush: 'sync' })
+watch(() => [pipeEnhancementSelection.value, modeLayerPlan.value.pipelines, modeLayerPlan.value.facilities,
+    mapStore.summary.batchUpdatedAt, mapStore.summary.phase, mapStore.summary.objects], () => {
+    if (mapStore.refreshing) return
+    if (pipeEnhancementPlan.value.visible) void enhancementDevicesStore.load(mapStore.summary)
+    else enhancementDevicesStore.clear()
+})
+
+
+function selectPipeEnhancement(hit) {
+    const kind = hit && hit.objectKind === 'component'
+        ? PIPE_ENHANCEMENT_KIND.COMPONENT
+        : hit && hit.objectKind === 'pipe'
+          ? PIPE_ENHANCEMENT_KIND.PIPE
+          : ''
+    if (!kind) return
+    pipeEnhancementStore.select(kind, hit.code)
+}
+
+function closePipeEnhancement() {
+    pipeEnhancementStore.clear()
+    enhancementDevicesStore.clear()
+}
 const selectionVisibleInMode = computed(() => modeLayerPlan.value.selection.visible === true)
 
 /**
@@ -1281,8 +1343,7 @@ const uiDebug = computed(() => {
 
 // ---- 进入引导与视角预设(票 30)----
 // 引导默认开着;配置里 map.intro.enabled=false 时直接进系统(其它票的走查用它自隔离)。
-const intro = ref(getIntroConfig())
-const introVisible = ref(intro.value.enabled)
+// `intro`/`introVisible` 的声明已上移至 `modeLayerPlan` 之前(TDZ,见上移处的说明)。
 const atHomeView = ref(false)
 const homeView = computed(() => getHomeView())
 const homeTitle = computed(() => `回到${homeLabel(homeView.value)}`)
@@ -1776,6 +1837,8 @@ onBeforeUnmount(() => {
     for (const stop of stopDeviceWatches) stop()
     stopDeviceWatches.length = 0
     deviceLayerStore.reset()
+    pipeEnhancementStore.clear()
+    enhancementDevicesStore.clear()
     if (anchorResizeHandler) {
         window.removeEventListener('resize', anchorResizeHandler)
         anchorResizeHandler = null
@@ -2529,6 +2592,14 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
     z-index: var(--z-map);
 }
 
+/* 局部增强允许覆盖一张图,保持外壳交互层高于两侧业务栏。 */
+.cockpit-shell__main > .cockpit-shell__enhancement {
+    width: min(1100px, calc(100% - 32px));
+    height: min(760px, calc(100% - 32px));
+    z-index: calc(var(--z-columns) + 1);
+    pointer-events: auto;
+}
+
 .cockpit-shell__column {
     position: absolute;
     top: 0;

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

@@ -455,6 +455,9 @@ onBeforeUnmount(() => {
     box-shadow: inset 0 1px 0 rgba(1, 171, 234, 0.12), var(--shadow, 0 18px 44px rgba(0, 0, 0, 0.55));
 }
 
+.map-canvas:focus { outline: none; }
+.map-canvas[data-scene-focused] { border-color: var(--cyan, #01abea); }
+
 /* ---------- 临时影像底图:铺满 ---------- */
 .map-canvas__photo {
     position: absolute;

+ 88 - 19
src/modules/cockpit/components/PipeEnhancement.vue

@@ -1,60 +1,117 @@
 <template>
-    <aside v-if="open" class="pipe-enhancement" data-pipe-enhancement role="dialog" aria-label="重点对象局部增强">
+    <aside v-if="open" class="pipe-enhancement" data-pipe-enhancement role="dialog" aria-label="重点对象局部增强" @keydown.esc.stop.prevent="$emit('close')">
         <header class="pipe-enhancement__header">
             <div>
                 <span class="pipe-enhancement__eyebrow">Three.js 局部增强</span>
                 <h2 data-pipe-enhancement-title>{{ plan.object.title }}</h2>
+                <p class="pipe-enhancement__controls">左键拖动平移 · 右键旋转 · 滚轮缩放 · Esc 关闭</p>
             </div>
             <button type="button" class="pipe-enhancement__close" aria-label="关闭局部增强" title="关闭局部增强" data-pipe-enhancement-close @click="$emit('close')">×</button>
         </header>
-        <div ref="viewport" class="pipe-enhancement__viewport" data-pipe-enhancement-viewport></div>
+        <div class="pipe-enhancement__scope" v-if="plan.coverage">
+            <label>附近范围
+                <select aria-label="附近范围" :value="plan.radiusM" @change="$emit('radius-change', Number($event.target.value))">
+                    <option v-for="radius in [25, 50, 100]" :key="radius" :value="radius">{{ radius }} 米</option>
+                </select>
+            </label>
+            <span>管道 {{ plan.coverage.pipeCount }} 条 · 构件 {{ plan.coverage.componentCount }} 个 · 邻近管道按范围裁切</span>
+        </div>
+        <div class="pipe-enhancement__workspace">
+            <div ref="viewport" class="pipe-enhancement__viewport" data-pipe-enhancement-viewport role="region" aria-label="管道局部三维视口"></div>
+            <aside class="pipe-enhancement__devices" aria-label="附近设备与详情">
+                <section v-if="localPipeStates.length" aria-label="附近管道状态">
+                    <h3>附近管道状态</h3>
+                    <p v-for="pipe in localPipeStates" :key="pipe.code">{{ pipe.title }}({{ pipe.code }}):{{ pipe.stateText || '状态未提供' }}</p>
+                </section>
+                <h3>监测设备</h3>
+                <p v-if="plan.deviceCoverage?.loading">设备加载中</p>
+                <p v-else-if="plan.deviceCoverage?.partial">设备覆盖不完整:快照未齐、设备待确认或部分点位读取失败。</p>
+                <button v-if="plan.deviceCoverage?.partial && !plan.deviceCoverage?.loading" type="button" @click="$emit('devices-reload')">重新读取设备清单</button>
+                <p v-if="plan.deviceCoverage?.stalePoints">部分设备为上次保留数据,非实时。</p>
+                <p v-if="!plan.deviceList?.length && !plan.deviceCoverage?.loading">当前可用数据中没有附近设备。</p>
+                <button v-for="device in plan.deviceList || []" :key="device.code" class="pipe-enhancement__device" type="button" @click="$emit('device-pick', device)">
+                    {{ device.name || device.code }}<small>{{ device.placementText }}{{ device.retained ? ' · 过期数据' : '' }}</small>
+                </button>
+                <section v-if="deviceState.selected" data-enhancement-device-detail>
+                    <header><strong>{{ deviceState.selected.name || deviceState.selected.code }}</strong><button type="button" @click="$emit('device-close')">关闭详情</button></header>
+                    <p>{{ deviceState.selected.placementText }}</p>
+                    <p v-if="deviceState.selected.pointCode">所属监测点:{{ deviceState.selected.pointCode }}</p>
+                    <p v-if="deviceState.loadingDetail">设备详情加载中</p>
+                    <template v-else-if="!deviceState.aggregate?.data"><p>设备详情暂不可用</p><button type="button" @click="$emit('device-retry')">重新加载</button></template>
+                    <EquipmentDetailSections v-else :aggregate="deviceState.aggregate" :device-code="deviceState.selected.code" />
+                </section>
+            </aside>
+        </div>
         <dl class="pipe-enhancement__facts">
-            <div><dt>对象</dt><dd>{{ plan.object.sourceKind === 'pipe' ? '重点管段' : '重点构件' }}</dd></div>
+            <div><dt>对象</dt><dd>{{ plan.object.renderKind === 'manhole' ? '窨井剖切' : plan.object.sourceKind === 'pipe' ? '重点管段' : '重点构件' }}</dd></div>
             <div><dt>编码</dt><dd>{{ plan.object.code }}</dd></div>
             <div v-if="plan.object.diameter !== null"><dt>源管径</dt><dd>{{ plan.object.diameter }} mm</dd></div>
+            <div><dt>状态</dt><dd>{{ plan.object.stateText || '状态未提供' }}</dd></div>
             <div v-if="plan.object.material"><dt>材质</dt><dd>{{ plan.object.material }}</dd></div>
         </dl>
-        <p class="pipe-enhancement__note" data-pipe-enhancement-note>{{ plan.disclaimer }}</p>
+        <p class="pipe-enhancement__note" data-pipe-enhancement-note>{{ plan.disclaimer }} · {{ plan.connectionNote }}</p>
     </aside>
 </template>
 
 <script setup>
-import { nextTick, onBeforeUnmount, watch, ref } from 'vue'
+import { computed, nextTick, onBeforeUnmount, watch, ref } from 'vue'
 import { mountPipeEnhancement } from '../services/threePipeEnhancement.js'
+import EquipmentDetailSections from './EquipmentDetailSections.vue'
 
 const props = defineProps({
     plan: { type: Object, required: true },
+    deviceState: { type: Object, default: () => ({ selected: null, aggregate: null, loadingDetail: false }) },
 })
-defineEmits(['close'])
+const emit = defineEmits(['close', 'radius-change', 'device-pick', 'device-close', 'device-retry', 'devices-reload'])
 const viewport = ref(null)
+const localPipeStates = computed(() => [...new Map((props.plan.objects || [])
+    .filter((object) => object.sourceKind === 'pipe').map((object) => [object.code, object])).values()])
 let viewer = null
+let mountGeneration = 0
 const open = ref(false)
 
-function unmount() {
+function disposeViewer() {
     if (viewer) viewer.destroy()
     viewer = null
 }
 
 async function mount() {
-    unmount()
+    const generation = ++mountGeneration
+    disposeViewer()
     open.value = props.plan?.visible === true
     if (!open.value) return
     await nextTick()
-    if (viewport.value) viewer = mountPipeEnhancement(viewport.value, props.plan)
+    if (generation !== mountGeneration || !open.value || !viewport.value) return
+    viewer = mountPipeEnhancement(viewport.value, props.plan, { onDevicePick: (device) => emit('device-pick', device) })
 }
 
-watch(() => [props.plan?.visible, props.plan?.object?.code], mount, { immediate: true })
-onBeforeUnmount(unmount)
+watch(
+    () => props.plan?.visible === true ? JSON.stringify([props.plan.object, props.plan.radiusM, props.plan.objects, props.plan.devices]) : 'hidden',
+    mount,
+    { immediate: true },
+)
+onBeforeUnmount(() => {
+    mountGeneration += 1
+    disposeViewer()
+})
 </script>
 
 <style scoped>
 .pipe-enhancement {
     position: absolute;
     z-index: 5;
-    right: calc(18 * var(--u, 1px));
-    bottom: calc(18 * var(--u, 1px));
-    width: min(360px, calc(100% - 36px));
-    padding: calc(12 * var(--u, 1px));
+    left: 50%;
+    top: 50%;
+    transform: translate(-50%, -50%);
+    width: min(1100px, calc(100% - 32px));
+    height: min(760px, calc(100% - 32px));
+    display: grid;
+    grid-template-rows: auto auto minmax(0, 1fr) auto auto;
+    gap: 12px;
+    min-height: 0;
+    overflow: hidden;
+    box-sizing: border-box;
+    padding: 16px;
     border: 1px solid rgba(1, 171, 234, .58);
     border-radius: 6px;
     background: rgba(2, 12, 32, .94);
@@ -63,14 +120,26 @@ onBeforeUnmount(unmount)
 }
 .pipe-enhancement__header { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; }
 .pipe-enhancement__eyebrow { color: var(--cyan, #01abea); font-size: 10px; letter-spacing: .08em; }
-.pipe-enhancement h2 { margin: 3px 0 0; font-size: 15px; line-height: 1.35; overflow-wrap: anywhere; }
+.pipe-enhancement h2 { margin: 3px 0 0; font-size: 20px; line-height: 1.35; overflow-wrap: anywhere; }
+.pipe-enhancement__controls { margin: 6px 0 0; color: var(--text-3, #7ba4c3); font-size: 12px; }
 .pipe-enhancement__close { width: 28px; height: 28px; border: 1px solid rgba(1, 171, 234, .46); border-radius: 4px; background: transparent; color: inherit; font-size: 20px; line-height: 1; cursor: pointer; }
 .pipe-enhancement__close:hover, .pipe-enhancement__close:focus-visible { border-color: var(--cyan, #01abea); color: #fff; outline: none; }
-.pipe-enhancement__viewport { height: 190px; margin-top: 10px; overflow: hidden; border: 1px solid rgba(1, 171, 234, .24); background: #031226; }
+.pipe-enhancement__workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 32%); gap: 12px; min-height: 0; }
+.pipe-enhancement__devices { min-height: 0; overflow-y: auto; border-left: 1px solid #174969; padding-left: 12px; font-size: 12px; }
+.pipe-enhancement__devices h3 { margin: 0 0 8px; }
+.pipe-enhancement__devices button { color: #e4f4ff; background: #08213a; border: 1px solid #21618a; padding: 7px; cursor: pointer; }
+.pipe-enhancement__device { display: block; width: 100%; margin-bottom: 6px; text-align: left; }
+.pipe-enhancement__device small { display: block; color: #a7c4d9; margin-top: 4px; }
+.pipe-enhancement__devices section { margin-top: 12px; }
+.pipe-enhancement__devices header { display: flex; justify-content: space-between; gap: 8px; align-items: center; }
+.pipe-enhancement__scope { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; color: #a7c4d9; font-size: 12px; }
+.pipe-enhancement__scope select { margin-left: 8px; padding: 5px 10px; border: 1px solid #168bff; border-radius: 4px; background: #031226; color: #e4f4ff; }
+.pipe-enhancement__viewport { min-height: 0; overflow: hidden; border: 1px solid rgba(1, 171, 234, .24); background: #031226; outline: none; touch-action: none; }
+.pipe-enhancement__viewport[data-scene-focused] { border-color: var(--cyan, #01abea); box-shadow: inset 0 0 0 1px var(--cyan, #01abea); }
 .pipe-enhancement__viewport :deep(canvas) { display: block; width: 100%; height: 100%; }
-.pipe-enhancement__facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px 12px; margin: 10px 0 0; font-size: 11px; }
+.pipe-enhancement__facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 8px 16px; margin: 0; font-size: 14px; }
 .pipe-enhancement__facts div { min-width: 0; }
 .pipe-enhancement__facts dt { color: var(--text-3, #7ba4c3); }
 .pipe-enhancement__facts dd { margin: 2px 0 0; overflow-wrap: anywhere; }
-.pipe-enhancement__note { margin: 10px 0 0; color: #a7c4d9; font-size: 10px; line-height: 1.45; }
+.pipe-enhancement__note { margin: 0; color: #a7c4d9; font-size: 12px; line-height: 1.45; }
 </style>

+ 87 - 0
src/modules/cockpit/domain/enhancementDevices.js

@@ -0,0 +1,87 @@
+import { createNeighborhood } from './pipeNeighborhood.js'
+import { pipeColorKey, pipeVirtualDepthM } from './pipeVisual.js'
+import { isAssociatedDeviceVisible } from './modeLayerPlan.js'
+
+function admitted(row) {
+    return row.coordinate?.renderable === true && Number.isFinite(row.longitude) && Number.isFinite(row.latitude)
+}
+
+/** Nearest centreline position is a display slot only, not a facility relationship. */
+function nearest(point, path) {
+    const factor = Math.cos(point[1] * Math.PI / 180) * 111320
+    let best = null
+    for (let i = 1; i < path.length; i += 1) {
+        const a = [(path[i - 1][0] - point[0]) * factor, (path[i - 1][1] - point[1]) * 111320]
+        const d = [(path[i][0] - path[i - 1][0]) * factor, (path[i][1] - path[i - 1][1]) * 111320]
+        const length = d[0] ** 2 + d[1] ** 2
+        const t = length ? Math.max(0, Math.min(1, -(a[0] * d[0] + a[1] * d[1]) / length)) : 0
+        const distance = Math.hypot(a[0] + t * d[0], a[1] + t * d[1])
+        if (!best || distance < best.distance) best = { distance, position: path[i - 1].map((v, axis) => v + t * (path[i][axis] - v)) }
+    }
+    return best
+}
+
+export function planEnhancementDevices(plan, rows = [], { mode = 'overview', abnormalDevices = null } = {}) {
+    if (!plan.visible) return { devices: [], deviceList: [] }
+    const corridor = createNeighborhood(plan.object.path, plan.radiusM || 50)
+    const pipes = (plan.objects || [plan.object]).filter((o) => o.sourceKind === 'pipe')
+    const devices = []
+    const deviceList = []
+    const used = new Map()
+    const seen = new Set()
+    for (const row of [...rows].sort((a, b) => String(a.code).localeCompare(String(b.code)))) {
+        if (!row.code || seen.has(row.code)) continue
+        const abnormal = Array.isArray(abnormalDevices) ? abnormalDevices.find((d) => d.code === row.code) : null
+        if (!isAssociatedDeviceVisible(mode, abnormal?.status || row.status)
+            || (mode === 'warningResponse' && !abnormal)) continue
+        seen.add(row.code)
+        // Only compare actual source identifiers; never compare a display code to networkId.
+        const linked = row.networkId ? pipes.find((p) => p.networkId && p.networkId === row.networkId) : null
+        const position = [row.longitude, row.latitude]
+        const valid = admitted(row)
+        if (!linked && valid && !corridor.contains(position)) continue
+        if (!valid && !linked) { deviceList.push({ ...row, placement: 'list-only', placementText: '位置未提供或未准入 · 仅清单' }); continue }
+        const candidates = linked ? [linked] : pipes.filter((p) => p.industry === row.industry)
+        let chosen = null
+        for (const pipe of candidates) {
+            const at = valid ? nearest(position, pipe.path) : { distance: 0, position: pipe.path[0] }
+            if (at && (!chosen || at.distance < chosen.distance)) chosen = { ...at, pipe }
+        }
+        const slotKey = chosen ? `${chosen.pipe.code}|${chosen.pipe.piece ?? 0}` : row.code
+        const slot = used.get(slotKey) || 0
+        used.set(slotKey, slot + 1)
+        const colorKey = chosen?.pipe.colorKey || pipeColorKey(row)
+        const device = { ...row, position: chosen?.position || position, colorKey,
+            depthM: chosen?.pipe.depthM ?? pipeVirtualDepthM(colorKey), pipeCode: chosen?.pipe.code || '',
+            pipePiece: chosen?.pipe.piece ?? 0, slot,
+            placement: linked ? 'linked' : chosen ? 'nearby' : 'standalone',
+            placementText: linked ? '关联设备 · 示意安装' : chosen ? '邻近设备 · 示意安装' : '设备原位置 · 无同业管段',
+            retained: row.retained === true }
+        devices.push(device)
+        deviceList.push(device)
+    }
+    const groups = new Map()
+    for (const device of devices.filter((d) => d.pipeCode)) {
+        const key = `${device.pipeCode}|${device.pipePiece}`
+        const list = groups.get(key) || []
+        list.push(device)
+        groups.set(key, list)
+    }
+    for (const list of groups.values()) {
+        const path = pipes.find((p) => p.code === list[0].pipeCode && (p.piece ?? 0) === list[0].pipePiece).path
+        const lengths = path.slice(1).map((p, i) => Math.hypot((p[0] - path[i][0]) * Math.cos(p[1] * Math.PI / 180), p[1] - path[i][1]))
+        const total = lengths.reduce((a, b) => a + b, 0)
+        list.forEach((device, slot) => {
+            let remaining = total * (slot + 1) / (list.length + 1)
+            for (let i = 0; i < lengths.length; i += 1) {
+                if (remaining <= lengths[i] || i === lengths.length - 1) {
+                    const t = lengths[i] ? Math.min(1, remaining / lengths[i]) : 0
+                    device.position = path[i].map((v, axis) => v + (path[i + 1][axis] - v) * t)
+                    break
+                }
+                remaining -= lengths[i]
+            }
+        })
+    }
+    return { devices, deviceList }
+}

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

@@ -85,6 +85,7 @@ export function toUnassociatedEquipment(raw) {
     return {
         kind: MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT,
         code: reader.str('deviceCode'),
+        networkId: raw?.networkId == null ? '' : String(raw.networkId),
         name: reader.str('deviceName'),
         // 未关联设备没有点位状态:留空而不是借用设备的在线态(0/NORMAL 都算伪造)。
         status: '',

+ 52 - 7
src/modules/cockpit/domain/pipeEnhancement.js

@@ -5,6 +5,9 @@
  * Three.js 局部查看窗所需的最小领域数据。计划不创建渲染器,也不猜工程拓扑。
  */
 
+import { pipeColorKey, pipeVirtualDepthM } from './pipeVisual.js'
+import { createNeighborhood, neighborhoodRadius } from './pipeNeighborhood.js'
+
 export const PIPE_ENHANCEMENT_KIND = Object.freeze({
     PIPE: 'pipe',
     COMPONENT: 'component',
@@ -15,20 +18,28 @@ const COMPONENT_LABELS = Object.freeze({
     valve: '阀门',
     branch: '支管管道',
     cross: '交叉构件',
+    manhole: '窨井剖切',
     other: '其它构件',
 })
 
+function validPosition(point) {
+    return Array.isArray(point) && point.length >= 2 && Number.isFinite(point[0]) && Number.isFinite(point[1])
+        && Math.abs(point[0]) <= 180 && Math.abs(point[1]) < 90
+}
+
 function validPipe(item) {
     return item && item.code && Array.isArray(item.positions) && item.positions.length >= 2
+        && item.positions.every(validPosition) && item.coordinateValidation?.renderable !== false
+        && item.positions.some((point) => point[0] !== item.positions[0][0] || point[1] !== item.positions[0][1])
 }
 
 function validComponent(item) {
-    return item && item.code && Array.isArray(item.position) && item.position.length >= 2
+    return item && item.code && validPosition(item.position) && item.coordinateValidation?.renderable !== false
 }
 
 function findVisible(selected, pipes, components) {
-    const kind = selected && selected.kind
-    const list = kind === PIPE_ENHANCEMENT_KIND.COMPONENT ? components : pipes
+    if (!selected || ![PIPE_ENHANCEMENT_KIND.PIPE, PIPE_ENHANCEMENT_KIND.COMPONENT].includes(selected.kind)) return null
+    const list = selected.kind === PIPE_ENHANCEMENT_KIND.COMPONENT ? components : pipes
     return list.find((item) => item && item.code === selected.code) || null
 }
 
@@ -37,8 +48,12 @@ function pipeView(item) {
         sourceKind: PIPE_ENHANCEMENT_KIND.PIPE,
         renderKind: PIPE_ENHANCEMENT_KIND.PIPE,
         code: String(item.code),
+        sourceId: item.sourceId || '',
+        networkId: item.networkId || '',
         title: item.name || `重点管段 ${item.code}`,
         industry: item.industry || '',
+        colorKey: pipeColorKey(item),
+        stateText: item.statusLabel || item.status || '状态未提供',
         material: item.material || '',
         diameter: Number.isFinite(item.diameter) ? item.diameter : null,
         path: item.positions.map((pair) => [Number(pair[0]), Number(pair[1])]),
@@ -47,13 +62,17 @@ function pipeView(item) {
 }
 
 function componentView(item) {
-    const renderKind = COMPONENT_LABELS[item.kind] ? item.kind : 'other'
+    const renderKind = item.industry === 'MANHOLE' ? 'manhole' : COMPONENT_LABELS[item.kind] ? item.kind : 'other'
     return {
         sourceKind: PIPE_ENHANCEMENT_KIND.COMPONENT,
         renderKind,
         code: String(item.code),
+        sourceId: item.sourceId || '',
+        networkId: item.networkId || '',
         title: item.name || COMPONENT_LABELS[renderKind],
         industry: item.industry || '',
+        colorKey: pipeColorKey(item),
+        stateText: item.statusLabel || item.status || '状态未提供',
         material: '',
         diameter: null,
         path: [item.position.map(Number)],
@@ -65,9 +84,9 @@ function componentView(item) {
  * @param {{selected?:object, visiblePipes?:Array, visibleComponents?:Array}} input
  * @returns {{visible:boolean, reason:string, object?:object, disclaimer?:string}}
  */
-export function planPipeEnhancement({ selected = null, visiblePipes = [], visibleComponents = [] } = {}) {
-    if (!selected || !selected.code || !selected.kind) {
-        return { visible: false, reason: 'no-selection' }
+export function planPipeEnhancement({ selected = null, visiblePipes = [], visibleComponents = [], radiusM = 50 } = {}) {
+    if (!selected || !selected.code || ![PIPE_ENHANCEMENT_KIND.PIPE, PIPE_ENHANCEMENT_KIND.COMPONENT].includes(selected.kind)) {
+        return { visible: false, reason: selected && selected.code ? 'invalid-selection' : 'no-selection' }
     }
 
     const item = findVisible(selected, visiblePipes, visibleComponents)
@@ -78,10 +97,36 @@ export function planPipeEnhancement({ selected = null, visiblePipes = [], visibl
         : (validPipe(item) ? pipeView(item) : null)
     if (!object) return { visible: false, reason: 'invalid-geometry' }
 
+    radiusM = neighborhoodRadius(radiusM)
+    const corridor = createNeighborhood(object.path, radiusM)
+    const objects = [{ ...object, primary: true, depthM: pipeVirtualDepthM(object.colorKey) }]
+    for (const pipe of visiblePipes) {
+        if (!validPipe(pipe) || (selected.kind === 'pipe' && pipe.code === selected.code)) continue
+        const view = pipeView(pipe)
+        const pieces = corridor.clip(view.path)
+        pieces.forEach((path, index) => objects.push({ ...view, path, piece: index, primary: false,
+            clipped: pieces.length !== 1 || JSON.stringify(path) !== JSON.stringify(view.path),
+            depthM: pipeVirtualDepthM(view.colorKey) }))
+    }
+    for (const component of visibleComponents) {
+        if (!validComponent(component) || (selected.kind === 'component' && component.code === selected.code)
+            || !corridor.contains(component.position)) continue
+        const view = componentView(component)
+        objects.push({ ...view, primary: false, depthM: pipeVirtualDepthM(view.colorKey) })
+    }
     return {
         visible: true,
         reason: '',
         object,
+        objects,
+        radiusM,
+        connectionNote: object.renderKind === 'manhole'
+            ? object.networkId && objects.some((o) => o.sourceKind === 'pipe' && o.networkId === object.networkId)
+                ? `已提供设施关联;井筒与接管构造为示意,邻近关系不等于实际连接`
+                : '附近管道,接管关系未提供;井筒为剖切示意'
+            : '附近管道不代表实际连通关系',
+        coverage: { available: true, pipeCount: new Set(objects.filter((o) => o.sourceKind === 'pipe').map((o) => o.code)).size,
+            componentCount: objects.filter((o) => o.sourceKind === 'component').length, partial: false },
         disclaimer: '局部示意 · 显示尺寸与构件朝向不代表工程竣工模型',
     }
 }

+ 88 - 0
src/modules/cockpit/domain/pipeNeighborhood.js

@@ -0,0 +1,88 @@
+/** Local metric corridor clipping. Proximity describes display context, never connectivity. */
+export const NEIGHBORHOOD_RADII = Object.freeze([25, 50, 100])
+export const neighborhoodRadius = (value) => NEIGHBORHOOD_RADII.includes(Number(value)) ? Number(value) : 50
+const EPS = 1e-9
+const dot = (a, b) => a[0] * b[0] + a[1] * b[1]
+const sub = (a, b) => [a[0] - b[0], a[1] - b[1]]
+const lerp = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t)
+
+function circleInterval(p, d, center, radius) {
+    const v = sub(p, center)
+    const a = dot(d, d)
+    const c = dot(v, v) - radius * radius
+    if (a < EPS) return c <= EPS ? [[0, 1]] : []
+    const b = 2 * dot(v, d)
+    const disc = b * b - 4 * a * c
+    if (disc < 0) return []
+    const lo = Math.max(0, (-b - Math.sqrt(disc)) / (2 * a))
+    const hi = Math.min(1, (-b + Math.sqrt(disc)) / (2 * a))
+    return hi - lo > EPS ? [[lo, hi]] : []
+}
+
+function slab(value, delta, min, max, range) {
+    if (Math.abs(delta) < EPS) return value >= min - EPS && value <= max + EPS ? range : null
+    const a = (min - value) / delta
+    const b = (max - value) / delta
+    const lo = Math.max(range[0], Math.min(a, b))
+    const hi = Math.min(range[1], Math.max(a, b))
+    return hi - lo > EPS ? [lo, hi] : null
+}
+
+function corridorIntervals(p, q, a, b, radius) {
+    const d = sub(q, p)
+    const ab = sub(b, a)
+    const length = Math.hypot(...ab)
+    const intervals = [...circleInterval(p, d, a, radius), ...circleInterval(p, d, b, radius)]
+    if (length < EPS) return intervals
+    const along = ab.map((v) => v / length)
+    const normal = [-along[1], along[0]]
+    const local = sub(p, a)
+    let range = slab(dot(local, along), dot(d, along), 0, length, [0, 1])
+    if (range) range = slab(dot(local, normal), dot(d, normal), -radius, radius, range)
+    if (range) intervals.push(range)
+    return intervals
+}
+
+function mergeIntervals(intervals) {
+    const merged = []
+    for (const range of intervals.sort((a, b) => a[0] - b[0])) {
+        const last = merged.at(-1)
+        if (last && range[0] <= last[1] + EPS) last[1] = Math.max(last[1], range[1])
+        else merged.push([...range])
+    }
+    return merged
+}
+
+export function createNeighborhood(path, radiusM) {
+    const origin = path[0]
+    const xFactor = 111320 * Math.cos(origin[1] * Math.PI / 180)
+    const project = (point) => [(point[0] - origin[0]) * xFactor, (point[1] - origin[1]) * 111320]
+    const points = path.map(project)
+    const segments = points.length > 1 ? points.slice(1).map((p, i) => [points[i], p]) : [[points[0], points[0]]]
+    const bounds = points.reduce((box, p) => [Math.min(box[0], p[0]), Math.min(box[1], p[1]), Math.max(box[2], p[0]), Math.max(box[3], p[1])], [Infinity, Infinity, -Infinity, -Infinity])
+    const overlapsBounds = (p, q) => !(Math.max(p[0], q[0]) < bounds[0] - radiusM || Math.min(p[0], q[0]) > bounds[2] + radiusM || Math.max(p[1], q[1]) < bounds[1] - radiusM || Math.min(p[1], q[1]) > bounds[3] + radiusM)
+    const intervals = (p, q) => overlapsBounds(p, q)
+        ? mergeIntervals(segments.flatMap(([a, b]) => corridorIntervals(p, q, a, b, radiusM))) : []
+    return {
+        contains(point) { const p = project(point); return intervals(p, p).length > 0 },
+        clip(pathToClip) {
+            const pieces = []
+            let current = null
+            for (let i = 1; i < pathToClip.length; i += 1) {
+                const start = pathToClip[i - 1]
+                const end = pathToClip[i]
+                const ranges = intervals(project(start), project(end))
+                if (!ranges.length) { current = null; continue }
+                for (const [lo, hi] of ranges) {
+                    const a = lerp(start, end, lo)
+                    const b = lerp(start, end, hi)
+                    const joined = current && current.at(-1).every((v, axis) => Math.abs(v - a[axis]) < 1e-10)
+                    if (joined) current.push(b)
+                    else { current = [a, b]; pieces.push(current) }
+                    if (hi < 1 - EPS) current = null
+                }
+            }
+            return pieces
+        },
+    }
+}

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

@@ -178,6 +178,10 @@ export function toPipeNetwork(payload) {
             }
             pipes.push({
                 code: id,
+                sourceId: String(properties.sourceId ?? ''),
+                networkId: String(properties.networkId ?? ''),
+                status: typeof properties.status === 'string' ? properties.status : '',
+                statusLabel: typeof properties.statusLabel === 'string' ? properties.statusLabel : '',
                 name: String(properties.name || ''),
                 remark: typeof properties.remark === 'string' ? properties.remark : '',
                 networkLevel: properties.networkLevel ?? properties.network_level ?? 0,
@@ -198,7 +202,10 @@ export function toPipeNetwork(payload) {
         const component = componentOf(properties.pointType)
         components.push({
             code: id,
-            networkId: String(properties.networkId || ''),
+            sourceId: String(properties.sourceId ?? ''),
+            networkId: String(properties.networkId ?? ''),
+            status: typeof properties.status === 'string' ? properties.status : '',
+            statusLabel: typeof properties.statusLabel === 'string' ? properties.statusLabel : '',
             industry,
             kind: component.kind,
             label: component.label,

+ 2 - 0
src/modules/cockpit/domain/pointDetail.js

@@ -25,6 +25,8 @@ export function toDeviceCard(raw) {
     const source = isPlainObject(raw) ? raw : {}
     return {
         code: reader.str('deviceCode'),
+        networkId: String(source.networkId ?? ''),
+        industry: reader.optionalStr('industry'),
         name: reader.optionalStr('deviceName'),
         typeName: reader.optionalStr('deviceTypeName'),
         status: reader.optionalStr('deviceStatus'),

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

@@ -1549,7 +1549,7 @@ export function pickPipeObjectAt(viewer, windowX, windowY) {
     const source = pipelineSource(viewer)
     const id = picked && picked.id
 
-    if (source && id && source.entities.contains(id)) {
+    if (source && id && source.entities.values.includes(id)) {
         const properties = id.properties || {}
         const read = (key) => properties[key] && properties[key].getValue ? properties[key].getValue() : ''
         const kind = read('kind')
@@ -1577,7 +1577,7 @@ function pickThroughPipelines(viewer, 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))))
+        return Boolean(id && (id.kind === 'pipe-model-batch' || (source && source.entities.values.includes(id))))
     }
     if (!isPipeline(picked)) return picked
     // 普通拾取不受影响;只有最上层是管线时才执行多层拾取。

+ 39 - 0
src/modules/cockpit/services/enhancementDevices.js

@@ -0,0 +1,39 @@
+import { fetchMonitoringPointDetail } from './detailApi.js'
+
+/** Read the current snapshot, not just the selected point. Never borrow a parent's position. */
+export async function loadEnhancementDevices(summary, { fetchPoint = fetchMonitoringPointDetail, alive = () => true } = {}) {
+    const objects = summary?.objects || []
+    const points = objects.filter((o) => o.kind === 'monitoring-point')
+    const rows = new Map(objects.filter((o) => o.kind === 'unassociated-equipment').map((o) => [o.code, { ...o, pointCode: '' }]))
+    let cursor = 0
+    let failedPoints = 0
+    let missingDevices = 0
+    let extraDevices = 0
+    let stalePoints = 0
+    async function worker() {
+        while (cursor < points.length && alive()) {
+            const point = points[cursor++]
+            let result
+            try { result = await fetchPoint(point.code) } catch { failedPoints += 1; continue }
+            if (!alive()) return
+            if (!['success', 'empty', 'stale'].includes(result?.state) || !Array.isArray(result?.data?.devices)
+                || result.data.code !== point.code) { failedPoints += 1; continue }
+            if (result.state === 'stale') stalePoints += 1
+            const expected = new Set(point.deviceCodes || [])
+            const found = new Set()
+            for (const device of result.data.devices) {
+                if (!expected.has(device.code)) { extraDevices += 1; continue }
+                found.add(device.code)
+                rows.set(device.code, { ...device, pointCode: point.code, industry: device.industry || point.industry,
+                    retained: result.state === 'stale' })
+            }
+            missingDevices += [...expected].filter((code) => !found.has(code)).length
+        }
+    }
+    await Promise.all(Array.from({ length: Math.min(4, points.length) }, worker))
+    return { rows: [...rows.values()], failedPoints, missingDevices, extraDevices, stalePoints,
+        partial: !['complete', 'empty'].includes(summary?.phase) || !!summary?.truncated
+            || (Number.isFinite(summary?.total) && summary.total > objects.length)
+            || failedPoints > 0 || missingDevices > 0 || extraDevices > 0 || !alive(),
+        relationNote: '仅使用已提供的设施关联;其余为邻近示意,非实测安装位置' }
+}

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

@@ -23,6 +23,7 @@ import { ensureCesiumBaseUrl } from './cesiumAssets.js'
 import { getCockpitConfig, loadCockpitConfig } from './cockpitConfig.js'
 import { attachCameraInput, createCameraControl } from './cameraControl.js'
 import { attachPointPick } from './pointPick.js'
+import { attachSceneFocus } from './sceneInputFocus.js'
 import { formatCameraState, INITIAL_PANORAMA, RETURN_FLIGHT_MS, SETTLE_EPSILON } from '../domain/camera.js'
 import { BASEMAP_STATE, basemapStatusText, planBasemap } from '../domain/basemap.js'
 import { isAtHome, planHome, planIntro } from '../domain/viewPresets.js'
@@ -968,6 +969,7 @@ export function mountMap(host) {
         statusNode: null,
         resizePending: false,
         cameraControl: null,
+        detachSceneFocus: attachSceneFocus(host),
         detachCameraInput: null,
         // 票 10:点位点选的 DOM detach(与相机输入同一套生命周期)。
         detachPickInput: null,
@@ -1027,6 +1029,9 @@ export function unmountMap() {
         record.statusNode.remove()
         record.statusNode = null
     }
+    // 场景焦点与浏览器菜单抑制随宿主生命周期拆除。
+    record.detachSceneFocus?.()
+    record.detachSceneFocus = null
     // 相机:先摘事件绑定再销毁实例,避免销毁后仍有输入打进来。
     if (record.detachLocateInput) {
         record.detachLocateInput()

+ 59 - 0
src/modules/cockpit/services/pipeDetailGeometry.js

@@ -0,0 +1,59 @@
+import * as THREE from 'three'
+
+const RADIUS = 0.23
+const WALL = 0.035
+const AXIS = new THREE.Vector3(0, 1, 0)
+
+/** Closed annular cross-section revolved around Y: open bore, solid wall and end rims. */
+function sleeve(outer, inner, length) {
+    const half = length / 2
+    return new THREE.LatheGeometry([
+        new THREE.Vector2(outer, -half), new THREE.Vector2(outer, half),
+        new THREE.Vector2(inner, half), new THREE.Vector2(inner, -half),
+        new THREE.Vector2(outer, -half),
+    ], 32)
+}
+
+function metal(color, roughness = 0.48) {
+    return new THREE.MeshStandardMaterial({ color, roughness, metalness: 0.42, side: THREE.DoubleSide })
+}
+
+function addMesh(group, name, geometry, color, position = new THREE.Vector3()) {
+    const mesh = new THREE.Mesh(geometry, metal(color))
+    mesh.name = name
+    mesh.position.copy(position)
+    group.add(mesh)
+    return mesh
+}
+
+/** Decorative dimensions only. The pipe centreline still follows the admitted source path. */
+export function addDetailedPipeSegment(root, start, end, color, { startFlange = true, endFlange = true } = {}) {
+    const direction = new THREE.Vector3().subVectors(end, start)
+    const length = direction.length()
+    if (!Number.isFinite(length) || length < 0.001) return
+    const segment = new THREE.Group()
+    segment.name = 'pipe-segment'
+    segment.position.copy(start).add(end).multiplyScalar(0.5)
+    segment.quaternion.setFromUnitVectors(AXIS, direction.normalize())
+    const inner = RADIUS - WALL
+    addMesh(segment, 'pipe-wall', sleeve(RADIUS, inner, length), color)
+
+    const flangeDepth = Math.min(0.07, length * 0.15)
+    for (const sign of [-1, 1]) {
+        if ((sign === -1 && !startFlange) || (sign === 1 && !endFlange)) continue
+        const y = sign * (length / 2 - flangeDepth / 2)
+        addMesh(segment, 'pipe-flange', sleeve(RADIUS * 1.38, inner, flangeDepth), color, new THREE.Vector3(0, y, 0))
+        for (let i = 0; i < 8; i += 1) {
+            const angle = i * Math.PI / 4
+            addMesh(segment, 'pipe-bolt', new THREE.CylinderGeometry(0.025, 0.025, flangeDepth * 1.45, 6),
+                '#b9c5d0', new THREE.Vector3(Math.cos(angle) * RADIUS * 1.17, y, Math.sin(angle) * RADIUS * 1.17))
+        }
+    }
+    const sections = Math.min(8, Math.max(1, Math.ceil(length / 1.2)))
+    const seamDepth = Math.min(0.025, length * 0.04)
+    for (let i = 1; i < sections; i += 1) {
+        addMesh(segment, 'pipe-seam', sleeve(RADIUS * 1.035, RADIUS, seamDepth), color,
+            new THREE.Vector3(0, -length / 2 + length * i / sections, 0))
+    }
+    root.add(segment)
+}

+ 39 - 0
src/modules/cockpit/services/sceneInputFocus.js

@@ -0,0 +1,39 @@
+/** Three/Cesium 视口的输入焦点边界;只接管视口内部的浏览器菜单。 */
+export function attachSceneFocus(element) {
+    if (!element || typeof element.addEventListener !== 'function') return () => {}
+
+    const tabIndex = element.getAttribute?.('tabindex') ?? null
+    if (tabIndex === null) element.setAttribute?.('tabindex', '0')
+    const editable = (target) => target?.closest?.('input, textarea, select, [contenteditable]')
+    const focus = () => element.focus?.({ preventScroll: true })
+    const onDown = (event) => {
+        if (event.button !== 0 && event.button !== 2) return
+        if (editable(event.target) || event.target?.closest?.('button, a, [role="button"]')) return
+        focus()
+    }
+    const onMenu = (event) => {
+        if (editable(event.target)) return
+        event.preventDefault()
+        event.stopPropagation()
+        focus()
+    }
+    const onFocus = () => element.setAttribute?.('data-scene-focused', 'true')
+    const onBlur = (event) => {
+        if (!element.contains?.(event.relatedTarget)) element.removeAttribute?.('data-scene-focused')
+    }
+
+    // 捕获阶段覆盖 canvas、三维标签和覆盖层,即使后代停止冒泡也不会漏掉右键。
+    const capture = { capture: true }
+    element.addEventListener('pointerdown', onDown, capture)
+    element.addEventListener('contextmenu', onMenu, capture)
+    element.addEventListener('focusin', onFocus)
+    element.addEventListener('focusout', onBlur)
+    return () => {
+        element.removeEventListener('pointerdown', onDown, capture)
+        element.removeEventListener('contextmenu', onMenu, capture)
+        element.removeEventListener('focusin', onFocus)
+        element.removeEventListener('focusout', onBlur)
+        element.removeAttribute?.('data-scene-focused')
+        if (tabIndex === null) element.removeAttribute?.('tabindex')
+    }
+}

+ 169 - 46
src/modules/cockpit/services/threePipeEnhancement.js

@@ -1,6 +1,10 @@
-/** Three.js adapter for one selected pipe or facility component. */
+/** Three.js adapter for a selected object and its bounded local underground context. */
 import * as THREE from 'three'
 import { enhancementLabel } from '../domain/pipeEnhancement.js'
+import { PIPE_PALETTE } from '../domain/pipeVisual.js'
+import { attachSceneFocus } from './sceneInputFocus.js'
+import { addDetailedPipeSegment } from './pipeDetailGeometry.js'
+import { attachPointPick } from './pointPick.js'
 
 function defaultRendererFactory({ canvas }) {
     return new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true })
@@ -15,14 +19,15 @@ function project([lon, lat], center, scale) {
     )
 }
 
-function bounds(path, center) {
-    let extent = 1
+function fitScale(path, center) {
+    let extent = 0.01
+    const cosLat = Math.cos((center[1] * Math.PI) / 180)
     for (const point of path) {
-        const x = Math.abs((point[0] - center[0]) * 111320)
+        const x = Math.abs((point[0] - center[0]) * 111320 * cosLat)
         const z = Math.abs((point[1] - center[1]) * 111320)
         extent = Math.max(extent, x, z)
     }
-    return Math.min(0.025, 7 / extent)
+    return 2.5 / extent
 }
 
 function disposeObject(root) {
@@ -39,30 +44,52 @@ function material(color, opacity = 1) {
     return new THREE.MeshStandardMaterial({ color, roughness: 0.62, metalness: 0.18, transparent: opacity < 1, opacity })
 }
 
-function addSegment(root, start, end, radius, color) {
-    const delta = new THREE.Vector3().subVectors(end, start)
-    const length = delta.length()
-    if (!Number.isFinite(length) || length < 0.001) return
-    const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, 16), material(color))
-    mesh.position.copy(start).add(end).multiplyScalar(0.5)
-    mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize())
-    root.add(mesh)
-}
-
-function buildObject(plan) {
+function buildObject(plan, frame = null) {
     const object = plan.object
     const root = new THREE.Group()
     root.name = `pipe-enhancement:${object.code}`
-    const center = object.position || object.path[0]
-    const scale = bounds(object.path, center)
-    const points = object.path.map((point) => project(point, center, scale))
-    const color = object.industry === 'WATER' ? 0x3b9fe8 : object.industry === 'DRAINAGE' ? 0x41c7a2 : 0xff6e54
+    const center = object.renderKind === 'pipe'
+        ? [0, 1].map((axis) => {
+            const [min, max] = object.path.reduce(([lo, hi], point) =>
+                [Math.min(lo, point[axis]), Math.max(hi, point[axis])], [Infinity, -Infinity])
+            return (min + max) / 2
+        })
+        : object.position || object.path[0]
+    const scale = frame?.scale ?? fitScale(object.path, center)
+    const points = object.path.map((point) => project(point, frame?.center || center, scale))
+    const color = (PIPE_PALETTE[object.colorKey] || PIPE_PALETTE.UNKNOWN).color
 
     if (object.renderKind === 'pipe') {
-        for (let i = 1; i < points.length; i += 1) addSegment(root, points[i - 1], points[i], 0.23, color)
-        const ring = new THREE.Mesh(new THREE.TorusGeometry(0.31, 0.055, 10, 24), material(0xf4fbff, 0.82))
-        ring.rotation.x = Math.PI / 2
-        root.add(ring)
+        for (let i = 1; i < points.length; i += 1) {
+            if (object.primary === false) {
+                const delta = new THREE.Vector3().subVectors(points[i], points[i - 1])
+                const length = delta.length()
+                if (length < 0.001) continue
+                const mesh = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.1, length, 12, 1, true), material(color))
+                mesh.name = 'neighbor-pipe-wall'
+                mesh.position.copy(points[i - 1]).add(points[i]).multiplyScalar(0.5)
+                mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize())
+                root.add(mesh)
+            } else addDetailedPipeSegment(root, points[i - 1], points[i], color, {
+                startFlange: i === 1,
+                endFlange: i === points.length - 1,
+            })
+        }
+    } else if (object.renderKind === 'manhole') {
+        const shell = new THREE.Mesh(new THREE.CylinderGeometry(0.85, 0.85, 2.4, 32, 1, true, Math.PI * 0.25, Math.PI * 1.5),
+            new THREE.MeshStandardMaterial({ color: '#718093', roughness: 0.9, side: THREE.DoubleSide }))
+        shell.name = 'manhole-shaft'
+        shell.position.copy(points[0])
+        shell.position.y = -0.9
+        root.add(shell)
+        const base = new THREE.Mesh(new THREE.CylinderGeometry(0.86, 0.86, 0.12, 32), material('#53616f'))
+        base.position.copy(points[0]); base.position.y = -2.15
+        root.add(base)
+        const cover = new THREE.Mesh(new THREE.CylinderGeometry(0.9, 0.9, 0.12, 32), material(color))
+        cover.name = 'manhole-cover'
+        cover.position.copy(points[0]); cover.position.y = 0.55
+        cover.rotation.x = -0.2
+        root.add(cover)
     } else {
         const shape = object.renderKind === 'valve'
             ? new THREE.CylinderGeometry(0.48, 0.48, 0.16, 24)
@@ -76,15 +103,53 @@ function buildObject(plan) {
         if (object.renderKind === 'elbow') mesh.rotation.y = Math.PI / 2
         root.add(mesh)
     }
-    root.position.y = 0
+    root.position.y = frame ? ((frame.depthM || 0) - (object.depthM || 0)) * scale : 0
+    root.userData = { code: object.code, sourceKind: object.sourceKind, primary: object.primary !== false, clipped: object.clipped === true }
+    return root
+}
+
+function buildNeighborhood(plan) {
+    if ((!plan.objects || plan.objects.length <= 1) && !plan.devices?.length) return buildObject(plan)
+    const objects = plan.objects || [{ ...plan.object, primary: true, depthM: 0 }]
+    const path = [...objects.flatMap((object) => object.path), ...(plan.devices || []).map((device) => device.position)]
+    const center = [0, 1].map((axis) => {
+        const [min, max] = path.reduce(([lo, hi], p) => [Math.min(lo, p[axis]), Math.max(hi, p[axis])], [Infinity, -Infinity])
+        return (min + max) / 2
+    })
+    const depths = [...objects, ...(plan.devices || [])].map((item) => item.depthM || 0)
+    const depthSpread = Math.max(5, ...depths.map((depth) => Math.abs(depth - depths[0])))
+    const frame = { center, scale: Math.min(fitScale(path, center), 2.5 / depthSpread), depthM: objects[0].depthM }
+    const root = new THREE.Group()
+    root.name = `pipe-enhancement:${plan.object.code}`
+    for (const object of objects) root.add(buildObject({ object }, frame))
+    for (const device of plan.devices || []) {
+        const group = new THREE.Group()
+        group.name = `device:${device.code}`
+        group.userData.device = device
+        group.position.copy(project(device.position, center, frame.scale))
+        group.position.y = ((frame.depthM || 0) - device.depthM) * frame.scale + 0.35
+        const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.25, 8), material('#b9c5d0'))
+        stem.position.y = -0.16
+        group.add(stem)
+        const color = (PIPE_PALETTE[device.colorKey] || PIPE_PALETTE.UNKNOWN).color
+        const shape = device.industry === 'GAS' ? new THREE.BoxGeometry(0.22, 0.32, 0.18)
+            : device.industry === 'WATER_SUPPLY' ? new THREE.CylinderGeometry(0.15, 0.15, 0.28, 12)
+            : device.industry === 'MANHOLE' ? new THREE.BoxGeometry(0.3, 0.1, 0.22)
+            : new THREE.ConeGeometry(0.17, 0.3, 12)
+        group.add(new THREE.Mesh(shape, material(color)))
+        root.add(group)
+    }
+    root.updateMatrixWorld(true)
+    const box = new THREE.Box3().setFromObject(root)
+    root.position.sub(box.getCenter(new THREE.Vector3()))
     return root
 }
 
 /**
- * Mount a single-object viewer. The optional rendererFactory is a system-boundary seam
+ * Mount a bounded local viewer. The optional rendererFactory is a system-boundary seam
  * for offline tests; production uses WebGLRenderer.
  */
-export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRendererFactory, pixelRatio = 1 } = {}) {
+export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRendererFactory, pixelRatio = 1, onDevicePick = () => {} } = {}) {
     if (!host || !plan || plan.visible !== true || !plan.object) {
         return { status: 'unavailable', sync: () => {}, destroy: () => {}, getState: () => ({ status: 'unavailable' }) }
     }
@@ -111,14 +176,15 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
 
     let root = null
     let current = plan
+    const target = new THREE.Vector3()
     let yaw = 0
-    let pitch = -0.28
-    let distance = 7.2
-    let dragging = false
-    let lastX = 0
-    let lastY = 0
+    let pitch = Math.atan2(3.8, 7.2)
+    let distance = Math.hypot(3.8, 7.2)
+    let drag = null
     const canvas = renderer.domElement || host
+    let destroyed = false
     const resize = () => {
+        if (destroyed) return
         const width = host.clientWidth || 320
         const height = host.clientHeight || 220
         camera.aspect = width / Math.max(1, height)
@@ -127,8 +193,13 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
         renderer.render?.(scene, camera)
     }
     const render = () => {
-        camera.position.set(Math.sin(yaw) * distance, 3.8 + Math.sin(pitch) * 2, Math.cos(yaw) * distance)
-        camera.lookAt(0, 0, 0)
+        const horizontal = Math.cos(pitch) * distance
+        camera.position.set(
+            target.x + Math.sin(yaw) * horizontal,
+            target.y + Math.sin(pitch) * distance,
+            target.z + Math.cos(yaw) * horizontal,
+        )
+        camera.lookAt(target)
         renderer.render?.(scene, camera)
     }
     const sync = (next) => {
@@ -137,28 +208,74 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
             scene.remove(root)
             disposeObject(root)
         }
-        root = buildObject(next)
+        root = buildNeighborhood(next)
         scene.add(root)
         render()
     }
-    const onDown = (event) => { dragging = true; lastX = event.clientX; lastY = event.clientY }
+    const detachFocus = attachSceneFocus(host)
+    const raycaster = new THREE.Raycaster()
+    const detachPick = attachPointPick(canvas, ({ x, y }) => {
+        if (!root || !canvas.getBoundingClientRect) return
+        const rect = canvas.getBoundingClientRect()
+        if (!rect.width || !rect.height) return
+        camera.updateMatrixWorld()
+        root.updateMatrixWorld(true)
+        raycaster.setFromCamera(new THREE.Vector2((x - rect.left) / rect.width * 2 - 1, 1 - (y - rect.top) / rect.height * 2), camera)
+        const hit = raycaster.intersectObject(root, true)[0]
+        let node = hit?.object
+        while (node && !node.userData.device) node = node.parent
+        if (node?.userData.device) onDevicePick(node.userData.device)
+    })
+    const onDown = (event) => {
+        if (drag || (event.button !== 0 && event.button !== 2)) return
+        drag = { button: event.button, id: event.pointerId, x: event.clientX, y: event.clientY }
+        event.preventDefault?.()
+        try { canvas.setPointerCapture?.(event.pointerId) } catch { /* 合成事件可能没有可捕获指针。 */ }
+    }
     const onMove = (event) => {
-        if (!dragging) return
-        yaw += (event.clientX - lastX) * 0.01
-        pitch = Math.max(-1.1, Math.min(1.1, pitch + (event.clientY - lastY) * 0.008))
-        lastX = event.clientX; lastY = event.clientY; render()
+        if (!drag || event.pointerId !== drag.id) return
+        const dx = event.clientX - drag.x
+        const dy = event.clientY - drag.y
+        if (drag.button === 0) {
+            // 将像素位移换算为目标平面距离,保持“抓住场景拖动”的方向和速度。
+            const unitsPerPixel = 2 * distance * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) / (host.clientHeight || 220)
+            const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion)
+            const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion)
+            target.addScaledVector(right, -dx * unitsPerPixel).addScaledVector(up, dy * unitsPerPixel)
+        } else {
+            yaw -= dx * 0.01
+            pitch = Math.max(-1.4, Math.min(1.4, pitch + dy * 0.008))
+        }
+        drag.x = event.clientX
+        drag.y = event.clientY
+        render()
+    }
+    const onUp = (event) => {
+        if (!drag || (event?.pointerId !== undefined && event.pointerId !== drag.id)) return
+        const id = drag.id
+        drag = null
+        try { canvas.releasePointerCapture?.(id) } catch { /* 指针可能已由浏览器释放。 */ }
+    }
+    const onLeave = (event) => {
+        if (!canvas.hasPointerCapture?.(event.pointerId)) onUp(event)
+    }
+    const onWheel = (event) => {
+        event.preventDefault?.()
+        distance = Math.max(3.5, Math.min(12, distance + event.deltaY * 0.004))
+        render()
     }
-    const onUp = () => { dragging = false }
-    const onWheel = (event) => { distance = Math.max(3.5, Math.min(12, distance + event.deltaY * 0.004)); render() }
     canvas.addEventListener?.('pointerdown', onDown)
     canvas.addEventListener?.('pointermove', onMove)
     canvas.addEventListener?.('pointerup', onUp)
-    canvas.addEventListener?.('pointerleave', onUp)
-    canvas.addEventListener?.('wheel', onWheel)
+    canvas.addEventListener?.('pointercancel', onUp)
+    canvas.addEventListener?.('lostpointercapture', onUp)
+    canvas.addEventListener?.('pointerleave', onLeave)
+    canvas.addEventListener?.('wheel', onWheel, { passive: false })
     resize()
     sync(plan)
+    const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null
+    observer?.observe(host)
 
-    let destroyed = false
     return {
         status: 'ready',
         title: enhancementLabel(plan.object.sourceKind),
@@ -168,10 +285,16 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
         destroy() {
             if (destroyed) return
             destroyed = true
+            onUp()
+            observer?.disconnect()
+            detachFocus()
+            detachPick()
             canvas.removeEventListener?.('pointerdown', onDown)
             canvas.removeEventListener?.('pointermove', onMove)
             canvas.removeEventListener?.('pointerup', onUp)
-            canvas.removeEventListener?.('pointerleave', onUp)
+            canvas.removeEventListener?.('pointercancel', onUp)
+            canvas.removeEventListener?.('lostpointercapture', onUp)
+            canvas.removeEventListener?.('pointerleave', onLeave)
             canvas.removeEventListener?.('wheel', onWheel)
             if (root) disposeObject(root)
             renderer.dispose?.()

+ 58 - 0
src/modules/cockpit/stores/enhancementDevicesStore.js

@@ -0,0 +1,58 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import { loadEnhancementDevices } from '../services/enhancementDevices.js'
+import { fetchEquipmentDetailAggregate } from '../services/detailApi.js'
+
+/** Local viewer ownership: never overwrite the main map's point/device selection. */
+export const useEnhancementDevicesStore = defineStore('enhancementDevices', () => {
+    const source = ref({ rows: [], partial: true, loading: false })
+    const selected = ref(null)
+    const aggregate = ref(null)
+    const loadingDetail = ref(false)
+    let sourceToken = 0
+    let detailToken = 0
+    let fingerprint = ''
+    async function load(summary, options = {}) {
+        const next = JSON.stringify([summary.batchUpdatedAt, summary.phase, summary.truncated, summary.objects])
+        if (next === fingerprint && !options.force) return
+        fingerprint = next
+        const token = ++sourceToken
+        source.value = { rows: [], partial: true, loading: true }
+        closeDetail()
+        const result = await loadEnhancementDevices(summary, { ...options, alive: () => token === sourceToken })
+        if (token === sourceToken) source.value = { ...result, loading: false }
+    }
+    async function open(device, options = {}) {
+        if (!device?.code) return
+        const token = ++detailToken
+        selected.value = device
+        aggregate.value = null
+        loadingDetail.value = true
+        let result
+        try { result = await (options.fetchDetail || fetchEquipmentDetailAggregate)(device.code) }
+        catch { result = { state: 'failed', data: null } }
+        if (token !== detailToken) return
+        aggregate.value = result?.data?.deviceCode === device.code ? result : { state: 'failed', data: null }
+        loadingDetail.value = false
+    }
+    function closeDetail() {
+        detailToken += 1
+        selected.value = null
+        aggregate.value = null
+        loadingDetail.value = false
+    }
+    function reconcile(deviceList) {
+        if (selected.value && !deviceList.some((device) => device.code === selected.value.code)) closeDetail()
+    }
+    function invalidate() {
+        clear()
+        source.value.loading = true
+    }
+    function clear() {
+        sourceToken += 1
+        fingerprint = ''
+        source.value = { rows: [], partial: true, loading: false }
+        closeDetail()
+    }
+    return { source, selected, aggregate, loadingDetail, load, open, closeDetail, reconcile, invalidate, clear }
+})

+ 26 - 0
src/modules/cockpit/stores/pipeEnhancementStore.js

@@ -0,0 +1,26 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import { enhancementSelection } from '../domain/pipeEnhancement.js'
+import { neighborhoodRadius } from '../domain/pipeNeighborhood.js'
+
+/** Cesium 与 Three.js 之间共享的局部增强选择态。 */
+export const usePipeEnhancementStore = defineStore('pipeEnhancementStore', () => {
+    const selected = ref(null)
+    const radiusM = ref(50)
+
+    function setRadius(value) {
+        radiusM.value = neighborhoodRadius(value)
+    }
+
+    function select(kind, code) {
+        selected.value = enhancementSelection(kind, code)
+        return selected.value
+    }
+
+    function clear() {
+        selected.value = null
+        radiusM.value = 50
+    }
+
+    return { selected, radiusM, setRadius, select, clear }
+})