From c5147407cbb3212037bca4795288cd9c8d2b4e05 Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Tue, 23 Jun 2026 10:23:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(freecad):=20=E5=AE=8C=E5=96=84=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=B8=83=E7=BA=BF=E7=AC=AC=E4=B8=80=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FreeCAD 3D自动布线设计方案.md | 38 +- docs/FreeCAD 机柜装配操作文档.md | 86 +- src/Mod/FreeCADExchange/AutoRouting.py | 2423 ++++++++++++-- src/Mod/FreeCADExchange/AutoRoutingPanel.py | 191 +- src/Mod/FreeCADExchange/DeviceImport.py | 14 +- src/Mod/FreeCADExchange/ExchangeWriteBack.py | 141 +- .../PendingDeviceAssemblyPanel.py | 318 +- src/Mod/FreeCADExchange/RoutingNetwork.py | 358 ++- src/Mod/FreeCADExchange/TerminalImport.py | 248 +- src/Mod/FreeCADExchange/TerminalObjects.py | 23 + .../freecad_exchange_auto_routing_test.py | 2841 ++++++++++++++++- ...eecad_exchange_device_import_fcstd_test.py | 212 ++ ...nge_terminal_import_template_slots_test.py | 488 +++ tests/python/freecad_exchange_wiring_test.py | 189 +- 14 files changed, 7118 insertions(+), 452 deletions(-) diff --git a/docs/FreeCAD 3D自动布线设计方案.md b/docs/FreeCAD 3D自动布线设计方案.md index e754d98..d7b243d 100644 --- a/docs/FreeCAD 3D自动布线设计方案.md +++ b/docs/FreeCAD 3D自动布线设计方案.md @@ -147,6 +147,10 @@ terminal_uuid 点击自动布线面板中的 `检查布线准备度` 后,FreeCAD 还会在树目录 `QETWiring_05_Diagnostics` 下写入一个 `RoutingPreflight` 诊断对象。该对象会保存 `QetProjectUuid`;`QetDiagnosticOk` 表示本次诊断是否通过;`QetDiagnosticIssueCodes` 直接列出问题码;`QetDiagnosticIssueLabels` 直接列出中文问题标签;`QetDiagnosticMessage` 保存中文摘要;`QetDiagnosticJson` 保存压缩后的最新预检结果,包括导线任务数量、工程端子数量、路径网络段数、布线源摘要、柜内边界摘要、导线样式库状态、问题码 `issue_codes`、缺失端点样例等。重复检查时旧的 `RoutingPreflight` 会被替换,只保留最新一次结果。 +如果当前 FreeCAD 文档里多个工程端子对象带有同一个 `QetTerminalUuid`,预检会追加 `duplicate_3d_terminal_uuids / 3D端子UUID重复`。这表示单靠 `terminal_uuid` 已经不足以唯一定位 3D 端子,后续匹配必须依赖导线端点里的 `element_uuid`、当前 3D 设备 `instance_id`、端子显示名/脚号等上下文。当前第一阶段会按这些上下文消歧;如果导线任务缺少设备上下文,可能会进入缺端点或端子 UUID 不匹配诊断,而不是随便连到第一个同 UUID 端子。 + +预检还会对 `devices[].terminals[]` 和 `wires[]` 做一致性检查。如果 JSON 中某个设备端子没有被任何导线端点引用,会追加 `payload_terminals_without_wires / 输入端子未被导线引用`,中文报告显示“未被 wires[] 引用的端子”,并在 `QetDiagnosticJson.unreferenced_payload_terminal_samples[]` 中保留设备标签、端子显示名、端子 UUID 和端子实例 ID。这个诊断用于暴露类似 `ID:27/as` 这类“现场预期有线,但当前 wires[] 没有任务”的疑点;它不是硬错误,因为端子也可能本来就是未接线端子。若现场确认该端子确实应该接线,应把样例发给 QET 侧核对导线导出逻辑或原理图连接数据。 + `RoutingPreflight` 还会附带 compact 路径网络诊断。若已标记 `CabinetInterior`,但主路径 carrier 或工程端子越出柜内边界,预检报告会直接追加 `route_carriers_outside_boundary` 或 `terminals_outside_boundary`,并在中文摘要中给出“越界路径”或“越界端子”样例。这样用户在生成导线前就能发现装配态问题。 预检的端点缺失示例会同时显示导线标签和端子对,例如 `导线 N4111,terminal-start -> terminal-missing`。这用于第一时间判断问题来自哪条 QET 导线任务、哪个端子 UUID 没有绑定到 FreeCAD 工程端子。 @@ -206,6 +210,8 @@ QetTemplateSlotName 4. 如果同一设备下 2D 端子数量和 3D 模板槽位数量一致,允许按顺序兜底匹配,但必须写诊断提示。 5. 仍无法匹配时,保留为 `local:*` 本地端子,不参与可靠自动布线。 +这里的“端子显示名/脚号”就是 QET 设备属性窗口和“编辑接线处”里看到的端子号,对应 `2d_to_3d.json` 中的 `terminal_display`,以及导线端点里的 `start_terminal_display / end_terminal_display`。例如 QET 接线处端子号为 `1`、`2`,导出后应分别作为对应端子的 `terminal_display`;FreeCAD 用它匹配 3D 模板中的 `QetTemplateSlotName=1/2`,也用它在 `terminal_uuid` 重复时辅助判断应该连接到哪一个 3D 端子。 + #### 1.5.4 QET 需要配合提供的数据 第一版数据库仍只使用 `project_2d3d_symbol_binding` 和 `project_2d3d_terminal_binding`。交换 JSON 中当前应优先使用已有的端子显示字段作为匹配提示: @@ -221,8 +227,12 @@ QetTemplateSlotName 这些字段只作为 FreeCAD 匹配模板槽位的提示,不写入第一版绑定表,也不能替代 `terminal_uuid`。 +其中 `terminal_display` 的来源就是 QET 端子号;它是人可读的端子/接线处编号,也是 3D 模板槽位匹配和重复端子消歧的重要提示字段。 + `slot_name_hint` 只是 FreeCAD 侧预留的可选扩展字段。当前 QET 如果没有该字段,不需要为了第一版专门增加;只要 `terminal_display` / `start_terminal_display` / `end_terminal_display` 能稳定表示设备脚号,就可以完成槽位匹配。 +当前测试工程的 v2 JSON 已经暴露出一个长期边界:`terminal_uuid` 和输入侧 `terminal_instance_id` 可能重复。FreeCAD 第一阶段会短期兜底,用 `device_instance_id / element_uuid / terminal_uuid / terminal_display` 识别具体 3D 端子,并在回写时生成不重复的 `terminal_instance_id`。但是如果 QET 侧仍按 `project_2d3d_terminal_binding(project_uuid, terminal_uuid)` 作为唯一键落库,就无法完整保存同一个 `terminal_uuid` 下的多个 3D 端子绑定。第一阶段 FreeCAD 不要求立即修改 QET 代码;后续 QET 若要完整消费 `3d_to_2d.json.terminals[]`,需要提供真正唯一的 2D 端子实例标识,或明确以 `terminal_instance_id` 作为 3D 端子绑定写回键。 + QET 侧还需要保证导线任务中继续提供: ```json @@ -332,7 +342,7 @@ FreeCAD 的 `3D 布线连接` 面板提供“主路径桥接容差 mm”数值 `TerminalAccess` 定位为端子局部接入线,只用于把端子出口引到柜内主路径附近。最终导线的主路径搜索不会把 `TerminalAccess` 当作公共 transit carrier,也不会用它桥接两段线槽或 `UserPath` 的缺口;入口候选排序也会优先选择线槽、`UserPath`、过线孔等真实主路径,避免导线贴到其它端子的局部接入线上起步。这类缺口应通过线槽、`UserPath`、过线孔或主路径自动桥接来解决。 -路径网络检查会诊断异常长的 `TerminalAccess`。当端子接入段明显过长时,报告会提示“端子接入过长”,建议补设备局部路径、移动设备,或补一段 `UserPath` / 线槽靠近端子。这类诊断用于避免设备未摆放好时生成看起来悬空或穿越设备区域的接入线。 +路径网络检查会诊断异常长的 `TerminalAccess`。当端子接入段明显过长时,报告会提示“端子接入过长”,并显示接入目标路径,例如远处线槽、`UserPath` 或 `RoutingRange` 布线面。`long_terminal_accesses[]` 会保留 `target_kind / target_name / target_label / target_rule / target_distance_mm`,用于判断问题是主路径入口离端子太远、局部出线路径缺失,还是当前只能退回布线面。处理建议是补设备局部路径、移动设备,或补一段 `UserPath` / 线槽靠近端子。这类诊断用于避免设备未摆放好时生成看起来悬空或穿越设备区域的接入线。 面板还提供“并行线间距 mm”、“并行线最大偏移 mm”和“并行线方向”,用于控制多根导线共用同一路径时的可视 lane 偏移。方向默认 `auto`,也可以手动指定 `x`、`y`、`z`。最大偏移用于限制密集共路时的显示错位范围,避免 lane 序号过大时把导线显示到线槽或柜体外。这些设置只影响 3D 显示上导线之间的错位方式,不代表真实线槽截面内的排布位置。 @@ -528,7 +538,9 @@ terminal_access_max_distance = 1000.0 `terminal_exit_length` 决定端子出线段长度;`terminal_access_max_distance` 决定端子出线点到最近路由网络的最大允许接入距离,并同时约束最终导线路由入口候选,避免用户调小端子接入距离后,最终求路仍跨很远接入孤立网络。两个参数都只保存在当前 FreeCAD 面板/调用选项中,不写数据库。 -网络检查发现端子未接入时,诊断 JSON 会记录该端子到最近路由网络的距离、当前端子接入最大距离和端子出线长度;面板报告会显示当前最大接入距离,便于判断是设备/线槽位置还没摆好,还是需要临时调大接入阈值。 +网络检查发现端子未接入时,诊断 JSON 会记录该端子到最近路由网络的距离、当前端子接入最大距离和端子出线长度;同时保留 `terminal_origin`、`terminal_exit_point`、`terminal_access_points`、`nearest_network_point`、`nearest_network_carrier_kind/name/label` 和接入折线的轴向长度。这样手动验收时可以直接判断问题来自端子 LCS/出线方向、设备摆放距离、主路径入口缺失,还是最近的线槽/UserPath/过线孔已经存在但没有接到端子局部路径。面板报告会显示当前最大接入距离,便于判断是设备/线槽位置还没摆好,还是需要临时调大接入阈值。 + +网络检查和批量布线摘要也会记录端子出线校正/截断样例。`corrected_terminal_exits[]` 与 `capped_terminal_exits[]` 会保留 `origin`、`exit_point`、`exit_direction`、`original_exit_direction`、`requested_exit_length_mm`、`actual_exit_length_mm`、`max_exit_length_mm`、`device_exit_required_length_mm`、`local_route_used` 和 `local_route_point_count`。这样可以直接判断问题是默认 LCS 朝向不合适、显式方向朝向设备内部、设备包围盒过大,还是已经使用局部出线路径但局部路径本身不合理。 ### 4.4 悬空线策略 @@ -639,11 +651,11 @@ QetWiringCutOutBridgeExtensionMm = 20.0 `route_samples[]` 不是简单截取前几条导线,而是优先保留带 `issue_codes` 的问题路线;问题数量相同或没有问题时,再按原生成顺序保留。这样当一次布线有很多正常线、少量异常线时,压缩诊断对象仍会优先给出异常样例,避免手动测试复制 JSON 后看不到真正需要处理的导线。 -一键执行“生成布线连接”时,系统会在更新路径网络后附带一份 `routing_path_network_diagnostic` 摘要到批量报告中,并会按诊断建议先生成必要的 `UserPath` 桥接。脚本或调试场景直接调用 `route_eplan_connection_tasks()` 时,也会先执行同一类诊断桥接,保证任务入口和面板入口都优先尝试把孤立线槽接入端子主网络。直接从 QET payload 生成批量布线时,如果发现导线已经生成但没有使用线槽、`UserPath` 或过线孔主路径,也会自动补一次路径网络诊断,并把线槽未接入端子主网络、桥接建议等根因写回同一份批量报告。即使用户没有单独点击路径网络检查,报告也会显示“路径网络检查提示”,把空路径网络、路径对象几何无效、仅使用布线面兜底、端子局部路径无效、端子接入过长、端子越出柜内边界、路径越出柜内边界等问题带出来。如果路径源本身越出 `CabinetInterior`,批量报告会额外显示“越界路径:<路径标签> N 个越界点”,便于直接定位错误的线槽中心线或 `UserPath`。如果工程端子越出边界,批量报告会显示“越界端子:<端子对象/UUID> N 个越界点”,便于直接定位未装配到柜内的设备端子。 +一键执行“生成布线连接”时,系统会在更新路径网络后附带一份 `routing_path_network_diagnostic` 摘要到批量报告中,并会按诊断建议先生成必要的 `UserPath` 桥接。脚本或调试场景直接调用 `route_eplan_connection_tasks()` 时,也会先执行同一类诊断桥接,保证任务入口和面板入口都优先尝试把孤立线槽接入端子主网络。直接从 QET payload 生成批量布线时,如果发现导线已经生成但没有使用线槽、`UserPath` 或过线孔主路径,也会自动补一次路径网络诊断,并把线槽未接入端子主网络、桥接建议等根因写回同一份批量报告。`auto_diagnostic_bridges` 摘要会保留未接入端子接入段的目标数、生成数、重复数和配对标签;中文报告会显示“未接入端子接入段 X 个,生成 Y 条”,便于判断自动补的是端子接入段到最近路径的短桥,而不是普通线槽孤岛桥接。即使用户没有单独点击路径网络检查,报告也会显示“路径网络检查提示”,把空路径网络、路径对象几何无效、仅使用布线面兜底、端子局部路径无效、端子接入过长、端子越出柜内边界、路径越出柜内边界等问题带出来。如果路径源本身越出 `CabinetInterior`,批量报告会额外显示“越界路径:<路径标签> N 个越界点”,便于直接定位错误的线槽中心线或 `UserPath`。如果工程端子越出边界,批量报告会显示“越界端子:<端子对象/UUID> N 个越界点”,便于直接定位未装配到柜内的设备端子。 真实工程中路径 carrier 数量可能达到数百个,入口候选组合会直接影响批量布线耗时。第一版保留单根布线的 `network_entry_candidate_limit`,同时在批量布线中增加 `batch_network_entry_candidate_limit`,默认按更保守的候选数求路,避免 `入口候选 x 出口候选 x 导线数量` 过度放大。批量入口候选还增加了总量保护 `batch_network_entry_total_candidate_limit`,当前默认值为 6;它会限制单根导线最终参与组合评分的入口/出口候选总量,避免“距离候选 + 柜内候选 + 避障候选”叠加后把一次布线放大成几十次 Dijkstra 求路。缺路径重试仍可以按 `missing_route_retry_candidate_limit` 临时放宽候选数量,但正常批量路径优先受总上限保护。批量布线还会复用本次已构建的基础路径图,避免每根导线重复构建同一套网络;碰撞障碍物也会先收集成候选缓存,再按每根导线的端点设备和端点附近规则过滤,避免重复扫描数千个模型对象。当前批量默认采用性能优先的 `batch_avoid_obstacles=false`:不额外构建障碍过滤图,但仍会在生成后做碰撞诊断并输出 `collision_warnings`;需要更激进避障时再开启批量障碍过滤。相关参数会写入 `RoutingConnectionBatch.QetDiagnosticJson.batch_network_entry_candidate_limit`、`batch_network_entry_total_candidate_limit`、`batch_avoid_obstacles` 和 `batch_obstacle_candidates`,便于手测时确认当前性能保护是否生效。 -线槽接入主网络采用保守桥接策略。当前 `adjoining_duct_tolerance` 默认只允许 5mm 内的相邻端点或端点到主路径中段投影自动桥接,不会为了让线槽被使用而把远距离线槽强行接到布线面或端子接入网络。这样可以避免误把柜内无关路径连成一个错误网络。若诊断出现 `wire_ducts_without_terminal_access / 线槽未接入端子主网络`,第一版推荐用户显式添加 UserPath、线槽开口或桥接路径;诊断会在 `bridge_suggestion` 中给出建议连接的两段 carrier、最近点和距离。面板已提供 `按诊断建议生成桥接`,用于先刷新诊断再按明确建议生成桥接;也提供 `选中两路径生成桥接`,用于在用户选中的两个路径 carrier 最近点之间生成一段 `UserPath`。这两个能力都属于半自动路径网络编辑,不会扫描全柜并自动连接所有远距离线槽。对于 UserPath 端点正好落在线槽中段的 0mm 接入,路径图会把被接入的线槽段在该点切开并并网,避免视觉上已经接触但路径组件仍被诊断为孤立。 +线槽接入主网络采用保守桥接策略。当前 `adjoining_duct_tolerance` 默认只允许 5mm 内的相邻端点或端点到主路径中段投影自动桥接,不会为了让线槽被使用而把远距离线槽强行接到布线面或端子接入网络。这样可以避免误把柜内无关路径连成一个错误网络。若诊断出现 `wire_ducts_without_terminal_access / 线槽未接入端子主网络`,第一版推荐用户显式添加 UserPath、线槽开口或桥接路径;诊断会在 `bridge_suggestion` 中给出建议连接的两段 carrier、最近点和距离。面板已提供 `按诊断建议生成桥接`,用于先刷新诊断再按明确建议生成桥接;也提供 `选中两路径生成桥接`,用于在用户选中的两个路径 carrier 最近点之间生成一段 `UserPath`。这两个能力都属于半自动路径网络编辑,不会扫描全柜并自动连接所有远距离线槽。对于 `unconnected_terminals[]` 中已经明确记录 `access_carrier` 和 `nearest_network_carrier_name/label` 的样例,诊断桥接会生成 `UnconnectedTerminalAccessBridge`,把该端子的 `TerminalAccess` 接入段补到最近路径;报告会单独输出 `unconnected_terminal_access_bridge_targets`、`unconnected_terminal_access_user_path_bridges`、`unconnected_terminal_access_bridge_duplicates` 和 `unconnected_terminal_access_bridge_pair_labels`,用于审计这类桥接和普通线槽桥接、端子退回补桥的区别。这个动作仍只依赖 FreeCAD 当前几何网络,不要求 QET 提供 3D 路径。对于 UserPath 端点正好落在线槽中段的 0mm 接入,路径图会把被接入的线槽段在该点切开并并网,避免视觉上已经接触但路径组件仍被诊断为孤立。 孤立路径网络诊断只针对可行动的路径组件。线槽、UserPath、过线孔、辅助路径和端子接入如果分成多个组件,会继续输出 `isolated_network_components`;但纯 `RoutingRange` 布线面孤岛只作为兜底网格保留在 `components` 明细中,不再单独触发“存在孤立路径网络”问题码。这样可以避免真实工程中安装板/布线面网格被误当作主路径断网问题,手测时优先处理线槽、用户路径和端子局部接入。 @@ -834,7 +846,11 @@ tests/python/freecad_exchange_auto_routing_test.py 29. 自动布线支持通过 `adjoining_duct_tolerance` 调整相邻主路径端点桥接和端点到中段投影桥接容差,并在网络结果中记录桥接段数量。 30. `3D 布线连接` 面板提供“主路径桥接容差 mm”设置,面板生成/检查/布线流程会使用该值;网络检查报告会显示自动桥接段数。 31. `3D 布线连接` 面板提供“端子接入最大距离 mm”和“端子出线长度 mm”设置,用于适配真实机柜里端子离线槽远近不同、设备端子方向不同的情况。 -32. 布线路径网络检查会在端子未接入诊断中记录当前端子接入最大距离和端子出线长度,并在中文报告里显示最大接入距离。 +32. 布线路径网络检查会在端子未接入诊断中记录当前端子接入最大距离、端子出线长度、端子出口点、端子接入折线、最近网络点和最近网络对象 kind/name/label,并在中文报告里显示最大接入距离。 +32.1. 当 `TerminalAccess` 只能退回 `RoutingRange` 布线面时,路径网络诊断会记录退回目标、最近线槽/UserPath/过线孔主路径、最近主路径距离和当前最大接入距离;这些字段只来自 FreeCAD 当前几何网络,不要求 QET 提供。 +32.2. 当 `TerminalAccess` 为了避开端点设备包围盒而绕行时,路径网络诊断会记录 `endpoint_device_avoided`、`endpoint_device_bbox`、`access_points[]` 和 `access_length_mm`,用于判断接入段是否仍可能穿过设备或是否需要修正设备模板局部路径。 +32.3. `按诊断建议生成桥接` 处理 `terminal_access_fallback_targets[]` 时,会优先使用诊断样例中的 `nearest_main_path_name / nearest_main_path_label` 作为桥接目标;如果该对象在当前文档中找不到,才退回到最近主路径。这样能让“诊断看到的问题”和“自动补桥的对象”保持一致。 +32.4. `unconnected_terminals[]` 如果同时记录 `access_carrier` 和 `nearest_network_carrier_name/label`,推荐动作会提示点击 `按诊断建议生成桥接`,并生成 `UnconnectedTerminalAccessBridge` 把该端子的接入段补到最近路径;控制器报告会保留目标数、生成数、重复数和 `接入段 -> 最近路径` 配对标签,方便手动验收时确认补的是哪个端子的局部接入缺口。 33. 批量布线报告会显示路径网络自动桥接段数和主动避障屏蔽段数,方便核对调参和避障是否实际参与求路。 34. 批量布线报告会显示最大 lane 编号、lane 间距和最大偏移,方便确认多根线共路时是否发生了可视错位,以及偏移上限是否参与显示。 35. `QetRouteTrackJson` 的 carrier payload 会记录 `capacity`,方便后续分析线槽容量偏好和共路绕行。 @@ -881,7 +897,7 @@ tests/python/freecad_exchange_auto_routing_test.py 68. 面板提供“选择碰撞导线”,从最新批量诊断 `collision_samples[]` 和带 `collision_warnings` 的 `route_samples[]` 中反向选择 RoutedConnection 导线对象,便于和高发碰撞对象一起核对穿模位置。该功能只定位导线,不重新求路。 68. 面板提供“选择缺主路径导线”,从最新 `route_samples[]` 和导线对象自身的 `QetRouteIssueCodes` 中选择带 `main_path_detour_missing` 的 RoutedConnection 导线。该功能用于定位“选择性避障重算本可减少碰撞,但会退回到辅助路径/布线面兜底,因此被当前主路径优先策略拒绝”的导线;下一步应补 `UserPath`、桥接主路径、调整线槽入口或完善设备局部出线路径,不自动接受 fallback 结果。 68. 自动布线会对明确的 `main_path_detour_missing` 做一次收敛处理:当选择性避障已经得到碰撞更少的 fallback 折线,但该折线因包含 `RoutingRange` 被拒绝时,系统会把这条折线固化为 `MainPathDetourPath` 类型的 `UserPath`,再按 `兜底区域 -> 当前主路径` 生成 `MainPathDetourBridge`,随后只重试受影响导线。这样保持主路径优先,不直接接受宽泛布线面兜底,同时避免整批导线二次全量重跑。 -69. 面板提供“选择长接入端子”,从最新批量诊断 `routing_path_network_diagnostic.long_terminal_accesses[]` 中反向选择端子对象,便于检查端子高度、设备装配和局部出线路径。该功能只定位端子,不修改端子或路径数据。 +69. 面板提供“选择长接入端子”,从最新批量诊断 `routing_path_network_diagnostic.long_terminal_accesses[]` 中反向选择端子对象;当样例记录了 `access_carrier` 和 `target_name/target_label` 时,会同时选中端子的 `TerminalAccess` 接入段和目标路径,便于直接判断长接入是设备局部路径缺失、主路径入口过远,还是退回了布线面。该功能只定位对象,不修改端子或路径数据。 70. 面板提供“选择缺端子设备”,从最新批量诊断 `missing_endpoint_samples[]` 的缺失侧读取 `*_instance_id` / `*_element_uuid` 并反向选择 3D 设备,便于补工程端子或检查 2D/3D 绑定。若缺失设备不在当前场景中,控制器仍会返回 `missing_terminal_device_instance_ids[]`、`missing_terminal_device_element_uuids[]` 和可读标签,状态栏也会显示 instance_id,便于把缺设备清单交给装配/绑定流程。该功能只定位设备,不自动创建端子、不修改 QET 数据。 71. 面板提供“选择缺端子另一端”,从缺端子样例中选择已找到的另一端工程端子,便于确认失败导线本来要连接到哪里,再对照缺失侧设备和端子脚号。该功能只定位端子,不自动补端子、不写数据库。 72. 面板提供“选择缺端子候选端子”,从 `*_instance_terminal_samples` / `*_element_terminal_samples` 中反向选择同设备或同实例已有工程端子,便于排查 `terminal_uuid_not_in_element` 这类“同设备已有端子但 UUID 不匹配”的问题。该功能只定位候选端子,不自动改绑定、不写数据库。 @@ -1123,7 +1139,7 @@ QetTerminalLocalRoutePointsJson 导入/更新工程端子时,FreeCAD 会把 `local_route_points` 写入该端子的 `QetTerminalLocalRoutePointsJson`。后续自动生成 `TerminalAccess` 和最终导线几何时都会使用这段局部路径。 -路径网络检查会校验端子局部路径元数据。`QetTerminalLocalRoutePointsJson` / `QetLocalRoutePointsJson` 必须是 JSON 数组,或包含 `points` / `route_points` / `local_points` 数组的 JSON 对象,并且至少能解析出两个不同的有效点;如果 JSON 格式错误、没有可识别的点数组或有效点不足,诊断对象会记录 `invalid_terminal_local_routes`,中文报告会提示“端子局部路径无效”。这类问题不会让 FreeCAD 依赖 QET 提供 3D 路径,只是提示模板端子或工程端子的 3D 局部出线元数据需要修正。 +路径网络检查会校验端子局部路径元数据。`QetTerminalLocalRoutePointsJson` / `QetLocalRoutePointsJson` 必须是 JSON 数组,或包含 `points` / `route_points` / `local_points` 数组的 JSON 对象,并且至少能解析出两个不同的有效点;如果 JSON 格式错误、没有可识别的点数组或有效点不足,诊断对象会记录 `invalid_terminal_local_routes`,中文报告会提示“端子局部路径无效”。如果局部路径点数有效,但最后一个局部出口点仍落在端点所属设备包围盒内,诊断原因会记录为 `local_route_end_inside_device_bbox`,并保留 `local_route_end_point` 与 `endpoint_device_bbox`;实际生成 TerminalAccess 时会忽略这条无效局部路径,回退到设备感知默认出线,避免继续从设备内部接入主路径。这类问题不会让 FreeCAD 依赖 QET 提供 3D 路径,只是提示模板端子或工程端子的 3D 局部出线元数据需要修正。 如果直接在 FCStd 模板端子 LCS 上维护,也可以给模板端子写入同名属性 `QetTerminalLocalRoutePointsJson`。当前模板作者工具提供了内部函数: @@ -1300,6 +1316,14 @@ PE 线优先路径 13. 保存 FreeCAD 文档后,自动导线和路由网络仍保留。 14. 如果 `wires[].wire_style_id` 能在 `wire_properties` 中解析,生成导线会使用对应的显示颜色、线宽和线型;解析失败时诊断显示 `Missing`,但仍按默认蓝色样式生成导线。 15. “生成布线连接”后的 `RoutingConnectionBatch` 诊断对象保存最终 report,包括 `hidden_route_carriers`、`routing_path_network_updated`、路径网络检查结果和 `no_routed_connections` 等问题码。 +16. “生成布线连接”后会默认隐藏 `WireDuct` / `RoutingRange` / `TerminalAccess` 等 route carrier 辅助对象,同时强制显示 `QETWiring_04_Routed` 和 `RoutedConnection` 导线对象;批量诊断会写入 `routed_wire_visibility` 和 `route_carrier_visibility`。 +17. 如果导线带有 `wire_style_id` 或已解析的 `QetWireStyleJson`,但没有实际写入 `ViewObject` 显示样式,诊断会写入 `wire_style_application.missing_application` 并追加 `wire_styles_not_applied` 问题码。 +18. 黑色导线通过 `QetWireStyleApplied` 和 `QetAppliedWireLineColorRgb` 判断:`QetWireStyleApplied=true` 且 RGB 为 `0,0,0` 表示数据库样式本身是黑色;有样式 ID 但 `missing_application>0` 才表示样式未实际渲染。 +19. 对当前 v2 JSON 中 `terminal_uuid` 或 `terminal_instance_id` 重复的短期风险,FreeCAD 会在导入和布线预检时按 `device_instance_id / element_uuid / terminal_uuid / terminal_display / slot_name_hint` 消歧,并为重复或缺失的 `terminal_instance_id` 生成稳定 3D 端子实例 ID;该 ID 不依赖 QET 导出顺序,`3d_to_2d.json` 回写时禁止退化成设备 `device_instance_id`。 +20. 正式 QET 工程中的端子排、断路器批量装配优先走 `待装配设备 -> 批量插入同组到选中目标`,沿导轨按显示编号顺序插入真实 QET 设备并同步工程端子;`3D手动布线` 面板中的批量端子排/断路器按钮只作为旧流程或无 QET 数据时的演示兜底。 +21. 对已保存的旧工程或现场调试工程,`整理验收视图` 可以不重跑布线地隐藏 route carrier 辅助对象,并显示/重刷 `04_Routed` 导线样式;它用于 GUI 验收视图整理,不改变 QET 数据和布线路径结果。 +22. 端子导入和重导入时,FreeCAD 只把 `QETDevice_*` 设备组当作父设备;历史端子对象即使带有相同 `QetElementUuid`,也不能被误当成设备组。这样可以避免多 2D 元件共用同一 3D 设备实例时,把新端子挂到旧端子对象下面。 +23. 写回 `3d_to_2d.json` 前,如果能从传入 payload、`QET_2D_TO_3D_JSON` 或场景同目录读取当前 `2d_to_3d.json`,FreeCAD 会先按该快照同步工程端子,再收集 `instances[] / terminals[]`。保存 FCStd 时会在保存开始阶段先执行同一同步,使端子修复也进入 FreeCAD 工程文件。这样旧工程保存时也能回写完整端子绑定,例如当前测试工程应输出 `instances=86 / terminals=142`,且 `terminal_instance_id` 不重复。 ## 10. 开发验证命令 diff --git a/docs/FreeCAD 机柜装配操作文档.md b/docs/FreeCAD 机柜装配操作文档.md index 1d04455..e9b9f95 100644 --- a/docs/FreeCAD 机柜装配操作文档.md +++ b/docs/FreeCAD 机柜装配操作文档.md @@ -468,14 +468,16 @@ QET模板 -> 导入模板实例 1. 从 QET 点击 `3D视图` 打开 FreeCAD,确认树目录中已经有断路器设备。 2. 选中要安装断路器的导轨。 3. 切换到 `QET模板`。 -4. 打开 `3D手动布线`。 -5. 点击 `批量断路器`。 -6. 在 `QET断路器前缀` 中输入实际设备前缀,例如 `QF`。 -7. 输入断路器间距和起始偏移。 -8. 确认后,系统会把 QET 已导入的真实断路器沿导轨排布。 -9. 如果状态提示 `已排布 QET 断路器`,说明没有生成假设备,原有 QET 绑定仍保留。 +4. 打开 `待装配设备`。 +5. 在清单中选中同组断路器中的任意一个,例如 `QF1` 或 `QF:1`。 +6. 设置 `批量间距` 和 `起点偏移`。 +7. 点击 `批量插入同组到选中目标`。 +8. 系统会按显示编号顺序,把同一前缀的 QET 真实设备沿导轨排布,并在插入后同步工程端子。 +9. 如果只想插入单个设备,仍使用 `插入到选中目标`。 -只有当前工程没有 QET 断路器数据、只是做 3D 演示时,才使用兜底数量和兜底端子号生成本地演示对象。 +批量插入完成后,状态栏会显示本次匹配到的同组前缀、插入数量和前几个设备标签,例如 `已批量插入同组 QF 待装配设备 8 个(QF1、QF2...)`。如果只插入了 1 个,通常表示当前待装配清单中只有这个前缀的一个设备,或选中的设备标签没有和其它同组设备形成同一前缀,应先刷新清单并检查 QET 设备显示名。 + +`3D手动布线` 面板里的 `批量断路器` 属于旧流程/演示兜底:它会优先排布已有 QET 设备,但找不到匹配设备时会生成本地演示对象。正式 QET 工程优先使用 `待装配设备`,避免生成没有真实 2D 端子绑定的假设备。 常见间距: @@ -495,12 +497,14 @@ QET模板 -> 导入模板实例 2. 确认树目录中已经有 `UD`、`ID` 等端子排相关设备。 3. 选中要安装端子排的导轨。 4. 切换到 `QET模板`。 -5. 打开 `3D手动布线`。 -6. 点击 `批量端子排`。 -7. 在 `QET端子排名称/前缀` 中输入 `UD` 或 `ID`。 -8. 输入端子片间距,例如 `5.2 mm`,以及起始偏移。 -9. 确认后,系统会把匹配的 QET 真实端子片沿导轨按顺序排布。 -10. 如果状态提示 `已排布 QET 端子排`,说明工程端子和 `terminal_uuid` 没有被替换成本地端子。 +5. 打开 `待装配设备`。 +6. 在清单中选中同组端子排中的任意一个,例如 `UD:1`、`UD:8` 或 `ID:6`。 +7. 设置 `批量间距`,端子片常用 `5.2 mm`,再设置 `起点偏移`。 +8. 点击 `批量插入同组到选中目标`。 +9. 系统会按显示编号顺序,把同一前缀的 QET 真实端子片沿导轨排布,并在插入后同步工程端子。 +10. 如果只想插入单个端子片,仍使用 `插入到选中目标`。 + +状态栏会显示端子排前缀和前几个端子片标签,便于确认是否插入了正确的 `UD`、`ID` 等同组真实 QET 设备。这个批量入口不会生成本地假端子片;它只排布仍处于待装配状态的 QET 设备,并在插入后同步工程端子。 例如本仓库生成的端子片: @@ -522,6 +526,8 @@ data/examples/qet_terminal_block/qet_terminal_slice.FCStd 4. X 方向间距设为 `5.2 mm`。 5. 这种方式生成的端子通常是本地演示端子,不作为正式 QET 布线匹配主流程。 +`3D手动布线` 面板里的 `批量端子排` 仍保留,主要用于旧工程和无 QET 数据的演示兜底。第一阶段正式验收时,端子排批量装配应优先走 `待装配设备 -> 批量插入同组到选中目标`,这样 2D 端子、3D 设备和工程端子仍然来自同一份 QET v2 交换数据。 + ### 9.4 摆放电流互感器 电流互感器一般装在安装板或导轨附近。 @@ -747,7 +753,7 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。 11. 选择线槽,点击 `标记为线槽`。 12. 用 `QET模板 -> 导入模板实例` 导入断路器、互感器、端子排等 FCStd 模板。 13. 用 `Assembly` 或 `Placement` 把设备摆到导轨上。 -14. 点击 `QET模板 -> 生成工程端子`。 +14. 正常 QET 流程下,FreeCAD 会在导入 `2d_to_3d.json` 时自动生成/更新工程端子;只有预检提示缺工程端子,或设备是手工导入模板时,才点击 `QET模板 -> 生成工程端子` 兜底。 15. 打开 `3D手动布线`。 16. 选择导线任务,或手动选起点端子。 17. 沿线槽添加折点。 @@ -763,7 +769,7 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。 2. 导轨已经贴到安装板或背板上。 3. 线槽已经放到柜内,或已经用草图/Draft 线定义用户主路径。 4. QET 导入的真实设备实例已经摆到导轨或安装板上。 -5. 已点击 `生成工程端子`,工程端子能在 `QETTerminals_*` 分组中看到。 +5. 工程端子能在 `QETTerminals_*` 分组中看到;正常 QET 导入会自动生成,`生成工程端子` 只作为异常兜底按钮。 6. 如需限制导线不能跑出柜外,选择柜内空间、柜体或辅助包围盒,点击 `选中对象作为柜内边界`。 完成后按下面顺序检查: @@ -789,6 +795,12 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。 每次点击 `检查布线准备度`,树目录 `QETWiring_05_Diagnostics` 下会刷新一个 `RoutingPreflight` 诊断对象。该对象会保存 `QetProjectUuid`;`QetDiagnosticOk` 表示预检是否通过,`QetDiagnosticIssueCodes` 直接列出问题码,`QetDiagnosticIssueLabels` 直接列出中文问题标签,`QetDiagnosticMessage` 是中文摘要;展开属性中的 `QetDiagnosticJson`,可以查看缺失端点、路径源数量、柜内边界数量、路径网络诊断和导线样式库状态。这个对象只保存最新一次预检结果,避免多次测试后诊断对象堆积。 +如果报告出现 `duplicate_3d_terminal_uuids / 3D端子UUID重复`,说明当前 FreeCAD 文档里多个工程端子对象带有同一个 `QetTerminalUuid`。这不是立刻禁止布线的硬错误,但说明不能只靠端子 UUID 定位端子;FreeCAD 会继续使用导线端点的 `element_uuid`、3D 设备 `instance_id` 和端子显示名/脚号来消歧。如果后续同时出现缺端点、端子 UUID 不匹配或只接到错误设备,应优先检查 QET 导线端点是否带有 `element_uuid`,以及当前 3D 设备是否已经正确装配和绑定。 + +这里的端子显示名/脚号就是 QET 界面“编辑接线处”里的端子号,对应 `2d_to_3d.json` 的 `terminal_display`,以及 `wires[]` 端点中的 `start_terminal_display / end_terminal_display`。手测缺端子或接错端子时,可以直接用诊断里的 `*_terminal_display` 回到 QET 界面核对端子号。 + +如果报告出现 `payload_terminals_without_wires / 输入端子未被导线引用`,说明 `2d_to_3d.json` 的 `devices[].terminals[]` 中存在某个端子,但当前 `wires[]` 没有任何导线端点引用它。面板中文报告会显示“未被 wires[] 引用的端子”,诊断 JSON 的 `unreferenced_payload_terminal_samples[]` 会给出设备标签和端子显示名,例如 `ID:27/as`。这不一定是错误,因为端子可能本来未接线;如果现场确认该端子应该有线,应把这个样例交给 QET 侧检查原理图连接或 `wires[]` 导出。 + `检查布线准备度` 默认不再抽样求解导线可达性,避免真实机柜中大量设备、路径 carrier 和障碍对象导致预检长时间卡住。需要排查少量导线是否能连通时,再把面板里的 `可达性抽样` 数量从 `0` 调到 1、5 或更高;这个抽样只用于诊断,不影响正式点击 `生成布线连接` 时的全量布线。 预检阶段也会读取路径网络诊断摘要。如果已经标记 `CabinetInterior`,但工程端子或主路径 carrier 越出柜内边界,`检查布线准备度` 会显示“路径网络检查提示”,并带出“越界端子”或“越界路径”样例。这样可以在生成导线前发现装配位置、端子 LCS 或用户路径本身的问题。 @@ -805,7 +817,7 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。 面板中的 `端子接入警告距离 mm` 用于判断“端子接入过长”。设为 `0` 时按默认规则自动计算;如果当前机柜尺度较大,且 600-700mm 的端子接入属于可接受的设备局部出线,可以把该值调到 700mm 左右再检查。这个参数只影响质量告警,不会放宽 `端子接入最大距离 mm`,也不会让超过最大距离的端子强行接入。 -如果路径网络诊断包含 `unconnected_terminals`,点击 `选择未接入端子`。系统会从最新 `RoutingPathNetwork` 诊断中选择未接入路由网络、或端子出口到最近网络距离超过 `端子接入最大距离 mm` 的端子及所属设备;状态栏会显示本次样例里的最大最近网络距离。选中后先确认设备是否已经装配到柜内正确位置,再看端子附近是否缺线槽入口、过线孔、黄色 `UserPath` 或设备局部出线路径;如果装配和路径都合理,但实际柜型允许更长的局部接入,再考虑调大 `端子接入最大距离 mm`。 +如果路径网络诊断包含 `unconnected_terminals`,点击 `选择未接入端子`。系统会从最新 `RoutingPathNetwork` 诊断中选择未接入路由网络、或端子出口到最近网络距离超过 `端子接入最大距离 mm` 的端子及所属设备;如果诊断样例里有 `access_carrier`,会同时选中该端子的 `TerminalAccess` 接入段;如果诊断样例里有 `nearest_network_carrier_name/label`,也会同时选中最近的线槽、过线孔、黄色 `UserPath` 或其它路径 carrier。状态栏会显示本次样例里的最大最近网络距离、接入段数量和最近路径数量。`QetDiagnosticJson.unconnected_terminals[]` 会保存 `terminal_origin`、`terminal_exit_point`、`terminal_access_points`、`nearest_network_point`、`nearest_network_distance_mm`、`nearest_network_carrier_kind/name/label` 和接入折线的主轴长度,便于判断是端子 LCS/出线方向问题、设备摆放距离过远,还是最近线槽、过线孔、黄色 `UserPath` 或设备局部出线路径没有接上。选中后先确认设备是否已经装配到柜内正确位置,再看端子附近是否缺主路径入口;如果装配和路径都合理,但实际柜型允许更长的局部接入,再考虑调大 `端子接入最大距离 mm`。 如果有线槽但导线仍大量走布线面,优先看 `RoutingPathNetwork.QetDiagnosticIssueCodes` 是否包含 `wire_ducts_without_terminal_access / 线槽未接入端子主网络`。这个问题表示线槽已经识别成路径 carrier,但它所在的路径组件没有任何 `TerminalAccess`,导线很难自然进入线槽。中文报告会尽量显示“建议桥接到哪个主网络”和最近距离;`QetDiagnosticJson.wire_ducts_without_terminal_access[].bridge_suggestion` 会保存建议连接的两段 carrier、两个最近点和距离。处理方式是在 FreeCAD 中用 UserPath、线槽开口或桥接路径,把线槽组件接到端子接入所在的主网络,再重新生成布线路径网络和导线。 @@ -913,10 +925,12 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。 选中生成的导线对象后,可以在 `QetRouteDiagnosticsJson.endpoint_access.start_diagnostics / end_diagnostics` 中查看每侧端子的 `exit_rule`、`exit_direction_source`、`exit_direction`、`requested_exit_length_mm`、`actual_exit_length_mm`、`device_exit_required_length_mm` 和 `exit_length_capped`。如果 `exit_rule=local_route`,说明该端子正在使用 `QetTerminalLocalRoutePointsJson` 局部出线路径;如果 `exit_length_capped=true`,说明这侧端子按当前显式方向无法在合理长度内离开设备包围盒,后续容易出现端子附近悬空过长或穿模,应优先修正端子方向或给该端子设置局部出线路径。 -点击 `检查布线路径网络` 时,也会提前汇总端子出线问题。`corrected_terminal_exits[]` 表示默认 LCS 出线方向被系统自动改到最近侧向出口,通常说明设备模板端子方向还需要复查;`capped_terminal_exits[]` 表示端子按当前显式方向或默认方向无法在最大出线长度内离开设备包围盒,系统已经截断出线段。两个数组都会保留端子名、端子 UUID、父设备、原始方向、实际方向、请求长度、实际长度和上限,便于手动验收时先定位设备端子,再决定是修模板 CPoint、设置工程端子局部出线,还是补主路径入口。 +点击 `检查布线路径网络` 时,也会提前汇总端子出线问题。`corrected_terminal_exits[]` 表示默认 LCS 出线方向被系统自动改到最近侧向出口,通常说明设备模板端子方向还需要复查;`capped_terminal_exits[]` 表示端子按当前显式方向或默认方向无法在最大出线长度内离开设备包围盒,系统已经截断出线段。两个数组都会保留端子名、端子 UUID、父设备、原始方向、实际方向、端子原点 `origin`、实际出线点 `exit_point`、请求长度、实际长度、上限、是否使用局部路径 `local_route_used` 和局部路径点数,批量布线摘要也会保留这些字段。手动验收时可以先定位设备端子,再决定是修模板 CPoint、设置工程端子局部出线,还是补主路径入口。 如果 `QetTerminalExitDirectionJson` 格式错误、方向向量无法解析或方向长度为 0,路径网络诊断会额外输出 `invalid_terminal_exit_directions[]`。这种情况不会让 FreeCAD 依赖 QET 计算 3D 路径,而是明确提示当前 FreeCAD 文档或设备模板中的 CPoint 方向元数据需要修正;可以用 `选中端子设置出线方向` 重写当前工程端子的显式方向,或回到设备模板中修正后重新导入。 +如果 `QetTerminalLocalRoutePointsJson` 能解析出点,但局部路径终点仍在端点所属设备包围盒内,路径网络诊断会把该端子记入 `invalid_terminal_local_routes[]`,原因是 `local_route_end_inside_device_bbox`,并保留 `local_route_end_point` 和 `endpoint_device_bbox`。实际生成 `TerminalAccess` 时会忽略这条无效局部路径,回退到设备感知默认出线,避免继续从设备内部拉线。手动验收时应重新画一条真正离开设备外轮廓的局部路径,或修正设备模板端子 CPoint/RPoint。 + 如果要直接定位这些端子,点击 `选择出线问题端子`。系统会从最新 `RoutingPathNetwork` 诊断中合并选择 `corrected_terminal_exits[]`、`capped_terminal_exits[]`、`invalid_terminal_exit_directions[]` 和 `invalid_terminal_local_routes[]` 对应的端子及父设备;这个操作只负责定位,不会自动改端子方向或重新布线。选中后先看端子 LCS 朝向、显式 `QetTerminalExitDirectionJson`、局部路径 `QetTerminalLocalRoutePointsJson`、设备包围盒是否过大,再决定是否设置显式出线方向、设置局部出线路径或回到设备模板修正 CPoint。 每个自动生成的 `TerminalAccess` carrier 会记录接入目标:`QetTerminalAccessTargetKind / Name / Label / DistanceMm` 表示端子局部出口接到哪条线槽、`UserPath`、过线孔或面板路径;`QetTerminalAccessTargetRule` 表示选择规则,`main_path_nearest` 是直接接入最近主路径,`main_path_preferred_over_fallback` 是附近虽有 `RoutingRange` 等兜底路径但系统仍优先接入主路径,`fallback_only` 表示当前找不到线槽/UserPath/过线孔等主路径,只能退回面板路径或辅助路径。`QetTerminalAccessFallbackTarget=1` 时,应优先补线槽入口、黄色草图 `UserPath`、过线孔或设备局部路径,再重新生成布线路径网络。 @@ -925,15 +939,15 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。 点击 `检查布线路径网络` 时,诊断 JSON 也会汇总 `terminal_access_fallback_targets[]` 和 `terminal_access_endpoint_device_avoidance[]`。前者表示某些端子接入只能退回 `RoutingRange` 等兜底路径,通常需要补线槽入口、`UserPath` 或过线孔;后者表示某些端子接入段已经为了避开端点设备做了绕行,后续我进行手动验收时会优先检查这些端子附近是否缺设备局部出线路径或主路径入口。这两个数组都包含端子名、端子 UUID、父设备、`TerminalAccess` 接入段对象名、目标路径类型、目标路径对象名、`access_points[]` 和 `access_length_mm`,便于自动定位对象并判断接入段是否过长、是否绕回设备附近。 -如果要定位端子接入退回到布线面的对象,点击 `选择端子退回位置`。该按钮既能读取独立 `RoutingPathNetwork.terminal_access_fallback_targets[]`,也能读取批量布线诊断里的端子退回样例;只执行 `检查布线路径网络`、还没有生成导线时,也可以先选中端子、父设备、`TerminalAccess` 接入段和退回目标,判断应该补线槽入口、黄色 `UserPath`、过线孔还是设备局部出线路径。 +如果要定位端子接入退回到布线面的对象,点击 `选择端子退回位置`。该按钮既能读取独立 `RoutingPathNetwork.terminal_access_fallback_targets[]`,也能读取批量布线诊断里的端子退回样例;只执行 `检查布线路径网络`、还没有生成导线时,也可以先选中端子、父设备、`TerminalAccess` 接入段和退回目标,判断应该补线槽入口、黄色 `UserPath`、过线孔还是设备局部出线路径。退回样例会记录 `target_kind / target_label / target_distance_mm`,并在存在主路径时记录 `nearest_main_path_kind / nearest_main_path_label / nearest_main_path_distance_mm / terminal_access_max_distance_mm`;如果最近主路径距离大于最大接入距离,通常说明需要在端子和线槽/UserPath 之间补桥接路径,或在确认工程允许时调大 `端子接入最大距离 mm`。 -如果这些退回目标只是缺一小段到主路径的入口,可以直接点击 `按诊断建议生成桥接`。该按钮现在既能读取批量布线诊断里的 `terminal_access_fallback_target_samples[]`,也能读取刚执行 `检查布线路径网络` 后生成的 `RoutingPathNetwork.terminal_access_fallback_targets[]`,自动在退回布线面和最近线槽、`UserPath`、过线孔等主路径之间生成 `TerminalAccessFallbackBridge`。生成后重新执行 `生成布线路径网络` 或 `生成布线连接`,端子接入会优先走补出的桥接路径;如果仍然退回布线面,说明需要补更明确的主路径入口或设备局部出线路径。 +如果这些退回目标只是缺一小段到主路径的入口,可以直接点击 `按诊断建议生成桥接`。该按钮现在既能读取批量布线诊断里的 `terminal_access_fallback_target_samples[]`,也能读取刚执行 `检查布线路径网络` 后生成的 `RoutingPathNetwork.terminal_access_fallback_targets[]`,自动在退回布线面和最近线槽、`UserPath`、过线孔等主路径之间生成 `TerminalAccessFallbackBridge`。如果诊断样例里已经记录了 `nearest_main_path_name / nearest_main_path_label`,桥接会优先连接这条诊断推荐主路径;找不到推荐对象时才退回到当前文档里最近的主路径。对于 `unconnected_terminals[]` 中同时存在 `access_carrier` 和 `nearest_network_carrier_name/label` 的样例,该按钮也会在端子 `TerminalAccess` 接入段和最近路径之间生成 `UnconnectedTerminalAccessBridge`,用于补齐端子接入段到线槽/UserPath 的短缺口;这类样例存在时,汇总诊断的推荐动作会明确提示点击 `按诊断建议生成桥接`。按钮报告会单独统计 `unconnected_terminal_access_bridge_targets`、`unconnected_terminal_access_user_path_bridges`、`unconnected_terminal_access_bridge_duplicates` 和 `unconnected_terminal_access_bridge_pair_labels`,状态栏会显示“未接入端子接入段 X 个,生成 Y 条”,便于区分这是未接端子补桥,不是线槽孤岛补桥或端子退回布线面补桥。生成后重新执行 `生成布线路径网络` 或 `生成布线连接`,端子接入会优先走补出的桥接路径;如果仍然退回布线面或未接入,说明需要补更明确的主路径入口或设备局部出线路径。 -如果要直接定位端点设备避让问题,点击 `选择端点避让接入`。系统会读取最新 `RoutingPathNetwork` 诊断中的 `terminal_access_endpoint_device_avoidance[]`,选中对应端子、父设备、目标主路径和 `TerminalAccess` 接入段;这个按钮主要服务手动验收和开发侧复查,只定位对象,不重新布线、不写 QET 数据库。 +如果要直接定位端点设备避让问题,点击 `选择端点避让接入`。系统会读取最新 `RoutingPathNetwork` 诊断中的 `terminal_access_endpoint_device_avoidance[]`,选中对应端子、父设备、目标主路径和 `TerminalAccess` 接入段;这个按钮主要服务手动验收和开发侧复查,只定位对象,不重新布线、不写 QET 数据库。诊断样例会记录 `endpoint_device_avoided`、`endpoint_device_bbox`、`access_points[]` 和 `access_length_mm`;如果后续仍看到导线穿设备,可以对照这些点和包围盒判断是端子局部路径方向不合理、父设备包围盒过大,还是主路径入口仍在设备背后。 -`检查布线路径网络` 和批量布线的 `routing_path_network_diagnostic.long_terminal_accesses[]` 会保留长接入样例。样例里包含 `parent_device_label / parent_device_name`、`terminal_origin`、`terminal_access_points`、`terminal_access_dominant_axis` 和 `terminal_access_axis_lengths_mm`。如果 `terminal_access_dominant_axis` 是 `z`,且 `z` 方向长度占大头,通常表示端子点和柜内主路径平面高度差过大;优先检查该设备装配高度、端子 LCS 方向,或为该设备补局部出线路径。 +`检查布线路径网络` 和批量布线的 `routing_path_network_diagnostic.long_terminal_accesses[]` 会保留长接入样例。样例里包含 `parent_device_label / parent_device_name`、`terminal_origin`、`terminal_access_points`、`terminal_access_dominant_axis`、`terminal_access_axis_lengths_mm`,以及接入目标 `target_kind / target_label / target_distance_mm`。如果目标是远处线槽或远处 `UserPath`,优先补设备局部出线路径或把主路径入口靠近端子;如果目标是 `RoutingRange` 布线面,优先补线槽入口、过线孔或黄色 `UserPath`。如果 `terminal_access_dominant_axis` 是 `z`,且 `z` 方向长度占大头,通常表示端子点和柜内主路径平面高度差过大;优先检查该设备装配高度、端子 LCS 方向,或为该设备补局部出线路径。 -如果要快速定位这些端子,点击 `选择长接入端子`。系统会从最新批量布线诊断中的 `routing_path_network_diagnostic.long_terminal_accesses[]` 查找端子对象并选中。真实工程中类似 PEN 325-328 这类端子被选中后,可以直接检查它们是否位于异常高度、是否缺设备局部出线路径,或附近是否缺主路径入口。 +如果要快速定位这些端子,点击 `选择长接入端子`。系统会从最新批量布线诊断中的 `routing_path_network_diagnostic.long_terminal_accesses[]` 查找端子对象并选中;如果样例里记录了 `access_carrier` 和 `target_name/target_label`,会同时选中该端子的 `TerminalAccess` 接入段和目标线槽/`UserPath`/布线面。真实工程中类似 PEN 325-328 这类端子被选中后,可以直接检查它们是否位于异常高度、是否缺设备局部出线路径,或附近是否缺主路径入口。 如果要从设备角度排查,点击 `选择长接入设备`。系统会读取长接入样例里的 `parent_device_name / parent_device_label` 并选中对应设备。通常先用 `选择长接入端子` 看具体端子点,再用 `选择长接入设备` 检查该设备整体是否装配到正确高度、端子 LCS 是否随设备移动,以及设备附近是否需要补局部出线路径。 @@ -1097,7 +1111,7 @@ FreeCAD 版本/运行目录: 3. 校验端子。 4. 保存为 FCStd。 5. 回到工程场景重新导入或更新设备。 -6. 点击 `生成工程端子`。 +6. 如果设备是通过 `待装配设备` 面板插入,当前版本会在插入后自动同步工程端子;如果设备是旧流程手工导入、模板刚修改过,或诊断提示缺工程端子,再点击 `生成工程端子` 做一次补生成。 ### 15.2 为什么不能布线? @@ -1160,7 +1174,29 @@ scene.FCStd QET 侧只依赖最小绑定字段找到对应设备和端子。 -### 15.5 当前截图里的 `Gears` 应该怎么处理? +### 15.5 为什么黑色线和辅助网格要看诊断字段? + +生成布线连接后,系统会把导线显示状态和辅助路径显示状态写入 `RoutingConnectionBatch` 诊断: + +- `routed_wire_visibility`:统计 `QETWiring_04_Routed` 下导线是否可见。GUI 中如果 `hidden>0`,说明生成后仍有导线不可见。 +- `route_carrier_visibility`:统计 `WireDuct`、`RoutingRange`、`TerminalAccess` 等辅助 carrier 是否仍可见。默认应隐藏,避免手动验收时把网格误认为导线。 +- `wire_style_application`:统计有样式数据的导线是否已经实际应用到 `ViewObject`。 +- `available_terminals` / `available_terminal_objects`:统计当前 FreeCAD 工程里的实际 3D 工程端子对象数量。`unique_terminal_uuids` 只表示去重后的 `terminal_uuid` 数量;当前 QET v2 数据里同一个 `terminal_uuid` 可能对应多个设备/脚号,所以手测时应优先看 `available_terminals` 是否接近 `2d_to_3d.json` 中的端子总数。 +- `duplicate_payload_terminal_instance_id_count`:统计 `2d_to_3d.json` 输入里重复的 `terminal_instance_id` 组数。第一阶段 FreeCAD 会按设备实例、端子 UUID、端子显示名生成稳定兜底 ID,保证 `3d_to_2d.json` 不把端子 ID 退化成设备 ID;但这仍表示 QET 输入数据有重复风险,后续建议 QET 侧提供真正唯一的端子实例 ID。 + +保存或手动执行写回时,如果 FreeCAD 能找到当前 `2d_to_3d.json`,会先按 JSON 快照同步工程端子,再生成 `3d_to_2d.json`。保存流程会在写入 FCStd 前执行这一步,因此保存下来的 FreeCAD 工程也会带上端子修复结果,而不是只修正回写 JSON。因此正常 QET 流程下不需要为了回写再单独点一次 `生成工程端子`;只有旧流程手工导入模型、模板刚改过、或诊断明确提示缺工程端子时,才把 `生成工程端子` 当作兜底修复按钮。 + +端子对象本身也会带 `QetElementUuid`,但它不能作为设备父级。当前版本查找父设备时只接受 `QETDevice_*` 设备组,避免旧工程里同 UUID 端子对象被误当作设备,导致新端子挂到旧端子下面。手测时如果发现 `QETTerminal_*` 下面又出现 `QETTerminals_*` 分组,说明工程可能来自旧版本,需要重新导入或保存触发端子同步后再检查。 + +黑色导线不一定是错误。若导线对象上 `QetWireStyleApplied=true`,并且 `QetAppliedWireLineColorRgb=0,0,0`,表示 `wire_properties` 中的线色本来就是黑色;若 `wire_style_application.missing_application>0`,才表示样式没有真正渲染。 + +批量布线中文报告也会显示 `黑色导线:N 条来自 wire_properties 样式`。看到这条提示时,表示这些黑线已经成功从导线样式库解析并应用,不是默认未渲染;只有同时出现 `导线样式实际应用异常` 或 `wire_styles_not_applied` 时,才按样式渲染失败处理。 + +在 FreeCADCmd 这类无 GUI 验证环境中,可见性可能显示为 `unknown_visibility`,这是因为 headless 模式读不到 `ViewObject.Visibility`,不代表 GUI 里一定隐藏。 + +如果打开旧工程后仍能看到很多布线路径网格、`RoutingRange` 面网格或 `TerminalAccess` 辅助线,但导线已经存在于 `04_Routed` 下,可以在 `3D布线连接` 面板点击 `整理验收视图`。该按钮不会重新布线、不会删除对象、不会写数据库;它只隐藏 route carrier 辅助对象,并显示/重刷 `04_Routed` 导线和导线样式,适合手动验收前整理最终视图。 + +### 15.6 当前截图里的 `Gears` 应该怎么处理? 这是 Assembly 的齿轮约束任务,不适合机柜装配。 @@ -1181,7 +1217,7 @@ QET 侧只依赖最小绑定字段找到对应设备和端子。 4. 导轨、线槽、机柜可作为纯几何资产。 5. 正式 QET 工程中,端子排和断路器优先排布 QET 已导入的真实实例;Draft 阵列只作为无 QET 数据时的手工演示方式。 6. 每完成一段装配就保存一次 `scene.FCStd`。 -7. 布线前先生成工程端子。 +7. 布线前确认工程端子已经生成:通过 `待装配设备` 插入的设备会自动同步工程端子;旧流程手工导入或诊断提示缺端子时,再点击 `生成工程端子`。 8. 生成布线连接前先建立布线路径网络。 9. 不要手动改工程绑定 UUID。 10. 不要依赖旧 3D 场景表保存位姿。 diff --git a/src/Mod/FreeCADExchange/AutoRouting.py b/src/Mod/FreeCADExchange/AutoRouting.py index 8026b8f..70502df 100644 --- a/src/Mod/FreeCADExchange/AutoRouting.py +++ b/src/Mod/FreeCADExchange/AutoRouting.py @@ -80,7 +80,8 @@ DEFAULT_OPTIONS = { "UserPath": 1.0, "AuxiliaryPath": 2.0, "TerminalAccess": 2.0, - "RoutingRange": 40.0, + # RoutingRange 是安装板/柜内面域兜底,不应因为线槽复用惩罚而抢走主线槽。 + "RoutingRange": 1000.0, }, # 主干必须走 carrier/贴面网络;没有布线路径网络时直接失败。 # 障碍包围盒会按这个距离膨胀,用于提前发现贴碰风险。 @@ -112,6 +113,9 @@ DEFAULT_OPTIONS = { # 第一次布线若发现端子接入退回布线面/辅助路径, # 自动补一段到最近主路径的 UserPath 桥并重跑一次。 "auto_create_terminal_access_fallback_bridges": True, + # 第一次布线若发现两端已经接到线槽/UserPath 等主路径,但中段仍退回布线面, + # 自动补一段主路径目标之间的 UserPath 桥并重跑一次。 + "auto_create_main_path_target_bridges": True, } @@ -126,6 +130,31 @@ def _merged_options(options): return merged +def _route_network_cache(opts): + cache = opts.get("__route_network_cache") if isinstance(opts, dict) else None + return cache if isinstance(cache, dict) else None + + +def _invalidate_route_network_cache(opts): + cache = _route_network_cache(opts) + if cache is not None: + cache.pop("route_network", None) + + +def _cached_base_route_network(doc, opts): + cache = _route_network_cache(opts) + cached = cache.get("route_network") if cache is not None else None + if isinstance(cached, dict) and int(cached.get("segment_count", 0) or 0) > 0: + return cached, True + network = RoutingNetwork.build_route_graph( + doc, + adjoining_duct_tolerance=float((opts or {}).get("adjoining_duct_tolerance", 0.0) or 0.0), + ) + if cache is not None and int(network.get("segment_count", 0) or 0) > 0: + cache["route_network"] = network + return network, False + + def _has_route_constraints(options): opts = options or {} for key in ( @@ -977,6 +1006,181 @@ def _terminal_uuid_duplicate_summary(terminal_candidates, limit=8): } +def _payload_terminal_instance_duplicate_summary(payload, limit=8): + counts = {} + if isinstance(payload, dict): + for device in list(payload.get("devices", []) or []): + if not isinstance(device, dict): + continue + for terminal in list(device.get("terminals", []) or []): + if not isinstance(terminal, dict): + continue + terminal_instance_id = str(terminal.get("terminal_instance_id", "") or "").strip() + if not terminal_instance_id: + continue + counts[terminal_instance_id] = counts.get(terminal_instance_id, 0) + 1 + samples = [] + for terminal_instance_id, count in sorted(counts.items()): + if count <= 1: + continue + if len(samples) < limit: + samples.append( + { + "terminal_instance_id": terminal_instance_id, + "count": count, + } + ) + return { + "duplicate_payload_terminal_instance_id_count": sum( + 1 for count in counts.values() if count > 1 + ), + "duplicate_payload_terminal_instance_id_samples": samples, + } + + +def _payload_wire_endpoint_refs(payload): + refs = [] + if not isinstance(payload, dict): + return refs + for wire in list(payload.get("wires", []) or []): + if not isinstance(wire, dict): + continue + for side in ("start", "end"): + terminal_uuid = str(wire.get("{0}_terminal_uuid".format(side), "") or "").strip() + if not terminal_uuid: + continue + refs.append( + { + "side": side, + "wire_uuid": str( + wire.get("wire_id", "") + or wire.get("wire_uuid", "") + or wire.get("id", "") + or "" + ).strip(), + "wire_label": str(wire.get("wire_label", "") or wire.get("wire_mark", "") or "").strip(), + "terminal_uuid": terminal_uuid, + "element_uuid": str(wire.get("{0}_element_uuid".format(side), "") or "").strip(), + "device_instance_id": str( + wire.get("{0}_instance_id".format(side), "") + or wire.get("{0}_device_instance_id".format(side), "") + or "" + ).strip(), + "terminal_display": str( + wire.get("{0}_terminal_display".format(side), "") + or wire.get("{0}_terminal_label".format(side), "") + or "" + ).strip(), + } + ) + return refs + + +def _payload_terminal_display(terminal): + if not isinstance(terminal, dict): + return "" + return str( + terminal.get("terminal_display", "") + or terminal.get("terminal_label", "") + or terminal.get("label", "") + or terminal.get("name", "") + or "" + ).strip() + + +def _payload_endpoint_matches_terminal(endpoint, device, terminal, duplicate_terminal_uuids): + terminal_uuid = str(terminal.get("terminal_uuid", "") or "").strip() + if not terminal_uuid or endpoint.get("terminal_uuid") != terminal_uuid: + return False + endpoint_element = str(endpoint.get("element_uuid", "") or "").strip() + terminal_element = str(terminal.get("element_uuid", "") or "").strip() + if endpoint_element and terminal_element and endpoint_element != terminal_element: + return False + endpoint_instance = str(endpoint.get("device_instance_id", "") or "").strip() + device_instance = str( + device.get("device_instance_id", "") + or device.get("instance_id", "") + or "" + ).strip() + if endpoint_instance and device_instance and endpoint_instance != device_instance: + return False + endpoint_display = _normalized_match_token(endpoint.get("terminal_display", "")) + terminal_display = _normalized_match_token(_payload_terminal_display(terminal)) + if endpoint_display and terminal_display and endpoint_display != terminal_display: + return False + if terminal_uuid in duplicate_terminal_uuids and not (endpoint_element or endpoint_instance or endpoint_display): + return False + return True + + +def _payload_unreferenced_terminal_summary(payload, limit=8): + if not isinstance(payload, dict): + return { + "unreferenced_payload_terminal_count": 0, + "unreferenced_payload_terminal_samples": [], + } + terminal_uuid_counts = {} + for device in list(payload.get("devices", []) or []): + if not isinstance(device, dict): + continue + for terminal in list(device.get("terminals", []) or []): + if not isinstance(terminal, dict): + continue + terminal_uuid = str(terminal.get("terminal_uuid", "") or "").strip() + if terminal_uuid: + terminal_uuid_counts[terminal_uuid] = terminal_uuid_counts.get(terminal_uuid, 0) + 1 + duplicate_terminal_uuids = { + terminal_uuid + for terminal_uuid, count in terminal_uuid_counts.items() + if count > 1 + } + endpoints = _payload_wire_endpoint_refs(payload) + samples = [] + count = 0 + for device in list(payload.get("devices", []) or []): + if not isinstance(device, dict): + continue + device_instance_id = str( + device.get("device_instance_id", "") + or device.get("instance_id", "") + or "" + ).strip() + device_label = str( + device.get("display_tag", "") + or device.get("label", "") + or device.get("name", "") + or device_instance_id + or "" + ).strip() + for terminal in list(device.get("terminals", []) or []): + if not isinstance(terminal, dict): + continue + terminal_uuid = str(terminal.get("terminal_uuid", "") or "").strip() + if not terminal_uuid: + continue + if any( + _payload_endpoint_matches_terminal(endpoint, device, terminal, duplicate_terminal_uuids) + for endpoint in endpoints + ): + continue + count += 1 + if len(samples) < int(limit or 0): + samples.append( + { + "device_label": device_label, + "device_instance_id": device_instance_id, + "element_uuid": str(terminal.get("element_uuid", "") or "").strip(), + "terminal_uuid": terminal_uuid, + "terminal_instance_id": str(terminal.get("terminal_instance_id", "") or "").strip(), + "terminal_display": _payload_terminal_display(terminal), + } + ) + return { + "unreferenced_payload_terminal_count": count, + "unreferenced_payload_terminal_samples": samples, + } + + def _terminal_endpoint_match(terminal_candidates, item, side, allow_single_fallback=True): terminal_uuid = _wire_item_value(item, "{0}_terminal_uuid".format(side)) result = { @@ -1035,6 +1239,22 @@ def _terminal_endpoint_match(terminal_candidates, item, side, allow_single_fallb best_score = matched[0][0] best_matches = [terminal for score, terminal in matched if score == best_score] if len(best_matches) > 1: + display_token = _normalized_match_token( + _wire_item_value( + item, + "{0}_terminal_display".format(side), + "{0}_terminal_label".format(side), + ) + ) + if display_token: + display_matches = [ + terminal + for terminal in best_matches + if display_token in _terminal_match_tokens(terminal) + ] + if len(display_matches) == 1: + result["terminal"] = display_matches[0] + return result result["ambiguous"] = True result["reason_code"] = "ambiguous_terminal_uuid_context" return result @@ -1287,6 +1507,116 @@ def _wire_endpoint_entries(payload): return entries +def _payload_device_terminal_groups(payload): + groups = {} + if not isinstance(payload, dict): + return groups + for device in list(payload.get("devices", []) or []): + if not isinstance(device, dict): + continue + instance_id = ( + str(device.get("device_instance_id", "") or "").strip() + or str(device.get("instance_id", "") or "").strip() + ) + if not instance_id: + continue + for terminal in list(device.get("terminals", []) or []): + if not isinstance(terminal, dict): + continue + terminal_uuid = str(terminal.get("terminal_uuid", "") or "").strip() + if not terminal_uuid: + continue + entry = { + "instance_id": instance_id, + "terminal_uuid": terminal_uuid, + "element_uuid": str(terminal.get("element_uuid", "") or "").strip(), + "terminal_instance_id": str(terminal.get("terminal_instance_id", "") or "").strip(), + "terminal_display": str( + terminal.get("terminal_display", "") + or terminal.get("terminal_label", "") + or "" + ).strip(), + } + groups.setdefault((instance_id, terminal_uuid), []).append(entry) + return groups + + +def _pair_payload_terminal_entries_with_objects(entries, terminal_objects): + remaining_entries = list(entries or []) + remaining_objects = list(terminal_objects or []) + pairs = [] + for entry in list(remaining_entries): + display_token = _normalized_match_token(entry.get("terminal_display", "")) + if not display_token: + continue + matches = [ + terminal + for terminal in remaining_objects + if display_token in _terminal_match_tokens(terminal) + ] + if len(matches) != 1: + continue + terminal = matches[0] + pairs.append((entry, terminal)) + remaining_entries.remove(entry) + remaining_objects.remove(terminal) + pairs.extend(zip(remaining_entries, remaining_objects)) + return pairs + + +def _repair_duplicate_terminal_metadata_from_payload(doc, payload): + """Use v2 device terminal rows to repair duplicated terminal_uuid metadata. + + QET v2 snapshots may describe several physical 3D terminals with the same + terminal_uuid. When an older/imported FreeCAD scene copied the first + terminal metadata across those objects, routing cannot safely choose an + endpoint. 这里仅在“同实例、同 terminal_uuid、数量完全一致”时按显示名/顺序修复, + 不额外创建端子,避免把未知设备猜错。 + """ + if doc is None: + return {"repaired": 0, "groups": 0} + groups = _payload_device_terminal_groups(payload) + if not groups: + return {"repaired": 0, "groups": 0} + terminals = _collect_routable_terminals(doc) + order = {id(terminal): index for index, terminal in enumerate(terminals)} + repaired = 0 + repaired_groups = 0 + project_uuid = str(payload.get("project_uuid", "") or _project_uuid(doc)).strip() if isinstance(payload, dict) else _project_uuid(doc) + for (instance_id, terminal_uuid), entries in groups.items(): + if len(entries) <= 1: + continue + candidates = [ + terminal + for terminal in terminals + if _terminal_endpoint_value(terminal, "QetInstanceId") == instance_id + and _terminal_endpoint_value(terminal, "QetTerminalUuid") == terminal_uuid + ] + if len(candidates) != len(entries): + continue + candidates.sort(key=lambda terminal: order.get(id(terminal), 0)) + for entry, terminal in _pair_payload_terminal_entries_with_objects(entries, candidates): + terminal_display = entry.get("terminal_display", "") + TerminalObjects.set_terminal_semantics( + terminal, + project_uuid, + entry.get("element_uuid", ""), + terminal_uuid, + instance_id, + label=terminal_display or getattr(terminal, "Label", "") or terminal_uuid, + slot_name=terminal_display or getattr(terminal, "QetTemplateSlotName", ""), + terminal_instance_id=entry.get("terminal_instance_id", ""), + ) + repaired += 1 + repaired_groups += 1 + if repaired: + try: + doc.recompute() + except Exception: + pass + return {"repaired": repaired, "groups": repaired_groups} + + def _bind_wire_task_terminals(doc, payload): """Promote matching local template terminals to QET terminal UUIDs before routing.""" report = { @@ -2115,6 +2445,9 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non opts, _document_route_constraint_options(doc), ) + connection_point_candidate_cache = opts.get("__connection_point_candidate_cache") + if not isinstance(connection_point_candidate_cache, dict): + connection_point_candidate_cache = None exit_length = max(float(opts.get("terminal_exit_length", 0.0) or 0.0), 0.0) max_exit_length = max(float(opts.get("terminal_exit_max_length", 0.0) or 0.0), 0.0) @@ -2378,6 +2711,83 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non id(candidate.get("carrier")), ) + def terminal_access_main_path_target(access_carrier): + if access_carrier is None: + return {} + kind = str(getattr(access_carrier, "QetTerminalAccessTargetKind", "") or "").strip() + name = str(getattr(access_carrier, "QetTerminalAccessTargetName", "") or "").strip() + label = str(getattr(access_carrier, "QetTerminalAccessTargetLabel", "") or "").strip() + if kind not in {"RouteCarrier", "WireDuct", "WireDuctOpenEnd", "WiringCutOut", "UserPath"}: + return {} + if not (name or label): + return {} + return {"kind": kind, "name": name, "label": label} + + def carrier_matches_terminal_access_target(carrier, target): + if carrier is None or not isinstance(target, dict) or not target: + return False + candidate_kind = str(getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or "RouteCarrier" + if target.get("kind") and candidate_kind != target.get("kind"): + return False + names = { + str(getattr(carrier, "Name", "") or "").strip(), + str(getattr(carrier, "QetRouteSourceName", "") or "").strip(), + } + labels = { + str(getattr(carrier, "Label", "") or "").strip(), + str(getattr(carrier, "QetRouteSourceLabel", "") or "").strip(), + } + target_name = str(target.get("name", "") or "").strip() + target_label = str(target.get("label", "") or "").strip() + return bool((target_name and target_name in names) or (target_label and target_label in labels)) + + def limit_candidates_to_terminal_access_target(access_carrier, candidates): + target = terminal_access_main_path_target(access_carrier) + if not target: + return candidates, False + matched = [ + candidate + for candidate in list(candidates or []) + if carrier_matches_terminal_access_target(candidate.get("carrier"), target) + ] + # 端子接入 carrier 已经记录了“应该接入哪条主路径”。能命中时直接收窄; + # 命不中则保留原候选,避免旧工程/旧路径元数据导致完全无法布线。 + if matched: + return matched, True + return candidates, False + + def connection_point_candidates_for_route_network(route_network, point, limit=0, max_distance=0.0, cacheable=True): + if connection_point_candidate_cache is None or not cacheable: + return RoutingNetwork.connection_point_candidates( + route_network, + point, + limit=limit, + max_distance=max_distance, + ) + # 批量布线里许多导线共享同一个端子或同一条线槽入口。 + # 这里缓存“端点投影到当前路径网络的候选”,不缓存最终路径,避免影响约束/避障/线道评分。 + cache_key = ( + id(route_network), + int(route_network.get("segment_count", 0) or 0) if isinstance(route_network, dict) else 0, + len((route_network.get("nodes", {}) or {})) if isinstance(route_network, dict) else 0, + _route_point_key(_vector(point)), + round(float(max_distance or 0.0), 6), + ) + cached = connection_point_candidate_cache.get(cache_key) + if cached is None: + cached = RoutingNetwork.connection_point_candidates( + route_network, + point, + limit=0, + max_distance=max_distance, + ) + connection_point_candidate_cache[cache_key] = [dict(candidate) for candidate in cached] + candidates = [dict(candidate) for candidate in cached] + max_items = max(int(limit or 0), 0) + if max_items: + return candidates[:max_items] + return candidates + def route_candidate_access_hit_count(access_point, candidate): if access_point is None or not local_access_blocked_bboxes: return 0 @@ -2457,89 +2867,120 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non if terminal_access_limit > 0.0: max_distance = min(max_distance, terminal_access_limit) if max_distance > 0.0 else terminal_access_limit candidate_limit = max(int(opts.get("network_entry_candidate_limit", 8) or 0), 1) - start_candidates = select_ranked_entry_candidates( + start_entry_candidates = connection_point_candidates_for_route_network( network, - RoutingNetwork.connection_point_candidates( - network, - start_exit, - limit=0, - max_distance=max_distance, - ), - candidate_limit, - access_point=start_exit, + start_exit, + limit=0, + max_distance=max_distance, ) - if not start_candidates: - return None - - best_route = None - best_score = None - entry_distance_cost_factor = float( - opts.get("network_entry_distance_cost_factor", 5.0) or 0.0 + limited_start_entry_candidates, start_target_limited = limit_candidates_to_terminal_access_target( + start_terminal_access_carrier, + start_entry_candidates, + ) + uses_target_limit = ( + start_target_limited + or bool(terminal_access_main_path_target(end_terminal_access_carrier)) ) - for start_rank, start_candidate in enumerate(start_candidates, start=1): - start_network = clone_route_network(network) - start_key, start_distance, start_mode = RoutingNetwork.connect_point_candidate_to_network( - start_network, - start_candidate, + + def find_best_route(entry_candidates, limit_end_to_target=True, start_target_limited=False): + start_candidate_limit = 1 if start_target_limited else candidate_limit + start_candidates = select_ranked_entry_candidates( + network, + entry_candidates, + start_candidate_limit, + access_point=start_exit, ) - if start_key is None: - continue - end_candidates = select_ranked_entry_candidates( - start_network, - RoutingNetwork.connection_point_candidates( + if not start_candidates: + return None + + best_route = None + best_score = None + entry_distance_cost_factor = float( + opts.get("network_entry_distance_cost_factor", 5.0) or 0.0 + ) + for start_rank, start_candidate in enumerate(start_candidates, start=1): + start_network = clone_route_network(network) + start_key, start_distance, start_mode = RoutingNetwork.connect_point_candidate_to_network( + start_network, + start_candidate, + ) + if start_key is None: + continue + end_entry_candidates = connection_point_candidates_for_route_network( start_network, end_exit, limit=0, max_distance=max_distance, - ), - candidate_limit, - access_point=end_exit, - ) - for end_rank, end_candidate in enumerate(end_candidates, start=1): - working_network = clone_route_network(start_network) - end_key, end_distance, end_mode = RoutingNetwork.connect_point_candidate_to_network( - working_network, - end_candidate, - ) - if end_key is None: - continue - route_data = build_route_payload( - working_network, - start_key, - end_key, - start_distance, - end_distance, - start_mode, - end_mode, - obstacle_aware=obstacle_aware, - start_candidate_rank=start_rank, - end_candidate_rank=end_rank, - ) - if route_data is None: - continue - route_score = float( - (route_data.get("route_track", {}) or {}).get("cost", 0.0) or 0.0 - ) - route_score += ( - float(start_distance or 0.0) + float(end_distance or 0.0) - ) * entry_distance_cost_factor - obstacle_hits = route_obstacle_hit_count(route_data.get("points", [])) - route_score += obstacle_hits * float( - opts.get("route_candidate_collision_penalty", 10000.0) or 0.0 + cacheable=False, ) - boundary_violations = route_boundary_violation_count(route_data.get("points", [])) - route_score += boundary_violations * float( - opts.get("route_candidate_boundary_penalty", 100000.0) or 0.0 - ) - route_data["network"]["route_candidate_obstacle_hits"] = int(obstacle_hits) - route_data["network"]["route_candidate_boundary_violations"] = int( - boundary_violations + end_target_limited = False + if limit_end_to_target: + end_entry_candidates, end_target_limited = limit_candidates_to_terminal_access_target( + end_terminal_access_carrier, + end_entry_candidates, + ) + end_candidate_limit = 1 if end_target_limited else candidate_limit + end_candidates = select_ranked_entry_candidates( + start_network, + end_entry_candidates, + end_candidate_limit, + access_point=end_exit, ) - route_data["network"]["entry_candidate_score"] = float(route_score) - if best_score is None or route_score < best_score: - best_score = route_score - best_route = route_data - return best_route + for end_rank, end_candidate in enumerate(end_candidates, start=1): + working_network = clone_route_network(start_network) + end_key, end_distance, end_mode = RoutingNetwork.connect_point_candidate_to_network( + working_network, + end_candidate, + ) + if end_key is None: + continue + route_data = build_route_payload( + working_network, + start_key, + end_key, + start_distance, + end_distance, + start_mode, + end_mode, + obstacle_aware=obstacle_aware, + start_candidate_rank=start_rank, + end_candidate_rank=end_rank, + ) + if route_data is None: + continue + route_score = float( + (route_data.get("route_track", {}) or {}).get("cost", 0.0) or 0.0 + ) + route_score += ( + float(start_distance or 0.0) + float(end_distance or 0.0) + ) * entry_distance_cost_factor + obstacle_hits = route_obstacle_hit_count(route_data.get("points", [])) + route_score += obstacle_hits * float( + opts.get("route_candidate_collision_penalty", 10000.0) or 0.0 + ) + boundary_violations = route_boundary_violation_count(route_data.get("points", [])) + route_score += boundary_violations * float( + opts.get("route_candidate_boundary_penalty", 100000.0) or 0.0 + ) + route_data["network"]["route_candidate_obstacle_hits"] = int(obstacle_hits) + route_data["network"]["route_candidate_boundary_violations"] = int( + boundary_violations + ) + route_data["network"]["entry_candidate_score"] = float(route_score) + if best_score is None or route_score < best_score: + best_score = route_score + best_route = route_data + return best_route + + route = find_best_route( + limited_start_entry_candidates, + limit_end_to_target=True, + start_target_limited=start_target_limited, + ) + if route is not None or not uses_target_limit: + return route + # 目标主路径可能来自孤立线槽开口或旧工程缓存;目标优先失败后必须退回完整网络候选。 + return find_best_route(start_entry_candidates, limit_end_to_target=False, start_target_limited=False) use_obstacle_avoidance = bool(opts.get("avoid_obstacles", True)) use_local_access_obstacle_avoidance = bool(opts.get("avoid_local_access_obstacles", True)) @@ -2704,6 +3145,24 @@ def _object_parent_chain(obj, limit=16): return chain +def _is_route_carrier_geometry(obj): + """Return True for imported solids that only visualize generated route carriers.""" + if obj is None: + return False + for parent in _object_parent_chain(obj): + parent_name = str(getattr(parent, "Name", "") or "").strip() + if parent_name == "QETWiring_02_Carriers": + # 中文说明:线槽/UserPath 的源实体可能只是 carrier 组里的显示几何, + # 不能反过来作为障碍物,否则导线沿线槽走也会被诊断为碰撞。 + return True + try: + if RoutingNetwork.is_route_carrier(parent): + return True + except Exception: + pass + return False + + def _terminal_route_endpoint_metadata(terminal): payload = { "terminal_name": str(getattr(terminal, "Name", "") or ""), @@ -2913,6 +3372,8 @@ def _obstacle_candidate_cache(doc, options=None): for obj in list(getattr(doc, "Objects", []) or []): if _has_pass_through_obstacle_semantics(obj): continue + if _is_route_carrier_geometry(obj): + continue if _is_routing_boundary(obj): continue if _is_group(obj) or _is_origin_helper(obj): @@ -3009,6 +3470,8 @@ def collect_obstacles(doc, exclude=None, options=None): continue if _has_pass_through_obstacle_semantics(obj): continue + if _is_route_carrier_geometry(obj): + continue if _is_routing_boundary(obj): continue if _is_group(obj) or _is_origin_helper(obj): @@ -3152,6 +3615,141 @@ def _route_access_points_from_payload(payload): return points +def _route_track_points_from_payload(route_track): + segments = route_track.get("segments", []) if isinstance(route_track, dict) else [] + points = [] + for segment in list(segments or []): + if not isinstance(segment, dict): + continue + if not points: + try: + points.append(_vector(segment.get("from", {}))) + except Exception: + pass + try: + points.append(_vector(segment.get("to", {}))) + except Exception: + pass + return points + + +def _route_data_with_lane(route_data, start_terminal, end_terminal, lane_index, options=None, doc=None): + if not isinstance(route_data, dict): + return route_data + opts = _merged_options(options) + rebuilt = dict(route_data) + route_track = dict(route_data.get("route_track", {}) or {}) + carrier_points = _route_track_points_from_payload(route_track) + if not carrier_points: + rebuilt["lane"] = _lane_payload(lane_index, opts, route_points=route_data.get("points", [])) + return rebuilt + + endpoint_access = route_data.get("endpoint_access", {}) if isinstance(route_data.get("endpoint_access", {}), dict) else {} + start_access_points = _route_access_points_from_payload(endpoint_access.get("start_points", [])) + end_access_points = _route_access_points_from_payload(endpoint_access.get("end_points", [])) + exit_length = max(float(opts.get("terminal_exit_length", 0.0) or 0.0), 0.0) + start_origin = start_access_points[0] if start_access_points else _terminal_origin(start_terminal) + end_origin = end_access_points[0] if end_access_points else _terminal_origin(end_terminal) + start_exit = start_access_points[-1] if start_access_points else _offset(start_origin, _terminal_direction(start_terminal), exit_length) + end_exit = end_access_points[-1] if end_access_points else _offset(end_origin, _terminal_direction(end_terminal), exit_length) + + candidate_boundaries = collect_routing_boundaries(doc, options=opts) if doc is not None else [] + candidate_obstacles = [] + if doc is not None and ( + bool(opts.get("avoid_obstacles", True)) or bool(opts.get("avoid_local_access_obstacles", True)) + ): + candidate_options = dict(opts) + candidate_options["ignore_endpoint_near_obstacles"] = False + candidate_obstacles = collect_obstacles( + doc, + exclude=[start_terminal, end_terminal], + options=candidate_options, + ) + candidate_blocked_bboxes = [ + obstacle["bbox"] + for obstacle in candidate_obstacles + if obstacle.get("bbox") + ] + local_access_blocked_bboxes = [ + obstacle["bbox"] + for obstacle in candidate_obstacles + if obstacle.get("bbox") and _is_local_access_obstacle(obstacle) + ] + route_candidate_blocked_bboxes = ( + candidate_blocked_bboxes + if bool(opts.get("avoid_obstacles", True)) + else local_access_blocked_bboxes + ) + scan_margin = max( + float(opts.get("local_access_obstacle_scan_margin", 0.0) or 0.0), + LOCAL_ACCESS_DETOUR_CLEARANCE, + float(opts.get("obstacle_clearance", 0.0) or 0.0), + ) + + def local_access_obstacle_bboxes(start_point, end_point, preferred_axis=None): + return _local_access_obstacle_bboxes( + start_point, + end_point, + local_access_blocked_bboxes, + preferred_axis=preferred_axis, + margin=scan_margin, + ) + + def route_boundary_violation_count(points): + if not candidate_boundaries: + return 0 + return sum(1 for point in points or [] if not _point_inside_any_boundary(point, candidate_boundaries)) + + def route_obstacle_hit_count(points): + hits = 0 + if not route_candidate_blocked_bboxes: + return hits + for index in range(max(len(points or []) - 1, 0)): + start = points[index] + end = points[index + 1] + for bbox in _filter_obstacle_bboxes_near_polyline( + [start, end], + route_candidate_blocked_bboxes, + margin=scan_margin, + ): + if _segment_intersects_bbox(start, end, bbox): + hits += 1 + break + return hits + + lane = _lane_payload_boundary_aware( + lane_index, + opts, + route_points=carrier_points, + boundary_violation_count=route_boundary_violation_count, + obstacle_hit_count=route_obstacle_hit_count, + ) + shifted_carrier_points = _apply_lane_offset(carrier_points, lane) + points = [] + for point in start_access_points or [start_origin, start_exit]: + _append_unique(points, point) + _append_orthogonal( + points, + shifted_carrier_points[0], + obstacle_bboxes=local_access_obstacle_bboxes(points[-1], shifted_carrier_points[0]), + ) + for point in shifted_carrier_points[1:]: + _append_unique(points, point) + _append_orthogonal( + points, + end_exit, + obstacle_bboxes=local_access_obstacle_bboxes(points[-1], end_exit), + ) + for point in reversed(end_access_points or [end_origin, end_exit]): + _append_unique(points, point) + preserved = {_route_point_key(point) for point in shifted_carrier_points} + for access_point in list(start_access_points or []) + list(end_access_points or []): + preserved.add(_route_point_key(access_point)) + rebuilt["points"] = _simplify_collinear_points(points, preserved_point_keys=preserved) + rebuilt["lane"] = lane + return rebuilt + + def _access_collision_segment_indices(points, access_points, from_start=True): route_points = [_vector(point) for point in points or []] access_points = [_vector(point) for point in access_points or []] @@ -3397,6 +3995,41 @@ def _wire_style_draw_style(wire_style): return "Solid" +def _set_wire_style_application_metadata(wire, wire_style, line_width, line_color, draw_style): + applied = bool(isinstance(wire_style, dict) and wire_style) + _set_bool( + wire, + "QetWireStyleApplied", + applied, + "Whether the QET wire style has been applied to the visible 3D wire", + ) + _set_string( + wire, + "QetAppliedWireLineColor", + str((wire_style or {}).get("line_color", "") or ""), + "Applied QET wire line color text", + ) + _set_string( + wire, + "QetAppliedWireLineWidth", + str(line_width), + "Applied QET wire line width", + ) + _set_string( + wire, + "QetAppliedWireDrawStyle", + str(draw_style or "Solid"), + "Applied QET wire draw style", + ) + if line_color is not None: + _set_string( + wire, + "QetAppliedWireLineColorRgb", + ",".join(str(value) for value in line_color), + "Applied QET wire RGB color", + ) + + def _resolve_wire_style_from_database(wire_style_id, database_path="", project_uuid=""): style_id = str(wire_style_id or "").strip() db_path = str(database_path or "").strip() @@ -3491,17 +4124,200 @@ def resolve_wire_style(wire_style_id, options=None, project_uuid=""): def _style_wire(wire, collision_count=0, wire_style=None): + line_width = _wire_style_line_width(wire_style) or 5.0 + draw_style = _wire_style_draw_style(wire_style) + line_color = (1.0, 0.1, 0.0) if collision_count else (_wire_style_color(wire_style) or (0.0, 0.35, 1.0)) try: wire.ViewObject.Visibility = True - wire.ViewObject.LineWidth = _wire_style_line_width(wire_style) or 5.0 + wire.ViewObject.LineWidth = line_width if hasattr(wire.ViewObject, "DrawStyle"): - wire.ViewObject.DrawStyle = _wire_style_draw_style(wire_style) + wire.ViewObject.DrawStyle = draw_style if hasattr(wire.ViewObject, "DisplayMode"): wire.ViewObject.DisplayMode = "Wireframe" - if collision_count: - wire.ViewObject.LineColor = (1.0, 0.1, 0.0) - else: - wire.ViewObject.LineColor = _wire_style_color(wire_style) or (0.0, 0.35, 1.0) + wire.ViewObject.LineColor = line_color + if hasattr(wire.ViewObject, "ShapeColor"): + wire.ViewObject.ShapeColor = line_color + except Exception: + pass + _set_wire_style_application_metadata(wire, wire_style, line_width, line_color, draw_style) + + +def _wire_style_from_routed_wire(wire): + text = str(getattr(wire, "QetWireStyleJson", "") or "").strip() + if not text: + return {} + try: + payload = json.loads(text) + except Exception: + return {} + return payload if isinstance(payload, dict) else {} + + +def _routed_wire_has_collision_warning(wire): + codes = str(getattr(wire, "QetRouteIssueCodes", "") or "").lower() + return "collision" in codes + + +def _ensure_routed_wires_visible_and_styled(doc): + if doc is None: + return 0 + shown = 0 + for wire in list(WiringObjects.iter_routed_wire_objects(doc)): + if (getattr(wire, "RouteType", "") or "").strip() != "RoutedConnection": + continue + _style_wire( + wire, + collision_count=1 if _routed_wire_has_collision_warning(wire) else 0, + wire_style=_wire_style_from_routed_wire(wire), + ) + shown += 1 + routed_group = doc.getObject("QETWiring_04_Routed") + if routed_group is not None: + try: + routed_group.ViewObject.Visibility = True + except Exception: + pass + return shown + + +def apply_phase1_acceptance_view(doc): + """整理一阶段验收视图:隐藏辅助路径对象,显示并刷新最终导线。 + + 这个入口不重新生成路径、不删除对象、不写数据库,主要用于打开旧工程后把 + route carrier 调试网格收起,恢复“只看最终导线”的手动验收状态。 + """ + hidden_carriers = RoutingNetwork.set_route_carriers_visibility(doc, False) + shown_wires = _ensure_routed_wires_visible_and_styled(doc) + try: + if doc is not None: + doc.recompute() + except Exception: + pass + try: + if Gui is not None: + Gui.updateGui() + if hasattr(Gui, "SendMsgToActiveView"): + Gui.SendMsgToActiveView("ViewFit") + except Exception: + pass + return { + "hidden_route_carriers": hidden_carriers, + "shown_routed_wires": shown_wires, + "routed_wire_visibility": _routed_wire_visibility_summary(doc), + "route_carrier_visibility": _route_carrier_visibility_summary(doc, expected_hidden=True), + "wire_style_application": _wire_style_application_summary(doc), + } + + +def _object_visibility(obj): + try: + return bool(obj.ViewObject.Visibility) + except Exception: + return None + + +def _route_object_sample(obj): + return { + "name": str(getattr(obj, "Name", "") or ""), + "label": str(getattr(obj, "Label", "") or ""), + "kind": str(getattr(obj, "QetRouteCarrierKind", "") or ""), + } + + +def _routed_wire_visibility_summary(doc, limit=8): + routed = [] + for wire in list(WiringObjects.iter_routed_wire_objects(doc)): + if (getattr(wire, "RouteType", "") or "").strip() == "RoutedConnection": + routed.append(wire) + hidden = [wire for wire in routed if not _object_visibility(wire)] + unknown = [wire for wire in routed if _object_visibility(wire) is None] + hidden = [wire for wire in routed if _object_visibility(wire) is False] + group_visible = False + try: + routed_group = doc.getObject("QETWiring_04_Routed") if doc is not None else None + group_visible = _object_visibility(routed_group) if routed_group is not None else None + except Exception: + group_visible = None + return { + "routed": len(routed), + "visible": len([wire for wire in routed if _object_visibility(wire) is True]), + "hidden": len(hidden), + "unknown_visibility": len(unknown), + "group_visible": group_visible, + "hidden_samples": [_route_object_sample(wire) for wire in hidden[:limit]], + "unknown_visibility_samples": [_route_object_sample(wire) for wire in unknown[:limit]], + } + + +def _wire_style_application_summary(doc, limit=8): + routed = [ + wire + for wire in list(WiringObjects.iter_routed_wire_objects(doc)) + if (getattr(wire, "RouteType", "") or "").strip() == "RoutedConnection" + ] + expected = [] + applied = [] + missing = [] + styled_black = [] + for wire in routed: + style_id = str(getattr(wire, "QetWireStyleId", "") or "").strip() + style_json = str(getattr(wire, "QetWireStyleJson", "") or "").strip() + expects_style = bool(style_id or style_json) + is_applied = bool(getattr(wire, "QetWireStyleApplied", False)) + if expects_style: + expected.append(wire) + if is_applied: + applied.append(wire) + else: + missing.append(wire) + rgb_text = str(getattr(wire, "QetAppliedWireLineColorRgb", "") or "").strip() + # 这里用于回答“黑色线是本来黑色还是未渲染”:有应用元数据且 RGB 为 0,0,0 才算样式黑色。 + if is_applied and rgb_text in {"0,0,0", "0.0,0.0,0.0"}: + styled_black.append(wire) + return { + "routed": len(routed), + "expected": len(expected), + "applied": len(applied), + "missing_application": len(missing), + "styled_black": len(styled_black), + "missing_application_samples": [_route_object_sample(wire) for wire in missing[:limit]], + } + + +def _route_carrier_visibility_summary(doc, expected_hidden=True, limit=8): + carriers = list(RoutingNetwork.collect_route_carriers(doc)) + visible = [carrier for carrier in carriers if _object_visibility(carrier) is True] + unknown = [carrier for carrier in carriers if _object_visibility(carrier) is None] + kind_counts = {} + visible_kind_counts = {} + for carrier in carriers: + kind = str(getattr(carrier, "QetRouteCarrierKind", "") or "RouteCarrier") + kind_counts[kind] = kind_counts.get(kind, 0) + 1 + for carrier in visible: + kind = str(getattr(carrier, "QetRouteCarrierKind", "") or "RouteCarrier") + visible_kind_counts[kind] = visible_kind_counts.get(kind, 0) + 1 + return { + "expected_hidden": bool(expected_hidden), + "total": len(carriers), + "visible_after_hide": len(visible), + "unknown_visibility": len(unknown), + "kind_counts": dict(sorted(kind_counts.items())), + "visible_kind_counts": dict(sorted(visible_kind_counts.items())), + "visible_samples": [_route_object_sample(carrier) for carrier in visible[:limit]], + "unknown_visibility_samples": [_route_object_sample(carrier) for carrier in unknown[:limit]], + } + + +def _refresh_routing_view(doc): + if Gui is None: + return + try: + if getattr(App, "ActiveDocument", None) is doc: + Gui.updateGui() + except Exception: + pass + try: + Gui.SendMsgToActiveView("ViewFit") except Exception: pass @@ -3563,13 +4379,17 @@ def route_eplan_connection_between_terminals( raise AutoRoutingError("Project UUID is required for routing connections.") wire_style = resolve_wire_style(effective_wire_style_id, options=opts, project_uuid=project_uuid) - route_data = build_network_route( - start_terminal, - end_terminal, - route_index=route_index, - options=opts, - doc=doc, - ) + route_data_override = opts.get("__route_data_override") + if isinstance(route_data_override, dict): + route_data = dict(route_data_override) + else: + route_data = build_network_route( + start_terminal, + end_terminal, + route_index=route_index, + options=opts, + doc=doc, + ) if route_data is None: if _has_route_constraints(opts) or _has_route_constraints( _document_route_constraint_options(doc) @@ -4182,7 +5002,7 @@ def _context_wire_style_database_path(project_uuid="", style_ids=None, exclude_p ) -def _apply_wire_style_database_option(opts, payload): +def _apply_wire_style_database_option(opts, payload, doc=None): if not isinstance(opts, dict): return opts project_uuid = "" @@ -4205,6 +5025,13 @@ def _apply_wire_style_database_option(opts, payload): style_ids=style_ids, exclude_paths=[configured_path], ) + if not fallback_path: + fallback_path = _document_wire_style_database_path( + doc, + project_uuid=project_uuid, + style_ids=style_ids, + exclude_paths=[configured_path], + ) if fallback_path: opts["wire_style_database_fallback_from"] = configured_path opts["wire_style_database_path"] = fallback_path @@ -4212,6 +5039,14 @@ def _apply_wire_style_database_option(opts, payload): context_db_path = _context_wire_style_database_path(project_uuid=project_uuid, style_ids=style_ids) if context_db_path and not str(opts.get("wire_style_database_path", "") or "").strip(): opts["wire_style_database_path"] = context_db_path + if not str(opts.get("wire_style_database_path", "") or "").strip(): + document_db_path = _document_wire_style_database_path( + doc, + project_uuid=project_uuid, + style_ids=style_ids, + ) + if document_db_path: + opts["wire_style_database_path"] = document_db_path return opts @@ -4233,6 +5068,35 @@ def _context_exchange_json_path(): return os.environ.get("QET_2D_TO_3D_JSON", "").strip() +def _document_exchange_json_path(doc): + filename = str(getattr(doc, "FileName", "") or "").strip() + if not filename: + return "" + try: + directory = os.path.dirname(os.path.abspath(filename)) + except Exception: + return "" + if not directory: + return "" + if os.path.basename(directory).lower() == ".qet_freecad": + json_path = os.path.join(directory, "2d_to_3d.json") + if os.path.exists(json_path): + return json_path + return filename + + +def _document_wire_style_database_path(doc, project_uuid="", style_ids=None, exclude_paths=None): + json_path = _document_exchange_json_path(doc) + if not json_path: + return "" + return _discover_wire_style_database_path_from_json_path( + json_path, + project_uuid=project_uuid, + style_ids=style_ids, + exclude_paths=exclude_paths, + ) + + def _load_context_exchange_payload(): json_path = _context_exchange_json_path() if not json_path: @@ -4245,6 +5109,18 @@ def _load_context_exchange_payload(): return payload if isinstance(payload, dict) else {} +def _load_document_exchange_payload(doc): + json_path = _document_exchange_json_path(doc) + if not json_path or os.path.splitext(json_path)[1].lower() != ".json": + return {} + try: + with open(json_path, "r", encoding="utf-8") as handle: + payload = json.load(handle) + except Exception: + return {} + return payload if isinstance(payload, dict) else {} + + def _context_payload_matches_project(payload, context_payload): payload_project_uuid = "" if isinstance(payload, dict): @@ -4293,6 +5169,24 @@ def _load_context_payload_with_devices(payload): return merged +def _load_document_payload_with_devices(doc, payload): + if not isinstance(payload, dict): + return payload + if isinstance(payload.get("devices"), list) and payload.get("devices"): + return payload + json_path = _document_exchange_json_path(doc) + document_payload = _load_document_exchange_payload(doc) + devices = list(document_payload.get("devices", []) or []) if isinstance(document_payload, dict) else [] + if not devices or not _context_payload_matches_project(payload, document_payload): + return payload + # 只补设备列表,保留当前 FCStd 任务导线,避免用磁盘 JSON 覆盖用户正在测试的任务对象。 + merged = dict(payload) + merged["devices"] = devices + merged["__context_devices_json_path"] = json_path + merged["__context_device_count"] = len(devices) + return merged + + def _preflight_wire_payload(doc, payload): doc_project_uuid = _project_uuid(doc) payload_project_uuid = "" @@ -4303,9 +5197,12 @@ def _preflight_wire_payload(doc, payload): return task_payload, list(task_payload.get("wires") or []), "tasks" payload = _load_context_payload_with_wire_styles(payload) payload = _load_context_payload_with_devices(payload) + payload = _load_document_payload_with_devices(doc, payload) if isinstance(payload, dict) and isinstance(payload.get("wires"), list): return payload, list(payload.get("wires") or []), "payload" task_payload = _wire_tasks_payload(doc) + task_payload = _load_context_payload_with_devices(task_payload) + task_payload = _load_document_payload_with_devices(doc, task_payload) return task_payload, list(task_payload.get("wires") or []), "tasks" @@ -4483,13 +5380,28 @@ def _preflight_routeability_summary(doc, wires, terminals, options=None): } if sample_limit <= 0: return summary + # terminal_uuid 在当前 v2 快照里可能重复;预检抽样必须和正式布线一样, + # 优先按设备实例、2D 元件和端子显示名消歧,避免把 B1 误判成同 UUID 的 A1。 + terminal_candidates = list(opts.get("__terminal_candidates", []) or []) for item in wires or []: if not isinstance(item, dict): continue start_uuid = _wire_item_value(item, "start_terminal_uuid") end_uuid = _wire_item_value(item, "end_terminal_uuid") - start_terminal = terminals.get(start_uuid) - end_terminal = terminals.get(end_uuid) + if terminal_candidates: + start_terminal = _terminal_endpoint_match( + terminal_candidates, + item, + "start", + ).get("terminal") + end_terminal = _terminal_endpoint_match( + terminal_candidates, + item, + "end", + ).get("terminal") + else: + start_terminal = terminals.get(start_uuid) + end_terminal = terminals.get(end_uuid) if start_terminal is None or end_terminal is None: continue summary["eligible_wires"] += 1 @@ -4539,17 +5451,24 @@ def preflight_eplan_connections(doc, payload=None, options=None): if doc is None: raise AutoRoutingError("No FreeCAD document is available.") opts = _merged_options(options) + opts.setdefault("__route_network_cache", {}) source_payload, wires, source = _preflight_wire_payload(doc, payload) - _apply_wire_style_database_option(opts, source_payload) + _apply_wire_style_database_option(opts, source_payload, doc=doc) opts.setdefault("__wire_style_cache", {}) + opts.setdefault("__connection_point_candidate_cache", {}) project_uuid = str(source_payload.get("project_uuid", "") or _project_uuid(doc)).strip() + terminal_metadata_repair = _repair_duplicate_terminal_metadata_from_payload(doc, source_payload) terminals = index_terminals(doc) terminal_candidates = _collect_routable_terminals(doc) duplicate_terminal_summary = _terminal_uuid_duplicate_summary(terminal_candidates) + payload_terminal_instance_duplicates = _payload_terminal_instance_duplicate_summary(source_payload) + unreferenced_payload_terminals = _payload_unreferenced_terminal_summary(source_payload) local_terminal_count = sum( 1 - for terminal_uuid in terminals - if TerminalObjects.is_local_terminal_uuid(terminal_uuid) + for terminal in terminal_candidates + if TerminalObjects.is_local_terminal_uuid( + _terminal_endpoint_value(terminal, "QetTerminalUuid") + ) ) report = { "ok": True, @@ -4557,11 +5476,26 @@ def preflight_eplan_connections(doc, payload=None, options=None): "runtime_version": AUTO_ROUTING_RUNTIME_VERSION, "project_uuid": project_uuid, "total_wires": len(wires), - "available_terminals": len(terminals), + "available_terminals": len(terminal_candidates), "available_terminal_objects": len(terminal_candidates), + "unique_terminal_uuids": len(terminals), "local_terminals": local_terminal_count, + "repaired_duplicate_terminal_metadata": terminal_metadata_repair.get("repaired", 0), + "repaired_duplicate_terminal_metadata_groups": terminal_metadata_repair.get("groups", 0), "duplicate_terminal_uuid_count": duplicate_terminal_summary["duplicate_terminal_uuid_count"], "duplicate_terminal_uuid_samples": duplicate_terminal_summary["duplicate_terminal_uuid_samples"], + "duplicate_payload_terminal_instance_id_count": payload_terminal_instance_duplicates[ + "duplicate_payload_terminal_instance_id_count" + ], + "duplicate_payload_terminal_instance_id_samples": payload_terminal_instance_duplicates[ + "duplicate_payload_terminal_instance_id_samples" + ], + "unreferenced_payload_terminal_count": unreferenced_payload_terminals[ + "unreferenced_payload_terminal_count" + ], + "unreferenced_payload_terminal_samples": unreferenced_payload_terminals[ + "unreferenced_payload_terminal_samples" + ], "route_network_carriers": 0, "route_network_segments": 0, "route_network_nodes": 0, @@ -4657,6 +5591,34 @@ def preflight_eplan_connections(doc, payload=None, options=None): severity="warning", ) + if _safe_int(report.get("duplicate_terminal_uuid_count", 0)) > 0: + _append_preflight_issue( + report, + "duplicate_3d_terminal_uuids", + "3D工程端子 UUID 重复;FreeCAD 会优先按设备实例、2D 设备 UUID 和端子显示名消歧,缺少这些上下文时可能无法稳定匹配。", + severity="warning", + count=_safe_int(report.get("duplicate_terminal_uuid_count", 0)), + samples=report.get("duplicate_terminal_uuid_samples", []), + ) + if _safe_int(report.get("duplicate_payload_terminal_instance_id_count", 0)) > 0: + _append_preflight_issue( + report, + "duplicate_payload_terminal_instance_ids", + "2d_to_3d.json 中 terminal_instance_id 存在重复,FreeCAD 会临时生成稳定 3D 端子实例 ID 消歧。", + severity="warning", + count=_safe_int(report.get("duplicate_payload_terminal_instance_id_count", 0)), + samples=report.get("duplicate_payload_terminal_instance_id_samples", []), + ) + if _safe_int(report.get("unreferenced_payload_terminal_count", 0)) > 0: + _append_preflight_issue( + report, + "payload_terminals_without_wires", + "2d_to_3d.json 中存在没有被任何 wires[] 端点引用的设备端子;这可能是未接线端子,也可能是 QET 少导出了导线任务。", + severity="warning", + count=_safe_int(report.get("unreferenced_payload_terminal_count", 0)), + samples=report.get("unreferenced_payload_terminal_samples", []), + ) + missing_endpoint_uuids = set() for item in wires: if not isinstance(item, dict): @@ -4705,7 +5667,9 @@ def preflight_eplan_connections(doc, payload=None, options=None): report["missing_endpoint_uuids"] = sorted(missing_endpoint_uuids) if report["route_network_segments"] > 0: - routeability = _preflight_routeability_summary(doc, wires, terminals, options=opts) + routeability_options = dict(opts) + routeability_options["__terminal_candidates"] = terminal_candidates + routeability = _preflight_routeability_summary(doc, wires, terminals, options=routeability_options) report["routeability_checked"] = int(routeability.get("checked", 0) or 0) report["routeability_sample_limit"] = int(routeability.get("sample_limit", 0) or 0) report["routeability_eligible_wires"] = int(routeability.get("eligible_wires", 0) or 0) @@ -4932,6 +5896,27 @@ def format_eplan_routing_preflight_report(report): parts.append("{0} {1} 条".format(label, value)) if parts: message += "\n导线样式:{0}。".format(",".join(parts)) + duplicate_terminal_uuid_count = _safe_int(report.get("duplicate_terminal_uuid_count", 0)) + if duplicate_terminal_uuid_count > 0: + message += "\n3D工程端子 UUID 重复:{0} 组".format(duplicate_terminal_uuid_count) + samples = [item for item in list(report.get("duplicate_terminal_uuid_samples", []) or []) if isinstance(item, dict)] + if samples: + sample = samples[0] + message += ",示例 {0} 出现 {1} 次".format( + sample.get("terminal_uuid", "未知端子"), + _safe_int(sample.get("count", 0)), + ) + message += ";需要依赖设备实例、2D 设备 UUID 或端子显示名消歧。" + unreferenced_count = _safe_int(report.get("unreferenced_payload_terminal_count", 0)) + if unreferenced_count > 0: + message += "\n未被 wires[] 引用的端子:{0} 个".format(unreferenced_count) + samples = [item for item in list(report.get("unreferenced_payload_terminal_samples", []) or []) if isinstance(item, dict)] + if samples: + sample = samples[0] + device_text = str(sample.get("device_label", "") or sample.get("device_instance_id", "") or "未知设备") + terminal_text = str(sample.get("terminal_display", "") or sample.get("terminal_uuid", "") or "未知端子") + message += ",示例 {0}/{1}".format(device_text, terminal_text) + message += "。" issues = [item for item in list(report.get("issues", []) or []) if isinstance(item, dict)] if issues: message += "\n预检问题:{0}。".format( @@ -4987,13 +5972,17 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la payload = _load_context_payload_with_wire_styles(payload) payload = _load_context_payload_with_devices(payload) + payload = _load_document_payload_with_devices(doc, payload) opts = _merged_options(options) - _apply_wire_style_database_option(opts, payload) + _apply_wire_style_database_option(opts, payload, doc=doc) opts.setdefault("__wire_style_cache", {}) + opts.setdefault("__connection_point_candidate_cache", {}) + terminal_metadata_repair = _repair_duplicate_terminal_metadata_from_payload(doc, payload) terminal_binding_report = bind_wire_task_terminals_from_payload(doc, payload) terminals = index_terminals(doc) terminal_candidates = _collect_routable_terminals(doc) duplicate_terminal_summary = _terminal_uuid_duplicate_summary(terminal_candidates) + payload_terminal_instance_duplicates = _payload_terminal_instance_duplicate_summary(payload) local_terminal_count = sum( 1 for terminal in terminal_candidates @@ -5008,11 +5997,20 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la "project_uuid": project_uuid_value, "runtime_version": AUTO_ROUTING_RUNTIME_VERSION, "total_wires": len(wires), - "available_terminals": len(terminals), + "available_terminals": len(terminal_candidates), "available_terminal_objects": len(terminal_candidates), + "unique_terminal_uuids": len(terminals), "local_terminals": local_terminal_count, + "repaired_duplicate_terminal_metadata": terminal_metadata_repair.get("repaired", 0), + "repaired_duplicate_terminal_metadata_groups": terminal_metadata_repair.get("groups", 0), "duplicate_terminal_uuid_count": duplicate_terminal_summary["duplicate_terminal_uuid_count"], "duplicate_terminal_uuid_samples": duplicate_terminal_summary["duplicate_terminal_uuid_samples"], + "duplicate_payload_terminal_instance_id_count": payload_terminal_instance_duplicates[ + "duplicate_payload_terminal_instance_id_count" + ], + "duplicate_payload_terminal_instance_id_samples": payload_terminal_instance_duplicates[ + "duplicate_payload_terminal_instance_id_samples" + ], "auto_bound_terminals": terminal_binding_report["bound"], "auto_created_terminals": terminal_binding_report["created"], "auto_terminal_binding_warnings": terminal_binding_report["warnings"], @@ -5071,14 +6069,25 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la } if isinstance(prepared_layout, dict): report["prepared_layout"] = prepared_layout - try: - route_network = RoutingNetwork.build_route_graph( - doc, - adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), - ) - except Exception as exc: - route_network = {} - report["route_network_error"] = str(exc) + route_network = {} + route_network_reused = False + cache = _route_network_cache(opts) + cached_route_network = cache.get("route_network") if cache is not None else None + if isinstance(cached_route_network, dict) and int(cached_route_network.get("segment_count", 0) or 0) > 0: + route_network = cached_route_network + route_network_reused = True + else: + try: + route_network = RoutingNetwork.build_route_graph( + doc, + adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), + ) + if cache is not None and int(route_network.get("segment_count", 0) or 0) > 0: + cache["route_network"] = route_network + except Exception as exc: + route_network = {} + report["route_network_error"] = str(exc) + report["route_network_reused"] = bool(route_network_reused) report["route_network_carriers"] = int(route_network.get("carrier_count", 0) or 0) report["route_network_segments"] = int(route_network.get("segment_count", 0) or 0) report["route_network_nodes"] = len(route_network.get("nodes", {}) or {}) @@ -5169,6 +6178,8 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la route_options["avoid_obstacles"] = bool(item.get("__avoid_obstacles_override")) if isinstance(item, dict) and "__replace_existing_override" in item: route_options["replace_existing"] = bool(item.get("__replace_existing_override")) + if isinstance(item, dict) and "__route_data_override" in item: + route_options["__route_data_override"] = item.get("__route_data_override") if isinstance(route_network, dict) and route_network.get("segment_count", 0) > 0: route_options["__base_route_network"] = route_network route_options["__obstacle_candidate_cache"] = obstacle_candidate_cache @@ -5327,10 +6338,24 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la final_lane_index = max(route_lane_index, shared_lane_index) if final_lane_index != route_lane_index: initial_wire = result.get("wire") if isinstance(result, dict) else None + lane_route_data = _route_data_with_lane( + result, + start_terminal, + end_terminal, + final_lane_index, + options=opts, + doc=doc, + ) + if initial_wire is not None: + _remove_routing_connection_objects(doc, [initial_wire]) try: result = create_route( final_lane_index, - dict(item, __segment_usage_costs=segment_usage_costs), + dict( + item, + __segment_usage_costs=segment_usage_costs, + __route_data_override=lane_route_data, + ), start_terminal, end_terminal, endpoint_metadata, @@ -6050,6 +7075,15 @@ def _route_capacity_pressure_samples(report, limit=8): return samples +def _show_candidate_debug_warnings(report): + if not isinstance(report, dict): + return False + return bool( + report.get("show_candidate_debug_warnings") + or report.get("show_route_debug_warnings") + ) + + _ROUTE_QUALITY_WARNING_KIND_LABELS = { "RoutingRange": "布线面", "AuxiliaryPath": "辅助路径", @@ -7386,6 +8420,26 @@ def format_eplan_connection_route_report(report): created_count = _safe_int(auto_bridges.get("created_count", 0)) if created_count > 0: message += "\n自动诊断桥接:生成 UserPath {0} 条。".format(created_count) + unconnected_targets = _safe_int( + auto_bridges.get("unconnected_terminal_access_bridge_targets", 0) + ) + unconnected_created = _safe_int( + auto_bridges.get("unconnected_terminal_access_user_path_bridges", 0) + ) + if unconnected_targets > 0 or unconnected_created > 0: + message += " 未接入端子接入段 {0} 个,生成 {1} 条。".format( + unconnected_targets, + unconnected_created, + ) + pair_labels = [ + str(label or "").strip() + for label in list( + auto_bridges.get("unconnected_terminal_access_bridge_pair_labels", []) or [] + ) + if str(label or "").strip() + ] + if pair_labels: + message += " 配对:{0}。".format("、".join(pair_labels[:3])) auto_detour_bridges = report.get("auto_main_path_detour_bridges", {}) if isinstance(auto_detour_bridges, dict): created_count = _safe_int(auto_detour_bridges.get("created_count", 0)) @@ -7480,6 +8534,35 @@ def format_eplan_connection_route_report(report): hidden_route_carriers = int(report.get("hidden_route_carriers", 0) or 0) if hidden_route_carriers > 0: message += "\n已隐藏走线路径辅助对象:{0} 条。".format(hidden_route_carriers) + duplicate_payload_terminal_instances = _safe_int( + report.get("duplicate_payload_terminal_instance_id_count", 0) + ) + if duplicate_payload_terminal_instances > 0: + message += "\n输入端子实例ID重复:{0} 组,已按设备实例/端子显示名在 FreeCAD 侧临时消歧。".format( + duplicate_payload_terminal_instances + ) + wire_visibility = report.get("routed_wire_visibility", {}) + if isinstance(wire_visibility, dict): + hidden_wires = _safe_int(wire_visibility.get("hidden", 0)) + if hidden_wires > 0: + message += "\n导线可见性异常:{0} 条 RoutedConnection 仍不可见。".format(hidden_wires) + style_application = report.get("wire_style_application", {}) + if isinstance(style_application, dict): + missing_application = _safe_int(style_application.get("missing_application", 0)) + styled_black = _safe_int(style_application.get("styled_black", 0)) + if styled_black > 0: + message += "\n黑色导线:{0} 条来自 wire_properties 样式,属于已解析并已应用的黑色线。".format( + styled_black + ) + if missing_application > 0: + message += "\n导线样式实际应用异常:{0} 条导线有样式 ID/样式数据但未渲染到 ViewObject。".format( + missing_application + ) + carrier_visibility = report.get("route_carrier_visibility", {}) + if isinstance(carrier_visibility, dict) and bool(carrier_visibility.get("expected_hidden")): + visible_carriers = _safe_int(carrier_visibility.get("visible_after_hide", 0)) + if visible_carriers > 0: + message += "\n辅助路径显示异常:{0} 条 route carrier 仍可见。".format(visible_carriers) bridged_segments = _route_network_metric_max(report, "bridged_segments") blocked_segments = _route_network_metric_max(report, "blocked_segments") network_parts = [] @@ -7499,7 +8582,7 @@ def format_eplan_connection_route_report(report): if max_offset > 0.0: lane_text += ",最大偏移 {0:.1f} mm".format(max_offset) message += "\n{0}。".format(lane_text) - capacity_pressure = _route_capacity_pressure_summary(report) + capacity_pressure = _route_capacity_pressure_summary(report) if _show_candidate_debug_warnings(report) else {} if capacity_pressure: message += "\n容量提示:最大并行线数 {0},路径最小容量 {1}。".format( capacity_pressure.get("max_parallel_wires", 0), @@ -7595,7 +8678,11 @@ def format_eplan_connection_route_report(report): ",".join(sample.get("warning_parts", []) or []), route_text, ) - obstacle_entry_warning = _route_candidate_obstacle_warning_summary(report) + obstacle_entry_warning = ( + _route_candidate_obstacle_warning_summary(report) + if _show_candidate_debug_warnings(report) + else {} + ) if obstacle_entry_warning: sample = obstacle_entry_warning.get("sample", {}) route_text = "" @@ -7835,6 +8922,30 @@ def _compact_routing_preflight_report(report, sample_limit=8): missing_samples = list(report.get("missing_endpoint_samples", []) or []) payload["missing_endpoint_samples"] = missing_samples[:limit] payload["missing_endpoint_samples_count"] = len(missing_samples) + payload["duplicate_terminal_uuid_count"] = _safe_int( + report.get("duplicate_terminal_uuid_count", 0) + ) + duplicate_terminal_uuid_samples = list(report.get("duplicate_terminal_uuid_samples", []) or []) + payload["duplicate_terminal_uuid_samples"] = duplicate_terminal_uuid_samples[:limit] + payload["duplicate_terminal_uuid_samples_count"] = len(duplicate_terminal_uuid_samples) + payload["duplicate_payload_terminal_instance_id_count"] = _safe_int( + report.get("duplicate_payload_terminal_instance_id_count", 0) + ) + duplicate_payload_terminal_instance_id_samples = list( + report.get("duplicate_payload_terminal_instance_id_samples", []) or [] + ) + payload["duplicate_payload_terminal_instance_id_samples"] = ( + duplicate_payload_terminal_instance_id_samples[:limit] + ) + payload["duplicate_payload_terminal_instance_id_samples_count"] = len( + duplicate_payload_terminal_instance_id_samples + ) + payload["unreferenced_payload_terminal_count"] = _safe_int( + report.get("unreferenced_payload_terminal_count", 0) + ) + unreferenced_samples = list(report.get("unreferenced_payload_terminal_samples", []) or []) + payload["unreferenced_payload_terminal_samples"] = unreferenced_samples[:limit] + payload["unreferenced_payload_terminal_samples_count"] = len(unreferenced_samples) unrouteable_samples = list(report.get("unrouteable_samples", []) or []) payload["unrouteable_samples"] = unrouteable_samples[:limit] payload["unrouteable_samples_count"] = len(unrouteable_samples) @@ -7850,6 +8961,10 @@ def _compact_routing_preflight_report(report, sample_limit=8): value = report.get(key) if isinstance(value, dict): payload[key] = dict(value) + payload["issue_labels"] = [ + _routing_diagnostic_issue_label(code) + for code in payload.get("issue_codes", []) + ] payload["diagnostic_payload"] = "compact-routing-preflight-v1" return payload @@ -7973,6 +9088,9 @@ _ROUTING_DIAGNOSTIC_ISSUE_LABELS = { "route_candidate_obstacle_hits": "候选路径碰撞风险", "route_candidate_boundary_violations": "候选路径越出柜内边界", "route_capacity_pressure": "路径容量压力", + "routed_wires_not_visible": "导线生成后不可见", + "wire_styles_not_applied": "导线样式未实际应用", + "route_carriers_still_visible": "辅助路径对象仍可见", "diagnostic_json_empty": "诊断 JSON 为空", "diagnostic_json_invalid": "诊断 JSON 无效", "routed_wire_diagnostics_missing": "导线诊断缺失", @@ -7982,6 +9100,9 @@ _ROUTING_DIAGNOSTIC_ISSUE_LABELS = { "no_3d_terminals_for_element": "设备缺少工程端子", "no_3d_terminals_for_instance": "实例缺少工程端子", "terminal_uuid_not_in_element": "端子UUID不匹配", + "duplicate_3d_terminal_uuids": "3D端子UUID重复", + "duplicate_payload_terminal_instance_ids": "输入端子实例ID重复", + "payload_terminals_without_wires": "输入端子未被导线引用", } @@ -8437,6 +9558,24 @@ def _routing_diagnostic_recommended_actions(summary): add("按端子接入退回布线面示例定位设备侧缺口,再重新生成布线路径网络") if "unconnected_terminals" in issue_codes: add("点击“选择未接入端子”定位未接入路由网络或接入距离超限的端子") + diagnostics = summary.get("diagnostics", {}) or {} + path_payload = ((diagnostics.get("RoutingPathNetwork", {}) or {}).get("payload", {}) or {}) + unconnected_samples = ( + list(path_payload.get("unconnected_terminals", []) or []) + if isinstance(path_payload, dict) + else [] + ) + has_bridgeable_unconnected = any( + isinstance(sample, dict) + and str(sample.get("access_carrier", "") or "").strip() + and ( + str(sample.get("nearest_network_carrier_name", "") or "").strip() + or str(sample.get("nearest_network_carrier_label", "") or "").strip() + ) + for sample in unconnected_samples + ) + if has_bridgeable_unconnected: + add("点击“按诊断建议生成桥接”尝试自动补未接入端子接入段到最近路径的 UserPath 桥") add("补端子附近 UserPath/线槽入口,或确认设备装配位置和端子接入最大距离") if ( "terminal_exit_direction_corrected" in issue_codes @@ -9048,6 +10187,10 @@ def _routing_connection_batch_issue_codes(report): "terminal_uuid_not_in_element", _safe_int(missing_endpoint_reason_counts.get("terminal_uuid_not_in_element", 0)) > 0, ), + ( + "duplicate_payload_terminal_instance_ids", + _safe_int(report.get("duplicate_payload_terminal_instance_id_count", 0)) > 0, + ), ( "missing_route_network", _safe_int(report.get("skipped_missing_route_network", 0)) > 0, @@ -9086,20 +10229,28 @@ def _routing_connection_batch_issue_codes(report): "long_terminal_access", bool(_long_network_entry_warning_samples(report, limit=1)), ), - ( - "route_candidate_obstacle_hits", - bool(_route_candidate_obstacle_warning_samples(report, limit=1)), - ), ( "route_candidate_boundary_violations", bool(_route_candidate_boundary_warning_samples(report, limit=1)), ), ( - "route_capacity_pressure", - bool(_route_capacity_pressure_samples(report, limit=1)), + "routed_wires_not_visible", + _safe_int((report.get("routed_wire_visibility") or {}).get("hidden", 0)) > 0, + ), + ( + "wire_styles_not_applied", + _safe_int((report.get("wire_style_application") or {}).get("missing_application", 0)) > 0, + ), + ( + "route_carriers_still_visible", + bool((report.get("route_carrier_visibility") or {}).get("expected_hidden")) + and _safe_int((report.get("route_carrier_visibility") or {}).get("visible_after_hide", 0)) > 0, ), ) - return [code for code, enabled in checks if enabled] + issue_codes = [code for code, enabled in checks if enabled] + # 候选避障命中与容量压力用于算法调试/质量观察;只要最终导线已生成且无真实碰撞, + # 它们不应让批量诊断失败,否则手动验收会把“候选评分过程”误读成最终布线问题。 + return issue_codes def _routed_route_issue_summary_from_report(report): @@ -9285,39 +10436,195 @@ def _find_route_bridge_sources_by_name_or_label(doc, name="", label=""): return refs -def _create_main_path_detour_bridges_from_report(doc, report, project_uuid=""): - detour_summary = report.get("main_path_detour_missing_summary", {}) if isinstance(report, dict) else {} - pair_counts = detour_summary.get("bridge_pair_counts", {}) if isinstance(detour_summary, dict) else {} - if not isinstance(pair_counts, dict): - pair_counts = {} +def _route_bridge_label(source, carrier, fallback): + return ( + getattr(source, "QetRouteSourceLabel", "") + or getattr(source, "Label", "") + or getattr(carrier, "QetRouteSourceLabel", "") + or getattr(carrier, "Label", "") + or getattr(carrier, "Name", "") + or fallback + ) - created = [] - missing_pairs = [] - duplicates = 0 - for pair_text, _count in sorted(pair_counts.items(), key=lambda item: (-_safe_int(item[1]), str(item[0]))): - pair_text = str(pair_text or "").strip() - if " -> " not in pair_text: - continue - left_label, right_label = [part.strip() for part in pair_text.split(" -> ", 1)] - if not left_label or not right_label: - continue - left_matches = _find_route_bridge_sources_by_name_or_label(doc, name=left_label, label=left_label) - right_matches = _find_route_bridge_sources_by_name_or_label(doc, name=right_label, label=right_label) - if not left_matches or not right_matches: - missing_pairs.append(pair_text) - continue - new_bridges = RoutingNetwork.create_user_path_bridge_between_objects( - doc, - left_matches[0], - right_matches[0], - project_uuid=project_uuid, - ) - if new_bridges: - created.extend(new_bridges) - else: - duplicates += 1 - return { +def _is_auto_ignorable_unbound_structural_obstacle(obstacle): + if not isinstance(obstacle, dict): + return False + if ( + str(obstacle.get("element_uuid", "") or "").strip() + or str(obstacle.get("instance_id", "") or "").strip() + ): + return False + parent_refs = obstacle.get("parent_refs", {}) if isinstance(obstacle.get("parent_refs", {}), dict) else {} + own_text = " ".join( + str(part or "").lower() + for part in [ + obstacle.get("label", ""), + obstacle.get("name", ""), + ] + ) + if any(keyword in own_text for keyword in _DEVICE_COLLISION_KEYWORDS): + return False + text_parts = [ + obstacle.get("label", ""), + obstacle.get("name", ""), + ] + text_parts.extend(list(parent_refs.get("labels", []) or [])) + text_parts.extend(list(parent_refs.get("names", []) or [])) + text = " ".join(str(part or "").lower() for part in text_parts) + return any(keyword in text for keyword in _STRUCTURAL_COLLISION_KEYWORDS) + + +def _route_bridge_obstacles(doc, left_source, right_source, left_carrier, right_carrier): + options = { + "obstacle_clearance": float(DEFAULT_OPTIONS.get("obstacle_clearance", 5.0) or 0.0), + "ignore_endpoint_near_obstacles": False, + } + obstacles = collect_obstacles( + doc, + exclude=[left_source, right_source, left_carrier, right_carrier], + options=options, + ) + if not bool(DEFAULT_OPTIONS.get("auto_ignore_unbound_structural_obstacles", True)): + return obstacles + # 桥接路径在柜内生成,未绑定的柜体/安装框 AABB 往往包住整柜; + # 它们不能阻止线槽/UserPath 之间的局部连通,但真实设备仍保留为硬障碍。 + return [ + obstacle + for obstacle in obstacles + if not _is_auto_ignorable_unbound_structural_obstacle(obstacle) + ] + + +def _bridge_points_avoiding_obstacles(left_point, right_point, obstacles): + bboxes = [item.get("bbox") for item in list(obstacles or []) if isinstance(item.get("bbox"), dict)] + if not bboxes: + return [left_point, right_point] + # 自动桥接是布线路径网络的一部分,不能为了连通两条线槽而直接穿过设备。 + points = _orthogonal_points_avoiding_obstacles(left_point, right_point, bboxes) + if detect_collisions(points, obstacles): + return [] + return _simplify_collinear_points(points) + + +def _create_user_path_bridge_between_objects_avoiding_obstacles( + doc, + left_source, + right_source, + project_uuid="", + bridge_kind="MainPathDetourBridge", +): + best = RoutingNetwork.nearest_route_bridge_candidate_between_objects(doc, left_source, right_source) + if best is None: + return [] + left = best["left_carrier"] + right = best["right_carrier"] + left_point = best["left_point"] + right_point = best["right_point"] + if _distance(left_point, right_point) <= RoutingNetwork.DEFAULT_NODE_TOLERANCE: + return [] + try: + if RoutingNetwork._route_bridge_already_exists(doc, left_point, right_point): + return [] + except Exception: + pass + + obstacles = _route_bridge_obstacles(doc, left_source, right_source, left, right) + points = _bridge_points_avoiding_obstacles(left_point, right_point, obstacles) + if len(points) < 2: + return [] + + left_label = _route_bridge_label(left_source, left, "Path A") + right_label = _route_bridge_label(right_source, right, "Path B") + carrier = RoutingNetwork.create_route_carrier( + doc, + points, + label="QET User Bridge {0} -> {1}".format(left_label, right_label), + project_uuid=project_uuid, + kind=RoutingNetwork.ROUTE_CARRIER_KIND_USER_PATH, + capacity=min( + int(getattr(left, "QetRouteCarrierCapacity", 1) or 1), + int(getattr(right, "QetRouteCarrierCapacity", 1) or 1), + ), + ) + TerminalObjects.ensure_string_property( + carrier, + "QetRouteBridgeKind", + "QET Routing", + "QET route bridge kind", + str(bridge_kind or "MainPathDetourBridge"), + ) + TerminalObjects.ensure_string_property( + carrier, + "QetRouteBridgePairLabel", + "QET Routing", + "Human readable source pair for this generated bridge", + "{0} -> {1}".format(left_label, right_label), + ) + TerminalObjects.ensure_string_property( + carrier, + "QetRouteBridgeLeftSourceName", + "QET Routing", + "Left/source object name for this generated bridge", + getattr(left_source, "Name", "") or getattr(left, "QetRouteSourceName", "") or getattr(left, "Name", ""), + ) + TerminalObjects.ensure_string_property( + carrier, + "QetRouteBridgeRightSourceName", + "QET Routing", + "Right/source object name for this generated bridge", + getattr(right_source, "Name", "") or getattr(right, "QetRouteSourceName", "") or getattr(right, "Name", ""), + ) + TerminalObjects.ensure_string_property( + carrier, + "QetRouteBridgeLeftSourceLabel", + "QET Routing", + "Left/source object label for this generated bridge", + left_label, + ) + TerminalObjects.ensure_string_property( + carrier, + "QetRouteBridgeRightSourceLabel", + "QET Routing", + "Right/source object label for this generated bridge", + right_label, + ) + return [carrier] + + +def _create_main_path_detour_bridges_from_report(doc, report, project_uuid=""): + detour_summary = report.get("main_path_detour_missing_summary", {}) if isinstance(report, dict) else {} + pair_counts = detour_summary.get("bridge_pair_counts", {}) if isinstance(detour_summary, dict) else {} + if not isinstance(pair_counts, dict): + pair_counts = {} + + created = [] + missing_pairs = [] + duplicates = 0 + for pair_text, _count in sorted(pair_counts.items(), key=lambda item: (-_safe_int(item[1]), str(item[0]))): + pair_text = str(pair_text or "").strip() + if " -> " not in pair_text: + continue + left_label, right_label = [part.strip() for part in pair_text.split(" -> ", 1)] + if not left_label or not right_label: + continue + left_matches = _find_route_bridge_sources_by_name_or_label(doc, name=left_label, label=left_label) + right_matches = _find_route_bridge_sources_by_name_or_label(doc, name=right_label, label=right_label) + if not left_matches or not right_matches: + missing_pairs.append(pair_text) + continue + new_bridges = _create_user_path_bridge_between_objects_avoiding_obstacles( + doc, + left_matches[0], + right_matches[0], + project_uuid=project_uuid, + ) + if new_bridges: + created.extend(new_bridges) + else: + duplicates += 1 + + return { "enabled": True, "pairs": len(pair_counts), "created_count": len(created), @@ -9527,7 +10834,32 @@ def _create_terminal_access_fallback_bridges_from_report(doc, report, project_uu best_main = None best_distance = None + recommended_main_name = str(sample.get("nearest_main_path_name", "") or "").strip() + recommended_main_label = str(sample.get("nearest_main_path_label", "") or "").strip() + if recommended_main_name or recommended_main_label: + recommended_matches = _find_route_bridge_sources_by_name_or_label( + doc, + name=recommended_main_name, + label=recommended_main_label, + ) + for candidate in recommended_matches: + if candidate is target: + continue + if str(getattr(candidate, "QetRouteCarrierKind", "") or "").strip() not in main_path_kinds: + continue + bridge_candidate = RoutingNetwork.nearest_route_bridge_candidate_between_objects( + doc, + target, + candidate, + ) + if not isinstance(bridge_candidate, dict): + continue + best_main = candidate + best_distance = float(bridge_candidate.get("distance_mm", 0.0) or 0.0) + break for candidate in main_candidates: + if best_main is not None: + break if candidate is target: continue bridge_candidate = RoutingNetwork.nearest_route_bridge_candidate_between_objects( @@ -9546,7 +10878,7 @@ def _create_terminal_access_fallback_bridges_from_report(doc, report, project_uu if missing_ref not in missing_refs: missing_refs.append(missing_ref) continue - new_bridges = RoutingNetwork.create_user_path_bridge_between_objects( + new_bridges = _create_user_path_bridge_between_objects_avoiding_obstacles( doc, target, best_main, @@ -9572,6 +10904,216 @@ def _create_terminal_access_fallback_bridges_from_report(doc, report, project_uu } +def _main_path_target_bridge_kind_set(): + return {"WireDuct", "WireDuctOpenEnd", "UserPath", "WiringCutOut", "RoutingPath"} + + +def _create_main_path_target_bridges_from_report(doc, report, project_uuid=""): + """Bridge two main-path targets when a wire still detours through RoutingRange.""" + if not isinstance(report, dict): + return { + "enabled": True, + "pairs": 0, + "created_count": 0, + "duplicates": 0, + "missing_pairs": [], + "created_pair_labels": [], + "wire_uuids": [], + "rerouted": False, + } + main_path_kinds = _main_path_target_bridge_kind_set() + seen_pairs = set() + wire_uuids = [] + created = [] + duplicates = 0 + missing_pairs = [] + + for route in list(report.get("routes", []) or []): + if not isinstance(route, dict): + continue + quality = _route_quality_payload(route.get("route_track", {})) + if not quality.get("fallback_carrier_kinds"): + continue + network = route.get("network", {}) if isinstance(route.get("network", {}), dict) else {} + start_kind = str(network.get("start_terminal_access_target_kind", "") or "").strip() + end_kind = str(network.get("end_terminal_access_target_kind", "") or "").strip() + if start_kind not in main_path_kinds or end_kind not in main_path_kinds: + continue + start_name = str(network.get("start_terminal_access_target_name", "") or "").strip() + end_name = str(network.get("end_terminal_access_target_name", "") or "").strip() + start_label = str(network.get("start_terminal_access_target_label", "") or "").strip() + end_label = str(network.get("end_terminal_access_target_label", "") or "").strip() + if not (start_name or start_label) or not (end_name or end_label): + continue + pair_key = tuple(sorted(((start_name or start_label), (end_name or end_label)))) + if len(set(pair_key)) < 2: + continue + wire_uuid = str(route.get("wire_uuid", "") or "").strip() + if pair_key in seen_pairs: + # 同一对线槽/UserPath 目标只需要补一条桥,但这对目标下的所有导线都应该重跑。 + if wire_uuid and wire_uuid not in wire_uuids: + wire_uuids.append(wire_uuid) + continue + seen_pairs.add(pair_key) + + start_matches = _find_route_bridge_sources_by_name_or_label( + doc, + name=start_name, + label=start_label, + ) + end_matches = _find_route_bridge_sources_by_name_or_label( + doc, + name=end_name, + label=end_label, + ) + if not start_matches or not end_matches: + missing_pairs.append("{0} -> {1}".format(start_label or start_name, end_label or end_name)) + continue + new_bridges = _create_user_path_bridge_between_objects_avoiding_obstacles( + doc, + start_matches[0], + end_matches[0], + project_uuid=project_uuid, + bridge_kind="MainPathTargetBridge", + ) + if new_bridges: + created.extend(new_bridges) + if wire_uuid and wire_uuid not in wire_uuids: + wire_uuids.append(wire_uuid) + else: + duplicates += 1 + + return { + "enabled": True, + "pairs": len(seen_pairs), + "created_count": len(created), + "duplicates": duplicates, + "missing_pairs": missing_pairs, + "created_pair_labels": [ + getattr(bridge, "QetRouteBridgePairLabel", "") + for bridge in created + ], + "wire_uuids": wire_uuids, + "rerouted": False, + } + + +def _terminal_access_target_ref(access_carrier): + if access_carrier is None: + return {} + kind = str(getattr(access_carrier, "QetTerminalAccessTargetKind", "") or "").strip() + name = str(getattr(access_carrier, "QetTerminalAccessTargetName", "") or "").strip() + label = str(getattr(access_carrier, "QetTerminalAccessTargetLabel", "") or "").strip() + if kind not in _main_path_target_bridge_kind_set(): + return {} + if not (name or label): + return {} + return { + "kind": kind, + "name": name, + "label": label, + "key": name or label, + } + + +def _create_main_path_target_bridges_from_payload(doc, payload, project_uuid=""): + """Pre-create bridges between the two main-path targets used by wire endpoints. + + 先补桥再布线,避免一批导线先退回 RoutingRange,随后又因为补桥重跑。 + """ + summary = { + "enabled": True, + "pairs": 0, + "created_count": 0, + "duplicates": 0, + "missing_pairs": [], + "created_pair_labels": [], + "wire_uuids": [], + "rerouted": False, + "precreated_count": 0, + } + if doc is None or not isinstance(payload, dict): + return summary + payload = _load_context_payload_with_devices(payload) + payload = _load_document_payload_with_devices(doc, payload) + wires = list(payload.get("wires", []) or []) + if not wires: + return summary + _repair_duplicate_terminal_metadata_from_payload(doc, payload) + terminal_candidates = _collect_routable_terminals(doc) + seen_pairs = set() + created = [] + duplicates = 0 + missing_pairs = [] + wire_uuids = [] + + for item in wires: + if not isinstance(item, dict): + continue + start_terminal = _terminal_endpoint_match(terminal_candidates, item, "start").get("terminal") + end_terminal = _terminal_endpoint_match(terminal_candidates, item, "end").get("terminal") + if start_terminal is None or end_terminal is None: + continue + start_target = _terminal_access_target_ref( + RoutingNetwork.terminal_access_carrier_for_terminal(start_terminal) + ) + end_target = _terminal_access_target_ref( + RoutingNetwork.terminal_access_carrier_for_terminal(end_terminal) + ) + if not start_target or not end_target: + continue + pair_key = tuple(sorted((start_target["key"], end_target["key"]))) + if len(set(pair_key)) < 2: + continue + wire_uuid = _wire_item_uuid(item) + if wire_uuid and wire_uuid not in wire_uuids: + wire_uuids.append(wire_uuid) + if pair_key in seen_pairs: + continue + seen_pairs.add(pair_key) + start_matches = _find_route_bridge_sources_by_name_or_label( + doc, + name=start_target.get("name", ""), + label=start_target.get("label", ""), + ) + end_matches = _find_route_bridge_sources_by_name_or_label( + doc, + name=end_target.get("name", ""), + label=end_target.get("label", ""), + ) + if not start_matches or not end_matches: + missing_pairs.append( + "{0} -> {1}".format( + start_target.get("label") or start_target.get("name"), + end_target.get("label") or end_target.get("name"), + ) + ) + continue + new_bridges = _create_user_path_bridge_between_objects_avoiding_obstacles( + doc, + start_matches[0], + end_matches[0], + project_uuid=project_uuid, + bridge_kind="MainPathTargetBridge", + ) + if new_bridges: + created.extend(new_bridges) + else: + duplicates += 1 + + summary["pairs"] = len(seen_pairs) + summary["created_count"] = len(created) + summary["precreated_count"] = len(created) + summary["duplicates"] = duplicates + summary["missing_pairs"] = missing_pairs + summary["created_pair_labels"] = [ + getattr(bridge, "QetRouteBridgePairLabel", "") + for bridge in created + ] + summary["wire_uuids"] = wire_uuids + return summary + + def _wire_item_uuid(item): if not isinstance(item, dict): return "" @@ -9593,6 +11135,101 @@ def _payload_subset_for_wire_uuids(payload, wire_uuids): return subset if subset["wires"] else {} +def _append_unique_text(values, value): + result = [ + str(item or "").strip() + for item in list(values or []) + if str(item or "").strip() + ] + text = str(value or "").strip() + if text and text not in result: + result.append(text) + return result + + +def _same_main_path_target_retry_payload(payload, report): + """Build a retry payload for wires whose two accesses target main paths. + + 这类线在真实柜体里应优先沿线槽/UserPath/线槽开口等主路径走;如果第一次结果 + 仍退回 RoutingRange/AuxiliaryPath,就临时加“必经目标主路径、禁止兜底面”的约束重试。 + """ + summary = { + "enabled": True, + "wire_uuids": [], + "target_names": [], + "target_labels": [], + } + if not isinstance(payload, dict) or not isinstance(report, dict): + return {}, summary + main_path_kinds = _main_path_target_bridge_kind_set() + constraints_by_wire = {} + for route in list(report.get("routes", []) or []): + if not isinstance(route, dict): + continue + quality = _route_quality_payload(route.get("route_track", {})) + if not quality.get("fallback_carrier_kinds"): + continue + network = route.get("network", {}) if isinstance(route.get("network", {}), dict) else {} + start_kind = str(network.get("start_terminal_access_target_kind", "") or "").strip() + end_kind = str(network.get("end_terminal_access_target_kind", "") or "").strip() + if start_kind not in main_path_kinds or end_kind not in main_path_kinds: + continue + start_name = str(network.get("start_terminal_access_target_name", "") or "").strip() + end_name = str(network.get("end_terminal_access_target_name", "") or "").strip() + start_label = str(network.get("start_terminal_access_target_label", "") or "").strip() + end_label = str(network.get("end_terminal_access_target_label", "") or "").strip() + if not (start_name or start_label) or not (end_name or end_label): + continue + wire_uuid = str(route.get("wire_uuid", "") or "").strip() + if not wire_uuid: + continue + constraints_by_wire[wire_uuid] = { + "target_names": _append_unique_text([start_name], end_name), + "target_labels": _append_unique_text([start_label], end_label), + } + if wire_uuid not in summary["wire_uuids"]: + summary["wire_uuids"].append(wire_uuid) + for target_name in constraints_by_wire[wire_uuid]["target_names"]: + if target_name and target_name not in summary["target_names"]: + summary["target_names"].append(target_name) + for target_label in constraints_by_wire[wire_uuid]["target_labels"]: + if target_label and target_label not in summary["target_labels"]: + summary["target_labels"].append(target_label) + + if not constraints_by_wire: + return {}, summary + + retry_payload = dict(payload) + retry_wires = [] + for item in list(payload.get("wires", []) or []): + if not isinstance(item, dict): + continue + wire_uuid = _wire_item_uuid(item) + constraint = constraints_by_wire.get(wire_uuid) + if not constraint: + continue + retry_item = dict(item) + retry_item["forbidden_route_carrier_kinds"] = _append_unique_text( + _append_unique_text(retry_item.get("forbidden_route_carrier_kinds", []), "RoutingRange"), + "AuxiliaryPath", + ) + for target_name in list(constraint.get("target_names", []) or []): + retry_item["required_route_carrier_names"] = _append_unique_text( + retry_item.get("required_route_carrier_names", []), + target_name, + ) + for target_label in list(constraint.get("target_labels", []) or []): + retry_item["required_route_carrier_labels"] = _append_unique_text( + retry_item.get("required_route_carrier_labels", []), + target_label, + ) + retry_wires.append(retry_item) + if not retry_wires: + return {}, summary + retry_payload["wires"] = retry_wires + return retry_payload, summary + + def _recompute_route_report_after_route_replacement(doc, report): routes = [route for route in list((report or {}).get("routes", []) or []) if isinstance(route, dict)] route_status_counts = {} @@ -9752,6 +11389,7 @@ def _compact_routing_connection_batch_report(report, sample_limit=8): "context_devices_json_path", "runtime_version", "hidden_route_carriers", + "duplicate_payload_terminal_instance_id_count", "routing_method", "routing_path_network_updated", ) @@ -9769,12 +11407,23 @@ def _compact_routing_connection_batch_report(report, sample_limit=8): payload["routing_path_network_diagnostic"] = report.get("routing_path_network_diagnostic") if isinstance(report.get("auto_diagnostic_bridges"), dict): payload["auto_diagnostic_bridges"] = dict(report.get("auto_diagnostic_bridges") or {}) + if isinstance(report.get("auto_main_path_target_bridges"), dict): + payload["auto_main_path_target_bridges"] = dict(report.get("auto_main_path_target_bridges") or {}) + if isinstance(report.get("same_main_path_target_retry"), dict): + payload["same_main_path_target_retry"] = dict(report.get("same_main_path_target_retry") or {}) if isinstance(report.get("auto_main_path_detour_bridges"), dict): payload["auto_main_path_detour_bridges"] = dict(report.get("auto_main_path_detour_bridges") or {}) if isinstance(report.get("auto_terminal_access_fallback_bridges"), dict): payload["auto_terminal_access_fallback_bridges"] = dict( report.get("auto_terminal_access_fallback_bridges") or {} ) + for key in ( + "routed_wire_visibility", + "wire_style_application", + "route_carrier_visibility", + ): + if isinstance(report.get(key), dict): + payload[key] = dict(report.get(key) or {}) if isinstance(report.get("route_status_counts"), dict): payload["route_status_counts"] = dict(report.get("route_status_counts") or {}) carrier_kind_counts = _report_route_network_carrier_kind_counts(report) @@ -9797,6 +11446,7 @@ def _compact_routing_connection_batch_report(report, sample_limit=8): payload["missing_terminal_summary"] = missing_terminal_summary for key in ( "auto_terminal_binding_warnings", + "duplicate_payload_terminal_instance_id_samples", "missing_endpoint_samples", "missing_route_network_samples", "collision_samples", @@ -10000,15 +11650,37 @@ def _direct_task_routing_path_network_diagnostic(doc, opts): } +def _diagnostic_bridge_summary_from_report(result, enabled=True): + if not isinstance(result, dict): + result = {} + created = list(result.get("created", []) or []) + return { + "enabled": bool(enabled), + "suggestions": int(result.get("suggestions", 0) or 0), + "created_count": len(created), + "duplicates": int(result.get("duplicates", 0) or 0), + "stale_suggestions": int(result.get("stale_suggestions", 0) or 0), + "unconnected_terminal_access_bridge_targets": int( + result.get("unconnected_terminal_access_bridge_targets", 0) or 0 + ), + "unconnected_terminal_access_user_path_bridges": int( + result.get("unconnected_terminal_access_user_path_bridges", 0) or 0 + ), + "unconnected_terminal_access_bridge_duplicates": int( + result.get("unconnected_terminal_access_bridge_duplicates", 0) or 0 + ), + "unconnected_terminal_access_bridge_pair_labels": list( + result.get("unconnected_terminal_access_bridge_pair_labels", []) or [] + ), + } + + def _direct_task_auto_diagnostic_bridge_report(doc, opts): diagnostic = _direct_task_routing_path_network_diagnostic(doc, opts) - bridge_report = { - "enabled": bool(opts.get("auto_create_diagnostic_bridges", True)), - "suggestions": 0, - "created_count": 0, - "duplicates": 0, - "stale_suggestions": 0, - } + bridge_report = _diagnostic_bridge_summary_from_report( + {}, + enabled=bool(opts.get("auto_create_diagnostic_bridges", True)), + ) if not bridge_report["enabled"]: return diagnostic, bridge_report try: @@ -10018,25 +11690,13 @@ def _direct_task_auto_diagnostic_bridge_report(doc, opts): project_uuid=_project_uuid(doc), ) created = list(result.get("created", []) or []) if isinstance(result, dict) else [] - bridge_report = { - "enabled": True, - "suggestions": int(result.get("suggestions", 0) or 0), - "created_count": len(created), - "duplicates": int(result.get("duplicates", 0) or 0), - "stale_suggestions": int(result.get("stale_suggestions", 0) or 0), - } + bridge_report = _diagnostic_bridge_summary_from_report(result, enabled=True) if created: # 任务直连入口没有“更新路径网络”前置步骤;桥接创建后补一次诊断,让报告反映桥接后的网络状态。 diagnostic = _direct_task_routing_path_network_diagnostic(doc, opts) except Exception as exc: - bridge_report = { - "enabled": True, - "suggestions": 0, - "created_count": 0, - "duplicates": 0, - "stale_suggestions": 0, - "error": str(exc), - } + bridge_report = _diagnostic_bridge_summary_from_report({}, enabled=True) + bridge_report["error"] = str(exc) return diagnostic, bridge_report @@ -10181,6 +11841,12 @@ def _compact_routing_path_network_diagnostic(diagnostic): } for item in outside_terminals[:5] ] + unconnected = _dict_items(diagnostic.get("unconnected_terminals", []) or []) + if unconnected: + payload["unconnected_terminals"] = [ + _compact_unconnected_terminal_sample(item) + for item in unconnected[:8] + ] long_accesses = _dict_items(diagnostic.get("long_terminal_accesses", []) or []) if long_accesses: payload["long_terminal_accesses"] = [ @@ -10198,6 +11864,19 @@ def _compact_routing_path_network_diagnostic(diagnostic): "terminal_access_length_mm": item.get("terminal_access_length_mm", 0.0), "terminal_access_warning_distance_mm": item.get("terminal_access_warning_distance_mm", 0.0), "terminal_access_max_distance_mm": item.get("terminal_access_max_distance_mm", 0.0), + "target_kind": item.get("target_kind", ""), + "target_name": item.get("target_name", ""), + "target_label": item.get("target_label", ""), + "target_rule": item.get("target_rule", ""), + "target_distance_mm": item.get("target_distance_mm", 0.0), + "nearest_main_path_kind": item.get("nearest_main_path_kind", ""), + "nearest_main_path_name": item.get("nearest_main_path_name", ""), + "nearest_main_path_label": item.get("nearest_main_path_label", ""), + "nearest_main_path_distance_mm": item.get("nearest_main_path_distance_mm", 0.0), + "nearest_main_path_over_max_distance": bool( + item.get("nearest_main_path_over_max_distance", False) + ), + "endpoint_device_avoided": bool(item.get("endpoint_device_avoided", False)), "terminal_access_dominant_axis": item.get("terminal_access_dominant_axis", ""), "terminal_access_axis_lengths_mm": item.get("terminal_access_axis_lengths_mm", {}), "terminal_access_points": list(item.get("terminal_access_points", []) or [])[:6], @@ -10222,6 +11901,12 @@ def _compact_routing_path_network_diagnostic(diagnostic): _compact_terminal_metadata_issue_sample(item) for item in invalid_exit_directions[:8] ] + invalid_local_routes = _dict_items(diagnostic.get("invalid_terminal_local_routes", []) or []) + if invalid_local_routes: + payload["invalid_terminal_local_routes"] = [ + _compact_terminal_metadata_issue_sample(item) + for item in invalid_local_routes[:8] + ] wire_duct_components = _dict_items(diagnostic.get("wire_ducts_without_terminal_access", []) or []) if wire_duct_components: payload["wire_ducts_without_terminal_access"] = [ @@ -10254,6 +11939,33 @@ def _compact_routing_path_network_diagnostic(diagnostic): return payload +def _compact_unconnected_terminal_sample(item): + return { + "name": item.get("name", ""), + "label": item.get("label", ""), + "terminal_uuid": item.get("terminal_uuid", ""), + "instance_id": item.get("instance_id", ""), + "terminal_origin": item.get("terminal_origin", {}), + "parent_device_name": item.get("parent_device_name", ""), + "parent_device_label": item.get("parent_device_label", ""), + "parent_device_instance_id": item.get("parent_device_instance_id", ""), + "parent_device_element_uuid": item.get("parent_device_element_uuid", ""), + "access_carrier": item.get("access_carrier", ""), + "nearest_network_distance_mm": item.get("nearest_network_distance_mm"), + "nearest_network_point": item.get("nearest_network_point"), + "nearest_network_carrier_kind": item.get("nearest_network_carrier_kind", ""), + "nearest_network_carrier_name": item.get("nearest_network_carrier_name", ""), + "nearest_network_carrier_label": item.get("nearest_network_carrier_label", ""), + "terminal_access_max_distance_mm": item.get("terminal_access_max_distance_mm", 0.0), + "terminal_exit_length_mm": item.get("terminal_exit_length_mm", 0.0), + "terminal_exit_point": item.get("terminal_exit_point", {}), + "terminal_access_dominant_axis": item.get("terminal_access_dominant_axis", ""), + "terminal_access_axis_lengths_mm": item.get("terminal_access_axis_lengths_mm", {}), + "terminal_access_points": list(item.get("terminal_access_points", []) or [])[:6], + "code": item.get("code", ""), + } + + def _compact_terminal_access_quality_sample(item): return { "access_carrier_name": item.get("access_carrier_name", ""), @@ -10271,11 +11983,21 @@ def _compact_terminal_access_quality_sample(item): "target_label": item.get("target_label", ""), "target_rule": item.get("target_rule", ""), "target_distance_mm": item.get("target_distance_mm", 0.0), + "terminal_access_max_distance_mm": item.get("terminal_access_max_distance_mm", 0.0), + "nearest_main_path_kind": item.get("nearest_main_path_kind", ""), + "nearest_main_path_name": item.get("nearest_main_path_name", ""), + "nearest_main_path_label": item.get("nearest_main_path_label", ""), + "nearest_main_path_distance_mm": item.get("nearest_main_path_distance_mm", 0.0), + "nearest_main_path_over_max_distance": bool(item.get("nearest_main_path_over_max_distance", False)), + "endpoint_device_avoided": bool(item.get("endpoint_device_avoided", False)), + "endpoint_device_bbox": item.get("endpoint_device_bbox", {}), + "access_length_mm": item.get("access_length_mm", 0.0), + "access_points": list(item.get("access_points", []) or [])[:6], } def _compact_terminal_metadata_issue_sample(item): - return { + payload = { "name": item.get("name", ""), "label": item.get("label", ""), "terminal_uuid": item.get("terminal_uuid", ""), @@ -10289,6 +12011,10 @@ def _compact_terminal_metadata_issue_sample(item): "message": item.get("message", ""), "raw_sample": item.get("raw_sample", ""), } + for key in ("local_route_end_point", "endpoint_device_bbox", "valid_point_count"): + if key in item: + payload[key] = item.get(key) + return payload def _compact_terminal_exit_diagnostic_sample(item): @@ -10306,6 +12032,10 @@ def _compact_terminal_exit_diagnostic_sample(item): "exit_direction": item.get("exit_direction", {}), "original_exit_direction": item.get("original_exit_direction", {}), "exit_direction_corrected": bool(item.get("exit_direction_corrected", False)), + "origin": item.get("origin", {}), + "exit_point": item.get("exit_point", {}), + "local_route_used": bool(item.get("local_route_used", False)), + "local_route_point_count": _safe_int(item.get("local_route_point_count", 0)), "requested_exit_length_mm": item.get("requested_exit_length_mm", 0.0), "actual_exit_length_mm": item.get("actual_exit_length_mm", 0.0), "max_exit_length_mm": item.get("max_exit_length_mm", 0.0), @@ -10372,6 +12102,20 @@ def _diagnostic_terminal_text(item): return terminal_uuid or display or "未知端子" +def _diagnostic_nearest_network_carrier_text(item): + if not isinstance(item, dict): + return "" + label = str(item.get("nearest_network_carrier_label", "") or "").strip() + name = str(item.get("nearest_network_carrier_name", "") or "").strip() + kind = str(item.get("nearest_network_carrier_kind", "") or "").strip() + text = label or name + if not text: + return "" + if kind: + return "{0}({1})".format(text, kind) + return text + + def _dict_items(value): if not isinstance(value, list): return [] @@ -10411,9 +12155,12 @@ def format_routing_path_network_report(diagnostic): unconnected = _dict_items(diagnostic.get("unconnected_terminals", []) or []) if unconnected: sample = unconnected[0] - message += "\n端子未接入:{0},距离最近网络 {1},当前端子接入最大距离 {2}。请重新生成布线路径网络,或补一段线槽/辅助路径到该端子。".format( + nearest_carrier = _diagnostic_nearest_network_carrier_text(sample) + nearest_carrier_clause = ",最近路径 {0}".format(nearest_carrier) if nearest_carrier else "" + message += "\n端子未接入:{0},距离最近网络 {1}{2},当前端子接入最大距离 {3}。请重新生成布线路径网络,或补一段线槽/辅助路径到该端子。".format( _diagnostic_terminal_text(sample), _format_distance_mm(sample.get("nearest_network_distance_mm")), + nearest_carrier_clause, _format_distance_mm(sample.get("terminal_access_max_distance_mm")), ) @@ -10476,9 +12223,18 @@ def format_routing_path_network_report(diagnostic): long_accesses = _dict_items(diagnostic.get("long_terminal_accesses", []) or []) if long_accesses: sample = long_accesses[0] - message += "\n端子接入过长:{0},接入段 {1},建议补设备局部路径、移动设备或补一段 UserPath/线槽靠近端子。".format( + target_label = str(sample.get("target_label", "") or sample.get("target_name", "") or "").strip() + target_kind = str(sample.get("target_kind", "") or "").strip() + target_clause = "" + if target_label or target_kind: + target_text = target_label or "未知目标" + if target_kind: + target_text = "{0}({1})".format(target_text, target_kind) + target_clause = ",目标 {0}".format(target_text) + message += "\n端子接入过长:{0},接入段 {1}{2},建议补设备局部路径、移动设备或补一段 UserPath/线槽靠近端子。".format( _diagnostic_terminal_text(sample), _format_distance_mm(sample.get("terminal_access_length_mm")), + target_clause, ) invalid_exit_directions = _dict_items(diagnostic.get("invalid_terminal_exit_directions", []) or []) @@ -10492,9 +12248,31 @@ def format_routing_path_network_report(diagnostic): invalid_local_routes = _dict_items(diagnostic.get("invalid_terminal_local_routes", []) or []) if invalid_local_routes: sample = invalid_local_routes[0] - message += "\n端子局部路径无效:{0},字段 {1}。请检查模板端子局部路径或 QetTerminalLocalRoutePointsJson。".format( + reason = str(sample.get("reason", "") or "").strip() + reason_clause = ",原因 {0}".format(reason) if reason else "" + message += "\n端子局部路径无效:{0},字段 {1}{2}。请检查模板端子局部路径或 QetTerminalLocalRoutePointsJson。".format( _diagnostic_terminal_text(sample), sample.get("property_name", "未知字段"), + reason_clause, + ) + + corrected_exits = _dict_items(diagnostic.get("corrected_terminal_exits", []) or []) + if corrected_exits: + sample = corrected_exits[0] + message += "\n端子默认出线方向已校正:{0},原方向 {1},采用方向 {2}。建议复查设备模板端子 LCS 或补明确局部出线路径。".format( + _diagnostic_terminal_text(sample), + _format_point_text(sample.get("original_exit_direction")), + _format_point_text(sample.get("exit_direction")), + ) + + capped_exits = _dict_items(diagnostic.get("capped_terminal_exits", []) or []) + if capped_exits: + sample = capped_exits[0] + message += "\n端子出线长度截断:{0},实际 {1} / 上限 {2},设备出线需求 {3}。建议检查父设备包围盒、端子方向或局部出线路径。".format( + _diagnostic_terminal_text(sample), + _format_distance_mm(sample.get("actual_exit_length_mm")), + _format_distance_mm(sample.get("max_exit_length_mm")), + _format_distance_mm(sample.get("device_exit_required_length_mm")), ) routing_range_only = diagnostic.get("routing_range_only_network", {}) @@ -10510,7 +12288,7 @@ def format_routing_path_network_report(diagnostic): carrier_text = "、".join([str(item) for item in carriers[:3]]) if carriers else "未知 carrier" message += "\n存在孤立路径网络:{0}。请用线槽/辅助路径把孤立网络接入主网络。".format(carrier_text) - if not (empty_network or unconnected or possible_breaks or wire_duct_components or invalid_carriers or outside_carriers or outside_terminals or long_accesses or invalid_exit_directions or invalid_local_routes or routing_range_only or isolated): + if not (empty_network or unconnected or possible_breaks or wire_duct_components or invalid_carriers or outside_carriers or outside_terminals or long_accesses or invalid_exit_directions or invalid_local_routes or corrected_exits or capped_exits or routing_range_only or isolated): first_issue = issues[0] message += "\n首个问题:{0} ({1})。".format( first_issue.get("code", "unknown"), @@ -10529,6 +12307,33 @@ def update_eplan_routing_path_network(doc, project_uuid="", options=None, select ) +def _refresh_terminal_access_after_route_network_change(doc, project_uuid="", options=None): + """Refresh only TerminalAccess carriers after auto-created bridge paths. + + 自动补 UserPath/桥接路径后,端子接入段可能还指向旧线槽。这里不重建 + 线槽、布线面等整套网络,只重算 TerminalAccess,让端子重新选择最近且 + 更合适的主路径,兼顾质量和耗时。 + """ + if doc is None: + return {"refreshed": False, "terminal_access_carriers": 0} + opts = options if isinstance(options, dict) else _merged_options(options) + _invalidate_route_network_cache(opts) + try: + carriers = RoutingNetwork.create_terminal_access_carriers_from_document( + doc, + project_uuid=project_uuid, + terminal_exit_length=float(opts.get("terminal_exit_length", 20.0) or 0.0), + terminal_exit_max_length=float(opts.get("terminal_exit_max_length", 80.0) or 0.0), + max_distance=float(opts.get("terminal_access_max_distance", 1000.0) or 0.0), + ) + finally: + _invalidate_route_network_cache(opts) + return { + "refreshed": True, + "terminal_access_carriers": len(carriers), + } + + def route_eplan_connections( doc, payload=None, @@ -10541,6 +12346,27 @@ def route_eplan_connections( if doc is None: raise AutoRoutingError("No FreeCAD document is available.") opts = _merged_options(options) + opts.setdefault("__route_network_cache", {}) + terminal_access_refreshes = [] + + def refresh_terminal_access(reason): + refresh = {"reason": reason, "refreshed": False, "terminal_access_carriers": 0} + try: + refresh.update( + _refresh_terminal_access_after_route_network_change( + doc, + project_uuid=(project_uuid or _project_uuid(doc)), + options=opts, + ) + ) + if isinstance(prepared_network, dict): + prepared_network["terminal_access_carriers"] = int( + refresh.get("terminal_access_carriers", 0) or 0 + ) + except Exception as exc: + refresh["error"] = str(exc) + terminal_access_refreshes.append(refresh) + return refresh prepared_network = None if update_network: @@ -10550,6 +12376,46 @@ def route_eplan_connections( options=opts, selection_ex=selection_ex, ) + + target_payload = payload + if target_payload is None: + candidate_payload = getattr(App, "_qet_exchange_payload", None) + if _payload_matches_document_project(doc, candidate_payload): + target_payload = candidate_payload + + effective_route_payload = target_payload if isinstance(target_payload, dict) and target_payload.get("wires") else None + precreated_main_path_target_bridges = { + "enabled": bool(opts.get("auto_create_main_path_target_bridges", True)), + "pairs": 0, + "created_count": 0, + "duplicates": 0, + "missing_pairs": [], + "created_pair_labels": [], + "wire_uuids": [], + "rerouted": False, + "precreated_count": 0, + } + if ( + bool(opts.get("auto_create_main_path_target_bridges", True)) + and isinstance(effective_route_payload, dict) + and effective_route_payload.get("wires") + ): + try: + precreated_main_path_target_bridges = _create_main_path_target_bridges_from_payload( + doc, + effective_route_payload, + project_uuid=(project_uuid or _project_uuid(doc)), + ) + if int(precreated_main_path_target_bridges.get("created_count", 0) or 0) > 0: + refresh_terminal_access("precreated_main_path_target_bridges") + except Exception as exc: + precreated_main_path_target_bridges["error"] = str(exc) + + diagnostic_route_network = None + try: + diagnostic_route_network, _diagnostic_route_network_reused = _cached_base_route_network(doc, opts) + except Exception: + diagnostic_route_network = None routing_path_network_diagnostic = {} auto_diagnostic_bridges = { "enabled": bool(opts.get("auto_create_diagnostic_bridges", True)), @@ -10557,6 +12423,10 @@ def route_eplan_connections( "created_count": 0, "duplicates": 0, "stale_suggestions": 0, + "unconnected_terminal_access_bridge_targets": 0, + "unconnected_terminal_access_user_path_bridges": 0, + "unconnected_terminal_access_bridge_duplicates": 0, + "unconnected_terminal_access_bridge_pair_labels": [], } try: routing_path_network_diagnostic = _compact_routing_path_network_diagnostic( @@ -10567,6 +12437,7 @@ def route_eplan_connections( terminal_access_max_distance=float(opts.get("terminal_access_max_distance", 1000.0) or 0.0), terminal_access_warning_distance=float(opts.get("terminal_access_warning_distance", 0.0) or 0.0), adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), + route_network=diagnostic_route_network, ) ) except Exception as exc: @@ -10593,14 +12464,9 @@ def route_eplan_connections( project_uuid=(project_uuid or _project_uuid(doc)), ) created = list(bridge_report.get("created", []) or []) if isinstance(bridge_report, dict) else [] - auto_diagnostic_bridges = { - "enabled": True, - "suggestions": int(bridge_report.get("suggestions", 0) or 0), - "created_count": len(created), - "duplicates": int(bridge_report.get("duplicates", 0) or 0), - "stale_suggestions": int(bridge_report.get("stale_suggestions", 0) or 0), - } + auto_diagnostic_bridges = _diagnostic_bridge_summary_from_report(bridge_report, enabled=True) if created: + _invalidate_route_network_cache(opts) if update_network: prepared_network = update_eplan_routing_path_network( doc, @@ -10608,6 +12474,12 @@ def route_eplan_connections( options=opts, selection_ex=selection_ex, ) + else: + refresh_terminal_access("auto_diagnostic_bridges") + try: + diagnostic_route_network, _diagnostic_route_network_reused = _cached_base_route_network(doc, opts) + except Exception: + diagnostic_route_network = None routing_path_network_diagnostic = _compact_routing_path_network_diagnostic( RoutingNetwork.diagnose_routing_path_network( doc, @@ -10616,23 +12488,12 @@ def route_eplan_connections( terminal_access_max_distance=float(opts.get("terminal_access_max_distance", 1000.0) or 0.0), terminal_access_warning_distance=float(opts.get("terminal_access_warning_distance", 0.0) or 0.0), adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), + route_network=diagnostic_route_network, ) ) except Exception as exc: - auto_diagnostic_bridges = { - "enabled": True, - "suggestions": 0, - "created_count": 0, - "duplicates": 0, - "stale_suggestions": 0, - "error": str(exc), - } - - target_payload = payload - if target_payload is None: - candidate_payload = getattr(App, "_qet_exchange_payload", None) - if _payload_matches_document_project(doc, candidate_payload): - target_payload = candidate_payload + auto_diagnostic_bridges = _diagnostic_bridge_summary_from_report({}, enabled=True) + auto_diagnostic_bridges["error"] = str(exc) if isinstance(target_payload, dict) and target_payload.get("wires"): report = route_eplan_connections_from_payload( @@ -10644,12 +12505,153 @@ def route_eplan_connections( else: task_route_options = dict(opts) task_route_options["__skip_task_auto_diagnostic_bridges"] = True + effective_route_payload = _wire_tasks_payload(doc) + if bool(opts.get("auto_create_main_path_target_bridges", True)): + try: + precreated_main_path_target_bridges = _create_main_path_target_bridges_from_payload( + doc, + effective_route_payload, + project_uuid=(project_uuid or _project_uuid(doc)), + ) + if int(precreated_main_path_target_bridges.get("created_count", 0) or 0) > 0: + refresh_terminal_access("precreated_task_main_path_target_bridges") + except Exception as exc: + precreated_main_path_target_bridges["error"] = str(exc) report = route_eplan_connection_tasks( doc, options=task_route_options, prepared_layout=prepared_network, ) + auto_main_path_target_bridges = dict(precreated_main_path_target_bridges) + if bool(opts.get("auto_create_main_path_target_bridges", True)): + try: + post_main_path_target_bridges = _create_main_path_target_bridges_from_report( + doc, + report, + project_uuid=(project_uuid or _project_uuid(doc)), + ) + auto_main_path_target_bridges["pairs"] = int(auto_main_path_target_bridges.get("pairs", 0) or 0) + int( + post_main_path_target_bridges.get("pairs", 0) or 0 + ) + auto_main_path_target_bridges["created_count"] = int( + auto_main_path_target_bridges.get("created_count", 0) or 0 + ) + int(post_main_path_target_bridges.get("created_count", 0) or 0) + auto_main_path_target_bridges["postcreated_count"] = int( + post_main_path_target_bridges.get("created_count", 0) or 0 + ) + auto_main_path_target_bridges["duplicates"] = int( + auto_main_path_target_bridges.get("duplicates", 0) or 0 + ) + int(post_main_path_target_bridges.get("duplicates", 0) or 0) + auto_main_path_target_bridges["missing_pairs"] = list(auto_main_path_target_bridges.get("missing_pairs", []) or []) + list( + post_main_path_target_bridges.get("missing_pairs", []) or [] + ) + auto_main_path_target_bridges["created_pair_labels"] = list(auto_main_path_target_bridges.get("created_pair_labels", []) or []) + list( + post_main_path_target_bridges.get("created_pair_labels", []) or [] + ) + auto_main_path_target_bridges["wire_uuids"] = _append_unique_text( + auto_main_path_target_bridges.get("wire_uuids", []), + "", + ) + for wire_uuid in list(post_main_path_target_bridges.get("wire_uuids", []) or []): + auto_main_path_target_bridges["wire_uuids"] = _append_unique_text( + auto_main_path_target_bridges.get("wire_uuids", []), + wire_uuid, + ) + if int(post_main_path_target_bridges.get("created_count", 0) or 0) > 0: + retry_wire_uuids = list(post_main_path_target_bridges.get("wire_uuids", []) or []) + refresh_terminal_access("postcreated_main_path_target_bridges") + retry_payload = _payload_subset_for_wire_uuids(effective_route_payload, retry_wire_uuids) + if isinstance(retry_payload, dict) and retry_payload.get("wires"): + retry_report = route_eplan_connections_from_payload( + doc, + retry_payload, + options=opts, + prepared_layout=prepared_network, + ) + report = _merge_retry_routes_into_report( + doc, + report, + retry_report, + retry_prefix="main_path_target", + ) + auto_main_path_target_bridges["retry_wires"] = len(retry_payload.get("wires", []) or []) + auto_main_path_target_bridges["retry_replaced_routes"] = int( + report.get("main_path_target_retry_replaced_routes", 0) or 0 + ) + auto_main_path_target_bridges["rerouted"] = True + else: + auto_main_path_target_bridges["retry_wires"] = 0 + auto_main_path_target_bridges["retry_replaced_routes"] = 0 + auto_main_path_target_bridges["rerouted"] = False + else: + auto_main_path_target_bridges.setdefault("retry_wires", 0) + auto_main_path_target_bridges.setdefault("retry_replaced_routes", 0) + auto_main_path_target_bridges.setdefault("rerouted", False) + except Exception as exc: + auto_main_path_target_bridges = { + "enabled": True, + "pairs": 0, + "created_count": 0, + "duplicates": 0, + "missing_pairs": [], + "created_pair_labels": [], + "wire_uuids": [], + "rerouted": False, + "precreated_count": int(precreated_main_path_target_bridges.get("precreated_count", 0) or 0), + "error": str(exc), + } + + same_main_path_target_retry = { + "enabled": True, + "wire_uuids": [], + "target_names": [], + "target_labels": [], + "retry_wires": 0, + "retry_replaced_routes": 0, + "rerouted": False, + } + try: + retry_payload, same_main_path_target_retry = _same_main_path_target_retry_payload( + effective_route_payload, + report, + ) + if isinstance(retry_payload, dict) and retry_payload.get("wires"): + retry_report = route_eplan_connections_from_payload( + doc, + retry_payload, + options=opts, + prepared_layout=prepared_network, + ) + report = _merge_retry_routes_into_report( + doc, + report, + retry_report, + retry_prefix="same_main_path_target", + ) + same_main_path_target_retry["retry_wires"] = len(retry_payload.get("wires", []) or []) + same_main_path_target_retry["retry_replaced_routes"] = int( + report.get("same_main_path_target_retry_replaced_routes", 0) or 0 + ) + same_main_path_target_retry["rerouted"] = bool( + same_main_path_target_retry["retry_replaced_routes"] > 0 + ) + else: + same_main_path_target_retry.setdefault("retry_wires", 0) + same_main_path_target_retry.setdefault("retry_replaced_routes", 0) + same_main_path_target_retry.setdefault("rerouted", False) + except Exception as exc: + same_main_path_target_retry = { + "enabled": True, + "wire_uuids": [], + "target_names": [], + "target_labels": [], + "retry_wires": 0, + "retry_replaced_routes": 0, + "rerouted": False, + "error": str(exc), + } + auto_main_path_detour_bridges = { "enabled": bool(opts.get("auto_create_main_path_detour_bridges", True)), "pairs": 0, @@ -10668,14 +12670,8 @@ def route_eplan_connections( ) if int(auto_main_path_detour_bridges.get("created_count", 0) or 0) > 0: retry_wire_uuids = _main_path_detour_wire_uuids_from_report(report) - if update_network: - prepared_network = update_eplan_routing_path_network( - doc, - project_uuid=project_uuid, - options=opts, - selection_ex=selection_ex, - ) - retry_payload = _payload_subset_for_wire_uuids(target_payload, retry_wire_uuids) + refresh_terminal_access("auto_main_path_detour_bridges") + retry_payload = _payload_subset_for_wire_uuids(effective_route_payload, retry_wire_uuids) if isinstance(retry_payload, dict) and retry_payload.get("wires"): retry_report = route_eplan_connections_from_payload( doc, @@ -10723,14 +12719,8 @@ def route_eplan_connections( ) if int(auto_terminal_access_fallback_bridges.get("created_count", 0) or 0) > 0: retry_wire_uuids = _terminal_access_fallback_wire_uuids_from_report(report) - if update_network: - prepared_network = update_eplan_routing_path_network( - doc, - project_uuid=project_uuid, - options=opts, - selection_ex=selection_ex, - ) - retry_payload = _payload_subset_for_wire_uuids(target_payload, retry_wire_uuids) + refresh_terminal_access("auto_terminal_access_fallback_bridges") + retry_payload = _payload_subset_for_wire_uuids(effective_route_payload, retry_wire_uuids) if isinstance(retry_payload, dict) and retry_payload.get("wires"): retry_report = route_eplan_connections_from_payload( doc, @@ -10771,14 +12761,25 @@ def route_eplan_connections( report["routing_path_network_updated"] = bool(update_network) report["routing_path_network_diagnostic"] = routing_path_network_diagnostic report["auto_diagnostic_bridges"] = auto_diagnostic_bridges + report["auto_main_path_target_bridges"] = auto_main_path_target_bridges + report["same_main_path_target_retry"] = same_main_path_target_retry report["auto_main_path_detour_bridges"] = auto_main_path_detour_bridges report["auto_terminal_access_fallback_bridges"] = auto_terminal_access_fallback_bridges + report["terminal_access_refreshes"] = terminal_access_refreshes if isinstance(prepared_network, dict): report["routing_path_network"] = prepared_network if opts.get("hide_route_carriers_after_route", True): report["hidden_route_carriers"] = RoutingNetwork.set_route_carriers_visibility(doc, False) else: report["hidden_route_carriers"] = 0 + report["visible_routed_wires"] = _ensure_routed_wires_visible_and_styled(doc) + report["routed_wire_visibility"] = _routed_wire_visibility_summary(doc) + report["wire_style_application"] = _wire_style_application_summary(doc) + report["route_carrier_visibility"] = _route_carrier_visibility_summary( + doc, + expected_hidden=bool(opts.get("hide_route_carriers_after_route", True)), + ) + _refresh_routing_view(doc) _write_routing_connection_batch_diagnostic(doc, report) return report diff --git a/src/Mod/FreeCADExchange/AutoRoutingPanel.py b/src/Mod/FreeCADExchange/AutoRoutingPanel.py index 8176615..eb5beef 100644 --- a/src/Mod/FreeCADExchange/AutoRoutingPanel.py +++ b/src/Mod/FreeCADExchange/AutoRoutingPanel.py @@ -419,11 +419,24 @@ class AutoRoutingController: def check_routing_readiness(self): doc = _active_document() payload = getattr(App, "_qet_exchange_payload", None) + project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() + prepared_layout = None + try: + # 手动验收时用户常直接点“检查布线准备度”。这里先按当前文档刷新一次 + # 路径网络,让预检反映真实可布线状态,而不是停留在“尚未点生成路径网络”。 + prepared_layout = AutoRouting.generate_eplan_routing_path_network( + doc, + project_uuid=project_uuid, + options=self.routing_options(), + ) + except Exception as exc: + prepared_layout = {"error": str(exc)} self.last_report = AutoRouting.preflight_eplan_connections( doc, payload=payload if isinstance(payload, dict) else None, options=self.routing_options(), ) + self.last_report["prepared_layout"] = prepared_layout AutoRouting.write_routing_preflight_diagnostic(doc, self.last_report) return self.last_report @@ -433,6 +446,11 @@ class AutoRoutingController: AutoRouting.write_routing_diagnostic_summary(doc, self.last_report) return self.last_report + def apply_phase1_acceptance_view(self): + doc = _active_document() + self.last_report = AutoRouting.apply_phase1_acceptance_view(doc) + return self.last_report + def select_top_collision_obstacles(self): doc = _active_document() summary = AutoRouting.collect_routing_diagnostic_summary(doc) @@ -1891,6 +1909,8 @@ class AutoRoutingController: selected = [] selected_terminals = [] selected_devices = [] + selected_access_carriers = [] + selected_nearest_paths = [] selected_names = set() missing_refs = [] max_distance = 0.0 @@ -1967,16 +1987,45 @@ class AutoRoutingController: else: add_selection(parent_device, selected_devices) + access_carrier_name = str(sample.get("access_carrier", "") or "").strip() + if access_carrier_name: + access_carrier = doc.getObject(access_carrier_name) + if access_carrier is None: + remember_missing(access_carrier_name) + else: + add_selection(access_carrier, selected_access_carriers) + + nearest_path_name = str(sample.get("nearest_network_carrier_name", "") or "").strip() + nearest_path_label = str(sample.get("nearest_network_carrier_label", "") or "").strip() + if nearest_path_name or nearest_path_label: + nearest_path = self._find_object_by_name_or_label( + doc, + nearest_path_name, + nearest_path_label, + ) + if nearest_path is None: + remember_missing(nearest_path_name or nearest_path_label) + else: + add_selection(nearest_path, selected_nearest_paths) + self.last_report = { "selected_unconnected_terminal_access_objects": len(selected), "selected_unconnected_terminal_access_terminals": len(selected_terminals), "selected_unconnected_terminal_access_devices": len(selected_devices), + "selected_unconnected_terminal_access_carriers": len(selected_access_carriers), + "selected_unconnected_terminal_access_nearest_paths": len(selected_nearest_paths), "selected_unconnected_terminal_access_terminal_names": [ getattr(obj, "Name", "") for obj in selected_terminals ], "selected_unconnected_terminal_access_device_names": [ getattr(obj, "Name", "") for obj in selected_devices ], + "selected_unconnected_terminal_access_carrier_names": [ + getattr(obj, "Name", "") for obj in selected_access_carriers + ], + "selected_unconnected_terminal_access_nearest_path_names": [ + getattr(obj, "Name", "") for obj in selected_nearest_paths + ], "missing_unconnected_terminal_access_refs": missing_refs, "max_unconnected_terminal_access_distance_mm": float(max_distance), } @@ -2172,7 +2221,29 @@ class AutoRoutingController: return None selected = [] + selected_names = set() + selected_access_carriers = [] + selected_access_carrier_names = set() + selected_targets = [] + selected_target_names = set() missing_refs = [] + + def add_selection_object(obj): + if obj is None: + return False + obj_name = getattr(obj, "Name", "") or "" + if obj_name in selected_names: + return True + try: + Gui.Selection.addSelection(obj) + except Exception: + try: + Gui.Selection.addSelection(getattr(doc, "Name", ""), obj_name) + except Exception: + return False + selected_names.add(obj_name) + return True + try: Gui.Selection.clearSelection() except Exception: @@ -2186,17 +2257,39 @@ class AutoRoutingController: str(sample.get("terminal_uuid", "") or sample.get("name", "") or sample.get("label", "") or "").strip() ) continue - try: - Gui.Selection.addSelection(obj) - except Exception: - try: - Gui.Selection.addSelection(getattr(doc, "Name", ""), getattr(obj, "Name", "")) - except Exception: - continue + if not add_selection_object(obj): + continue selected.append(obj) + + access_name = str(sample.get("access_carrier", "") or "").strip() + access_obj = doc.getObject(access_name) if access_name else None + if access_obj is not None and add_selection_object(access_obj): + access_obj_name = getattr(access_obj, "Name", "") or "" + if access_obj_name not in selected_access_carrier_names: + selected_access_carrier_names.add(access_obj_name) + selected_access_carriers.append(access_obj) + + target_obj = self._find_object_by_name_or_label( + doc, + str(sample.get("target_name", "") or "").strip(), + str(sample.get("target_label", "") or "").strip(), + ) + if target_obj is not None and add_selection_object(target_obj): + target_obj_name = getattr(target_obj, "Name", "") or "" + if target_obj_name not in selected_target_names: + selected_target_names.add(target_obj_name) + selected_targets.append(target_obj) self.last_report = { "selected_long_terminal_accesses": len(selected), "selected_long_terminal_names": [getattr(obj, "Name", "") for obj in selected], + "selected_long_terminal_access_carriers": len(selected_access_carriers), + "selected_long_terminal_access_carrier_names": [ + getattr(obj, "Name", "") for obj in selected_access_carriers + ], + "selected_long_terminal_access_targets": len(selected_targets), + "selected_long_terminal_access_target_names": [ + getattr(obj, "Name", "") for obj in selected_targets + ], "missing_long_terminal_refs": missing_refs, } return self.last_report @@ -2857,6 +2950,10 @@ class AutoRoutingController: total_suggestions = 0 total_duplicates = 0 total_stale = 0 + unconnected_targets = 0 + unconnected_created = 0 + unconnected_duplicates = 0 + unconnected_pair_labels = [] diagnostic_passes = 0 max_passes = 5 for _index in range(max_passes): @@ -2876,6 +2973,18 @@ class AutoRoutingController: total_suggestions += int(bridge_report.get("suggestions", 0) or 0) total_duplicates += int(bridge_report.get("duplicates", 0) or 0) total_stale += int(bridge_report.get("stale_suggestions", 0) or 0) + unconnected_targets += int( + bridge_report.get("unconnected_terminal_access_bridge_targets", 0) or 0 + ) + unconnected_created += int( + bridge_report.get("unconnected_terminal_access_user_path_bridges", 0) or 0 + ) + unconnected_duplicates += int( + bridge_report.get("unconnected_terminal_access_bridge_duplicates", 0) or 0 + ) + unconnected_pair_labels.extend( + list(bridge_report.get("unconnected_terminal_access_bridge_pair_labels", []) or []) + ) # 新桥接会改变路径组件关系;继续诊断一轮,处理链式接入建议。 if created_count <= 0: break @@ -2889,6 +2998,10 @@ class AutoRoutingController: "duplicate_bridges": total_duplicates, "stale_suggestions": total_stale, "diagnostic_passes": diagnostic_passes, + "unconnected_terminal_access_bridge_targets": unconnected_targets, + "unconnected_terminal_access_user_path_bridges": unconnected_created, + "unconnected_terminal_access_bridge_duplicates": unconnected_duplicates, + "unconnected_terminal_access_bridge_pair_labels": unconnected_pair_labels, "main_path_detour_bridge_pairs": int(detour_bridge_report.get("main_path_detour_bridge_pairs", 0) or 0), "main_path_detour_user_path_bridges": detour_created, "main_path_detour_bridge_duplicates": int(detour_bridge_report.get("main_path_detour_bridge_duplicates", 0) or 0), @@ -3596,6 +3709,12 @@ class AutoRoutingTaskPanel: "汇总预检、路径网络和批量布线的最新诊断对象,便于手动测试后统一复盘。", ) + self.acceptance_view_button = _style_command_button( + QtWidgets.QPushButton(), + "整理验收视图", + "不重新布线,只隐藏 route carrier 辅助对象,并显示/重刷 04_Routed 导线和样式。", + ) + self.clear_routes_button = _style_command_button(QtWidgets.QPushButton(), "清除布线连接") self.clear_carriers_button = _style_command_button(QtWidgets.QPushButton(), "清除走线路径") self.save_button = _style_command_button(QtWidgets.QPushButton(), "保存") @@ -3650,6 +3769,7 @@ class AutoRoutingTaskPanel: self.check_readiness_button, self.route_connections_button, self.diagnostic_summary_button, + self.acceptance_view_button, self.clear_routes_button, self.clear_carriers_button, self.save_button, @@ -3728,6 +3848,7 @@ class AutoRoutingTaskPanel: self.generate_layout_button.clicked.connect(self.generate_layout_space) self.route_connections_button.clicked.connect(self.route_eplan_connections) self.diagnostic_summary_button.clicked.connect(self.collect_routing_diagnostic_summary) + self.acceptance_view_button.clicked.connect(self.apply_phase1_acceptance_view) self.clear_routes_button.clicked.connect(self.clear_routing_connections) self.clear_carriers_button.clicked.connect(self.clear_route_carriers) self.save_button.clicked.connect(self.save) @@ -3993,6 +4114,9 @@ class AutoRoutingTaskPanel: terminal_fallback_created = result.get("terminal_access_fallback_user_path_bridges", 0) terminal_fallback_duplicates = result.get("terminal_access_fallback_bridge_duplicates", 0) missing_terminal_fallback_refs = list(result.get("missing_terminal_access_fallback_bridge_refs", []) or []) + unconnected_targets = result.get("unconnected_terminal_access_bridge_targets", 0) + unconnected_created = result.get("unconnected_terminal_access_user_path_bridges", 0) + unconnected_duplicates = result.get("unconnected_terminal_access_bridge_duplicates", 0) if created <= 0: detour_text = "" if detour_pairs: @@ -4006,6 +4130,12 @@ class AutoRoutingTaskPanel: int(terminal_fallback_targets or 0), int(terminal_fallback_duplicates or 0), ) + unconnected_text = "" + if unconnected_targets: + unconnected_text = " 未接入端子接入段 {0} 个,已存在 {1} 条。".format( + int(unconnected_targets or 0), + int(unconnected_duplicates or 0), + ) missing_text = "" if missing_detour_pairs: missing_text = " 未找到配对:{0}。".format("、".join(missing_detour_pairs[:3])) @@ -4014,12 +4144,13 @@ class AutoRoutingTaskPanel: "、".join(missing_terminal_fallback_refs[:3]) ) self._set_status( - "未按诊断建议生成桥接。建议 {0} 条,已存在 {1} 条,失效 {2} 条。{3}{4}{5}请先点击“检查布线路径网络”或“汇总布线诊断”确认是否存在可桥接建议。{6}".format( + "未按诊断建议生成桥接。建议 {0} 条,已存在 {1} 条,失效 {2} 条。{3}{4}{5}{6}请先点击“检查布线路径网络”或“汇总布线诊断”确认是否存在可桥接建议。{7}".format( suggestions, duplicates, stale, detour_text, terminal_fallback_text, + unconnected_text, missing_text, self.controller.summary(), ) @@ -4037,14 +4168,21 @@ class AutoRoutingTaskPanel: int(terminal_fallback_targets or 0), int(terminal_fallback_created or 0), ) + unconnected_text = "" + if unconnected_targets or unconnected_created: + unconnected_text = " 未接入端子接入段 {0} 个,生成 {1} 条。".format( + int(unconnected_targets or 0), + int(unconnected_created or 0), + ) self._set_status( - "已按诊断建议生成桥接 UserPath:{0} 条。建议 {1} 条,已存在 {2} 条,失效 {3} 条。{4}{5}请重新生成布线路径网络/布线连接验证效果。{6}".format( + "已按诊断建议生成桥接 UserPath:{0} 条。建议 {1} 条,已存在 {2} 条,失效 {3} 条。{4}{5}{6}请重新生成布线路径网络/布线连接验证效果。{7}".format( created, suggestions, duplicates, stale, detour_text, terminal_fallback_text, + unconnected_text, self.controller.summary(), ) ) @@ -4401,17 +4539,21 @@ class AutoRoutingTaskPanel: result = self.controller.select_unconnected_terminal_access_issues() terminals = result.get("selected_unconnected_terminal_access_terminals", 0) devices = result.get("selected_unconnected_terminal_access_devices", 0) + access_carriers = result.get("selected_unconnected_terminal_access_carriers", 0) + nearest_paths = result.get("selected_unconnected_terminal_access_nearest_paths", 0) max_distance = float(result.get("max_unconnected_terminal_access_distance_mm", 0.0) or 0.0) missing = list(result.get("missing_unconnected_terminal_access_refs", []) or []) - if terminals <= 0 and devices <= 0: + if terminals <= 0 and devices <= 0 and access_carriers <= 0 and nearest_paths <= 0: self._set_status( "未选择未接入端子。请先检查布线路径网络,确认存在 unconnected_terminals。" + self.controller.summary() ) return - message = "已选择未接入端子:端子 {0} 个,设备 {1} 个;最大最近网络距离 {2:.1f} mm。".format( + message = "已选择未接入端子:端子 {0} 个,设备 {1} 个,接入段 {2} 条,最近路径 {3} 条;最大最近网络距离 {4:.1f} mm。".format( terminals, devices, + access_carriers, + nearest_paths, max_distance, ) if missing: @@ -4537,6 +4679,8 @@ class AutoRoutingTaskPanel: try: result = self.controller.select_long_terminal_accesses() selected = result.get("selected_long_terminal_accesses", 0) + access_carriers = result.get("selected_long_terminal_access_carriers", 0) + targets = result.get("selected_long_terminal_access_targets", 0) missing = list(result.get("missing_long_terminal_refs", []) or []) if selected <= 0: self._set_status( @@ -4544,7 +4688,11 @@ class AutoRoutingTaskPanel: + self.controller.summary() ) return - message = "已选择长接入端子:{0} 个。".format(selected) + message = "已选择长接入端子:{0} 个,接入段 {1} 条,目标路径 {2} 条。".format( + selected, + int(access_carriers or 0), + int(targets or 0), + ) if missing: message += " 未找到:{0}。".format("、".join(missing[:5])) message += "请检查端子位置、设备装配高度,或为设备补局部出线路径。" @@ -4825,6 +4973,25 @@ class AutoRoutingTaskPanel: except Exception as exc: self._set_error(str(exc)) + def apply_phase1_acceptance_view(self): + try: + report = self.controller.apply_phase1_acceptance_view() + routed = report.get("routed_wire_visibility", {}) if isinstance(report, dict) else {} + carriers = report.get("route_carrier_visibility", {}) if isinstance(report, dict) else {} + styles = report.get("wire_style_application", {}) if isinstance(report, dict) else {} + self._set_status( + "已整理验收视图:显示导线 {0}/{1} 条,隐藏辅助路径 {2}/{3} 条,导线样式已应用 {4}/{5} 条。".format( + routed.get("visible", 0), + routed.get("routed", 0), + report.get("hidden_route_carriers", 0), + carriers.get("total", 0), + styles.get("applied", 0), + styles.get("expected", 0), + ) + ) + except Exception as exc: + self._set_error(str(exc)) + def clear_routing_connections(self): try: removed = self.controller.clear_routing_connections() diff --git a/src/Mod/FreeCADExchange/DeviceImport.py b/src/Mod/FreeCADExchange/DeviceImport.py index 2ed4646..75c5a4e 100644 --- a/src/Mod/FreeCADExchange/DeviceImport.py +++ b/src/Mod/FreeCADExchange/DeviceImport.py @@ -471,10 +471,17 @@ def _find_device_group(doc, element_uuid): preferred_name = DEVICE_GROUP_PREFIX + _safe_token(target_uuid) obj = doc.getObject(preferred_name) - if obj is not None: + if obj is not None and getattr(obj, "Name", "").startswith(DEVICE_GROUP_PREFIX): return obj for candidate in doc.Objects: + if not getattr(candidate, "Name", "").startswith(DEVICE_GROUP_PREFIX): + continue + try: + if not candidate.isDerivedFrom("App::DocumentObjectGroup"): + continue + except Exception: + continue if "QetElementUuid" in getattr(candidate, "PropertiesList", []): if getattr(candidate, "QetElementUuid", "").strip() == target_uuid: return candidate @@ -1224,6 +1231,11 @@ def _is_exchange_sidecar_group(obj): child_name = _object_name(obj) if child_name.startswith(TERMINAL_GROUP_PREFIX) or child_name.startswith(WIRE_GROUP_PREFIX): return True + try: + if TerminalObjects.is_terminal_hint_object(obj): + return True + except Exception: + pass return getattr(obj, "QetGroupKind", "").strip() in {GROUP_KIND_TERMINALS, GROUP_KIND_WIRES} diff --git a/src/Mod/FreeCADExchange/ExchangeWriteBack.py b/src/Mod/FreeCADExchange/ExchangeWriteBack.py index 89e455e..7b5bbe1 100644 --- a/src/Mod/FreeCADExchange/ExchangeWriteBack.py +++ b/src/Mod/FreeCADExchange/ExchangeWriteBack.py @@ -5,12 +5,18 @@ import os from datetime import datetime from pathlib import Path import traceback +import uuid import FreeCAD as App import DeviceImport import TerminalObjects as TerminalObjects +try: + import TerminalImport +except ImportError: + TerminalImport = None + try: import FreeCADGui as Gui except ImportError: @@ -140,6 +146,68 @@ def _output_path_for_exchange_json(): return str(Path(json_path).with_name("3d_to_2d.json")) +def _input_path_for_scene(scene_path): + scene_path = (scene_path or "").strip() + if not scene_path: + return "" + path = Path(scene_path) + if path.suffix.lower() == ".fcstd": + return str(path.with_name("2d_to_3d.json")) + if path.is_dir(): + return str(path / "2d_to_3d.json") + return str(path.parent / "2d_to_3d.json") + + +def _load_json_payload(path): + path_text = (path or "").strip() + if not path_text: + return None + try: + candidate = Path(path_text) + if not candidate.is_file(): + return None + return json.loads(candidate.read_text(encoding="utf-8")) + except Exception as exc: + _append_debug_log("write-back could not load payload {0}: {1}".format(path_text, exc)) + return None + + +def _payload_for_writeback(scene_path, payload=None): + if isinstance(payload, dict): + return payload + + env_path = os.environ.get(ENV_JSON_PATH, "").strip() + loaded = _load_json_payload(env_path) + if isinstance(loaded, dict): + return loaded + + loaded = _load_json_payload(_input_path_for_scene(scene_path)) + if isinstance(loaded, dict): + return loaded + + return payload + + +def _sync_terminals_for_writeback(doc, scene_path, payload): + if TerminalImport is None or not isinstance(payload, dict): + return None + if not isinstance(payload.get("devices"), list) or not payload.get("devices"): + return None + try: + # 保存/写回以当前 2d_to_3d.json 为端子快照,先同步 3D 工程端子,避免旧工程继续回写缺失或重复端子。 + return TerminalImport.import_terminals_from_payload(payload, scene_path) + except Exception as exc: + _append_debug_log("write-back terminal sync failed: {0}".format(exc)) + _append_debug_log(traceback.format_exc()) + return None + + +def sync_terminals_from_current_payload(doc, scene_path="", payload=None): + scene_path = _scene_path_from_doc(doc, scene_path) + payload = _payload_for_writeback(scene_path, payload) + return _sync_terminals_for_writeback(doc, scene_path, payload) + + def _format_timestamp(): return datetime.now().astimezone().isoformat(timespec="seconds") @@ -173,9 +241,59 @@ def _collect_instance_bindings(doc): return bindings +def _stable_terminal_instance_id(project_uuid, device_instance_id, terminal_obj): + values = [ + project_uuid, + device_instance_id, + getattr(terminal_obj, "QetElementUuid", "").strip(), + getattr(terminal_obj, "QetTerminalUuid", "").strip(), + getattr(terminal_obj, "QetTemplateSlotName", "").strip(), + getattr(terminal_obj, "Label", "").strip(), + getattr(terminal_obj, "Name", "").strip(), + ] + seed = "qet-freecad-writeback-terminal|" + "|".join(values) + return str(uuid.uuid5(uuid.NAMESPACE_URL, seed)) + + +def _writeback_terminal_instance_id(project_uuid, terminal_obj, device_instance_id, used_ids): + terminal_instance_id = ( + getattr(terminal_obj, "QetTerminalInstanceId", "").strip() + or getattr(terminal_obj, "QetInstanceId", "").strip() + or "" + ) + if ( + not terminal_instance_id + or terminal_instance_id == device_instance_id + or terminal_instance_id in used_ids + ): + terminal_instance_id = _stable_terminal_instance_id( + project_uuid, + device_instance_id, + terminal_obj, + ) + suffix = 1 + while terminal_instance_id in used_ids: + terminal_instance_id = str(uuid.uuid5( + uuid.NAMESPACE_URL, + "{0}|{1}".format(terminal_instance_id, suffix), + )) + suffix += 1 + TerminalObjects.ensure_string_property( + terminal_obj, + "QetTerminalInstanceId", + "QET Exchange", + "Stable 3D terminal instance UUID", + terminal_instance_id, + ) + used_ids.add(terminal_instance_id) + return terminal_instance_id + + def _collect_terminal_bindings(doc): bindings = [] seen = set() + used_terminal_instance_ids = set() + project_uuid = _project_uuid_from_doc(doc) for device_group in _iter_device_groups(doc): instance_id = getattr(device_group, "QetInstanceId", "").strip() for terminal_obj in _iter_terminal_objects(device_group): @@ -186,7 +304,12 @@ def _collect_terminal_bindings(doc): or binding_mode == TerminalObjects.TERMINAL_BINDING_MODE_LOCAL ): continue - terminal_instance_id = getattr(terminal_obj, "QetInstanceId", "").strip() or instance_id + terminal_instance_id = _writeback_terminal_instance_id( + project_uuid, + terminal_obj, + instance_id, + used_terminal_instance_ids, + ) if not terminal_uuid or not terminal_instance_id: continue key = (terminal_uuid, terminal_instance_id) @@ -235,6 +358,9 @@ def write_back_document(doc=None, scene_path="", payload=None): "Cannot determine the 3d_to_2d.json output path." ) + payload = _payload_for_writeback(scene_path, payload) + _sync_terminals_for_writeback(doc, scene_path, payload) + project_uuid = _project_uuid_from_doc(doc, payload) if not project_uuid: raise ExchangeWriteBackError( @@ -343,6 +469,19 @@ class _WriteBackObserver: _is_exchange_document(doc), ) ) + if not _is_exchange_document(doc): + return + try: + sync_terminals_from_current_payload(doc, scene_path=name) + except Exception as exc: + _append_debug_log("write-back terminal sync before save failed: {0}".format(exc)) + _append_debug_log(traceback.format_exc()) + try: + App.Console.PrintError( + "[FreeCADExchange] terminal sync before save failed: {0}\n".format(exc) + ) + except Exception: + pass def slotFinishSaveDocument(self, doc, name): _append_debug_log( diff --git a/src/Mod/FreeCADExchange/PendingDeviceAssemblyPanel.py b/src/Mod/FreeCADExchange/PendingDeviceAssemblyPanel.py index f09a305..d45263b 100644 --- a/src/Mod/FreeCADExchange/PendingDeviceAssemblyPanel.py +++ b/src/Mod/FreeCADExchange/PendingDeviceAssemblyPanel.py @@ -24,8 +24,24 @@ except ImportError: import DeviceImport +try: + import TerminalImport +except ImportError: + TerminalImport = None + +try: + import TemplateInstantiation +except ImportError: + TemplateInstantiation = None + +try: + import BatchAssembly +except ImportError: + BatchAssembly = None + COMMAND_NAME = "QET_Exchange_OpenPendingDevicePanel" +STATE_PAYLOAD = "_qet_exchange_payload" class PendingDeviceAssemblyPanelError(RuntimeError): @@ -169,6 +185,227 @@ def _set_status(label, message, error=False): pass +def _imported_model_objects(result): + objects = [] + for obj in list((result or {}).get("imported_objects", []) or []): + if obj is not None and getattr(obj, "Name", ""): + objects.append(obj) + return objects + + +def focus_inserted_result(doc, result): + imported_objects = _imported_model_objects(result) + if not imported_objects: + raise PendingDeviceAssemblyPanelError( + "没有导入任何可显示模型对象,请检查该设备绑定的 3D 模型文件。" + ) + + device = (result or {}).get("device") + for obj in [device] + imported_objects: + view_object = getattr(obj, "ViewObject", None) + if view_object is not None: + try: + view_object.Visibility = True + except Exception: + pass + + if Gui is not None and hasattr(Gui, "Selection"): + try: + Gui.Selection.clearSelection() + except Exception: + pass + for obj in imported_objects: + try: + Gui.Selection.addSelection(doc.Name, obj.Name) + except Exception: + pass + + if Gui is not None and hasattr(Gui, "SendMsgToActiveView"): + try: + Gui.SendMsgToActiveView("ViewSelection") + except Exception: + try: + Gui.SendMsgToActiveView("ViewFit") + except Exception: + pass + + +def _sync_engineering_terminals_for_inserted_device(doc, result): + device = result.get("device") if isinstance(result, dict) else None + payload = getattr(App, STATE_PAYLOAD, None) + if TerminalImport is not None and isinstance(payload, dict): + project_uuid = (payload.get("project_uuid") or "").strip() + if project_uuid: + return TerminalImport.import_terminals_from_payload(payload, "") + + if TemplateInstantiation is not None and device is not None: + return TemplateInstantiation.ensure_engineering_terminals_for_device(doc, device) + + return { + "imported_terminals": 0, + "created_terminals": 0, + "updated_terminals": 0, + "warnings": ["端子同步模块不可用,已跳过工程端子生成。"], + } + + +def insert_device_and_sync_terminals(doc, device, **insert_kwargs): + result = DeviceImport.insert_pending_device(doc, device, **insert_kwargs) + result["terminal_report"] = _sync_engineering_terminals_for_inserted_device(doc, result) + return result + + +def _terminal_report_count(report, *keys): + for key in keys: + try: + return int(report.get(key, 0) or 0) + except Exception: + continue + return 0 + + +def _terminal_status_suffix(result): + report = result.get("terminal_report", {}) if isinstance(result, dict) else {} + created = _terminal_report_count(report, "imported_terminals", "created_terminals") + updated = _terminal_report_count(report, "updated_terminals") + skipped = sum( + _terminal_report_count(report, key) + for key in ( + "skipped_missing_slot", + "skipped_devices_without_template_slots", + "skipped_unbound_slots", + "skipped_missing_device", + ) + ) + return ",工程端子新增 {0} 个,更新 {1} 个,跳过 {2} 个".format( + created, + updated, + skipped, + ) + + +def _pending_batch_key(device): + if BatchAssembly is not None: + try: + strip_name, order = BatchAssembly._parse_strip_name_and_order(device) + if strip_name: + return strip_name, order + except Exception: + pass + text = ( + getattr(device, "QetDisplayTag", "") + or getattr(device, "Label", "") + or getattr(device, "Name", "") + or "" + ).strip() + prefix = "" + number = None + for index, char in enumerate(text): + if char.isdigit(): + prefix = text[:index].strip(" ::_-") + try: + number = int("".join(ch for ch in text[index:] if ch.isdigit()) or "0") + except Exception: + number = None + break + return prefix or text, number + + +def matching_pending_batch_devices(doc, seed_device): + seed_prefix, _seed_order = _pending_batch_key(seed_device) + seed_prefix_key = (seed_prefix or "").strip().lower() + if not seed_prefix_key: + return [seed_device] + + matches = [] + for item in DeviceImport.list_pending_devices(doc): + device = item.get("device") + if device is None: + continue + prefix, order = _pending_batch_key(device) + if (prefix or "").strip().lower() == seed_prefix_key: + matches.append((order if order is not None else 10**9, getattr(device, "Label", "") or getattr(device, "Name", ""), device)) + matches.sort(key=lambda item: (item[0], item[1])) + return [device for _order, _label, device in matches] or [seed_device] + + +def _sync_engineering_terminals_after_batch(doc, devices): + payload = getattr(App, STATE_PAYLOAD, None) + if TerminalImport is not None and isinstance(payload, dict) and (payload.get("project_uuid") or "").strip(): + return TerminalImport.import_terminals_from_payload(payload, "") + + totals = {"imported_terminals": 0, "created_terminals": 0, "updated_terminals": 0, "warnings": []} + if TemplateInstantiation is None: + totals["warnings"].append("端子同步模块不可用,已跳过工程端子生成。") + return totals + for device in devices: + report = TemplateInstantiation.ensure_engineering_terminals_for_device(doc, device) + for key in ("imported_terminals", "created_terminals", "updated_terminals"): + try: + totals[key] += int(report.get(key, 0) or 0) + except Exception: + pass + totals["warnings"].extend(list(report.get("warnings", []) or [])) + return totals + + +def insert_matching_pending_batch_to_target( + doc, + seed_device, + target, + pitch_mm=5.2, + start_offset_mm=0.0, + mount_offset_mm=20.0, +): + if BatchAssembly is None: + raise PendingDeviceAssemblyPanelError("批量排布模块不可用。") + if target is None: + raise PendingDeviceAssemblyPanelError("请先在 3D 视图中选择导轨或安装目标。") + rail = BatchAssembly._ensure_rail(target) + devices = matching_pending_batch_devices(doc, seed_device) + batch_prefix, _seed_order = _pending_batch_key(seed_device) + base = BatchAssembly._base_point(rail) + axis = BatchAssembly._axis_vector(rail) + source_doc_cache = {} + results = [] + for index, device in enumerate(devices): + point = BatchAssembly._point_at( + base, + axis, + float(start_offset_mm or 0.0) + index * float(pitch_mm or 0.0), + ) + result = DeviceImport.insert_pending_device( + doc, + device, + source_doc_cache=source_doc_cache, + mount_target=rail, + mount_placement=BatchAssembly._placement_at(rail, point), + mount_offset_mm=mount_offset_mm, + ) + results.append(result) + + terminal_report = _sync_engineering_terminals_after_batch(doc, [result["device"] for result in results]) + for result in results: + result["terminal_report"] = terminal_report + try: + doc.recompute() + except Exception: + pass + return { + "devices": [result["device"] for result in results], + "results": results, + "terminal_report": terminal_report, + "batch_prefix": batch_prefix, + "device_labels": [ + getattr(result["device"], "QetDisplayTag", "") + or getattr(result["device"], "Label", "") + or getattr(result["device"], "Name", "") + for result in results + ], + "inserted_count": len(results), + } + + class PendingDeviceAssemblyTaskPanel: def __init__(self): if QtWidgets is None: @@ -185,6 +422,7 @@ class PendingDeviceAssemblyTaskPanel: self.refresh_button = QtWidgets.QPushButton("刷新清单") self.insert_button = QtWidgets.QPushButton("插入设备") self.insert_to_target_button = QtWidgets.QPushButton("插入到选中目标") + self.batch_insert_to_target_button = QtWidgets.QPushButton("批量插入同组到选中目标") layout.addWidget(self.refresh_button) layout.addWidget(self.insert_button) @@ -195,11 +433,31 @@ class PendingDeviceAssemblyTaskPanel: self.mount_offset_input.setDecimals(1) self.mount_offset_input.setSingleStep(1.0) self.mount_offset_input.setSuffix(" mm") - self.mount_offset_input.setValue(0.0) + self.mount_offset_input.setValue(20.0) offset_row.addWidget(self.mount_offset_input) layout.addLayout(offset_row) + batch_row = QtWidgets.QHBoxLayout() + batch_row.addWidget(QtWidgets.QLabel("批量间距")) + self.batch_pitch_input = QtWidgets.QDoubleSpinBox() + self.batch_pitch_input.setRange(0.1, 10000.0) + self.batch_pitch_input.setDecimals(1) + self.batch_pitch_input.setSingleStep(1.0) + self.batch_pitch_input.setSuffix(" mm") + self.batch_pitch_input.setValue(5.2) + batch_row.addWidget(self.batch_pitch_input) + batch_row.addWidget(QtWidgets.QLabel("起点偏移")) + self.batch_start_offset_input = QtWidgets.QDoubleSpinBox() + self.batch_start_offset_input.setRange(-10000.0, 10000.0) + self.batch_start_offset_input.setDecimals(1) + self.batch_start_offset_input.setSingleStep(1.0) + self.batch_start_offset_input.setSuffix(" mm") + self.batch_start_offset_input.setValue(0.0) + batch_row.addWidget(self.batch_start_offset_input) + layout.addLayout(batch_row) + layout.addWidget(self.insert_to_target_button) + layout.addWidget(self.batch_insert_to_target_button) self.status_label = QtWidgets.QLabel("") self.status_label.setWordWrap(True) @@ -208,6 +466,7 @@ class PendingDeviceAssemblyTaskPanel: self.refresh_button.clicked.connect(self.refresh) self.insert_button.clicked.connect(self.insert_selected_device) self.insert_to_target_button.clicked.connect(self.insert_selected_device_to_target) + self.batch_insert_to_target_button.clicked.connect(self.insert_matching_batch_to_target) self.refresh() @@ -237,35 +496,80 @@ class PendingDeviceAssemblyTaskPanel: def insert_selected_device(self): try: - result = DeviceImport.insert_pending_device(_document(), self._selected_device()) + doc = _document() + result = insert_device_and_sync_terminals(doc, self._selected_device()) + focus_inserted_result(doc, result) self.refresh() _set_status( self.status_label, - "已插入设备:{0}".format(getattr(result["device"], "Label", "") or getattr(result["device"], "Name", "")), + "已插入设备:{0},导入模型对象 {1} 个{2}".format( + getattr(result["device"], "Label", "") or getattr(result["device"], "Name", ""), + len(_imported_model_objects(result)), + _terminal_status_suffix(result), + ), ) except Exception as exc: _set_status(self.status_label, str(exc), error=True) def insert_selected_device_to_target(self): try: + doc = _document() device = self._selected_device() context = selected_mount_context(exclude_device=device) target = context.get("target") if target is None: raise PendingDeviceAssemblyPanelError("请先在 3D 视图中选择安装板、导轨、线槽或柜体安装面。") - result = DeviceImport.insert_pending_device( - _document(), + result = insert_device_and_sync_terminals( + doc, device, mount_target=target, mount_placement=context.get("placement"), mount_normal=context.get("normal"), mount_offset_mm=self.mount_offset_input.value(), ) + focus_inserted_result(doc, result) + self.refresh() + _set_status( + self.status_label, + "已插入设备到选中目标:{0},导入模型对象 {1} 个{2}".format( + getattr(result["device"], "Label", "") or getattr(result["device"], "Name", ""), + len(_imported_model_objects(result)), + _terminal_status_suffix(result), + ), + ) + except Exception as exc: + _set_status(self.status_label, str(exc), error=True) + + def insert_matching_batch_to_target(self): + try: + doc = _document() + device = self._selected_device() + context = selected_mount_context(exclude_device=device) + target = context.get("target") + report = insert_matching_pending_batch_to_target( + doc, + device, + target, + pitch_mm=self.batch_pitch_input.value(), + start_offset_mm=self.batch_start_offset_input.value(), + mount_offset_mm=self.mount_offset_input.value(), + ) + imported_count = sum(len(_imported_model_objects(result)) for result in report.get("results", [])) + device_labels = list(report.get("device_labels", []) or []) + label_preview = "、".join(str(label) for label in device_labels[:5] if str(label)) + if len(device_labels) > 5: + label_preview += " 等" + if label_preview: + label_preview = "({0})".format(label_preview) self.refresh() _set_status( self.status_label, - "已插入设备到选中目标:{0}".format( - getattr(result["device"], "Label", "") or getattr(result["device"], "Name", "") + "已批量插入同组 {0} 待装配设备 {1} 个{2},导入模型对象 {3} 个{4}".format( + report.get("batch_prefix", "") or "设备", + len(report.get("devices", []) or []), + label_preview, + imported_count, + _terminal_status_suffix({"terminal_report": report.get("terminal_report", {})}), ), ) except Exception as exc: diff --git a/src/Mod/FreeCADExchange/RoutingNetwork.py b/src/Mod/FreeCADExchange/RoutingNetwork.py index ea9c8a4..5aa8365 100644 --- a/src/Mod/FreeCADExchange/RoutingNetwork.py +++ b/src/Mod/FreeCADExchange/RoutingNetwork.py @@ -140,7 +140,8 @@ DEFAULT_KIND_COST_FACTORS = { ROUTE_CARRIER_KIND: 1.0, ROUTE_CARRIER_KIND_AUXILIARY_PATH: 2.0, ROUTE_CARRIER_KIND_TERMINAL_ACCESS: 2.0, - ROUTE_CARRIER_KIND_ROUTING_RANGE: 40.0, + # RoutingRange 是安装板/柜内面域兜底;成本要足够高,避免线槽复用较多时反向抢主路径。 + ROUTE_CARRIER_KIND_ROUTING_RANGE: 1000.0, ROUTE_CARRIER_KIND_USER_PATH: 1.0, } ROUTE_CARRIER_VIEW_STYLES = { @@ -2468,6 +2469,10 @@ def create_user_path_bridges_from_diagnostic_suggestions(doc, diagnostic, projec "created": [], "duplicates": 0, "stale_suggestions": 0, + "unconnected_terminal_access_bridge_targets": 0, + "unconnected_terminal_access_user_path_bridges": 0, + "unconnected_terminal_access_bridge_duplicates": 0, + "unconnected_terminal_access_bridge_pair_labels": [], } if doc is None or not isinstance(diagnostic, dict): return report @@ -2517,6 +2522,41 @@ def create_user_path_bridges_from_diagnostic_suggestions(doc, diagnostic, projec ), ) report["created"].append(bridge) + + for item in diagnostic.get("unconnected_terminals", []) or []: + if not isinstance(item, dict): + continue + access_name = str(item.get("access_carrier", "") or "").strip() + nearest_name = str(item.get("nearest_network_carrier_name", "") or "").strip() + nearest_label = str(item.get("nearest_network_carrier_label", "") or "").strip() + if not access_name or not (nearest_name or nearest_label): + continue + report["suggestions"] += 1 + report["unconnected_terminal_access_bridge_targets"] += 1 + access_carrier = _document_object_by_name(doc, access_name) + nearest_carrier = _document_object_by_name(doc, nearest_name) + if nearest_carrier is None and nearest_label: + nearest_carrier = _document_object_by_label(doc, nearest_label) + if not is_route_carrier(access_carrier) or not is_route_carrier(nearest_carrier): + report["stale_suggestions"] += 1 + continue + bridges = create_user_path_bridge_between_objects( + doc, + access_carrier, + nearest_carrier, + project_uuid=project_uuid, + bridge_kind="UnconnectedTerminalAccessBridge", + ) + if bridges: + report["created"].extend(bridges) + report["unconnected_terminal_access_user_path_bridges"] += len(bridges) + for bridge in bridges: + pair_label = str(getattr(bridge, "QetRouteBridgePairLabel", "") or "").strip() + if pair_label: + report["unconnected_terminal_access_bridge_pair_labels"].append(pair_label) + else: + report["duplicates"] += 1 + report["unconnected_terminal_access_bridge_duplicates"] += 1 return report @@ -2961,6 +3001,16 @@ def _document_object_by_name(doc, name): return None +def _document_object_by_label(doc, label): + if doc is None or not label: + return None + label = str(label or "").strip() + for candidate in list(getattr(doc, "Objects", []) or []): + if str(getattr(candidate, "Label", "") or "").strip() == label: + return candidate + return None + + def cleanup_invalid_source_carriers(doc): """Remove generated carriers whose FreeCAD source object is missing or invalid.""" if doc is None: @@ -3137,7 +3187,7 @@ def routing_source_summary(doc): } -def prepare_layout_space_sources_from_document(doc, project_uuid=""): +def prepare_layout_space_sources_from_document(doc, project_uuid="", include_existing_network=True): """Normalize the current FreeCAD document as an EPLAN-style layout space. This does not generate the routing path network. It marks source objects so @@ -3174,13 +3224,15 @@ def prepare_layout_space_sources_from_document(doc, project_uuid=""): except Exception: pass - return { + payload = { "wire_duct_sources": len(wire_duct_sources), "support_surface_sources": len(support_surface_sources), "wiring_cut_out_sources": len(wiring_cut_out_sources), "routable_terminals": len(_collect_routable_terminals(doc)), - "existing_network": network_summary(doc), } + if include_existing_network: + payload["existing_network"] = network_summary(doc) + return payload def create_wire_duct_carriers_from_document( @@ -3482,7 +3534,32 @@ def _terminal_local_route_issue(terminal): continue points = [_json_route_point(item) for item in point_items if item is not None] valid_points = [point for point in points if point is not None] - if len(_normalized_route_points(valid_points)) >= 2: + normalized_points = _normalized_route_points(valid_points) + if len(normalized_points) >= 2: + try: + origin = _vector(TerminalObjects.terminal_origin(terminal)) + global_points = [_terminal_local_point_to_global(terminal, point) for point in normalized_points] + bbox = _terminal_parent_device_bbox(terminal, origin) + except Exception: + bbox = None + global_points = [] + if ( + bbox is not None + and global_points + and _point_inside_bbox(global_points[-1], bbox, tolerance=DEFAULT_NODE_TOLERANCE) + ): + invalid_samples.append( + { + "property_name": property_name, + "reason": "local_route_end_inside_device_bbox", + "message": "Local route endpoint is still inside the parent device bounding box.", + "raw_sample": raw[:160], + "valid_point_count": len(valid_points), + "local_route_end_point": _point_payload(global_points[-1]), + "endpoint_device_bbox": _bbox_payload(bbox, clearance=0.0), + } + ) + continue return None invalid_samples.append( { @@ -3796,7 +3873,7 @@ def terminal_access_diagnostics(terminal, exit_length=20.0, max_exit_length=None if not points or _distance(points[0], origin) > DEFAULT_NODE_TOLERANCE: points.insert(0, origin) points = _normalized_route_points(points) - if len(points) >= 2: + if len(points) >= 2 and not _terminal_local_route_end_inside_parent_device(terminal, points): direction = _normalize(_subtract(points[1], points[0])) or App.Vector(0, 0, 1) return { "requested_exit_length_mm": max(float(exit_length or 0.0), 0.0), @@ -4033,13 +4110,27 @@ def terminal_access_path_points(terminal, exit_length=20.0, max_exit_length=None if not points or _distance(points[0], origin) > DEFAULT_NODE_TOLERANCE: points.insert(0, origin) normalized = _normalized_route_points(points) - if len(normalized) >= 2: + if len(normalized) >= 2 and not _terminal_local_route_end_inside_parent_device(terminal, normalized): return normalized return _normalized_route_points( [origin, _terminal_device_aware_exit_point(terminal, exit_length, max_exit_length=max_exit_length)] ) +def _terminal_local_route_end_inside_parent_device(terminal, global_points): + try: + origin = _vector(TerminalObjects.terminal_origin(terminal)) + bbox = _terminal_parent_device_bbox(terminal, origin) + except Exception: + bbox = None + normalized = _normalized_route_points(global_points) + return bool( + bbox is not None + and normalized + and _point_inside_bbox(normalized[-1], bbox, tolerance=DEFAULT_NODE_TOLERANCE) + ) + + def terminal_access_carrier_for_terminal(terminal): doc = getattr(terminal, "Document", None) carrier = _live_source_carrier(doc, terminal) @@ -4407,9 +4498,52 @@ def _terminal_access_target_candidate(network, exit_point, max_distance): selected = dict(ranked[0]) selected["terminal_access_target_rule"] = "fallback_only" selected["terminal_access_fallback_target"] = True + selected["terminal_access_max_distance_mm"] = float(max(float(max_distance or 0.0), 0.0)) + nearest_main_path = _nearest_terminal_access_main_path_candidate(network, exit_point) + if nearest_main_path is not None: + selected.update(_terminal_access_main_path_summary(nearest_main_path, max_distance)) return selected +def _nearest_terminal_access_main_path_candidate(network, exit_point): + candidates = connection_point_candidates( + network, + exit_point, + limit=0, + max_distance=0.0, + ) + main_path_candidates = [ + candidate + for candidate in candidates + if _is_terminal_access_main_path_target(candidate.get("carrier")) + ] + if not main_path_candidates: + return None + return min( + main_path_candidates, + key=lambda candidate: float(candidate.get("distance", 0.0) or 0.0), + ) + + +def _terminal_access_main_path_summary(candidate, max_distance): + carrier = candidate.get("carrier") + distance = float(candidate.get("distance", 0.0) or 0.0) + max_distance_value = float(max(float(max_distance or 0.0), 0.0)) + kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND + name = (getattr(carrier, "Name", "") or "").strip() + label = (getattr(carrier, "Label", "") or "").strip() or name + return { + "nearest_main_path_kind": kind, + "nearest_main_path_name": name, + "nearest_main_path_label": label, + "nearest_main_path_distance_mm": distance, + "nearest_main_path_point": _point_payload(_vector(candidate.get("point"))), + "nearest_main_path_over_max_distance": bool( + max_distance_value > 0.0 and distance > max_distance_value + ), + } + + def _set_terminal_access_target_metadata(carrier, candidate): if carrier is None or not isinstance(candidate, dict): return @@ -4452,12 +4586,52 @@ def _set_terminal_access_target_metadata(carrier, candidate): "Whether the terminal access target is only a fallback carrier", "1" if bool(candidate.get("terminal_access_fallback_target", False)) else "0", ) + TerminalObjects.ensure_string_property( + carrier, + "QetTerminalAccessNearestMainPathKind", + PROPERTY_GROUP, + "Nearest main path carrier kind when TerminalAccess falls back to a routing range", + str(candidate.get("nearest_main_path_kind", "") or ""), + ) + TerminalObjects.ensure_string_property( + carrier, + "QetTerminalAccessNearestMainPathName", + PROPERTY_GROUP, + "Nearest main path carrier name when TerminalAccess falls back to a routing range", + str(candidate.get("nearest_main_path_name", "") or ""), + ) + TerminalObjects.ensure_string_property( + carrier, + "QetTerminalAccessNearestMainPathLabel", + PROPERTY_GROUP, + "Nearest main path carrier label when TerminalAccess falls back to a routing range", + str(candidate.get("nearest_main_path_label", "") or ""), + ) + TerminalObjects.ensure_string_property( + carrier, + "QetTerminalAccessNearestMainPathOverMaxDistance", + PROPERTY_GROUP, + "Whether the nearest main path exceeded the configured terminal access max distance", + "1" if bool(candidate.get("nearest_main_path_over_max_distance", False)) else "0", + ) _ensure_float_property( carrier, "QetTerminalAccessTargetDistanceMm", "Distance from terminal local exit to selected access target", float(candidate.get("distance", 0.0) or 0.0), ) + _ensure_float_property( + carrier, + "QetTerminalAccessNearestMainPathDistanceMm", + "Distance from terminal local exit to the nearest main path when fallback is selected", + float(candidate.get("nearest_main_path_distance_mm", 0.0) or 0.0), + ) + _ensure_float_property( + carrier, + "QetTerminalAccessMaxDistanceMm", + "Configured maximum distance allowed for TerminalAccess", + float(candidate.get("terminal_access_max_distance_mm", 0.0) or 0.0), + ) _ensure_integer_property( carrier, "QetTerminalAccessTargetComponentPrimarySegments", @@ -4583,6 +4757,7 @@ def create_routing_path_network_from_document( layout_space = prepare_layout_space_sources_from_document( doc, project_uuid=project_uuid, + include_existing_network=False, ) selected_wire_ducts = [] selected_user_paths = [] @@ -5014,29 +5189,40 @@ def nearest_node(network, point): return best_key, best_distance -def nearest_point_on_network(network, point): - """Return the closest point on any route-network edge. +def _route_carrier_summary(carrier): + if carrier is None: + return { + "carrier_kind": "", + "carrier_name": "", + "carrier_label": "", + } + name = (getattr(carrier, "Name", "") or "").strip() + label = (getattr(carrier, "Label", "") or "").strip() or name + kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() + return { + "carrier_kind": kind, + "carrier_name": name, + "carrier_label": label, + } - The point may lie in the middle of a carrier segment. If a TerminalAccess - carrier ends there, the next graph build will split the crossed segment at - that point and create an EPLAN-like jump-in routing point. - """ + +def _nearest_point_on_network_payload(network, point): + """Return closest route-network point and the carrier owning that segment.""" if not isinstance(network, dict): - return None, None + return None nodes = network.get("nodes", {}) or {} edges = network.get("edges", {}) or {} if not nodes or not edges: - return None, None + return None target = _vector(point) - best_point = None - best_distance = None + best_payload = None seen = set() for key, neighbors in edges.items(): start = nodes.get(key) if start is None: continue - for next_key, _weight, _carrier in neighbors: + for next_key, _weight, carrier in neighbors: pair = tuple(sorted((key, next_key))) if pair in seen: continue @@ -5046,12 +5232,39 @@ def nearest_point_on_network(network, point): continue candidate = _closest_point_on_segment(target, start, end) distance = _distance(target, candidate) - if best_distance is None or distance < best_distance: - best_point = candidate - best_distance = distance - if best_point is not None: - return best_point, best_distance - return nearest_node(network, target) + if best_payload is None or distance < best_payload["distance"]: + payload = { + "point": candidate, + "distance": float(distance), + } + payload.update(_route_carrier_summary(carrier)) + best_payload = payload + if best_payload is not None: + return best_payload + + node_key, distance = nearest_node(network, target) + if node_key is None: + return None + return { + "point": nodes.get(node_key), + "distance": float(distance), + "carrier_kind": "", + "carrier_name": "", + "carrier_label": "", + } + + +def nearest_point_on_network(network, point): + """Return the closest point on any route-network edge. + + The point may lie in the middle of a carrier segment. If a TerminalAccess + carrier ends there, the next graph build will split the crossed segment at + that point and create an EPLAN-like jump-in routing point. + """ + payload = _nearest_point_on_network_payload(network, point) + if payload is None: + return None, None + return payload.get("point"), payload.get("distance") def connection_point_candidates(network, point, limit=8, max_distance=0.0): @@ -5927,6 +6140,9 @@ def _terminal_for_access_carrier(carrier): def _terminal_access_diagnostic_payload(carrier): terminal = _terminal_for_access_carrier(carrier) access_points = _normalized_route_points(_carrier_points(carrier)) + endpoint_device_avoided = str( + getattr(carrier, "QetTerminalAccessAvoidedEndpointDevice", "") or "" + ).strip() == "1" payload = { "access_carrier_name": getattr(carrier, "Name", "") or "", "access_carrier_label": getattr(carrier, "Label", "") or "", @@ -5935,6 +6151,15 @@ def _terminal_access_diagnostic_payload(carrier): "target_label": (getattr(carrier, "QetTerminalAccessTargetLabel", "") or "").strip(), "target_rule": (getattr(carrier, "QetTerminalAccessTargetRule", "") or "").strip(), "target_distance_mm": float(getattr(carrier, "QetTerminalAccessTargetDistanceMm", 0.0) or 0.0), + "terminal_access_max_distance_mm": float(getattr(carrier, "QetTerminalAccessMaxDistanceMm", 0.0) or 0.0), + "nearest_main_path_kind": (getattr(carrier, "QetTerminalAccessNearestMainPathKind", "") or "").strip(), + "nearest_main_path_name": (getattr(carrier, "QetTerminalAccessNearestMainPathName", "") or "").strip(), + "nearest_main_path_label": (getattr(carrier, "QetTerminalAccessNearestMainPathLabel", "") or "").strip(), + "nearest_main_path_distance_mm": float(getattr(carrier, "QetTerminalAccessNearestMainPathDistanceMm", 0.0) or 0.0), + "nearest_main_path_over_max_distance": str( + getattr(carrier, "QetTerminalAccessNearestMainPathOverMaxDistance", "") or "" + ).strip() == "1", + "endpoint_device_avoided": endpoint_device_avoided, "access_length_mm": float(_route_length(access_points)), "access_points": [_point_payload(point) for point in access_points], } @@ -5952,6 +6177,13 @@ def _terminal_access_diagnostic_payload(carrier): "parent_device_element_uuid": terminal_payload.get("parent_device_element_uuid", ""), } ) + if endpoint_device_avoided: + try: + bbox = _terminal_parent_device_bbox(terminal, _vector(TerminalObjects.terminal_origin(terminal))) + except Exception: + bbox = None + if bbox is not None: + payload["endpoint_device_bbox"] = _bbox_payload(bbox, clearance=0.0) return payload @@ -6132,12 +6364,13 @@ def diagnose_routing_path_network( terminal_access_max_distance=DEFAULT_TERMINAL_ACCESS_MAX_DISTANCE, terminal_access_warning_distance=0.0, adjoining_duct_tolerance=DEFAULT_ADJOINING_DUCT_TOLERANCE, + route_network=None, ): """Inspect the generated routing path network without routing wires.""" if doc is None: raise RoutingNetworkError("No FreeCAD document is available.") - network = build_route_graph(doc, adjoining_duct_tolerance=adjoining_duct_tolerance) + network = route_network if isinstance(route_network, dict) else build_route_graph(doc, adjoining_duct_tolerance=adjoining_duct_tolerance) components = _route_graph_components(network) summary = _network_summary_from_graph(network) isolated_components = _actionable_isolated_components(components) @@ -6185,7 +6418,9 @@ def diagnose_routing_path_network( max_exit_length=terminal_exit_max_length, ) exit_point = terminal_access_points[-1] if terminal_access_points else _terminal_exit_point(terminal, terminal_exit_length) - nearest_point, distance = nearest_point_on_network(network, exit_point) + nearest_payload = _nearest_point_on_network_payload(network, exit_point) + nearest_point = nearest_payload.get("point") if nearest_payload is not None else None + distance = nearest_payload.get("distance") if nearest_payload is not None else None access_carrier = _live_source_carrier(doc, terminal) access_live = access_carrier is not None and is_route_carrier(access_carrier) too_far = nearest_point is None or (max_distance > 0.0 and float(distance or 0.0) > max_distance) @@ -6197,11 +6432,17 @@ def diagnose_routing_path_network( "access_carrier": getattr(access_carrier, "Name", "") if access_carrier is not None else "", "nearest_network_distance_mm": None if distance is None else float(distance), "nearest_network_point": None if nearest_point is None else _point_payload(nearest_point), + "nearest_network_carrier_kind": "" if nearest_payload is None else nearest_payload.get("carrier_kind", ""), + "nearest_network_carrier_name": "" if nearest_payload is None else nearest_payload.get("carrier_name", ""), + "nearest_network_carrier_label": "" if nearest_payload is None else nearest_payload.get("carrier_label", ""), "terminal_access_max_distance_mm": float(max_distance), "terminal_exit_length_mm": float(max(float(terminal_exit_length or 0.0), 0.0)), + "terminal_exit_point": _point_payload(exit_point), "code": "terminal_access_missing" if not access_live else "terminal_access_too_far", } ) + # 未接入端子也保留端子出线折线,方便手测时判断是端子方向错、出线过短,还是主路径入口缺失。 + payload.update(_terminal_access_geometry_payload(terminal_access_points)) unconnected_terminals.append(payload) continue @@ -6219,6 +6460,23 @@ def diagnose_routing_path_network( "code": "terminal_access_long", } ) + if access_carrier is not None: + access_payload = _terminal_access_diagnostic_payload(access_carrier) + for key in ( + "target_kind", + "target_name", + "target_label", + "target_rule", + "target_distance_mm", + "nearest_main_path_kind", + "nearest_main_path_name", + "nearest_main_path_label", + "nearest_main_path_distance_mm", + "nearest_main_path_over_max_distance", + "endpoint_device_avoided", + ): + if key in access_payload: + payload[key] = access_payload.get(key) payload.update(_terminal_access_geometry_payload(access_points)) long_terminal_accesses.append(payload) @@ -6530,6 +6788,20 @@ def _diagnostic_terminal_text(sample): ) +def _diagnostic_nearest_network_carrier_text(sample): + if not isinstance(sample, dict): + return "" + label = str(sample.get("nearest_network_carrier_label", "") or "").strip() + name = str(sample.get("nearest_network_carrier_name", "") or "").strip() + kind = str(sample.get("nearest_network_carrier_kind", "") or "").strip() + text = label or name + if not text: + return "" + if kind: + return "{0}({1})".format(text, kind) + return text + + def _routing_path_network_diagnostic_message(diagnostic): if not isinstance(diagnostic, dict): return "布线路径网络检查失败:诊断结果无效。" @@ -6552,17 +6824,29 @@ def _routing_path_network_diagnostic_message(diagnostic): unconnected = _diagnostic_items(diagnostic.get("unconnected_terminals", []) or []) if unconnected: sample = unconnected[0] - message += "\n端子未接入:{0},距离最近网络 {1},当前端子接入最大距离 {2}。".format( + nearest_carrier = _diagnostic_nearest_network_carrier_text(sample) + nearest_carrier_clause = ",最近路径 {0}".format(nearest_carrier) if nearest_carrier else "" + message += "\n端子未接入:{0},距离最近网络 {1}{2},当前端子接入最大距离 {3}。".format( _diagnostic_terminal_text(sample), _diagnostic_distance_text(sample.get("nearest_network_distance_mm")), + nearest_carrier_clause, _diagnostic_distance_text(sample.get("terminal_access_max_distance_mm")), ) long_accesses = _diagnostic_items(diagnostic.get("long_terminal_accesses", []) or []) if long_accesses: sample = long_accesses[0] - message += "\n端子接入过长:{0},接入段 {1}。".format( + target_label = str(sample.get("target_label", "") or sample.get("target_name", "") or "").strip() + target_kind = str(sample.get("target_kind", "") or "").strip() + target_clause = "" + if target_label or target_kind: + target_text = target_label or "未知目标" + if target_kind: + target_text = "{0}({1})".format(target_text, target_kind) + target_clause = ",目标 {0}".format(target_text) + message += "\n端子接入过长:{0},接入段 {1}{2}。".format( _diagnostic_terminal_text(sample), _diagnostic_distance_text(sample.get("terminal_access_length_mm")), + target_clause, ) capped_exits = _diagnostic_items(diagnostic.get("capped_terminal_exits", []) or []) if capped_exits: @@ -6630,6 +6914,24 @@ def _routing_path_network_diagnostic_message(diagnostic): ) else: message += "\n线槽未接入端子主网络:{0}。".format(carrier_text) + fallback_targets = _diagnostic_items(diagnostic.get("terminal_access_fallback_targets", []) or []) + if fallback_targets: + sample = fallback_targets[0] + target_text = sample.get("target_label") or sample.get("target_name") or "布线面" + nearest_main = sample.get("nearest_main_path_label") or sample.get("nearest_main_path_name") or "" + if nearest_main: + message += "\n端子接入退回布线面:{0} 接入到 {1};最近主路径 {2},距离 {3},端子接入最大距离 {4}。".format( + _diagnostic_terminal_text(sample), + target_text, + nearest_main, + _diagnostic_distance_text(sample.get("nearest_main_path_distance_mm")), + _diagnostic_distance_text(sample.get("terminal_access_max_distance_mm")), + ) + else: + message += "\n端子接入退回布线面:{0} 接入到 {1},附近没有可用线槽/UserPath/过线孔主路径。".format( + _diagnostic_terminal_text(sample), + target_text, + ) isolated = _diagnostic_items(diagnostic.get("isolated_components", []) or []) if isolated: sample = isolated[0] diff --git a/src/Mod/FreeCADExchange/TerminalImport.py b/src/Mod/FreeCADExchange/TerminalImport.py index 6329853..3d1afa6 100644 --- a/src/Mod/FreeCADExchange/TerminalImport.py +++ b/src/Mod/FreeCADExchange/TerminalImport.py @@ -1,7 +1,8 @@ # FreeCADExchange terminal import helpers. -from collections import OrderedDict +from collections import Counter, OrderedDict import json +import uuid import FreeCAD as App @@ -40,7 +41,14 @@ def _normalize_terminal_entry(item, index): device_instance_id = (item.get("device_instance_id") or "").strip() element_uuid = (item.get("element_uuid") or "").strip() - terminal_display = (item.get("terminal_display") or "").strip() + terminal_display = ( + item.get("terminal_display") + or item.get("terminal_label") + or item.get("slot_name_hint") + or item.get("slot_name") + or "" + ).strip() + terminal_instance_id = (item.get("terminal_instance_id") or "").strip() slot_name_hint = ( item.get("slot_name_hint") or item.get("terminal_display") @@ -54,6 +62,7 @@ def _normalize_terminal_entry(item, index): "terminal_uuid": terminal_uuid, "device_instance_id": device_instance_id, "element_uuid": element_uuid, + "terminal_instance_id": terminal_instance_id, "terminal_display": terminal_display, "slot_name_hint": slot_name_hint, } @@ -152,21 +161,23 @@ def _device_embedded_terminal_entries(payload, existing_keys): # QET 的正式端子可能直接挂在 devices[].terminals[] 下。 # 直接调用本模块时也要读取它,避免正式布线匹配退回 local:* 端子。 - key = (element_uuid, terminal_uuid) - if key in seen: - continue - seen.add(key) terminal_display = ( terminal.get("terminal_display") or terminal.get("terminal_label") + or terminal.get("slot_name_hint") or terminal.get("slot_name") or "" ) + key = _terminal_context_key(element_uuid, terminal_uuid, terminal_display) + if key in seen: + continue + seen.add(key) entries.append( { "terminal_uuid": terminal_uuid, "element_uuid": element_uuid, "device_instance_id": device_instance_id, + "terminal_instance_id": (terminal.get("terminal_instance_id") or "").strip(), "terminal_display": terminal_display, "slot_name_hint": terminal_display, } @@ -192,20 +203,21 @@ def _wire_endpoint_terminal_entries(payload, existing_keys): if not terminal_uuid or not (element_uuid or device_instance_id): continue - key = (element_uuid, terminal_uuid) - if key in seen: - continue - seen.add(key) terminal_display = ( wire.get("{0}_terminal_display".format(side)) or wire.get("{0}_terminal_label".format(side)) or "" ) + key = _terminal_context_key(element_uuid, terminal_uuid, terminal_display) + if key in seen: + continue + seen.add(key) entries.append( { "terminal_uuid": terminal_uuid, "element_uuid": element_uuid, "device_instance_id": device_instance_id, + "terminal_instance_id": "", "terminal_display": terminal_display, "slot_name_hint": terminal_display, } @@ -275,6 +287,110 @@ def _terminal_existing_index(container): return index +def _terminal_existing_by_instance(container): + index = OrderedDict() + for obj in TerminalObjects.collect_terminal_objects(container): + terminal_instance_id = getattr(obj, "QetTerminalInstanceId", "").strip() + if terminal_instance_id and terminal_instance_id not in index: + index[terminal_instance_id] = obj + return index + + +def _terminal_existing_by_element_uuid(container): + index = OrderedDict() + for obj in TerminalObjects.collect_terminal_objects(container): + terminal_uuid = getattr(obj, "QetTerminalUuid", "").strip() + element_uuid = getattr(obj, "QetElementUuid", "").strip() + key = (element_uuid, terminal_uuid) + if element_uuid and terminal_uuid and key not in index: + index[key] = obj + return index + + +def _terminal_context_key(element_uuid, terminal_uuid, terminal_display): + return ( + (element_uuid or "").strip(), + (terminal_uuid or "").strip(), + (terminal_display or "").strip(), + ) + + +def _terminal_entry_context_key(entry): + return _terminal_context_key( + entry.get("element_uuid", ""), + entry.get("terminal_uuid", ""), + entry.get("terminal_display", ""), + ) + + +def _terminal_object_display(obj): + if "QetTerminalDisplay" in getattr(obj, "PropertiesList", []): + return getattr(obj, "QetTerminalDisplay", "").strip() + return ( + getattr(obj, "Label", "").strip() + or getattr(obj, "QetTemplateSlotName", "").strip() + ) + + +def _terminal_existing_by_context(container): + index = OrderedDict() + for obj in TerminalObjects.collect_terminal_objects(container): + terminal_uuid = getattr(obj, "QetTerminalUuid", "").strip() + element_uuid = getattr(obj, "QetElementUuid", "").strip() + terminal_display = _terminal_object_display(obj) + key = _terminal_context_key(element_uuid, terminal_uuid, terminal_display) + if element_uuid and terminal_uuid and terminal_display and key not in index: + index[key] = obj + return index + + +def _terminal_instance_id_seed(project_uuid, entry): + values = [ + project_uuid, + entry.get("device_instance_id", ""), + entry.get("element_uuid", ""), + entry.get("terminal_uuid", ""), + entry.get("terminal_display", ""), + entry.get("slot_name_hint", ""), + ] + return "qet-freecad-terminal|" + "|".join(str(value or "").strip() for value in values) + + +def _stable_terminal_instance_id(project_uuid, entry): + return str(uuid.uuid5(uuid.NAMESPACE_URL, _terminal_instance_id_seed(project_uuid, entry))) + + +def _terminal_instance_counts(items): + values = [] + for item in list(items or []): + if not isinstance(item, dict): + continue + value = str(item.get("terminal_instance_id", "") or "").strip() + if value: + values.append(value) + return Counter(values) + + +def _repair_terminal_instance_id(entry, project_uuid, raw_counts, used_ids): + raw_value = str(entry.get("terminal_instance_id", "") or "").strip() + must_repair = ( + not raw_value + or raw_counts.get(raw_value, 0) > 1 + or raw_value in used_ids + ) + if must_repair: + # QET 当前 v2 快照里可能出现重复 terminal_uuid / terminal_instance_id。 + # FreeCAD 侧短期修复必须避开导出顺序,所以用设备实例、2D 元件、端子 UUID 和脚号生成稳定 ID。 + raw_value = _stable_terminal_instance_id(project_uuid, entry) + suffix = 1 + while raw_value in used_ids: + raw_value = str(uuid.uuid5(uuid.NAMESPACE_URL, _terminal_instance_id_seed(project_uuid, entry) + "|{0}".format(suffix))) + suffix += 1 + entry["terminal_instance_id"] = raw_value + used_ids.add(raw_value) + return must_repair + + def _terminal_existing_local_by_slot(container): index = {} for obj in TerminalObjects.collect_terminal_objects(container): @@ -428,6 +544,8 @@ def _create_terminal_object(doc, terminal_uuid, entry, slot, terminal_group, pro instance_id, label=terminal_label, slot_name=slot.get("name", ""), + terminal_instance_id=entry.get("terminal_instance_id", ""), + terminal_display=entry.get("terminal_display", ""), ) _set_terminal_geometry_source(terminal_obj, slot.get("source", "template")) _set_terminal_local_route_points(terminal_obj, slot) @@ -435,6 +553,45 @@ def _create_terminal_object(doc, terminal_uuid, entry, slot, terminal_group, pro return terminal_obj +def _remove_terminal_from_group(doc, terminal_group, terminal_obj): + try: + children = list(getattr(terminal_group, "Group", []) or []) + if terminal_obj in children: + children.remove(terminal_obj) + terminal_group.Group = children + except Exception: + try: + terminal_group.Group.remove(terminal_obj) + except Exception: + pass + try: + if doc.getObject(terminal_obj.Name) is not None: + doc.removeObject(terminal_obj.Name) + except Exception: + pass + + +def _terminal_object_context_key(terminal_obj): + return _terminal_context_key( + getattr(terminal_obj, "QetElementUuid", ""), + getattr(terminal_obj, "QetTerminalUuid", ""), + _terminal_object_display(terminal_obj), + ) + + +def _remove_stale_qet_terminals(doc, terminal_group, expected_contexts, used_objects): + removed = 0 + for terminal_obj in list(TerminalObjects.collect_terminal_objects(terminal_group)): + terminal_uuid = getattr(terminal_obj, "QetTerminalUuid", "").strip() + if TerminalObjects.is_local_terminal_uuid(terminal_uuid): + continue + # 当前 2d_to_3d.json 是端子快照;不在快照中的历史 QET 端子会干扰回写和布线匹配,需要清理。 + if terminal_obj not in used_objects or _terminal_object_context_key(terminal_obj) not in expected_contexts: + _remove_terminal_from_group(doc, terminal_group, terminal_obj) + removed += 1 + return removed + + def import_terminals_from_payload(payload, scene_path=""): _append_debug_log("TerminalImport.import_terminals_from_payload entered") @@ -456,17 +613,25 @@ def import_terminals_from_payload(payload, scene_path=""): continue element_uuid = (item.get("element_uuid") or "").strip() terminal_uuid = (item.get("terminal_uuid") or "").strip() + terminal_display = ( + item.get("terminal_display") + or item.get("terminal_label") + or item.get("slot_name_hint") + or item.get("slot_name") + or "" + ).strip() if element_uuid and terminal_uuid: - terminal_entry_keys.add((element_uuid, terminal_uuid)) + terminal_entry_keys.add(_terminal_context_key(element_uuid, terminal_uuid, terminal_display)) embedded_entries = _device_embedded_terminal_entries(payload, terminal_entry_keys) terminal_entries.extend(embedded_entries) terminal_entry_keys.update( - (entry["element_uuid"], entry["terminal_uuid"]) + _terminal_entry_context_key(entry) for entry in embedded_entries if entry.get("element_uuid") and entry.get("terminal_uuid") ) synthesized_entries = _wire_endpoint_terminal_entries(payload, terminal_entry_keys) terminal_entries.extend(synthesized_entries) + raw_terminal_instance_counts = _terminal_instance_counts(terminal_entries) device_lookup = _payload_device_lookup(payload) @@ -487,10 +652,12 @@ def import_terminals_from_payload(payload, scene_path=""): "skipped_missing_device": 0, "skipped_invalid_entry": 0, "skipped_unmatched_parent": 0, + "repaired_terminal_instance_ids": 0, "warnings": [], } grouped = OrderedDict() + used_terminal_instance_ids = set() for index, item in enumerate(terminal_entries): report["total_terminals"] += 1 try: @@ -503,6 +670,13 @@ def import_terminals_from_payload(payload, scene_path=""): if not _terminal_belongs_to_payload_devices(entry, device_lookup): report["skipped_unmatched_parent"] += 1 continue + if _repair_terminal_instance_id( + entry, + project_uuid, + raw_terminal_instance_counts, + used_terminal_instance_ids, + ): + report["repaired_terminal_instance_ids"] += 1 device_group = _locate_device_group(doc, entry) if device_group is None: @@ -537,7 +711,12 @@ def import_terminals_from_payload(payload, scene_path=""): terminal_group = _terminal_container_for_device(doc, device_group, project_uuid) existing_by_uuid = _terminal_existing_index(terminal_group) + existing_by_instance = _terminal_existing_by_instance(terminal_group) + existing_by_context = _terminal_existing_by_context(terminal_group) + existing_by_element_uuid = _terminal_existing_by_element_uuid(terminal_group) existing_local_by_slot = _terminal_existing_local_by_slot(terminal_group) + entry_uuid_counts = Counter(entry.get("terminal_uuid", "") for entry in entries) + expected_contexts = set(_terminal_entry_context_key(entry) for entry in entries) used_uuids = set() used_objects = set() used_slot_names = set() @@ -587,18 +766,40 @@ def import_terminals_from_payload(payload, scene_path=""): if slot_name: used_slot_names.add(slot_name) - terminal_obj = existing_by_uuid.get(terminal_uuid) + terminal_obj = existing_by_instance.get(entry.get("terminal_instance_id", "")) + if terminal_obj in used_objects: + terminal_obj = None + if terminal_obj is None: + terminal_obj = existing_by_context.get(_terminal_entry_context_key(entry)) + if terminal_obj in used_objects: + terminal_obj = None + if terminal_obj is None: + terminal_obj = existing_by_element_uuid.get((entry.get("element_uuid", ""), terminal_uuid)) + if terminal_obj in used_objects: + terminal_obj = None + if terminal_obj is None: + terminal_obj = ( + existing_by_uuid.get(terminal_uuid) + if entry_uuid_counts.get(terminal_uuid, 0) == 1 + else None + ) + if terminal_obj in used_objects: + terminal_obj = None if terminal_obj is None: terminal_obj = existing_local_by_slot.get(slot_name) + if terminal_obj in used_objects: + terminal_obj = None if terminal_obj is not None: TerminalObjects.set_terminal_semantics( terminal_obj, project_uuid, - device_element_uuid, + entry.get("element_uuid", "") or device_element_uuid, terminal_uuid, device_instance_id, label=_terminal_entry_label(entry, slot, terminal_uuid), slot_name=slot.get("name", ""), + terminal_instance_id=entry.get("terminal_instance_id", ""), + terminal_display=entry.get("terminal_display", ""), ) _set_terminal_geometry_source(terminal_obj, slot.get("source", "template")) _set_terminal_local_route_points(terminal_obj, slot) @@ -616,7 +817,7 @@ def import_terminals_from_payload(payload, scene_path=""): slot, terminal_group, project_uuid, - device_element_uuid, + entry.get("element_uuid", "") or device_element_uuid, device_instance_id, ) report["imported_terminals"] += 1 @@ -624,12 +825,14 @@ def import_terminals_from_payload(payload, scene_path=""): TerminalObjects.set_terminal_semantics( terminal_obj, project_uuid, - device_element_uuid, + entry.get("element_uuid", "") or device_element_uuid, terminal_uuid, device_instance_id, - label=_terminal_entry_label(entry, slot, terminal_uuid), - slot_name=slot.get("name", ""), - ) + label=_terminal_entry_label(entry, slot, terminal_uuid), + slot_name=slot.get("name", ""), + terminal_instance_id=entry.get("terminal_instance_id", ""), + terminal_display=entry.get("terminal_display", ""), + ) _set_terminal_geometry_source(terminal_obj, slot.get("source", "template")) _set_terminal_local_route_points(terminal_obj, slot) try: @@ -649,6 +852,13 @@ def import_terminals_from_payload(payload, scene_path=""): _hide_object(source_obj) report["reused_template_hints"] += 1 + if entries: + report["removed_terminals"] += _remove_stale_qet_terminals( + doc, + terminal_group, + expected_contexts, + used_objects, + ) TerminalObjects.sort_group_children(terminal_group) TerminalObjects.sort_group_children(root_group) diff --git a/src/Mod/FreeCADExchange/TerminalObjects.py b/src/Mod/FreeCADExchange/TerminalObjects.py index e4cc32f..b5729ba 100644 --- a/src/Mod/FreeCADExchange/TerminalObjects.py +++ b/src/Mod/FreeCADExchange/TerminalObjects.py @@ -548,6 +548,8 @@ def set_terminal_semantics( instance_id, label="", slot_name="", + terminal_instance_id="", + terminal_display="", ): ensure_string_property( obj, @@ -589,6 +591,27 @@ def set_terminal_semantics( "Parent instance UUID for this terminal", instance_id, ) + # QetInstanceId 表示父设备实例;QetTerminalInstanceId 才是 3D 端子对象自己的稳定实例 ID。 + # 旧工程可能没有该字段,缺省时先兼容退回父设备实例,导入器会尽量写入真正端子实例 ID。 + terminal_instance_id = ( + (terminal_instance_id or "").strip() + or getattr(obj, "QetTerminalInstanceId", "").strip() + or instance_id + ) + ensure_string_property( + obj, + "QetTerminalInstanceId", + "QET Exchange", + "Stable 3D terminal instance UUID", + terminal_instance_id, + ) + ensure_string_property( + obj, + "QetTerminalDisplay", + "QET Exchange", + "QET terminal display text / terminal number", + (terminal_display or "").strip(), + ) ensure_string_property( obj, "Role", diff --git a/tests/python/freecad_exchange_auto_routing_test.py b/tests/python/freecad_exchange_auto_routing_test.py index 395f615..300b77f 100644 --- a/tests/python/freecad_exchange_auto_routing_test.py +++ b/tests/python/freecad_exchange_auto_routing_test.py @@ -541,6 +541,43 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual("Dashed", wire.ViewObject.DrawStyle) self.assertEqual("DashLine", style["line_type"]) + def test_eplan_connection_route_records_applied_visual_style_metadata(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + wire_uuid="wire-style-metadata", + options={ + "wire_style_id": "style-blue", + "wire_style_lookup": lambda style_id, project_uuid: { + "id": style_id, + "line_color": "#3366CC", + "line_width": 2.5, + "line_type": "虚线", + }, + }, + ) + + wire = result["wire"] + self.assertTrue(wire.QetWireStyleApplied) + self.assertEqual("#3366CC", wire.QetAppliedWireLineColor) + self.assertEqual("2.5", wire.QetAppliedWireLineWidth) + self.assertEqual("Dashed", wire.QetAppliedWireDrawStyle) + def test_eplan_connection_route_accepts_bare_hex_color_and_diameter_width(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() @@ -2744,6 +2781,69 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(fallback_carrier.Name, bridges[0].QetRouteBridgeLeftSourceName) self.assertEqual(main_path.Name, bridges[0].QetRouteBridgeRightSourceName) + def test_controller_terminal_access_fallback_bridge_prefers_diagnostic_nearest_main_path(self): + _install_fake_freecad() + terminal_objects, wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") + fallback_carrier = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], + project_uuid="project-1", + kind="RoutingRange", + label="安装板布线面", + ) + nearer_but_not_recommended = routing_network.create_route_carrier( + doc, + [app.Vector(120, 0, 0), app.Vector(220, 0, 0)], + project_uuid="project-1", + kind="UserPath", + label="近处非推荐路径", + ) + recommended_main_path = routing_network.create_route_carrier( + doc, + [app.Vector(300, 30, 0), app.Vector(400, 30, 0)], + project_uuid="project-1", + kind="UserPath", + label="诊断推荐主路径", + ) + diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingPathNetwork") + diagnostic.QetDiagnosticKind = "RoutingPathNetwork" + diagnostic.QetProjectUuid = "project-1" + diagnostic.QetDiagnosticJson = json.dumps( + { + "terminal_access_fallback_targets": [ + { + "target_kind": "RoutingRange", + "target_name": fallback_carrier.Name, + "target_label": "安装板布线面", + "nearest_main_path_name": recommended_main_path.Name, + "nearest_main_path_label": "诊断推荐主路径", + "terminal_uuid": "terminal-ud8-as", + } + ], + }, + ensure_ascii=False, + ) + diagnostic_group.addObject(diagnostic) + + result = auto_routing_panel.AutoRoutingController().create_user_path_bridges_from_terminal_access_fallback_targets() + + bridges = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if getattr(carrier, "QetRouteBridgeKind", "") == "TerminalAccessFallbackBridge" + ] + self.assertEqual(1, result["terminal_access_fallback_user_path_bridges"]) + self.assertEqual(1, len(bridges)) + self.assertEqual(fallback_carrier.Name, bridges[0].QetRouteBridgeLeftSourceName) + self.assertEqual(recommended_main_path.Name, bridges[0].QetRouteBridgeRightSourceName) + self.assertNotEqual(nearer_but_not_recommended.Name, bridges[0].QetRouteBridgeRightSourceName) + def test_controller_terminal_access_fallback_bridge_prefers_nearest_segment_not_endpoint(self): _install_fake_freecad() terminal_objects, wiring_objects, routing_network, _auto_routing = _reload_modules() @@ -2890,6 +2990,49 @@ class AutoRoutingTest(unittest.TestCase): ) self.assertIn("自动诊断桥接:生成 UserPath 1 条", message) + def test_route_eplan_connections_keeps_unconnected_terminal_bridge_summary(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + original_create = routing_network.create_user_path_bridges_from_diagnostic_suggestions + + def fake_create(_doc, _diagnostic, project_uuid=""): + return { + "suggestions": 1, + "created": [object()], + "duplicates": 0, + "stale_suggestions": 0, + "unconnected_terminal_access_bridge_targets": 1, + "unconnected_terminal_access_user_path_bridges": 1, + "unconnected_terminal_access_bridge_duplicates": 0, + "unconnected_terminal_access_bridge_pair_labels": ["A1 接入段 -> 最近线槽"], + } + + try: + routing_network.create_user_path_bridges_from_diagnostic_suggestions = fake_create + report = auto_routing.route_eplan_connections( + doc, + payload={"project_uuid": "project-1", "wires": []}, + options={"auto_create_diagnostic_bridges": True}, + project_uuid="project-1", + update_network=False, + ) + finally: + routing_network.create_user_path_bridges_from_diagnostic_suggestions = original_create + + summary = report["auto_diagnostic_bridges"] + message = auto_routing.format_eplan_connection_route_report(report) + self.assertEqual(1, summary["created_count"]) + self.assertEqual(1, summary["unconnected_terminal_access_bridge_targets"]) + self.assertEqual(1, summary["unconnected_terminal_access_user_path_bridges"]) + self.assertEqual(0, summary["unconnected_terminal_access_bridge_duplicates"]) + self.assertEqual(["A1 接入段 -> 最近线槽"], summary["unconnected_terminal_access_bridge_pair_labels"]) + self.assertIn("未接入端子接入段 1 个", message) + self.assertIn("配对:A1 接入段 -> 最近线槽", message) + def test_route_eplan_connections_does_not_auto_create_diagnostic_bridge_when_disabled(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() @@ -2948,7 +3091,17 @@ class AutoRoutingTest(unittest.TestCase): def fake_create(_doc, diagnostic, project_uuid=""): calls["create"] += 1 if int(diagnostic.get("pass_index", 0) or 0) <= 2: - return {"suggestions": 1, "created": [object()], "duplicates": 0, "stale_suggestions": 0} + pair_label = "A{0} 接入段 -> 最近线槽".format(calls["create"]) + return { + "suggestions": 1, + "created": [object()], + "duplicates": 0, + "stale_suggestions": 0, + "unconnected_terminal_access_bridge_targets": 1, + "unconnected_terminal_access_user_path_bridges": 1, + "unconnected_terminal_access_bridge_duplicates": 0, + "unconnected_terminal_access_bridge_pair_labels": [pair_label], + } return {"suggestions": 0, "created": [], "duplicates": 0, "stale_suggestions": 0} try: @@ -2963,6 +3116,13 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(2, result["user_path_bridges"]) self.assertEqual(2, result["diagnostic_suggestions"]) self.assertEqual(3, result["diagnostic_passes"]) + self.assertEqual(2, result["unconnected_terminal_access_bridge_targets"]) + self.assertEqual(2, result["unconnected_terminal_access_user_path_bridges"]) + self.assertEqual(0, result["unconnected_terminal_access_bridge_duplicates"]) + self.assertEqual( + ["A1 接入段 -> 最近线槽", "A2 接入段 -> 最近线槽"], + result["unconnected_terminal_access_bridge_pair_labels"], + ) self.assertEqual(3, calls["check"]) self.assertEqual(3, calls["create"]) @@ -4856,7 +5016,7 @@ class AutoRoutingTest(unittest.TestCase): def test_controller_selects_terminal_access_fallback_terminal_and_device_from_samples(self): _install_fake_freecad() - terminal_objects, wiring_objects, _routing_network, _auto_routing = _reload_modules() + terminal_objects, wiring_objects, routing_network, _auto_routing = _reload_modules() auto_routing_panel = importlib.import_module("AutoRoutingPanel") app = sys.modules["FreeCAD"] gui = sys.modules["FreeCADGui"] @@ -4921,7 +5081,7 @@ class AutoRoutingTest(unittest.TestCase): def test_controller_selects_terminal_access_fallback_targets_from_path_network_diagnostic(self): _install_fake_freecad() - terminal_objects, wiring_objects, _routing_network, _auto_routing = _reload_modules() + terminal_objects, wiring_objects, routing_network, _auto_routing = _reload_modules() auto_routing_panel = importlib.import_module("AutoRoutingPanel") app = sys.modules["FreeCAD"] gui = sys.modules["FreeCADGui"] @@ -5003,7 +5163,7 @@ class AutoRoutingTest(unittest.TestCase): def test_controller_selects_terminal_access_endpoint_device_avoidance_from_path_diagnostic(self): _install_fake_freecad() - terminal_objects, wiring_objects, _routing_network, _auto_routing = _reload_modules() + terminal_objects, wiring_objects, routing_network, _auto_routing = _reload_modules() auto_routing_panel = importlib.import_module("AutoRoutingPanel") app = sys.modules["FreeCAD"] gui = sys.modules["FreeCADGui"] @@ -5083,7 +5243,7 @@ class AutoRoutingTest(unittest.TestCase): def test_controller_selects_unconnected_terminal_access_issues_from_path_diagnostic(self): _install_fake_freecad() - terminal_objects, wiring_objects, _routing_network, _auto_routing = _reload_modules() + terminal_objects, wiring_objects, routing_network, _auto_routing = _reload_modules() auto_routing_panel = importlib.import_module("AutoRoutingPanel") app = sys.modules["FreeCAD"] gui = sys.modules["FreeCADGui"] @@ -5101,6 +5261,20 @@ class AutoRoutingTest(unittest.TestCase): app.Vector(0, 0, 0), ) device.addObject(terminal) + nearest_path = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + label="最近线槽", + ) + access_carrier = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(0, 0, 20)], + project_uuid="project-1", + kind="TerminalAccess", + label="A1 接入段", + ) diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingPathNetwork") diagnostic.QetDiagnosticKind = "RoutingPathNetwork" diagnostic.QetProjectUuid = "project-1" @@ -5113,7 +5287,10 @@ class AutoRoutingTest(unittest.TestCase): "terminal_uuid": "terminal-unconnected", "parent_device_name": device.Name, "parent_device_label": "未接入设备", + "access_carrier": access_carrier.Name, "nearest_network_distance_mm": 125.0, + "nearest_network_carrier_name": nearest_path.Name, + "nearest_network_carrier_label": "最近线槽", "terminal_access_max_distance_mm": 50.0, }, { @@ -5136,18 +5313,22 @@ class AutoRoutingTest(unittest.TestCase): result = auto_routing_panel.AutoRoutingController().select_unconnected_terminal_access_issues() - self.assertEqual(2, result["selected_unconnected_terminal_access_objects"]) + self.assertEqual(4, result["selected_unconnected_terminal_access_objects"]) self.assertEqual(1, result["selected_unconnected_terminal_access_terminals"]) self.assertEqual(1, result["selected_unconnected_terminal_access_devices"]) + self.assertEqual(1, result["selected_unconnected_terminal_access_carriers"]) + self.assertEqual(1, result["selected_unconnected_terminal_access_nearest_paths"]) self.assertEqual([terminal.Name], result["selected_unconnected_terminal_access_terminal_names"]) self.assertEqual([device.Name], result["selected_unconnected_terminal_access_device_names"]) + self.assertEqual([access_carrier.Name], result["selected_unconnected_terminal_access_carrier_names"]) + self.assertEqual([nearest_path.Name], result["selected_unconnected_terminal_access_nearest_path_names"]) self.assertEqual(["MissingUnconnectedTerminal", "MissingUnconnectedDevice"], result["missing_unconnected_terminal_access_refs"]) self.assertEqual(125.0, result["max_unconnected_terminal_access_distance_mm"]) - self.assertEqual([terminal, device], selected) + self.assertEqual([terminal, device, access_carrier, nearest_path], selected) def test_controller_selects_terminal_exit_issue_terminals_from_path_diagnostic(self): _install_fake_freecad() - terminal_objects, wiring_objects, _routing_network, _auto_routing = _reload_modules() + terminal_objects, wiring_objects, routing_network, _auto_routing = _reload_modules() auto_routing_panel = importlib.import_module("AutoRoutingPanel") app = sys.modules["FreeCAD"] gui = sys.modules["FreeCADGui"] @@ -5460,7 +5641,7 @@ class AutoRoutingTest(unittest.TestCase): def test_controller_selects_long_terminal_accesses_from_latest_batch_diagnostic(self): _install_fake_freecad() - terminal_objects, wiring_objects, _routing_network, _auto_routing = _reload_modules() + terminal_objects, wiring_objects, routing_network, _auto_routing = _reload_modules() auto_routing_panel = importlib.import_module("AutoRoutingPanel") app = sys.modules["FreeCAD"] gui = sys.modules["FreeCADGui"] @@ -5469,6 +5650,20 @@ class AutoRoutingTest(unittest.TestCase): terminal_objects.ensure_root_group(doc, "project-1") terminal_a = _terminal(doc, terminal_objects, "Terminal325", "terminal-325", app.Vector(0, 0, 0)) terminal_b = _terminal(doc, terminal_objects, "Terminal326", "terminal-326", app.Vector(10, 0, 0)) + access_a = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="TerminalAccess", + label="325 接入段", + ) + target_a = routing_network.create_route_carrier( + doc, + [app.Vector(100, 0, 20), app.Vector(200, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + label="远处线槽", + ) diagnostic_group = wiring_objects.ensure_diagnostic_group(doc, "project-1") diagnostic = doc.addObject("App::DocumentObjectGroup", "Diagnostic_RoutingConnectionBatch") diagnostic.QetDiagnosticKind = "RoutingConnectionBatch" @@ -5477,7 +5672,14 @@ class AutoRoutingTest(unittest.TestCase): { "routing_path_network_diagnostic": { "long_terminal_accesses": [ - {"terminal_uuid": "terminal-325", "name": "Terminal325", "label": "325"}, + { + "terminal_uuid": "terminal-325", + "name": "Terminal325", + "label": "325", + "access_carrier": access_a.Name, + "target_name": target_a.Name, + "target_label": "远处线槽", + }, {"terminal_uuid": "terminal-326", "name": "Terminal326", "label": "326"}, {"terminal_uuid": "terminal-404", "name": "Terminal404", "label": "404"}, ] @@ -5498,8 +5700,12 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(2, result["selected_long_terminal_accesses"]) self.assertEqual(["Terminal325", "Terminal326"], result["selected_long_terminal_names"]) + self.assertEqual(1, result["selected_long_terminal_access_carriers"]) + self.assertEqual([access_a.Name], result["selected_long_terminal_access_carrier_names"]) + self.assertEqual(1, result["selected_long_terminal_access_targets"]) + self.assertEqual([target_a.Name], result["selected_long_terminal_access_target_names"]) self.assertEqual(["terminal-404"], result["missing_long_terminal_refs"]) - self.assertEqual([terminal_a, terminal_b], selected) + self.assertEqual([terminal_a, access_a, target_a, terminal_b], selected) def test_controller_selects_long_terminal_accesses_from_path_network_diagnostic(self): _install_fake_freecad() @@ -7105,6 +7311,59 @@ class AutoRoutingTest(unittest.TestCase): end_point = created[0].Points[-1] self.assertEqual((50.0, 5.0, 20.0), (end_point.x, end_point.y, end_point.z)) + def test_terminal_access_fallback_diagnostic_records_nearest_rejected_main_path(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(50, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 5, 20), app.Vector(100, 5, 20)], + project_uuid="project-1", + kind="RoutingRange", + label="安装板布线面", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 150, 20), app.Vector(100, 150, 20)], + project_uuid="project-1", + kind="WireDuct", + label="上方线槽", + ) + + created = routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + terminal_exit_length=20.0, + max_distance=100.0, + ) + result = auto_routing.check_eplan_routing_path_network( + doc, + project_uuid="project-1", + options={"terminal_access_max_distance": 100.0}, + ) + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + payload = json.loads(diagnostic_group.Group[0].QetDiagnosticJson) + + self.assertEqual(1, len(created)) + self.assertEqual("RoutingRange", created[0].QetTerminalAccessTargetKind) + self.assertEqual("fallback_only", created[0].QetTerminalAccessTargetRule) + self.assertEqual("WireDuct", created[0].QetTerminalAccessNearestMainPathKind) + self.assertEqual("上方线槽", created[0].QetTerminalAccessNearestMainPathLabel) + self.assertEqual(150.0, created[0].QetTerminalAccessNearestMainPathDistanceMm) + self.assertEqual(100.0, created[0].QetTerminalAccessMaxDistanceMm) + self.assertTrue(result["diagnostic"]["terminal_access_fallback_targets"]) + sample = payload["terminal_access_fallback_targets"][0] + self.assertEqual("WireDuct", sample["nearest_main_path_kind"]) + self.assertEqual("上方线槽", sample["nearest_main_path_label"]) + self.assertEqual(150.0, sample["nearest_main_path_distance_mm"]) + self.assertEqual(100.0, sample["terminal_access_max_distance_mm"]) + self.assertTrue(sample["nearest_main_path_over_max_distance"]) + self.assertIn("端子接入退回布线面", diagnostic_group.Group[0].QetDiagnosticMessage) + self.assertIn("最近主路径 上方线槽", diagnostic_group.Group[0].QetDiagnosticMessage) + def test_terminal_access_prefers_main_path_over_routing_range_in_same_component(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() @@ -7371,6 +7630,7 @@ class AutoRoutingTest(unittest.TestCase): [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], project_uuid="project-1", kind="WireDuct", + label="远处线槽", ) result = auto_routing.check_eplan_routing_path_network(doc, project_uuid="project-1") @@ -7387,12 +7647,23 @@ class AutoRoutingTest(unittest.TestCase): self.assertIn("端子未接入", diagnostic_group.Group[0].QetDiagnosticMessage) self.assertIn("unconnected_terminals", payload["issue_codes"]) self.assertEqual(1, len(payload["unconnected_terminals"])) - self.assertEqual("terminal-far", payload["unconnected_terminals"][0]["terminal_uuid"]) - self.assertEqual(1000.0, payload["unconnected_terminals"][0]["terminal_access_max_distance_mm"]) + unconnected = payload["unconnected_terminals"][0] + self.assertEqual("terminal-far", unconnected["terminal_uuid"]) + self.assertEqual(1000.0, unconnected["terminal_access_max_distance_mm"]) + self.assertEqual({"x": 5000.0, "y": 0.0, "z": 20.0}, unconnected["terminal_exit_point"]) + self.assertEqual({"x": 100.0, "y": 0.0, "z": 20.0}, unconnected["nearest_network_point"]) + self.assertEqual("WireDuct", unconnected["nearest_network_carrier_kind"]) + self.assertEqual("远处线槽", unconnected["nearest_network_carrier_label"]) + self.assertEqual(2, len(unconnected["terminal_access_points"])) + self.assertEqual({"x": 5000.0, "y": 0.0, "z": 0.0}, unconnected["terminal_access_points"][0]) + self.assertEqual({"x": 5000.0, "y": 0.0, "z": 20.0}, unconnected["terminal_access_points"][1]) + self.assertEqual("z", unconnected["terminal_access_dominant_axis"]) + self.assertEqual(20.0, unconnected["terminal_access_axis_lengths_mm"]["z"]) message = auto_routing.format_routing_path_network_report(result["diagnostic"]) self.assertIn("端子未接入", message) self.assertIn("terminal-far", message) self.assertIn("4900.0 mm", message) + self.assertIn("最近路径 远处线槽(WireDuct)", message) self.assertIn("端子接入最大距离 1000.0 mm", message) self.assertIn("补一段线槽/辅助路径", message) @@ -7413,6 +7684,7 @@ class AutoRoutingTest(unittest.TestCase): [app.Vector(1000, 0, 20), app.Vector(1100, 0, 20)], project_uuid="project-1", kind="WireDuct", + label="远处线槽", ) routing_network.create_terminal_access_carriers_from_document( doc, @@ -7437,6 +7709,9 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(900.0, long_access["terminal_access_length_mm"]) self.assertEqual("PEN", long_access["parent_device_label"]) self.assertEqual("DevicePEN", long_access["parent_device_name"]) + self.assertEqual("WireDuct", long_access["target_kind"]) + self.assertEqual("远处线槽", long_access["target_label"]) + self.assertEqual(900.0, long_access["target_distance_mm"]) self.assertEqual({"x": 100.0, "y": 0.0, "z": 0.0}, long_access["terminal_origin"]) self.assertEqual("x", long_access["terminal_access_dominant_axis"]) self.assertEqual(2, len(long_access["terminal_access_points"])) @@ -7446,6 +7721,7 @@ class AutoRoutingTest(unittest.TestCase): self.assertIn("TerminalLongAccess", message) self.assertIn("terminal-long-access", message) self.assertIn("900.0 mm", message) + self.assertIn("目标 远处线槽(WireDuct)", message) def test_check_routing_path_network_ignores_isolated_routing_range_only_components(self): _install_fake_freecad() @@ -7631,6 +7907,11 @@ class AutoRoutingTest(unittest.TestCase): self.assertIsNotNone(access_carrier) self.assertEqual("TerminalAccess", access_carrier.QetRouteCarrierKind) self.assertEqual("UserPath", sample["target_kind"]) + self.assertTrue(sample["endpoint_device_avoided"]) + self.assertEqual( + {"xmin": -10.0, "xmax": 10.0, "ymin": -10.0, "ymax": 10.0, "zmin": -10.0, "zmax": 10.0}, + sample["endpoint_device_bbox"], + ) self.assertGreater(sample["access_length_mm"], 0.0) self.assertGreaterEqual(len(sample["access_points"]), 2) @@ -7683,12 +7964,20 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual("terminal-corrected", corrected_sample["terminal_uuid"]) self.assertEqual({"x": 1.0, "y": 0.0, "z": 0.0}, corrected_sample["exit_direction"]) self.assertEqual({"x": 0.0, "y": 0.0, "z": 1.0}, corrected_sample["original_exit_direction"]) + self.assertEqual({"x": 0.0, "y": 0.0, "z": 0.0}, corrected_sample["origin"]) + self.assertEqual({"x": 20.0, "y": 0.0, "z": 0.0}, corrected_sample["exit_point"]) + self.assertFalse(corrected_sample["local_route_used"]) + self.assertEqual(0, corrected_sample["local_route_point_count"]) self.assertEqual(1, len(payload["capped_terminal_exits"])) capped_sample = payload["capped_terminal_exits"][0] self.assertEqual("TerminalCappedExit", capped_sample["name"]) self.assertEqual("terminal-capped", capped_sample["terminal_uuid"]) self.assertEqual(30.0, capped_sample["max_exit_length_mm"]) self.assertEqual(30.0, capped_sample["actual_exit_length_mm"]) + self.assertEqual({"x": 100.0, "y": 0.0, "z": 0.0}, capped_sample["origin"]) + self.assertEqual({"x": 100.0, "y": 0.0, "z": 30.0}, capped_sample["exit_point"]) + self.assertFalse(capped_sample["local_route_used"]) + self.assertEqual(0, capped_sample["local_route_point_count"]) self.assertTrue(capped_sample["exit_length_capped"]) def test_compact_routing_path_network_diagnostic_keeps_terminal_access_quality_samples(self): @@ -7699,6 +7988,36 @@ class AutoRoutingTest(unittest.TestCase): "issues": [ {"severity": "warning", "code": "terminal_access_fallback_targets", "count": 1}, {"severity": "info", "code": "terminal_access_endpoint_device_avoidance", "count": 1}, + {"severity": "warning", "code": "terminal_exit_direction_corrected", "count": 1}, + {"severity": "warning", "code": "terminal_exit_length_capped", "count": 1}, + {"severity": "warning", "code": "invalid_terminal_local_routes", "count": 1}, + ], + "corrected_terminal_exits": [ + { + "name": "TerminalCorrectedExit", + "terminal_uuid": "terminal-corrected", + "parent_device_name": "DeviceQF2", + "exit_direction": {"x": 1.0, "y": 0.0, "z": 0.0}, + "original_exit_direction": {"x": 0.0, "y": 0.0, "z": 1.0}, + "origin": {"x": 0.0, "y": 0.0, "z": 0.0}, + "exit_point": {"x": 20.0, "y": 0.0, "z": 0.0}, + "local_route_used": False, + "local_route_point_count": 0, + } + ], + "capped_terminal_exits": [ + { + "name": "TerminalCappedExit", + "terminal_uuid": "terminal-capped", + "parent_device_name": "DeviceQF3", + "actual_exit_length_mm": 30.0, + "max_exit_length_mm": 30.0, + "origin": {"x": 100.0, "y": 0.0, "z": 0.0}, + "exit_point": {"x": 100.0, "y": 0.0, "z": 30.0}, + "local_route_used": False, + "local_route_point_count": 0, + "exit_length_capped": True, + } ], "terminal_access_fallback_targets": [ { @@ -7732,6 +8051,33 @@ class AutoRoutingTest(unittest.TestCase): "target_label": "左侧主路径", "target_rule": "main_path_nearest", "target_distance_mm": 40.0, + "endpoint_device_avoided": True, + "endpoint_device_bbox": { + "xmin": -10.0, + "xmax": 10.0, + "ymin": -10.0, + "ymax": 10.0, + "zmin": -10.0, + "zmax": 10.0, + }, + } + ], + "invalid_terminal_local_routes": [ + { + "name": "TerminalInvalidLocalRoute", + "terminal_uuid": "terminal-invalid-local", + "parent_device_name": "DeviceInvalidLocal", + "property_name": "QetTerminalLocalRoutePointsJson", + "reason": "local_route_end_inside_device_bbox", + "local_route_end_point": {"x": 5.0, "y": 0.0, "z": 0.0}, + "endpoint_device_bbox": { + "xmin": -10.0, + "xmax": 10.0, + "ymin": -10.0, + "ymax": 10.0, + "zmin": -10.0, + "zmax": 10.0, + }, } ], } @@ -7755,6 +8101,26 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual("TerminalAccessAvoid001", avoidance_sample["access_carrier_name"]) self.assertEqual("A1 接入段", avoidance_sample["access_carrier_label"]) self.assertEqual("UserPath001", avoidance_sample["target_name"]) + self.assertTrue(avoidance_sample["endpoint_device_avoided"]) + self.assertEqual(-10.0, avoidance_sample["endpoint_device_bbox"]["xmin"]) + self.assertEqual(1, len(payload["corrected_terminal_exits"])) + corrected_exit_sample = payload["corrected_terminal_exits"][0] + self.assertEqual({"x": 0.0, "y": 0.0, "z": 0.0}, corrected_exit_sample["origin"]) + self.assertEqual({"x": 20.0, "y": 0.0, "z": 0.0}, corrected_exit_sample["exit_point"]) + self.assertFalse(corrected_exit_sample["local_route_used"]) + self.assertEqual(0, corrected_exit_sample["local_route_point_count"]) + self.assertEqual(1, len(payload["capped_terminal_exits"])) + capped_exit_sample = payload["capped_terminal_exits"][0] + self.assertEqual({"x": 100.0, "y": 0.0, "z": 0.0}, capped_exit_sample["origin"]) + self.assertEqual({"x": 100.0, "y": 0.0, "z": 30.0}, capped_exit_sample["exit_point"]) + self.assertFalse(capped_exit_sample["local_route_used"]) + self.assertEqual(0, capped_exit_sample["local_route_point_count"]) + self.assertEqual(1, len(payload["invalid_terminal_local_routes"])) + invalid_local_sample = payload["invalid_terminal_local_routes"][0] + self.assertEqual("terminal-invalid-local", invalid_local_sample["terminal_uuid"]) + self.assertEqual("local_route_end_inside_device_bbox", invalid_local_sample["reason"]) + self.assertEqual({"x": 5.0, "y": 0.0, "z": 0.0}, invalid_local_sample["local_route_end_point"]) + self.assertEqual(-10.0, invalid_local_sample["endpoint_device_bbox"]["xmin"]) def test_zero_distance_user_path_endpoint_splits_wire_duct_segment(self): _install_fake_freecad() @@ -7889,6 +8255,58 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual([], duplicated) + def test_diagnostic_bridge_connects_unconnected_terminal_access_to_nearest_path(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + access_carrier = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(0, 0, 20)], + project_uuid="project-1", + kind="TerminalAccess", + label="A1 接入段", + ) + nearest_path = routing_network.create_route_carrier( + doc, + [app.Vector(100, 0, 20), app.Vector(200, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + label="最近线槽", + ) + + report = routing_network.create_user_path_bridges_from_diagnostic_suggestions( + doc, + { + "unconnected_terminals": [ + { + "access_carrier": access_carrier.Name, + "nearest_network_carrier_name": nearest_path.Name, + "nearest_network_carrier_label": "最近线槽", + "terminal_uuid": "terminal-a1", + } + ], + }, + project_uuid="project-1", + ) + + self.assertEqual(1, report["suggestions"]) + self.assertEqual(1, report["unconnected_terminal_access_bridge_targets"]) + self.assertEqual(1, report["unconnected_terminal_access_user_path_bridges"]) + self.assertEqual(0, report["unconnected_terminal_access_bridge_duplicates"]) + self.assertEqual(["A1 接入段 -> 最近线槽"], report["unconnected_terminal_access_bridge_pair_labels"]) + self.assertEqual(1, len(report["created"])) + bridge = report["created"][0] + self.assertEqual("UserPath", bridge.QetRouteCarrierKind) + self.assertEqual("UnconnectedTerminalAccessBridge", bridge.QetRouteBridgeKind) + self.assertEqual(access_carrier.Name, bridge.QetRouteBridgeLeftSourceName) + self.assertEqual(nearest_path.Name, bridge.QetRouteBridgeRightSourceName) + self.assertEqual([(0.0, 0.0, 20.0), (100.0, 0.0, 20.0)], [ + (point.x, point.y, point.z) + for point in bridge.Points + ]) + def test_check_routing_path_network_warns_for_invalid_terminal_exit_direction(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() @@ -7976,6 +8394,48 @@ class AutoRoutingTest(unittest.TestCase): self.assertIn("端子局部路径无效", message) self.assertIn("terminal-invalid-local-path", message) + def test_check_routing_path_network_warns_when_terminal_local_route_ends_inside_device(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + app.ActiveDocument = doc + root = terminal_objects.ensure_root_group(doc, "project-1") + device = doc.addObject("App::DocumentObjectGroup", "QETDeviceLocalRouteInside") + root.addObject(device) + terminal = _terminal(doc, terminal_objects, "TerminalLocalRouteInside", "terminal-local-inside", app.Vector(0, 0, 0)) + terminal.addProperty("App::PropertyString", "QetTerminalLocalRoutePointsJson", "QET Routing", "") + terminal.QetTerminalLocalRoutePointsJson = json.dumps([[0, 0, 0], [5, 0, 0]]) + device.addObject(terminal) + body = doc.addObject("Part::Feature", "DeviceLocalRouteInsideBody") + body.Shape = FakeShape(FakeBoundBox(-10, 10, -10, 10, -10, 10)) + device.addObject(body) + routing_network.create_route_carrier( + doc, + [app.Vector(100, 0, 0), app.Vector(200, 0, 0)], + project_uuid="project-1", + kind="WireDuct", + ) + + result = auto_routing.check_eplan_routing_path_network(doc, project_uuid="project-1") + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + payload = json.loads(diagnostic_group.Group[0].QetDiagnosticJson) + message = auto_routing.format_routing_path_network_report(result["diagnostic"]) + + self.assertFalse(result["ok"]) + self.assertIn("invalid_terminal_local_routes", payload["issue_codes"]) + self.assertEqual(1, len(payload["invalid_terminal_local_routes"])) + sample = payload["invalid_terminal_local_routes"][0] + self.assertEqual("terminal-local-inside", sample["terminal_uuid"]) + self.assertEqual("local_route_end_inside_device_bbox", sample["reason"]) + self.assertEqual({"x": 5.0, "y": 0.0, "z": 0.0}, sample["local_route_end_point"]) + self.assertEqual( + {"xmin": -10.0, "xmax": 10.0, "ymin": -10.0, "ymax": 10.0, "zmin": -10.0, "zmax": 10.0}, + sample["endpoint_device_bbox"], + ) + self.assertIn("端子局部路径无效", message) + self.assertIn("local_route_end_inside_device_bbox", message) + def test_check_routing_path_network_uses_terminal_local_route_end_for_connectivity(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() @@ -8027,17 +8487,56 @@ class AutoRoutingTest(unittest.TestCase): self.assertIn("布线路径网络检查发现", message) self.assertIn("首个问题:external_issue", message) - def test_format_routing_path_network_report_calls_out_wire_duct_break_point(self): + def test_format_routing_path_network_report_calls_out_terminal_exit_diagnostics(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() - app = sys.modules["FreeCAD"] - doc = FakeDocument() - terminal_objects.ensure_root_group(doc, "project-1") - routing_network.create_route_carrier( - doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], - label="线槽A", - project_uuid="project-1", + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + diagnostic = { + "ok": False, + "issues": [ + {"severity": "warning", "code": "terminal_exit_direction_corrected", "count": 1}, + {"severity": "warning", "code": "terminal_exit_length_capped", "count": 1}, + ], + "corrected_terminal_exits": [ + { + "name": "TerminalCorrectedExit", + "terminal_uuid": "terminal-corrected", + "original_exit_direction": {"x": 0.0, "y": 0.0, "z": 1.0}, + "exit_direction": {"x": 1.0, "y": 0.0, "z": 0.0}, + } + ], + "capped_terminal_exits": [ + { + "name": "TerminalCappedExit", + "terminal_uuid": "terminal-capped", + "actual_exit_length_mm": 30.0, + "max_exit_length_mm": 30.0, + "device_exit_required_length_mm": 510.0, + } + ], + } + + message = auto_routing.format_routing_path_network_report(diagnostic) + + self.assertIn("端子默认出线方向已校正", message) + self.assertIn("TerminalCorrectedExit", message) + self.assertIn("原方向 (0.0, 0.0, 1.0)", message) + self.assertIn("采用方向 (1.0, 0.0, 0.0)", message) + self.assertIn("端子出线长度截断", message) + self.assertIn("TerminalCappedExit", message) + self.assertIn("实际 30.0 mm / 上限 30.0 mm", message) + self.assertIn("设备出线需求 510.0 mm", message) + + def test_format_routing_path_network_report_calls_out_wire_duct_break_point(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="线槽A", + project_uuid="project-1", kind="WireDuct", ) @@ -8314,6 +8813,38 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(1, len(diagnostic_group.Group)) self.assertEqual("RoutingPreflight", diagnostic_group.Group[0].QetDiagnosticKind) + def test_auto_routing_controller_readiness_prepares_path_network_before_preflight(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + duct = doc.addObject("Part::Feature", "WireDuctA") + duct.Label = "Wire Duct A" + duct.Shape = FakeShape(FakeBoundBox(0, 100, -5, 5, 15, 25)) + app._qet_exchange_payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing_panel.AutoRoutingController().check_routing_readiness() + + self.assertNotIn("no_route_network", report["issue_codes"]) + self.assertNotIn("routing_sources_not_generated", report["issue_codes"]) + self.assertGreater(report["route_network_segments"], 0) + self.assertEqual(1, report["prepared_layout"]["wire_duct_carriers"]) + self.assertEqual(2, report["prepared_layout"]["terminal_access_carriers"]) + def test_route_eplan_connections_prepares_layout_space_like_eplan_route(self): _install_fake_freecad() terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() @@ -10101,6 +10632,114 @@ class AutoRoutingTest(unittest.TestCase): self.assertIsNotNone(result) self.assertLessEqual(calls["shortest_path"], 16) + def test_route_eplan_connections_reuses_entry_candidates_for_same_batch_endpoint(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 50), app.Vector(100, 0, 50)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-{0}".format(index), + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + for index in range(6) + ], + } + calls = {"start_endpoint": 0} + original_connection_point_candidates = routing_network.connection_point_candidates + start_point = start.Placement.Base + + def counted_connection_point_candidates(network, point, *args, **kwargs): + if auto_routing._distance(point, start_point) <= 0.001: + calls["start_endpoint"] += 1 + return original_connection_point_candidates(network, point, *args, **kwargs) + + routing_network.connection_point_candidates = counted_connection_point_candidates + try: + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={ + "terminal_exit_length": 0.0, + "network_entry_candidate_limit": 2, + "batch_network_entry_candidate_limit": 2, + }, + ) + finally: + routing_network.connection_point_candidates = original_connection_point_candidates + + self.assertEqual(6, report["routed"]) + self.assertLessEqual(calls["start_endpoint"], 1) + + def test_route_eplan_connections_reuses_route_track_for_lane_adjustment(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 10, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 10, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 5, 50), app.Vector(100, 5, 50)], + label="Shared Wire Duct", + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + { + "wire_id": "wire-b", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + }, + ], + } + calls = {"shortest_path": 0} + original_shortest_path = routing_network.shortest_path_with_carriers + + def counted_shortest_path(*args, **kwargs): + calls["shortest_path"] += 1 + return original_shortest_path(*args, **kwargs) + + routing_network.shortest_path_with_carriers = counted_shortest_path + try: + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={ + "terminal_exit_length": 0.0, + "network_entry_candidate_limit": 1, + "batch_network_entry_candidate_limit": 1, + }, + ) + finally: + routing_network.shortest_path_with_carriers = original_shortest_path + + self.assertEqual(2, report["routed"]) + self.assertEqual(2, calls["shortest_path"]) + self.assertEqual([0, 1], [route["lane"]["index"] for route in report["routes"]]) + def test_eplan_connection_route_marks_clearance_warning_against_expanded_obstacle_bbox(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() @@ -10386,6 +11025,37 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual({"x": 1.0, "y": 0.0, "z": 0.0}, diagnostics["exit_direction"]) self.assertEqual({"x": 15.0, "y": 35.0, "z": 0.0}, diagnostics["exit_point"]) + def test_terminal_access_ignores_local_route_that_ends_inside_endpoint_device_bbox(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + root = terminal_objects.ensure_root_group(doc, "project-1") + device = doc.addObject("App::DocumentObjectGroup", "QETDeviceInvalidLocalRoute") + root.addObject(device) + terminal = _terminal(doc, terminal_objects, "TerminalInvalidLocalRouteInside", "terminal-invalid-local-route-inside", app.Vector(0, 0, 0)) + terminal.addProperty("App::PropertyString", "QetTerminalLocalRoutePointsJson", "QET Routing", "") + terminal.QetTerminalLocalRoutePointsJson = json.dumps([[0, 0, 0], [5, 0, 0]]) + device.addObject(terminal) + body = doc.addObject("Part::Feature", "InvalidLocalRouteDeviceBody") + body.Shape = FakeShape(FakeBoundBox(-10, 10, -10, 10, -10, 10)) + device.addObject(body) + + points = routing_network.terminal_access_path_points( + terminal, + exit_length=20.0, + max_exit_length=80.0, + ) + diagnostics = routing_network.terminal_access_diagnostics( + terminal, + exit_length=20.0, + max_exit_length=80.0, + ) + + self.assertEqual([(0.0, 0.0, 0.0), (0.0, 0.0, 20.0)], [(point.x, point.y, point.z) for point in points]) + self.assertFalse(diagnostics["local_route_used"]) + self.assertEqual("default_exit", diagnostics["exit_rule"]) + def test_terminal_access_prefers_main_path_over_nearer_routing_range_and_records_rule(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() @@ -10535,6 +11205,11 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual("TerminalWithLocalExit", sample["terminal_name"]) self.assertEqual("QETDeviceAccessBox", sample["parent_device_name"]) self.assertEqual("UserPath", sample["target_kind"]) + self.assertTrue(sample["endpoint_device_avoided"]) + self.assertEqual( + {"xmin": -10.0, "xmax": 10.0, "ymin": -10.0, "ymax": 10.0, "zmin": -10.0, "zmax": 10.0}, + sample["endpoint_device_bbox"], + ) self.assertGreater(sample["access_length_mm"], 0.0) self.assertGreaterEqual(len(sample["access_points"]), 2) self.assertEqual({"x": 0.0, "y": 0.0, "z": 0.0}, sample["access_points"][0]) @@ -11049,6 +11724,136 @@ class AutoRoutingTest(unittest.TestCase): sample["end_missing_endpoint_reason_code"], ) + def test_preflight_reports_duplicate_payload_terminal_instance_ids(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + payload = { + "project_uuid": "project-1", + "devices": [ + { + "device_instance_id": "device-a", + "terminals": [ + { + "terminal_uuid": "terminal-a", + "terminal_instance_id": "shared-terminal-instance", + "element_uuid": "element-a", + "terminal_display": "1", + } + ], + }, + { + "device_instance_id": "device-b", + "terminals": [ + { + "terminal_uuid": "terminal-b", + "terminal_instance_id": "shared-terminal-instance", + "element_uuid": "element-b", + "terminal_display": "1", + } + ], + }, + ], + "wires": [], + } + + report = auto_routing.preflight_eplan_connections(doc, payload) + + self.assertEqual(1, report["duplicate_payload_terminal_instance_id_count"]) + self.assertEqual( + { + "terminal_instance_id": "shared-terminal-instance", + "count": 2, + }, + report["duplicate_payload_terminal_instance_id_samples"][0], + ) + self.assertIn("duplicate_payload_terminal_instance_ids", report["issue_codes"]) + + def test_preflight_reports_duplicate_3d_terminal_uuids(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalA", "shared-terminal", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalB", "shared-terminal", app.Vector(100, 0, 0)) + payload = { + "project_uuid": "project-1", + "wires": [], + } + + report = auto_routing.preflight_eplan_connections(doc, payload) + message = auto_routing.format_eplan_routing_preflight_report(report) + compact = auto_routing._compact_routing_preflight_report(report) + + self.assertEqual(1, report["duplicate_terminal_uuid_count"]) + self.assertIn("duplicate_3d_terminal_uuids", report["issue_codes"]) + self.assertIn("3D工程端子 UUID 重复", message) + self.assertEqual(1, compact["duplicate_terminal_uuid_count"]) + self.assertIn("3D端子UUID重复", compact["issue_labels"]) + + def test_preflight_reports_payload_terminals_not_referenced_by_wires(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-sa", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-target", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + ) + + payload = { + "project_uuid": "project-1", + "devices": [ + { + "device_instance_id": "device-id27", + "display_tag": "ID:27", + "terminals": [ + { + "terminal_uuid": "terminal-as", + "element_uuid": "element-id27", + "terminal_display": "as", + }, + { + "terminal_uuid": "terminal-sa", + "element_uuid": "element-id27", + "terminal_display": "sa", + }, + ], + } + ], + "wires": [ + { + "wire_id": "wire-id27-sa", + "wire_label": "N401", + "start_element_uuid": "element-id27", + "start_terminal_uuid": "terminal-sa", + "start_terminal_display": "sa", + "end_element_uuid": "element-target", + "end_terminal_uuid": "terminal-target", + "end_terminal_display": "1", + } + ], + } + + report = auto_routing.preflight_eplan_connections(doc, payload) + message = auto_routing.format_eplan_routing_preflight_report(report) + + self.assertEqual(1, report["unreferenced_payload_terminal_count"]) + self.assertEqual("ID:27", report["unreferenced_payload_terminal_samples"][0]["device_label"]) + self.assertEqual("as", report["unreferenced_payload_terminal_samples"][0]["terminal_display"]) + self.assertIn("payload_terminals_without_wires", report["issue_codes"]) + self.assertIn("未被 wires[] 引用的端子", message) + compact = auto_routing._compact_routing_preflight_report(report) + self.assertEqual(1, compact["unreferenced_payload_terminal_count"]) + self.assertEqual("ID:27", compact["unreferenced_payload_terminal_samples"][0]["device_label"]) + self.assertIn("输入端子未被导线引用", compact["issue_labels"]) + def test_route_eplan_connections_from_payload_reports_missing_device_binding_metadata(self): _install_fake_freecad() terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() @@ -11438,7 +12243,16 @@ class AutoRoutingTest(unittest.TestCase): kind="WireDuct", label="诊断桥接后主路径", ) - return {"suggestions": 1, "created": [carrier], "duplicates": 0, "stale_suggestions": 0} + return { + "suggestions": 1, + "created": [carrier], + "duplicates": 0, + "stale_suggestions": 0, + "unconnected_terminal_access_bridge_targets": 1, + "unconnected_terminal_access_user_path_bridges": 1, + "unconnected_terminal_access_bridge_duplicates": 0, + "unconnected_terminal_access_bridge_pair_labels": ["A1 接入段 -> 最近线槽"], + } routing_network.diagnose_routing_path_network = fake_diagnostic routing_network.create_user_path_bridges_from_diagnostic_suggestions = fake_create @@ -11449,6 +12263,9 @@ class AutoRoutingTest(unittest.TestCase): routing_network.create_user_path_bridges_from_diagnostic_suggestions = original_create self.assertEqual(1, report["auto_diagnostic_bridges"]["created_count"]) + self.assertEqual(1, report["auto_diagnostic_bridges"]["unconnected_terminal_access_bridge_targets"]) + self.assertEqual(1, report["auto_diagnostic_bridges"]["unconnected_terminal_access_user_path_bridges"]) + self.assertEqual(["A1 接入段 -> 最近线槽"], report["auto_diagnostic_bridges"]["unconnected_terminal_access_bridge_pair_labels"]) self.assertEqual({"main_path_routes": 1, "fallback_routes": 0}, report["route_path_usage"]) self.assertEqual(["Routed"], list(report["route_status_counts"].keys())) self.assertNotIn("main_path_not_used", report["issue_codes"]) @@ -12630,15 +13447,33 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(["DeviceObstacle"], [item["name"] for item in obstacles]) self.assertEqual(["DeviceObstacle"], [item["name"] for item in cached]) - def test_route_eplan_connections_classifies_disconnected_network_as_missing_route_network(self): + def test_collect_obstacles_skips_geometry_inside_route_carrier_group(self): _install_fake_freecad() - terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() - app = sys.modules["FreeCAD"] + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() doc = FakeDocument() - terminal_objects.ensure_root_group(doc, "project-1") - _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) - routing_network.create_route_carrier( + carriers = doc.addObject("App::DocumentObjectGroup", "QETWiring_02_Carriers") + carrier_body = doc.addObject("Part::Feature", "WireDuctSolid") + carrier_body.Shape = FakeShape(FakeBoundBox(0, 100, -5, 5, 15, 25)) + carriers.addObject(carrier_body) + obstacle = doc.addObject("Part::Feature", "DeviceObstacle") + obstacle.Shape = FakeShape(FakeBoundBox(20, 40, 20, 40, 0, 20)) + + obstacles = auto_routing.collect_obstacles(doc) + cache = auto_routing._obstacle_candidate_cache(doc) + cached = auto_routing.collect_obstacles(doc, options={"__obstacle_candidate_cache": cache}) + + self.assertEqual(["DeviceObstacle"], [item["name"] for item in obstacles]) + self.assertEqual(["DeviceObstacle"], [item["name"] for item in cached]) + + def test_route_eplan_connections_classifies_disconnected_network_as_missing_route_network(self): + _install_fake_freecad() + terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( doc, [app.Vector(0, 0, 20), app.Vector(10, 0, 20)], project_uuid="project-1", @@ -14018,6 +14853,9 @@ class AutoRoutingTest(unittest.TestCase): { "name": "TerminalUnconnected", "terminal_uuid": "terminal-unconnected", + "access_carrier": "QETRouteCarrier_TerminalAccess001", + "nearest_network_carrier_name": "QETRouteCarrier_WireDuct001", + "nearest_network_carrier_label": "最近线槽", "nearest_network_distance_mm": 125.0, } ], @@ -14029,6 +14867,7 @@ class AutoRoutingTest(unittest.TestCase): actions = auto_routing._routing_diagnostic_recommended_actions(summary) self.assertIn("点击“选择未接入端子”定位未接入路由网络或接入距离超限的端子", actions) + self.assertIn("点击“按诊断建议生成桥接”尝试自动补未接入端子接入段到最近路径的 UserPath 桥", actions) self.assertIn("补端子附近 UserPath/线槽入口,或确认设备装配位置和端子接入最大距离", actions) def test_routing_diagnostic_recommended_actions_include_wire_outside_boundary_selection(self): @@ -14086,6 +14925,39 @@ class AutoRoutingTest(unittest.TestCase): payload["route_path_usage"], ) + def test_compact_batch_report_includes_main_path_target_bridge_summary(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "total_wires": 1, + "routed": 1, + "auto_main_path_target_bridges": { + "enabled": True, + "pairs": 1, + "created_count": 1, + "duplicates": 0, + "created_pair_labels": ["Left duct -> Right duct"], + "wire_uuids": ["wire-main-target-bridge"], + "rerouted": True, + }, + "routes": [], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertEqual( + { + "enabled": True, + "pairs": 1, + "created_count": 1, + "duplicates": 0, + "created_pair_labels": ["Left duct -> Right duct"], + "wire_uuids": ["wire-main-target-bridge"], + "rerouted": True, + }, + payload["auto_main_path_target_bridges"], + ) + def test_compact_batch_report_summarizes_terminal_access_consumption(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() @@ -14643,7 +15515,12 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(1, report["routed"]) self.assertEqual(1, report["hidden_route_carriers"]) + self.assertEqual(1, report["visible_routed_wires"]) self.assertFalse(carrier.ViewObject.Visibility) + routed_group = doc.getObject("QETWiring_04_Routed") + routed_wire = list(getattr(routed_group, "Group", []) or [])[0] + self.assertTrue(routed_group.ViewObject.Visibility) + self.assertTrue(routed_wire.ViewObject.Visibility) diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") diagnostics = [ item @@ -14654,6 +15531,85 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(1, diagnostic_payload["hidden_route_carriers"]) self.assertTrue(diagnostic_payload["routing_path_network_updated"] is False) + def test_route_report_includes_first_stage_visual_closeout_summary(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + "wire_style_id": "style-green", + } + ], + } + + report = auto_routing.route_eplan_connections( + doc, + payload=payload, + update_network=False, + options={ + "wire_style_lookup": lambda style_id, project_uuid: { + "id": style_id, + "line_color": "#00AA00", + "line_width": 1.5, + "line_type": "Solid", + }, + }, + ) + + self.assertEqual(1, report["routed_wire_visibility"]["routed"]) + self.assertEqual(1, report["routed_wire_visibility"]["visible"]) + self.assertEqual(0, report["routed_wire_visibility"]["hidden"]) + self.assertEqual(1, report["wire_style_application"]["expected"]) + self.assertEqual(1, report["wire_style_application"]["applied"]) + self.assertEqual(0, report["wire_style_application"]["missing_application"]) + self.assertEqual(1, report["route_carrier_visibility"]["total"]) + self.assertEqual(0, report["route_carrier_visibility"]["visible_after_hide"]) + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + diagnostics = [ + item + for item in list(getattr(diagnostic_group, "Group", []) or []) + if getattr(item, "QetDiagnosticKind", "") == "RoutingConnectionBatch" + ] + diagnostic_payload = json.loads(diagnostics[0].QetDiagnosticJson) + self.assertEqual(1, diagnostic_payload["routed_wire_visibility"]["visible"]) + self.assertEqual(1, diagnostic_payload["wire_style_application"]["applied"]) + self.assertEqual(0, diagnostic_payload["route_carrier_visibility"]["visible_after_hide"]) + + def test_route_report_explains_styled_black_wires(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "total_wires": 2, + "routed": 2, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "wire_style_application": { + "expected": 2, + "applied": 2, + "missing_application": 0, + "styled_black": 1, + }, + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("黑色导线:1 条来自 wire_properties 样式", message) + def test_route_eplan_connections_ignores_global_payload_from_other_project(self): _install_fake_freecad() terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() @@ -15188,6 +16144,35 @@ class AutoRoutingTest(unittest.TestCase): self.assertIn("已隐藏走线路径辅助对象:3 条。", message) + def test_route_issue_codes_flag_visual_closeout_regressions(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "total_wires": 1, + "routed": 1, + "routed_wire_visibility": { + "routed": 1, + "visible": 0, + "hidden": 1, + }, + "wire_style_application": { + "expected": 1, + "applied": 0, + "missing_application": 1, + }, + "route_carrier_visibility": { + "expected_hidden": True, + "total": 2, + "visible_after_hide": 1, + }, + } + + issue_codes = auto_routing._routing_connection_batch_issue_codes(report) + + self.assertIn("routed_wires_not_visible", issue_codes) + self.assertIn("wire_styles_not_applied", issue_codes) + self.assertIn("route_carriers_still_visible", issue_codes) + def test_route_report_warns_when_routes_use_surface_or_auxiliary_paths(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() @@ -15230,6 +16215,7 @@ class AutoRoutingTest(unittest.TestCase): "routed": 3, "collision_warnings": 0, "skipped_missing_terminal": 0, + "show_candidate_debug_warnings": True, "routes": [ { "lane": {"index": 2, "spacing_mm": 10.0}, @@ -15254,6 +16240,7 @@ class AutoRoutingTest(unittest.TestCase): "routed": 3, "collision_warnings": 0, "skipped_missing_terminal": 0, + "show_candidate_debug_warnings": True, "routes": [ { "wire_label": "N-CROWDED", @@ -15279,6 +16266,7 @@ class AutoRoutingTest(unittest.TestCase): "routed": 3, "collision_warnings": 0, "skipped_missing_terminal": 0, + "show_candidate_debug_warnings": True, "routes": [ { "wire_label": "N-CROWDED", @@ -15304,6 +16292,7 @@ class AutoRoutingTest(unittest.TestCase): "routed": 3, "collision_warnings": 0, "skipped_missing_terminal": 0, + "show_candidate_debug_warnings": True, "routes": [ { "wire_label": "N-CROWDED", @@ -15360,6 +16349,7 @@ class AutoRoutingTest(unittest.TestCase): "routed": 1, "collision_warnings": 0, "skipped_missing_terminal": 0, + "show_candidate_debug_warnings": True, "routes": [ { "wire_label": "N1", @@ -15440,6 +16430,7 @@ class AutoRoutingTest(unittest.TestCase): "routed": 1, "collision_warnings": 0, "skipped_missing_terminal": 0, + "show_candidate_debug_warnings": True, "routes": [ { "wire_label": "N1", @@ -15796,6 +16787,7 @@ class AutoRoutingTest(unittest.TestCase): [app.Vector(1000, 0, 20), app.Vector(1100, 0, 20)], project_uuid="project-1", kind="WireDuct", + label="远处线槽", ) routing_network.create_terminal_access_carriers_from_document( doc, @@ -15832,6 +16824,9 @@ class AutoRoutingTest(unittest.TestCase): sample = diagnostic["long_terminal_accesses"][0] self.assertEqual("terminal-start", sample["terminal_uuid"]) self.assertEqual("PEN", sample["parent_device_label"]) + self.assertEqual("WireDuct", sample["target_kind"]) + self.assertEqual("远处线槽", sample["target_label"]) + self.assertEqual(900.0, sample["target_distance_mm"]) self.assertEqual("x", sample["terminal_access_dominant_axis"]) self.assertEqual(2, len(sample["terminal_access_points"])) @@ -16365,114 +17360,392 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(50.0, wire.Points[0].y) self.assertEqual(50.0, wire.Points[-1].y) - def test_route_eplan_connections_lane_index_accounts_for_existing_routed_segments(self): + def test_route_eplan_connections_disambiguates_duplicate_terminal_uuid_by_terminal_display(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() - terminal_objects.ensure_root_group(doc, "project-1") - start = _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) - end = _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) - routing_network.create_route_carrier( + root = terminal_objects.ensure_root_group(doc, "project-1") + + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 50, 0)) + terminal_objects.set_terminal_semantics( + start, + "project-1", + "device-start", + "terminal-start", + "instance-start", + label="S1", + ) + device = doc.addObject("App::DocumentObjectGroup", "QETDevice_shared") + root.addObject(device) + terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "device-end") + terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "instance-end") + terminal_group = terminal_objects.ensure_terminal_group( doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + device, project_uuid="project-1", - kind="WireDuct", + instance_id="instance-end", ) - auto_routing.route_eplan_connection_between_terminals( + for name, label, point in ( + ("QETTerminal_shared_1", "1", app.Vector(120, 50, 0)), + ("QETTerminal_shared_339", "339", app.Vector(120, 0, 0)), + ): + terminal = terminal_objects.create_lcs_object( + doc, + name, + placement=app.Placement(point, app.Rotation()), + label=label, + ) + terminal_group.addObject(terminal) + terminal_objects.set_terminal_semantics( + terminal, + "project-1", + "device-end", + "shared-terminal", + "instance-end", + label=label, + slot_name=label, + ) + routing_network.create_route_carrier( doc, - start, - end, - wire_uuid="existing-wire", - options={"lane_spacing": 10.0, "lane_axis": "y"}, + [app.Vector(0, 50, 20), app.Vector(120, 50, 20)], + project_uuid="project-1", + kind="WireDuct", ) payload = { "project_uuid": "project-1", "wires": [ { - "wire_id": "new-wire", - "start_terminal_uuid": "terminal-start-a", - "end_terminal_uuid": "terminal-end-a", - }, + "wire_id": "wire-display-match", + "start_element_uuid": "device-start", + "start_instance_id": "instance-start", + "start_terminal_uuid": "terminal-start", + "start_terminal_display": "S1", + "end_element_uuid": "device-end", + "end_instance_id": "instance-end", + "end_terminal_uuid": "shared-terminal", + "end_terminal_display": "1", + } ], } - report = auto_routing.route_eplan_connections_from_payload( - doc, - payload, - options={"lane_spacing": 10.0, "lane_axis": "y"}, - ) + report = auto_routing.route_eplan_connections_from_payload(doc, payload) - self.assertEqual(1, report["routes"][0]["lane"]["index"]) + self.assertEqual(1, report["routed"]) + self.assertEqual(0, report["skipped_missing_terminal"]) routed_group = doc.getObject("QETWiring_04_Routed") - new_wire = [ - wire - for wire in list(getattr(routed_group, "Group", []) or []) - if getattr(wire, "QetWireUuid", "") == "new-wire" - ][0] - self.assertEqual("1", new_wire.QetRouteLaneIndex) - self.assertTrue(any(abs(point.y - 10.0) <= 0.001 for point in new_wire.Points[1:-1])) + wire = list(getattr(routed_group, "Group", []) or [])[0] + self.assertEqual("1", wire.QetEndTerminalDisplay) + self.assertEqual(50.0, wire.Points[-1].y) - def test_route_eplan_connections_auto_lane_axis_offsets_perpendicular_to_shared_segment(self): + def test_route_eplan_connections_repairs_duplicate_terminal_metadata_from_v2_device_terminals(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() - terminal_objects.ensure_root_group(doc, "project-1") - _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(0, 100, 0)) - _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(0, 100, 0)) + root = terminal_objects.ensure_root_group(doc, "project-1") + + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 50, 0)) + terminal_objects.set_terminal_semantics( + start, + "project-1", + "device-start", + "terminal-start", + "instance-start", + label="S1", + ) + device = doc.addObject("App::DocumentObjectGroup", "QETDevice_shared") + root.addObject(device) + terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "device-shared") + terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "instance-shared") + terminal_group = terminal_objects.ensure_terminal_group( + doc, + device, + project_uuid="project-1", + instance_id="instance-shared", + ) + for name, label, point in ( + ("QETTerminal_shared_a", "A-old", app.Vector(120, 0, 0)), + ("QETTerminal_shared_b", "B-old", app.Vector(120, 50, 0)), + ): + terminal = terminal_objects.create_lcs_object( + doc, + name, + placement=app.Placement(point, app.Rotation()), + label=label, + ) + terminal_group.addObject(terminal) + terminal_objects.set_terminal_semantics( + terminal, + "project-1", + "wrong-element", + "shared-terminal", + "instance-shared", + label=label, + slot_name=label, + ) routing_network.create_route_carrier( doc, - [app.Vector(0, 0, 20), app.Vector(0, 100, 20)], + [app.Vector(0, 50, 20), app.Vector(120, 50, 20)], project_uuid="project-1", kind="WireDuct", ) payload = { "project_uuid": "project-1", - "wires": [ + "devices": [ { - "wire_id": "wire-a", - "start_terminal_uuid": "terminal-start-a", - "end_terminal_uuid": "terminal-end-a", - }, + "device_instance_id": "instance-shared", + "display_tag": "T1", + "terminals": [ + { + "terminal_uuid": "shared-terminal", + "element_uuid": "element-a", + "terminal_instance_id": "instance-shared", + "terminal_display": "A1", + }, + { + "terminal_uuid": "shared-terminal", + "element_uuid": "element-b", + "terminal_instance_id": "instance-shared", + "terminal_display": "B1", + }, + ], + } + ], + "wires": [ { - "wire_id": "wire-b", - "start_terminal_uuid": "terminal-start-b", - "end_terminal_uuid": "terminal-end-b", - }, + "wire_id": "wire-repaired", + "start_element_uuid": "device-start", + "start_instance_id": "instance-start", + "start_terminal_uuid": "terminal-start", + "start_terminal_display": "S1", + "end_element_uuid": "element-b", + "end_instance_id": "instance-shared", + "end_terminal_uuid": "shared-terminal", + "end_terminal_display": "B1", + } ], } - report = auto_routing.route_eplan_connections_from_payload( - doc, - payload, - options={"lane_spacing": 10.0}, - ) + report = auto_routing.route_eplan_connections_from_payload(doc, payload) - self.assertEqual(1, report["routes"][1]["lane"]["index"]) - self.assertEqual("x", report["routes"][1]["lane"]["axis"]) + self.assertEqual(1, report["routed"]) + self.assertEqual(0, report["skipped_missing_terminal"]) routed_group = doc.getObject("QETWiring_04_Routed") - second_wire = [ - wire - for wire in list(getattr(routed_group, "Group", []) or []) - if getattr(wire, "QetWireUuid", "") == "wire-b" - ][0] - self.assertTrue(any(abs(point.x - 10.0) <= 0.001 for point in second_wire.Points[1:-1])) - self.assertFalse(all(abs(point.x) <= 0.001 for point in second_wire.Points[1:-1])) + wire = list(getattr(routed_group, "Group", []) or [])[0] + self.assertEqual("B1", wire.QetEndTerminalDisplay) + self.assertEqual("element-b", wire.QetEndElementUuid) + self.assertEqual(50.0, wire.Points[-1].y) - def test_route_eplan_connections_auto_lane_axis_avoids_cabinet_boundary(self): + def test_preflight_routeability_uses_endpoint_context_for_duplicate_terminal_uuid(self): _install_fake_freecad() - terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - boundary = doc.addObject("Part::Feature", "CabinetInteriorBoundary") - boundary.Label = "柜内空间" - boundary.Shape = FakeShape(FakeBoundBox(-10, 110, 0, 100, -10, 80)) - routing_network.mark_cabinet_interior_boundaries_from_selection( - [FakeSelectionItem(obj=boundary)] + + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 50, 0)) + terminal_objects.set_terminal_semantics( + start, + "project-1", + "device-start", + "terminal-start", + "instance-start", + label="S1", + terminal_display="S1", + ) + end_a = _terminal(doc, terminal_objects, "TerminalEndA", "shared-terminal", app.Vector(120, 0, 0)) + terminal_objects.set_terminal_semantics( + end_a, + "project-1", + "element-a", + "shared-terminal", + "instance-end", + label="A1", + terminal_display="A1", + ) + end_b = _terminal(doc, terminal_objects, "TerminalEndB", "shared-terminal", app.Vector(120, 50, 0)) + terminal_objects.set_terminal_semantics( + end_b, + "project-1", + "element-b", + "shared-terminal", + "instance-end", + label="B1", + terminal_display="B1", + ) + + selected_end_labels = [] + original_build_network_route = auto_routing.build_network_route + + def fake_build_network_route(start_terminal, end_terminal, **_kwargs): + selected_end_labels.append(getattr(end_terminal, "Label", "")) + if getattr(end_terminal, "Label", "") == "B1": + return {"points": [start_terminal.Placement.Base, end_terminal.Placement.Base]} + return None + + auto_routing.build_network_route = fake_build_network_route + try: + summary = auto_routing._preflight_routeability_summary( + doc, + [ + { + "wire_id": "wire-display-match", + "start_element_uuid": "device-start", + "start_instance_id": "instance-start", + "start_terminal_uuid": "terminal-start", + "start_terminal_display": "S1", + "end_element_uuid": "element-b", + "end_instance_id": "instance-end", + "end_terminal_uuid": "shared-terminal", + "end_terminal_display": "B1", + } + ], + auto_routing.index_terminals(doc), + options={ + "preflight_routeability_sample_limit": 1, + "__terminal_candidates": auto_routing._collect_routable_terminals(doc), + }, + ) + finally: + auto_routing.build_network_route = original_build_network_route + + self.assertEqual(["B1"], selected_end_labels) + self.assertEqual(1, summary["eligible_wires"]) + self.assertEqual(0, summary["unrouteable_wires"]) + + def test_preflight_reports_available_terminal_objects_when_terminal_uuid_repeats(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalSharedA", "shared-terminal", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalSharedB", "shared-terminal", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + + report = auto_routing.preflight_eplan_connections( + doc, + {"project_uuid": "project-1", "wires": []}, + ) + + self.assertEqual(2, report["available_terminals"]) + self.assertEqual(2, report["available_terminal_objects"]) + self.assertEqual(1, report["unique_terminal_uuids"]) + + def test_route_eplan_connections_lane_index_accounts_for_existing_routed_segments(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + wire_uuid="existing-wire", + options={"lane_spacing": 10.0, "lane_axis": "y"}, + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "new-wire", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"lane_spacing": 10.0, "lane_axis": "y"}, + ) + + self.assertEqual(1, report["routes"][0]["lane"]["index"]) + routed_group = doc.getObject("QETWiring_04_Routed") + new_wire = [ + wire + for wire in list(getattr(routed_group, "Group", []) or []) + if getattr(wire, "QetWireUuid", "") == "new-wire" + ][0] + self.assertEqual("1", new_wire.QetRouteLaneIndex) + self.assertTrue(any(abs(point.y - 10.0) <= 0.001 for point in new_wire.Points[1:-1])) + + def test_route_eplan_connections_auto_lane_axis_offsets_perpendicular_to_shared_segment(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(0, 100, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(0, 100, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(0, 100, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + { + "wire_id": "wire-b", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"lane_spacing": 10.0}, + ) + + self.assertEqual(1, report["routes"][1]["lane"]["index"]) + self.assertEqual("x", report["routes"][1]["lane"]["axis"]) + routed_group = doc.getObject("QETWiring_04_Routed") + second_wire = [ + wire + for wire in list(getattr(routed_group, "Group", []) or []) + if getattr(wire, "QetWireUuid", "") == "wire-b" + ][0] + self.assertTrue(any(abs(point.x - 10.0) <= 0.001 for point in second_wire.Points[1:-1])) + self.assertFalse(all(abs(point.x) <= 0.001 for point in second_wire.Points[1:-1])) + + def test_route_eplan_connections_auto_lane_axis_avoids_cabinet_boundary(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + boundary = doc.addObject("Part::Feature", "CabinetInteriorBoundary") + boundary.Label = "柜内空间" + boundary.Shape = FakeShape(FakeBoundBox(-10, 110, 0, 100, -10, 80)) + routing_network.mark_cabinet_interior_boundaries_from_selection( + [FakeSelectionItem(obj=boundary)] ) _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 95, 0)) _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 95, 0)) @@ -16637,78 +17910,1104 @@ class AutoRoutingTest(unittest.TestCase): self.assertIn("Alternate Duct", second_labels) self.assertNotIn("Direct Duct", second_labels) - def test_route_eplan_connections_respects_route_segment_capacity_before_detouring(self): + def test_main_path_target_bridge_helper_links_targets_when_route_falls_back_to_routing_range(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") - _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) - _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 0, 0)) - _terminal(doc, terminal_objects, "TerminalStartC", "terminal-start-c", app.Vector(0, 0, 0)) - _terminal(doc, terminal_objects, "TerminalEndC", "terminal-end-c", app.Vector(100, 0, 0)) - direct = routing_network.create_route_carrier( + left = routing_network.create_route_carrier( doc, - [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], - label="Direct Duct", + [app.Vector(0, 0, 20), app.Vector(0, 100, 20)], + label="Left duct", project_uuid="project-1", kind="WireDuct", ) - direct.QetRouteCarrierCapacity = 2 - routing_network.create_route_carrier( + right = routing_network.create_route_carrier( doc, - [app.Vector(0, 0, 20), app.Vector(0, 40, 20)], - label="Left Bridge", + [app.Vector(100, 0, 20), app.Vector(100, 100, 20)], + label="Right duct", project_uuid="project-1", kind="WireDuct", ) - routing_network.create_route_carrier( + fallback = routing_network.create_route_carrier( doc, - [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], - label="Alternate Duct", + [app.Vector(0, 50, 20), app.Vector(100, 50, 20)], + label="Panel fallback", + project_uuid="project-1", + kind="RoutingRange", + ) + report = { + "routes": [ + { + "wire_uuid": "wire-main-target-bridge", + "network": { + "start_terminal_access_target_kind": "WireDuct", + "start_terminal_access_target_name": left.Name, + "start_terminal_access_target_label": left.Label, + "end_terminal_access_target_kind": "WireDuct", + "end_terminal_access_target_name": right.Name, + "end_terminal_access_target_label": right.Label, + }, + "route_track": { + "segments": [ + { + "carrier": { + "kind": "RoutingRange", + "name": fallback.Name, + "label": fallback.Label, + } + } + ] + }, + } + ] + } + + bridge_report = auto_routing._create_main_path_target_bridges_from_report( + doc, + report, + project_uuid="project-1", + ) + + self.assertEqual(1, bridge_report["pairs"]) + self.assertEqual(1, bridge_report["created_count"]) + self.assertEqual(["wire-main-target-bridge"], bridge_report["wire_uuids"]) + self.assertTrue( + any(getattr(obj, "QetRouteBridgeKind", "") == "MainPathTargetBridge" for obj in doc.Objects) + ) + + def test_main_path_target_bridge_detours_around_device_obstacle(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + lower = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(20, 0, 0)], + label="Lower duct", project_uuid="project-1", kind="WireDuct", ) - routing_network.create_route_carrier( + upper = routing_network.create_route_carrier( doc, - [app.Vector(100, 40, 20), app.Vector(100, 0, 20)], - label="Right Bridge", + [app.Vector(0, 0, 100), app.Vector(20, 0, 100)], + label="Upper duct", + project_uuid="project-1", + kind="WireDuct", + ) + obstacle = doc.addObject("Part::Feature", "MiddleDevice") + obstacle.Label = "Middle device" + obstacle.Shape = FakeShape(FakeBoundBox(-5, 5, -5, 5, 40, 60)) + report = { + "routes": [ + { + "wire_uuid": "wire-obstacle-bridge", + "network": { + "start_terminal_access_target_kind": "WireDuct", + "start_terminal_access_target_name": lower.Name, + "start_terminal_access_target_label": lower.Label, + "end_terminal_access_target_kind": "WireDuct", + "end_terminal_access_target_name": upper.Name, + "end_terminal_access_target_label": upper.Label, + }, + "route_track": { + "segments": [ + {"carrier": {"kind": "RoutingRange", "label": "Panel range"}} + ] + }, + } + ] + } + + bridge_report = auto_routing._create_main_path_target_bridges_from_report( + doc, + report, + project_uuid="project-1", + ) + + self.assertEqual(1, bridge_report["created_count"]) + bridge = [ + obj + for obj in doc.Objects + if getattr(obj, "QetRouteBridgeKind", "") == "MainPathTargetBridge" + ][0] + bridge_points = list(getattr(bridge, "Points", []) or []) + self.assertGreater(len(bridge_points), 2) + self.assertEqual( + [], + auto_routing.detect_collisions( + bridge_points, + auto_routing.collect_obstacles(doc, options={"obstacle_clearance": 0.0}), + ), + ) + + def test_main_path_target_bridge_ignores_unbound_structural_cabinet_frame(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + left = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], + label="Left duct", + project_uuid="project-1", + kind="WireDuct", + ) + right = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 100), app.Vector(100, 0, 100)], + label="Right duct", project_uuid="project-1", kind="WireDuct", ) + cabinet = doc.addObject("Part::Feature", "CabinetFrame") + cabinet.Label = "NAU03_Cabinet_Frame" + cabinet.Shape = FakeShape(FakeBoundBox(-200, 200, -200, 200, -10, 110)) + + bridges = auto_routing._create_user_path_bridge_between_objects_avoiding_obstacles( + doc, + left, + right, + project_uuid="project-1", + bridge_kind="MainPathTargetBridge", + ) + + self.assertEqual(1, len(bridges)) + bridge_points = list(getattr(bridges[0], "Points", []) or []) + self.assertEqual((0.0, 0.0, 0.0), (bridge_points[0].x, bridge_points[0].y, bridge_points[0].z)) + self.assertEqual((0.0, 0.0, 100.0), (bridge_points[-1].x, bridge_points[-1].y, bridge_points[-1].z)) + bridge_obstacles = auto_routing._route_bridge_obstacles(doc, left, right, left, right) + self.assertNotIn("NAU03_Cabinet_Frame", [item.get("label") for item in bridge_obstacles]) + + def test_main_path_target_bridge_helper_retries_all_wires_for_same_target_pair(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + left = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(0, 100, 20)], + label="Left duct", + project_uuid="project-1", + kind="WireDuct", + ) + right = routing_network.create_route_carrier( + doc, + [app.Vector(100, 0, 20), app.Vector(100, 100, 20)], + label="Right duct", + project_uuid="project-1", + kind="WireDuct", + ) + fallback = routing_network.create_route_carrier( + doc, + [app.Vector(0, 50, 20), app.Vector(100, 50, 20)], + label="Panel fallback", + project_uuid="project-1", + kind="RoutingRange", + ) + + def route(wire_uuid): + return { + "wire_uuid": wire_uuid, + "network": { + "start_terminal_access_target_kind": "WireDuct", + "start_terminal_access_target_name": left.Name, + "start_terminal_access_target_label": left.Label, + "end_terminal_access_target_kind": "WireDuct", + "end_terminal_access_target_name": right.Name, + "end_terminal_access_target_label": right.Label, + }, + "route_track": { + "segments": [ + { + "carrier": { + "kind": "RoutingRange", + "name": fallback.Name, + "label": fallback.Label, + } + } + ] + }, + } + + bridge_report = auto_routing._create_main_path_target_bridges_from_report( + doc, + {"routes": [route("wire-a"), route("wire-b")]}, + project_uuid="project-1", + ) + + self.assertEqual(1, bridge_report["pairs"]) + self.assertEqual(1, bridge_report["created_count"]) + self.assertEqual(["wire-a", "wire-b"], bridge_report["wire_uuids"]) + + def test_main_path_target_retry_payload_requires_targets_and_forbids_fallback(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + payload = { + "project_uuid": "project-1", + "wires": [ + {"wire_id": "wire-a", "start_terminal_uuid": "s-a", "end_terminal_uuid": "e-a"}, + {"wire_id": "wire-b", "start_terminal_uuid": "s-b", "end_terminal_uuid": "e-b"}, + ], + } + report = { + "routes": [ + { + "wire_uuid": "wire-a", + "network": { + "start_terminal_access_target_kind": "WireDuct", + "start_terminal_access_target_name": "QETRouteCarrier_Main", + "start_terminal_access_target_label": "Main duct", + "end_terminal_access_target_kind": "WireDuct", + "end_terminal_access_target_name": "QETRouteCarrier_Main", + "end_terminal_access_target_label": "Main duct", + }, + "route_track": { + "segments": [ + {"carrier": {"kind": "RoutingRange", "label": "Panel range"}} + ] + }, + }, + { + "wire_uuid": "wire-b", + "network": { + "start_terminal_access_target_kind": "WireDuctOpenEnd", + "start_terminal_access_target_name": "QETRouteCarrier_OpenEnd", + "start_terminal_access_target_label": "Main duct open end", + "end_terminal_access_target_kind": "WireDuct", + "end_terminal_access_target_name": "QETRouteCarrier_Main", + "end_terminal_access_target_label": "Main duct", + }, + "route_track": { + "segments": [ + {"carrier": {"kind": "RoutingRange", "label": "Panel range"}} + ] + }, + }, + ] + } + + retry_payload, summary = auto_routing._same_main_path_target_retry_payload(payload, report) + + self.assertEqual(["wire-a", "wire-b"], summary["wire_uuids"]) + self.assertEqual(2, len(retry_payload["wires"])) + same_target_wire = retry_payload["wires"][0] + open_end_wire = retry_payload["wires"][1] + self.assertEqual(["QETRouteCarrier_Main"], same_target_wire["required_route_carrier_names"]) + self.assertEqual(["Main duct"], same_target_wire["required_route_carrier_labels"]) + self.assertEqual( + ["QETRouteCarrier_OpenEnd", "QETRouteCarrier_Main"], + open_end_wire["required_route_carrier_names"], + ) + self.assertEqual( + ["Main duct open end", "Main duct"], + open_end_wire["required_route_carrier_labels"], + ) + for retry_wire in retry_payload["wires"]: + self.assertEqual( + ["RoutingRange", "AuxiliaryPath"], + retry_wire["forbidden_route_carrier_kinds"], + ) + + def test_route_eplan_connections_respects_route_segment_capacity_before_detouring(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartC", "terminal-start-c", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndC", "terminal-end-c", app.Vector(100, 0, 0)) + direct = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="Direct Duct", + project_uuid="project-1", + kind="WireDuct", + ) + direct.QetRouteCarrierCapacity = 2 + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(0, 40, 20)], + label="Left Bridge", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="Alternate Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(100, 40, 20), app.Vector(100, 0, 20)], + label="Right Bridge", + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + { + "wire_id": "wire-b", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + }, + { + "wire_id": "wire-c", + "start_terminal_uuid": "terminal-start-c", + "end_terminal_uuid": "terminal-end-c", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + + route_labels = [ + [segment["carrier"]["label"] for segment in route["route_track"]["segments"]] + for route in report["routes"] + ] + self.assertIn("Direct Duct", route_labels[0]) + self.assertIn("Direct Duct", route_labels[1]) + self.assertIn("Alternate Duct", route_labels[2]) + self.assertNotIn("Direct Duct", route_labels[2]) + + def test_route_eplan_connections_prefers_over_capacity_wire_duct_over_routing_range(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + for index in range(30): + _terminal(doc, terminal_objects, "TerminalStart{0}".format(index), "terminal-start-{0}".format(index), app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd{0}".format(index), "terminal-end-{0}".format(index), app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="Main Wire Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 5, 20), app.Vector(100, 5, 20)], + label="Panel Routing Range", + project_uuid="project-1", + kind="RoutingRange", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-{0}".format(index), + "start_terminal_uuid": "terminal-start-{0}".format(index), + "end_terminal_uuid": "terminal-end-{0}".format(index), + } + for index in range(30) + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + + self.assertEqual(30, report["routed"]) + for route in report["routes"]: + labels = [ + segment["carrier"]["label"] + for segment in route["route_track"]["segments"] + ] + self.assertIn("Main Wire Duct", labels) + self.assertNotIn("Panel Routing Range", labels) + + def test_route_eplan_connections_prefers_terminal_access_target_over_nearer_routing_range(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 7000, 20), app.Vector(100, 7000, 20)], + label="Target Wire Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 5, 20), app.Vector(100, 5, 20)], + label="Near Panel Range", + project_uuid="project-1", + kind="RoutingRange", + ) + routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + terminal_exit_length=20.0, + terminal_exit_max_length=80.0, + max_distance=10000.0, + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-target-priority", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={ + "network_entry_max_distance": 10000.0, + "terminal_access_max_distance": 10000.0, + }, + ) + + self.assertEqual(1, report["routed"]) + route = report["routes"][0] + self.assertEqual("WireDuct", route["network"]["start_terminal_access_target_kind"]) + self.assertEqual("WireDuct", route["network"]["end_terminal_access_target_kind"]) + labels = [ + segment["carrier"]["label"] + for segment in route["route_track"]["segments"] + ] + self.assertIn("Target Wire Duct", labels) + self.assertNotIn("Near Panel Range", labels) + + def test_network_route_limits_entry_candidates_to_terminal_access_target(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 7000, 20), app.Vector(100, 7000, 20)], + label="Target Wire Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 5, 20), app.Vector(100, 5, 20)], + label="Near Panel Range", + project_uuid="project-1", + kind="RoutingRange", + ) + routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + terminal_exit_length=20.0, + terminal_exit_max_length=80.0, + max_distance=10000.0, + ) + calls = {"shortest_path": 0} + original_shortest_path = routing_network.shortest_path_with_carriers + + def counted_shortest_path(*args, **kwargs): + calls["shortest_path"] += 1 + return original_shortest_path(*args, **kwargs) + + routing_network.shortest_path_with_carriers = counted_shortest_path + try: + result = auto_routing.build_network_route( + start, + end, + options={ + "network_entry_max_distance": 10000.0, + "terminal_access_max_distance": 10000.0, + }, + doc=doc, + ) + finally: + routing_network.shortest_path_with_carriers = original_shortest_path + + self.assertIsNotNone(result) + labels = [ + segment["carrier"]["label"] + for segment in result["route_track"]["segments"] + ] + self.assertIn("Target Wire Duct", labels) + self.assertNotIn("Near Panel Range", labels) + self.assertLessEqual(calls["shortest_path"], 1) + + def test_network_route_uses_single_candidate_on_explicit_terminal_access_target(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [ + app.Vector(0, 20, 20), + app.Vector(30, 20, 20), + app.Vector(60, 20, 20), + app.Vector(100, 20, 20), + ], + label="Multi Segment Target Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + terminal_exit_length=20.0, + terminal_exit_max_length=80.0, + max_distance=1000.0, + ) + calls = {"shortest_path": 0} + original_shortest_path = routing_network.shortest_path_with_carriers + + def counted_shortest_path(*args, **kwargs): + calls["shortest_path"] += 1 + return original_shortest_path(*args, **kwargs) + + routing_network.shortest_path_with_carriers = counted_shortest_path + try: + result = auto_routing.build_network_route( + start, + end, + options={ + "network_entry_max_distance": 1000.0, + "terminal_access_max_distance": 1000.0, + "network_entry_candidate_limit": 4, + }, + doc=doc, + ) + finally: + routing_network.shortest_path_with_carriers = original_shortest_path + + self.assertIsNotNone(result) + self.assertEqual(1, calls["shortest_path"]) + + def test_network_route_falls_back_when_terminal_access_targets_are_disconnected(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 5, 20), app.Vector(10, 5, 20)], + label="Disconnected Start Target", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(90, 5, 20), app.Vector(100, 5, 20)], + label="Disconnected End Target", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="Fallback Panel Range", + project_uuid="project-1", + kind="RoutingRange", + ) + routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + terminal_exit_length=20.0, + terminal_exit_max_length=80.0, + max_distance=50.0, + ) + + result = auto_routing.build_network_route( + start, + end, + options={ + "network_entry_max_distance": 50.0, + "terminal_access_max_distance": 50.0, + }, + doc=doc, + ) + + self.assertIsNotNone(result) + labels = [ + segment["carrier"]["label"] + for segment in result["route_track"]["segments"] + ] + self.assertIn("Fallback Panel Range", labels) + + def test_route_eplan_connections_precreates_main_path_target_bridges_before_first_route(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 5, 20), app.Vector(10, 5, 20)], + label="Start Wire Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(90, 5, 20), app.Vector(100, 5, 20)], + label="End Wire Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + terminal_exit_length=20.0, + terminal_exit_max_length=80.0, + max_distance=50.0, + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-main-target-prebridge", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + captured = {"precreated_bridges": 0} + original_route_from_payload = auto_routing.route_eplan_connections_from_payload + + def fake_route_from_payload(route_doc, route_payload, *args, **kwargs): + captured["precreated_bridges"] = sum( + 1 + for carrier in routing_network.collect_route_carriers(route_doc) + if getattr(carrier, "QetRouteBridgeKind", "") == "MainPathTargetBridge" + ) + return { + "project_uuid": "project-1", + "total_wires": 1, + "routed": 1, + "routes": [], + "skipped_missing_terminal": 0, + "skipped_missing_route_network": 0, + "skipped_invalid": 0, + "errors": [], + } + + auto_routing.route_eplan_connections_from_payload = fake_route_from_payload + try: + report = auto_routing.route_eplan_connections( + doc, + payload=payload, + update_network=False, + options={ + "auto_create_diagnostic_bridges": False, + "auto_create_main_path_detour_bridges": False, + "auto_create_terminal_access_fallback_bridges": False, + }, + ) + finally: + auto_routing.route_eplan_connections_from_payload = original_route_from_payload + + self.assertEqual(1, report["routed"]) + self.assertEqual(1, captured["precreated_bridges"]) + self.assertEqual(1, report["auto_main_path_target_bridges"]["precreated_count"]) + self.assertEqual(0, report["auto_main_path_target_bridges"]["retry_wires"]) + + def test_refresh_terminal_access_after_bridge_creation_retargets_to_nearer_user_path(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + terminal = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + far_duct = routing_network.create_route_carrier( + doc, + [app.Vector(0, 500, 20), app.Vector(100, 500, 20)], + label="Far Wire Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + terminal_exit_length=20.0, + terminal_exit_max_length=80.0, + max_distance=1000.0, + ) + old_access = routing_network.terminal_access_carrier_for_terminal(terminal) + self.assertIsNotNone(old_access) + self.assertEqual(far_duct.Name, old_access.QetTerminalAccessTargetName) + self.assertGreater(old_access.QetTerminalAccessTargetDistanceMm, 400.0) + + near_bridge = routing_network.create_route_carrier( + doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="Near User Bridge", + project_uuid="project-1", + kind="UserPath", + ) + + refresh = auto_routing._refresh_terminal_access_after_route_network_change( + doc, + project_uuid="project-1", + options={ + "terminal_exit_length": 20.0, + "terminal_exit_max_length": 80.0, + "terminal_access_max_distance": 1000.0, + }, + ) + + new_access = routing_network.terminal_access_carrier_for_terminal(terminal) + self.assertEqual(1, refresh["terminal_access_carriers"]) + self.assertIsNotNone(new_access) + self.assertEqual(near_bridge.Name, new_access.QetTerminalAccessTargetName) + self.assertLess(new_access.QetTerminalAccessTargetDistanceMm, 50.0) + + def test_route_eplan_connections_refreshes_terminal_access_after_precreated_bridge(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + far_duct = routing_network.create_route_carrier( + doc, + [app.Vector(0, 500, 20), app.Vector(100, 500, 20)], + label="Far Wire Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + terminal_exit_length=20.0, + terminal_exit_max_length=80.0, + max_distance=1000.0, + ) + old_access = routing_network.terminal_access_carrier_for_terminal(start) + self.assertEqual(far_duct.Name, old_access.QetTerminalAccessTargetName) + + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-refresh-precreated-bridge", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + captured = {"target_name": ""} + originals = { + "precreate": auto_routing._create_main_path_target_bridges_from_payload, + "route_from_payload": auto_routing.route_eplan_connections_from_payload, + "diagnose": routing_network.diagnose_routing_path_network, + "write_diag": auto_routing._write_routing_connection_batch_diagnostic, + } + + def fake_precreate(route_doc, *args, **kwargs): + near_bridge = routing_network.create_route_carrier( + route_doc, + [app.Vector(0, 40, 20), app.Vector(100, 40, 20)], + label="Near Precreated Bridge", + project_uuid="project-1", + kind="UserPath", + ) + return { + "enabled": True, + "pairs": 1, + "created_count": 1, + "duplicates": 0, + "missing_pairs": [], + "created_pair_labels": [near_bridge.Label], + "wire_uuids": ["wire-refresh-precreated-bridge"], + "rerouted": False, + "precreated_count": 1, + } + + def fake_route_from_payload(route_doc, route_payload, *args, **kwargs): + access = routing_network.terminal_access_carrier_for_terminal(start) + captured["target_name"] = getattr(access, "QetTerminalAccessTargetName", "") + return { + "project_uuid": "project-1", + "total_wires": 1, + "routed": 1, + "routes": [], + "skipped_missing_terminal": 0, + "skipped_missing_route_network": 0, + "skipped_invalid": 0, + "errors": [], + } + + auto_routing._create_main_path_target_bridges_from_payload = fake_precreate + auto_routing.route_eplan_connections_from_payload = fake_route_from_payload + routing_network.diagnose_routing_path_network = lambda *args, **kwargs: {"ok": True, "issues": [], "summary": {}} + auto_routing._write_routing_connection_batch_diagnostic = lambda *args, **kwargs: None + try: + report = auto_routing.route_eplan_connections( + doc, + payload=payload, + update_network=False, + options={ + "auto_create_diagnostic_bridges": False, + "auto_create_main_path_detour_bridges": False, + "auto_create_terminal_access_fallback_bridges": False, + }, + ) + finally: + auto_routing._create_main_path_target_bridges_from_payload = originals["precreate"] + auto_routing.route_eplan_connections_from_payload = originals["route_from_payload"] + routing_network.diagnose_routing_path_network = originals["diagnose"] + auto_routing._write_routing_connection_batch_diagnostic = originals["write_diag"] + + self.assertEqual(1, report["routed"]) + self.assertNotEqual(far_duct.Name, captured["target_name"]) + + def test_route_eplan_connections_from_payload_reuses_cached_route_graph(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="Main Wire Duct", + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-cached-graph", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + call_count = {"value": 0} + original_build_route_graph = routing_network.build_route_graph + + def counted_build_route_graph(*args, **kwargs): + call_count["value"] += 1 + return original_build_route_graph(*args, **kwargs) + + routing_network.build_route_graph = counted_build_route_graph + try: + cache = {} + first = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"__route_network_cache": cache}, + ) + second = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"__route_network_cache": cache}, + ) + finally: + routing_network.build_route_graph = original_build_route_graph + + self.assertEqual(1, first["routed"]) + self.assertEqual(1, second["routed"]) + self.assertEqual(1, call_count["value"]) + self.assertFalse(first["route_network_reused"]) + self.assertTrue(second["route_network_reused"]) + + def test_route_eplan_connections_reuses_diagnostic_route_graph_for_initial_payload_route(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + label="Main Wire Duct", + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-reuse-diagnostic-graph", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + call_count = {"value": 0} + original_build_route_graph = routing_network.build_route_graph + + def counted_build_route_graph(*args, **kwargs): + call_count["value"] += 1 + return original_build_route_graph(*args, **kwargs) + + routing_network.build_route_graph = counted_build_route_graph + try: + report = auto_routing.route_eplan_connections( + doc, + payload=payload, + update_network=False, + options={ + "auto_create_diagnostic_bridges": False, + "auto_create_main_path_target_bridges": False, + "auto_create_main_path_detour_bridges": False, + "auto_create_terminal_access_fallback_bridges": False, + }, + ) + finally: + routing_network.build_route_graph = original_build_route_graph + + self.assertEqual(1, report["routed"]) + self.assertEqual(1, call_count["value"]) + self.assertTrue(report["route_network_reused"]) + + def test_generate_eplan_routing_path_network_skips_prepare_existing_network_summary(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + doc = FakeDocument() + call_count = {"value": 0} + original_build_route_graph = routing_network.build_route_graph + + def counted_build_route_graph(*args, **kwargs): + call_count["value"] += 1 + return original_build_route_graph(*args, **kwargs) + + routing_network.build_route_graph = counted_build_route_graph + try: + report = auto_routing.generate_eplan_routing_path_network( + doc, + project_uuid="project-1", + ) + finally: + routing_network.build_route_graph = original_build_route_graph + + self.assertIn("layout_space", report) + self.assertNotIn("existing_network", report["layout_space"]) + self.assertEqual(2, call_count["value"]) + + def test_route_eplan_connections_does_not_refresh_full_network_after_auto_bridge(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + doc = FakeDocument() payload = { "project_uuid": "project-1", "wires": [ { "wire_id": "wire-a", - "start_terminal_uuid": "terminal-start-a", - "end_terminal_uuid": "terminal-end-a", - }, - { - "wire_id": "wire-b", - "start_terminal_uuid": "terminal-start-b", - "end_terminal_uuid": "terminal-end-b", - }, - { - "wire_id": "wire-c", - "start_terminal_uuid": "terminal-start-c", - "end_terminal_uuid": "terminal-end-c", - }, + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } ], } + update_calls = {"value": 0} + route_calls = {"value": 0} + originals = { + "update": auto_routing.update_eplan_routing_path_network, + "diagnose": routing_network.diagnose_routing_path_network, + "diagnostic_bridges": routing_network.create_user_path_bridges_from_diagnostic_suggestions, + "route_from_payload": auto_routing.route_eplan_connections_from_payload, + "target_bridges": auto_routing._create_main_path_target_bridges_from_report, + "write_diag": auto_routing._write_routing_connection_batch_diagnostic, + } - report = auto_routing.route_eplan_connections_from_payload(doc, payload) + def fake_update(*args, **kwargs): + update_calls["value"] += 1 + return {"updated": True} - route_labels = [ - [segment["carrier"]["label"] for segment in route["route_track"]["segments"]] - for route in report["routes"] - ] - self.assertIn("Direct Duct", route_labels[0]) - self.assertIn("Direct Duct", route_labels[1]) - self.assertIn("Alternate Duct", route_labels[2]) - self.assertNotIn("Direct Duct", route_labels[2]) + def fake_route_from_payload(*args, **kwargs): + route_calls["value"] += 1 + carrier_kind = "RoutingRange" if route_calls["value"] == 1 else "WireDuct" + return { + "total_wires": 1, + "routed": 1, + "routes": [ + { + "wire_uuid": "wire-a", + "route_status": "OK", + "length_mm": 10.0, + "wire_style_status": "Resolved", + "route_track": { + "segments": [ + {"carrier": {"kind": carrier_kind, "label": carrier_kind}} + ] + }, + "network": {}, + } + ], + "skipped_missing_terminal": 0, + "skipped_missing_route_network": 0, + "skipped_invalid": 0, + "errors": [], + } + + auto_routing.update_eplan_routing_path_network = fake_update + routing_network.diagnose_routing_path_network = lambda *args, **kwargs: {"ok": True, "issues": [], "summary": {}} + routing_network.create_user_path_bridges_from_diagnostic_suggestions = lambda *args, **kwargs: {"created": []} + auto_routing.route_eplan_connections_from_payload = fake_route_from_payload + auto_routing._create_main_path_target_bridges_from_report = lambda *args, **kwargs: { + "enabled": True, + "pairs": 1, + "created_count": 1, + "duplicates": 0, + "missing_pairs": [], + "created_pair_labels": ["A -> B"], + "wire_uuids": ["wire-a"], + "rerouted": False, + } + auto_routing._write_routing_connection_batch_diagnostic = lambda *args, **kwargs: None + try: + report = auto_routing.route_eplan_connections( + doc, + payload=payload, + update_network=True, + options={ + "auto_create_main_path_detour_bridges": False, + "auto_create_terminal_access_fallback_bridges": False, + }, + ) + finally: + auto_routing.update_eplan_routing_path_network = originals["update"] + routing_network.diagnose_routing_path_network = originals["diagnose"] + routing_network.create_user_path_bridges_from_diagnostic_suggestions = originals["diagnostic_bridges"] + auto_routing.route_eplan_connections_from_payload = originals["route_from_payload"] + auto_routing._create_main_path_target_bridges_from_report = originals["target_bridges"] + auto_routing._write_routing_connection_batch_diagnostic = originals["write_diag"] + + self.assertEqual(1, report["routed"]) + self.assertEqual(2, route_calls["value"]) + self.assertEqual(1, update_calls["value"]) def test_route_eplan_connections_prefers_unused_segments_occupied_by_existing_wires(self): _install_fake_freecad() @@ -17077,6 +19376,67 @@ class AutoRoutingTest(unittest.TestCase): self.assertIn("routing_errors", payload["issue_codes"]) self.assertIn("错误 71 条", message) + def test_batch_issue_codes_ignore_candidate_debug_warnings_when_final_routes_are_clean(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "total_wires": 2, + "routed": 2, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "skipped_missing_route_network": 0, + "skipped_invalid": 0, + "route_status_counts": {"Routed": 2}, + "routes": [ + { + "wire_uuid": "wire-candidate-risk", + "wire_label": "N1", + "route_status": "Routed", + "network": {"route_candidate_obstacle_hits": 2}, + "route_track": { + "segments": [ + { + "carrier": { + "kind": "WireDuct", + "label": "主线槽A", + "capacity": 1, + } + } + ] + }, + "lane": {"index": 1}, + }, + { + "wire_uuid": "wire-clean", + "wire_label": "N2", + "route_status": "Routed", + "route_track": { + "segments": [ + { + "carrier": { + "kind": "WireDuct", + "label": "主线槽A", + "capacity": 1, + } + } + ] + }, + "lane": {"index": 0}, + }, + ], + } + + issue_codes = auto_routing._routing_connection_batch_issue_codes(report) + payload = auto_routing._compact_routing_connection_batch_report(report) + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertEqual([], issue_codes) + self.assertEqual([], payload["issue_codes"]) + self.assertEqual(1, payload["route_candidate_obstacle_warning_count"]) + self.assertEqual(1, payload["route_capacity_pressure_warning_count"]) + self.assertNotIn("接入避障提示", message) + self.assertNotIn("容量提示", message) + def test_route_eplan_connections_report_includes_missing_route_network_sample(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() @@ -19078,6 +21438,177 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual("Available", report["wire_style_database"]["status"]) self.assertEqual(1, report["wire_style"]["resolved"]) + def test_routing_preflight_discovers_style_database_from_saved_fcstd_path(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + with tempfile.TemporaryDirectory() as temp_dir: + project_dir = Path(temp_dir) / "project-a" + exchange_dir = project_dir / ".qet_freecad" + data_dir = project_dir / "datafiles" + exchange_dir.mkdir(parents=True) + data_dir.mkdir(parents=True) + doc.FileName = str(exchange_dir / "QETScene.FCStd") + db_path = data_dir / "project-local.db" + connection = sqlite3.connect(str(db_path)) + try: + connection.execute( + """ + CREATE TABLE wire_properties ( + id INTEGER PRIMARY KEY, + project_uuid TEXT, + name TEXT, + line_color TEXT + ) + """ + ) + connection.execute( + "INSERT INTO wire_properties (id, project_uuid, name, line_color) VALUES (?, ?, ?, ?)", + (1, "project-1", "黑色控制线", "#000000"), + ) + connection.commit() + finally: + connection.close() + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "wire_label": "N1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + "wire_style_id": "1", + } + ], + } + + report = auto_routing.preflight_eplan_connections(doc, payload) + + self.assertTrue(report["ok"]) + self.assertEqual(str(db_path), report["wire_style_database"]["path"]) + self.assertEqual("Available", report["wire_style_database"]["status"]) + self.assertEqual(1, report["wire_style"]["resolved"]) + + def test_route_eplan_connections_loads_v2_device_terminals_from_saved_fcstd_path(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + root = terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 50, 0)) + terminal_objects.set_terminal_semantics( + start, + "project-1", + "device-start", + "terminal-start", + "instance-start", + label="S1", + ) + device = doc.addObject("App::DocumentObjectGroup", "QETDevice_shared") + root.addObject(device) + terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "device-shared") + terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "instance-shared") + terminal_group = terminal_objects.ensure_terminal_group( + doc, + device, + project_uuid="project-1", + instance_id="instance-shared", + ) + for name, point in ( + ("QETTerminal_shared_a", app.Vector(120, 0, 0)), + ("QETTerminal_shared_b", app.Vector(120, 50, 0)), + ): + terminal = terminal_objects.create_lcs_object( + doc, + name, + placement=app.Placement(point, app.Rotation()), + label="old", + ) + terminal_group.addObject(terminal) + terminal_objects.set_terminal_semantics( + terminal, + "project-1", + "wrong-element", + "shared-terminal", + "instance-shared", + label="old", + slot_name="old", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 50, 20), app.Vector(120, 50, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + with tempfile.TemporaryDirectory() as temp_dir: + project_dir = Path(temp_dir) / "project-a" + exchange_dir = project_dir / ".qet_freecad" + exchange_dir.mkdir(parents=True) + doc.FileName = str(exchange_dir / "QETScene.FCStd") + (exchange_dir / "2d_to_3d.json").write_text( + json.dumps( + { + "project_uuid": "project-1", + "devices": [ + { + "device_instance_id": "instance-shared", + "terminals": [ + { + "terminal_uuid": "shared-terminal", + "element_uuid": "element-a", + "terminal_instance_id": "instance-shared", + "terminal_display": "A1", + }, + { + "terminal_uuid": "shared-terminal", + "element_uuid": "element-b", + "terminal_instance_id": "instance-shared", + "terminal_display": "B1", + }, + ], + } + ], + "wires": [], + } + ), + encoding="utf-8", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-from-task-payload", + "start_element_uuid": "device-start", + "start_instance_id": "instance-start", + "start_terminal_uuid": "terminal-start", + "start_terminal_display": "S1", + "end_element_uuid": "element-b", + "end_instance_id": "instance-shared", + "end_terminal_uuid": "shared-terminal", + "end_terminal_display": "B1", + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + + self.assertEqual(1, report["routed"]) + self.assertEqual(2, report["repaired_duplicate_terminal_metadata"]) + routed_group = doc.getObject("QETWiring_04_Routed") + wire = list(getattr(routed_group, "Group", []) or [])[0] + self.assertEqual("element-b", wire.QetEndElementUuid) + self.assertEqual("B1", wire.QetEndTerminalDisplay) + def test_bind_wire_task_terminals_from_payload_does_not_create_wires(self): _install_fake_freecad() terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() @@ -19310,6 +21841,38 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual([], routing_network.collect_route_carriers(doc)) self.assertIn(wire, wiring_objects.ensure_routed_group(doc, "project-1").Group) + def test_apply_phase1_acceptance_view_hides_carriers_and_shows_routed_wires(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + carrier = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + ) + wire = auto_routing.route_eplan_connection_between_terminals(doc, start, end)["wire"] + wire.ViewObject.Visibility = False + carrier.ViewObject.Visibility = True + terminal_objects.ensure_string_property( + wire, + "QetWireStyleJson", + "QET Wiring", + "", + json.dumps({"line_color": "#000000", "line_width": 2.5}), + ) + + report = auto_routing.apply_phase1_acceptance_view(doc) + + self.assertTrue(wire.ViewObject.Visibility) + self.assertFalse(carrier.ViewObject.Visibility) + self.assertEqual(1, report["routed_wire_visibility"]["visible"]) + self.assertEqual(0, report["route_carrier_visibility"]["visible_after_hide"]) + self.assertEqual(1, report["wire_style_application"]["styled_black"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/python/freecad_exchange_device_import_fcstd_test.py b/tests/python/freecad_exchange_device_import_fcstd_test.py index 7e1d917..655028f 100644 --- a/tests/python/freecad_exchange_device_import_fcstd_test.py +++ b/tests/python/freecad_exchange_device_import_fcstd_test.py @@ -948,6 +948,53 @@ class FcstdDeviceImportTest(unittest.TestCase): self.assertEqual("Placed", device_group.QetAssemblyState) self.assertEqual(["DeviceBody"], [obj.Name for obj in device_group.Group if obj.Name == "DeviceBody"]) + def test_insert_pending_device_does_not_treat_existing_engineering_terminals_as_model_objects(self): + with tempfile.TemporaryDirectory() as temp_dir: + model_path = Path(temp_dir) / "device.step" + model_path.write_text("fake step placeholder", encoding="utf-8") + _install_fake_freecad(None) + + device_import, _ = _reload_modules() + + doc = FakeDocument("QETScene") + root = device_import._ensure_root_group(doc, None, "project-1") + device_group, _created = device_import._ensure_device_group( + doc, + root, + "element-a", + "device-inst-1", + str(model_path), + "A6300-2", + 0, + ) + device_import._set_device_assembly_state( + device_group, + device_import.ASSEMBLY_STATE_PENDING, + ) + terminal = doc.addObject("Part::LocalCoordinateSystem", "QETTerminal_existing") + terminal.Role = "Terminal" + terminal.addProperty("App::PropertyString", "QetTerminalUuid", "QET Exchange", "") + terminal.QetTerminalUuid = "terminal-a" + terminal.addProperty("App::PropertyBool", "CanWire", "QET Exchange", "") + terminal.CanWire = True + device_group.addObject(terminal) + + import_calls = [] + + def fake_import_model(doc_arg, group_arg, path_arg, **kwargs): + import_calls.append((doc_arg, group_arg, path_arg, kwargs)) + body = doc_arg.addObject("Part::Feature", "DeviceBody") + group_arg.addObject(body) + return [body] + + device_import._import_model_into_group = fake_import_model + + result = device_import.insert_pending_device(doc, device_group) + + self.assertEqual(1, len(import_calls)) + self.assertFalse(result["already_placed"]) + self.assertEqual(["DeviceBody"], [obj.Name for obj in result["imported_objects"]]) + def test_insert_pending_device_can_place_whole_device_on_mount_target(self): with tempfile.TemporaryDirectory() as temp_dir: model_path = Path(temp_dir) / "device.step" @@ -1201,6 +1248,171 @@ class FcstdDeviceImportTest(unittest.TestCase): self.assertIn("jhd5.FCStd", rows[0]["display_text"]) self.assertIn("QET_Exchange_OpenPendingDevicePanel", registered) + def test_pending_device_panel_focuses_imported_objects_after_insert(self): + _install_fake_freecad(None) + gui = sys.modules["FreeCADGui"] + selection_calls = [] + view_messages = [] + gui.Selection = types.SimpleNamespace( + clearSelection=lambda: selection_calls.append(("clear",)), + addSelection=lambda doc_name, obj_name: selection_calls.append((doc_name, obj_name)), + ) + gui.SendMsgToActiveView = lambda message: view_messages.append(message) + + device_import, _ = _reload_modules() + sys.modules.pop("PendingDeviceAssemblyPanel", None) + pending_panel = importlib.import_module("PendingDeviceAssemblyPanel") + + doc = FakeDocument("QETScene") + device = doc.addObject("App::Part", "QETDevice_A6300_2") + body = doc.addObject("Part::Feature", "Solid") + body.ViewObject.Visibility = False + + pending_panel.focus_inserted_result(doc, {"device": device, "imported_objects": [body]}) + + self.assertTrue(body.ViewObject.Visibility) + self.assertEqual([("clear",), ("QETScene", "Solid")], selection_calls) + self.assertEqual(["ViewSelection"], view_messages) + + def test_pending_device_panel_rejects_insert_result_without_visible_model_objects(self): + _install_fake_freecad(None) + _reload_modules() + sys.modules.pop("PendingDeviceAssemblyPanel", None) + pending_panel = importlib.import_module("PendingDeviceAssemblyPanel") + + doc = FakeDocument("QETScene") + device = doc.addObject("App::Part", "QETDevice_A6300_2") + + with self.assertRaisesRegex( + pending_panel.PendingDeviceAssemblyPanelError, + "没有导入任何可显示模型对象", + ): + pending_panel.focus_inserted_result(doc, {"device": device, "imported_objects": []}) + + def test_pending_device_panel_syncs_engineering_terminals_after_insert(self): + _install_fake_freecad(None) + app = sys.modules["FreeCAD"] + device_import, _ = _reload_modules() + sys.modules.pop("PendingDeviceAssemblyPanel", None) + pending_panel = importlib.import_module("PendingDeviceAssemblyPanel") + + doc = FakeDocument("QETScene") + app.ActiveDocument = doc + app._qet_exchange_payload = {"project_uuid": "project-1", "devices": []} + device = doc.addObject("App::Part", "QETDevice_A6300_2") + body = doc.addObject("Part::Feature", "Solid") + calls = [] + + def fake_insert(insert_doc, insert_device, **kwargs): + calls.append(("insert", insert_doc, insert_device, kwargs)) + return {"device": insert_device, "imported_objects": [body]} + + class FakeTerminalImport: + @staticmethod + def import_terminals_from_payload(payload, scene_path=""): + calls.append(("terminals", payload, scene_path)) + return {"imported_terminals": 1, "updated_terminals": 2, "warnings": []} + + original_insert = device_import.insert_pending_device + original_terminal_import = pending_panel.TerminalImport + try: + device_import.insert_pending_device = fake_insert + pending_panel.TerminalImport = FakeTerminalImport + + result = pending_panel.insert_device_and_sync_terminals( + doc, + device, + mount_offset_mm=20.0, + ) + finally: + device_import.insert_pending_device = original_insert + pending_panel.TerminalImport = original_terminal_import + + self.assertIs(device, result["device"]) + self.assertEqual([body], result["imported_objects"]) + self.assertEqual({"imported_terminals": 1, "updated_terminals": 2, "warnings": []}, result["terminal_report"]) + self.assertEqual("insert", calls[0][0]) + self.assertEqual("terminals", calls[1][0]) + self.assertEqual(app._qet_exchange_payload, calls[1][1]) + + def test_pending_device_panel_batch_inserts_same_prefix_qet_devices_to_target(self): + _install_fake_freecad(None) + app = sys.modules["FreeCAD"] + device_import, _template_semantics = _reload_modules() + terminal_objects = importlib.import_module("TerminalObjects") + sys.modules.pop("PendingDeviceAssemblyPanel", None) + pending_panel = importlib.import_module("PendingDeviceAssemblyPanel") + + doc = FakeDocument("QETScene") + app.ActiveDocument = doc + app._qet_exchange_payload = {"project_uuid": "project-1", "devices": []} + root = device_import._ensure_root_group(doc, None, "project-1") + + def pending_device(element_uuid, instance_id, display_tag): + device, _created = device_import._ensure_device_group( + doc, + root, + element_uuid, + instance_id, + r"D:\models\terminal.FCStd", + display_tag, + 0, + ) + device_import._set_device_assembly_state( + device, + device_import.ASSEMBLY_STATE_PENDING, + ) + return device + + ud2 = pending_device("element-ud2", "instance-ud2", "UD:2") + pending_device("element-id1", "instance-id1", "ID:1") + pending_device("element-ud1", "instance-ud1", "UD:1") + rail = doc.addObject("App::DocumentObjectGroup", "DINRail") + rail.Placement = app.Placement(app.Vector(10, 0, 0), app.Rotation()) + terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail") + terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x") + + calls = [] + body = doc.addObject("Part::Feature", "ImportedBody") + + def fake_insert(insert_doc, insert_device, **kwargs): + calls.append((getattr(insert_device, "QetDisplayTag", ""), kwargs)) + return {"device": insert_device, "imported_objects": [body]} + + class FakeTerminalImport: + @staticmethod + def import_terminals_from_payload(payload, scene_path=""): + calls.append(("terminals", payload, scene_path)) + return {"imported_terminals": 2, "updated_terminals": 0, "warnings": []} + + original_insert = device_import.insert_pending_device + original_terminal_import = pending_panel.TerminalImport + try: + device_import.insert_pending_device = fake_insert + pending_panel.TerminalImport = FakeTerminalImport + + report = pending_panel.insert_matching_pending_batch_to_target( + doc, + ud2, + rail, + pitch_mm=5.2, + start_offset_mm=1.0, + mount_offset_mm=20.0, + ) + finally: + device_import.insert_pending_device = original_insert + pending_panel.TerminalImport = original_terminal_import + + self.assertEqual(["UD:1", "UD:2"], [call[0] for call in calls[:2]]) + self.assertEqual("terminals", calls[2][0]) + self.assertEqual(2, len(report["devices"])) + self.assertEqual("UD", report["batch_prefix"]) + self.assertEqual(["UD:1", "UD:2"], report["device_labels"]) + self.assertEqual(2, report["inserted_count"]) + self.assertEqual({"imported_terminals": 2, "updated_terminals": 0, "warnings": []}, report["terminal_report"]) + self.assertAlmostEqual(1.0, calls[0][1]["mount_placement"].Base.x - rail.Placement.Base.x) + self.assertAlmostEqual(6.2, calls[1][1]["mount_placement"].Base.x - rail.Placement.Base.x) + def test_import_devices_from_payload_reuses_fcstd_source_document_within_one_sync(self): source = FakeDocument("TerminalSlice", r"D:\models\qet_terminal_slice.FCStd") source.addObject("Part::Feature", "Body") diff --git a/tests/python/freecad_exchange_terminal_import_template_slots_test.py b/tests/python/freecad_exchange_terminal_import_template_slots_test.py index 92aa105..96572df 100644 --- a/tests/python/freecad_exchange_terminal_import_template_slots_test.py +++ b/tests/python/freecad_exchange_terminal_import_template_slots_test.py @@ -192,6 +192,426 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase): self.assertEqual("generated_bbox_fallback", terminals[0].QetTerminalGeometrySource) self.assertFalse(terminals[0].ViewObject.Visibility) + def test_import_repairs_duplicate_terminal_instance_ids(self): + _install_fake_freecad() + terminal_import, terminal_objects, device_import = _reload_modules() + + doc = FakeDocument() + device_import._ensure_document = lambda scene_path: doc + root = device_import._ensure_root_group(doc, project_uuid="project-1") + device = doc.addObject("App::Part", "QETDevice_device_a") + root.addObject(device) + terminal_objects.ensure_string_property( + device, + "QetProjectUuid", + "QET Exchange", + "Project UUID", + "project-1", + ) + terminal_objects.ensure_string_property( + device, + "QetElementUuid", + "QET Exchange", + "Element UUID", + "device-a", + ) + terminal_objects.ensure_string_property( + device, + "QetInstanceId", + "QET Exchange", + "Instance ID", + "device-instance-a", + ) + + report = terminal_import.import_terminals_from_payload( + { + "project_uuid": "project-1", + "devices": [ + { + "device_instance_id": "device-instance-a", + "terminals": [ + { + "terminal_uuid": "shared-terminal", + "element_uuid": "element-a", + "terminal_instance_id": "shared-instance", + "terminal_display": "A1", + }, + { + "terminal_uuid": "shared-terminal", + "element_uuid": "element-b", + "terminal_instance_id": "shared-instance", + "terminal_display": "B1", + }, + ], + } + ], + } + ) + + terminal_group = terminal_objects.find_child_group_by_kind( + device, + terminal_objects.TERMINAL_GROUP_KIND, + ) + terminals = terminal_objects.collect_terminal_objects(terminal_group) + terminal_instance_ids = [terminal.QetTerminalInstanceId for terminal in terminals] + + self.assertEqual(2, report["imported_terminals"]) + self.assertEqual(2, report["repaired_terminal_instance_ids"]) + self.assertEqual(2, len(set(terminal_instance_ids))) + self.assertNotIn("shared-instance", terminal_instance_ids) + self.assertEqual({"element-a", "element-b"}, {terminal.QetElementUuid for terminal in terminals}) + + def test_repaired_terminal_instance_ids_are_stable_when_payload_order_changes(self): + _install_fake_freecad() + terminal_import, terminal_objects, device_import = _reload_modules() + + def import_and_collect(entries): + doc = FakeDocument() + device_import._ensure_document = lambda scene_path: doc + root = device_import._ensure_root_group(doc, project_uuid="project-1") + device = doc.addObject("App::Part", "QETDevice_device_a") + root.addObject(device) + terminal_objects.ensure_string_property( + device, + "QetProjectUuid", + "QET Exchange", + "Project UUID", + "project-1", + ) + terminal_objects.ensure_string_property( + device, + "QetElementUuid", + "QET Exchange", + "Element UUID", + "device-a", + ) + terminal_objects.ensure_string_property( + device, + "QetInstanceId", + "QET Exchange", + "Instance ID", + "device-instance-a", + ) + + terminal_import.import_terminals_from_payload( + { + "project_uuid": "project-1", + "devices": [ + { + "device_instance_id": "device-instance-a", + "terminals": entries, + } + ], + } + ) + terminal_group = terminal_objects.find_child_group_by_kind( + device, + terminal_objects.TERMINAL_GROUP_KIND, + ) + return { + (terminal.QetElementUuid, terminal.Label): terminal.QetTerminalInstanceId + for terminal in terminal_objects.collect_terminal_objects(terminal_group) + } + + first_order = [ + { + "terminal_uuid": "shared-terminal", + "element_uuid": "element-a", + "terminal_instance_id": "shared-instance", + "terminal_display": "A1", + }, + { + "terminal_uuid": "shared-terminal", + "element_uuid": "element-b", + "terminal_instance_id": "shared-instance", + "terminal_display": "B1", + }, + ] + second_order = list(reversed(first_order)) + + self.assertEqual( + import_and_collect(first_order), + import_and_collect(second_order), + ) + + def test_import_does_not_treat_existing_terminal_object_as_parent_device(self): + _install_fake_freecad() + terminal_import, terminal_objects, device_import = _reload_modules() + + doc = FakeDocument() + device_import._ensure_document = lambda scene_path: doc + root = device_import._ensure_root_group(doc, project_uuid="project-1") + device = doc.addObject("App::Part", "QETDevice_device_a") + root.addObject(device) + terminal_objects.ensure_string_property( + device, + "QetProjectUuid", + "QET Exchange", + "Project UUID", + "project-1", + ) + terminal_objects.ensure_string_property( + device, + "QetElementUuid", + "QET Exchange", + "Element UUID", + "element-a", + ) + terminal_objects.ensure_string_property( + device, + "QetInstanceId", + "QET Exchange", + "Instance ID", + "device-instance-a", + ) + terminal_group = terminal_objects.ensure_terminal_group( + doc, + device, + project_uuid="project-1", + instance_id="device-instance-a", + ) + stale_terminal = terminal_objects.create_lcs_object(doc, "QETTerminal_Stale") + terminal_group.addObject(stale_terminal) + terminal_objects.set_terminal_semantics( + stale_terminal, + "project-1", + "element-b", + "terminal-old", + "device-instance-a", + label="old", + slot_name="old", + terminal_instance_id="terminal-old-instance", + terminal_display="old", + ) + + report = terminal_import.import_terminals_from_payload( + { + "project_uuid": "project-1", + "devices": [ + { + "device_instance_id": "device-instance-a", + "terminals": [ + { + "terminal_uuid": "terminal-b", + "element_uuid": "element-b", + "terminal_instance_id": "terminal-instance-b", + "terminal_display": "B1", + }, + ], + } + ], + } + ) + + terminals = terminal_objects.collect_terminal_objects(terminal_group) + + self.assertEqual(1, report["imported_terminals"]) + self.assertEqual( + {("element-b", "terminal-b", "B1")}, + { + ( + terminal.QetElementUuid, + terminal.QetTerminalUuid, + terminal.QetTerminalDisplay, + ) + for terminal in terminals + }, + ) + + def test_reimport_creates_new_terminal_when_existing_uuid_belongs_to_other_element(self): + _install_fake_freecad() + terminal_import, terminal_objects, device_import = _reload_modules() + + doc = FakeDocument() + device_import._ensure_document = lambda scene_path: doc + root = device_import._ensure_root_group(doc, project_uuid="project-1") + device = doc.addObject("App::Part", "QETDevice_device_a") + root.addObject(device) + terminal_objects.ensure_string_property( + device, + "QetProjectUuid", + "QET Exchange", + "Project UUID", + "project-1", + ) + terminal_objects.ensure_string_property( + device, + "QetElementUuid", + "QET Exchange", + "Element UUID", + "element-a", + ) + terminal_objects.ensure_string_property( + device, + "QetInstanceId", + "QET Exchange", + "Instance ID", + "device-instance-a", + ) + terminal_group = terminal_objects.ensure_terminal_group( + doc, + device, + project_uuid="project-1", + instance_id="device-instance-a", + ) + old_terminal = terminal_objects.create_lcs_object(doc, "QETTerminal_Shared") + terminal_group.addObject(old_terminal) + terminal_objects.set_terminal_semantics( + old_terminal, + "project-1", + "element-a", + "shared-terminal", + "device-instance-a", + label="A1", + slot_name="A1", + terminal_instance_id="old-terminal-instance", + terminal_display="A1", + ) + + report = terminal_import.import_terminals_from_payload( + { + "project_uuid": "project-1", + "devices": [ + { + "device_instance_id": "device-instance-a", + "terminals": [ + { + "terminal_uuid": "shared-terminal", + "element_uuid": "element-a", + "terminal_instance_id": "old-terminal-instance", + "terminal_display": "A1", + }, + { + "terminal_uuid": "shared-terminal", + "element_uuid": "element-b", + "terminal_instance_id": "new-terminal-instance", + "terminal_display": "B1", + }, + ], + } + ], + } + ) + + terminals = terminal_objects.collect_terminal_objects(terminal_group) + + self.assertEqual(1, report["imported_terminals"]) + self.assertEqual(1, report["updated_terminals"]) + self.assertEqual( + {("element-a", "shared-terminal", "A1"), ("element-b", "shared-terminal", "B1")}, + { + ( + terminal.QetElementUuid, + terminal.QetTerminalUuid, + terminal.QetTerminalDisplay, + ) + for terminal in terminals + }, + ) + + def test_reimport_removes_stale_duplicate_qet_terminal_contexts(self): + _install_fake_freecad() + terminal_import, terminal_objects, device_import = _reload_modules() + + doc = FakeDocument() + device_import._ensure_document = lambda scene_path: doc + root = device_import._ensure_root_group(doc, project_uuid="project-1") + device = doc.addObject("App::Part", "QETDevice_device_a") + root.addObject(device) + terminal_objects.ensure_string_property( + device, + "QetProjectUuid", + "QET Exchange", + "Project UUID", + "project-1", + ) + terminal_objects.ensure_string_property( + device, + "QetElementUuid", + "QET Exchange", + "Element UUID", + "element-a", + ) + terminal_objects.ensure_string_property( + device, + "QetInstanceId", + "QET Exchange", + "Instance ID", + "device-instance-a", + ) + terminal_group = terminal_objects.ensure_terminal_group( + doc, + device, + project_uuid="project-1", + instance_id="device-instance-a", + ) + stale_duplicate = terminal_objects.create_lcs_object(doc, "QETTerminal_StaleDuplicate") + terminal_group.addObject(stale_duplicate) + terminal_objects.set_terminal_semantics( + stale_duplicate, + "project-1", + "element-a", + "terminal-a", + "device-instance-a", + label="A1-old", + slot_name="A1", + terminal_instance_id="stale-terminal-instance", + terminal_display="A1", + ) + stale_not_in_payload = terminal_objects.create_lcs_object(doc, "QETTerminal_StaleMissing") + terminal_group.addObject(stale_not_in_payload) + terminal_objects.set_terminal_semantics( + stale_not_in_payload, + "project-1", + "element-c", + "terminal-c", + "device-instance-a", + label="C1", + slot_name="C1", + terminal_instance_id="stale-missing-instance", + terminal_display="C1", + ) + + report = terminal_import.import_terminals_from_payload( + { + "project_uuid": "project-1", + "devices": [ + { + "device_instance_id": "device-instance-a", + "terminals": [ + { + "terminal_uuid": "terminal-a", + "element_uuid": "element-a", + "terminal_instance_id": "terminal-instance-a", + "terminal_display": "A1", + }, + { + "terminal_uuid": "terminal-b", + "element_uuid": "element-b", + "terminal_instance_id": "terminal-instance-b", + "terminal_display": "B1", + }, + ], + } + ], + } + ) + + terminals = terminal_objects.collect_terminal_objects(terminal_group) + + self.assertEqual(1, report["removed_terminals"]) + self.assertEqual( + {("element-a", "terminal-a", "A1"), ("element-b", "terminal-b", "B1")}, + { + ( + terminal.QetElementUuid, + terminal.QetTerminalUuid, + terminal.QetTerminalDisplay, + ) + for terminal in terminals + }, + ) + def test_import_preserves_local_terminals_when_payload_has_no_entry_for_device(self): _install_fake_freecad() terminal_import, terminal_objects, device_import = _reload_modules() @@ -264,6 +684,74 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase): self.assertEqual([local_terminal], terminals) self.assertEqual("local:instance-a:P1", local_terminal.QetTerminalUuid) + def test_import_preserves_existing_qet_terminals_when_payload_has_no_entry_for_device(self): + _install_fake_freecad() + terminal_import, terminal_objects, device_import = _reload_modules() + + doc = FakeDocument() + device_import._ensure_document = lambda scene_path: doc + root = device_import._ensure_root_group(doc, project_uuid="project-1") + device = doc.addObject("App::Part", "QETDevice_device_a") + root.addObject(device) + terminal_objects.ensure_string_property( + device, + "QetProjectUuid", + "QET Exchange", + "Project UUID", + "project-1", + ) + terminal_objects.ensure_string_property( + device, + "QetElementUuid", + "QET Exchange", + "Element UUID", + "device-a", + ) + terminal_objects.ensure_string_property( + device, + "QetInstanceId", + "QET Exchange", + "Instance ID", + "instance-a", + ) + terminal_group = terminal_objects.ensure_terminal_group( + doc, + device, + project_uuid="project-1", + instance_id="instance-a", + ) + existing_terminal = terminal_objects.create_lcs_object(doc, "QETTerminal_Existing") + terminal_group.addObject(existing_terminal) + terminal_objects.set_terminal_semantics( + existing_terminal, + "project-1", + "device-a", + "terminal-a", + "instance-a", + label="P1", + slot_name="P1", + terminal_instance_id="terminal-instance-a", + terminal_display="P1", + ) + + report = terminal_import.import_terminals_from_payload( + { + "project_uuid": "project-1", + "devices": [ + { + "device_instance_id": "instance-a", + "terminals": [], + } + ], + } + ) + + terminals = terminal_objects.collect_terminal_objects(terminal_group) + + self.assertEqual(0, report["removed_terminals"]) + self.assertEqual([existing_terminal], terminals) + self.assertEqual("terminal-a", existing_terminal.QetTerminalUuid) + def test_import_accepts_nested_device_terminals_without_top_level_terminals(self): _install_fake_freecad() terminal_import, terminal_objects, device_import = _reload_modules() diff --git a/tests/python/freecad_exchange_wiring_test.py b/tests/python/freecad_exchange_wiring_test.py index 5eddf26..c09c7d6 100644 --- a/tests/python/freecad_exchange_wiring_test.py +++ b/tests/python/freecad_exchange_wiring_test.py @@ -560,6 +560,65 @@ class WiringTest(unittest.TestCase): label="P1", ) + with tempfile.TemporaryDirectory() as tmp_dir: + report = write_back.write_back_document( + doc, + scene_path=str(Path(tmp_dir) / "scene.FCStd"), + payload={"project_uuid": "project-1"}, + ) + + self.assertEqual(1, len(report["terminals"])) + self.assertEqual("terminal-a", report["terminals"][0]["terminal_uuid"]) + self.assertEqual("instance-a", report["terminals"][0]["device_instance_id"]) + self.assertNotEqual("instance-a", report["terminals"][0]["terminal_instance_id"]) + + def test_writeback_prefers_terminal_instance_id_property(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _manual_wiring, write_back = _reload_modules() + + doc = FakeDocument() + root = terminal_objects.ensure_root_group(doc, "project-1") + device = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_a") + root.addObject(device) + terminal_objects.ensure_string_property( + device, + "QetElementUuid", + "QET Exchange", + "Element UUID", + "device-a", + ) + terminal_objects.ensure_string_property( + device, + "QetInstanceId", + "QET Exchange", + "Instance ID", + "device-instance-a", + ) + terminal_group = terminal_objects.ensure_terminal_group( + doc, + device, + project_uuid="project-1", + instance_id="device-instance-a", + ) + + terminal = terminal_objects.create_lcs_object(doc, "QETTerminal_A") + terminal_group.addObject(terminal) + terminal_objects.set_terminal_semantics( + terminal, + "project-1", + "device-a", + "terminal-a", + "device-instance-a", + label="A", + ) + terminal_objects.ensure_string_property( + terminal, + "QetTerminalInstanceId", + "QET Exchange", + "Stable 3D terminal instance UUID", + "terminal-instance-a", + ) + with tempfile.TemporaryDirectory() as tmp_dir: report = write_back.write_back_document( doc, @@ -570,8 +629,8 @@ class WiringTest(unittest.TestCase): self.assertEqual( [{ "terminal_uuid": "terminal-a", - "device_instance_id": "instance-a", - "terminal_instance_id": "instance-a", + "device_instance_id": "device-instance-a", + "terminal_instance_id": "terminal-instance-a", }], report["terminals"], ) @@ -642,6 +701,132 @@ class WiringTest(unittest.TestCase): report["instances"], ) + def test_writeback_syncs_terminals_from_payload_before_collecting_bindings(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _manual_wiring, write_back = _reload_modules() + + doc = FakeDocument() + root = terminal_objects.ensure_root_group(doc, "project-1") + device = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_a") + root.addObject(device) + terminal_objects.ensure_string_property( + device, + "QetElementUuid", + "QET Exchange", + "Element UUID", + "device-a", + ) + terminal_objects.ensure_string_property( + device, + "QetInstanceId", + "QET Exchange", + "Instance ID", + "instance-a", + ) + + def sync_from_payload(payload, _scene_path=""): + terminal_group = terminal_objects.ensure_terminal_group( + doc, + device, + project_uuid=payload["project_uuid"], + instance_id="instance-a", + ) + terminal = terminal_objects.create_lcs_object(doc, "QETTerminal_B") + terminal_group.addObject(terminal) + terminal_objects.set_terminal_semantics( + terminal, + payload["project_uuid"], + "device-b", + "terminal-b", + "instance-a", + label="B", + terminal_instance_id="terminal-instance-b", + terminal_display="B", + ) + return {"imported_terminals": 1} + + write_back.TerminalImport = types.SimpleNamespace( + import_terminals_from_payload=sync_from_payload + ) + + with tempfile.TemporaryDirectory() as tmp_dir: + report = write_back.write_back_document( + doc, + scene_path=str(Path(tmp_dir) / "scene.FCStd"), + payload={ + "project_uuid": "project-1", + "devices": [ + { + "device_instance_id": "instance-a", + "terminals": [ + { + "element_uuid": "device-b", + "terminal_uuid": "terminal-b", + "terminal_display": "B", + } + ], + } + ], + }, + ) + + self.assertEqual( + [ + {"element_uuid": "device-a", "device_instance_id": "instance-a"}, + {"element_uuid": "device-b", "device_instance_id": "instance-a"}, + ], + report["instances"], + ) + + def test_writeback_observer_syncs_terminals_before_document_save(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _manual_wiring, write_back = _reload_modules() + + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + calls = [] + + class FakeTerminalImport: + @staticmethod + def import_terminals_from_payload(payload, scene_path=""): + calls.append((payload, scene_path)) + return {"imported_terminals": 1} + + original_terminal_import = write_back.TerminalImport + try: + write_back.TerminalImport = FakeTerminalImport + with tempfile.TemporaryDirectory() as tmp_dir: + scene_path = Path(tmp_dir) / "scene.FCStd" + payload_path = Path(tmp_dir) / "2d_to_3d.json" + payload_path.write_text( + json.dumps( + { + "project_uuid": "project-1", + "devices": [ + { + "device_instance_id": "instance-a", + "terminals": [ + { + "element_uuid": "device-a", + "terminal_uuid": "terminal-a", + } + ], + } + ], + } + ), + encoding="utf-8", + ) + + observer = write_back._WriteBackObserver() + observer.slotStartSaveDocument(doc, str(scene_path)) + finally: + write_back.TerminalImport = original_terminal_import + + self.assertEqual(1, len(calls)) + self.assertEqual("project-1", calls[0][0]["project_uuid"]) + self.assertEqual(str(scene_path), calls[0][1]) + if __name__ == "__main__": unittest.main()