From 199412b2c8ae9185ee6a464b89d06438ad8ea49c Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Thu, 4 Jun 2026 17:53:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(freecad):=20=E5=A2=9E=E5=BC=BA=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=B8=83=E7=BA=BF=E8=B7=AF=E5=BE=84=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FreeCAD 3D自动布线设计方案.md | 74 +- src/Mod/FreeCADExchange/AutoRouting.py | 533 +++++++++-- src/Mod/FreeCADExchange/AutoRoutingPanel.py | 25 + src/Mod/FreeCADExchange/RoutingNetwork.py | 321 +++++-- .../freecad_exchange_auto_routing_test.py | 847 +++++++++++++++++- 5 files changed, 1670 insertions(+), 130 deletions(-) diff --git a/docs/FreeCAD 3D自动布线设计方案.md b/docs/FreeCAD 3D自动布线设计方案.md index 080d2bb..1307203 100644 --- a/docs/FreeCAD 3D自动布线设计方案.md +++ b/docs/FreeCAD 3D自动布线设计方案.md @@ -254,15 +254,17 @@ QET 侧如果能提供端子排/断路器的顺序、数量和显示编号,则 构图时不要求所有 carrier 都提前手工打断。系统会识别轴向线段之间的几何相交和同线重叠,把交点/重叠端点自动切成图节点。这样多条线槽中心路径只要在空间中相交,就可以在交点处换向,Dijkstra 才能得到符合工程布线习惯的折线路径。 -相邻主路径端点允许存在小间隙。默认情况下,`WireDuct` / `UserPath` / `WiringCutOut` 等主路径端点距离不超过 5 mm 时会被视为相邻并自动桥接;自动布线选项 `adjoining_duct_tolerance` 可以按需要调大或调小,用于适配不同建模精度、线槽端部留缝,以及用户路径贴近线槽但未精确相交的情况。 +相邻主路径允许存在小间隙。默认情况下,`WireDuct` / `UserPath` / `WiringCutOut` 等主路径端点距离不超过 5 mm 时会被视为相邻并自动桥接;如果支路端点靠近另一条主路径的中段,也会把端点投影到该主路径中段并补一条虚拟桥接边。桥接段仍会经过障碍包围盒检查,穿过障碍时不会建立桥接。自动布线选项 `adjoining_duct_tolerance` 可以按需要调大或调小,用于适配不同建模精度、线槽端部留缝,以及用户路径贴近线槽但未精确相交的情况。 -FreeCAD 的 `3D 布线连接` 面板提供“主路径桥接容差 mm”数值框,手动测试时可直接调整这个选项;生成布线路径网络、检查布线路径网络和生成布线连接都会读取当前面板值。内部字段名仍沿用 `adjoining_duct_tolerance` 以兼容已有代码,但界面语义已明确为主路径端点桥接。面板摘要和检查报告都会按当前容差显示自动桥接段数,便于确认当前容差是否生效。 +FreeCAD 的 `3D 布线连接` 面板提供“主路径桥接容差 mm”数值框,手动测试时可直接调整这个选项;生成布线路径网络、检查布线路径网络和生成布线连接都会读取当前面板值。内部字段名仍沿用 `adjoining_duct_tolerance` 以兼容已有代码,但界面语义已明确为主路径端点桥接和端点到中段投影桥接。面板摘要和检查报告都会按当前容差显示自动桥接段数,便于确认当前容差是否生效。 同一面板还提供“端子接入最大距离 mm”和“端子出线长度 mm”。前者用于控制端子距离最近路由网络超过多少毫米时不再生成 `TerminalAccess`,避免设备还没摆放好时生成超长悬空接入线;后者用于控制端子沿 LCS 出线方向先走出的短线长度,避免导线从设备壳体内部或端子原点直接折返。 +`TerminalAccess` 定位为端子局部接入线,只用于把端子出口引到柜内主路径附近。最终导线的主路径搜索不会把 `TerminalAccess` 当作公共 transit carrier,也不会用它桥接两段线槽或 `UserPath` 的缺口;入口候选排序也会优先选择线槽、`UserPath`、过线孔等真实主路径,避免导线贴到其它端子的局部接入线上起步。这类缺口应通过线槽、`UserPath`、过线孔或主路径自动桥接来解决。 + 路径网络检查会诊断异常长的 `TerminalAccess`。当端子接入段明显过长时,报告会提示“端子接入过长”,建议补设备局部路径、移动设备,或补一段 `UserPath` / 线槽靠近端子。这类诊断用于避免设备未摆放好时生成看起来悬空或穿越设备区域的接入线。 -面板还提供“并行线间距 mm”和“并行线方向”,用于控制多根导线共用同一路径时的可视 lane 偏移。方向默认 `auto`,也可以手动指定 `x`、`y`、`z`。这些设置只影响 3D 显示上导线之间的错位方式,不代表真实线槽截面内的排布位置。 +面板还提供“并行线间距 mm”、“并行线最大偏移 mm”和“并行线方向”,用于控制多根导线共用同一路径时的可视 lane 偏移。方向默认 `auto`,也可以手动指定 `x`、`y`、`z`。最大偏移用于限制密集共路时的显示错位范围,避免 lane 序号过大时把导线显示到线槽或柜体外。这些设置只影响 3D 显示上导线之间的错位方式,不代表真实线槽截面内的排布位置。 ### 2.1 路由优先级 @@ -452,7 +454,7 @@ terminal_exit_length = 20.0 terminal_access_max_distance = 1000.0 ``` -`terminal_exit_length` 决定端子出线段长度;`terminal_access_max_distance` 决定端子出线点到最近路由网络的最大允许接入距离。两个参数都只保存在当前 FreeCAD 面板/调用选项中,不写数据库。 +`terminal_exit_length` 决定端子出线段长度;`terminal_access_max_distance` 决定端子出线点到最近路由网络的最大允许接入距离,并同时约束最终导线路由入口候选,避免用户调小端子接入距离后,最终求路仍跨很远接入孤立网络。两个参数都只保存在当前 FreeCAD 面板/调用选项中,不写数据库。 网络检查发现端子未接入时,诊断 JSON 会记录该端子到最近路由网络的距离、当前端子接入最大距离和端子出线长度;面板报告会显示当前最大接入距离,便于判断是设备/线槽位置还没摆好,还是需要临时调大接入阈值。 @@ -541,21 +543,29 @@ QetWiringCutOutBridgeExtensionMm = 20.0 生成导线的 `QetRouteTrackJson` 会记录实际经过的 carrier。carrier 如果来自线槽、过线孔、支撑面或端子接入源对象,route track 中还会保留 `source_name`、`source_label`、`source_kind`,用于手动测试时追踪“这段线实际走过哪个 3D 源对象”。route track 同时记录 carrier 的 `capacity`,用于后续核对多根线共路、容量偏好和绕行行为。 -批量生成布线连接后,面板/控制台报告会从第一条可追踪路径中提取一条“路径示例”,显示导线经过的源对象标签,便于快速确认线路是否进入了预期线槽、过线孔和支撑面。 +如果导线实际走过自动桥接边,`QetRouteTrackJson` 中对应段会记录 `is_bridge=true`,并汇总 `bridged_segments`。批量布线报告和诊断对象中的 route sample 会优先使用这个本路线实际桥接数量;旧诊断缺少该字段时,再回退到整张路径网络的桥接数量。自动桥接段是虚拟连通边,不代表真实线槽截面,因此不参与容量最小值计算,也不参与共路 lane 计数、路径复用惩罚、真实 carrier 类型汇总、诊断样例 carrier 列表和路径质量提示。 + +批量生成布线连接后,面板/控制台报告会从第一条可追踪路径中提取一条“路径示例”,显示导线经过的源对象标签,便于快速确认线路是否进入了预期线槽、过线孔和支撑面。路径示例会跳过 `is_bridge=true` 的虚拟桥接段,避免把自动补出来的连通边误显示成真实线槽或用户路径。 -批量布线报告还会汇总本批次路线中使用到的路径网络特征:如果路线依赖相邻主路径自动桥接,报告会显示自动桥接段数;如果主动避障时屏蔽了穿过障碍包围盒的网络边,报告会显示避障屏蔽段数。这里采用路线中的最大值展示,避免多条导线共用同一网络时重复累加。 +批量布线报告还会汇总本批次路线中使用到的路径网络特征:如果路线依赖相邻/投影主路径自动桥接,报告会显示自动桥接段数;如果主动避障时屏蔽了穿过障碍包围盒的网络边,报告会显示避障屏蔽段数。这里采用路线中的最大值展示,避免多条导线共用同一网络时重复累加。 一键执行“生成布线连接”时,系统会在更新路径网络后附带一份 `routing_path_network_diagnostic` 摘要到批量报告中。即使用户没有单独点击路径网络检查,报告也会显示“路径网络检查提示”,把空路径网络、路径对象几何无效、仅使用布线面兜底、端子局部路径无效、端子接入过长等问题带出来。 +当导线因为缺少布线路径网络被跳过时,批量报告会显示一条“缺路径网络示例”,包含导线号、起终点端子标签和已记录的失败原因。这里既包括整份文档没有有效路径段,也包括路径网络存在但该导线两端无法连通、端子接入距离阈值过小等情况。手动测试时可先按该示例定位设备两端附近是否缺线槽、`UserPath`、过线孔或布线面路径,或判断是否需要调整端子接入距离。 + +当最终导线虽然布通、但起点或终点到主路径入口距离超过警戒阈值时,批量报告会显示“接入距离提示”,列出触发导线数量、一条导线样例及起点/终点接入距离。这个提示不阻止生成导线,用于暴露设备附近缺少局部路径、主路径离端子过远或端子接入距离设置过大的情况。批量诊断 JSON 也会记录 `route_entry_distance_warning_count` 和 `route_entry_distance_warning_samples`,便于导出后定位全部样例。 + 当单条路线使用 `RoutingRange` 或 `AuxiliaryPath` 时,批量报告会提示“路径质量提示”,说明该导线可能没有完全优先进入线槽。这个提示不阻止布线,只用于暴露“当前路径依赖布线面兜底”的情况,方便后续补线槽、补 `UserPath` 或调整设备位置。批量诊断 JSON 也会记录这类提示:`route_quality_warning_count` 表示依赖布线面/辅助路径的导线数量,`route_quality_warning_samples` 保留少量导线样例及其使用的 carrier 类型。 路径网络检查还会识别“只有 `RoutingRange`、没有 `WireDuct` / `UserPath` / `WiringCutOut` 主路径”的情况,并记录 `routing_range_only_network`。这类网络可以作为无线槽或路径不完整时的临时兜底,但不是推荐的第一版主路径形态;手动测试看到该提示时,优先补线槽、补 `UserPath` 或补过线孔路径。 如果当前文档没有任何可用路径段,路径网络检查会记录 `empty_routing_path_network`,中文报告显示“布线路径网络为空”。这表示还没有生成可供自动布线搜索的线槽、`UserPath`、过线孔或布线面路径,不能把 0 carrier / 0 segment 当作检查通过。 +端子未接入、端子接入过长和端子局部路径无效等诊断会在中文报告里优先显示 FreeCAD 对象名,并同时保留 `terminal_uuid`。手动测试时可以按对象名在树里定位具体端子,再根据距离提示补设备局部路径、移动设备、补 `UserPath` 或补线槽。 + 如果 carrier 对象存在但 `Points` 为空、只有一个点,或多个点归一化后仍不足两个有效点,路径网络检查会记录 `invalid_route_carriers`,中文报告提示“路径对象几何无效”。这通常意味着用户路径、线槽路径或刷新后的 carrier 几何已经损坏,需要重新生成该路径对象。 -如果多条导线共用同一路径并触发 lane 偏移,批量报告会显示最大 lane 编号和 lane 间距。这个值用于确认当前结果是否只是完全重叠的导线,还是已经按共路情况做了可视错位;它仍然是显示层偏移,不等于真实线槽截面排布或填充率计算。 +如果多条导线共用同一路径并触发 lane 偏移,批量报告会显示最大 lane 编号、lane 间距和最大偏移。这个值用于确认当前结果是否只是完全重叠的导线,还是已经按共路情况做了可视错位,并能确认密集共路时偏移上限是否参与显示;它仍然是显示层偏移,不等于真实线槽截面排布或填充率计算。 当单条路线的最大并行线数超过该路线 route track 中记录的路径最小容量时,批量报告会给出容量提示。这个提示只基于 `QetRouteCarrierCapacity` 和当前 lane 情况,用于暴露“可能容量不足”的调试线索,不等同于按线径、截面积和线槽填充率计算的工程容量校核。 @@ -627,7 +637,7 @@ tests/python/freecad_exchange_auto_routing_test.py 18. “准备布线布局空间”始终按整份文档识别线槽、支撑面和工程端子,并标记障碍处理方式。 19. “生成布线路径网络”按 EPLAN 的 Generate routing path network 语义生成 `WireDuct` / `UserPath` / `WiringCutOut` / `RoutingRange` / `TerminalAccess` carrier;有选择时,选中线槽或草图路径只作为额外识别提示,仍会扫描整份文档。 20. “生成布线连接”会先更新同一套布线路径网络,再按全部 QET 导线任务批量求路。 -21. 相邻主路径端点在容差内会被网络自动连通;端子接入会连接到最近的网络线段点,而不是只连接到已有端点。 +21. 相邻主路径端点在容差内会被网络自动连通;支路端点靠近主路径中段时也会投影桥接;端子接入会连接到最近的网络线段点,而不是只连接到已有端点。 22. 线槽端部会生成 `WireDuctOpenEnd` 横向路径,穿线孔/过线孔会生成 `WiringCutOut` carrier。 23. 导线会保存 routing track;网络检查会生成 `RoutingPathNetwork` 诊断对象。 24. 自动生成的线槽、过线孔和支撑面 carrier 会在源对象移动、缩放、删除或失效后刷新/清理。 @@ -635,15 +645,28 @@ tests/python/freecad_exchange_auto_routing_test.py 26. `QetRouteTrackJson` 会在 carrier 有源对象元数据时保存 `source_name`、`source_label`、`source_kind`,方便核对导线实际走过的线槽、过线孔或支撑面。 27. 批量布线报告会显示一条路径示例,列出首条可追踪导线经过的源对象标签。 28. 线槽源对象支持通过 `QetWireDuctEndMarginMm` 按对象调整中心路径端部缩进距离。 -29. 自动布线支持通过 `adjoining_duct_tolerance` 调整相邻主路径端点自动桥接容差,并在网络结果中记录桥接段数量。 +29. 自动布线支持通过 `adjoining_duct_tolerance` 调整相邻主路径端点桥接和端点到中段投影桥接容差,并在网络结果中记录桥接段数量。 30. `3D 布线连接` 面板提供“主路径桥接容差 mm”设置,面板生成/检查/布线流程会使用该值;网络检查报告会显示自动桥接段数。 31. `3D 布线连接` 面板提供“端子接入最大距离 mm”和“端子出线长度 mm”设置,用于适配真实机柜里端子离线槽远近不同、设备端子方向不同的情况。 32. 布线路径网络检查会在端子未接入诊断中记录当前端子接入最大距离和端子出线长度,并在中文报告里显示最大接入距离。 33. 批量布线报告会显示路径网络自动桥接段数和主动避障屏蔽段数,方便核对调参和避障是否实际参与求路。 -34. 批量布线报告会显示最大 lane 编号和 lane 间距,方便确认多根线共路时是否发生了可视错位。 +34. 批量布线报告会显示最大 lane 编号、lane 间距和最大偏移,方便确认多根线共路时是否发生了可视错位,以及偏移上限是否参与显示。 35. `QetRouteTrackJson` 的 carrier payload 会记录 `capacity`,方便后续分析线槽容量偏好和共路绕行。 36. 批量布线报告会在最大并行线数超过路径最小容量时显示容量提示,但当前仍不做真实填充率计算。 -37. `3D 布线连接` 面板提供“并行线间距 mm”和“并行线方向”设置,用于调整多线共路时的可视 lane 偏移。 +37. `3D 布线连接` 面板提供“并行线间距 mm”、“并行线最大偏移 mm”和“并行线方向”设置,用于调整多线共路时的可视 lane 偏移。 +38. 最终导线选路会在多个入口候选中避开接入段穿障碍的入口,并优先选择可避障的线槽 / `UserPath` 入口。 +39. 同一入口下的端子接入正交折线会尝试不同轴向顺序,优先选择不穿过障碍包围盒的折线。 +40. 并行导线可视 lane 偏移默认限制在固定上限内,防止密集共路时导线被显示到柜外。 +41. 完整自动布线流程会使用支路端点到主路径中段的投影桥接,避免这类支路网络被误判为孤立。 +42. `QetRouteTrackJson` 会标记实际走过的自动桥接段,并记录本路线实际使用的桥接段数量。 +43. 批量布线报告的路径示例会跳过虚拟桥接段,只列出真实经过的源对象标签。 +44. 共路 lane 计数和路径复用惩罚会跳过虚拟桥接段,避免仅共享自动桥接边的导线被误判为真实共路。 +45. 路径质量提示会按非桥接段重新判断 carrier 类型,避免把虚拟桥接到 `RoutingRange` 误报为真实使用布线面兜底。 +46. 缺少布线路径网络或路径网络两端不连通时,批量布线报告会显示一条导线、端点样例和失败原因,便于直接定位需要补路径的设备区域。 +47. “端子接入最大距离”同时约束自动 `TerminalAccess` 和最终导线入口候选,防止最终求路绕过面板设置生成超长接入线。 +48. 批量诊断 JSON 的 route sample 会跳过虚拟桥接段统计 carrier 类型和 carrier 名称,保持与中文报告一致。 +49. 最终导线路由不会把 `TerminalAccess` 当作公共 transit carrier,入口候选也会优先真实主路径,避免端子局部接入线被误用来桥接主路径缺口或作为其它导线的起步路径。 +50. 批量布线报告会提示最终导线起点/终点接入距离过长的样例,用于排查设备附近缺局部路径或主路径离端子太远。 已完成 FreeCAD smoke: @@ -809,6 +832,35 @@ TemplateAuthoring.set_template_terminal_local_route_points(terminal, points) ## 8. 后续需要完成 +### 8.0 2026-06-03 手动测试问题记录 + +本次手动测试视频 `D:\video\0603布线效果.mp4` 中,真实机柜、导轨、线槽和设备已经装配,但自动布线结果暴露出以下问题: + +1. 80 条 QET 导线任务中只成功生成 43 条,7 条存在碰撞告警,37 条失败。端子匹配没有失败,问题主要出在路径网络连通性和端子接入。 +2. 路径网络规模较大,但检查提示存在孤立路径网络和端子接入过长,说明部分设备局部路径没有可靠接入柜内主路径。 +3. 部分导线穿过设备模型。当前碰撞检测只给出告警,不会强制阻止生成,因此在可用绕行路径不足时仍可能生成穿模导线。 +4. 多根导线在公共路径上共线或高度拥挤。在线槽内共路可以接受,但在线槽外和设备端子附近需要更好的并行错位、束线显示和容量策略。 +5. 部分导线跑到机柜外侧。该问题需要后续增加柜内有效区域/柜体边界诊断,但当前优先级低于提升布通率。 +6. Draft 线段可能悬空。原因通常是 Draft 当前工作平面没有锁定到安装板或线槽面;作为自由空间 `UserPath` 这是允许的,但作为贴面主路径时需要投影、吸附或明确提示。 + +当前开发优先级调整为: + +1. 先保证更多导线能稳定布通,优先处理孤立路径网络和端子接入过长。 +2. 其次降低明显穿模和线槽外共线拥挤。 +3. 柜内越界诊断放到后续阶段,不阻塞当前布通率改进。 + +已完成的对应改进: + +1. 最终导线选路不再只接入最近路径段;当最近路径属于孤立网络且稍远处存在可连通主路径时,会尝试多个接入候选并选择综合成本更低的可连通路径。 +2. 自动生成 `TerminalAccess` 时,不再盲目接入最近的孤立短段;会优先接入更大的连通主网络。 +3. 当线槽 / `UserPath` / 过线孔等主路径存在时,`TerminalAccess` 会优先接入主路径组件;`RoutingRange` 仍保留为无线槽或主路径不足时的兜底布线面。 +4. 最终导线路由进一步提高 `RoutingRange` 的默认成本,避免布线面在有线槽 / `UserPath` 可用时抢占主路径;极短局部过渡和无线槽兜底场景仍可使用 `RoutingRange`。 +5. 最终导线接入候选不再先按几何距离截断;会先按“主路径优先、连通组件优先、距离次之”的规则排序,再取候选,避免大量 `RoutingRange` 网格把稍远的线槽 / `UserPath` 挤出候选列表。 +6. 批量布线报告会补充“接入候选”提示,帮助手动测试判断某条导线是否因为近处孤立网络或布线面网格太密而使用了第几个候选接入口。 +7. 接入候选评分会检查端子出口到路径网络入口之间的小段是否穿过障碍包围盒;当近入口接入段穿模、稍远入口可避开障碍时,会优先选择不穿模的入口。最终碰撞诊断仍保留端点附近设备外壳的宽容规则,避免把端子自身外壳误报成碰撞。 +8. 同一个路径入口已经确定后,端子出口到入口、主路径出口到端子入口的正交折线会尝试不同轴向顺序;当“先走 X”会穿设备、“先走 Y/Z”可绕开时,优先使用不穿模的折线顺序。 +9. 并行导线 lane 偏移增加默认上限,避免大量导线共路时可视错位距离随 lane 序号无限增大,把导线推到线槽或柜体外。lane 序号仍保留,用于容量提示和并行数量报告。 + ### 8.1 近期优先级 1. 线槽语义库 diff --git a/src/Mod/FreeCADExchange/AutoRouting.py b/src/Mod/FreeCADExchange/AutoRouting.py index 4d44eb6..f203606 100644 --- a/src/Mod/FreeCADExchange/AutoRouting.py +++ b/src/Mod/FreeCADExchange/AutoRouting.py @@ -5,6 +5,7 @@ # 然后在 QETWiring_04_Routed 下生成一条可见的折线导线。 import json +import itertools import math import FreeCAD as App @@ -25,10 +26,15 @@ DEFAULT_OPTIONS = { "terminal_exit_length": 20.0, "lane_axis": "auto", "lane_spacing": 10.0, + "lane_max_offset": 30.0, "segment_reuse_penalty": 200.0, # 线槽网络相关参数。 "use_routing_network": True, "network_entry_max_distance": 1000.0, + "network_entry_candidate_limit": 8, + "network_entry_distance_cost_factor": 5.0, + "route_candidate_collision_penalty": 10000.0, + "ignore_endpoint_near_obstacles": True, "adjoining_duct_tolerance": RoutingNetwork.DEFAULT_ADJOINING_DUCT_TOLERANCE, "bend_penalty": 25.0, # EPLAN/SOLIDWORKS 风格:线槽/路由路径最优先,辅助面域只作为过渡/兜底区域。 @@ -40,7 +46,7 @@ DEFAULT_OPTIONS = { "UserPath": 1.0, "AuxiliaryPath": 2.0, "TerminalAccess": 2.0, - "RoutingRange": 25.0, + "RoutingRange": 40.0, }, # 主干必须走 carrier/贴面网络;没有布线路径网络时直接失败。 # 障碍包围盒会按这个距离膨胀,用于提前发现贴碰风险。 @@ -176,10 +182,15 @@ def _lane_payload(route_index, options, route_points=None): lane_order = (lane_index + 1) // 2 lane_direction = 1.0 if lane_index % 2 == 1 else -1.0 lane_offset = float(lane_order) * lane_spacing * lane_direction + # 多根线共路时 lane 序号可能很大;限制显示偏移,避免把线推到柜体或线槽外。 + max_offset = float(opts.get("lane_max_offset", 0.0) or 0.0) + if max_offset > 0.0 and abs(lane_offset) > max_offset: + lane_offset = max_offset if lane_offset > 0.0 else -max_offset return { "index": lane_index, "axis": lane_axis, "spacing_mm": lane_spacing, + "max_offset_mm": float(opts.get("lane_max_offset", 0.0) or 0.0), "offset_mm": lane_offset, } @@ -195,11 +206,7 @@ def _apply_lane_offset(points, lane): ] -def _orthogonal_points(start_point, end_point, preferred_axis=None): - if _vector_close(start_point, end_point): - return [start_point] - - # 每一段只沿一个坐标轴移动,这样生成的线天然是机柜布线常见的折线。 +def _orthogonal_axis_order(start_point, end_point, preferred_axis=None): axis_order = sorted( ("x", "y", "z"), key=lambda axis: abs(_axis_value(end_point, axis) - _axis_value(start_point, axis)), @@ -208,7 +215,10 @@ def _orthogonal_points(start_point, end_point, preferred_axis=None): if preferred_axis in {"x", "y", "z"}: axis_order = [axis for axis in axis_order if axis != preferred_axis] axis_order.append(preferred_axis) + return axis_order + +def _orthogonal_points_for_axis_order(start_point, end_point, axis_order): points = [start_point] current = start_point for axis in axis_order: @@ -221,11 +231,85 @@ def _orthogonal_points(start_point, end_point, preferred_axis=None): return points -def _append_orthogonal(points, target_point, preferred_axis=None): +def _orthogonal_points(start_point, end_point, preferred_axis=None): + if _vector_close(start_point, end_point): + return [start_point] + + # 每一段只沿一个坐标轴移动,这样生成的线天然是机柜布线常见的折线。 + return _orthogonal_points_for_axis_order( + start_point, + end_point, + _orthogonal_axis_order(start_point, end_point, preferred_axis), + ) + + +def _orthogonal_hit_count(points, obstacle_bboxes): + hits = 0 + if not obstacle_bboxes: + return hits + for index in range(max(len(points or []) - 1, 0)): + start = points[index] + end = points[index + 1] + for bbox in obstacle_bboxes: + if _segment_intersects_bbox(start, end, bbox): + hits += 1 + break + return hits + + +def _orthogonal_points_avoiding_obstacles(start_point, end_point, obstacle_bboxes, preferred_axis=None): + base_order = _orthogonal_axis_order(start_point, end_point, preferred_axis) + base_points = _orthogonal_points_for_axis_order(start_point, end_point, base_order) + if not obstacle_bboxes or len(base_order) <= 1: + return base_points + + active_axes = [ + axis + for axis in base_order + if abs(_axis_value(end_point, axis) - _axis_value(start_point, axis)) > 0.000001 + ] + if len(active_axes) <= 1: + return base_points + + inactive_axes = [axis for axis in base_order if axis not in active_axes] + best_points = base_points + best_hits = _orthogonal_hit_count(base_points, obstacle_bboxes) + if best_hits <= 0: + return best_points + + # 同一入口下,“先走 X”或“先走 Y/Z”可能决定端子接入段是否穿模。 + # 这里只重排正交轴顺序,不改变主路径网络和端点绑定语义。 + for order in itertools.permutations(active_axes): + candidate_order = list(order) + inactive_axes + if candidate_order == base_order: + continue + candidate_points = _orthogonal_points_for_axis_order( + start_point, + end_point, + candidate_order, + ) + candidate_hits = _orthogonal_hit_count(candidate_points, obstacle_bboxes) + if candidate_hits < best_hits: + best_hits = candidate_hits + best_points = candidate_points + if best_hits <= 0: + break + return best_points + + +def _append_orthogonal(points, target_point, preferred_axis=None, obstacle_bboxes=None): if not points: _append_unique(points, target_point) return - segment = _orthogonal_points(points[-1], _vector(target_point), preferred_axis) + if obstacle_bboxes: + segment = _orthogonal_points_avoiding_obstacles( + points[-1], + _vector(target_point), + obstacle_bboxes, + preferred_axis=preferred_axis, + ) + else: + segment = _orthogonal_points(points[-1], _vector(target_point), preferred_axis) for point in segment[1:]: _append_unique(points, point) @@ -775,22 +859,31 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non 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) - def route_on_network(network, obstacle_aware=False): - if network.get("segment_count", 0) <= 0: - return None - - start_key, start_distance, start_mode = RoutingNetwork.connect_point_to_network(network, start_exit) - end_key, end_distance, end_mode = RoutingNetwork.connect_point_to_network(network, end_exit) - if start_key is None or end_key is None: - return None - - max_distance = float(opts.get("network_entry_max_distance", 0.0) or 0.0) - if max_distance > 0.0 and ( - float(start_distance or 0.0) > max_distance - or float(end_distance or 0.0) > max_distance - ): + def clone_route_network(source): + cloned = dict(source or {}) + cloned["nodes"] = dict((source or {}).get("nodes", {}) or {}) + cloned["edges"] = { + key: list(value or []) + for key, value in ((source or {}).get("edges", {}) or {}).items() + } + cloned["carriers"] = list((source or {}).get("carriers", []) or []) + cloned["bridge_pairs"] = set((source or {}).get("bridge_pairs", set()) or set()) + return cloned + + def build_route_payload( + network, + start_key, + end_key, + start_distance, + end_distance, + start_mode, + end_mode, + obstacle_aware=False, + start_candidate_rank=1, + end_candidate_rank=1, + ): + if start_key == end_key and _distance(start_exit, end_exit) > RoutingNetwork.DEFAULT_NODE_TOLERANCE: return None - path_result = RoutingNetwork.shortest_path_with_carriers( network, start_key, @@ -799,6 +892,7 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non kind_cost_factors=opts.get("carrier_kind_cost_factors", {}), segment_usage_costs=opts.get("segment_usage_costs", {}), segment_reuse_penalty=float(opts.get("segment_reuse_penalty", 0.0) or 0.0), + excluded_transit_carrier_kinds={"TerminalAccess"}, ) path_keys = path_result.get("path", []) if isinstance(path_result, dict) else [] if not path_keys: @@ -813,10 +907,10 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non points = [] for point in start_access_points or [start_origin, start_exit]: _append_unique(points, point) - _append_orthogonal(points, carrier_points[0]) + _append_orthogonal(points, carrier_points[0], obstacle_bboxes=candidate_blocked_bboxes) for point in carrier_points[1:]: _append_unique(points, point) - _append_orthogonal(points, end_exit) + _append_orthogonal(points, end_exit, obstacle_bboxes=candidate_blocked_bboxes) for point in reversed(end_access_points or [end_origin, end_exit]): _append_unique(points, point) points = _simplify_collinear_points( @@ -837,17 +931,128 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non "exit_distance": float(end_distance or 0.0), "entry_point_mode": start_mode, "exit_point_mode": end_mode, + "entry_candidate_rank": int(start_candidate_rank or 1), + "exit_candidate_rank": int(end_candidate_rank or 1), "obstacle_aware": bool(obstacle_aware), }, "route_track": path_result, "lane": lane, } + def route_obstacle_hit_count(points): + hits = 0 + if not 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 candidate_blocked_bboxes: + if _segment_intersects_bbox(start, end, bbox): + hits += 1 + break + return hits + + def route_on_network(network, obstacle_aware=False): + if network.get("segment_count", 0) <= 0: + return None + + max_distance = float(opts.get("network_entry_max_distance", 0.0) or 0.0) + terminal_access_limit = float(opts.get("terminal_access_max_distance", 0.0) or 0.0) + # 面板只暴露“端子接入最大距离”,最终求路也要遵守它,避免跨很远直接接入孤立网络。 + 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 = RoutingNetwork.rank_connection_point_candidates( + network, + RoutingNetwork.connection_point_candidates( + network, + start_exit, + limit=0, + max_distance=max_distance, + ), + ) + start_candidates = start_candidates[:candidate_limit] + 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_candidates = RoutingNetwork.rank_connection_point_candidates( + start_network, + RoutingNetwork.connection_point_candidates( + start_network, + end_exit, + limit=0, + max_distance=max_distance, + ), + ) + end_candidates = end_candidates[:candidate_limit] + 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 + ) + route_data["network"]["route_candidate_obstacle_hits"] = int(obstacle_hits) + 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 + use_obstacle_avoidance = bool(opts.get("avoid_obstacles", True)) obstacles = [] + candidate_obstacles = [] if use_obstacle_avoidance: obstacles = collect_obstacles(doc, exclude=[start_terminal, end_terminal], options=opts) + candidate_options = dict(opts) + candidate_options["ignore_endpoint_near_obstacles"] = False + candidate_obstacles = collect_obstacles( + doc, + exclude=[start_terminal, end_terminal], + options=candidate_options, + ) blocked_bboxes = [obstacle["bbox"] for obstacle in obstacles if obstacle.get("bbox")] + candidate_blocked_bboxes = [ + obstacle["bbox"] for obstacle in candidate_obstacles if obstacle.get("bbox") + ] if blocked_bboxes: obstacle_aware_network = RoutingNetwork.build_route_graph( @@ -986,7 +1191,7 @@ def collect_obstacles(doc, exclude=None, options=None): bbox = _bbox_payload(obj, clearance=clearance) if bbox is None: continue - if endpoint_points and any( + if bool(opts.get("ignore_endpoint_near_obstacles", True)) and endpoint_points and any( _distance_point_to_bbox(point, bbox) <= endpoint_clearance for point in endpoint_points ): @@ -1362,6 +1567,9 @@ def _route_track_segment_keys(route_track): segments = route_track.get("segments", []) if isinstance(route_track, dict) else [] keys = [] for segment in segments or []: + # 虚拟桥接段不是真实线槽/路径共路,不能触发并行 lane 递增或复用惩罚。 + if isinstance(segment, dict) and bool(segment.get("is_bridge", False)): + continue key = _route_segment_key(segment) if key is not None: keys.append(key) @@ -1468,6 +1676,13 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la "skipped_missing_terminal": 0, "skipped_missing_route_network": 0, "skipped_invalid": 0, + "terminal_access_warning_distance": float( + opts.get( + "terminal_access_warning_distance", + RoutingNetwork.DEFAULT_TERMINAL_ACCESS_WARNING_DISTANCE, + ) + or 0.0 + ), "missing_endpoint_uuids": [], "missing_endpoint_samples": [], "missing_route_network_samples": [], @@ -1509,6 +1724,35 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la return _set_task_status(_find_task_by_wire_uuid(doc, wire_uuid), status) + def missing_route_network_sample(item, start_uuid, end_uuid, error_text=""): + sample = { + "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), + "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), + "start_terminal_uuid": start_uuid, + "start_element_uuid": _wire_item_value(item, "start_element_uuid"), + "start_terminal_display": _wire_item_value(item, "start_terminal_display"), + "start_device_label": _wire_item_value(item, "start_device_label"), + "end_terminal_uuid": end_uuid, + "end_element_uuid": _wire_item_value(item, "end_element_uuid"), + "end_terminal_display": _wire_item_value(item, "end_terminal_display"), + "end_device_label": _wire_item_value(item, "end_device_label"), + "endpoint_label": _wire_item_value(item, "endpoint_label"), + } + if error_text: + sample["error"] = error_text + return sample + + def add_missing_route_network_sample(item, start_uuid, end_uuid, error_text=""): + if len(report["missing_route_network_samples"]) >= 8: + return + report["missing_route_network_samples"].append( + missing_route_network_sample(item, start_uuid, end_uuid, error_text=error_text) + ) + + def is_missing_route_network_error(error_text): + text = str(error_text or "") + return "没有可用的布线路径网络" in text or "No route path" in text + def create_route(route_lane_index, item, start_terminal, end_terminal, endpoint_metadata): route_options = dict(options or {}) if isinstance(item, dict) and "__segment_usage_costs" in item: @@ -1567,22 +1811,7 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la report["skipped_missing_route_network"] += 1 add_status("MissingRouteNetwork") set_item_task_status(item, "MissingRouteNetwork") - if len(report["missing_route_network_samples"]) < 8: - report["missing_route_network_samples"].append( - { - "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), - "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), - "start_terminal_uuid": start_uuid, - "start_element_uuid": _wire_item_value(item, "start_element_uuid"), - "start_terminal_display": _wire_item_value(item, "start_terminal_display"), - "start_device_label": _wire_item_value(item, "start_device_label"), - "end_terminal_uuid": end_uuid, - "end_element_uuid": _wire_item_value(item, "end_element_uuid"), - "end_terminal_display": _wire_item_value(item, "end_terminal_display"), - "end_device_label": _wire_item_value(item, "end_device_label"), - "endpoint_label": _wire_item_value(item, "endpoint_label"), - } - ) + add_missing_route_network_sample(item, start_uuid, end_uuid) continue lane_key = _route_lane_key(start_uuid, end_uuid) route_lane_index = lane_indexes_by_pair.get(lane_key, 0) @@ -1625,6 +1854,13 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la route_segment_keys = _route_segment_keys(result) except Exception as exc: error_text = str(exc) + if is_missing_route_network_error(error_text): + # 路径网络存在但两端无法连通时,按缺路径网络处理,避免被普通 Error 淹没。 + report["skipped_missing_route_network"] += 1 + add_status("MissingRouteNetwork") + set_item_task_status(item, "MissingRouteNetwork") + add_missing_route_network_sample(item, start_uuid, end_uuid, error_text=error_text) + continue report["errors"].append(error_text) add_status("Error") set_item_task_status(item, "Error") @@ -1767,6 +2003,9 @@ def _route_source_labels(route_track, limit=5): if not isinstance(route_track, dict): return labels for segment in route_track.get("segments", []) or []: + # 自动桥接段是虚拟连通边,路径示例只展示真实经过的源对象。 + if isinstance(segment, dict) and bool(segment.get("is_bridge", False)): + continue carrier = segment.get("carrier", {}) if isinstance(segment, dict) else {} if not isinstance(carrier, dict): continue @@ -1802,6 +2041,14 @@ def _route_network_metric_max(report, key): for route in report.get("routes", []) or []: if not isinstance(route, dict): continue + if key == "bridged_segments": + route_track = route.get("route_track", {}) + if isinstance(route_track, dict) and key in route_track: + try: + maximum = max(maximum, int(route_track.get(key, 0) or 0)) + except Exception: + pass + continue network = route.get("network", {}) if not isinstance(network, dict): continue @@ -1815,6 +2062,7 @@ def _route_network_metric_max(report, key): def _route_lane_summary(report): max_lane_index = 0 lane_spacing = 0.0 + lane_max_offset = 0.0 for route in report.get("routes", []) or []: if not isinstance(route, dict): continue @@ -1832,11 +2080,16 @@ def _route_lane_summary(report): lane_spacing = float(lane.get("spacing_mm", 0.0) or 0.0) except Exception: lane_spacing = 0.0 + try: + lane_max_offset = float(lane.get("max_offset_mm", 0.0) or 0.0) + except Exception: + lane_max_offset = 0.0 if max_lane_index <= 0: return {} return { "max_lane_index": max_lane_index, "spacing_mm": lane_spacing, + "max_offset_mm": lane_max_offset, } @@ -1845,6 +2098,9 @@ def _route_track_min_capacity(route_track): return None capacities = [] for segment in route_track.get("segments", []) or []: + # 自动桥接段是虚拟连通边,不代表真实线槽截面,不能参与容量最小值计算。 + if isinstance(segment, dict) and bool(segment.get("is_bridge", False)): + continue carrier = segment.get("carrier", {}) if isinstance(segment, dict) else {} if not isinstance(carrier, dict): continue @@ -1891,6 +2147,24 @@ _ROUTE_QUALITY_WARNING_KIND_LABELS = { def _route_track_carrier_kinds(route_track): if not isinstance(route_track, dict): return {} + counts = {} + has_segment_list = isinstance(route_track.get("segments"), list) + raw_segments = route_track.get("segments", []) + segments = raw_segments or [] + for segment in segments: + # 虚拟桥接段只表示网络连通,不代表导线真实经过该类型 carrier。 + if isinstance(segment, dict) and bool(segment.get("is_bridge", False)): + continue + carrier = segment.get("carrier", {}) if isinstance(segment, dict) else {} + if not isinstance(carrier, dict): + continue + kind = str(carrier.get("kind", "") or "").strip() + if kind: + counts[kind] = counts.get(kind, 0) + 1 + if counts: + return counts + if has_segment_list: + return {} carrier_kinds = route_track.get("carrier_kinds", {}) if isinstance(carrier_kinds, dict) and carrier_kinds: return { @@ -1898,15 +2172,32 @@ def _route_track_carrier_kinds(route_track): for key, value in carrier_kinds.items() if str(key).strip() } - counts = {} + return {} + + +def _route_track_carrier_names(route_track, limit=8): + if not isinstance(route_track, dict): + return [] + names = [] + seen = set() + has_segment_list = isinstance(route_track.get("segments"), list) for segment in route_track.get("segments", []) or []: + # 诊断样例只列真实经过的 carrier;虚拟桥接段不显示为源路径对象。 + if isinstance(segment, dict) and bool(segment.get("is_bridge", False)): + continue carrier = segment.get("carrier", {}) if isinstance(segment, dict) else {} if not isinstance(carrier, dict): continue - kind = str(carrier.get("kind", "") or "").strip() - if kind: - counts[kind] = counts.get(kind, 0) + 1 - return counts + name = str(carrier.get("name", "") or "").strip() + if not name or name in seen: + continue + seen.add(name) + names.append(name) + if len(names) >= int(limit or 0): + return names + if has_segment_list: + return names + return list(route_track.get("carrier_names", []) or [])[: int(limit or 0)] def _route_quality_warning_summary(report): @@ -1961,6 +2252,72 @@ def _route_quality_warning_samples(report, limit=8): return samples +def _long_network_entry_warning_samples(report, limit=8): + try: + warning_distance = float( + report.get( + "terminal_access_warning_distance", + RoutingNetwork.DEFAULT_TERMINAL_ACCESS_WARNING_DISTANCE, + ) + or 0.0 + ) + except Exception: + warning_distance = float(RoutingNetwork.DEFAULT_TERMINAL_ACCESS_WARNING_DISTANCE) + if warning_distance <= 0.0: + return [] + + samples = [] + max_samples = int(limit or 0) + for route in report.get("routes", []) or []: + if not isinstance(route, dict): + continue + network = route.get("network", {}) + if not isinstance(network, dict): + continue + long_parts = [] + warning_sides = [] + for side, label, key in ( + ("entry", "起点", "entry_distance"), + ("exit", "终点", "exit_distance"), + ): + try: + distance = float(network.get(key, 0.0) or 0.0) + except Exception: + distance = 0.0 + if distance > warning_distance: + warning_sides.append(side) + long_parts.append("{0}接入 {1:.1f} mm".format(label, distance)) + if not long_parts: + continue + if max_samples <= 0 or len(samples) < max_samples: + samples.append( + { + "wire_uuid": route.get("wire_uuid", ""), + "wire_label": route.get("wire_label", ""), + "wire": _wire_sample_text(route), + "start_terminal_uuid": route.get("start_terminal_uuid", ""), + "end_terminal_uuid": route.get("end_terminal_uuid", ""), + "entry_distance": float(network.get("entry_distance", 0.0) or 0.0), + "exit_distance": float(network.get("exit_distance", 0.0) or 0.0), + "warning_sides": warning_sides, + "warning_parts": long_parts, + "warning_distance": float(warning_distance), + } + ) + return samples + + +def _long_network_entry_summary(report): + samples = _long_network_entry_warning_samples(report, limit=1) + if not samples: + return {} + return { + "count": len(_long_network_entry_warning_samples(report, limit=0)), + "sample": samples[0], + "warning_distance": float(samples[0].get("warning_distance", 0.0) or 0.0), + } + + def format_eplan_connection_route_report(report): message = "批量生成布线连接完成:routed={0}, collision_warnings={1}, missing_terminals={2}".format( report.get("routed", 0), @@ -2019,6 +2376,16 @@ def format_eplan_connection_route_report(report): message += "\n缺少布线路径网络:{0} 条导线已跳过。请先生成线槽、布线面或布线路径网络。".format( report.get("skipped_missing_route_network", 0) ) + route_sample = (report.get("missing_route_network_samples") or [None])[0] + if route_sample: + message += "\n缺路径网络示例:导线 {0},{1}。".format( + _wire_sample_text(route_sample), + _endpoint_pair_text(route_sample), + ) + # 示例带上原始失败原因,手动测试时可以直接判断是空网络、端点不连通还是距离阈值限制。 + route_error = str(route_sample.get("error", "") or "").strip() + if route_error: + message += "原因:{0}。".format(route_error) total_length_mm = float(report.get("total_length_mm", 0.0) or 0.0) if total_length_mm > 0.0: message += "\n布线连接总长度:{0:.1f} mm。".format(total_length_mm) @@ -2032,23 +2399,61 @@ def format_eplan_connection_route_report(report): blocked_segments = _route_network_metric_max(report, "blocked_segments") network_parts = [] if bridged_segments > 0: - network_parts.append("自动桥接 {0} 段相邻主路径".format(bridged_segments)) + network_parts.append("自动桥接 {0} 段相邻/投影主路径".format(bridged_segments)) if blocked_segments > 0: network_parts.append("避障屏蔽 {0} 段".format(blocked_segments)) if network_parts: message += "\n路径网络:{0}。".format(",".join(network_parts)) lane_summary = _route_lane_summary(report) if lane_summary: - message += "\n并行错位:最大 lane {0},间距 {1:.1f} mm。".format( + lane_text = "并行错位:最大 lane {0},间距 {1:.1f} mm".format( lane_summary.get("max_lane_index", 0), float(lane_summary.get("spacing_mm", 0.0) or 0.0), ) + max_offset = float(lane_summary.get("max_offset_mm", 0.0) or 0.0) + 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) if capacity_pressure: message += "\n容量提示:最大并行线数 {0},路径最小容量 {1}。".format( capacity_pressure.get("max_parallel_wires", 0), capacity_pressure.get("min_capacity", 0), ) + candidate_ranks = [] + for route in report.get("routes", []) or []: + if not isinstance(route, dict): + continue + network = route.get("network", {}) + if not isinstance(network, dict): + continue + try: + entry_rank = int(network.get("entry_candidate_rank", 0) or 0) + except Exception: + entry_rank = 0 + try: + exit_rank = int(network.get("exit_candidate_rank", 0) or 0) + except Exception: + exit_rank = 0 + if entry_rank > 1 or exit_rank > 1: + candidate_ranks.append((entry_rank, exit_rank)) + if candidate_ranks: + entry_rank, exit_rank = candidate_ranks[0] + parts = [] + if entry_rank > 1: + parts.append("起点第 {0} 个".format(entry_rank)) + if exit_rank > 1: + parts.append("终点第 {0} 个".format(exit_rank)) + message += "\n接入候选:{0}。".format(",".join(parts)) + long_entry_warning = _long_network_entry_summary(report) + if long_entry_warning: + sample = long_entry_warning.get("sample", {}) + # 最终导线接入距离过长时,通常意味着设备附近缺少局部路径或主路径离端子太远。 + message += "\n接入距离提示:{0} 条导线起点/终点接入过长,示例导线 {1} {2},可能存在悬空或跨距过长。".format( + long_entry_warning.get("count", 0), + sample.get("wire", "未知导线"), + ",".join(sample.get("warning_parts", []) or []), + ) route_source_sample = _route_source_sample_text(report) if route_source_sample: message += "\n{0}".format(route_source_sample) @@ -2167,16 +2572,19 @@ def _compact_route_sample(route): "lane": route.get("lane", {}), "algorithm": route.get("algorithm", ""), "collision_count": route.get("collision_count", 0), - "carrier_kinds": route_track.get("carrier_kinds", {}), - "carrier_names": list(route_track.get("carrier_names", []) or [])[:8], + "carrier_kinds": _route_track_carrier_kinds(route_track), + "carrier_names": _route_track_carrier_names(route_track, limit=8), "route_source_labels": _route_source_labels(route_track, limit=8), } network = route.get("network", {}) if isinstance(network, dict): + bridged_segments = network.get("bridged_segments", 0) + if "bridged_segments" in route_track: + bridged_segments = route_track.get("bridged_segments", 0) sample["network"] = { "carriers": network.get("carriers", 0), "segments": network.get("segments", 0), - "bridged_segments": network.get("bridged_segments", 0), + "bridged_segments": bridged_segments, "blocked_segments": network.get("blocked_segments", 0), "entry_distance": network.get("entry_distance", 0.0), "exit_distance": network.get("exit_distance", 0.0), @@ -2236,6 +2644,11 @@ def _compact_routing_connection_batch_report(report, sample_limit=8): route_quality_warnings = _route_quality_warning_samples(report, limit=limit) payload["route_quality_warning_count"] = len(_route_quality_warning_samples(report, limit=0)) payload["route_quality_warning_samples"] = route_quality_warnings + entry_distance_warnings = _long_network_entry_warning_samples(report, limit=limit) + payload["route_entry_distance_warning_count"] = len( + _long_network_entry_warning_samples(report, limit=0) + ) + payload["route_entry_distance_warning_samples"] = entry_distance_warnings payload["diagnostic_payload"] = "compact-routing-connection-batch-v1" return payload @@ -2446,12 +2859,14 @@ def _format_point_text(point): def _diagnostic_terminal_text(item): if not isinstance(item, dict): return "未知端子" - return ( - item.get("terminal_uuid") - or item.get("label") - or item.get("name") - or "未知端子" - ) + terminal_uuid = str(item.get("terminal_uuid", "") or "").strip() + label = str(item.get("label", "") or "").strip() + name = str(item.get("name", "") or "").strip() + # 报告优先给人看的 FreeCAD 对象名,同时保留 terminal_uuid 便于和 QET 数据对应。 + display = label if label and label != terminal_uuid else name + if display and terminal_uuid and display != terminal_uuid: + return "{0}({1})".format(display, terminal_uuid) + return terminal_uuid or display or "未知端子" def _dict_items(value): @@ -2475,7 +2890,7 @@ def format_routing_path_network_report(diagnostic): ) bridged_segments = int(summary.get("bridged_segments", 0) or 0) if bridged_segments > 0: - message += " 自动桥接 {0} 段相邻主路径。".format(bridged_segments) + message += " 自动桥接 {0} 段相邻/投影主路径。".format(bridged_segments) return message message = "布线路径网络检查发现 {0} 类问题。".format(len(issues)) diff --git a/src/Mod/FreeCADExchange/AutoRoutingPanel.py b/src/Mod/FreeCADExchange/AutoRoutingPanel.py index f3ff62e..3ce310b 100644 --- a/src/Mod/FreeCADExchange/AutoRoutingPanel.py +++ b/src/Mod/FreeCADExchange/AutoRoutingPanel.py @@ -132,6 +132,13 @@ class AutoRoutingController: lane_spacing = AutoRouting.DEFAULT_OPTIONS["lane_spacing"] self.options["lane_spacing"] = max(lane_spacing, 0.0) + def set_lane_max_offset(self, value): + try: + lane_max_offset = float(value) + except Exception: + lane_max_offset = AutoRouting.DEFAULT_OPTIONS["lane_max_offset"] + self.options["lane_max_offset"] = max(lane_max_offset, 0.0) + def set_lane_axis(self, value): lane_axis = str(value or "").strip().lower() if lane_axis not in {"auto", "x", "y", "z"}: @@ -294,6 +301,9 @@ class AutoRoutingTaskPanel: self.adjoining_duct_tolerance_spin.setRange(0.0, 1000.0) self.adjoining_duct_tolerance_spin.setDecimals(1) self.adjoining_duct_tolerance_spin.setSingleStep(1.0) + self.adjoining_duct_tolerance_spin.setToolTip( + "用于相邻主路径端点桥接,也用于支路端点到主路径中段的投影桥接。" + ) self.adjoining_duct_tolerance_spin.setValue( float( self.controller.routing_options().get( @@ -345,6 +355,20 @@ class AutoRoutingTaskPanel: ) ) options_layout.addWidget(self.lane_spacing_spin) + options_layout.addWidget(QtWidgets.QLabel("并行线最大偏移 mm")) + self.lane_max_offset_spin = QtWidgets.QDoubleSpinBox() + self.lane_max_offset_spin.setRange(0.0, 1000.0) + self.lane_max_offset_spin.setDecimals(1) + self.lane_max_offset_spin.setSingleStep(1.0) + self.lane_max_offset_spin.setValue( + float( + self.controller.routing_options().get( + "lane_max_offset", + AutoRouting.DEFAULT_OPTIONS["lane_max_offset"], + ) + ) + ) + options_layout.addWidget(self.lane_max_offset_spin) options_layout.addWidget(QtWidgets.QLabel("并行线方向")) self.lane_axis_combo = QtWidgets.QComboBox() self.lane_axis_combo.addItems(["auto", "x", "y", "z"]) @@ -441,6 +465,7 @@ class AutoRoutingTaskPanel: self.controller.set_terminal_access_max_distance(self.terminal_access_max_distance_spin.value()) self.controller.set_terminal_exit_length(self.terminal_exit_length_spin.value()) self.controller.set_lane_spacing(self.lane_spacing_spin.value()) + self.controller.set_lane_max_offset(self.lane_max_offset_spin.value()) self.controller.set_lane_axis(self.lane_axis_combo.currentText()) def generate_routing_paths(self): diff --git a/src/Mod/FreeCADExchange/RoutingNetwork.py b/src/Mod/FreeCADExchange/RoutingNetwork.py index 21b33ed..1157dec 100644 --- a/src/Mod/FreeCADExchange/RoutingNetwork.py +++ b/src/Mod/FreeCADExchange/RoutingNetwork.py @@ -48,6 +48,9 @@ DEFAULT_ROUTE_PATH_FACE_OFFSET = 2.0 DEFAULT_AUTO_WIRE_DUCT_MIN_ASPECT = 2.5 DEFAULT_TERMINAL_ACCESS_MAX_DISTANCE = 1000.0 DEFAULT_TERMINAL_ACCESS_WARNING_DISTANCE = 500.0 +DEFAULT_TERMINAL_ACCESS_COMPONENT_SEGMENT_PENALTY = 25.0 +DEFAULT_TERMINAL_ACCESS_FALLBACK_ONLY_COMPONENT_PENALTY = 1000.0 +DEFAULT_TERMINAL_ACCESS_ENTRY_CANDIDATE_PENALTY = 2000.0 DEFAULT_ADJOINING_DUCT_TOLERANCE = 5.0 DEFAULT_WIRING_CUT_OUT_BRIDGE_EXTENSION = 20.0 WIRE_DUCT_OBSTACLE_MODE = "PassThrough" @@ -125,7 +128,7 @@ 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: 25.0, + ROUTE_CARRIER_KIND_ROUTING_RANGE: 40.0, ROUTE_CARRIER_KIND_USER_PATH: 1.0, } ROUTE_CARRIER_VIEW_STYLES = { @@ -2402,6 +2405,106 @@ def _orthogonal_access_points(start, end): return points +def _is_primary_route_carrier(carrier): + kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND + return kind in { + ROUTE_CARRIER_KIND, + ROUTE_CARRIER_KIND_WIRE_DUCT, + ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END, + ROUTE_CARRIER_KIND_WIRING_CUT_OUT, + ROUTE_CARRIER_KIND_USER_PATH, + ROUTE_CARRIER_KIND_AUXILIARY_PATH, + } + + +def _component_metrics_by_node(network): + nodes = network.get("nodes", {}) if isinstance(network, dict) else {} + edges = network.get("edges", {}) if isinstance(network, dict) else {} + seen = set() + metrics_by_node = {} + for start_key in nodes.keys(): + if start_key in seen: + continue + stack = [start_key] + seen.add(start_key) + component_nodes = [] + component_edges = set() + primary_edges = set() + while stack: + key = stack.pop() + component_nodes.append(key) + for next_key, _weight, carrier in edges.get(key, []) or []: + edge_key = tuple(sorted((key, next_key))) + component_edges.add(edge_key) + if _is_primary_route_carrier(carrier): + primary_edges.add(edge_key) + if next_key not in seen: + seen.add(next_key) + stack.append(next_key) + metrics = { + "segments": len(component_edges), + "primary_segments": len(primary_edges), + } + for key in component_nodes: + metrics_by_node[key] = metrics + return metrics_by_node + + +def rank_connection_point_candidates(network, candidates): + """Sort graph entry candidates by route usefulness, not only distance.""" + candidates = [candidate for candidate in list(candidates or []) if isinstance(candidate, dict)] + if not candidates: + return [] + metrics_by_node = _component_metrics_by_node(network) + max_segments = max( + [int(metrics.get("segments", 0) or 0) for metrics in metrics_by_node.values()] or [0] + ) + max_primary_segments = max( + [int(metrics.get("primary_segments", 0) or 0) for metrics in metrics_by_node.values()] + or [0] + ) + ranked = [] + for candidate in candidates: + left_metrics = metrics_by_node.get(candidate.get("key"), {}) + right_metrics = metrics_by_node.get(candidate.get("next_key"), {}) + component_segments = max( + int(left_metrics.get("segments", 0) or 0), + int(right_metrics.get("segments", 0) or 0), + ) + component_primary_segments = max( + int(left_metrics.get("primary_segments", 0) or 0), + int(right_metrics.get("primary_segments", 0) or 0), + ) + score = float(candidate.get("distance", 0.0) or 0.0) + if max_primary_segments > 0 and component_primary_segments <= 0: + score += DEFAULT_TERMINAL_ACCESS_FALLBACK_ONLY_COMPONENT_PENALTY + carrier_kind = (getattr(candidate.get("carrier"), "QetRouteCarrierKind", "") or "").strip() + if max_primary_segments > 0 and carrier_kind == ROUTE_CARRIER_KIND_TERMINAL_ACCESS: + # 入口候选也要优先真实主路径,避免导线贴到其它端子的局部接入线上起步。 + score += DEFAULT_TERMINAL_ACCESS_ENTRY_CANDIDATE_PENALTY + score += max(0, max_segments - component_segments) * DEFAULT_TERMINAL_ACCESS_COMPONENT_SEGMENT_PENALTY + item = dict(candidate) + item["route_entry_score"] = float(score) + item["route_entry_component_segments"] = int(component_segments) + item["route_entry_component_primary_segments"] = int(component_primary_segments) + ranked.append(item) + ranked.sort(key=lambda item: float(item.get("route_entry_score", 0.0) or 0.0)) + return ranked + + +def _terminal_access_target_candidate(network, exit_point, max_distance): + candidates = connection_point_candidates( + network, + exit_point, + limit=0, + max_distance=max_distance, + ) + ranked = rank_connection_point_candidates(network, candidates) + if not ranked: + return None + return ranked[0] + + def create_terminal_access_carriers_from_document( doc, project_uuid="", @@ -2444,9 +2547,11 @@ def create_terminal_access_carriers_from_document( if len(terminal_access_points) < 2: continue exit_point = terminal_access_points[-1] - nearest_point, distance = nearest_point_on_network(network, exit_point) - if nearest_point is None: + candidate = _terminal_access_target_candidate(network, exit_point, max_distance) + if candidate is None: continue + nearest_point = _vector(candidate.get("point")) + distance = float(candidate.get("distance", 0.0) or 0.0) if max_distance and float(distance or 0.0) > float(max_distance): continue if float(distance or 0.0) <= DEFAULT_NODE_TOLERANCE: @@ -2716,6 +2821,8 @@ def build_route_graph( blocked_bboxes = list(blocked_bboxes or []) segments = [] bridgeable_endpoint_nodes = [] + projection_bridge_candidates = [] + adjoining_limit = max(float(adjoining_duct_tolerance or 0.0), 0.0) def ensure_node(point): key = _point_key(point, tolerance=tolerance) @@ -2760,6 +2867,31 @@ def build_route_graph( left["points"].extend(intersections) right["points"].extend(intersections) + # 现场线槽/UserPath 常见“支路端点靠近主干中段”,并不总是端点对端点。 + # 在容差内时先把主干投影点加入分段点,后面再补一条虚拟桥接边。 + if adjoining_limit > tolerance: + for left in segments: + left_carrier = left["carrier"] + left_kind = (getattr(left_carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND + if left_kind not in BRIDGEABLE_ENDPOINT_CARRIER_KINDS: + continue + for endpoint in (left["start"], left["end"]): + for right in segments: + right_carrier = right["carrier"] + if right_carrier is left_carrier: + continue + right_kind = (getattr(right_carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND + if right_kind not in BRIDGEABLE_ENDPOINT_CARRIER_KINDS: + continue + projected = _closest_point_on_segment(endpoint, right["start"], right["end"]) + distance = _distance(endpoint, projected) + if distance <= tolerance or distance > adjoining_limit: + continue + right["points"].append(projected) + projection_bridge_candidates.append( + (endpoint, projected, left_carrier, right_carrier) + ) + for segment in segments: ordered = _sorted_segment_points( segment["start"], @@ -2793,35 +2925,52 @@ def build_route_graph( previous_key = current_key previous_point = current_point - adjoining_limit = max(float(adjoining_duct_tolerance or 0.0), 0.0) bridged_pairs = set() + + def add_bridge_edge(left_key, left_point, left_carrier, right_key, right_point, right_carrier): + nonlocal blocked_segment_count, bridged_segment_count, segment_count + if left_key == right_key or left_carrier is right_carrier: + return + pair = tuple(sorted((left_key, right_key))) + if pair in bridged_pairs: + return + distance = _distance(left_point, right_point) + if distance <= tolerance or distance > adjoining_limit: + return + if any(next_key == right_key for next_key, _weight, _carrier in edges.get(left_key, [])): + return + if _segment_hits_blocked_bbox(left_point, right_point, blocked_bboxes): + blocked_segment_count += 1 + return + edges[left_key].append((right_key, distance, left_carrier)) + edges[right_key].append((left_key, distance, right_carrier)) + segment_count += 1 + bridged_segment_count += 1 + bridged_pairs.add(pair) + if adjoining_limit > tolerance: + for endpoint, projected, endpoint_carrier, projected_carrier in projection_bridge_candidates: + endpoint_key = ensure_node(endpoint) + projected_key = ensure_node(projected) + add_bridge_edge( + endpoint_key, + nodes[endpoint_key], + endpoint_carrier, + projected_key, + nodes[projected_key], + projected_carrier, + ) for left_index, left in enumerate(bridgeable_endpoint_nodes): left_key, left_point, left_carrier = left for right_key, right_point, right_carrier in bridgeable_endpoint_nodes[left_index + 1:]: - if left_key == right_key or left_carrier is right_carrier: - continue - pair = tuple(sorted((left_key, right_key))) - if pair in bridged_pairs: - continue - distance = _distance(left_point, right_point) - if distance <= tolerance or distance > adjoining_limit: - continue - if any(next_key == right_key for next_key, _weight, _carrier in edges.get(left_key, [])): - continue - if _segment_hits_blocked_bbox(left_point, right_point, blocked_bboxes): - blocked_segment_count += 1 - continue - edges[left_key].append((right_key, distance, left_carrier)) - edges[right_key].append((left_key, distance, right_carrier)) - segment_count += 1 - bridged_segment_count += 1 - bridged_pairs.add(pair) + add_bridge_edge(left_key, left_point, left_carrier, right_key, right_point, right_carrier) return { "nodes": nodes, "edges": edges, "carriers": carriers, + # 自动桥接边只存在于路径图里;保存 key 对用于 route track 标记实际走过的桥接段。 + "bridge_pairs": set(bridged_pairs), "carrier_count": len(carriers), "segment_count": segment_count, "bridged_segment_count": bridged_segment_count, @@ -2885,18 +3034,19 @@ def nearest_point_on_network(network, point): return nearest_node(network, target) -def connect_point_to_network(network, point): - """Connect the closest projected point to a route graph and return key/distance/mode.""" +def connection_point_candidates(network, point, limit=8, max_distance=0.0): + """Return nearby graph entry candidates sorted by distance.""" if not isinstance(network, dict): - return None, None, "none" + return [] nodes = network.get("nodes", {}) or {} edges = network.get("edges", {}) or {} if not nodes or not edges: - return None, None, "none" + return [] tolerance = float(network.get("tolerance", DEFAULT_NODE_TOLERANCE) or DEFAULT_NODE_TOLERANCE) target = _vector(point) - best = None + candidates = [] + seen_candidates = set() seen = set() for key, neighbors in edges.items(): start = nodes.get(key) @@ -2912,28 +3062,71 @@ def connect_point_to_network(network, point): continue projected = _closest_point_on_segment(target, start, end) distance = _distance(target, projected) - if best is None or distance < best["distance"]: - best = { + if max_distance > 0.0 and distance > max_distance: + continue + projected_key = _point_key(projected, tolerance=tolerance) + candidate_key = (projected_key, key, next_key, id(carrier)) + if candidate_key in seen_candidates: + continue + seen_candidates.add(candidate_key) + candidates.append( + { "key": key, "next_key": next_key, "carrier": carrier, "point": projected, + "projected_key": projected_key, "distance": distance, } + ) - if best is None: + if not candidates: node_key, distance = nearest_node(network, target) - return node_key, distance, "node" if node_key is not None else "none" + if node_key is None: + return [] + if max_distance > 0.0 and float(distance or 0.0) > max_distance: + return [] + candidates.append( + { + "key": node_key, + "next_key": None, + "carrier": None, + "point": nodes.get(node_key, target), + "projected_key": node_key, + "distance": float(distance or 0.0), + "mode": "node", + } + ) - projected_key = _point_key(best["point"], tolerance=tolerance) + candidates.sort(key=lambda item: float(item.get("distance", 0.0) or 0.0)) + max_items = max(int(limit or 0), 0) + if max_items: + return candidates[:max_items] + return candidates + + +def connect_point_candidate_to_network(network, candidate): + """Connect a preselected projected point to a route graph.""" + if not isinstance(network, dict) or not isinstance(candidate, dict): + return None, None, "none" + nodes = network.get("nodes", {}) or {} + edges = network.get("edges", {}) or {} + if not nodes or not edges: + return None, None, "none" + + tolerance = float(network.get("tolerance", DEFAULT_NODE_TOLERANCE) or DEFAULT_NODE_TOLERANCE) + projected = _vector(candidate.get("point")) + projected_key = candidate.get("projected_key") or _point_key(projected, tolerance=tolerance) if projected_key in nodes: - return projected_key, best["distance"], "node" + return projected_key, float(candidate.get("distance", 0.0) or 0.0), "node" - start_key = best["key"] - end_key = best["next_key"] + start_key = candidate.get("key") + end_key = candidate.get("next_key") + if start_key not in nodes or end_key not in nodes: + return None, None, "none" start = nodes[start_key] end = nodes[end_key] - carrier = best["carrier"] + carrier = candidate.get("carrier") def remove_edge_once(left_key, right_key, fallback_to_pair=False): neighbors = list(edges.get(left_key, []) or []) @@ -2953,12 +3146,12 @@ def connect_point_to_network(network, point): removed_forward = remove_edge_once(start_key, end_key) remove_edge_once(end_key, start_key, fallback_to_pair=removed_forward) - nodes[projected_key] = best["point"] + nodes[projected_key] = projected edges[projected_key] = [] added_segments = 0 for left_key, left_point, right_key, right_point in ( - (start_key, start, projected_key, best["point"]), - (projected_key, best["point"], end_key, end), + (start_key, start, projected_key, projected), + (projected_key, projected, end_key, end), ): weight = _distance(left_point, right_point) if weight <= tolerance: @@ -2967,7 +3160,15 @@ def connect_point_to_network(network, point): edges[right_key].append((left_key, weight, carrier)) added_segments += 1 network["segment_count"] = max(int(network.get("segment_count", 0) or 0) - 1 + added_segments, 0) - return projected_key, best["distance"], "segment_projection" + return projected_key, float(candidate.get("distance", 0.0) or 0.0), "segment_projection" + + +def connect_point_to_network(network, point): + """Connect the closest projected point to a route graph and return key/distance/mode.""" + candidates = connection_point_candidates(network, point, limit=1) + if not candidates: + return None, None, "none" + return connect_point_candidate_to_network(network, candidates[0]) def _carrier_track_payload(carrier): @@ -3018,6 +3219,7 @@ def shortest_path_with_carriers( kind_cost_factors=None, segment_usage_costs=None, segment_reuse_penalty=0.0, + excluded_transit_carrier_kinds=None, ): """Dijkstra search with a small extra cost when route direction changes.""" if start_key is None or end_key is None: @@ -3026,11 +3228,18 @@ def shortest_path_with_carriers( return { "path": [start_key], "segments": [], + "bridged_segments": 0, "cost": 0.0, } nodes = network.get("nodes", {}) edges = network.get("edges", {}) + bridge_pairs = set(network.get("bridge_pairs", set()) or set()) + excluded_transit_kinds = { + str(kind or "").strip() + for kind in (excluded_transit_carrier_kinds or []) + if str(kind or "").strip() + } queue = [] counter = 0 start_state = (start_key, None) @@ -3053,16 +3262,18 @@ def shortest_path_with_carriers( previous_key = previous_state[0] current_key = current_state[0] carrier = previous_entry.get("carrier") - segments.append( - { - "from_key": list(previous_key), - "to_key": list(current_key), - "from": _point_payload(nodes[previous_key]), - "to": _point_payload(nodes[current_key]), - "length_mm": float(previous_entry.get("weight", 0.0) or 0.0), - "carrier": _carrier_track_payload(carrier), - } - ) + segment_pair = tuple(sorted((previous_key, current_key))) + segment_payload = { + "from_key": list(previous_key), + "to_key": list(current_key), + "from": _point_payload(nodes[previous_key]), + "to": _point_payload(nodes[current_key]), + "length_mm": float(previous_entry.get("weight", 0.0) or 0.0), + "carrier": _carrier_track_payload(carrier), + } + if segment_pair in bridge_pairs: + segment_payload["is_bridge"] = True + segments.append(segment_payload) current_state = previous_state path.append(current_state[0]) path.reverse() @@ -3070,7 +3281,12 @@ def shortest_path_with_carriers( carrier_names = [] carrier_kinds = {} + bridged_segments = 0 for segment in segments: + if bool(segment.get("is_bridge", False)): + bridged_segments += 1 + # 桥接段是虚拟连通边,不纳入“真实经过的 carrier 类型”汇总。 + continue carrier = segment.get("carrier", {}) name = carrier.get("name", "") if name and name not in carrier_names: @@ -3083,10 +3299,15 @@ def shortest_path_with_carriers( "segments": segments, "carrier_names": carrier_names, "carrier_kinds": carrier_kinds, + "bridged_segments": bridged_segments, "cost": float(cost), } for next_key, weight, carrier in edges.get(key, []): + carrier_kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND + # TerminalAccess 是端子局部接入线,不能被其它导线当作柜内主路径或公共桥接段。 + if carrier_kind in excluded_transit_kinds: + continue direction = _direction_key(nodes[key], nodes[next_key]) bend_cost = 0.0 if previous_direction is not None and direction != previous_direction: diff --git a/tests/python/freecad_exchange_auto_routing_test.py b/tests/python/freecad_exchange_auto_routing_test.py index 719f249..8372a37 100644 --- a/tests/python/freecad_exchange_auto_routing_test.py +++ b/tests/python/freecad_exchange_auto_routing_test.py @@ -843,6 +843,146 @@ class AutoRoutingTest(unittest.TestCase): self.assertIsNotNone(result) self.assertIn("UserPath", result["carrier_kinds"]) + def test_route_graph_bridges_endpoint_to_nearby_segment_projection(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)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(50, 8, 20), app.Vector(50, 50, 20)], + project_uuid="project-1", + kind="UserPath", + ) + + network = routing_network.build_route_graph(doc, adjoining_duct_tolerance=15.0) + start_key, _start_distance = routing_network.nearest_node(network, app.Vector(50, 50, 20)) + end_key, _end_distance = routing_network.nearest_node(network, app.Vector(100, 0, 20)) + result = routing_network.shortest_path_with_carriers(network, start_key, end_key) + + self.assertEqual(1, network["bridged_segment_count"]) + self.assertIsNotNone(result) + self.assertIn((50000, 0, 20000), result["path"]) + + def test_auto_routing_uses_endpoint_to_segment_projection_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, "TerminalBranch", "terminal-branch", app.Vector(50, 50, 0)) + end = _terminal(doc, terminal_objects, "TerminalMain", "terminal-main", 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", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(50, 8, 20), app.Vector(50, 50, 20)], + project_uuid="project-1", + kind="UserPath", + ) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"adjoining_duct_tolerance": 15.0}, + ) + + self.assertEqual("Routed", result["route_status"]) + self.assertEqual(1, result["network"]["bridged_segments"]) + self.assertEqual(1, result["route_track"]["bridged_segments"]) + self.assertTrue(any(segment.get("is_bridge") for segment in result["route_track"]["segments"])) + self.assertIn("UserPath", result["route_track"]["carrier_kinds"]) + self.assertIn("WireDuct", result["route_track"]["carrier_kinds"]) + + def test_auto_routing_does_not_use_terminal_access_to_bridge_main_path_gap(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(40, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(40, 0, 20), app.Vector(60, 0, 20)], + project_uuid="project-1", + kind="TerminalAccess", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(60, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + + with self.assertRaises(auto_routing.AutoRoutingError): + auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_access_max_distance": 5.0}, + ) + + def test_route_graph_projection_bridge_respects_blocked_bbox(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)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(50, 8, 20), app.Vector(50, 50, 20)], + project_uuid="project-1", + kind="UserPath", + ) + blocked_bboxes = [ + { + "xmin": 45.0, + "xmax": 55.0, + "ymin": 2.0, + "ymax": 6.0, + "zmin": 15.0, + "zmax": 25.0, + } + ] + + network = routing_network.build_route_graph( + doc, + blocked_bboxes=blocked_bboxes, + adjoining_duct_tolerance=15.0, + ) + start_key, _start_distance = routing_network.nearest_node(network, app.Vector(50, 50, 20)) + end_key, _end_distance = routing_network.nearest_node(network, app.Vector(100, 0, 20)) + result = routing_network.shortest_path_with_carriers(network, start_key, end_key) + + self.assertEqual(0, network["bridged_segment_count"]) + self.assertGreaterEqual(network["blocked_segment_count"], 1) + self.assertIsNone(result) + def test_auto_routing_respects_adjoining_duct_tolerance_option(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() @@ -1644,6 +1784,97 @@ class AutoRoutingTest(unittest.TestCase): end_point = access_carriers[0].Points[-1] self.assertEqual((50.0, 0.0, 20.0), (end_point.x, end_point.y, end_point.z)) + def test_terminal_access_prefers_larger_connected_network_over_nearer_isolated_stub(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)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 1, 20), app.Vector(5, 1, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [ + app.Vector(0, 10, 20), + app.Vector(40, 10, 20), + app.Vector(80, 10, 20), + app.Vector(120, 10, 20), + ], + project_uuid="project-1", + kind="WireDuct", + ) + + created = routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + ) + + self.assertEqual(1, len(created)) + end_point = created[0].Points[-1] + self.assertEqual((0.0, 10.0, 20.0), (end_point.x, end_point.y, end_point.z)) + + def test_connection_entry_candidates_prefer_wire_duct_over_terminal_access(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(0, 10, 20)], + project_uuid="project-1", + kind="TerminalAccess", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 10, 20), app.Vector(100, 10, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + network = routing_network.build_route_graph(doc) + + ranked = routing_network.rank_connection_point_candidates( + network, + routing_network.connection_point_candidates(network, app.Vector(0, 0, 20), limit=0), + ) + + first_kind = getattr(ranked[0]["carrier"], "QetRouteCarrierKind", "") + self.assertEqual("WireDuct", first_kind) + + def test_terminal_access_prefers_wire_duct_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)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 1, 20), app.Vector(120, 1, 20)], + project_uuid="project-1", + kind="RoutingRange", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 10, 20), app.Vector(120, 10, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + + created = routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + ) + + self.assertEqual(1, len(created)) + end_point = created[0].Points[-1] + self.assertEqual((0.0, 10.0, 20.0), (end_point.x, end_point.y, end_point.z)) + def test_eplan_connection_route_enters_network_at_segment_projection(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() @@ -1840,6 +2071,8 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual("terminal-long-access", payload["long_terminal_accesses"][0]["terminal_uuid"]) self.assertEqual(900.0, payload["long_terminal_accesses"][0]["terminal_access_length_mm"]) self.assertIn("端子接入过长", message) + self.assertIn("TerminalLongAccess", message) + self.assertIn("terminal-long-access", message) self.assertIn("900.0 mm", message) def test_check_routing_path_network_warns_for_invalid_terminal_local_route_points(self): @@ -2042,7 +2275,7 @@ class AutoRoutingTest(unittest.TestCase): message = auto_routing.format_routing_path_network_report(diagnostic) - self.assertIn("桥接 1 段相邻主路径", message) + self.assertIn("桥接 1 段相邻/投影主路径", message) def test_check_routing_path_network_uses_adjoining_duct_tolerance_option(self): _install_fake_freecad() @@ -2194,7 +2427,7 @@ class AutoRoutingTest(unittest.TestCase): 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)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(1000, 0, 0)) routing_network.create_route_carrier( doc, [app.Vector(0, 0, 20), app.Vector(44, 0, 20)], @@ -2337,6 +2570,39 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual("z", report["routes"][1]["lane"]["axis"]) self.assertEqual(8.0, report["routes"][1]["lane"]["offset_mm"]) + def test_auto_routing_controller_exposes_lane_max_offset(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") + 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", + ) + + controller = auto_routing_panel.AutoRoutingController() + controller.set_lane_spacing(10.0) + controller.set_lane_axis("y") + controller.set_lane_max_offset(18.0) + result = _auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + route_index=21, + options=controller.routing_options(), + ) + + self.assertEqual(18.0, controller.routing_options()["lane_max_offset"]) + self.assertEqual(18.0, result["lane"]["max_offset_mm"]) + self.assertEqual(18.0, result["lane"]["offset_mm"]) + def test_auto_routing_panel_command_button_style_keeps_text_visible(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() @@ -2638,6 +2904,77 @@ class AutoRoutingTest(unittest.TestCase): self.assertGreaterEqual(result["network"]["blocked_segments"], 1) self.assertIn(50.0, [point.y for point in result["points"]]) + def test_eplan_connection_route_prefers_entry_candidate_without_access_collision(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(20, 0, 0), app.Vector(100, 0, 0)], + label="Near Duct", + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 30, 0), app.Vector(100, 30, 0)], + label="Clear Duct", + project_uuid="project-1", + kind="WireDuct", + ) + obstacle = doc.addObject("Part::Feature", "AccessObstacle") + obstacle.Label = "Access Obstacle" + obstacle.Shape = FakeShape(FakeBoundBox(10, 15, -5, 5, -5, 5)) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + labels = [ + segment["carrier"]["label"] + for segment in result["route_track"]["segments"] + ] + self.assertIn("Clear Duct", labels) + self.assertNotIn("Near Duct", labels) + self.assertEqual(0, result["collision_count"]) + + def test_eplan_connection_route_chooses_clear_orthogonal_access_order(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(30, 30, 0), app.Vector(100, 30, 0)], + label="Only Duct", + project_uuid="project-1", + kind="WireDuct", + ) + obstacle = doc.addObject("Part::Feature", "AccessOrderObstacle") + obstacle.Shape = FakeShape(FakeBoundBox(10, 20, -5, 5, -5, 5)) + + result = auto_routing.route_eplan_connection_between_terminals( + doc, + start, + end, + options={"terminal_exit_length": 0.0, "lane_spacing": 0.0}, + ) + + point_tuples = [(point.x, point.y, point.z) for point in result["points"]] + self.assertIn((0.0, 30.0, 0.0), point_tuples) + self.assertNotIn((30.0, 0.0, 0.0), point_tuples) + self.assertEqual(0, result["collision_count"]) + def test_eplan_connection_route_marks_collision_warning_against_obstacle_bbox(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() @@ -2771,7 +3108,7 @@ class AutoRoutingTest(unittest.TestCase): 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)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(1000, 0, 0)) payload = { "project_uuid": "project-1", "wires": [ @@ -2857,9 +3194,67 @@ class AutoRoutingTest(unittest.TestCase): self.assertIn("WireDuct", result["route_track"]["carrier_kinds"]) self.assertNotIn("RoutingRange", result["route_track"]["carrier_kinds"]) - def test_route_eplan_connections_from_payload_skips_tasks_when_carriers_have_no_segments(self): + def test_eplan_connection_route_prefers_wire_duct_when_routing_range_is_only_moderately_shorter(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") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(10, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(10, 0, 20)], + project_uuid="project-1", + kind="RoutingRange", + ) + routing_network.create_route_carrier( + doc, + [ + app.Vector(0, 0, 20), + app.Vector(0, 145, 20), + app.Vector(10, 145, 20), + app.Vector(10, 0, 20), + ], + project_uuid="project-1", + kind="WireDuct", + ) + + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + + self.assertIn("WireDuct", result["route_track"]["carrier_kinds"]) + self.assertNotIn("RoutingRange", result["route_track"]["carrier_kinds"]) + + def test_eplan_connection_route_considers_primary_entry_beyond_nearest_surface_candidates(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)) + for y in range(1, 11): + routing_network.create_route_carrier( + doc, + [app.Vector(0, y, 20), app.Vector(100, y, 20)], + project_uuid="project-1", + kind="RoutingRange", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 20, 20), app.Vector(100, 20, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + + self.assertIn("WireDuct", result["route_track"]["carrier_kinds"]) + self.assertNotIn("RoutingRange", result["route_track"]["carrier_kinds"]) + + def test_route_eplan_connections_from_payload_skips_tasks_when_carriers_have_no_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") @@ -2898,7 +3293,11 @@ class AutoRoutingTest(unittest.TestCase): ], } - report = auto_routing.route_eplan_connections_from_payload(doc, payload) + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"network_entry_max_distance": 30.0}, + ) self.assertEqual(1, report["route_network_carriers"]) self.assertEqual(0, report["route_network_segments"]) @@ -2909,6 +3308,79 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual([], report["errors"]) self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc)) + 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", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(1000, 0, 20), app.Vector(1010, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "wire_label": "N4111", + "start_terminal_uuid": "terminal-start", + "start_element_uuid": "QF1", + "start_terminal_display": "A1", + "end_terminal_uuid": "terminal-end", + "end_element_uuid": "KM1", + "end_terminal_display": "13", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload( + doc, + payload, + options={"network_entry_max_distance": 30.0}, + ) + + self.assertEqual(0, report["routed"]) + self.assertEqual(1, report["skipped_missing_route_network"]) + self.assertEqual(1, report["route_status_counts"]["MissingRouteNetwork"]) + self.assertEqual([], report["errors"]) + self.assertEqual("wire-a", report["missing_route_network_samples"][0]["wire_uuid"]) + self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc)) + + def test_network_entry_uses_terminal_access_max_distance_when_smaller(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(500, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(10, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + + route = auto_routing.build_network_route( + start, + end, + options={"terminal_access_max_distance": 30.0}, + doc=doc, + ) + + self.assertIsNone(route) + def test_route_eplan_connections_writes_diagnostic_object_for_missing_terminal(self): _install_fake_freecad() terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() @@ -2988,6 +3460,48 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual("wire-a", diagnostic_payload["route_samples"][0]["wire_uuid"]) self.assertEqual("Routed", diagnostic_payload["route_samples"][0]["route_status"]) + def test_compact_route_sample_prefers_route_track_bridged_segment_count(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + sample = auto_routing._compact_route_sample( + { + "wire_uuid": "wire-bridge", + "route_track": { + "bridged_segments": 1, + }, + "network": { + "bridged_segments": 3, + }, + } + ) + + self.assertEqual(1, sample["network"]["bridged_segments"]) + + def test_compact_route_sample_ignores_bridge_only_carrier_summary(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + sample = auto_routing._compact_route_sample( + { + "wire_uuid": "wire-bridge", + "route_track": { + "carrier_kinds": {"RoutingRange": 1}, + "carrier_names": ["VirtualBridge"], + "segments": [ + { + "is_bridge": True, + "carrier": {"name": "VirtualBridge", "kind": "RoutingRange"}, + }, + { + "carrier": {"name": "WireDuctA", "kind": "WireDuct"}, + }, + ], + }, + } + ) + + self.assertEqual({"WireDuct": 1}, sample["carrier_kinds"]) + self.assertEqual(["WireDuctA"], sample["carrier_names"]) + def test_route_eplan_connections_batch_diagnostic_includes_quality_warnings(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() @@ -3029,6 +3543,38 @@ class AutoRoutingTest(unittest.TestCase): diagnostic_payload["route_quality_warning_samples"][0]["carrier_kinds"], ) + def test_compact_batch_report_includes_entry_distance_warning_samples(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "terminal_access_warning_distance": 100.0, + "routes": [ + { + "wire_uuid": "wire-long-entry", + "wire_label": "N-LONG", + "network": { + "entry_distance": 125.0, + "exit_distance": 20.0, + }, + } + ], + } + + payload = auto_routing._compact_routing_connection_batch_report(report) + + self.assertEqual(1, payload["route_entry_distance_warning_count"]) + self.assertEqual( + "wire-long-entry", + payload["route_entry_distance_warning_samples"][0]["wire_uuid"], + ) + self.assertEqual( + ["entry"], + payload["route_entry_distance_warning_samples"][0]["warning_sides"], + ) + def test_route_eplan_connections_reports_total_connection_route_length(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() @@ -3245,6 +3791,83 @@ class AutoRoutingTest(unittest.TestCase): self.assertIn("路径示例:导线 N4111 经过 QF1:A1、线槽A、过线孔A。", message) + def test_route_report_source_sample_skips_bridge_segments(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N4111", + "route_track": { + "segments": [ + {"carrier": {"kind": "WireDuct", "source_label": "线槽A"}}, + {"is_bridge": True, "carrier": {"kind": "WireDuct", "source_label": "虚拟桥接"}}, + {"carrier": {"kind": "UserPath", "source_label": "用户路径B"}}, + ] + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("路径示例:导线 N4111 经过 线槽A、用户路径B。", message) + self.assertNotIn("虚拟桥接", message) + + def test_route_track_segment_keys_skip_bridge_segments(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + route_track = { + "segments": [ + { + "from_key": [0, 0, 0], + "to_key": [100, 0, 0], + "carrier": {"name": "WireDuctA"}, + }, + { + "is_bridge": True, + "from_key": [100, 0, 0], + "to_key": [100, 10, 0], + "carrier": {"name": "VirtualBridge"}, + }, + ] + } + + keys = auto_routing._route_track_segment_keys(route_track) + + self.assertEqual(1, len(keys)) + self.assertEqual("WireDuctA", keys[0][0]) + + def test_route_quality_warning_ignores_bridge_only_routing_range(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N4111", + "route_track": { + "carrier_kinds": {"RoutingRange": 1}, + "segments": [ + { + "is_bridge": True, + "carrier": {"kind": "RoutingRange", "source_label": "虚拟布线面桥接"}, + } + ], + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertNotIn("路径质量提示", message) + def test_route_report_includes_network_bridge_and_blocked_segment_counts(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() @@ -3264,7 +3887,31 @@ class AutoRoutingTest(unittest.TestCase): message = auto_routing.format_eplan_connection_route_report(report) - self.assertIn("路径网络:自动桥接 1 段相邻主路径,避障屏蔽 2 段。", message) + self.assertIn("路径网络:自动桥接 1 段相邻/投影主路径,避障屏蔽 2 段。", message) + + def test_route_report_prefers_route_track_bridged_segment_count(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "network": { + "bridged_segments": 3, + }, + "route_track": { + "bridged_segments": 1, + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("路径网络:自动桥接 1 段相邻/投影主路径。", message) + self.assertNotIn("自动桥接 3 段", message) def test_route_report_includes_parallel_lane_summary(self): _install_fake_freecad() @@ -3274,14 +3921,43 @@ class AutoRoutingTest(unittest.TestCase): "collision_warnings": 0, "skipped_missing_terminal": 0, "routes": [ - {"lane": {"index": 0, "axis": "y", "spacing_mm": 10.0, "offset_mm": 0.0}}, - {"lane": {"index": 2, "axis": "y", "spacing_mm": 10.0, "offset_mm": -10.0}}, + {"lane": {"index": 0, "axis": "y", "spacing_mm": 10.0, "max_offset_mm": 30.0, "offset_mm": 0.0}}, + {"lane": {"index": 2, "axis": "y", "spacing_mm": 10.0, "max_offset_mm": 30.0, "offset_mm": -10.0}}, ], } message = auto_routing.format_eplan_connection_route_report(report) - self.assertIn("并行错位:最大 lane 2,间距 10.0 mm。", message) + self.assertIn("并行错位:最大 lane 2,间距 10.0 mm,最大偏移 30.0 mm。", message) + + def test_eplan_connection_lane_offset_is_capped_for_dense_parallel_routes(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, + route_index=21, + options={"lane_spacing": 10.0, "lane_axis": "y"}, + ) + + self.assertEqual(30.0, result["lane"]["offset_mm"]) + self.assertLessEqual( + max(abs(point.y) for point in result["points"]), + 30.0, + ) def test_route_report_includes_replaced_routed_connection_count(self): _install_fake_freecad() @@ -3370,6 +4046,86 @@ class AutoRoutingTest(unittest.TestCase): self.assertIn("容量提示:最大并行线数 3,路径最小容量 2。", message) + def test_route_report_ignores_bridge_segments_for_capacity_pressure(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 3, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "lane": {"index": 2, "spacing_mm": 10.0}, + "route_track": { + "segments": [ + {"is_bridge": True, "carrier": {"kind": "UserPath", "capacity": 1}}, + {"carrier": {"kind": "WireDuct", "capacity": 4}}, + ] + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertNotIn("容量提示", message) + + def test_route_report_includes_entry_candidate_rank_when_route_uses_fallback_entry(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N1", + "network": { + "entry_candidate_rank": 3, + "exit_candidate_rank": 1, + "entry_candidate_score": 125.0, + }, + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("接入候选", message) + self.assertIn("起点第 3 个", message) + + def test_route_report_warns_when_network_entry_distance_is_long(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "terminal_access_warning_distance": 100.0, + "routes": [ + { + "wire_label": "N1", + "network": { + "entry_distance": 125.0, + "exit_distance": 20.0, + }, + }, + { + "wire_label": "N2", + "network": { + "entry_distance": 20.0, + "exit_distance": 150.0, + }, + }, + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("接入距离提示:2 条导线", message) + self.assertIn("示例导线 N1", message) + self.assertIn("起点接入 125.0 mm", message) + def test_route_report_capacity_pressure_is_checked_per_route(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() @@ -3445,6 +4201,47 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(1, route["network"]["carriers"]) self.assertEqual("WireDuct", route["route_track"]["segments"][0]["carrier"]["kind"]) + def test_route_eplan_connections_can_skip_nearer_isolated_entry_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, 1, 20), app.Vector(5, 1, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 10, 20), app.Vector(100, 10, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "wire_label": "N4111", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + + self.assertEqual(1, report["routed"]) + self.assertEqual(0, len(report["errors"])) + route = report["routes"][0] + self.assertEqual("network-dijkstra-v1", route["algorithm"]) + self.assertGreater(route["network"]["entry_distance"], 1.0) + self.assertGreater(route["network"]["entry_candidate_rank"], 1) + def test_route_eplan_connections_report_includes_routing_path_network_diagnostic(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() @@ -4178,6 +4975,36 @@ class AutoRoutingTest(unittest.TestCase): self.assertIn("缺少布线路径网络 3 条", message) self.assertIn("请先生成线槽、布线面或布线路径网络", 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() + report = { + "total_wires": 1, + "routed": 0, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "skipped_missing_route_network": 1, + "missing_route_network_samples": [ + { + "wire_uuid": "wire-1", + "wire_label": "N4111", + "start_terminal_uuid": "terminal-start", + "start_element_uuid": "QF1", + "start_terminal_display": "A1", + "end_terminal_uuid": "terminal-end", + "end_element_uuid": "KM1", + "end_terminal_display": "13", + "error": "没有可用的布线路径网络:起点和终点无法连通", + } + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("缺路径网络示例:导线 N4111", message) + self.assertIn("QF1/A1 (terminal-start) -> KM1/13 (terminal-end)", message) + self.assertIn("原因:没有可用的布线路径网络:起点和终点无法连通", message) + def test_route_eplan_connections_report_includes_readable_missing_endpoint_labels(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules()