1
0

3 Коммитууд 6bbbcc0db6 ... da9a88693f

Эзэн SHA1 Мессеж Огноо
  ChenJZ da9a88693f docs(cockpit): 票52 后端执行票收口 —— 单设备工单处置优先落地记录 3 өдөр өмнө
  ChenJZ 713cb2b605 feat(cockpit): use source pipe dimensions and materials 4 өдөр өмнө
  ChenJZ e5f65ad58f feat(cockpit): add local pipe neighborhood equipment and manhole cutaway 4 өдөр өмнө
53 өөрчлөгдсөн 2896 нэмэгдсэн , 151 устгасан
  1. 201 0
      .scratch/cockpit-real-data-wayfinding/issues/52-untitled.md
  2. 20 5
      .scratch/cockpit-real-data-wayfinding/map.md
  3. 94 0
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t52/real-data-consequence.md
  4. 2 2
      .scratch/cockpit-real-data-wayfinding/verify/t32-view-layer-flow.mjs
  5. 2 3
      .scratch/cockpit-real-data-wayfinding/verify/t32-visibility-style.mjs
  6. 11 12
      .scratch/cockpit-real-data-wayfinding/verify/t32-visual-default.mjs
  7. 168 0
      .scratch/cockpit-real-data-wayfinding/verify/t52-mutations.mjs
  8. 82 0
      .scratch/cockpit-real-data-wayfinding/verify/t52-t45-baseline-check.mjs
  9. 44 0
      .scratch/cockpit-shell-panel-layout/issues/50-untitled.md
  10. 49 0
      .scratch/cockpit-shell-panel-layout/issues/51-untitled.md
  11. 44 0
      .scratch/cockpit-shell-panel-layout/issues/52-untitled.md
  12. 43 0
      .scratch/cockpit-shell-panel-layout/issues/53-untitled.md
  13. 43 0
      .scratch/cockpit-shell-panel-layout/issues/54-untitled.md
  14. 69 0
      .scratch/cockpit-shell-panel-layout/local-network-enhancement-spec.md
  15. 4 1
      .scratch/cockpit-shell-panel-layout/map.md
  16. 142 0
      .scratch/cockpit-shell-panel-layout/verify/t23-click-flow.mjs
  17. 36 0
      .scratch/cockpit-shell-panel-layout/verify/t23-popup-placement.mjs
  18. 31 0
      .scratch/cockpit-shell-panel-layout/verify/t23-sfc-compile.mjs
  19. 115 0
      .scratch/cockpit-shell-panel-layout/verify/t23-threejs-enhancement.mjs
  20. 228 0
      .scratch/cockpit-shell-panel-layout/verify/t23-visual-input.mjs
  21. 13 0
      .scratch/cockpit-shell-panel-layout/verify/t50-53-local-enhancement.mjs
  22. 109 0
      .scratch/cockpit-shell-panel-layout/verify/t50-pipe-detail.mjs
  23. 58 0
      .scratch/cockpit-shell-panel-layout/verify/t51-neighborhood.mjs
  24. 47 0
      .scratch/cockpit-shell-panel-layout/verify/t52-device-pick.mjs
  25. 51 0
      .scratch/cockpit-shell-panel-layout/verify/t52-device-plan.mjs
  26. 32 0
      .scratch/cockpit-shell-panel-layout/verify/t52-device-source.mjs
  27. 45 0
      .scratch/cockpit-shell-panel-layout/verify/t53-manhole.mjs
  28. 45 0
      .scratch/cockpit-shell-panel-layout/verify/t54-main-map-material.mjs
  29. 53 0
      .scratch/cockpit-shell-panel-layout/verify/t54-physical-details.mjs
  30. 22 4
      CONTEXT.md
  31. 76 5
      src/modules/cockpit/CockpitShell.vue
  32. 3 0
      src/modules/cockpit/components/MapCanvas.vue
  33. 97 21
      src/modules/cockpit/components/PipeEnhancement.vue
  34. 87 0
      src/modules/cockpit/domain/enhancementDevices.js
  35. 1 0
      src/modules/cockpit/domain/mapSummary.js
  36. 58 8
      src/modules/cockpit/domain/pipeEnhancement.js
  37. 12 15
      src/modules/cockpit/domain/pipeModels.js
  38. 88 0
      src/modules/cockpit/domain/pipeNeighborhood.js
  39. 11 3
      src/modules/cockpit/domain/pipeNetwork.js
  40. 38 0
      src/modules/cockpit/domain/pipePhysical.js
  41. 44 3
      src/modules/cockpit/domain/pipeVisual.js
  42. 2 0
      src/modules/cockpit/domain/pointDetail.js
  43. 2 2
      src/modules/cockpit/services/cesiumEngine.js
  44. 3 3
      src/modules/cockpit/services/cesiumPipeModels.js
  45. 39 0
      src/modules/cockpit/services/enhancementDevices.js
  46. 5 0
      src/modules/cockpit/services/mapService.js
  47. 56 0
      src/modules/cockpit/services/pipeDetailGeometry.js
  48. 13 6
      src/modules/cockpit/services/pipeModelShader.js
  49. 36 0
      src/modules/cockpit/services/pipeSurfaceMaterial.js
  50. 39 0
      src/modules/cockpit/services/sceneInputFocus.js
  51. 199 58
      src/modules/cockpit/services/threePipeEnhancement.js
  52. 58 0
      src/modules/cockpit/stores/enhancementDevicesStore.js
  53. 26 0
      src/modules/cockpit/stores/pipeEnhancementStore.js

+ 201 - 0
.scratch/cockpit-real-data-wayfinding/issues/52-untitled.md

@@ -0,0 +1,201 @@
+<!-- DSH-IDEMPOTENCY-KEY: deck-51a682f3-5973748 -->
+<!-- parentKey: 00 -->
+# 后端执行票 —— 单设备业务优先级裁定落地(全部报警均有未结束工单时工单处置最高)
+
+Category: backend-execution
+
+## 来源
+
+[前端执行票 45](45-untitled.md) 实施前裁定(维护者 2026-10-16):
+- 监测点聚合:维持报警优先级最高(后端现行语义不动)。
+- **单设备**:只要该设备的**所有报警都有对应的工单**(即报警均已被工单承接、处置在进行),单设备自身状态应判**工单处置最高**——高于报警/预警。
+- 判定权归后端;前端只渲染(CONTEXT.md / ADR 0002 不变)。
+
+## 现状证据(前端票 45 侦察)
+
+- 权威解析器 `CockpitMonitoringPointStatusResolver.deviceStatus()` 现行顺序:
+  `alarm → warning → workOrderInProgress → online?NORMAL:OFFLINE`,即报警恒压工单。
+  实测设备 `21119740`(alarm=true, warning=true, workOrderInProgress=true)→ 后端判 ALARM;
+  按裁定应为工单处置(其报警已有工单承接时)。
+- `CockpitMonitoringPointStatus` 枚举优先级 ALARM(1)/WARNING(2)/WORK_ORDER(3)/NORMAL(4)/OFFLINE(5)/EMPTY(6)。
+- map-snapshot 不携带工单布尔——前端无法也不得自行判定。
+
+## 范围
+
+1. 明确「所有报警均有对应工单」的判定口径(报警-工单回联读模型已有 `alarm_data.work_order_id` 关联,票 12 已核实关联键)。
+2. 调整单设备状态解析:设备存在报警、且每条报警均有未结束/已关联工单时 → `WORK_ORDER`(工单处置);否则维持现行序。
+   注意只影响**单设备**状态(设备卡片 deviceStatus、快照未关联设备 deviceStatus、统计计数),**监测点聚合 `resolve()` 语义由维护者另行确认是否跟随**(聚合用 min(priority),若单设备可判 WORK_ORDER(3) 而报警设备判 WORK_ORDER,聚合优先级序是否需把 WORK_ORDER 提到 ALARM 之前,须裁定并同步枚举 priority 与前端词表)。
+3. 契约测试覆盖:报警无工单 → ALARM;全部报警有工单 → WORK_ORDER;混合 → 按裁定;无报警维持现状。
+4. 落地后通知前端票 45 消费侧复核(前端 `DEVICE_STATUS_VISUALS.priority` 与 `POINT_STATUS_VISUALS.priority` 需同步新序——前端票 45 已按双轴实现,priority 同步是唯一耦合点)。
+
+## 非目标
+
+- 不改前端代码(前端词表同步由票 45 收口时复核)。
+- 不动监测点聚合 `resolve()` 除非第 2 条裁明确认需要。
+
+## 进度
+
+**已实施、验证、推送完成(2026-10-17)→ 关闭。**
+
+提交 `3772220e` + 编译解阻 `5d4d03c6`,已推 `origin/master`(远端核实 3772220e)。
+`Cockpit*Test,BusinessCockpit*Test` **512/512**;变异闸 **7/7** 打红并字节还原;前端 t09 **38/38**(零耦合,裁定 4 成立)。
+
+裁定全部落地:只取未处理报警(`alarm_status=0`);承接=`work_order_id` 非空且工单 `order_status ∈ {0..4,6}`;
+空集/读失败 → 维持 ALARM;`resolve()` 与枚举 priority 不动;已推送。
+
+两处如实留档:① 票面「21119740 应为工单处置」被真实数据证伪(该设备 2 条未处理报警均无工单承接,
+按裁定应为 ALARM);② 新规则在当前数据上不改变任何读数(正确行为,非未生效)。
+`zksy-system` 全量 2 失败/1 错误为既有(stash 对照逐条相同),与本票零交集。
+
+**下一步**:无(本票交付物已闭环)。前端消费侧无需跟随改动。
+
+Status: resolved
+Type: task
+Blocked by:
+Labels: wayfinder:task, backend-execution
+
+## Comments
+
+### local — 2026-10-16T18:13:25.050Z
+阶段闸门诊断完成(2026-10-17):当前工作树是前端 City-Lifeline-Cockpit(origin=192.168.110.30/ChenJZ/City-Lifeline-Cockpit),没有后端 Java/Maven/Gradle 源码或 CockpitMonitoringPointStatusResolver;后端仓库/负责人/权限未提供。只读真实接口复核:map-snapshot 仍返回设备 21119740 的 ALARM,设备工单接口中存在多条未结束工单但也有未关联当前报警的工单;因此票面“全部报警均有未结束/已关联工单”的设备级判据不能从现有设备汇总直接推断,需要后端按每条当前报警关联键(alarm_data.work_order_id)且工单状态 0–4/6 视为未结束,状态 5/7 视为结束,空/缺关联即不满足。未改前端或模拟后端,避免越权猜测。
+
+### local — 2026-10-16T18:32:36.319Z
+【诊断记录 2026-10-17 —— 后端仓库已定位,票面前提与真实数据不符,判定口径待裁定】
+
+### local — 2026-10-16T18:46:42.632Z
+【维护者裁定 2026-10-17(5 项,全部采纳推荐项)—— 据此实施】
+
+1. **报警范围**:只取未处理报警(`alarm_data.alarm_status = 0`)。已处理的历史流水不参与。
+2. **承接判据**:`alarm_data.work_order_id` 非空 **且** 该工单未结束(`work_order.order_status ∈ {0,1,2,3,4,6}`,0-based;5 已结案 / 7 已取消为终态)。
+3. **空集与缺数据**:设备 `alarm` 标志为真但取不到任何未处理报警行(或根本没有 `alarm_data` 行)→ **维持现行序 → ALARM**(「查不到」不等于「已承接」)。
+4. **聚合与优先级**:**不跟随**。`resolve()` 与枚举 `priority` 不改,前端 `POINT_STATUS_VISUALS.priority` 零改动、无耦合;仅单设备 `deviceStatus()` 按新规则判定。
+5. **交付边界**:提交并推送 `origin/master`。
+
+**本条同时记录一处契约边界(非目标,如实留档)**:`monitoring/situation` 的设备 `metricStatus`(`BusinessCockpitMonitoringSituationAdapter.metricStatus()`)词表为 `NORMAL/WARNING/ALARM/EXPIRED/OFFLINE/UNKNOWN`,**没有 `WORK_ORDER` 桶**,因此本次不改它——改它需要动 situation 响应契约,超出本票范围。
+
+### local — 2026-10-16T19:24:28.181Z
+【实施完成 2026-10-17 —— 已提交并推送 origin/master】
+
+**提交**:`3772220e`(本票) + `5d4d03c6`(编译解阻)→ 已推 `origin/master`(`git ls-remote` 核实远端 `master = 3772220e`,本地/远端 0 ahead/0 behind)。推送前远端已有 2 个新提交,已 `git fetch` + `rebase` 后复跑测试再推(未强推)。
+
+## 交付
+判定单源仍是 `CockpitMonitoringPointStatusResolver.deviceStatus()`:
+报警**且** `isAlarmsCoveredByOpenWorkOrders()` → `WORK_ORDER`,否则维持现行序。
+
+事实由 `BusinessCockpitDeviceFactResolver` 新增计算(新增 `AlarmDataService` 依赖):
+取 `alarm_data.alarm_status=0` 的行,**每一条**都要有 `work_order_id` 且对应工单
+`order_status ∈ {0..4,6}`;无未处理行 / 任一行不满足 / 读失败 → `false`(保守降级为 ALARM)。
+
+**三处透传接缝**(丢任一处结论都会被抹掉,变异 M6/M7 专门钉住):
+`CockpitMonitoringPointDeviceSnapshot`、分布快照 `SourceSmartDevice` + 分布适配器、
+`CockpitMonitoringDistributionService.effectiveDevice()` 重建、`CockpitMonitoringPointDetailService` 归一化。
+
+## 验证
+- `Cockpit*Test,BusinessCockpit*Test` **512/512**(基线 510 + 本票新增 2)。
+- 变异闸 [t52-mutations.mjs](../verify/t52-mutations.mjs) **7/7 打红并按字节还原**;覆盖:判定分支删除、判据放宽为「至少一条」、已结案/已取消算作承接、空集视为已承接、去掉 `alarm_status` 过滤、分布适配器不透传、`effectiveDevice` 重建丢事实。
+- 真实载荷取证 [real-data-consequence.md](../verify/artifacts/t52/real-data-consequence.md)。
+- 前端 t09 闸门 **38/38**(priority 与词表零改动,确认裁定 4「不跟随」成立)。
+
+## 顺带修复(既有阻塞,非本票引入)
+`zksy-system` 在 HEAD 上**根本编译不过**:`SseEmitterManager`(`5bc0c6e4` 引入)用 `SseEmitter`,
+而 `spring-webmvc` 只由 `springfox-spring-webmvc` 以 **provided** 传递(provided 不参与编译期),
+模块编译期缺类。已按 same-scope 显式声明 `provided` 补齐编译期(`5d4d03c6`);运行期无影响
+(boot 包 `BOOT-INF/lib` 本就含该 jar)。**未改任何运行语义。**
+
+## 既有失败(如实登记,与本票零交集)
+`zksy-system` 全量另有 **2 失败 / 1 错误**:`AlarmLifecycleServiceTest.insertWritesLogBeforeCommittingWithoutTrigger`、
+`WorkOrderServiceImplTest.cancelDoesNotWriteSuccessLogWhenStatusChangesConcurrently` / `cancelAllowsOnlyOrdersAwaitingDispatchOrAcceptance`。
+已把本票源码整体 `git stash` 后复跑,**同样 2 失败/1 错误、逐条相同** → 确认为 HEAD 级既有失败。
+
+## 两点如实留档
+1. **票面举例被证伪**:票面写「实测 21119740 按裁定应为工单处置」。实测该设备 2 条未处理报警
+   **都没有工单承接**,按裁定**就应当是 ALARM**。票面把设备级 `workOrderInProgress`(该设备存在任意
+   未结束工单)误当成了「未处理报警已被逐条承接」——两者不是同一事实。
+2. **新规则在当前真实数据上不改变任何设备读数**(今天不存在「未处理且已承接」的报警行)。
+   这是正确行为而非未生效;待业务侧为未处理报警建单后,该设备才会由 `ALARM` 变 `WORK_ORDER`。
+
+## 消费侧影响
+前端**零改动**(裁定 4):`POINT_STATUS_VISUALS`/`DEVICE_STATUS_VISUALS` 的 `priority`
+(ALARM 1 / WARNING 2 / WORK_ORDER 3)与后端枚举一致,本次未动枚举。
+`monitoring/situation` 的 `metricStatus` 词表无 `WORK_ORDER` 桶,仍按现状(已在裁定评论留档为非目标)。
+
+## 1. 交付环境(已具备)
+- 后端仓库:`E:\pipe-ner-server`,远端 `http://192.168.110.30:3000/shaoy/pipe-ner-server.git`,`master = origin/master = 91444f51`(`feat(alarm): 扩展报警指标并重构遥测报警评估逻辑`),工作树干净。
+- 只读数据库可达:KingbaseES `192.168.110.30:54321 / pipi_ner`(schema `app_user`),已用 psql 直连核对(非只看接口自证)。
+- 模块:后端代码在 `pipe-network-service/zksy-system`(域)与 `zksy-admin`(控制器+测试)。
+
+## 2. 权威链(现状)
+`CockpitMonitoringPointStatusResolver`(`zksy-system/src/main/java/com/zksy/cockpit/point/`):
+- `deviceStatus()`:`alarm → warning → workOrderInProgress → online ? NORMAL : OFFLINE`(报警恒压工单)。
+- 枚举 `CockpitMonitoringPointStatus`:ALARM(1)/WARNING(2)/WORK_ORDER(3)/NORMAL(4)/OFFLINE(5)/EMPTY(6)。
+- 调用点(改 `deviceStatus` 即全覆盖):`resolve()` 点位聚合(min priority)、`cards()` 设备卡 deviceStatus、`statistics()` 设备统计计数、`orderedDevices()` 排序、`getPublishedDeviceStatus()`(快照/未关联设备 deviceStatus)。
+
+事实来源 `BusinessCockpitDeviceFactResolver.resolve()`:
+- `alarm` = `equipment_status.alarm_status == 1`(设备级报警标志)。
+- `warning` = `early_warning.status ∈ {PENDING, PROCESSING, HANDLING, RELEASED}`。
+- `workOrderInProgress` = 该设备**任意**一张 `work_order.order_status ∈ {0,1,2,3,4,6}`(0-based;5 已结案、7 已取消为终态)——**不与报警逐条回联**。
+
+## 3. 真实数据(推翻票面前提)
+`alarm_data` 是**报警历史流水**(同一设备的重复报警会一行一条):
+
+| 事实 | 实测 |
+| --- | --- |
+| `alarm_data` 全表行数 / 涉及设备 | 90 行 / 仅 `21119740` 一台 |
+| 未处理(`alarm_status=0`) | 2 行(`BJ202610161600430761`、`BJ202610101851389458`) |
+| 带 `work_order_id` 的行 | 2 行,**且这 2 行 `alarm_status` 都是 1(已处理)** |
+| 「未处理 且 已关联工单」的行 | **0 行** |
+| 已关联工单的两条报警 | `89b53f56…`→WO291(order_status=0 待派单,未结束);`e68a942d…`→WO287(order_status=5 已结案) |
+| 设备 `21119740` | `equipment_status.alarm_status=1`、`online_status=1`;7 张工单,3 张未结束(291=0、286=1、285=3) |
+
+**因此票面「实测设备 21119740(alarm=true, warning=true, workOrderInProgress=true)→ 按裁定应为工单处置(其报警已有工单承接时)」无法成立**:该设备当前的 2 条未处理报警**都没有工单承接**;有工单的那 2 条报警都已经是「已处理」历史记录。
+
+## 4. 为什么必须先裁定
+范围第 1 条要求「明确『所有报警均有对应工单』的判定口径」,范围第 2 条又要求「监测点聚合语义由维护者另行确认」。上述数据事实使这两条**各自都有多种自洽读法、且结果可观测不同**(同一台设备在四种口径下两种判 ALARM、两种判 WORK_ORDER),按纪律不猜。
+另:`CockpitMonitoringPointStatus` 的 priority 同时驱动点位聚合(min)与前端图例排序(前端 `POINT_STATUS_VISUALS.priority` 与 t09 A3 闸门按此锁定),是否调整属维护者裁定范围。
+
+## 5. 尚未动工
+本轮只做诊断与事实核对,**未改任何后端代码**,未提交、未推送。裁定后按单源(`deviceStatus()`)实施并补契约测试。
+
+## Answer
+
+**已实施、验证并推送关闭(2026-10-17)。**
+
+### 裁定口径(维护者 2026-10-17,5 项)
+单设备存在报警(`equipment_status.alarm_status=1`)、且该设备**全部未处理报警**
+(`alarm_data.alarm_status=0`)都满足 `work_order_id` 非空 **且** 对应工单
+`work_order.order_status ∈ {0,1,2,3,4,6}`(未结束;5 已结案、7 已取消为终态)
+→ 判 `WORK_ORDER`;否则维持现行序。无未处理报警行 / 任一行不满足 / 读失败 → 保守判 `ALARM`。
+`resolve()` 聚合与枚举 `priority` **不动**(前端零耦合)。
+
+### 交付
+- 判定单源:`CockpitMonitoringPointStatusResolver.deviceStatus()`。
+- 事实计算:`BusinessCockpitDeviceFactResolver.alarmsCoveredByOpenWorkOrders()`(新增 `AlarmDataService` 依赖)。
+- **三处事实透传接缝**(丢任一处结论会被抹掉):`CockpitMonitoringPointDeviceSnapshot`、
+  分布快照 `SourceSmartDevice` + `BusinessCockpitMonitoringDistributionAdapter`、
+  `CockpitMonitoringDistributionService.effectiveDevice()` 重建、`CockpitMonitoringPointDetailService` 归一化。
+- 提交 `3772220e`(本票)+ `5d4d03c6`(既有编译阻塞修复)→ 已推 `origin/master`(`git ls-remote` 核实远端 = `3772220e`)。
+
+### 验证
+- `Cockpit*Test,BusinessCockpit*Test` **512/512**(基线 510 + 新增 2)。
+- 变异闸 [t52-mutations.mjs](../verify/t52-mutations.mjs) **7/7 打红并按字节还原**;
+  覆盖:判定分支删除、判据放宽为「至少一条」、已结案/已取消算作承接、空集视为已承接、
+  去掉 `alarm_status` 过滤、分布适配器不透传、`effectiveDevice` 重建丢事实。
+- 真实载荷取证 [real-data-consequence.md](../verify/artifacts/t52/real-data-consequence.md)。
+- 前端 t09 闸门 **38/38**(确认裁定 4「不跟随」成立)。
+
+### 两点如实留档
+1. **票面举例被证伪**:票面写「实测 21119740 按裁定应为工单处置」。实测该设备 2 条未处理报警
+   **都没有工单承接**,按裁定**就应当是 ALARM**。票面把设备级 `workOrderInProgress`
+   (该设备存在任意未结束工单)误当成了「未处理报警已被逐条承接」——两者不是同一事实。
+2. **新规则在当前真实数据上不改变任何设备读数**(今天不存在「未处理且已承接」的报警行)。
+   这是正确行为而非未生效;待业务侧为未处理报警建单后,该设备才会由 `ALARM` 变 `WORK_ORDER`。
+
+### 既有失败(与本票零交集)
+`zksy-system` 全量另有 **2 失败 / 1 错误**:`AlarmLifecycleServiceTest.insertWritesLogBeforeCommittingWithoutTrigger`、
+`WorkOrderServiceImplTest.cancelDoesNotWriteSuccessLogWhenStatusChangesConcurrently` /
+`cancelAllowsOnlyOrdersAwaitingDispatchOrAcceptance`。已把本票源码整体 stash 后复跑,
+**同样 2 失败/1 错误、逐条相同** → 确认为 HEAD 级既有失败。
+
+### 下一步
+无。本票交付物已闭环,前端消费侧无需跟随改动。
+

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 20 - 5
.scratch/cockpit-real-data-wayfinding/map.md


+ 94 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t52/real-data-consequence.md

@@ -0,0 +1,94 @@
+# 票 52 真实数据取证(单设备「全部未处理报警由未结束工单承接」)
+
+- 后端仓库:`E:\pipe-ner-server`(Gogs `shaoy/pipe-ner-server`)
+- 实施提交:`3772220e`(已推 `origin/master`,前一条 `5d4d03c6` 为编译解阻)
+- 数据库:KingbaseES `192.168.110.30:54321 / pipi_ner`(schema `app_user`),**只读**查询
+- 取证时间:2026-10-17
+
+## 1. 判定口径(维护者 2026-10-17 裁定)
+
+设备存在报警(`equipment_status.alarm_status = 1`)、且该设备**全部未处理报警**
+(`alarm_data.alarm_status = 0`)都满足:`work_order_id` 非空 **且** 对应工单
+`work_order.order_status ∈ {0,1,2,3,4,6}`(未结束;5 已结案、7 已取消为终态)
+→ 单设备状态判 `WORK_ORDER`;否则维持现行序(报警优先)。
+
+## 2. 库内事实(复算 SQL 与结果)
+
+未处理报警与其工单承接情况:
+
+| 设备 | 未处理报警 | 其中已关联未结束工单 | 新规则判定 |
+| --- | --- | --- | --- |
+| `21119740` | 2 | 0 | **ALARM(维持现行序)** |
+| 其余 8 台 | 0 | 0 | 无报警标志,走常规序 |
+
+- `alarm_data` 全表 90 行,**只涉 1 台设备** `21119740`(报警历史流水,重复报警一行一条)。
+- 全表仅 2 行带 `work_order_id`,且这 2 行 `alarm_status` **都是 1(已处理)**:
+  - `89b53f56…` → WO291(`order_status=0` 待派单,未结束)
+  - `e68a942d…` → WO287(`order_status=5` 已结案)
+- 「未处理 **且** 已关联工单」= **0 行**。
+
+复算 SQL:
+
+```sql
+with dev as (
+  select eb.equipment_id, eb.equipment_code,
+         (es.alarm_status = 1) as device_alarm
+  from app_user.equipment_base eb
+  left join app_user.equipment_status es on es.equipment_id = eb.equipment_id
+),
+u as (
+  select d.equipment_code,
+         count(a.id) as unhandled_cnt,
+         count(a.work_order_id) as with_wo,
+         count(*) filter (where a.work_order_id is not null
+                            and w.order_status in (0,1,2,3,4,6)) as with_open_wo
+  from dev d
+  left join app_user.alarm_data a
+         on a.device_code = d.equipment_code and a.alarm_status = 0
+  left join app_user.work_order w on w.order_id = a.work_order_id
+  group by d.equipment_code
+)
+select d.equipment_code, d.device_alarm,
+       coalesce(u.unhandled_cnt,0) unhandled,
+       coalesce(u.with_open_wo,0) covered,
+       case when d.device_alarm and coalesce(u.unhandled_cnt,0) > 0
+                  and u.unhandled_cnt = u.with_open_wo
+            then 'WORK_ORDER(新规则命中)'
+            when d.device_alarm then 'ALARM(维持现行序)'
+            else '(无报警标志)' end as new_status
+from dev d left join u on u.equipment_code = d.equipment_code
+order by d.equipment_code;
+```
+
+实测输出:
+
+```
+equipment_code        |device_alarm|unhandled|covered|new_status
+21119740              |t           |2        |0      |ALARM(维持现行序)
+41010500001320000001  |f           |0        |0      |(无报警标志)
+41010500000000000002  |f           |0        |0      |(无报警标志)
+860347065467627       |f           |0        |0      |(无报警标志)
+8652052334            |f           |0        |0      |(无报警标志)
+TEST-CBLL222222222    |f           |0        |0      |(无报警标志)
+TEST-CBYL5432168      |f           |0        |0      |(无报警标志)
+TEST-CNXHS0000001     |f           |0        |0      |(无报警标志)
+TEST-TCLLJ002215      |f           |0        |0      |(无报警标志)
+```
+
+## 3. 结论(如实记录,不夸大)
+
+1. 新规则在**当前真实数据上不改变任何设备的状态**——因为今天不存在「未处理且已承接」的报警。
+   部署后的现场读数与部署前一致,这是**正确行为**,不是「没生效」。
+2. 新规则的效果会在业务侧为未处理报警建单后显现:届时该设备由 `ALARM` 变 `WORK_ORDER`。
+3. **票面举例不可复现**:票面写「实测 21119740(alarm/warning/workOrderInProgress 三 true)
+   按裁定应为工单处置」——该设备 2 条未处理报警**都没有工单承接**,按裁定**就应当是 ALARM**。
+   票面那句话把「设备存在未结束工单」(`workOrderInProgress`,设备级任意一张)误当成了
+   「未处理报警已被工单承接」(逐条回联),二者不是同一事实。
+
+## 4. 验证
+
+- `Cockpit*Test,BusinessCockpit*Test` **512/512** 全绿(基线 510 + 本票新增 2)。
+- 变异闸 `verify/t52-mutations.mjs` **7/7** 打红并按字节还原(基线绿 → 变异红 → 还原绿)。
+- `zksy-system` 全量另有 **2 失败 / 1 错误**(`AlarmLifecycleServiceTest`、
+  `WorkOrderServiceImplTest`),已按 HEAD 对照(还原本票源码后复跑)确认**为既有失败**,
+  与本票改动零交集。

+ 2 - 2
.scratch/cockpit-real-data-wayfinding/verify/t32-view-layer-flow.mjs

@@ -15,7 +15,7 @@ const sources=[]
 const viewer={terrainProvider:{},isDestroyed:()=>false,scene:{requestRender(){},primitives:{add(){},remove(m){m.destroy()}}},dataSources:{getByName:n=>sources.filter(s=>s.name===n),add:async s=>{sources.push(s);return s}}}
 try{
  globalThis.fetch=async url=>String(url).startsWith('blob:')?nativeFetch(url):new Response(await readFile(new URL('../../../public/cockpit/models/pipes/spline.gltf',import.meta.url),'utf8'))
- const pipe={code:'async',industry:'DRAINAGE',networkLevel:1,diameter:null,positions:[[110.39,28.44],[110.3901,28.44]]}
+ const pipe={code:'async',industry:'DRAINAGE',networkLevel:1,diameter:400,positions:[[110.39,28.44],[110.3901,28.44]]}
  let finish
  const loaded=new Promise(resolve=>finish=resolve)
  const states=[]
@@ -28,7 +28,7 @@ try{
  assert.equal(near(),0)
  globalThis.t32Models[0].readyEvent.raiseEvent()
  assert.equal(near(),0,'late readiness must use latest ground mode')
- assert.equal(states.at(-1).visualDefaultCount,1)
+ assert.equal(states.at(-1).visualDefaultCount,0)
  setPipeViewMode(viewer,'pipeline');assert.equal(near(),600)
  const previous=graphics()
  await syncPipeLayer(viewer,{pipes:[{...pipe,name:'metadata changed'}],components:[]})

+ 2 - 3
.scratch/cockpit-real-data-wayfinding/verify/t32-visibility-style.mjs

@@ -6,9 +6,8 @@ const pipe=(industry,remark,diameter=100)=>({code:industry+remark,industry,remar
 const input=[pipe('GAS',''),pipe('WATER_SUPPLY',''),pipe('DRAINAGE','污水'),{...pipe('DRAINAGE','雨水'),networkLevel:1}]
 const plan=planPipeModels({pipes:input,components:[]})
 assert.deepEqual(plan.instances.map(row=>row.colorKey),['GAS','WATER_SUPPLY','SEWAGE','RAINWATER'])
-assert.ok(plan.instances.every(row=>row.displayDiameterM>=1.5),'all narrow real diameters need legible demonstration thickness')
-assert.ok(plan.instances.every(row=>row.diameterM===.1),'source diameter is NOT visual size')
-assert.ok(plan.instances.every(row=>row.displayDiameterM<=3),'entire body stays beneath terrain at 2m centre depth')
+assert.ok(plan.instances.every(row=>row.displayDiameterM===.1),'physical body diameter follows source metres without exaggeration')
+assert.ok(plan.instances.every(row=>row.diameterM===.1),'source millimetres are converted once to metres')
 const payload={features:[{geometry:{type:'LineString',coordinates:[[110.39,28.44],[110.3901,28.44]]},properties:{code:'remark-wins',industry:'DRAINAGE',name:'雨水管',remark:'污水',diameter:400,coordinateValidation:{renderable:true}}}]}
 const adapted=toPipeNetwork(payload)
 assert.equal(adapted.pipes[0].remark,'污水')

+ 11 - 12
.scratch/cockpit-real-data-wayfinding/verify/t32-visual-default.mjs

@@ -3,18 +3,17 @@ import { planPipeModels } from '../../../src/modules/cockpit/domain/pipeModels.j
 const make=(industry,remark='',diameter=null)=>({code:industry+remark,industry,remark,diameter,positions:[[110.39,28.44],[110.3901,28.44]]})
 const pipes=[make('GAS'),make('WATER_SUPPLY'),make('DRAINAGE','污水'),make('DRAINAGE','雨水')]
 const before=JSON.stringify(pipes),plan=planPipeModels({pipes,components:[]})
-assert.equal(plan.instances.length,4,'missing diameters with known categories must have display models')
-assert.deepEqual(plan.instances.map(i=>i.visualDefaultDiameterM),[.16,.4,.8,.8])
-assert.ok(plan.instances.every(i=>i.diameterM===null&&i.diameterSource==='visual-default'))
-assert.ok(plan.instances.every(i=>i.diameterAnchors.length===0))
+assert.equal(plan.instances.length,0,'unknown source diameters must not create default-diameter physical bodies')
+assert.equal(plan.fallbacks.length,4)
 assert.equal(plan.diameterSummary.missing,4)
-assert.equal(plan.visualDefaultCount,4)
-assert.equal(JSON.stringify(pipes),before,'source stays missing')
+assert.equal(plan.visualDefaultCount,0)
+assert.equal(JSON.stringify(pipes),before)
 const unknown=planPipeModels({pipes:[{...make('DRAINAGE','雨水 污水'),networkLevel:2}],components:[]})
-assert.equal(unknown.instances[0].visualDefaultDiameterM,.8)
-assert.equal(unknown.instances[0].colorKey,'DRAINAGE_UNKNOWN')
-assert.equal(unknown.instances[0].diameterM,null)
-assert.equal(planPipeModels({pipes:[make('UNKNOWN')],components:[]}).fallbacks.length,1)
+assert.equal(unknown.instances.length,0)
+assert.equal(unknown.fallbacks.length,1)
 const reported=planPipeModels({pipes:[make('GAS','',150)],components:[]}).instances[0]
-assert.equal(reported.diameterM,.15);assert.equal(reported.diameterSource,'reported');assert.equal(reported.visualDefaultDiameterM,null)
-console.log('PASS explicit per-category display defaults, missing provenance, source priority and unknown fallback')
+assert.equal(reported.diameterM,.15)
+assert.equal(reported.displayDiameterM,.15)
+assert.equal(reported.diameterSource,'reported')
+assert.equal(reported.visualDefaultDiameterM,null)
+console.log('PASS source-sized bodies only; missing/unknown diameters retain explicit guide fallback, no defaults')

+ 168 - 0
.scratch/cockpit-real-data-wayfinding/verify/t52-mutations.mjs

@@ -0,0 +1,168 @@
+/**
+ * 票 52 变异闸:证明契约测试对「单设备工单处置优先」这条规则**有牙**。
+ *
+ * 每条变异:按字节把源码改成**规则失效**的形态 → 跑
+ * BusinessCockpitDeviceFactResolverTest → 必须转红 → 再按字节还原。
+ * 全部转红、且还原后复绿,闸门才算通过。
+ *
+ * 两个必须踩住的坑(首版都踩了,留档):
+ *  1. 用 `mvn -pl zksy-admin test` 会把 zksy-system 从**本地仓库 jar** 解析,
+ *     源码变异根本进不了测试 —— 必须 `-pl zksy-admin -am` 走 reactor 重新编译。
+ *  2. Windows 上 `mvn` 是 `mvn.cmd`,`execFileSync('mvn', ...)` 直接 ENOENT;
+ *     那样每次"失败"都会被误判成"打红"。这里显式用 shell + mvn.cmd 路径,
+ *     并把「构建失败」与「测试计数含非零失败」分开判定。
+ *
+ * 用法:node .scratch/cockpit-real-data-wayfinding/verify/t52-mutations.mjs
+ */
+import { execSync } from 'node:child_process'
+import { readFileSync, writeFileSync } from 'node:fs'
+
+const BACKEND = 'E:/pipe-ner-server'
+const MODULE = 'pipe-network-service'
+/**
+ * 变异闸覆盖**三个测试类**:判定单源(resolver/facts)、事实透传接缝(分布适配器)、
+ * 以及服务层重建设备时的透传(分布控制器)。少了最后一层,
+ * 「effectiveDevice 丢事实」这类变异抓不住 —— 首版就是这么漏掉 M7 的。
+ */
+const TEST = 'BusinessCockpitDeviceFactResolverTest,BusinessCockpitMonitoringDistributionAdapterTest,CockpitMonitoringDistributionControllerTest'
+const MVN = 'D:\\Maven\\apache-maven-3.9.15\\bin\\mvn.cmd'
+
+const RESOLVER = `${BACKEND}/${MODULE}/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointStatusResolver.java`
+const FACTS = `${BACKEND}/${MODULE}/zksy-system/src/main/java/com/zksy/cockpit/point/BusinessCockpitDeviceFactResolver.java`
+const DIST_ADAPTER = `${BACKEND}/${MODULE}/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java`
+const DIST_SERVICE = `${BACKEND}/${MODULE}/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java`
+
+const read = (p) => readFileSync(p, 'utf8')
+const write = (p, s) => writeFileSync(p, s)
+const toLf = (s) => s.replace(/\r\n/g, '\n')
+
+const mutations = [
+    {
+        id: 'M1',
+        name: 'deviceStatus 退回「报警恒压工单」(删掉承接分支)',
+        file: RESOLVER,
+        from: `            return device.isAlarmsCoveredByOpenWorkOrders()
+                    ? CockpitMonitoringPointStatus.WORK_ORDER
+                    : CockpitMonitoringPointStatus.ALARM;`,
+        to: `            return CockpitMonitoringPointStatus.ALARM;`,
+    },
+    {
+        id: 'M2',
+        name: '承接判据放宽为「至少一条」(逐条比对改为只看非空)',
+        file: FACTS,
+        from: `            if (orderIds.size() != unhandled.size()) {
+                return false;
+            }`,
+        to: `            if (orderIds.isEmpty()) {
+                return false;
+            }`,
+    },
+    {
+        id: 'M3',
+        name: '把已结案/已取消也当成承接(isOpenOrderStatus 恒真)',
+        file: FACTS,
+        from: `        return (orderStatus >= 0 && orderStatus <= 4) || orderStatus == 6;`,
+        to: `        return true;`,
+    },
+    {
+        id: 'M4',
+        name: '空集视为「全部都有工单」(unhandled.isEmpty 返回 true)',
+        file: FACTS,
+        from: `            if (unhandled.isEmpty()) {
+                return false;
+            }`,
+        to: `            if (unhandled.isEmpty()) {
+                return true;
+            }`,
+    },
+    {
+        id: 'M5',
+        name: '报警范围放宽为全部历史流水(去掉 alarm_status 过滤)',
+        file: FACTS,
+        from: `                    .eq(AlarmData::getDeviceCode, deviceCode.trim())
+                    .eq(AlarmData::getAlarmStatus, UNHANDLED_ALARM_STATUS)));`,
+        to: `                    .eq(AlarmData::getDeviceCode, deviceCode.trim())));`,
+    },
+    {
+        id: 'M6',
+        name: '分布适配器不再透传承接事实(快照丢事实 → 出口永远判 ALARM)',
+        file: DIST_ADAPTER,
+        from: `                    CockpitCreatedAt.format(device.getCreateTime()),
+                    fact.isAlarmsCoveredByOpenWorkOrders()));`,
+        to: `                    CockpitCreatedAt.format(device.getCreateTime()),
+                    false));`,
+    },
+    {
+        id: 'M7',
+        name: '分布服务 effectiveDevice 重建时丢承接事实',
+        file: DIST_SERVICE,
+        from: `                        : "ONLINE".equals(runtime.monitoringState) || "OFFLINE".equals(runtime.monitoringState),
+                source.isAlarmsCoveredByOpenWorkOrders());`,
+        to: `                        : "ONLINE".equals(runtime.monitoringState) || "OFFLINE".equals(runtime.monitoringState),
+                false);`,
+    },
+]
+
+/** 跑一次测试;返回 { ran, green } —— ran=false 表示构建没跑到测试(不能算证据)。 */
+const runTest = () => {
+    let out = ''
+    try {
+        out = execSync(
+            `"${MVN}" -o -pl zksy-admin -am test -Dtest=${TEST} -DfailIfNoTests=false`,
+            { cwd: `${BACKEND}/${MODULE}`, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
+    } catch (error) {
+        out = `${error.stdout ?? ''}${error.stderr ?? ''}`
+    }
+    const summary = out.match(/Tests run: (\d+), Failures: (\d+), Errors: (\d+)/g)
+    if (!summary || summary.length === 0) {
+        return { ran: false, green: false, out }
+    }
+    const last = summary[summary.length - 1]
+    const [, total, failures, errors] = last.match(/Tests run: (\d+), Failures: (\d+), Errors: (\d+)/)
+    return { ran: true, green: Number(failures) === 0 && Number(errors) === 0 && Number(total) > 0, out }
+}
+
+const baseline = runTest()
+if (!baseline.ran || !baseline.green) {
+    console.log('基线不绿,闸门无法判定:先修好源码/测试再跑变异。')
+    process.exit(1)
+}
+console.log('基线绿(未变异时测试全过),开始变异…\n')
+
+let red = 0
+let broken = 0
+const originals = new Map()
+
+for (const m of mutations) {
+    if (!originals.has(m.file)) originals.set(m.file, read(m.file))
+    const original = originals.get(m.file)
+    const crlf = original.includes('\r\n')
+    const norm = toLf(original)
+    if (!norm.includes(m.from)) {
+        console.log(`SKIP ${m.id} — anchor 未命中(源码已变,需同步变异脚本):${m.name}`)
+        broken += 1
+        continue
+    }
+    write(m.file, crlf ? norm.replace(m.from, m.to).replace(/\n/g, '\r\n') : norm.replace(m.from, m.to))
+
+    const result = runTest()
+    if (!result.ran) {
+        console.log(`FAIL ${m.id} — 构建没跑到测试,不能算打红:${m.name}`)
+        broken += 1
+    } else if (!result.green) {
+        red += 1
+        console.log(`PASS ${m.id} — 变异被打红:${m.name}`)
+    } else {
+        console.log(`FAIL ${m.id} — 变异未被抓住(测试无牙):${m.name}`)
+    }
+
+    write(m.file, original)
+    if (read(m.file) !== original) {
+        console.log(`FAIL ${m.id} — 还原后字节不一致`)
+        broken += 1
+    }
+}
+
+const final = runTest()
+console.log(`\n变异打红 ${red}/${mutations.length};还原后复绿:${final.green ? '是' : '否'};异常 ${broken}`)
+process.exit(red === mutations.length && final.green && broken === 0 ? 0 : 1)

+ 82 - 0
.scratch/cockpit-real-data-wayfinding/verify/t52-t45-baseline-check.mjs

@@ -0,0 +1,82 @@
+/**
+ * 票 52 收口自查:t45 的 4 条 C3x 失败是否为「既有」。
+ *
+ * t45 的 C3b/C3c/C3d/C3f 是对 `markerOverlay.js` 的**静态源码断言**,而该文件在本
+ * 工作树是并行在制品(脏)。本票只改后端仓库 7 个 Java 文件,从未碰前端 src。
+ * 这里把 HEAD 版本的 markerOverlay.js 取出来,逐条复跑同一批断言:
+ * 若 HEAD 版本同样失败 → 既有失败,与本票零交集。
+ *
+ * 用法:node .scratch/cockpit-real-data-wayfinding/verify/t52-t45-baseline-check.mjs
+ */
+import { execFileSync } from 'node:child_process'
+import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+const REPO = 'E:/City-Lifeline-Cockpit-TvT'
+const OVERLAY_REL = 'src/modules/cockpit/services/markerOverlay.js'
+const T45_REL = '.scratch/cockpit-real-data-wayfinding/verify/t45-state-axis-and-effects.mjs'
+
+// 1. HEAD 版 markerOverlay.js
+const headOverlay = execFileSync('git', ['show', `HEAD:${OVERLAY_REL}`],
+    { cwd: REPO, encoding: 'utf8' })
+
+// 2. 在仓库内建一个临时副本,脚本相对路径保持可用(放同目录层级)
+const dir = mkdtempSync(join(REPO, '.t52-tmp-'))
+const overlayCopy = join(dir, 'markerOverlay.HEAD.js')
+writeFileSync(overlayCopy, headOverlay)
+
+// 3. 取 t45 脚本,把读 markerOverlay 的那一行指向 HEAD 副本;其余读取保持原样
+const t45 = readFileSync(join(REPO, T45_REL), 'utf8')
+const ANCHOR = "read('src/modules/cockpit/services/markerOverlay.js')"
+if (!t45.includes(ANCHOR)) {
+    console.log('锚点未命中:t45 脚本已变,需同步本自查脚本')
+    process.exit(1)
+}
+// 脚本用 new URL(`../../../${rel}`) 基于脚本位置解析;临时脚本仍放回原目录以便相对导入
+const patched = t45.replace(ANCHOR, `read('.t52-tmp-${dir.split('.t52-tmp-')[1]}/markerOverlay.HEAD.js')`)
+const patchedPath = join(REPO, '.scratch/cockpit-real-data-wayfinding/verify/.t52-t45-baseline.mjs')
+writeFileSync(patchedPath, patched)
+
+let out = ''
+try {
+    out = execFileSync('node', [patchedPath], { cwd: REPO, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
+} catch (error) {
+    out = `${error.stdout ?? ''}${error.stderr ?? ''}`
+}
+
+const fails = out.split('\n').filter((line) => line.startsWith('FAIL C3'))
+const headFailed = new Set(fails.map((line) => line.split(' ')[1]))
+const summary = out.split('\n').find((line) => line.includes('状态双轴与特效检查')) ?? '(无汇总行)'
+console.log(`HEAD 版 markerOverlay.js 上,C3x 静态断言失败 ${fails.length} 条:`)
+fails.forEach((line) => console.log(`  ${line}`))
+console.log(`汇总:${summary}`)
+
+// 再跑一次**当前工作树**,取它失败的那批
+let worktreeOut = ''
+try {
+    worktreeOut = execFileSync('node', [T45_REL], { cwd: REPO, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
+} catch (error) {
+    worktreeOut = `${error.stdout ?? ''}${error.stderr ?? ''}`
+}
+const wtFails = new Set(worktreeOut.split('\n')
+    .filter((line) => line.startsWith('FAIL C3'))
+    .map((line) => line.split(' ')[1]))
+const wtSummary = worktreeOut.split('\n').find((line) => line.includes('状态双轴与特效检查')) ?? '(无汇总行)'
+console.log(`\n当前工作树(并行在制品)失败 ${wtFails.size} 条:${[...wtFails].join(', ') || '(无)'}`)
+console.log(`汇总:${wtSummary}`)
+
+// 清理
+execFileSync('node', ['-e', `require('fs').rmSync(${JSON.stringify(dir)},{recursive:true,force:true});require('fs').rmSync(${JSON.stringify(patchedPath)},{force:true})`])
+console.log('\n已清理临时文件。')
+
+// 判据:工作树失败集必须是 HEAD 失败集的**子集**(本票没引入任何新失败)。
+// 注意工作树是并行在制品,逐条数可以比 HEAD 少(正在修),但不能多出 HEAD 没有的。
+const newFails = [...wtFails].filter((id) => !headFailed.has(id))
+if (newFails.length === 0) {
+    console.log(`判定:工作树 ${wtFails.size} 条失败 ⊂ HEAD ${headFailed.size} 条失败 → 全部既有;`
+        + '票 52 是纯后端改动(本会话未碰前端 src),未引入任何新前端失败。')
+    process.exit(0)
+}
+console.log(`注意:工作树出现 HEAD 上没有的新失败 ${newFails.join(', ')} → 需人工复核。`)
+process.exit(1)

+ 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
+窨井剖切代码与离线验证完成;不作真实井内安装声明,待现场视觉确认。

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

@@ -0,0 +1,43 @@
+<!-- DSH-IDEMPOTENCY-KEY: underground-details-source-dimensions-material-outline-v1 -->
+<!-- parentKey: 00 -->
+# 地下管线详情视图:真实管径、数据材质与精模描边
+
+## What to build
+
+维护者追加四项修改:管道精模与粗模尺寸遵循真实数据;材质与源材质相符;地下详情视图主选精模有模型描边;正式名称“地下管线详情视图”。
+
+已确认长度冲突决策:全部保持真实地理路径长度,后端length保留并显示差异,不拉伸坐标去凑属性长度。邻域裁切保持原地理片段。管径按后端mm转m,不放大、不夹粗度、不用行业默认/推算值冒充真实管径;缺径保留明确的无尺寸导引。
+
+## Acceptance criteria
+
+- [x] 详情精模、邻近粗模和主图管体使用后端源管径;比例与地理米制一致,不再固定半径或8倍放大/下限。
+- [x] 源length、地理路径长度与差异透明呈现,不修改源路径或用全长替代裁片。
+- [x] 源材质分类映射金属/铸铁/塑料/混凝土等可辨的表面参数;钢、玻璃钢和未知编码有明确边界,不从行业猜材质。
+- [x] 精粗管体共用材质语义,行业识别色保留,精模单独描边高亮,不改变真实尺寸和设备拾取。
+- [x] 名称、无障碍标签、详情状态标题和运行信息入口统一为“地下管线详情视图”。
+- [x] 实际Three/Cesium计划与材质、几何边界、描边释放、既有交互离线回归通过。
+- [ ] 维护者正式入口现场视觉/GPU验收;本轮未运行浏览器和build/debug。
+
+## 进度
+
+代码完成,待现场视觉验收。真实尺寸、材质、描边、命名与异常length边界已通过独立回归;完整输入回归受并发 `markerOverlay.js` 导入错误 `FX_CLASS is not defined` 阻断,该错误不属于本票改动。
+
+验证:t54-physical-details、t54-main-map-material、t50-pipe-detail、t52-device-pick、t53-manhole、t23-threejs-enhancement 9/9、t23-sfc-compile 5/5、t23-popup-placement通过。另有t23-visual-input因并发markerOverlay导入错误未能启动。按AGENTS跳过build/dev/browser/GPU。
+
+## Context
+
+规格与前置邻域/设备/窨井能力见[局部邻域管网与窨井规格](local-network-enhancement-spec.md)。源材质编码没有完整后端权威字典,未知编码保持“材质未识别”并使用低金属度表面;地理路径长度优先,后端length仅原值展示。
+
+## Comments
+
+### local — 2026-10-16
+源管径mm→m贯通精模/邻近粗模/主图;按材质拆Cesium批次并使用PBR参数;精模描边共享几何但独立材质、随分辨率更新并释放;正式名称已统一。
+
+## Answer
+代码完成,待维护者现场视觉确认。
+
+Status: ready-for-human
+Assignee: codex
+Type: task
+Blocked by:
+Labels: ready-for-human, wayfinder:task

+ 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', diameter: 400, 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}`)

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

@@ -0,0 +1,109 @@
+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', diameter: 400, material: 'PE', 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 > 0.98 && size.x < 1.02)
+    assert.ok(size.y > 0.4 && size.y < 0.6)
+    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', diameter: 400, 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 读取所有当前快照点位设备和独立设备,保留父点与行业;失败/未完成覆盖不冒称完整')

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

@@ -0,0 +1,45 @@
+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 }]
+    let isolatedCamera
+    const next = mountPipeEnhancement(host, isolated, { rendererFactory: () => ({ domElement: canvas, setSize() {}, dispose() {}, render(s, c) { scene = s; isolatedCamera = c } }) })
+    const device = scene.getObjectByName('device:standalone')
+    scene.updateMatrixWorld(true); isolatedCamera.updateMatrixWorld()
+    const screen = device.getWorldPosition(new THREE.Vector3()).project(isolatedCamera)
+    assert.ok(Math.abs(screen.x) < 1 && Math.abs(screen.y) < 1 && screen.z < 1, '井边独立设备须纳入相机可见范围')
+    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 窨井身份计划 → 剖切井筒和井盖+邻近管道,不造接管/状态')

+ 45 - 0
.scratch/cockpit-shell-panel-layout/verify/t54-main-map-material.mjs

@@ -0,0 +1,45 @@
+import assert from 'node:assert/strict'
+import { register } from 'node:module'
+import { readFile } from 'node:fs/promises'
+import { pipeSurface } from '../../../src/modules/cockpit/domain/pipePhysical.js'
+register('../../cockpit-real-data-wayfinding/verify/t32-adapter-io-loader.mjs', import.meta.url)
+const { createCesiumPipeModels } = await import('../../../src/modules/cockpit/services/cesiumPipeModels.js')
+const { createPipeModelShader } = await import('../../../src/modules/cockpit/services/pipeModelShader.js')
+const { LightingModel } = await import('cesium')
+globalThis.t32Models = []
+globalThis.t32TerrainFails = false
+globalThis.window = { location: { href: 'http://test.invalid/cockpit/', origin: 'http://test.invalid' } }
+const nativeFetch = globalThis.fetch
+const source = { entities: { values: [] } }
+const viewer = { terrainProvider: {}, isDestroyed: () => false, scene: { requestRender() {}, primitives: { add() {}, remove(m) { m.destroy() } } } }
+const pipes = [
+    { code: 'pe', industry: 'WATER_SUPPLY', diameter: 400, material: 'PE', positions: [[110.39, 28.44], [110.3901, 28.44]] },
+    { code: 'steel', industry: 'WATER_SUPPLY', diameter: 200, material: '钢管', positions: [[110.39, 28.4401], [110.3901, 28.4401]] },
+]
+function values(model, key) {
+    const accessor = model.gltf.accessors[model.gltf.nodes[0].extensions.EXT_mesh_gpu_instancing.attributes[key]]
+    const view = model.gltf.bufferViews[accessor.bufferView]
+    const data = model.gltf.buffers[view.buffer].uri.split(',')[1]
+    const bytes = Buffer.from(data, 'base64')
+    return new Float32Array(bytes.buffer, bytes.byteOffset + (view.byteOffset || 0), accessor.count * 3)
+}
+let adapter
+try {
+    globalThis.fetch = async (url) => String(url).startsWith('blob:') ? nativeFetch(url)
+        : new Response(await readFile(new URL('../../../public/cockpit/models/pipes/spline.gltf', import.meta.url), 'utf8'))
+    adapter = createCesiumPipeModels(viewer, { source })
+    await adapter.sync({ pipes })
+    assert.equal(globalThis.t32Models.length, 2, '同业不同材质必须拆为不同着色批次')
+    const [pe, steel] = globalThis.t32Models
+    assert.ok(Math.abs(values(pe, 'SCALE')[0] - 0.4) < 1e-6)
+    assert.ok(Math.abs(values(steel, 'SCALE')[0] - 0.2) < 1e-6)
+    assert.ok(values(pe, 'SCALE')[2] > 9 && values(pe, 'SCALE')[2] < 11, '轴向长度保持地理路径')
+    const shader = createPipeModelShader('WATER_SUPPLY', pipeSurface('PE'))
+    assert.equal(shader.lightingModel, LightingModel.PBR)
+    assert.equal(shader.uniforms.u_pipeMetalness.value, 0)
+    shader.destroy()
+    await adapter.sync({ pipes: [{ ...pipes[0], material: '钢管' }, pipes[1]] })
+    assert.ok(globalThis.t32Models.length > 2, '仅材质变化也触发3D更新')
+    assert.equal(pe.isDestroyed(), true)
+    console.log('PASS 主图GPU实例源直径和地理长度、按材质分组/PBR参数、仅材质更新与旧批次释放')
+} finally { adapter?.destroy(); globalThis.fetch = nativeFetch }

+ 53 - 0
.scratch/cockpit-shell-panel-layout/verify/t54-physical-details.mjs

@@ -0,0 +1,53 @@
+import assert from 'node:assert/strict'
+import * as THREE from 'three'
+import { planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnhancement.js'
+import { planPipeModels } from '../../../src/modules/cockpit/domain/pipeModels.js'
+import { pipeSurface } from '../../../src/modules/cockpit/domain/pipePhysical.js'
+import { mountPipeEnhancement } from '../../../src/modules/cockpit/services/threePipeEnhancement.js'
+const surfaces = [['钢', 'steel'], ['钢管', 'steel'], ['玻璃钢管', 'frp'], ['未知编码', 'unknown']]
+for (const [value, expected] of surfaces) assert.equal(pipeSurface(value).kind, expected)
+const anomaly = planPipeEnhancement({ selected: { kind: 'pipe', code: 'zero' }, visiblePipes: [{ code: 'zero', diameter: 400, length: 0, material: '钢', positions: [[110, 28], [110.001, 28]] }] })
+assert.equal(anomaly.object.lengthM, 0)
+const pipes = [
+    { code: 'fine', industry: 'WATER_SUPPLY', diameter: 400, length: 70, material: 'PE', positions: [[110, 28], [110.001, 28]] },
+    { code: 'coarse', industry: 'WATER_SUPPLY', diameter: 200, length: 45, material: '钢管', positions: [[110, 28.0001], [110.001, 28.0001]] },
+    { code: 'missing', industry: 'WATER_SUPPLY', diameter: null, positions: [[110, 28.0002], [110.001, 28.0002]] },
+]
+const input = { selected: { kind: 'pipe', code: 'fine' }, visiblePipes: pipes }
+const plan = planPipeEnhancement(input)
+assert.equal(plan.object.diameterM, 0.4)
+assert.equal(plan.object.lengthM, 70)
+assert.ok(plan.object.geographicLengthM > 98 && plan.object.geographicLengthM < 99)
+assert.equal(plan.object.surface.kind, 'pe')
+const map = planPipeModels({ pipes })
+assert.equal(map.instances.find((i) => i.id === 'pipe:fine').displayDiameterM, 0.4)
+assert.equal(map.instances.find((i) => i.id === 'pipe:coarse').surface.kind, 'steel')
+assert.ok(!map.instances.some((i) => i.id === 'pipe:missing'))
+let scene, camera
+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, c) { scene = s; camera = c } }) })
+scene.updateMatrixWorld(true)
+const body = scene.getObjectByName('pipe-wall')
+const coarse = scene.getObjectByName('neighbor-pipe-wall')
+assert.ok(body && coarse)
+body.geometry.computeBoundingBox()
+assert.ok(Math.abs(body.geometry.boundingBox.max.x - 0.2) < 1e-6)
+assert.equal(coarse.geometry.parameters.radiusTop, 0.1)
+assert.ok(Math.abs(body.getWorldScale(new THREE.Vector3()).x - 1) < 1e-8)
+assert.ok(Math.abs(body.geometry.boundingBox.max.y - body.geometry.boundingBox.min.y - plan.object.geographicLengthM) < 1e-4)
+assert.equal(body.material.metalness, 0)
+assert.ok(coarse.material.metalness > 0.7)
+const outlines = []
+scene.traverse((n) => { if (n.name === 'selected-pipe-outline') outlines.push(n) })
+assert.ok(outlines.length)
+assert.ok(!outlines.some((n) => n.parent.name === 'neighbor-pipe-wall'))
+const outline = outlines[0]
+assert.equal(outline.material.uniforms.resolution.value.x, 900)
+assert.equal(outline.material.depthTest, true)
+let released = false
+outline.material.addEventListener('dispose', () => { released = true })
+assert.ok(camera.far > camera.position.length())
+viewer.destroy()
+assert.equal(released, true)
+console.log('PASS 精粗源管径/地理长度/数据材质一致;缺径不造管体;精模描边及释放')

+ 22 - 4
CONTEXT.md

@@ -32,11 +32,29 @@ _Avoid_: 监测点、把设施点并入监测点统计、把聚合 KPI 当设施
 依真实管段路径或真实构件类型与位置呈现的体积表达;标准构件外形、无来源的尺寸与安装朝向均为示意,不等于工程竣工模型。
 _Avoid_: 真实安装模型、用示意构件推断实际连接拓扑
 
-**显示粗度**:
-为使管道示意模型清晰可辨而放大的可视横截面尺寸,与源数据管径、推算管径分别保留;不代表工程尺寸,也不改变管段路径。
-_Avoid_: 真实管径、用显示尺寸回写业务管径
+**邻域管网**:
+围绕选中管段或窨井、在指定空间范围内呈现的地下管道与设施集合;空间邻近不表示管道真实连接或接入该窨井。
+_Avoid_: 连通管网(无连接依据时)、井内接管(仅空间邻近时)
+
+**设备示意挂载**:
+将真实设备按所属行业放置在管道上的固定示意位置;有明确设施关联时按关联展示,没有关联时允许对合法坐标的邻近同业设备做示意摆放,并明确区别于实际安装位置。
+_Avoid_: 真实安装位置(无安装资料时)、管道所属设备(仅邻近时)、虚构设备
+
+**窨井剖切查看**:
+以井盖与剖切井筒示意模型查看窨井附近地下管道、监测设备及已有状态;未提供接管关系时仅说明空间邻近,不强行将附近管道接入井筒。
+_Avoid_: 井下实测结构(无资料时)、从附近管道推断接管关系
 
-缺少可靠源/推算管径的3D显示可使用DN160燃气/DN400供水/DN800排水作为可读性占位;以visual-default标记,不是测量、推算管径,不参与插值锚点,也不得回写源数据。排水雨污仅按network_level:0污水、1雨水,旧空值按0;其它等级未知时可使用同一DN800几何占位但保持灰色未分类,不猜颜色。
+**地下管线详情视图**:
+围绕选中管段或窨井查看附近地下管道、构件及真实监测设备的局部场景;主选精模描边突出,精粗管体均使用后端源管径并保持地理路径长度,材质由源字段决定。
+_Avoid_: 局部增强视图、用固定粗度或默认DN冒充源管径
+
+**管体尺寸**:
+横截面按后端源管径毫米换算米,不放大、不夹最小粗度;长度依真实地理路径,后端管长原值单独保留,冲突时不拉伸坐标。缺径只呈现无尺寸导引,壁厚、法兰及安装朝向缺资料时仍是示意。
+_Avoid_: 把显示粗度称为真实管径、默认管径实体、推算值冒充源尺寸
+
+**显示粗度**:
+为辨认地理路径而使用的屏幕导引线宽,不是实体管径;实体管体的显示放大与行业默认DN占位规则已由源尺寸规则取代。
+_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;

+ 97 - 21
src/modules/cockpit/components/PipeEnhancement.vue

@@ -1,60 +1,123 @@
 <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>
+                <span class="pipe-enhancement__eyebrow">地下管线详情视图</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>
+            <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 || '状态未提供' }}
+                        <small class="pipe-enhancement__dimensions">源管径 {{ pipe.diameterM ? pipe.diameter + ' mm' : '未提供(仅导引)' }} · 源材质 {{ pipe.material || '未提供' }}({{ pipe.surface?.label || '材质未识别' }})</small>
+                        <small class="pipe-enhancement__dimensions">{{ pipe.clipped ? '当前裁片' : '地理路径' }} {{ pipe.geographicLengthM?.toFixed(2) }} m · 后端管长 {{ pipe.lengthM ?? '未提供' }}{{ pipe.lengthM !== null ? '(源值)' : '' }}</small>
+                    </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 v-if="plan.object.sourceKind === 'pipe'"><dt>地理路径长度</dt><dd>{{ plan.object.geographicLengthM?.toFixed(2) }} m</dd></div>
+            <div v-if="plan.object.sourceKind === 'pipe'"><dt>后端管长(源值)</dt><dd>{{ plan.object.lengthM ?? '未提供' }}</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 +126,27 @@ 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__dimensions { display: block; margin-top: 4px; color: #a7c4d9; }
+.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: '',

+ 58 - 8
src/modules/cockpit/domain/pipeEnhancement.js

@@ -5,6 +5,10 @@
  * Three.js 局部查看窗所需的最小领域数据。计划不创建渲染器,也不猜工程拓扑。
  */
 
+import { pipeColorKey, pipeVirtualDepthM } from './pipeVisual.js'
+import { createNeighborhood, neighborhoodRadius } from './pipeNeighborhood.js'
+import { sourceDiameterM, pipeSurface, geographicPipeLength } from './pipePhysical.js'
+
 export const PIPE_ENHANCEMENT_KIND = Object.freeze({
     PIPE: 'pipe',
     COMPONENT: 'component',
@@ -15,20 +19,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,23 +49,35 @@ 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,
+        diameterM: sourceDiameterM(item.diameter),
+        lengthM: Number.isFinite(item.length) ? item.length : null,
+        geographicLengthM: geographicPipeLength(item.positions),
+        surface: pipeSurface(item.material),
         path: item.positions.map((pair) => [Number(pair[0]), Number(pair[1])]),
         position: item.positions[0],
     }
 }
 
 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 +89,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 +102,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, geographicLengthM: geographicPipeLength(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: '局部示意 · 显示尺寸与构件朝向不代表工程竣工模型',
     }
 }
@@ -92,5 +142,5 @@ export function enhancementSelection(kind, code) {
 }
 
 export function enhancementLabel(kind) {
-    return kind === PIPE_ENHANCEMENT_KIND.PIPE ? '重点管段剖面示意' : '重点构件示意'
+    return kind === PIPE_ENHANCEMENT_KIND.PIPE ? '地下管线详情视图 · 管段' : '地下管线详情视图 · 构件'
 }

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

@@ -1,32 +1,30 @@
-import { displayDiameterFor } from './pipeDiameterDisplay.js'
-import { resolvePipeDiameters } from './pipeDiameter.js'
-import { displayPipeDiameter, pipeColorKey, pipeVirtualDepthM, PIPE_VIRTUAL_DEPTH_M } from './pipeVisual.js'
+import { sourceDiameterM, pipeSurface } from './pipePhysical.js'
+import { pipeColorKey, pipeVirtualDepthM, PIPE_VIRTUAL_DEPTH_M } from './pipeVisual.js'
 
-/** 管体是沿真实折线路径的体积示意;虚拟埋深不是业务实测值。 */
+/** 管体按源管径和真实地理路径体积表达;虚拟埋深仍是示意。 */
 export const PIPE_MODEL_POLICY = Object.freeze({ virtualDepthM: 2, componentSizeM: 1 })
 export const PIPE_MODEL_KINDS = ['spline', 'elbow', 'valve', 'branch', 'cross']
-export const PIPE_MODEL_NOTE = '管体示意放大8倍、限制1.5–3m(非工程尺寸);虚拟埋深按类别:燃气1.5m、供水4m、污水8m、雨水10m(均非实测);源/双侧推算优先,缺径仅显示默认DN160/400/800/800,不回写源管径。地面视图所有距离保留彩色导引,管线视图已建模段600m外保留导引;燃气红、供水蓝、污水绿、雨水青、窨井紫,雨污按network_level:0污水、1雨水、缺失按0,其它值未分类;构件固定示意朝向,素材/地表失败保留折线或符号。'
+export const PIPE_MODEL_NOTE = '管体按后端源管径毫米换算米,不放大、不设粗度下限;长度保持地理路径,后端管长仅作属性,差异不拉伸坐标。缺径只保留无尺寸导引。材质按源字段,未知不猜;行业色保留。虚拟埋深与无尺寸构件仍是示意,不代表实测安装。'
 
 /** 输入只接收 toPipeNetwork 已准入且经模式计划裁剪后的对象,不再推断坐标/拓扑。 */
 export function planPipeModels(network = {}) {
     const instances = []
     const fallbacks = []
     const pipes = network.pipes || []
-    const diameters = resolvePipeDiameters(pipes)
     let visualDefaultCount = 0
     for (const pipe of pipes) {
         const id = `pipe:${pipe.code}`
-        const diameter = diameters.get(pipe)
+        const diameterM = sourceDiameterM(pipe.diameter)
         const colorKey = pipeColorKey(pipe)
-        const visualDefaultDiameterM = diameter ? null : displayDiameterFor(colorKey)
-        if (!diameter && visualDefaultDiameterM === null) {
-            fallbacks.push({ id, reason: '缺管径且分类未明确,保留折线' })
+        if (diameterM === null) {
+            fallbacks.push({ id, reason: '源管径未提供,保留无尺寸导引' })
             continue
         }
-        if (!diameter) visualDefaultCount += 1
-        const diameterM = diameter ? diameter.diameterMm / 1000 : null
+        const surface = pipeSurface(pipe.material)
         for (let i = 1; i < pipe.positions.length; i += 1) {
-            instances.push({ id, kind: 'spline', industry: pipe.industry, colorKey, virtualDepthM:pipeVirtualDepthM(colorKey), start: pipe.positions[i - 1], end: pipe.positions[i], diameterM, displayDiameterM:displayPipeDiameter(diameterM ?? visualDefaultDiameterM), diameterSource:diameter?.source || 'visual-default', diameterAnchors:diameter?.anchors || [], visualDefaultDiameterM })
+            instances.push({ id, kind: 'spline', industry: pipe.industry, colorKey, surface,
+                virtualDepthM: pipeVirtualDepthM(colorKey), start: pipe.positions[i - 1], end: pipe.positions[i],
+                diameterM, displayDiameterM: diameterM, diameterSource: 'reported', diameterAnchors: [], visualDefaultDiameterM: null })
         }
     }
     for (const component of network.components || []) {
@@ -41,8 +39,7 @@ export function planPipeModels(network = {}) {
     }
     const diameterSummary = { reported:0, interpolated:0, missing:0 }
     for (const pipe of pipes) {
-        const diameter = diameters.get(pipe)
-        diameterSummary[diameter ? diameter.source : 'missing'] += 1
+        diameterSummary[sourceDiameterM(pipe.diameter) !== null ? 'reported' : 'missing'] += 1
     }
     return { instances, fallbacks, diameterSummary, visualDefaultCount, virtualDepthM: null, virtualDepthByColor: PIPE_VIRTUAL_DEPTH_M }
 }

+ 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
+        },
+    }
+}

