Compare commits

...

2 Commits

@ -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. 线槽语义库

@ -142,6 +142,144 @@ FreeCAD 导入工程端子时按下面顺序读取:
当前实现重点保证批量排布稳定、身份不丢失。复杂 Assembly Joint、端子片端挡、隔板、跨接片、短接片规则暂不纳入第一版。
## 装配视频复盘
本节作为后续 3D 装配优化的对比基准。装配相关需求、问题复盘和验收差异优先沉淀到本文档,再拆分为具体实现计划。
### 用户装配视频提炼
用户视频前半段体现的目标流程:
1. 先按真实设备和实物安装关系确认 3D 模型是否匹配。
2. 对设备补充可复用的装配脚点、连接点或接线点。
3. 设备脚点制作完成后,保存为可复用 `.FCStd` 模块。
4. 后续工程中再次插入该设备时,自动带出脚点、端子和装配语义。
5. 装配时可使用 FreeCAD 原生 `切换透明度`、`显示/隐藏所选`,便于选中柜板、导轨、线槽和设备背面。
6. 按步骤导入设备并完成贴合,避免只靠人工拖拽。
当前 FreeCAD 二开需要重点解决的问题:
- 面不容易选中,尤其是柜内导轨、线槽、设备背面被遮挡时。
- 旋转模型后再贴合,容易出现一部分贴合、一部分穿模或悬空。
- 贴合时如果只移动可视子对象,父对象 `Placement` 没同步,后续使用 `变换` 会回到旧位置。
- 多选多个设备面参与贴合不合理,约束语义不清,会导致算法不知道哪个面是移动面。
- 线槽、导轨贴合后仍需要能二次修改长度,并保持与柜板的贴合关系。
- FreeCAD 任务面板和原生 `变换` 任务框会冲突,普通用户需要一键关闭当前面板并进入原生变换。
### 甲方视频参考能力
甲方视频中可参考的装配体验:
- 柜体、导轨、线槽、安装板可透明显示,便于从柜内选择目标面;透明化优先复用 FreeCAD 原生右键菜单能力。
- 对象树、属性面板和三维操纵器联动,用户能明确看到当前选中的对象和坐标。
- 装配过程使用面、边、点作为参考,而不是单纯输入绝对坐标。
- 设备沿导轨或安装板成组排列,位置规则清晰,适合端子排、断路器、继电器等电气元件。
- 贴合后仍能继续微调距离、方向和局部偏移。
- 电气装配关注柜板、导轨、线槽、设备安装面,不需要第一阶段实现完整机械 CAD 装配约束。
## 后续装配优化方向
后续装配能力优先向 SolidWorks Electrical / EPLAN 的电气柜装配体验靠拢,但第一阶段只做电气常用能力,不做完整机械装配工作台。
### 1. 面贴合可靠性
目标:
- 目标面和移动面只允许一对一贴合。
- 如果用户已点击 `设为贴合目标面`,后续只能再选一个移动面。
- 如果用户一次选择两个面,按选择顺序解释为:第一个目标面,第二个移动面。
- 如果选择超过两个面,直接提示重新选择,不执行贴合。
- 贴合时同时更新父级可移动对象的 `Placement`,避免可视位置和对象坐标脱节。
贴合计算原则:
```text
移动面法向 -> 目标面反向法向
移动面参考点 -> 目标面参考点所在平面
最终位姿写入可移动父对象 Placement
```
### 2. 旋转模型后的贴合
目标:
- 用户为了选面临时旋转设备后,贴合仍能根据真实面法向计算旋转和位移。
- 不再只做单轴平移。
- 贴合完成后设备安装面应整体与目标面共面,不允许局部穿模。
- 用户可设置 `贴合间距`0 mm 表示完全贴合,正值表示沿目标面法向预留距离。
- 已贴合对象保存 `QetMountHostNormalJson``QetMountOffsetMm`,后续选择对象后可点击 `应用贴合间距` 做二次调节。
- 如果模型法向与现场直觉相反,选择已贴合对象后点击 `反转贴合方向`,再应用贴合间距。
验收:
- 电流互感器、小型断路器、端子片旋转后,仍可贴到导轨或柜板。
- 贴合后使用 FreeCAD 原生 `变换`,对象从当前贴合位置继续移动,不跳回旧位置。
- 在 `3D手动布线` 面板中选择对象后点击 `关闭面板并变换`,系统先关闭当前任务面板,再调用 FreeCAD 原生 `Std_TransformManip`
### 3. 导轨、线槽、柜板宿主语义
装配宿主分为:
- `cabinet`:柜板、安装板、门板等。
- `rail`DIN 导轨。
- `wire_duct`:线槽。
- `device`:已经装配好的设备,可作为局部参考。
宿主对象应保存:
- `QetCarrierKind`
- `QetCarrierAxis`
- `QetCarrierBaseLength`
- `QetMountMode`
- `QetMountHostName`
- `QetMountHostKind`
- `QetMountContactSubElement`
- `QetMountHostSubElement`
- `QetMountLocalBaseJson`
- `QetMountHostBaseJson`
这些属性用于保存重开、刷新宿主装配和后续自动布线。
### 4. 长度二次调节
导轨和线槽长度调整规则:
- 导入时可设置初始长度。
- 贴合到柜板后仍可修改长度。
- 修改长度时保持宿主贴合面不变。
- 长度变化应优先沿 `QetCarrierAxis` 扩展。
- 如果对象是导入的 FCStd/STEP 组合体,优先修改带 `QetCarrierBaseLength` 的父级载体对象,不应误选内部子零件。
### 5. 设备模板化与复用
设备模板应包含:
- 真实几何模型。
- 安装接触面或装配脚点。
- 工程端子 LCS。
- 端子出线方向。
- 可选局部出线路径。
保存为 `.FCStd`QET 再次导入同型号设备时,应复用这些模板语义。正式导线匹配仍以 QET 传入的 `terminal_uuid` 为准,不使用 `local:*` 作为正式端子身份。
### 6. 电气装配优先级
优先实现:
1. 导轨贴柜板。
2. 线槽贴柜板。
3. 端子排沿导轨排列。
4. 小型断路器沿导轨排列。
5. 电流互感器、继电器等设备贴导轨或柜板。
6. 贴合后的长度调节和刷新宿主装配。
暂不优先实现:
- 完整机械装配 Joint。
- 螺钉、孔、螺纹的精确机械配合。
- 复杂运动学约束。
- 完整 SW Mechanical 级别的 Mate 系统。
## UI
入口位于:
@ -154,6 +292,12 @@ QET模板 -> 3D手动布线
- `批量端子排`
- `批量断路器`
- `设为贴合目标面`
- `贴合到选中面`
- `应用贴合间距`
- `反转贴合方向`
- `刷新宿主装配`
- `贴合间距`
参数窗口说明:

@ -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))

@ -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):

@ -1109,6 +1109,7 @@ def import_devices_from_payload(payload, scene_path=""):
if not instance_id:
report["imported_without_instance_id"] += 1
TerminalObjects.sort_group_children(root_group)
doc.recompute()
try:
Gui.SendMsgToActiveView("ViewFit")

@ -409,7 +409,12 @@ def _selected_contact_face_refs():
shape_type = (getattr(sub_object, "ShapeType", "") or "").strip().lower()
if shape_type != "face":
continue
point = _face_anchor_point(picked, sub_object)
point = None
picked_points = list(getattr(picked, "PickedPoints", []) or [])
if index < len(picked_points):
point = picked_points[index]
if point is None:
point = _face_anchor_point(picked if not picked_points else None, sub_object)
normal = _face_normal(sub_object)
if point is None or normal is None:
continue
@ -422,7 +427,6 @@ def _selected_contact_face_refs():
"subelement_name": subelement_names[index] if index < len(subelement_names) else "",
}
)
break
if refs:
return refs
@ -464,6 +468,13 @@ def _has_placement(obj):
return getattr(obj, "Placement", None) is not None
def _is_app_part_object(obj):
try:
return bool(obj is not None and obj.isDerivedFrom("App::Part"))
except Exception:
return (getattr(obj, "TypeId", "") or "") == "App::Part"
def _contact_transform_object(obj):
carrier = _carrier_object_from_object(obj)
if carrier is not None and _has_placement(carrier):
@ -484,6 +495,7 @@ def _contact_transform_object(obj):
name.startswith("QETDevice_")
or (getattr(parent, "QetInstanceId", "") or "").strip()
or (getattr(parent, "QetCarrierKind", "") or "").strip()
or _is_app_part_object(parent)
):
best = parent
current = parent
@ -502,12 +514,12 @@ def _rotation_for_face_contact(moving_normal, target_normal):
return None
def _normal_contact_translation(target_point, target_normal, moving_point):
def _normal_contact_translation(target_point, target_normal, moving_point, offset_mm=0.0):
normal = _normalize_vector(target_normal)
if normal is None:
return None
signed_distance = _vector_dot(_vector_sub(moving_point, target_point), normal)
return _vector_scale(normal, -signed_distance)
return _vector_scale(normal, float(offset_mm or 0.0) - signed_distance)
def _rotate_object_about_point(obj, rotation, pivot):
@ -645,7 +657,7 @@ def _mount_kind(obj):
return "object"
def _set_face_contact_mount_metadata(moving_obj, target_ref, moving_ref):
def _set_face_contact_mount_metadata(moving_obj, target_ref, moving_ref, offset_mm=0.0):
if moving_obj is None or not isinstance(target_ref, dict) or not isinstance(moving_ref, dict):
return
target_obj = target_ref.get("object")
@ -672,6 +684,12 @@ def _set_face_contact_mount_metadata(moving_obj, target_ref, moving_ref):
"QET cabinet assembly mount metadata",
value,
)
_ensure_float_property(
moving_obj,
"QetMountOffsetMm",
float(offset_mm or 0.0),
"QET cabinet assembly contact offset in target normal direction",
)
if target_base is not None:
_set_vector_json_property(
moving_obj,
@ -679,6 +697,14 @@ def _set_face_contact_mount_metadata(moving_obj, target_ref, moving_ref):
target_base,
"QET cabinet assembly host base at bind time",
)
host_normal = _normalize_vector(target_ref.get("normal"))
if host_normal is not None:
_set_vector_json_property(
moving_obj,
"QetMountHostNormalJson",
host_normal,
"QET cabinet assembly host face normal at bind time",
)
if target_base is not None and moving_base is not None:
_set_vector_json_property(
moving_obj,
@ -1381,8 +1407,9 @@ def _abort_transaction(doc, opened):
class ManualWiringController:
def __init__(self, terminal_exit_length=DEFAULT_TERMINAL_EXIT_LENGTH):
def __init__(self, terminal_exit_length=DEFAULT_TERMINAL_EXIT_LENGTH, contact_offset_mm=0.0):
self.terminal_exit_length = float(terminal_exit_length or 0.0)
self.contact_offset_mm = float(contact_offset_mm or 0.0)
self.current_task = None
self.contact_target_ref = None
self.start_terminal = None
@ -1395,6 +1422,10 @@ class ManualWiringController:
self.terminal_exit_length = max(float(value or 0.0), 0.0)
return self.terminal_exit_length
def set_contact_offset(self, value):
self.contact_offset_mm = float(value or 0.0)
return self.contact_offset_mm
def mark_selected_carriers(self, carrier_kind):
carrier_kind = (carrier_kind or "").strip()
if carrier_kind not in CARRIER_ROLE_LABELS:
@ -1475,6 +1506,7 @@ class ManualWiringController:
_activate_document(doc)
def apply_length_to_selected_carriers(self, length_mm):
doc = _active_document()
selected = _selected_carrier_objects()
if not selected:
raise ManualWiringPanelError("请先选择线槽或导轨对象。")
@ -1485,8 +1517,83 @@ class ManualWiringController:
updated.append(_apply_carrier_length(carrier, length_mm))
if not updated:
raise ManualWiringPanelError("所选对象不是已标记的线槽或导轨。")
refresh_mount_hosted_objects(doc)
try:
_active_document().recompute()
doc.recompute()
except Exception:
pass
return updated
def apply_contact_offset_to_selected_mounts(self, offset_mm):
doc = _active_document()
selected = _selection()
if not selected:
raise ManualWiringPanelError("请先选择已贴合的对象。")
new_offset = float(offset_mm or 0.0)
updated = []
for selected_obj in selected:
obj = _contact_transform_object(selected_obj)
if obj is None or (getattr(obj, "QetMountMode", "") or "").strip() != "face_contact":
continue
host_normal = _normalize_vector(_vector_from_json_property(obj, "QetMountHostNormalJson"))
if host_normal is None:
raise ManualWiringPanelError("所选对象没有保存贴合法向,请重新执行一次贴合后再调节间距。")
old_offset = float(getattr(obj, "QetMountOffsetMm", 0.0) or 0.0)
delta = _vector_scale(host_normal, new_offset - old_offset)
_translate_object(obj, delta)
_ensure_float_property(
obj,
"QetMountOffsetMm",
new_offset,
"QET cabinet assembly contact offset in target normal direction",
)
host_name = (getattr(obj, "QetMountHostName", "") or "").strip()
host = doc.getObject(host_name) if host_name and hasattr(doc, "getObject") else None
host_base = _placement_base(host)
obj_base = _placement_base(obj)
if host_base is not None and obj_base is not None:
_set_vector_json_property(
obj,
"QetMountLocalBaseJson",
_vector_sub(obj_base, host_base),
"QET cabinet assembly local base offset from host",
)
updated.append(obj)
if not updated:
raise ManualWiringPanelError("所选对象不是已贴合的装配对象。")
self.contact_offset_mm = new_offset
try:
doc.recompute()
except Exception:
pass
return updated
def reverse_contact_normal_for_selected_mounts(self):
doc = _active_document()
selected = _selection()
if not selected:
raise ManualWiringPanelError("请先选择已贴合的对象。")
updated = []
for selected_obj in selected:
obj = _contact_transform_object(selected_obj)
if obj is None or (getattr(obj, "QetMountMode", "") or "").strip() != "face_contact":
continue
host_normal = _normalize_vector(_vector_from_json_property(obj, "QetMountHostNormalJson"))
if host_normal is None:
raise ManualWiringPanelError("所选对象没有保存贴合法向,请重新执行一次贴合后再反转方向。")
_set_vector_json_property(
obj,
"QetMountHostNormalJson",
_vector_scale(host_normal, -1.0),
"QET cabinet assembly host face normal at bind time",
)
updated.append(obj)
if not updated:
raise ManualWiringPanelError("所选对象不是已贴合的装配对象。")
try:
doc.recompute()
except Exception:
pass
return updated
@ -1523,11 +1630,12 @@ class ManualWiringController:
target["point"],
target["normal"],
moving["point"],
self.contact_offset_mm,
)
if translation is None:
raise ManualWiringPanelError("无法读取目标面的法向,不能执行贴合。")
_translate_object(moving_object, translation)
_set_face_contact_mount_metadata(moving_object, target, moving)
_set_face_contact_mount_metadata(moving_object, target, moving, self.contact_offset_mm)
try:
_active_document().recompute()
except Exception:
@ -1541,6 +1649,7 @@ class ManualWiringController:
"moving_point": moving["point"],
"translation": translation,
"translation_mode": "normal",
"contact_offset_mm": self.contact_offset_mm,
"rotated": rotated,
}
@ -1770,6 +1879,23 @@ class ManualWiringController:
return True
raise ManualWiringPanelError("当前 FreeCAD 文档不支持撤销。")
def launch_native_transform_for_selection(self):
_active_document()
if not _selection():
raise ManualWiringPanelError("请选择要变换的对象。")
if Gui is None or not hasattr(Gui, "runCommand"):
raise ManualWiringPanelError("当前 FreeCAD 界面不支持原生变换命令。")
# FreeCAD 原生变换也是任务面板;先关闭本面板,避免 TaskDialog 互相占用。
control = getattr(Gui, "Control", None)
if control is not None and hasattr(control, "closeDialog"):
try:
control.closeDialog()
except Exception:
pass
Gui.runCommand("Std_TransformManip")
return True
def set_end_from_selection_and_generate(self):
doc = _active_document()
if self.start_terminal is None:
@ -1859,10 +1985,11 @@ class ManualWiringController:
)
if len(self.waypoints) > 3:
waypoint_text += "..."
return "任务:{0};起点:{1};出线:{2:.1f} mm折点:{3} 个;最近导线:{4};折点明细:{5}".format(
return "任务:{0};起点:{1};出线:{2:.1f} mm贴合间距:{3:.1f} mm折点{4} 个;最近导线:{5};折点明细:{6}".format(
task_text,
start_text,
self.terminal_exit_length,
self.contact_offset_mm,
len(self.waypoints),
wire_text,
waypoint_text,
@ -1889,6 +2016,12 @@ class ManualWiringTaskPanel:
self.exit_length_input.setSingleStep(5.0)
self.exit_length_input.setSuffix(" mm")
self.exit_length_input.setValue(self.controller.terminal_exit_length)
self.contact_offset_input = QtWidgets.QDoubleSpinBox()
self.contact_offset_input.setRange(-1000.0, 1000.0)
self.contact_offset_input.setDecimals(1)
self.contact_offset_input.setSingleStep(1.0)
self.contact_offset_input.setSuffix(" mm")
self.contact_offset_input.setValue(self.controller.contact_offset_mm)
self.carrier_length_input = QtWidgets.QDoubleSpinBox()
self.carrier_length_input.setRange(1.0, 10000.0)
self.carrier_length_input.setDecimals(1)
@ -1906,7 +2039,10 @@ class ManualWiringTaskPanel:
self.batch_breaker_button = QtWidgets.QPushButton("批量断路器")
self.set_contact_target_button = QtWidgets.QPushButton("设为贴合目标面")
self.align_faces_button = QtWidgets.QPushButton("贴合到选中面")
self.apply_contact_offset_button = QtWidgets.QPushButton("应用贴合间距")
self.reverse_contact_normal_button = QtWidgets.QPushButton("反转贴合方向")
self.refresh_mount_hosts_button = QtWidgets.QPushButton("刷新宿主装配")
self.native_transform_button = QtWidgets.QPushButton("关闭面板并变换")
self.waypoint_button = QtWidgets.QPushButton("添加折点")
self.delete_waypoint_button = QtWidgets.QPushButton("删除最后折点")
self.end_button = QtWidgets.QPushButton("设为终点并生成")
@ -1925,6 +2061,10 @@ class ManualWiringTaskPanel:
exit_layout.addWidget(QtWidgets.QLabel("手动端子出线长度"))
exit_layout.addWidget(self.exit_length_input)
layout.addLayout(exit_layout)
contact_offset_layout = QtWidgets.QHBoxLayout()
contact_offset_layout.addWidget(QtWidgets.QLabel("贴合间距"))
contact_offset_layout.addWidget(self.contact_offset_input)
layout.addLayout(contact_offset_layout)
carrier_length_layout = QtWidgets.QHBoxLayout()
carrier_length_layout.addWidget(QtWidgets.QLabel("载体长度"))
carrier_length_layout.addWidget(self.carrier_length_input)
@ -1945,7 +2085,10 @@ class ManualWiringTaskPanel:
layout.addLayout(batch_layout)
layout.addWidget(self.set_contact_target_button)
layout.addWidget(self.align_faces_button)
layout.addWidget(self.apply_contact_offset_button)
layout.addWidget(self.reverse_contact_normal_button)
layout.addWidget(self.refresh_mount_hosts_button)
layout.addWidget(self.native_transform_button)
layout.addWidget(self.start_button)
layout.addWidget(self.waypoint_button)
layout.addWidget(self.delete_waypoint_button)
@ -1971,6 +2114,7 @@ class ManualWiringTaskPanel:
self.use_task_button.clicked.connect(self.use_selected_task)
self.reload_tasks_button.clicked.connect(self._refresh_task_list)
self.exit_length_input.valueChanged.connect(self.set_exit_length)
self.contact_offset_input.valueChanged.connect(self.set_contact_offset)
self.import_duct_button.clicked.connect(self.import_wire_duct)
self.import_rail_button.clicked.connect(self.import_din_rail)
self.apply_carrier_length_button.clicked.connect(self.apply_carrier_length)
@ -1981,7 +2125,10 @@ class ManualWiringTaskPanel:
self.batch_breaker_button.clicked.connect(self.create_breakers)
self.set_contact_target_button.clicked.connect(self.set_contact_target_face)
self.align_faces_button.clicked.connect(self.align_selected_contact_faces)
self.apply_contact_offset_button.clicked.connect(self.apply_contact_offset)
self.reverse_contact_normal_button.clicked.connect(self.reverse_contact_normal)
self.refresh_mount_hosts_button.clicked.connect(self.refresh_mount_hosts)
self.native_transform_button.clicked.connect(self.launch_native_transform)
self.start_button.clicked.connect(self.set_start)
self.waypoint_button.clicked.connect(self.add_waypoint)
self.delete_waypoint_button.clicked.connect(self.delete_last_waypoint)
@ -2057,6 +2204,13 @@ class ManualWiringTaskPanel:
except Exception as exc:
self._set_error(str(exc))
def set_contact_offset(self, value):
try:
self.controller.set_contact_offset(value)
self._set_status(self.controller.state_text())
except Exception as exc:
self._set_error(str(exc))
def _select_carrier_asset_path(self, carrier_kind):
default_path = _builtin_carrier_asset_path(carrier_kind)
default_dir = str(Path(default_path).parent) if default_path else ""
@ -2188,6 +2342,27 @@ class ManualWiringTaskPanel:
except Exception as exc:
self._set_error(str(exc))
def apply_contact_offset(self):
try:
updated = self.controller.apply_contact_offset_to_selected_mounts(
self.contact_offset_input.value()
)
self._set_status(
"已对 {0} 个贴合对象应用间距 {1:.1f} mm。".format(
len(updated),
self.contact_offset_input.value(),
)
)
except Exception as exc:
self._set_error(str(exc))
def reverse_contact_normal(self):
try:
updated = self.controller.reverse_contact_normal_for_selected_mounts()
self._set_status("已反转 {0} 个贴合对象的间距方向。".format(len(updated)))
except Exception as exc:
self._set_error(str(exc))
def set_contact_target_face(self):
try:
target = self.controller.set_contact_target_from_selection()
@ -2203,6 +2378,12 @@ class ManualWiringTaskPanel:
except Exception as exc:
self._set_error(str(exc))
def launch_native_transform(self):
try:
self.controller.launch_native_transform_for_selection()
except Exception as exc:
self._set_error(str(exc))
def set_start(self):
try:
terminal = self.controller.set_start_from_selection()

@ -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:

@ -629,6 +629,9 @@ def import_terminals_from_payload(payload, scene_path=""):
_hide_object(source_obj)
report["reused_template_hints"] += 1
TerminalObjects.sort_group_children(terminal_group)
TerminalObjects.sort_group_children(root_group)
doc.recompute()
if Gui is not None:
try:

@ -3,6 +3,7 @@
import json
import math
import os
import re
from pathlib import Path
import FreeCAD as App
@ -45,6 +46,45 @@ def is_local_terminal_uuid(value):
return (value or "").strip().lower().startswith("local:")
def natural_sort_key(value):
text = str(value or "").strip().casefold()
parts = re.split(r"(\d+)", text)
key = []
for part in parts:
if not part:
continue
if part.isdigit():
key.append((0, int(part)))
else:
key.append((1, part))
return tuple(key)
def object_display_sort_key(obj):
label = (getattr(obj, "Label", "") or "").strip()
name = (getattr(obj, "Name", "") or "").strip()
return (natural_sort_key(label or name), natural_sort_key(name))
def sort_group_children(group):
children = list(getattr(group, "Group", []) or [])
if len(children) < 2:
return children
sorted_children = sorted(
enumerate(children),
key=lambda item: (object_display_sort_key(item[1]), item[0]),
)
ordered = [child for _index, child in sorted_children]
try:
group.Group = ordered
except Exception:
try:
group.Group[:] = ordered
except Exception:
return children
return ordered
def ensure_string_property(obj, prop_name, group_name, description, value):
if prop_name not in getattr(obj, "PropertiesList", []):
obj.addProperty("App::PropertyString", prop_name, group_name, description)

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

@ -57,9 +57,15 @@ def _install_fake_freecad():
)
sys.modules["FreeCAD"] = fake_freecad
selection_state = {"selection": [], "selection_ex": []}
selection_state = {
"selection": [],
"selection_ex": [],
"commands": [],
"control_events": [],
}
fake_freecadgui = types.ModuleType("FreeCADGui")
fake_freecadgui.addCommand = lambda *args, **kwargs: None
fake_freecadgui.runCommand = lambda command: selection_state["commands"].append(command)
fake_freecadgui.SendMsgToActiveView = lambda *args, **kwargs: None
def clear_selection():
selection_state["selection"] = []
@ -76,7 +82,7 @@ def _install_fake_freecad():
fake_freecadgui.Control = types.SimpleNamespace(
activeDialog=lambda: False,
showDialog=lambda panel: panel,
closeDialog=lambda: None,
closeDialog=lambda: selection_state["control_events"].append("closeDialog"),
)
sys.modules["FreeCADGui"] = fake_freecadgui
@ -643,6 +649,58 @@ class ManualWiringPanelTest(unittest.TestCase):
self.assertEqual(400.0, getattr(carrier, "QetCarrierLength", None))
self.assertEqual(2.0, getattr(carrier, "QetCarrierScaleX", None))
def test_controller_refreshes_face_contact_mount_when_changing_carrier_length(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
terminal_objects.ensure_root_group(doc, "project-1")
cabinet = doc.addObject("Part::Feature", "CabinetPanel")
carrier = doc.addObject("App::DocumentObjectGroup", "WireDuctCarrier")
cabinet.Placement = app.Placement(app.Vector(100, 0, 0), app.Rotation())
carrier.Placement = app.Placement(app.Vector(120, 0, 5), app.Rotation())
terminal_objects.ensure_string_property(
carrier,
"QetCarrierKind",
"QET Wiring",
"Carrier kind",
"wire_duct",
)
carrier.addProperty("App::PropertyFloat", "QetCarrierBaseLength", "QET Wiring", "Base length")
carrier.QetCarrierBaseLength = 200.0
terminal_objects.ensure_string_property(
carrier,
"QetMountMode",
"QET Assembly",
"QET cabinet assembly mount metadata",
"face_contact",
)
terminal_objects.ensure_string_property(
carrier,
"QetMountHostName",
"QET Assembly",
"QET cabinet assembly mount metadata",
"CabinetPanel",
)
terminal_objects.ensure_string_property(
carrier,
"QetMountLocalBaseJson",
"QET Assembly",
"QET cabinet assembly local base offset",
json.dumps({"x": 20.0, "y": 0.0, "z": 5.0}, ensure_ascii=False),
)
cabinet.Placement = app.Placement(app.Vector(130, 0, 0), app.Rotation())
selection_state["selection"] = [carrier]
updated = panel.ManualWiringController().apply_length_to_selected_carriers(500.0)
self.assertEqual([carrier], updated)
self.assertEqual(500.0, carrier.QetCarrierLength)
self.assertEqual((150.0, 0.0, 5.0), (carrier.Placement.Base.x, carrier.Placement.Base.y, carrier.Placement.Base.z))
self.assertEqual({"x": 130.0, "y": 0.0, "z": 0.0}, json.loads(carrier.QetMountHostBaseJson))
def test_controller_auto_marks_selected_wire_duct_by_name_before_length_change(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()
@ -797,6 +855,160 @@ class ManualWiringPanelTest(unittest.TestCase):
self.assertEqual("Face2", rail.QetMountContactSubElement)
self.assertEqual({"x": 0.0, "y": 0.0, "z": 0.0}, json.loads(rail.QetMountHostBaseJson))
self.assertEqual({"x": 0.0, "y": 0.0, "z": 1.0}, json.loads(rail.QetMountLocalBaseJson))
self.assertEqual({"x": 0.0, "y": 0.0, "z": 1.0}, json.loads(rail.QetMountHostNormalJson))
def test_controller_aligns_contact_faces_with_configured_normal_offset(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
terminal_objects.ensure_root_group(doc, "project-1")
cabinet = doc.addObject("Part::Feature", "CabinetPanel")
rail = doc.addObject("Part::Feature", "DINRail")
rail.Placement = app.Placement(app.Vector(0, 0, 10), app.Rotation())
target_face = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, 1),
)
moving_face = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, -1),
)
selection_state["selection_ex"] = [
types.SimpleNamespace(
PickedPoints=[app.Vector(100, 20, 0)],
SubObjects=[target_face],
SubElementNames=["Face1"],
Object=cabinet,
),
types.SimpleNamespace(
PickedPoints=[app.Vector(5, 6, 9)],
SubObjects=[moving_face],
SubElementNames=["Face2"],
Object=rail,
),
]
result = panel.ManualWiringController(contact_offset_mm=2.0).align_selected_contact_faces()
self.assertIs(rail, result["moving_object"])
self.assertEqual((0.0, 0.0, 3.0), (rail.Placement.Base.x, rail.Placement.Base.y, rail.Placement.Base.z))
self.assertEqual((-0.0, -0.0, -7.0), (result["translation"].x, result["translation"].y, result["translation"].z))
self.assertEqual(2.0, result["contact_offset_mm"])
self.assertEqual(2.0, rail.QetMountOffsetMm)
def test_controller_applies_contact_offset_to_selected_mounted_object(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
terminal_objects.ensure_root_group(doc, "project-1")
cabinet = doc.addObject("Part::Feature", "CabinetPanel")
rail = doc.addObject("Part::Feature", "DINRail")
rail.Placement = app.Placement(app.Vector(0, 0, 10), app.Rotation())
target_face = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, 1),
)
moving_face = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, -1),
)
selection_state["selection_ex"] = [
types.SimpleNamespace(
PickedPoints=[app.Vector(100, 20, 0)],
SubObjects=[target_face],
SubElementNames=["Face1"],
Object=cabinet,
),
types.SimpleNamespace(
PickedPoints=[app.Vector(5, 6, 9)],
SubObjects=[moving_face],
SubElementNames=["Face2"],
Object=rail,
),
]
controller = panel.ManualWiringController()
controller.align_selected_contact_faces()
selection_state["selection"] = [rail]
updated = controller.apply_contact_offset_to_selected_mounts(5.0)
self.assertEqual([rail], updated)
self.assertEqual((0.0, 0.0, 6.0), (rail.Placement.Base.x, rail.Placement.Base.y, rail.Placement.Base.z))
self.assertEqual(5.0, rail.QetMountOffsetMm)
self.assertEqual({"x": 0.0, "y": 0.0, "z": 6.0}, json.loads(rail.QetMountLocalBaseJson))
def test_controller_reverses_contact_normal_for_selected_mounted_object(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
terminal_objects.ensure_root_group(doc, "project-1")
cabinet = doc.addObject("Part::Feature", "CabinetPanel")
rail = doc.addObject("Part::Feature", "DINRail")
rail.Placement = app.Placement(app.Vector(0, 0, 10), app.Rotation())
target_face = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, 1),
)
moving_face = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, -1),
)
selection_state["selection_ex"] = [
types.SimpleNamespace(
PickedPoints=[app.Vector(100, 20, 0)],
SubObjects=[target_face],
SubElementNames=["Face1"],
Object=cabinet,
),
types.SimpleNamespace(
PickedPoints=[app.Vector(5, 6, 9)],
SubObjects=[moving_face],
SubElementNames=["Face2"],
Object=rail,
),
]
controller = panel.ManualWiringController()
controller.align_selected_contact_faces()
selection_state["selection"] = [rail]
reversed_objects = controller.reverse_contact_normal_for_selected_mounts()
updated = controller.apply_contact_offset_to_selected_mounts(5.0)
self.assertEqual([rail], reversed_objects)
self.assertEqual([rail], updated)
self.assertEqual({"x": -0.0, "y": -0.0, "z": -1.0}, json.loads(rail.QetMountHostNormalJson))
self.assertEqual((0.0, 0.0, -4.0), (rail.Placement.Base.x, rail.Placement.Base.y, rail.Placement.Base.z))
def test_controller_requires_saved_contact_normal_before_applying_offset(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
terminal_objects.ensure_root_group(doc, "project-1")
rail = doc.addObject("Part::Feature", "DINRail")
rail.Placement = app.Placement(app.Vector(0, 0, 1), app.Rotation())
terminal_objects.ensure_string_property(
rail,
"QetMountMode",
"QET Assembly",
"QET cabinet assembly mount metadata",
"face_contact",
)
selection_state["selection"] = [rail]
with self.assertRaisesRegex(panel.ManualWiringPanelError, "贴合法向"):
panel.ManualWiringController().apply_contact_offset_to_selected_mounts(3.0)
def test_refresh_mount_hosted_objects_moves_child_by_host_delta(self):
_install_fake_freecad()
@ -999,6 +1211,97 @@ class ManualWiringPanelTest(unittest.TestCase):
self.assertEqual((0.0, 0.0, 0.0), (child.Placement.Base.x, child.Placement.Base.y, child.Placement.Base.z))
self.assertEqual([device], selection_state["selection"])
def test_controller_rejects_multiple_moving_faces_after_target_face_is_set(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
terminal_objects.ensure_root_group(doc, "project-1")
cabinet = doc.addObject("Part::Feature", "CabinetPanel")
rail = doc.addObject("Part::Feature", "DINRail")
target_face = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, 1),
)
moving_face_a = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, -1),
)
moving_face_b = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, -1),
)
controller = panel.ManualWiringController()
selection_state["selection_ex"] = [
types.SimpleNamespace(
PickedPoints=[app.Vector(0, 0, 0)],
SubObjects=[target_face],
SubElementNames=["Face1"],
Object=cabinet,
)
]
controller.set_contact_target_from_selection()
selection_state["selection_ex"] = [
types.SimpleNamespace(
PickedPoints=[app.Vector(0, 0, 9), app.Vector(0, 1, 9)],
SubObjects=[moving_face_a, moving_face_b],
SubElementNames=["Face2", "Face3"],
Object=rail,
)
]
with self.assertRaisesRegex(panel.ManualWiringPanelError, "只选择一个"):
controller.align_selected_contact_faces()
def test_controller_moves_plain_app_part_parent_when_selected_face_belongs_to_child_shape(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
terminal_objects.ensure_root_group(doc, "project-1")
cabinet = doc.addObject("Part::Feature", "CabinetPanel")
assembly = doc.addObject("App::Part", "ImportedFcstdDevice")
child = doc.addObject("Part::Feature", "ImportedFcstdBody")
assembly.Placement = app.Placement(app.Vector(0, 0, 10), app.Rotation())
child.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation())
assembly.addObject(child)
target_face = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, 1),
)
moving_face = types.SimpleNamespace(
ShapeType="Face",
normalAt=lambda u, v: app.Vector(0, 0, -1),
)
selection_state["selection_ex"] = [
types.SimpleNamespace(
PickedPoints=[app.Vector(0, 0, 0)],
SubObjects=[target_face],
SubElementNames=["Face1"],
Object=cabinet,
),
types.SimpleNamespace(
PickedPoints=[app.Vector(0, 0, 9)],
SubObjects=[moving_face],
SubElementNames=["Face2"],
Object=child,
),
]
result = panel.ManualWiringController().align_selected_contact_faces()
self.assertIs(assembly, result["moving_object"])
self.assertEqual((0.0, 0.0, 1.0), (assembly.Placement.Base.x, assembly.Placement.Base.y, assembly.Placement.Base.z))
self.assertEqual((0.0, 0.0, 0.0), (child.Placement.Base.x, child.Placement.Base.y, child.Placement.Base.z))
self.assertEqual([assembly], selection_state["selection"])
def test_controller_requires_two_faces_for_contact_alignment(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()
@ -1558,6 +1861,39 @@ class ManualWiringPanelTest(unittest.TestCase):
self.assertEqual(("undo", ""), doc.transactions[-1])
def test_controller_closes_panel_and_launches_native_transform(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
terminal_objects.ensure_root_group(doc, "project-1")
device = doc.addObject("Part::Feature", "Device")
selection_state["selection"] = [device]
result = panel.ManualWiringController().launch_native_transform_for_selection()
self.assertTrue(result)
self.assertEqual(["closeDialog"], selection_state["control_events"])
self.assertEqual(["Std_TransformManip"], selection_state["commands"])
def test_controller_requires_selection_before_native_transform(self):
selection_state = _install_fake_freecad()
terminal_objects, panel = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
terminal_objects.ensure_root_group(doc, "project-1")
selection_state["selection"] = []
with self.assertRaisesRegex(panel.ManualWiringPanelError, "请选择要变换的对象"):
panel.ManualWiringController().launch_native_transform_for_selection()
self.assertEqual([], selection_state["control_events"])
self.assertEqual([], selection_state["commands"])
if __name__ == "__main__":
unittest.main()

@ -42,6 +42,7 @@ def _install_fake_freecad():
class FakeObject:
def __init__(self, name, type_id="App::DocumentObjectGroup"):
self.Name = name
self.Label = name
self.TypeId = type_id
self.Group = []
self.InList = []
@ -131,5 +132,24 @@ class TemplateTerminalVisibilityTest(unittest.TestCase):
self.assertTrue(engineering_terminal.ViewObject.Visibility)
class GroupSortingTest(unittest.TestCase):
def test_sort_group_children_uses_case_insensitive_natural_label_order(self):
_install_fake_freecad()
terminal_objects = _reload_module()
root = FakeObject("QETExchangeDevices")
for label in ["ID:10", "TAa", "id:2", "TAb", "C - 电容柜001", "ID:7"]:
child = FakeObject("QETDevice_" + label)
child.Label = label
root.addObject(child)
sorted_children = terminal_objects.sort_group_children(root)
self.assertEqual(
["C - 电容柜001", "id:2", "ID:7", "ID:10", "TAa", "TAb"],
[child.Label for child in sorted_children],
)
if __name__ == "__main__":
unittest.main()

Loading…
Cancel
Save