Merge remote-tracking branch 'origin/dev' into dev

dev
Zhaowenlong 4 weeks ago
commit 96bf3ffc60

@ -6,6 +6,7 @@
```text
工程端子
-> 检查/绑定 QET terminal_uuid
-> 路由路径网络
-> 自动求路
-> 生成 3D 折线导线
@ -19,11 +20,12 @@
当前版本只要求完成“能够自动布线”:
1. 能识别 FreeCAD 文档中的工程端子。
2. 能从用户指定的线槽、草图、面域创建路由网络。
3. 能在两个端子之间自动生成 3D 折线导线。
4. 能批量处理 QET 导入的导线任务。
5. 能绕开或至少发现明显障碍碰撞。
6. 默认禁止长距离悬空线。
2. 能在一键布线前检查并绑定工程端子,把可匹配的 `local:*` 模板端子提升为真实 QET `terminal_uuid`
3. 能从用户指定或识别到的线槽实体、机柜/安装板面域创建路由网络。
4. 能在两个端子之间自动生成 3D 折线导线。
5. 能批量处理 QET 导入的导线任务。
6. 能绕开或至少发现明显障碍碰撞。
7. 默认禁止长距离悬空线。
### 1.2 当前版本不做
@ -81,14 +83,17 @@ terminal_uuid
路由网络由 `carrier` 组成。一个 carrier 表示一段可走线中心线或辅助路由区域中的一条网格线。
构图时不要求所有 carrier 都提前手工打断。系统会识别轴向线段之间的几何相交和同线重叠,把交点/重叠端点自动切成图节点。这样多条线槽中心路径只要在空间中相交就可以在交点处换向Dijkstra 才能得到符合工程布线习惯的折线路径。
### 2.1 路由优先级
当前版本按下面优先级处理:
1. `WireDuct`:线槽中心路径,最高优先级。
2. `RoutingPath`用户画的草图线、Draft Wire、明确选中的路由边。
3. `AuxiliaryPath`:辅助路径,后续扩展使用。
4. `RoutingRange`:选中面生成的辅助路由区域,成本较高,只用于过渡或没有线槽时兜底。
2. `RoutingPath`:历史兼容和内部调试用的明确路由线,不作为当前正式入口。
3. `TerminalAccess`:端子到路由网络的自动接入路径,只用于把工程端子接入线槽/布线面。
4. `AuxiliaryPath`:辅助路径,后续扩展使用。
5. `RoutingRange`:柜面/安装板等支撑面生成的辅助路由区域,成本较高,只用于过渡或没有线槽时兜底。
普通机柜、设备外壳、实体边默认不是路由路径。
@ -108,6 +113,22 @@ QetProjectUuid = <project_uuid>
端子空间位置来自 FreeCAD 文档。自动布线使用端子的全局坐标和方向。
模板实例生成的端子可能先处于本地状态:
```text
QetTerminalUuid = local:<instance_id>:<slot>
QetTerminalBindingMode = local
```
这类端子只有空间槽位,没有 2D 电气语义,不能直接用于批量导线任务。正式布线前需要执行“检查/绑定工程端子”。系统会根据导线任务中的 `start/end_terminal_uuid`、`start/end_instance_id`、`start/end_element_uuid` 和端子显示号,在对应 3D 设备下查找 local 端子或模板槽位;匹配成功后写入:
```text
QetTerminalUuid = <QET terminal_uuid>
QetTerminalBindingMode = qet
```
如果导线任务缺少实例定位信息,或 QET 端子显示号与模板槽位名称不一致,绑定会跳过并给出诊断。
### 3.2 路由路径 Carrier
路由路径对象使用以下语义属性:
@ -238,25 +259,28 @@ network-dijkstra-v1
allow_floating_fallback = false
```
也就是说,如果没有可用路由网络,系统不会生成长距离悬空线,而是提示用户先创建线槽路径、草图路径或辅助路由区域。
也就是说,如果没有可用路由网络,系统不会生成长距离悬空线,而是提示用户先创建线槽中心路径或辅助路由区域。
调试时可以显式启用 fallback但正式使用不建议开启。
### 4.5 障碍物处理
当前版本使用 AABB 包围盒做碰撞诊断:
当前版本使用 AABB 包围盒做主动避障和碰撞诊断:
1. 收集 FreeCAD 文档中的几何对象。
2. 排除端子、carrier、已布线导线、原点辅助对象。
3. 排除标记为 `QetRoutingObstacleMode = PassThrough` 的线槽实体。
4. 检查导线线段是否与障碍包围盒相交。
5. 如果有碰撞,导线状态设为:
4. 在构建路由图时,先移除穿过障碍包围盒的 carrier 边。
5. 如果还有可连通路径Dijkstra 会选择绕开障碍的路径。
6. 如果所有可用路径都被障碍挡住,系统退回未过滤路由图并保留碰撞诊断。
7. 检查最终导线线段是否与障碍包围盒相交。
8. 如果仍有碰撞,导线状态设为:
```text
RouteStatus = "CollisionWarning"
```
当前版本是“碰撞诊断”,不是完整自由空间避障
当前版本是“路由网络上的主动避障”,不是完整自由空间避障。也就是说,它会在已有线槽/路由路径网络中绕开机柜和障碍物;如果模型中没有可绕行的 carrier它不会凭空生成新的自由空间通道
## 5. 当前已完成
@ -281,24 +305,36 @@ src/Mod/FreeCADExchange/InitGui.py
已完成:
1. 从选中草图线、Draft Wire、明确选中的边创建 `RoutingPath`
2. 从选中线槽实体生成 `WireDuct` 中心路径。
3. 从选中面生成 `RoutingRange` 辅助路由区域。
4. 清除已生成路由路径。
5. 扫描并统计路由网络。
1. 自动识别文档中的线槽模型并生成 `WireDuct` 中心路径。
2. 从选中线槽实体生成 `WireDuct` 中心路径,作为自动识别失败时的补充手段。
3. 自动把相交或重叠的 carrier 切成可连接节点,支持线槽交叉处自动拐弯。
4. 自动识别安装板、背板、门板、panel、mounting plate 等薄板对象并生成低优先级 `RoutingRange`
5. 自动生成 `TerminalAccess`,把工程端子接入最近的路由网络节点。
6. 清除已生成路由路径。
7. 扫描并统计路由网络。
8. 保留 `RoutingPath` 底层兼容能力,但当前面板不再暴露草图/线段补路入口。
自动识别线槽时,系统会同时检查对象语义/名称和几何比例。对象名或标签需要包含 `线槽`、`走线槽`、`wire duct`、`cable duct`、`trunking` 等线槽语义,并且包围盒需要呈明显细长比例。`机柜`、`柜体`、`door`、`panel`、`安装板` 等对象不会被当作线槽生成路径,仍按普通模型参与障碍检测。
自动识别柜面/安装板时,系统只接受有支撑面语义且包围盒呈薄板形态的对象。识别出的对象标记为 `QetRoutingObstacleMode = "SupportSurface"`,用于避免沿支撑面走线时误报碰撞;普通机柜整体和设备外壳仍默认作为障碍物。
生成布线布局空间时,系统按整份 FreeCAD 文档处理,不再要求用户选中某个面。它会先准备线槽/支撑面 carrier再按端子 LCS 的全局坐标和出线方向生成短的 `TerminalAccess` 接入 carrier。这样正式自动布线更接近 EPLAN / SOLIDWORKS Electrical 的“一键准备布局空间并布线”逻辑。
### 5.3 自动布线功能
已完成:
1. 两个选中端子之间自动布线。
2. 根据导线任务批量自动布线。
3. 使用 Dijkstra 求路。
4. 支持转弯惩罚。
5. 支持 carrier 类型成本。
6. 默认禁止长距离悬空线。
7. 碰撞检测和 `CollisionWarning` 状态。
8. 自动导线可见显示并保存到 FreeCAD 文档。
1. 检查/绑定工程端子,不生成导线。
2. 两个选中端子之间自动布线。
3. 根据导线任务批量自动布线。
4. 使用 Dijkstra 求路。
5. 支持在 carrier 交点、重叠段端点处自动换向。
6. 支持转弯惩罚。
7. 支持 carrier 类型成本。
8. 默认禁止长距离悬空线。
9. 支持基于障碍 AABB 的路由图边级主动避障。
10. 无安全替代路径时保留碰撞检测和 `CollisionWarning` 状态。
11. 自动导线可见显示并保存到 FreeCAD 文档。
### 5.4 FreeCAD 面板
@ -308,15 +344,12 @@ src/Mod/FreeCADExchange/InitGui.py
QET模板 -> 3D自动布线
```
当前按钮:
当前面板按 EPLAN / SOLIDWORKS Electrical 的使用习惯收敛为三步正式流程。测试性入口不再显示在面板上;`生成布线布局空间` 和 `自动布线` 都按整份 3D 装配执行,不再沿用“选中面/草图辅助路径”的测试流程。
```text
扫描端子/网络
从线槽实体生成中心路径
从线槽/草图创建路由路径
从选中面创建辅助路由区域
测试布线选中两个端子
按导线任务自动布线全部
生成布线网络路径
生成布线布局空间
自动布线
清除自动布线
清除走线路径
保存
@ -336,13 +369,22 @@ tests/python/freecad_exchange_auto_routing_test.py
2. 显式调试 fallback 时生成正交路径。
3. 优先使用用户路由网络。
4. 优先使用 `WireDuct`,降低 `RoutingRange` 优先级。
5. 从选中面生成辅助路由区域
5. 自动生成端子接入路由网络的 `TerminalAccess`
6. 选中整个实体不会把所有外壳边转成路径。
7. 从线槽实体生成中心路径。
8. 线槽实体不作为自身路径碰撞障碍。
9. 碰撞状态标记。
10. 批量导线任务缺端子时跳过。
11. 清除路由路径不删除已布线导线。
12. 只执行工程端子绑定时不会生成导线。
13. 一键布线前自动把匹配到的 `local:*` 端子提升为 QET 工程端子。
14. 自动识别线槽模型生成中心路径,并避免把机柜模型误判为线槽。
15. 自动识别安装板/柜面生成 `RoutingRange`,并把支撑面标记为 `SupportSurface`
16. 无线槽或线槽不完整时,可使用自动识别的支撑面辅助路径完成贴面布线。
17. 面板流程已简化为“生成布线网络路径 -> 生成布线布局空间 -> 自动布线”。
18. “生成布线网络路径”在有选择时从选中的线槽实体生成中心路径;没有选择时自动识别整份文档。
19. “生成布线布局空间”始终按整份文档准备布局空间:自动识别线槽/支撑面,并生成端子接入 carrier。
20. “自动布线”会先执行同一套布局空间准备逻辑,再按全部 QET 导线任务批量求路。
已完成 FreeCAD smoke
@ -352,40 +394,47 @@ tests/manual/freecad_auto_routing_smoke.py
## 6. 当前使用流程
### 6.1 单条导线自动布线
### 6.1 简化正式流程
当前版本不再生成内置演示场景,也不把单端子测试作为正式入口。正式流程面向真实 FreeCAD 文档:
```text
1. 打开 FreeCAD 工程 scene.FCStd
2. 进入 QET模板 -> 3D自动布线
3. 清除走线路径
4. 选中线槽实体
5. 点击“从线槽实体生成中心路径”
6. 必要时选草图线或 Draft Wire点击“从线槽/草图创建路由路径”
7. 必要时选背板/门板面,点击“从选中面创建辅助路由区域”
8. 选中两个工程端子
9. 点击“测试布线选中两个端子”
3. 清除自动布线
4. 清除走线路径
5. 可选:全选或选中线槽实体后点击“生成布线网络路径”;如果不选择,则使用整份文档自动识别
6. 点击“生成布线网络路径”
7. 点击“生成布线布局空间”
8. 点击“自动布线”
```
### 6.2 批量导线自动布线
三个按钮的职责:
```text
生成布线网络路径:生成 WireDuct 中心线 carrier
生成布线布局空间:按整份装配准备线槽、支撑面和端子接入网络
自动布线:先准备布局空间,再自动检查/绑定工程端子,按 QET 导线任务批量求路并生成 AutoSuggested 导线
```
前提:
如果模型名称/标签足够规范,可以不手动选择,直接执行三步;也可以只点击“自动布线”,系统会自动准备当前可识别的布线网络和布局空间。若线槽无法自动识别,则先选中线槽实体执行“生成布线网络路径”作为补充。
### 6.2 批量导线自动布线前提
1. QET 导出的 `2d_to_3d.json` 中包含 `wires[]`
2. 每条导线包含:
```text
start_instance_id 或 start_element_uuid
start_terminal_uuid
start_terminal_display
end_instance_id 或 end_element_uuid
end_terminal_uuid
end_terminal_display
```
3. FreeCAD 文档中存在对应 `QetTerminalUuid` 的工程端子。
操作:
```text
1. 创建线槽/路由网络
2. 点击“按导线任务自动布线全部”
```
3. FreeCAD 文档中存在对应 `QetTerminalUuid` 的工程端子,或存在可按设备和端子显示号匹配的 `local:*` 模板端子。
4. 自动布线只按导线任务布线,不会把场景里所有端子任意两两相连。
注意:批量自动布线的依据是导线任务,不是“所有端子自动互连”。如果文档中只有端子而没有 `wires[]``QETWiring_01_Tasks`,系统不能判断哪些端子应该连接。
@ -466,9 +515,9 @@ PE 线优先路径
禁布区域
```
5. 更智能的线槽识别
5. 更完整的线槽/机柜模型识别
根据对象名称、尺寸比例、设备库语义自动识别线槽,而不完全依赖用户选择
当前已能按名称和薄板比例识别线槽、安装板、背板、门板和 panel。后续需要接入设备库语义进一步区分完整机柜、可布线柜面、禁布区域、可开门区域和普通设备外壳
### 8.3 长期能力
@ -484,15 +533,19 @@ PE 线优先路径
当前版本验收只看“能否自动布线”:
1. 文档中有至少两个工程端子。
2. 文档中有至少一条 `WireDuct``RoutingPath` carrier。
3. 选择两个端子后执行单线测试布线,能生成 `AutoSuggested` 导线。
4. 存在导线任务时执行“按导线任务自动布线全部”,能批量生成 `AutoSuggested` 导线。
5. 生成导线在 `QETWiring_04_Routed` 下可见。
6. 没有路由网络时不生成长距离悬空线。
7. 没有导线任务时,批量布线明确提示缺少连接关系。
8. 明显碰撞时状态为 `CollisionWarning`
9. 保存 FreeCAD 文档后,自动导线和路由网络仍保留。
1. 文档中有至少两个真实工程端子。
2. 文档中有至少一条 `WireDuct` carrier或有可作为低优先级路径的 `RoutingRange` 支撑面 carrier。
3. 执行“生成布线网络路径”后,能生成 `WireDuct` carrier。
4. 执行“生成布线布局空间”后,能生成或复用 `WireDuct` / `RoutingRange` carrier并为工程端子生成 `TerminalAccess` 接入 carrier。
5. 存在导线任务时执行“自动布线”,会先准备布局空间,再批量生成 `AutoSuggested` 导线。
6. 生成导线在 `QETWiring_04_Routed` 下可见。
7. 没有路由网络时正式布线不生成长距离悬空线。
8. 没有导线任务时,批量布线明确提示缺少连接关系。
9. 有备选 carrier 时,明显障碍会被绕开,生成导线状态为 `Routed`
10. 没有备选 carrier 时,明显碰撞状态为 `CollisionWarning`
11. 两条相交或重叠的线槽中心路径能在交点/重叠端点处连通并自动拐弯。
12. 自动识别出的安装板/柜面能生成低优先级 `RoutingRange`,并可被自动布线使用。
13. 保存 FreeCAD 文档后,自动导线和路由网络仍保留。
## 10. 开发验证命令

@ -5,6 +5,7 @@
# 然后在 QETWiring_04_Routed 下生成一条可见的折线导线。
import json
import math
import FreeCAD as App
@ -15,6 +16,7 @@ except ImportError:
import RoutingNetwork
import TerminalObjects
import TemplateSemantics
import WiringObjects
@ -28,7 +30,7 @@ DEFAULT_OPTIONS = {
"lane_spacing": 10.0,
# 线槽网络相关参数。
"use_routing_network": True,
"network_entry_max_distance": 0.0,
"network_entry_max_distance": 1000.0,
"bend_penalty": 25.0,
# EPLAN/SOLIDWORKS 风格:线槽/路由路径最优先,辅助面域只作为过渡/兜底区域。
"carrier_kind_cost_factors": {
@ -36,6 +38,7 @@ DEFAULT_OPTIONS = {
"RoutingPath": 1.0,
"UserPath": 1.0,
"AuxiliaryPath": 2.0,
"TerminalAccess": 2.0,
"RoutingRange": 8.0,
"SurfaceGrid": 8.0,
},
@ -43,6 +46,12 @@ DEFAULT_OPTIONS = {
"allow_floating_fallback": False,
# 障碍包围盒会按这个距离膨胀,用于提前发现贴碰风险。
"obstacle_clearance": 5.0,
# 防止坐标异常或端子离路由网络过远时生成超长接入线,把 FreeCAD
# 视图包围盒拉得过大,导致旋转时模型被裁剪到看不见。
"terminal_access_max_distance": 1000.0,
# 先把穿过障碍包围盒的路由网络边从 Dijkstra 图中移除;如果没有安全
# 替代路径,再退回原图并用 CollisionWarning 告诉用户当前网络不足。
"avoid_obstacles": True,
"replace_existing": True,
}
@ -93,8 +102,20 @@ def _point_payload(point):
}
def _is_finite_point(point):
try:
return all(
math.isfinite(float(getattr(point, axis, 0.0)))
for axis in ("x", "y", "z")
)
except Exception:
return False
def _append_unique(points, point):
vector = _vector(point)
if not _is_finite_point(vector):
return
if not points or not _vector_close(points[-1], vector):
points.append(vector)
@ -204,6 +225,222 @@ def index_terminals(doc):
return indexed
def _normalized_match_token(value):
return (value or "").strip().lower().replace(" ", "")
def _device_group_for_wire_endpoint(doc, instance_id, element_uuid):
device_group = TerminalObjects.find_device_group_by_instance_id(doc, instance_id)
if device_group is None:
device_group = TerminalObjects.find_device_group(doc, element_uuid)
return device_group
def _terminal_match_tokens(obj):
tokens = []
for value in (
getattr(obj, "QetTemplateSlotName", ""),
getattr(obj, "QetTerminalLabel", ""),
getattr(obj, "Label", ""),
getattr(obj, "Name", ""),
):
token = _normalized_match_token(value)
if token and token not in tokens:
tokens.append(token)
return tokens
def _slot_match_tokens(slot):
tokens = []
for value in (
slot.get("name", ""),
slot.get("label", ""),
):
token = _normalized_match_token(value)
if token and token not in tokens:
tokens.append(token)
return tokens
def _matching_local_terminal(terminal_group, terminal_display, used_objects):
local_terminals = []
display_token = _normalized_match_token(terminal_display)
for terminal in TerminalObjects.collect_terminal_objects(terminal_group):
if terminal in used_objects:
continue
terminal_uuid = (getattr(terminal, "QetTerminalUuid", "") or "").strip()
if not TerminalObjects.is_local_terminal_uuid(terminal_uuid):
continue
local_terminals.append(terminal)
if display_token and display_token in _terminal_match_tokens(terminal):
return terminal
if not display_token and len(local_terminals) == 1:
return local_terminals[0]
return None
def _matching_template_slot(device_group, terminal_display, used_slot_tokens):
display_token = _normalized_match_token(terminal_display)
if not display_token:
return None
for slot in TemplateSemantics.collect_terminal_hints(device_group):
slot_tokens = _slot_match_tokens(slot)
if display_token not in slot_tokens:
continue
slot_token = slot_tokens[0] if slot_tokens else ""
if slot_token and slot_token in used_slot_tokens:
continue
return slot
return None
def _slot_placement(slot):
base = slot.get("base")
if not isinstance(base, App.Vector):
base = App.Vector(0, 0, 0)
rotation = App.Rotation()
rotation_value = slot.get("rotation")
if isinstance(rotation_value, dict):
axis = rotation_value.get("axis")
angle = rotation_value.get("angle")
if isinstance(axis, App.Vector) and angle is not None:
try:
rotation = App.Rotation(axis, float(angle))
except Exception:
rotation = App.Rotation()
return App.Placement(base, rotation)
def _wire_endpoint_entries(payload):
entries = []
seen = set()
for item in payload.get("wires", []) or []:
if not isinstance(item, dict):
continue
for prefix in ("start", "end"):
terminal_uuid = _wire_item_value(item, "{0}_terminal_uuid".format(prefix))
if not terminal_uuid or TerminalObjects.is_local_terminal_uuid(terminal_uuid):
continue
if terminal_uuid in seen:
continue
seen.add(terminal_uuid)
entries.append(
{
"terminal_uuid": terminal_uuid,
"element_uuid": _wire_item_value(item, "{0}_element_uuid".format(prefix)),
"instance_id": _wire_item_value(item, "{0}_instance_id".format(prefix)),
"terminal_display": _wire_item_value(
item,
"{0}_terminal_display".format(prefix),
"{0}_terminal_label".format(prefix),
),
}
)
return entries
def _bind_wire_task_terminals(doc, payload):
"""Promote matching local template terminals to QET terminal UUIDs before routing."""
report = {
"bound": 0,
"created": 0,
"skipped": 0,
"warnings": [],
}
if doc is None or not isinstance(payload, dict):
return report
project_uuid = (payload.get("project_uuid") or "").strip()
if not project_uuid:
try:
project_uuid = (getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "") or "").strip()
except Exception:
project_uuid = ""
indexed = index_terminals(doc)
used_objects = set()
used_slot_tokens = set()
for entry in _wire_endpoint_entries(payload):
terminal_uuid = entry["terminal_uuid"]
if terminal_uuid in indexed:
continue
device_group = _device_group_for_wire_endpoint(
doc,
entry.get("instance_id", ""),
entry.get("element_uuid", ""),
)
if device_group is None:
report["skipped"] += 1
report["warnings"].append(
"端子 {0} 找不到所属 3D 设备实例。".format(terminal_uuid)
)
continue
instance_id = (getattr(device_group, "QetInstanceId", "") or "").strip()
element_uuid = (getattr(device_group, "QetElementUuid", "") or "").strip()
terminal_group = TerminalObjects.ensure_terminal_group(
doc,
device_group,
project_uuid=project_uuid,
instance_id=instance_id,
)
terminal_display = entry.get("terminal_display", "")
terminal_obj = _matching_local_terminal(terminal_group, terminal_display, used_objects)
if terminal_obj is None:
slot = _matching_template_slot(device_group, terminal_display, used_slot_tokens)
if slot is None:
report["skipped"] += 1
report["warnings"].append(
"端子 {0} 没有匹配到模板槽位 {1}".format(
terminal_uuid,
terminal_display or "<empty>",
)
)
continue
slot_name = (slot.get("name") or terminal_display or terminal_uuid).strip()
terminal_obj = TerminalObjects.create_lcs_object(
doc,
"QETTerminal_{0}".format(TerminalObjects.safe_token(terminal_uuid)),
placement=_slot_placement(slot),
label=terminal_display or terminal_uuid,
)
terminal_group.addObject(terminal_obj)
source_obj = slot.get("source_object")
if source_obj is not None:
try:
source_obj.ViewObject.Visibility = False
except Exception:
pass
report["created"] += 1
else:
slot_name = (getattr(terminal_obj, "QetTemplateSlotName", "") or terminal_display).strip()
report["bound"] += 1
TerminalObjects.set_terminal_semantics(
terminal_obj,
project_uuid,
element_uuid,
terminal_uuid,
instance_id,
label=terminal_display or getattr(terminal_obj, "Label", "") or terminal_uuid,
slot_name=slot_name,
)
used_objects.add(terminal_obj)
slot_token = _normalized_match_token(slot_name)
if slot_token:
used_slot_tokens.add(slot_token)
if report["bound"] or report["created"]:
try:
doc.recompute()
except Exception:
pass
return report
def _wire_object_name(start_terminal, end_terminal, wire_uuid=""):
if wire_uuid:
return "QETAutoWire_{0}".format(TerminalObjects.safe_token(wire_uuid))
@ -224,6 +461,19 @@ def _unique_name(doc, base_name):
def _create_wire_geometry(doc, name, points):
# Use a plain Part edge for generated auto wires. Draft wires are convenient for
# editing, but in large imported assemblies they can trigger view-provider redraw
# glitches while rotating the 3D scene.
try:
import Part
obj = doc.addObject("Part::Feature", name)
obj.Shape = Part.makePolygon(points)
_set_points(obj, points)
return obj
except Exception:
pass
if getattr(App, "ActiveDocument", None) is doc:
try:
import Draft
@ -232,20 +482,21 @@ def _create_wire_geometry(doc, name, points):
points,
closed=False,
placement=None,
face=None,
face=False,
support=None,
bs2wire=False,
)
if obj is not None:
try:
obj.MakeFace = False
except Exception:
pass
_set_points(obj, points)
return obj
except Exception:
pass
import Part
obj = doc.addObject("Part::Feature", name)
obj.Shape = Part.makePolygon(points)
obj = doc.addObject("App::FeaturePython", name)
_set_points(obj, points)
return obj
@ -344,62 +595,79 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non
if doc is None:
return None
network = RoutingNetwork.build_route_graph(doc)
if network.get("segment_count", 0) <= 0:
return None
exit_length = max(float(opts.get("terminal_exit_length", 0.0) or 0.0), 0.0)
start_origin = _terminal_origin(start_terminal)
end_origin = _terminal_origin(end_terminal)
start_exit = _offset(start_origin, _terminal_direction(start_terminal), exit_length)
end_exit = _offset(end_origin, _terminal_direction(end_terminal), exit_length)
start_key, start_distance = RoutingNetwork.nearest_node(network, start_exit)
end_key, end_distance = RoutingNetwork.nearest_node(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
):
return None
def route_on_network(network, obstacle_aware=False):
if network.get("segment_count", 0) <= 0:
return None
start_key, start_distance = RoutingNetwork.nearest_node(network, start_exit)
end_key, end_distance = RoutingNetwork.nearest_node(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
):
return None
path_keys = RoutingNetwork.shortest_path(
network,
start_key,
end_key,
bend_penalty=float(opts.get("bend_penalty", 0.0) or 0.0),
kind_cost_factors=opts.get("carrier_kind_cost_factors", {}),
)
if not path_keys:
return None
carrier_points = RoutingNetwork.path_points(network, path_keys)
if not carrier_points:
return None
points = []
_append_unique(points, start_origin)
_append_unique(points, start_exit)
_append_orthogonal(points, carrier_points[0])
for point in carrier_points[1:]:
_append_unique(points, point)
_append_orthogonal(points, end_exit)
_append_unique(points, end_origin)
path_keys = RoutingNetwork.shortest_path(
network,
start_key,
end_key,
bend_penalty=float(opts.get("bend_penalty", 0.0) or 0.0),
kind_cost_factors=opts.get("carrier_kind_cost_factors", {}),
)
if not path_keys:
return None
return {
"algorithm": "network-dijkstra-v1",
"points": points,
"network": {
"carriers": int(network.get("carrier_count", 0)),
"segments": int(network.get("segment_count", 0)),
"blocked_segments": int(network.get("blocked_segment_count", 0)),
"nodes": len(network.get("nodes", {})),
"entry_distance": float(start_distance or 0.0),
"exit_distance": float(end_distance or 0.0),
"obstacle_aware": bool(obstacle_aware),
},
}
carrier_points = RoutingNetwork.path_points(network, path_keys)
if not carrier_points:
return None
use_obstacle_avoidance = bool(opts.get("avoid_obstacles", True))
obstacles = []
if use_obstacle_avoidance:
obstacles = collect_obstacles(doc, exclude=[start_terminal, end_terminal], options=opts)
blocked_bboxes = [obstacle["bbox"] for obstacle in obstacles if obstacle.get("bbox")]
points = []
_append_unique(points, start_origin)
_append_unique(points, start_exit)
_append_orthogonal(points, carrier_points[0])
for point in carrier_points[1:]:
_append_unique(points, point)
_append_orthogonal(points, end_exit)
_append_unique(points, end_origin)
if blocked_bboxes:
obstacle_aware_network = RoutingNetwork.build_route_graph(doc, blocked_bboxes=blocked_bboxes)
route_data = route_on_network(obstacle_aware_network, obstacle_aware=True)
if route_data is not None:
return route_data
return {
"algorithm": "network-dijkstra-v1",
"points": points,
"network": {
"carriers": int(network.get("carrier_count", 0)),
"segments": int(network.get("segment_count", 0)),
"nodes": len(network.get("nodes", {})),
"entry_distance": float(start_distance or 0.0),
"exit_distance": float(end_distance or 0.0),
},
}
network = RoutingNetwork.build_route_graph(doc)
return route_on_network(network, obstacle_aware=False)
def _is_group(obj):
@ -453,7 +721,7 @@ def collect_obstacles(doc, exclude=None, options=None):
if id(obj) in excluded:
continue
obstacle_mode = (getattr(obj, "QetRoutingObstacleMode", "") or "").strip()
if obstacle_mode in {"PassThrough", "WireDuctPassThrough"}:
if obstacle_mode in {"PassThrough", "WireDuctPassThrough", "SupportSurface"}:
continue
if _is_group(obj) or _is_origin_helper(obj):
continue
@ -577,7 +845,11 @@ def _set_task_status(task, status):
def _style_wire(wire, collision_count=0):
try:
wire.ViewObject.Visibility = True
wire.ViewObject.LineWidth = 3.0
wire.ViewObject.LineWidth = 5.0
if hasattr(wire.ViewObject, "DrawStyle"):
wire.ViewObject.DrawStyle = "Solid"
if hasattr(wire.ViewObject, "DisplayMode"):
wire.ViewObject.DisplayMode = "Wireframe"
if collision_count:
wire.ViewObject.LineColor = (1.0, 0.1, 0.0)
else:
@ -628,7 +900,7 @@ def route_between_terminals(
if route_data is None:
if not opts.get("allow_floating_fallback", False):
raise AutoRoutingError(
"没有可用的线槽/路由路径网络;请先选择线槽中心线、草图路径或必要的辅助路由区域"
"没有可用的线槽/路由路径网络;请先自动识别线槽生成路径,或选择线槽实体生成中心路径。"
)
route_data = build_orthogonal_route(
start_terminal,
@ -688,6 +960,7 @@ def route_between_terminals(
"wire": wire,
"route_status": status,
"algorithm": route_data.get("algorithm", ""),
"network": route_data.get("network", {}),
"points": points,
"collision_count": len(collisions),
"collisions": collisions,
@ -704,23 +977,79 @@ def _wire_item_value(item, *names):
return ""
def bind_wire_task_terminals_from_payload(doc, payload):
"""Bind local template terminals to QET terminal UUIDs without creating wires."""
if doc is None:
raise AutoRoutingError("No FreeCAD document is available.")
if not isinstance(payload, dict):
raise AutoRoutingError("Exchange payload must be an object.")
binding_report = _bind_wire_task_terminals(doc, payload)
terminals = index_terminals(doc)
wires = payload.get("wires", []) or []
endpoints = _wire_endpoint_entries(payload)
binding_report.update(
{
"total_wires": len(wires),
"endpoint_terminals": len(endpoints),
"available_terminals": len(terminals),
"local_terminals": sum(
1
for terminal_uuid in terminals
if TerminalObjects.is_local_terminal_uuid(terminal_uuid)
),
}
)
return binding_report
def format_terminal_binding_report(report):
message = "工程端子检查/绑定完成:更新 {0} 个,新建 {1} 个,跳过 {2} 个;当前端子 {3} 个,本地端子 {4} 个。".format(
report.get("bound", 0),
report.get("created", 0),
report.get("skipped", 0),
report.get("available_terminals", 0),
report.get("local_terminals", 0),
)
warnings = report.get("warnings", []) or []
if warnings:
message += "\n首个问题:{0}".format(warnings[0])
if report.get("total_wires", 0) <= 0:
message += "\n没有导线任务,无法按 QET terminal_uuid 绑定工程端子。"
return message
def route_all_from_payload(doc, payload, options=None):
if doc is None:
raise AutoRoutingError("No FreeCAD document is available.")
if not isinstance(payload, dict):
raise AutoRoutingError("Exchange payload must be an object.")
terminal_binding_report = bind_wire_task_terminals_from_payload(doc, payload)
terminals = index_terminals(doc)
local_terminal_count = sum(
1
for terminal_uuid in terminals
if TerminalObjects.is_local_terminal_uuid(terminal_uuid)
)
wires = payload.get("wires", []) or []
report = {
"total_wires": len(wires),
"available_terminals": len(terminals),
"local_terminals": local_terminal_count,
"auto_bound_terminals": terminal_binding_report["bound"],
"auto_created_terminals": terminal_binding_report["created"],
"auto_terminal_binding_warnings": terminal_binding_report["warnings"],
"routed": 0,
"collision_warnings": 0,
"skipped_missing_terminal": 0,
"skipped_invalid": 0,
"missing_endpoint_uuids": [],
"missing_endpoint_samples": [],
"errors": [],
"routes": [],
}
missing_endpoint_uuids = set()
for index, item in enumerate(wires):
if not isinstance(item, dict):
@ -732,6 +1061,25 @@ def route_all_from_payload(doc, payload, options=None):
end_terminal = terminals.get(end_uuid)
if start_terminal is None or end_terminal is None:
report["skipped_missing_terminal"] += 1
for terminal_uuid in (start_uuid, end_uuid):
if terminal_uuid and terminal_uuid not in terminals:
missing_endpoint_uuids.add(terminal_uuid)
# 这里只保留少量样例,避免面板状态被大量导线任务刷屏。
if len(report["missing_endpoint_samples"]) < 8:
report["missing_endpoint_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_found": start_terminal is not None,
"start_element_uuid": _wire_item_value(item, "start_element_uuid"),
"start_terminal_display": _wire_item_value(item, "start_terminal_display"),
"end_terminal_uuid": end_uuid,
"end_found": end_terminal is not None,
"end_element_uuid": _wire_item_value(item, "end_element_uuid"),
"end_terminal_display": _wire_item_value(item, "end_terminal_display"),
}
)
continue
try:
result = route_between_terminals(
@ -761,9 +1109,49 @@ def route_all_from_payload(doc, payload, options=None):
"collision_count": result["collision_count"],
}
)
report["missing_endpoint_uuids"] = sorted(missing_endpoint_uuids)
return report
def format_route_all_report(report):
message = "批量自动布线完成routed={0}, collision_warnings={1}, missing_terminals={2}".format(
report.get("routed", 0),
report.get("collision_warnings", 0),
report.get("skipped_missing_terminal", 0),
)
prepared_layout = report.get("prepared_layout")
if isinstance(prepared_layout, dict):
message += "\n布线布局空间:线槽路径 {0} 条,布线面 {1} 条,端子接入 {2} 条。".format(
prepared_layout.get("wire_duct_carriers", 0),
prepared_layout.get("surface_carriers", 0),
prepared_layout.get("terminal_access_carriers", 0),
)
auto_bound = report.get("auto_bound_terminals", 0)
auto_created = report.get("auto_created_terminals", 0)
if auto_bound or auto_created:
message += "\n已按导线任务绑定 3D 工程端子:更新 {0} 个,新建 {1} 个。".format(
auto_bound,
auto_created,
)
if report.get("routed", 0) == 0 and report.get("skipped_missing_terminal", 0) > 0:
message += (
"\n端子匹配失败:当前 3D 可布线端子 {0} 个,其中本地模板端子 {1} 个;"
"导线任务引用的 QET terminal_uuid 没有绑定到这些 3D 工程端子。"
).format(
report.get("available_terminals", 0),
report.get("local_terminals", 0),
)
if report.get("local_terminals", 0) > 0:
message += " 请先从 QET 重新导入/更新工程端子,使端子 UUID 不再是 local:...。"
sample = (report.get("missing_endpoint_samples") or [None])[0]
if sample:
message += "\n缺失示例:{0} -> {1}".format(
sample.get("start_terminal_uuid", ""),
sample.get("end_terminal_uuid", ""),
)
return message
def _iter_wire_tasks(doc):
try:
task_group = doc.getObject("QETWiring_01_Tasks")
@ -778,8 +1166,8 @@ def _iter_wire_tasks(doc):
]
def route_all_tasks(doc, options=None):
payload = {"wires": []}
def _wire_tasks_payload(doc):
payload = {"project_uuid": _project_uuid(doc), "wires": []}
for task in _iter_wire_tasks(doc):
payload["wires"].append(
{
@ -789,13 +1177,52 @@ def route_all_tasks(doc, options=None):
"wire_mark_is_manual": bool(getattr(task, "QetWireMarkIsManual", False)),
"net_uuid": (getattr(task, "QetNetUuid", "") or "").strip(),
"group_uuid": (getattr(task, "QetGroupUuid", "") or "").strip(),
"start_element_uuid": (getattr(task, "QetStartElementUuid", "") or "").strip(),
"start_instance_id": (getattr(task, "QetStartInstanceId", "") or "").strip(),
"start_terminal_uuid": (getattr(task, "QetStartTerminalUuid", "") or "").strip(),
"start_terminal_display": (getattr(task, "QetStartTerminalDisplay", "") or "").strip(),
"end_element_uuid": (getattr(task, "QetEndElementUuid", "") or "").strip(),
"end_instance_id": (getattr(task, "QetEndInstanceId", "") or "").strip(),
"end_terminal_uuid": (getattr(task, "QetEndTerminalUuid", "") or "").strip(),
"end_terminal_display": (getattr(task, "QetEndTerminalDisplay", "") or "").strip(),
}
)
return payload
def bind_wire_task_terminals_from_tasks(doc):
return bind_wire_task_terminals_from_payload(doc, _wire_tasks_payload(doc))
def route_all_tasks(doc, options=None):
payload = _wire_tasks_payload(doc)
return route_all_from_payload(doc, payload, options=options)
def prepare_eplan_style_layout(doc, project_uuid="", options=None):
"""Prepare the whole document for production auto-routing.
EPLAN/SW 的操作语义是对布线布局空间执行布线不是要求用户先点面
画草图或手工补每个端子的接入线这里统一生成线槽中心路径柜内
可布线面以及端子到路由网络的自动接入 carrier
"""
if doc is None:
raise AutoRoutingError("No FreeCAD document is available.")
opts = _merged_options(options)
target_project_uuid = (project_uuid or "").strip() or _project_uuid(doc)
if not target_project_uuid:
try:
target_project_uuid = (getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "") or "").strip()
except Exception:
target_project_uuid = ""
return RoutingNetwork.create_layout_space_from_document(
doc,
project_uuid=target_project_uuid,
terminal_exit_length=float(opts.get("terminal_exit_length", 20.0) or 0.0),
terminal_access_max_distance=float(opts.get("terminal_access_max_distance", 1000.0) or 0.0),
)
def wire_task_count(doc):
return len(_iter_wire_tasks(doc))
@ -817,15 +1244,6 @@ def clear_auto_routes(doc):
return removed
def _selected_terminals():
if Gui is None:
return []
try:
return [obj for obj in Gui.Selection.getSelection() if TerminalObjects.is_terminal_object(obj)]
except Exception:
return []
def _console_message(message):
try:
App.Console.PrintMessage("[FreeCADExchange] {0}\n".format(message))
@ -840,44 +1258,11 @@ def _console_error(message):
pass
class CommandAutoRouteSelected:
def GetResources(self):
return {
"MenuText": "测试布线选中两个端子",
"ToolTip": "单条连接测试:只在两个选中的 QET 3D 工程端子之间生成自动折线导线",
}
def IsActive(self):
return getattr(App, "ActiveDocument", None) is not None and Gui is not None
def Activated(self):
doc = getattr(App, "ActiveDocument", None)
terminals = _selected_terminals()
if len(terminals) != 2:
_console_error("请先选中两个可布线工程端子。")
return
try:
result = route_between_terminals(doc, terminals[0], terminals[1])
_console_message(
"自动布线完成algorithm={0}, status={1}, collisions={2}".format(
result["algorithm"],
result["route_status"],
result["collision_count"],
)
)
try:
Gui.SendMsgToActiveView("ViewFit")
except Exception:
pass
except Exception as exc:
_console_error("自动布线失败:{0}".format(exc))
class CommandAutoRouteAll:
def GetResources(self):
return {
"MenuText": "按导线任务自动布线全部",
"ToolTip": "根据 QET 导线任务批量生成 3D 自动布线路径",
"MenuText": "一键自动布线(全部导线)",
"ToolTip": "自动识别线槽/安装板并生成全部 3D 布线路径",
}
def IsActive(self):
@ -886,21 +1271,18 @@ class CommandAutoRouteAll:
def Activated(self):
doc = getattr(App, "ActiveDocument", None)
try:
prepared_layout = prepare_eplan_style_layout(doc)
payload = getattr(App, "_qet_exchange_payload", None)
if isinstance(payload, dict) and payload.get("wires"):
report = route_all_from_payload(doc, payload)
else:
report = route_all_tasks(doc)
report["prepared_layout"] = prepared_layout
if report.get("total_wires", 0) <= 0:
_console_error("没有导线任务。自动布线全部导线需要 QET wires[] 或 QETWiring_01_Tasks。")
_console_error("没有导线任务。一键自动布线需要 QET wires[] 或 QETWiring_01_Tasks。")
return
_console_message(
"批量自动布线完成routed={0}, collision_warnings={1}, missing_terminals={2}".format(
report.get("routed", 0),
report.get("collision_warnings", 0),
report.get("skipped_missing_terminal", 0),
)
)
_console_message(format_route_all_report(report))
except Exception as exc:
_console_error("批量自动布线失败:{0}".format(exc))
@ -914,7 +1296,6 @@ def register_commands():
return
if Gui is None or not hasattr(Gui, "addCommand"):
return
Gui.addCommand("QET_Exchange_AutoRouteSelected", CommandAutoRouteSelected())
Gui.addCommand("QET_Exchange_AutoRouteAll", CommandAutoRouteAll())
_COMMANDS_REGISTERED = True

@ -1,7 +1,9 @@
# FreeCADExchange GUI panel for 3D automatic wiring.
#
# 面板只调用 Python 算法:扫描端子/走线网络、从选择创建 carrier、
# 对选中端子或全部导线任务执行自动布线。
# EPLAN-style simplified workflow:
# 1. "生成布线网络路径" - generate wire-duct centerline carriers
# 2. "生成布线布局空间" - prepare surfaces and terminal access routes for the whole assembly
# 3. "自动布线" - prepare the layout again and route all QET wire tasks
import FreeCAD as App
@ -59,15 +61,6 @@ def _console_error(message):
pass
def _selection():
if Gui is None:
return []
try:
return list(Gui.Selection.getSelection() or [])
except Exception:
return []
def _selection_ex():
if Gui is None:
return []
@ -77,10 +70,6 @@ def _selection_ex():
return []
def _selected_terminals():
return [obj for obj in _selection() if TerminalObjects.is_terminal_object(obj)]
class AutoRoutingController:
def __init__(self):
self.last_report = None
@ -97,7 +86,7 @@ class AutoRoutingController:
kinds = network.get("kinds", {}) if isinstance(network.get("kinds", {}), dict) else {}
kind_text = ""
if kinds:
kind_text = ";类型:" + "".join(
kind_text = "; types: " + ", ".join(
"{0}={1}".format(key, value)
for key, value in sorted(kinds.items())
)
@ -111,60 +100,59 @@ class AutoRoutingController:
kind_text,
)
def create_carriers_from_selection(self):
def generate_routing_paths(self):
"""Generate wire-duct routing paths from the current selection or whole document."""
doc = _active_document()
project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip()
created = RoutingNetwork.create_carriers_from_selection(
doc,
_selection_ex(),
project_uuid=project_uuid,
)
self.last_report = {"created_carriers": len(created)}
return created
selection_ex = _selection_ex()
source_mode = "selection" if selection_ex else "document"
if selection_ex:
wire_ducts = RoutingNetwork.create_wire_duct_carriers_from_selection(
doc,
selection_ex,
project_uuid=project_uuid,
)
else:
wire_ducts = RoutingNetwork.create_wire_duct_carriers_from_document(
doc,
project_uuid=project_uuid,
)
self.last_report = {
"wire_duct_carriers": len(wire_ducts),
"source_mode": source_mode,
}
return self.last_report
def create_wire_duct_carriers_from_selection(self):
def generate_layout_space(self):
"""Prepare the whole document as an EPLAN-style routing layout space."""
doc = _active_document()
project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip()
created = RoutingNetwork.create_wire_duct_carriers_from_selection(
self.last_report = AutoRouting.prepare_eplan_style_layout(
doc,
_selection_ex(),
project_uuid=project_uuid,
)
self.last_report = {"created_wire_duct_carriers": len(created)}
return created
self.last_report["source_mode"] = "document"
return self.last_report
def create_surface_carriers_from_selection(self):
def route_all(self):
doc = _active_document()
project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip()
created = RoutingNetwork.create_surface_carriers_from_selection(
# EPLAN-style one-click routing: prepare the whole layout space first, then
# solve every QET wire task. The user should not need to select faces or draw
# Draft/Sketch helper paths for normal production routing.
prepared_layout = AutoRouting.prepare_eplan_style_layout(
doc,
_selection_ex(),
project_uuid=project_uuid,
)
self.last_report = {"created_surface_carriers": len(created)}
return created
def route_selected(self):
doc = _active_document()
terminals = _selected_terminals()
if len(terminals) != 2:
raise AutoRoutingPanelError(
"这个按钮只用于单条连接测试,请正好选中两个可布线工程端子。批量布线请使用“按导线任务自动布线全部”。"
)
result = AutoRouting.route_between_terminals(doc, terminals[0], terminals[1])
self.last_report = result
return result
def route_all(self):
doc = _active_document()
payload = getattr(App, "_qet_exchange_payload", None)
if isinstance(payload, dict) and payload.get("wires"):
report = AutoRouting.route_all_from_payload(doc, payload)
else:
report = AutoRouting.route_all_tasks(doc)
report["prepared_layout"] = prepared_layout
if report.get("total_wires", 0) <= 0:
raise AutoRoutingPanelError(
"没有导线任务,不能判断哪些端子应该互相连接。请先从 QET 导入 wires[],或确认 QETWiring_01_Tasks 中存在导线任务。"
"没有导线任务。请先从 QET 导入 wires[],或确认 QETWiring_01_Tasks 中存在导线任务。"
)
self.last_report = report
return report
@ -200,106 +188,110 @@ class AutoRoutingTaskPanel:
self.form.setWindowTitle("3D 自动布线")
layout = QtWidgets.QVBoxLayout(self.form)
self.scan_button = QtWidgets.QPushButton("扫描端子/网络")
self.create_wire_duct_button = QtWidgets.QPushButton("从线槽实体生成中心路径")
self.create_carrier_button = QtWidgets.QPushButton("从线槽/草图创建路由路径")
self.create_surface_button = QtWidgets.QPushButton("从选中面创建辅助路由区域")
self.route_selected_button = QtWidgets.QPushButton("测试布线选中两个端子")
self.route_all_button = QtWidgets.QPushButton("按导线任务自动布线全部")
self.clear_button = QtWidgets.QPushButton("清除自动布线")
self.generate_paths_button = QtWidgets.QPushButton("生成布线网络路径")
self.generate_paths_button.setToolTip(
"优先从当前选择生成线槽中心路径;未选择时自动识别整份文档里的线槽。"
)
self.generate_layout_button = QtWidgets.QPushButton("生成布线布局空间")
self.generate_layout_button.setToolTip(
"按整份 3D 装配生成布线布局空间:识别线槽/安装面,并把工程端子自动接入路由网络。"
)
self.route_all_button = QtWidgets.QPushButton("自动布线")
self.route_all_button.setToolTip(
"一键准备布线网络和布局空间,并按全部 QET 导线任务生成 3D 自动布线。"
)
self.clear_routes_button = QtWidgets.QPushButton("清除自动布线")
self.clear_carriers_button = QtWidgets.QPushButton("清除走线路径")
self.save_button = QtWidgets.QPushButton("保存")
self.status_label = QtWidgets.QLabel("")
self.status_label.setWordWrap(True)
for widget in (
self.scan_button,
self.create_wire_duct_button,
self.create_carrier_button,
self.create_surface_button,
self.route_selected_button,
self.generate_paths_button,
self.generate_layout_button,
self.route_all_button,
self.clear_button,
self.clear_routes_button,
self.clear_carriers_button,
self.save_button,
self.status_label,
):
layout.addWidget(widget)
self.scan_button.clicked.connect(self.scan)
self.create_wire_duct_button.clicked.connect(self.create_wire_duct_carriers)
self.create_carrier_button.clicked.connect(self.create_carriers)
self.create_surface_button.clicked.connect(self.create_surface_carriers)
self.route_selected_button.clicked.connect(self.route_selected)
layout.addWidget(self.status_label)
self.generate_paths_button.clicked.connect(self.generate_routing_paths)
self.generate_layout_button.clicked.connect(self.generate_layout_space)
self.route_all_button.clicked.connect(self.route_all)
self.clear_button.clicked.connect(self.clear_auto_routes)
self.clear_routes_button.clicked.connect(self.clear_auto_routes)
self.clear_carriers_button.clicked.connect(self.clear_route_carriers)
self.save_button.clicked.connect(self.save)
self.scan()
def _set_status(self, message):
self.status_label.setText(message)
_console_message(message)
self._refresh_status()
def _set_error(self, message):
self.status_label.setText(message)
_console_error(message)
def scan(self):
def _refresh_status(self):
try:
self._set_status(self.controller.summary())
except Exception as exc:
self._set_error(str(exc))
def create_carriers(self):
try:
created = self.controller.create_carriers_from_selection()
self._set_status("已创建路由路径:{0} 条。{1}".format(len(created), self.controller.summary()))
except Exception as exc:
self._set_error(str(exc))
def _set_status(self, message):
self.status_label.setText(message)
_console_message(message)
def create_wire_duct_carriers(self):
try:
created = self.controller.create_wire_duct_carriers_from_selection()
self._set_status("已创建线槽中心路径:{0} 条。{1}".format(len(created), self.controller.summary()))
except Exception as exc:
self._set_error(str(exc))
def _set_error(self, message):
self.status_label.setText(message)
_console_error(message)
def create_surface_carriers(self):
def generate_routing_paths(self):
try:
created = self.controller.create_surface_carriers_from_selection()
self._set_status("已创建辅助路由区域:{0} 条。{1}".format(len(created), self.controller.summary()))
result = self.controller.generate_routing_paths()
wire_ducts = result.get("wire_duct_carriers", 0)
if wire_ducts == 0:
self._set_status(
"未生成布线网络路径。可先全选或选中线槽实体,再执行本按钮。"
+ self.controller.summary()
)
return
self._set_status(
"已生成布线网络路径:线槽中心路径 {0} 条。{1}".format(
wire_ducts, self.controller.summary()
)
)
except Exception as exc:
self._set_error(str(exc))
def route_selected(self):
def generate_layout_space(self):
try:
result = self.controller.route_selected()
result = self.controller.generate_layout_space()
wire_ducts = result.get("wire_duct_carriers", 0)
surfaces = result.get("surface_carriers", 0)
terminal_access = result.get("terminal_access_carriers", 0)
network = result.get("network", {}) if isinstance(result.get("network", {}), dict) else {}
if network.get("segments", 0) == 0:
self._set_status(
"未生成可用布线布局空间。请确认 3D 装配里有可识别的线槽、安装板、背板或已标记路由路径。"
+ self.controller.summary()
)
return
self._set_status(
"自动布线完成algorithm={0}, status={1}, collisions={2}".format(
result.get("algorithm", ""),
result.get("route_status", ""),
result.get("collision_count", 0),
"已生成布线布局空间:线槽路径 {0} 条,布线面 {1} 条,端子接入 {2} 条。{3}".format(
wire_ducts,
surfaces,
terminal_access,
self.controller.summary(),
)
)
try:
if Gui is not None:
Gui.SendMsgToActiveView("ViewFit")
except Exception:
pass
except Exception as exc:
self._set_error(str(exc))
def route_all(self):
try:
report = self.controller.route_all()
self._set_status(
"批量自动布线完成routed={0}, collision_warnings={1}, missing_terminals={2}".format(
report.get("routed", 0),
report.get("collision_warnings", 0),
report.get("skipped_missing_terminal", 0),
)
)
self._set_status(AutoRouting.format_route_all_report(report))
except Exception as exc:
self._set_error(str(exc))

@ -453,8 +453,10 @@ def _normalize_wires(payload):
"wire_mark": _optional_string(item, "wire_mark", entry_label),
"wire_mark_is_manual": wire_mark_is_manual,
"start_element_uuid": _optional_string(item, "start_element_uuid", entry_label),
"start_instance_id": _optional_string(item, "start_instance_id", entry_label),
"start_terminal_uuid": _optional_string(item, "start_terminal_uuid", entry_label),
"end_element_uuid": _optional_string(item, "end_element_uuid", entry_label),
"end_instance_id": _optional_string(item, "end_instance_id", entry_label),
"end_terminal_uuid": _optional_string(item, "end_terminal_uuid", entry_label),
"start_terminal_display": _optional_string(item, "start_terminal_display", entry_label),
"end_terminal_display": _optional_string(item, "end_terminal_display", entry_label),

File diff suppressed because it is too large Load Diff

@ -485,4 +485,10 @@ def resolve_terminal_slots(device_group, model_path, desired_count):
break
slots.append(slot)
if len(slots) < desired_count:
needed = desired_count - len(slots)
fallback = build_fallback_terminal_slots(device_group, needed)
if fallback:
slots.extend(fallback)
return slots[:desired_count]

@ -65,6 +65,7 @@ class FakeViewObject:
self.Visibility = True
self.LineWidth = None
self.LineColor = None
self.DrawStyle = None
class FakeObject:
@ -158,9 +159,11 @@ class FakeEdge:
class FakeFace:
ShapeType = "Face"
def __init__(self, bbox, normal):
def __init__(self, bbox, normal, vertices=None, center=None):
self.BoundBox = bbox
self._normal = normal
self.Vertexes = [FakeVertex(point) for point in (vertices or [])]
self.CenterOfMass = center
def normalAt(self, _u, _v):
return self._normal
@ -176,6 +179,7 @@ class FakeSelectionItem:
def _reload_modules():
for name in [
"TerminalObjects",
"TemplateSemantics",
"WiringObjects",
"RoutingNetwork",
"AutoRouting",
@ -266,6 +270,96 @@ class AutoRoutingTest(unittest.TestCase):
self.assertEqual("Routed", result["route_status"])
self.assertTrue(any(point.y == 30.0 for point in result["points"]))
def test_route_carrier_styles_make_generated_objects_distinguishable(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")
wire_duct = routing_network.create_route_carrier(
doc,
[app.Vector(0, 0, 0), app.Vector(100, 0, 0)],
project_uuid="project-1",
kind="WireDuct",
)
routing_range = routing_network.create_route_carrier(
doc,
[app.Vector(0, 10, 0), app.Vector(100, 10, 0)],
project_uuid="project-1",
kind="RoutingRange",
)
terminal_access = routing_network.create_route_carrier(
doc,
[app.Vector(0, 20, 0), app.Vector(100, 20, 0)],
project_uuid="project-1",
kind="TerminalAccess",
)
self.assertEqual((1.0, 0.55, 0.0), wire_duct.ViewObject.LineColor)
self.assertEqual(4.0, wire_duct.ViewObject.LineWidth)
self.assertEqual((0.0, 0.65, 0.35), routing_range.ViewObject.LineColor)
self.assertEqual("Solid", routing_range.ViewObject.DrawStyle)
self.assertEqual((0.65, 0.2, 1.0), terminal_access.ViewObject.LineColor)
self.assertEqual("Solid", terminal_access.ViewObject.DrawStyle)
def test_route_graph_connects_crossing_carriers_at_intersection(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(50, 50, 0))
routing_network.create_route_carrier(
doc,
[app.Vector(0, 0, 0), app.Vector(100, 0, 0)],
project_uuid="project-1",
kind="WireDuct",
)
routing_network.create_route_carrier(
doc,
[app.Vector(50, -50, 0), app.Vector(50, 50, 0)],
project_uuid="project-1",
kind="WireDuct",
)
network = routing_network.build_route_graph(doc)
result = auto_routing.route_between_terminals(doc, start, end)
self.assertEqual(5, len(network["nodes"]))
self.assertEqual("network-dijkstra-v1", result["algorithm"])
self.assertIn((50.0, 0.0, 0.0), [(point.x, point.y, point.z) for point in result["points"]])
def test_route_graph_connects_overlapping_collinear_carriers(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(120, 0, 0))
routing_network.create_route_carrier(
doc,
[app.Vector(0, 0, 0), app.Vector(80, 0, 0)],
project_uuid="project-1",
kind="WireDuct",
)
routing_network.create_route_carrier(
doc,
[app.Vector(40, 0, 0), app.Vector(120, 0, 0)],
project_uuid="project-1",
kind="WireDuct",
)
network = routing_network.build_route_graph(doc)
result = auto_routing.route_between_terminals(doc, start, end)
self.assertEqual("network-dijkstra-v1", result["algorithm"])
self.assertIn((40.0, 0.0, 0.0), [(point.x, point.y, point.z) for point in result["points"]])
self.assertIn((80.0, 0.0, 0.0), [(point.x, point.y, point.z) for point in result["points"]])
self.assertGreaterEqual(network["segment_count"], 3)
def test_auto_route_prefers_wire_duct_over_auxiliary_range(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules()
@ -325,6 +419,277 @@ class AutoRoutingTest(unittest.TestCase):
self.assertEqual("network-dijkstra-v1", result["algorithm"])
self.assertTrue(any(point.z == 4.0 for point in result["points"]))
def test_auto_detect_support_surface_creates_routing_range(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules()
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
panel = doc.addObject("Part::Feature", "MountingPlateA")
panel.Label = "安装板A"
panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100))
cabinet = doc.addObject("Part::Feature", "Cabinet")
cabinet.Label = "3D机柜"
cabinet.Shape = FakeShape(FakeBoundBox(0, 300, 0, 80, 0, 400))
duct = doc.addObject("Part::Feature", "WireDuctA")
duct.Label = "Wire Duct A"
duct.Shape = FakeShape(FakeBoundBox(0, 120, -10, 10, 5, 25))
created = routing_network.create_surface_carriers_from_document(
doc,
project_uuid="project-1",
spacing=60.0,
offset=5.0,
margin=0.0,
)
created_again = routing_network.create_surface_carriers_from_document(
doc,
project_uuid="project-1",
spacing=60.0,
offset=5.0,
margin=0.0,
)
self.assertEqual(6, len(created))
self.assertEqual(0, len(created_again))
self.assertTrue(all(carrier.QetRouteCarrierKind == "RoutingRange" for carrier in created))
self.assertEqual("RoutingRange", panel.QetRoutingSourceKind)
self.assertEqual("SupportSurface", panel.QetRoutingObstacleMode)
self.assertFalse(hasattr(cabinet, "QetRoutingSourceKind"))
self.assertFalse(hasattr(duct, "QetRoutingSourceKind"))
def test_auto_route_can_use_auto_detected_support_surface(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, 10, 0))
end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 10, 0))
panel = doc.addObject("Part::Feature", "MountingPlateA")
panel.Label = "Mounting Plate A"
panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100))
created = routing_network.create_surface_carriers_from_document(
doc,
project_uuid="project-1",
spacing=60.0,
offset=5.0,
margin=0.0,
)
result = auto_routing.route_between_terminals(doc, start, end)
self.assertGreater(len(created), 0)
self.assertEqual("network-dijkstra-v1", result["algorithm"])
self.assertEqual("Routed", result["route_status"])
self.assertEqual(0, result["collision_count"])
self.assertTrue(any(point.y == 10.0 for point in result["points"]))
def test_generate_layout_space_auto_detects_support_surface(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")
panel = doc.addObject("Part::Feature", "MountingPlateA")
panel.Label = "Mounting Plate A"
panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100))
result = auto_routing_panel.AutoRoutingController().generate_layout_space()
self.assertGreater(result["surface_carriers"], 0)
self.assertEqual("document", result["source_mode"])
def test_generate_routing_paths_uses_selected_wire_duct_entity(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"]
gui = sys.modules["FreeCADGui"]
doc = FakeDocument()
app.ActiveDocument = doc
terminal_objects.ensure_root_group(doc, "project-1")
duct = doc.addObject("Part::Feature", "UnlabeledLongDuct")
duct.Shape = FakeShape(FakeBoundBox(0, 160, -10, 10, 0, 20))
gui.Selection = types.SimpleNamespace(
getSelection=lambda: [],
getSelectionEx=lambda: [FakeSelectionItem(obj=duct)],
)
result = auto_routing_panel.AutoRoutingController().generate_routing_paths()
self.assertEqual(1, result["wire_duct_carriers"])
self.assertEqual("selection", result["source_mode"])
def test_generate_layout_space_uses_whole_document_not_selected_face_workflow(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"]
gui = sys.modules["FreeCADGui"]
doc = FakeDocument()
app.ActiveDocument = doc
terminal_objects.ensure_root_group(doc, "project-1")
panel = doc.addObject("Part::Feature", "MountingPlateA")
panel.Label = "Mounting Plate A"
panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100))
gui.Selection = types.SimpleNamespace(
getSelection=lambda: [],
getSelectionEx=lambda: [FakeSelectionItem(obj=panel)],
)
result = auto_routing_panel.AutoRoutingController().generate_layout_space()
self.assertGreater(result["surface_carriers"], 0)
self.assertEqual("document", result["source_mode"])
def test_generate_layout_space_adds_terminal_access_to_route_network(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules()
auto_routing_panel = importlib.import_module("AutoRoutingPanel")
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
terminal_objects.ensure_root_group(doc, "project-1")
_terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0))
_terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0))
duct = doc.addObject("Part::Feature", "WireDuctA")
duct.Label = "Wire Duct A"
duct.Shape = FakeShape(FakeBoundBox(0, 100, -5, 5, 15, 25))
result = auto_routing_panel.AutoRoutingController().generate_layout_space()
result_again = auto_routing_panel.AutoRoutingController().generate_layout_space()
access_carriers = [
carrier
for carrier in routing_network.collect_route_carriers(doc)
if getattr(carrier, "QetRouteCarrierKind", "") == "TerminalAccess"
]
self.assertEqual(1, result["wire_duct_carriers"])
self.assertEqual(2, result["terminal_access_carriers"])
self.assertEqual(0, result_again["wire_duct_carriers"])
self.assertEqual(2, result_again["terminal_access_carriers"])
self.assertEqual(2, len(access_carriers))
self.assertGreater(result["network"]["segments"], 0)
def test_generate_layout_space_skips_far_terminal_access_to_protect_view_bbox(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules()
auto_routing_panel = importlib.import_module("AutoRoutingPanel")
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
terminal_objects.ensure_root_group(doc, "project-1")
_terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0))
duct = doc.addObject("Part::Feature", "WireDuctFar")
duct.Label = "Wire Duct Far"
duct.Shape = FakeShape(FakeBoundBox(5000, 5100, -5, 5, 15, 25))
result = auto_routing_panel.AutoRoutingController().generate_layout_space()
self.assertEqual(1, result["wire_duct_carriers"])
self.assertEqual(0, result["terminal_access_carriers"])
def test_route_all_prepares_layout_space_like_one_click_routing(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules()
auto_routing_panel = importlib.import_module("AutoRoutingPanel")
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
terminal_objects.ensure_root_group(doc, "project-1")
_terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0))
_terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0))
duct = doc.addObject("Part::Feature", "WireDuctA")
duct.Label = "Wire Duct A"
duct.Shape = FakeShape(FakeBoundBox(0, 100, -5, 5, 15, 25))
app._qet_exchange_payload = {
"project_uuid": "project-1",
"wires": [
{
"wire_id": "wire-1",
"start_terminal_uuid": "terminal-start",
"end_terminal_uuid": "terminal-end",
}
],
}
report = auto_routing_panel.AutoRoutingController().route_all()
self.assertEqual(1, report["routed"])
self.assertEqual(1, report["prepared_layout"]["wire_duct_carriers"])
self.assertEqual(2, report["prepared_layout"]["terminal_access_carriers"])
def test_auto_route_rejects_far_network_entry_to_avoid_huge_render_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")
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(5000, 0, 20), app.Vector(5100, 0, 20)],
project_uuid="project-1",
kind="WireDuct",
)
with self.assertRaises(auto_routing.AutoRoutingError):
auto_routing.route_between_terminals(doc, start, end)
def test_route_between_terminals_fails_without_network(self):
_install_fake_freecad()
terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
app.ActiveDocument = doc
terminal_objects.ensure_root_group(doc, "project-1")
start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0))
end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 30, 0))
with self.assertRaises(auto_routing.AutoRoutingError):
auto_routing.route_between_terminals(doc, start, end)
self.assertEqual(0, len(wiring_objects.iter_routed_wire_objects(doc)))
def test_surface_carrier_grid_uses_actual_rotated_face_plane(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")
normal = app.Vector(0, 1, 1)
vertices = [
app.Vector(0, 0, 0),
app.Vector(100, 0, 0),
app.Vector(0, 50, -50),
app.Vector(100, 50, -50),
]
face = FakeFace(
FakeBoundBox(0, 100, 0, 50, -50, 0),
normal,
vertices=vertices,
center=app.Vector(50, 25, -25),
)
created = routing_network.create_surface_carriers_from_selection(
doc,
[FakeSelectionItem([face])],
project_uuid="project-1",
spacing=50.0,
offset=10.0,
margin=0.0,
)
self.assertGreater(len(created), 0)
first_point = created[0].Points[0]
for carrier in created:
for point in carrier.Points:
# The rotated face is y + z = 0; after a 10 mm normal offset,
# all generated points must stay on one parallel plane.
self.assertAlmostEqual(first_point.y + first_point.z, point.y + point.z, places=6)
def test_route_path_creation_ignores_whole_solid_object_edges(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules()
@ -346,6 +711,34 @@ class AutoRoutingTest(unittest.TestCase):
self.assertEqual([], created)
def test_route_path_creation_projects_line_to_selected_face(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")
face = FakeFace(
FakeBoundBox(0, 100, 0, 100, 0, 0),
app.Vector(0, 0, 1),
)
draft_line = doc.addObject("Part::Feature", "DraftLine")
draft_line.Shape = FakeShape(
FakeBoundBox(10, 90, 10, 90, 25, 35),
edges=[FakeEdge(app.Vector(10, 10, 25), app.Vector(90, 90, 35))],
)
created = routing_network.create_carriers_from_selection(
doc,
[
FakeSelectionItem([face]),
FakeSelectionItem(obj=draft_line),
],
project_uuid="project-1",
)
self.assertEqual(1, len(created))
self.assertEqual([2.0, 2.0], [point.z for point in created[0].Points])
def test_wire_duct_entity_generates_centerline_and_marks_source_pass_through(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules()
@ -367,6 +760,33 @@ class AutoRoutingTest(unittest.TestCase):
self.assertEqual("PassThrough", duct.QetRoutingObstacleMode)
self.assertEqual([(20.0, 0.0, 15.0), (100.0, 0.0, 15.0)], [(p.x, p.y, p.z) for p in carrier.Points])
def test_auto_detect_wire_ducts_ignores_cabinet_models(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules()
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
duct = doc.addObject("Part::Feature", "WireDuctA")
duct.Label = "线槽A"
duct.Shape = FakeShape(FakeBoundBox(0, 120, -10, 10, 5, 25))
cabinet = doc.addObject("Part::Feature", "Cabinet")
cabinet.Label = "3D机柜"
cabinet.Shape = FakeShape(FakeBoundBox(0, 300, 0, 80, 0, 400))
created = routing_network.create_wire_duct_carriers_from_document(
doc,
project_uuid="project-1",
)
created_again = routing_network.create_wire_duct_carriers_from_document(
doc,
project_uuid="project-1",
)
self.assertEqual(1, len(created))
self.assertEqual(0, len(created_again))
self.assertEqual("WireDuct", created[0].QetRouteCarrierKind)
self.assertEqual("PassThrough", duct.QetRoutingObstacleMode)
self.assertFalse(hasattr(cabinet, "QetRoutingObstacleMode"))
def test_wire_duct_source_is_not_reported_as_collision(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules()
@ -390,6 +810,43 @@ class AutoRoutingTest(unittest.TestCase):
self.assertEqual("Routed", result["route_status"])
self.assertEqual(0, result["collision_count"])
def test_auto_route_uses_alternate_carrier_to_avoid_obstacle(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
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",
)
routing_network.create_route_carrier(
doc,
[
app.Vector(0, 0, 20),
app.Vector(0, 50, 20),
app.Vector(100, 50, 20),
app.Vector(100, 0, 20),
],
project_uuid="project-1",
kind="WireDuct",
)
obstacle = doc.addObject("Part::Feature", "CabinetObstacle")
obstacle.Shape = FakeShape(FakeBoundBox(40, 60, -10, 10, 15, 25))
result = auto_routing.route_between_terminals(doc, start, end)
self.assertEqual("network-dijkstra-v1", result["algorithm"])
self.assertEqual("Routed", result["route_status"])
self.assertEqual(0, result["collision_count"])
self.assertTrue(result["network"]["obstacle_aware"])
self.assertGreaterEqual(result["network"]["blocked_segments"], 1)
self.assertIn(50.0, [point.y for point in result["points"]])
def test_auto_route_marks_collision_warning_against_obstacle_bbox(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules()
@ -433,6 +890,179 @@ class AutoRoutingTest(unittest.TestCase):
self.assertEqual(0, report["routed"])
self.assertEqual(1, report["skipped_missing_terminal"])
self.assertEqual(1, report["available_terminals"])
self.assertEqual(0, report["local_terminals"])
self.assertEqual(["terminal-missing"], report["missing_endpoint_uuids"])
self.assertEqual("terminal-start", report["missing_endpoint_samples"][0]["start_terminal_uuid"])
self.assertTrue(report["missing_endpoint_samples"][0]["start_found"])
self.assertFalse(report["missing_endpoint_samples"][0]["end_found"])
def test_route_all_report_calls_out_local_unbound_terminals(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
_terminal(
doc,
terminal_objects,
"LocalTerminal",
"local:instance-1:p1",
app.Vector(0, 0, 0),
)
payload = {
"wires": [
{
"wire_id": "wire-1",
"start_terminal_uuid": "qet-terminal-start",
"end_terminal_uuid": "qet-terminal-end",
}
]
}
report = auto_routing.route_all_from_payload(doc, payload)
message = auto_routing.format_route_all_report(report)
self.assertEqual(0, report["routed"])
self.assertEqual(1, report["available_terminals"])
self.assertEqual(1, report["local_terminals"])
self.assertIn("端子匹配失败", message)
self.assertIn("local:", message)
def test_bind_wire_task_terminals_from_payload_does_not_create_wires(self):
_install_fake_freecad()
terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
root = terminal_objects.ensure_root_group(doc, "project-1")
device = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "device-a")
terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "instance-a")
terminal_objects.ensure_string_property(device, "QetProjectUuid", "QET Exchange", "", "project-1")
terminal_group = terminal_objects.ensure_terminal_group(
doc,
device,
project_uuid="project-1",
instance_id="instance-a",
)
for slot_name, point in (
("P1", app.Vector(0, 0, 0)),
("P2", app.Vector(100, 0, 0)),
):
terminal = terminal_objects.create_lcs_object(
doc,
"QETTerminal_instance_a_{0}".format(slot_name),
placement=app.Placement(point, app.Rotation()),
label=slot_name,
)
terminal_group.addObject(terminal)
terminal_objects.set_terminal_semantics(
terminal,
"project-1",
"device-a",
"local:instance-a:{0}".format(slot_name),
"instance-a",
label=slot_name,
slot_name=slot_name,
)
payload = {
"project_uuid": "project-1",
"wires": [
{
"wire_id": "wire-1",
"start_element_uuid": "device-a",
"start_instance_id": "instance-a",
"start_terminal_uuid": "qet-terminal-p1",
"start_terminal_display": "P1",
"end_element_uuid": "device-a",
"end_instance_id": "instance-a",
"end_terminal_uuid": "qet-terminal-p2",
"end_terminal_display": "P2",
}
],
}
report = auto_routing.bind_wire_task_terminals_from_payload(doc, payload)
indexed = auto_routing.index_terminals(doc)
self.assertEqual(2, report["bound"])
self.assertEqual(0, report["created"])
self.assertEqual(0, report["local_terminals"])
self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc))
self.assertEqual("qet", indexed["qet-terminal-p1"].QetTerminalBindingMode)
def test_route_all_rebinds_local_template_terminals_from_wire_endpoints(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
root = terminal_objects.ensure_root_group(doc, "project-1")
device = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "device-a")
terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "instance-a")
terminal_objects.ensure_string_property(device, "QetProjectUuid", "QET Exchange", "", "project-1")
terminal_group = terminal_objects.ensure_terminal_group(
doc,
device,
project_uuid="project-1",
instance_id="instance-a",
)
for slot_name, point in (
("P1", app.Vector(0, 0, 0)),
("P2", app.Vector(100, 0, 0)),
):
terminal = terminal_objects.create_lcs_object(
doc,
"QETTerminal_instance_a_{0}".format(slot_name),
placement=app.Placement(point, app.Rotation()),
label=slot_name,
)
terminal_group.addObject(terminal)
terminal_objects.set_terminal_semantics(
terminal,
"project-1",
"device-a",
"local:instance-a:{0}".format(slot_name),
"instance-a",
label=slot_name,
slot_name=slot_name,
)
payload = {
"project_uuid": "project-1",
"wires": [
{
"wire_id": "wire-1",
"start_element_uuid": "device-a",
"start_instance_id": "instance-a",
"start_terminal_uuid": "qet-terminal-p1",
"start_terminal_display": "P1",
"end_element_uuid": "device-a",
"end_instance_id": "instance-a",
"end_terminal_uuid": "qet-terminal-p2",
"end_terminal_display": "P2",
}
],
}
report = auto_routing.route_all_from_payload(
doc,
payload,
options={"allow_floating_fallback": True},
)
indexed = auto_routing.index_terminals(doc)
self.assertEqual(1, report["routed"])
self.assertEqual(2, report["auto_bound_terminals"])
self.assertEqual(0, report["local_terminals"])
self.assertIn("qet-terminal-p1", indexed)
self.assertIn("qet-terminal-p2", indexed)
self.assertEqual("qet", indexed["qet-terminal-p1"].QetTerminalBindingMode)
def test_clear_route_carriers_keeps_routed_wires(self):
_install_fake_freecad()

Loading…
Cancel
Save