+ 11 - 3
src/modules/cockpit/domain/pipeNetwork.js

@@ -178,13 +178,18 @@ 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,
                 industry,
-                material: String(properties.material || ''),
+                material: String(properties.material ?? ''),
                 diameter: diameterOf(properties.diameter),
-                length: isFiniteNumber(Number(properties.length)) ? Number(properties.length) : null,
+                length: properties.length == null || String(properties.length).trim() === '' ? null
+                    : isFiniteNumber(Number(properties.length)) ? Number(properties.length) : null,
                 positions,
             })
             continue
@@ -198,7 +203,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,

+ 38 - 0
src/modules/cockpit/domain/pipePhysical.js

@@ -0,0 +1,38 @@
+/** Source material semantics shared by map and detail renderers; unknown codes are never guessed. */
+const SURFACES = Object.freeze({
+    steel: { kind: 'steel', label: '钢管', metalness: 0.82, roughness: 0.32 },
+    iron: { kind: 'iron', label: '铸铁', metalness: 0.65, roughness: 0.7 },
+    pe: { kind: 'pe', label: 'PE塑料', metalness: 0, roughness: 0.5 },
+    pvc: { kind: 'pvc', label: 'PVC塑料', metalness: 0, roughness: 0.28 },
+    frp: { kind: 'frp', label: '玻璃钢复合材料', metalness: 0, roughness: 0.4 },
+    concrete: { kind: 'concrete', label: '混凝土', metalness: 0, roughness: 0.96 },
+    copper: { kind: 'copper', label: '铜管', metalness: 0.92, roughness: 0.26 },
+    unknown: { kind: 'unknown', label: '材质未识别', metalness: 0, roughness: 0.8 },
+})
+export function pipeSurface(material) {
+    const text = typeof material === 'string' ? material.trim().toUpperCase() : ''
+    let kind = 'unknown'
+    if (/玻璃钢|^(FRP|GRP|GFRP)$/.test(text)) kind = 'frp'
+    else if (/不锈钢|钢管|碳钢|镀锌|无缝钢|^(钢|STEEL|STAINLESS STEEL|SS|Q235|Q345)$/.test(text)) kind = 'steel'
+    else if (/铸铁|球墨|^(DI|CI|DUCTILE IRON|CAST IRON)$/.test(text)) kind = 'iron'
+    else if (/聚乙烯|^(HDPE|MDPE|PE)([-\s]?\d+)?$/.test(text)) kind = 'pe'
+    else if (/聚氯乙烯|^(U?PVC|PVC[- ]?U)$/.test(text)) kind = 'pvc'
+    else if (/混凝土|水泥|^(CONCRETE|RCP|RC)$/.test(text)) kind = 'concrete'
+    else if (/铜管|^(COPPER|CU)$/.test(text)) kind = 'copper'
+    return { ...SURFACES[kind], source: typeof material === 'string' ? material : '' }
+}
+
+export function sourceDiameterM(value) {
+    return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value / 1000 : null
+}
+
+/** Same local tangent-plane metric as the detail projection. Source paths are never stretched. */
+export function geographicPipeLength(path) {
+    if (!Array.isArray(path) || path.length < 2) return 0
+    const cosLat = Math.cos(path[0][1] * Math.PI / 180)
+    let length = 0
+    for (let i = 1; i < path.length; i += 1) {
+        length += Math.hypot((path[i][0] - path[i - 1][0]) * 111320 * cosLat, (path[i][1] - path[i - 1][1]) * 111320)
+    }
+    return length
+}

+ 44 - 3
src/modules/cockpit/domain/pipeVisual.js

@@ -8,8 +8,49 @@ export const PIPE_PALETTE = Object.freeze({
     DRAINAGE_UNKNOWN: { color:'#aab7c4', label:'排水(雨污等级未识别)' },
     UNKNOWN: { color:'#aab7c4', label:'未分类' },
 })
-export const PIPE_DISPLAY = Object.freeze({ diameterScale:8, minDiameterM:1.5, maxDiameterM:3, guideFromM:600, guideWidthPx:4 })
+export const PIPE_DISPLAY = Object.freeze({ diameterScale:1, minDiameterM:0, maxDiameterM:Infinity, guideFromM:600, guideWidthPx:4 })
 export const PIPE_VIRTUAL_DEPTH_M = Object.freeze({ GAS:1.5, WATER_SUPPLY:4, SEWAGE:8, RAINWATER:10, DRAINAGE_UNKNOWN:8 })
+/**
+ * 管线颜色图例(票 48):地图实际会画出的**全部**颜色桶的展示顺序。
+ *
+ * 维护者点名五个(GAS/WATER_SUPPLY/SEWAGE/RAINWATER/MANHOLE);`pipeColorKey()` 对
+ * 雨污等级未识别的排水管与脏行业码还会给出 DRAINAGE_UNKNOWN / UNKNOWN 两桶,
+ * 地图上真的会画出这两种灰(见 `pipeColorKey` 的兜底分支)——按「图例必须覆盖
+ * 地图实际会画出的颜色」判据一并列出,用户看到灰线时含义可直接查到,不静默丢桶。
+ * 顺序表必须与 `PIPE_PALETTE` 的键集合一致(闸门 t48 锁定):调色板加桶而这里没跟上是缺陷。
+ */
+export const PIPE_LEGEND_ORDER = Object.freeze(['GAS','WATER_SUPPLY','SEWAGE','RAINWATER','MANHOLE','DRAINAGE_UNKNOWN','UNKNOWN'])
+
+/** 图例行:色值与词条**引用 `PIPE_PALETTE` 本体**(同一对象),图例与地图逐字节同源,不另抄十六进制。 */
+export function pipeLegendEntries() {
+    return PIPE_LEGEND_ORDER.map((key) => ({ key, color: PIPE_PALETTE[key].color, label: PIPE_PALETTE[key].label }))
+}
+
+/**
+ * 按颜色桶计数(票 48 图例的计数来源)。
+ *
+ * `network` 形状不完整(还没取数 / 取数失败)→ `{ available:false }`,调用方**不得画 0**——
+ * 「后端没给」与「确实是 0」是两件事(票 05 的纪律);计数 0 是真实值(该桶确实没有对象),照常显示。
+ * @param {{pipes?:Array, components?:Array}} [network] `domain/pipeNetwork.js` 的图层形状
+ * @returns {{available: boolean, byKey: object}} `byKey` 只含 `PIPE_LEGEND_ORDER` 的键
+ */
+export function pipeColorCounts(network) {
+    const pipes = network && Array.isArray(network.pipes) ? network.pipes : null
+    const components = network && Array.isArray(network.components) ? network.components : null
+    if (!pipes || !components) return { available: false, byKey: {} }
+    const byKey = {}
+    for (const key of PIPE_LEGEND_ORDER) byKey[key] = 0
+    for (const row of pipes) {
+        const key = pipeColorKey(row)
+        if (Object.prototype.hasOwnProperty.call(byKey, key)) byKey[key] += 1
+    }
+    for (const row of components) {
+        const key = pipeColorKey(row)
+        if (Object.prototype.hasOwnProperty.call(byKey, key)) byKey[key] += 1
+    }
+    return { available: true, byKey }
+}
+
 export function pipeVirtualDepthM(colorKey) {
     return PIPE_VIRTUAL_DEPTH_M[colorKey] ?? 2
 }
@@ -24,7 +65,7 @@ export function pipeColorKey(row) {
     return 'DRAINAGE_UNKNOWN'
 }
 
-/** Transverse enlargement only; max 3m keeps the body under a virtual 2m centre depth. */
+/** Compatibility seam: the rendered diameter is the positive source diameter in metres. */
 export function displayPipeDiameter(diameterM) {
-    return Math.min(PIPE_DISPLAY.maxDiameterM, Math.max(PIPE_DISPLAY.minDiameterM, diameterM * PIPE_DISPLAY.diameterScale))
+    return Number.isFinite(diameterM) && diameterM > 0 ? diameterM : null
 }

+ 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
     // 普通拾取不受影响;只有最上层是管线时才执行多层拾取。

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

@@ -78,7 +78,7 @@ async function groupsFor(viewer, plan, heights, alive) {
     for(const row of rows) {
         const p=row.start||row.position
         const tileX=Math.floor(p[0]/TILE_DEG),tileY=Math.floor(p[1]/TILE_DEG)
-        const key=`${tileX}:${tileY}:${row.kind}:${row.colorKey||'UNKNOWN'}`
+        const key=`${tileX}:${tileY}:${row.kind}:${row.colorKey||'UNKNOWN'}:${row.surface?.kind || 'unknown'}`
         const origin=[(tileX+.5)*TILE_DEG,(tileY+.5)*TILE_DEG]
         const matrix=Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(...origin))
         const inverse=Matrix4.inverseTransformation(matrix,new Matrix4())
@@ -93,7 +93,7 @@ async function groupsFor(viewer, plan, heights, alive) {
             rotation=rotationFromZ(new Cartesian3(...gltfCoordinates(delta)))
             scale=[row.displayDiameterM,row.displayDiameterM,length]
         }
-        if(!groups.has(key)) groups.set(key,{key,kind:row.kind,industry:row.industry,colorKey:row.colorKey||'UNKNOWN',origin,instances:[]})
+        if(!groups.has(key)) groups.set(key,{key,kind:row.kind,industry:row.industry,colorKey:row.colorKey||'UNKNOWN',surface:row.surface,origin,instances:[]})
         groups.get(key).instances.push({id:row.id,translation,rotation,scale})
     }
     // One missing span keeps its entire business pipe's fallback visible.
@@ -134,7 +134,7 @@ export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=i
             const gltf=instanceGltf(template,group.instances)
             // Use a public URL/Resource entry, not Cesium's private options.gltf JSON hook.
             const url=URL.createObjectURL(new Blob([JSON.stringify(gltf)],{type:'model/gltf+json'}))
-            const shader=createPipeModelShader(group.colorKey)
+            const shader=createPipeModelShader(group.colorKey, group.surface)
             let model
             try {
                 model=await Model.fromGltfAsync({url,modelMatrix:Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(...group.origin)),

+ 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()

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

@@ -0,0 +1,56 @@
+import * as THREE from 'three'
+import { pipeSurfaceMaterial } from './pipeSurfaceMaterial.js'
+
+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 addMesh(group, name, geometry, color, position = new THREE.Vector3(), surface) {
+    const mesh = new THREE.Mesh(geometry, pipeSurfaceMaterial(color, surface))
+    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, radius, surface } = {}) {
+    const direction = new THREE.Vector3().subVectors(end, start)
+    const length = direction.length()
+    if (!Number.isFinite(length) || length < 0.000001 || !Number.isFinite(radius) || radius <= 0) return
+    const RADIUS = radius
+    const WALL = radius * 0.12
+    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, new THREE.Vector3(), surface)
+
+    const flangeDepth = Math.min(radius * 0.3, 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), surface)
+        for (let i = 0; i < 8; i += 1) {
+            const angle = i * Math.PI / 4
+            addMesh(segment, 'pipe-bolt', new THREE.CylinderGeometry(radius * 0.1, radius * 0.1, 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(radius * 0.1, 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), surface)
+    }
+    root.add(segment)
+}

+ 13 - 6
src/modules/cockpit/services/pipeModelShader.js

@@ -1,15 +1,22 @@
 import { Cartesian3, Color, CustomShader, LightingModel, UniformType } from 'cesium'
 import { PIPE_PALETTE } from '../domain/pipeVisual.js'
+import { pipeSurface } from '../domain/pipePhysical.js'
 
-/** Bright industry colors with view-normal shading; independent of sun/night lighting. */
-export function createPipeModelShader(colorKey) {
+/** Shared source-material reflectance with the established industry tint. */
+export function createPipeModelShader(colorKey, surface = pipeSurface('')) {
     const color = Color.fromCssColorString((PIPE_PALETTE[colorKey] || PIPE_PALETTE.UNKNOWN).color)
     return new CustomShader({
-        lightingModel: LightingModel.UNLIT,
-        uniforms: { u_pipeColor: { type:UniformType.VEC3, value:new Cartesian3(color.red,color.green,color.blue) } },
+        lightingModel: LightingModel.PBR,
+        uniforms: {
+            u_pipeColor: { type:UniformType.VEC3, value:new Cartesian3(color.red,color.green,color.blue) },
+            u_pipeMetalness: { type:UniformType.FLOAT, value:surface.metalness },
+            u_pipeRoughness: { type:UniformType.FLOAT, value:surface.roughness },
+        },
         fragmentShaderText: `void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
-            float shade = 0.72 + 0.28 * abs(normalize(fsInput.attributes.normalEC).z);
-            material.diffuse = czm_srgbToLinear(u_pipeColor) * shade;
+            material.diffuse = czm_srgbToLinear(u_pipeColor);
+            material.metallic = u_pipeMetalness;
+            material.roughness = u_pipeRoughness;
+            material.emissive = czm_srgbToLinear(u_pipeColor) * 0.08;
             material.alpha = 1.0;
         }`,
     })

+ 36 - 0
src/modules/cockpit/services/pipeSurfaceMaterial.js

@@ -0,0 +1,36 @@
+import * as THREE from 'three'
+import { pipeSurface } from '../domain/pipePhysical.js'
+
+/** Industry tint communicates service; reflectance communicates the reported material. */
+export function pipeSurfaceMaterial(color, surface = pipeSurface('')) {
+    const material = new THREE.MeshStandardMaterial({ color, metalness: surface.metalness, roughness: surface.roughness, side: THREE.DoubleSide })
+    material.userData.pipeSurface = surface.kind
+    return material
+}
+
+/** Screen-space inverted hull: highlights the selected model without changing its physical geometry. */
+export function outlineSelectedPipe(root) {
+    const meshes = []
+    root.traverse((node) => { if (node.isMesh) meshes.push(node) })
+    if (!meshes.length) return
+    const material = new THREE.ShaderMaterial({
+        uniforms: { resolution: { value: new THREE.Vector2(1, 1) }, widthPx: { value: 2 }, color: { value: new THREE.Color('#e3fbff') } },
+        side: THREE.BackSide, depthWrite: false, depthTest: true,
+        vertexShader: `uniform vec2 resolution; uniform float widthPx;
+            void main() {
+                vec4 clip = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+                vec2 direction = (projectionMatrix * vec4(normalize(normalMatrix * normal), 0.0)).xy;
+                float magnitude = length(direction);
+                if (magnitude > 0.00001) clip.xy += direction / magnitude * widthPx * 2.0 / resolution * clip.w;
+                gl_Position = clip;
+            }`,
+        fragmentShader: `uniform vec3 color; void main() { gl_FragColor = vec4(color, 1.0); }`,
+    })
+    for (const mesh of meshes) {
+        const outline = new THREE.Mesh(mesh.geometry, material)
+        outline.name = 'selected-pipe-outline'
+        outline.raycast = () => {}
+        outline.renderOrder = 1
+        mesh.add(outline)
+    }
+}

+ 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')
+    }
+}

+ 199 - 58
src/modules/cockpit/services/threePipeEnhancement.js

@@ -1,6 +1,12 @@
-/** 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'
+import { pipeSurfaceMaterial, outlineSelectedPipe } from './pipeSurfaceMaterial.js'
+import { sourceDiameterM, pipeSurface } from '../domain/pipePhysical.js'
 
 function defaultRendererFactory({ canvas }) {
     return new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true })
@@ -15,54 +21,75 @@ function project([lon, lat], center, scale) {
     )
 }
 
-function bounds(path, center) {
-    let extent = 1
-    for (const point of path) {
-        const x = Math.abs((point[0] - center[0]) * 111320)
-        const z = Math.abs((point[1] - center[1]) * 111320)
-        extent = Math.max(extent, x, z)
-    }
-    return Math.min(0.025, 7 / extent)
-}
-
 function disposeObject(root) {
+    const resources = new Set()
     root.traverse((node) => {
-        if (node.geometry) node.geometry.dispose()
-        if (node.material) {
-            const materials = Array.isArray(node.material) ? node.material : [node.material]
-            materials.forEach((material) => material.dispose())
-        }
+        if (node.geometry) resources.add(node.geometry)
+        if (node.material) for (const value of [].concat(node.material)) resources.add(value)
     })
+    for (const resource of resources) resource.dispose()
 }
 
 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 = 1
+    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)
+        const diameterM = object.diameterM ?? sourceDiameterM(object.diameter)
+        const surface = object.surface || pipeSurface(object.material)
+        if (diameterM === null) {
+            const guide = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), new THREE.LineDashedMaterial({ color, dashSize: 1, gapSize: 0.6 }))
+            guide.name = 'missing-diameter-guide'
+            guide.computeLineDistances()
+            root.add(guide)
+        }
+        for (let i = 1; diameterM !== null && 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.000001) continue
+                const mesh = new THREE.Mesh(new THREE.CylinderGeometry(diameterM / 2, diameterM / 2, length, 16, 1, true), pipeSurfaceMaterial(color, surface))
+                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,
+                radius: diameterM / 2,
+                surface,
+            })
+        }
+    } 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,52 @@ function buildObject(plan) {
         if (object.renderKind === 'elbow') mesh.rotation.y = Math.PI / 2
         root.add(mesh)
     }
-    root.position.y = 0
+    if (object.primary !== false) outlineSelectedPipe(root)
+    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 frame = { center, scale: 1, 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,24 +175,37 @@ 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 sceneRadius = 1
+    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)
         camera.updateProjectionMatrix()
         renderer.setSize?.(width, height, false)
-        renderer.render?.(scene, camera)
+        render()
     }
     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,
+        )
+        root?.traverse((node) => {
+            if (node.name === 'selected-pipe-outline') node.material.uniforms.resolution.value.set(host.clientWidth || 320, host.clientHeight || 220)
+        })
+        camera.near = Math.max(0.0001, distance > sceneRadius * 1.05 ? distance - sceneRadius * 1.05 : distance / 1000)
+        camera.far = Math.max(camera.near + 1, distance + sceneRadius * 2)
+        camera.updateProjectionMatrix()
+        camera.lookAt(target)
         renderer.render?.(scene, camera)
     }
     const sync = (next) => {
@@ -137,28 +214,86 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
             scene.remove(root)
             disposeObject(root)
         }
-        root = buildObject(next)
+        root = buildNeighborhood(next)
         scene.add(root)
+        root.updateMatrixWorld(true)
+        const box = new THREE.Box3().setFromObject(root)
+        const sphere = box.getBoundingSphere(new THREE.Sphere())
+        if (!box.isEmpty()) {
+            sceneRadius = Math.max(0.01, sphere.radius)
+            target.copy(sphere.center)
+            const halfFov = Math.atan(Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * Math.min(1, camera.aspect))
+            distance = sceneRadius / Math.sin(halfFov) * 1.12
+            camera.near = Math.max(0.0001, sceneRadius / 10000)
+            camera.far = Math.max(100, sceneRadius * 100)
+            camera.updateProjectionMatrix()
+        }
         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(sceneRadius * 0.05, Math.min(sceneRadius * 50, distance * Math.exp(Math.max(-1000, Math.min(1000, event.deltaY)) * 0.001)))
+        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 +303,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 }
+})

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно