feat:自动布线功能

dev
邱德佳 13 hours ago
parent b64e751c29
commit 7c337c7522

@ -0,0 +1,515 @@
# FreeCAD 3D 自动布线设计方案
本文档描述 QET / LightWork3D 与 FreeCAD 协同中的 3D 自动布线设计。
当前版本目标不是完整复刻 EPLAN Pro Panel 或 SOLIDWORKS Electrical而是先完成一个可用的最小闭环
```text
工程端子
-> 路由路径网络
-> 自动求路
-> 生成 3D 折线导线
-> 可保存到 FreeCAD 文档
```
## 1. 设计边界
### 1.1 当前版本目标
当前版本只要求完成“能够自动布线”:
1. 能识别 FreeCAD 文档中的工程端子。
2. 能从用户指定的线槽、草图、面域创建路由网络。
3. 能在两个端子之间自动生成 3D 折线导线。
4. 能批量处理 QET 导入的导线任务。
5. 能绕开或至少发现明显障碍碰撞。
6. 默认禁止长距离悬空线。
### 1.2 当前版本不做
当前版本不做以下工程级能力:
1. 不修改 FreeCAD C++ 源码。
2. 不写入或依赖旧 3D 场景数据库表。
3. 不把 3D 位姿、3D 路径点写入数据库。
4. 不做完整线槽容量计算。
5. 不做线径、弯曲半径、线束层叠排列。
6. 不做强弱电隔离、EMC、屏蔽线等电气规则。
7. 不保证达到 EPLAN / SOLIDWORKS Electrical 的完整工程级自动布线效果。
### 1.3 数据库约束
第一版严格遵守 `docs/数据库设计.md`
1. 设备绑定只依赖:
```text
project_2d3d_symbol_binding:
project_uuid
element_uuid
instance_id
```
2. 端子绑定只依赖:
```text
project_2d3d_terminal_binding:
project_uuid
terminal_uuid
instance_id
```
3. 3D 端子绑定唯一依据是:
```text
terminal_uuid
```
4. 3D 位姿、装配关系、布线几何以 FreeCAD 文档为准。
## 2. 总体方案
自动布线不直接在任意 3D 空间里找线,也不会把所有端子任意两两连接。它参考 EPLAN / SOLIDWORKS 的思路,先建立“可走路径网络”,再按 QET 导线任务中的起点端子和终点端子逐条求路。
```text
端子出线段
-> 进入最近路由节点
-> 在线槽/路由路径网络中求最短路
-> 从路由网络出来
-> 进入目标端子
```
路由网络由 `carrier` 组成。一个 carrier 表示一段可走线中心线或辅助路由区域中的一条网格线。
### 2.1 路由优先级
当前版本按下面优先级处理:
1. `WireDuct`:线槽中心路径,最高优先级。
2. `RoutingPath`用户画的草图线、Draft Wire、明确选中的路由边。
3. `AuxiliaryPath`:辅助路径,后续扩展使用。
4. `RoutingRange`:选中面生成的辅助路由区域,成本较高,只用于过渡或没有线槽时兜底。
普通机柜、设备外壳、实体边默认不是路由路径。
## 3. FreeCAD 对象语义
### 3.1 工程端子
工程端子是可布线起点和终点,必须满足:
```text
Role = "Terminal"
CanWire = true
QetTerminalUuid = <2D terminal_uuid>
QetInstanceId = <3D instance_id>
QetProjectUuid = <project_uuid>
```
端子空间位置来自 FreeCAD 文档。自动布线使用端子的全局坐标和方向。
### 3.2 路由路径 Carrier
路由路径对象使用以下语义属性:
```text
QetRoutingRole = "RoutingCarrier"
QetRouteCarrierKind = "WireDuct" | "RoutingPath" | "AuxiliaryPath" | "RoutingRange"
CanRouteWire = true
QetProjectUuid = <project_uuid>
Points = [Vector, Vector, ...]
```
carrier 统一放在:
```text
QETWiring
02_Carriers
```
### 3.3 自动导线
自动生成的导线放在:
```text
QETWiring
04_Routed
```
自动导线语义:
```text
RouteType = "AutoSuggested"
RouteMode = "Auto"
RouteStatus = "Routed" | "CollisionWarning"
QetStartTerminalUuid
QetEndTerminalUuid
QetAutoRouteAlgorithm
QetAutoRouteDiagnosticsJson
```
### 3.4 线槽实体
当用户选择线槽实体并执行“从线槽实体生成中心路径”后:
1. 系统按包围盒长轴生成 `WireDuct` 中心路径。
2. 原线槽实体标记为可穿线路由源。
3. 碰撞检测跳过该线槽实体本身,避免“线在槽内却撞到线槽外壳”的误报。
线槽实体属性:
```text
QetRoutingSourceKind = "WireDuct"
QetRoutingObstacleMode = "PassThrough"
QetRouteCarrierName = <generated carrier name>
```
## 4. 算法设计
### 4.1 路由网络构建
模块:
```text
src/Mod/FreeCADExchange/RoutingNetwork.py
```
构建步骤:
1. 收集所有 `QetRoutingRole = RoutingCarrier` 的对象。
2. 读取 carrier 的 `Points`
3. 将相同或接近坐标的点合并为图节点。
4. 将相邻点之间的线段建立为图边。
5. 每条边记录所属 carrier用于计算路径成本。
### 4.2 最短路算法
当前使用:
```text
Dijkstra + bend_penalty
```
路径成本:
```text
edge_cost = segment_length * carrier_kind_factor + bend_penalty
```
其中:
```text
WireDuct = 1.0
RoutingPath = 1.0
AuxiliaryPath = 2.0
RoutingRange = 8.0
```
这使算法优先走线槽和明确路由路径,尽量少走辅助面域。
### 4.3 端子接入
模块:
```text
src/Mod/FreeCADExchange/AutoRouting.py
```
流程:
1. 读取起点端子和终点端子的全局坐标。
2. 按端子方向生成短出线段。
3. 找到距离起点出线点最近的路由节点。
4. 找到距离终点出线点最近的路由节点。
5. 在路由网络中执行 Dijkstra。
6. 将端子出线段、网络路径、终点入线段拼成最终折线。
当前算法名:
```text
network-dijkstra-v1
```
### 4.4 悬空线策略
当前版本默认:
```text
allow_floating_fallback = false
```
也就是说,如果没有可用路由网络,系统不会生成长距离悬空线,而是提示用户先创建线槽路径、草图路径或辅助路由区域。
调试时可以显式启用 fallback但正式使用不建议开启。
### 4.5 障碍物处理
当前版本使用 AABB 包围盒做碰撞诊断:
1. 收集 FreeCAD 文档中的几何对象。
2. 排除端子、carrier、已布线导线、原点辅助对象。
3. 排除标记为 `QetRoutingObstacleMode = PassThrough` 的线槽实体。
4. 检查导线线段是否与障碍包围盒相交。
5. 如果有碰撞,导线状态设为:
```text
RouteStatus = "CollisionWarning"
```
当前版本是“碰撞诊断”,不是完整自由空间避障。
## 5. 当前已完成
### 5.1 Python 模块
已实现:
```text
src/Mod/FreeCADExchange/RoutingNetwork.py
src/Mod/FreeCADExchange/AutoRouting.py
src/Mod/FreeCADExchange/AutoRoutingPanel.py
```
并已加入:
```text
src/Mod/FreeCADExchange/CMakeLists.txt
src/Mod/FreeCADExchange/InitGui.py
```
### 5.2 路由网络功能
已完成:
1. 从选中草图线、Draft Wire、明确选中的边创建 `RoutingPath`
2. 从选中线槽实体生成 `WireDuct` 中心路径。
3. 从选中面生成 `RoutingRange` 辅助路由区域。
4. 清除已生成路由路径。
5. 扫描并统计路由网络。
### 5.3 自动布线功能
已完成:
1. 两个选中端子之间自动布线。
2. 根据导线任务批量自动布线。
3. 使用 Dijkstra 求路。
4. 支持转弯惩罚。
5. 支持 carrier 类型成本。
6. 默认禁止长距离悬空线。
7. 碰撞检测和 `CollisionWarning` 状态。
8. 自动导线可见显示并保存到 FreeCAD 文档。
### 5.4 FreeCAD 面板
面板入口:
```text
QET模板 -> 3D自动布线
```
当前按钮:
```text
扫描端子/网络
从线槽实体生成中心路径
从线槽/草图创建路由路径
从选中面创建辅助路由区域
测试布线选中两个端子
按导线任务自动布线全部
清除自动布线
清除走线路径
保存
```
### 5.5 测试
已完成单元测试:
```text
tests/python/freecad_exchange_auto_routing_test.py
```
覆盖:
1. 无路由网络时默认拒绝悬空线。
2. 显式调试 fallback 时生成正交路径。
3. 优先使用用户路由网络。
4. 优先使用 `WireDuct`,降低 `RoutingRange` 优先级。
5. 从选中面生成辅助路由区域。
6. 选中整个实体不会把所有外壳边转成路径。
7. 从线槽实体生成中心路径。
8. 线槽实体不作为自身路径碰撞障碍。
9. 碰撞状态标记。
10. 批量导线任务缺端子时跳过。
11. 清除路由路径不删除已布线导线。
已完成 FreeCAD smoke
```text
tests/manual/freecad_auto_routing_smoke.py
```
## 6. 当前使用流程
### 6.1 单条导线自动布线
```text
1. 打开 FreeCAD 工程 scene.FCStd
2. 进入 QET模板 -> 3D自动布线
3. 清除走线路径
4. 选中线槽实体
5. 点击“从线槽实体生成中心路径”
6. 必要时选草图线或 Draft Wire点击“从线槽/草图创建路由路径”
7. 必要时选背板/门板面,点击“从选中面创建辅助路由区域”
8. 选中两个工程端子
9. 点击“测试布线选中两个端子”
```
### 6.2 批量导线自动布线
前提:
1. QET 导出的 `2d_to_3d.json` 中包含 `wires[]`
2. 每条导线包含:
```text
start_terminal_uuid
end_terminal_uuid
```
3. FreeCAD 文档中存在对应 `QetTerminalUuid` 的工程端子。
操作:
```text
1. 创建线槽/路由网络
2. 点击“按导线任务自动布线全部”
```
注意:批量自动布线的依据是导线任务,不是“所有端子自动互连”。如果文档中只有端子而没有 `wires[]``QETWiring_01_Tasks`,系统不能判断哪些端子应该连接。
## 7. 当前限制
当前版本可完成自动布线原型,但仍有以下限制:
1. 线槽实体中心线生成基于包围盒长轴,不理解真实线槽开口、盖板和内部空间。
2. 多根线会沿同一路径生成,暂未做并行错位排列。
3. 未计算线槽填充率和容量。
4. 未考虑线径、最小弯曲半径。
5. 未做强弱电分槽、线缆类型隔离。
6. 障碍检测基于 AABB存在误报和漏报。
7. 辅助路由区域是网格近似,不等于专业软件的完整布线区域建模。
8. 端子出线方向依赖端子 LCS 方向;如果模板端子方向不准,自动布线会受影响。
9. 导线几何当前保存在 FreeCAD 文档,不作为第一版数据库字段回写。
## 8. 后续需要完成
### 8.1 近期优先级
1. 线槽语义库
```text
WireDuct:
centerline
width
height
inlet/outlet
usable_area
cover_state
```
2. 多根线错位排列
```text
同一路径上的多根导线按 lane_offset 排列
避免完全重叠
```
3. 线槽容量和填充率
```text
按线径估算截面积
计算 duct fill ratio
超过阈值给出警告
```
4. 端子出线规则
```text
根据端子 LCS 方向、设备安装面、接线孔方向生成更合理的短出线段
```
### 8.2 中期能力
1. 真实几何碰撞检测
从 AABB 升级到更精确的 Shape / BRep 碰撞检测,降低误报。
2. 弯曲半径约束
根据线缆类型和线径控制转弯半径。
3. 线束对象
把多根同路导线抽象成 bundle并支持展开显示。
4. 规则约束
支持:
```text
强电 / 弱电隔离
PE 线优先路径
屏蔽线规则
不同电压等级分槽
禁布区域
```
5. 更智能的线槽识别
根据对象名称、尺寸比例、设备库语义自动识别线槽,而不完全依赖用户选择。
### 8.3 长期能力
1. 接近 EPLAN / SOLIDWORKS 的工程级路由器。
2. 完整导线长度统计。
3. 自动生成线束报表。
4. 2D / 3D 导线一致性校验。
5. 自动推荐线槽路径和线槽容量。
6. 多柜体、多门板、多安装板之间的跨区域路由。
7. 可视化布线诊断面板。
## 9. 验收标准
当前版本验收只看“能否自动布线”:
1. 文档中有至少两个工程端子。
2. 文档中有至少一条 `WireDuct``RoutingPath` carrier。
3. 选择两个端子后执行单线测试布线,能生成 `AutoSuggested` 导线。
4. 存在导线任务时执行“按导线任务自动布线全部”,能批量生成 `AutoSuggested` 导线。
5. 生成导线在 `QETWiring_04_Routed` 下可见。
6. 没有路由网络时不生成长距离悬空线。
7. 没有导线任务时,批量布线明确提示缺少连接关系。
8. 明显碰撞时状态为 `CollisionWarning`
9. 保存 FreeCAD 文档后,自动导线和路由网络仍保留。
## 10. 开发验证命令
单元测试:
```powershell
D:\FreeCAD-1.1.1\bin\python.exe -m unittest discover -s tests\python -p "freecad_exchange*_test.py"
```
真实 FreeCAD smoke
```powershell
D:\FreeCAD-1.1.1\bin\python.exe tests\manual\freecad_auto_routing_smoke.py
```
语法检查:
```powershell
D:\FreeCAD-1.1.1\bin\python.exe -m py_compile src\Mod\FreeCADExchange\AutoRouting.py src\Mod\FreeCADExchange\RoutingNetwork.py src\Mod\FreeCADExchange\AutoRoutingPanel.py
```

@ -0,0 +1,922 @@
# FreeCADExchange 3D automatic wiring.
#
# 第一版不碰 C++,也不把 3D 走线结果写进数据库。
# 它只读取 FreeCAD 文档里的端子、走线网络和几何障碍,
# 然后在 QETWiring_04_Routed 下生成一条可见的折线导线。
import json
import FreeCAD as App
try:
import FreeCADGui as Gui
except ImportError:
Gui = None
import RoutingNetwork
import TerminalObjects
import WiringObjects
DEFAULT_OPTIONS = {
# 端子出来先走一小段,避免导线贴着设备外壳起步。
"terminal_exit_length": 20.0,
# 没有线槽网络时,退回到这个方向抬高/偏移后做正交折线。
"clearance_axis": "z",
"clearance": 80.0,
"lane_axis": "y",
"lane_spacing": 10.0,
# 线槽网络相关参数。
"use_routing_network": True,
"network_entry_max_distance": 0.0,
"bend_penalty": 25.0,
# EPLAN/SOLIDWORKS 风格:线槽/路由路径最优先,辅助面域只作为过渡/兜底区域。
"carrier_kind_cost_factors": {
"WireDuct": 1.0,
"RoutingPath": 1.0,
"UserPath": 1.0,
"AuxiliaryPath": 2.0,
"RoutingRange": 8.0,
"SurfaceGrid": 8.0,
},
# 默认不再生成长距离悬空 fallback主干必须走 carrier/贴面网络。
"allow_floating_fallback": False,
# 障碍包围盒会按这个距离膨胀,用于提前发现贴碰风险。
"obstacle_clearance": 5.0,
"replace_existing": True,
}
class AutoRoutingError(RuntimeError):
pass
def _merged_options(options):
merged = dict(DEFAULT_OPTIONS)
if isinstance(options, dict):
merged.update(options)
return merged
def _vector(point):
if isinstance(point, App.Vector):
return App.Vector(point.x, point.y, point.z)
if isinstance(point, (list, tuple)) and len(point) >= 3:
return App.Vector(float(point[0]), float(point[1]), float(point[2]))
if isinstance(point, dict):
return App.Vector(
float(point.get("x", 0.0)),
float(point.get("y", 0.0)),
float(point.get("z", 0.0)),
)
if all(hasattr(point, name) for name in ("x", "y", "z")):
return App.Vector(float(point.x), float(point.y), float(point.z))
return App.Vector(0, 0, 0)
def _distance(left, right):
dx = float(left.x) - float(right.x)
dy = float(left.y) - float(right.y)
dz = float(left.z) - float(right.z)
return (dx * dx + dy * dy + dz * dz) ** 0.5
def _vector_close(left, right, tolerance=0.000001):
return _distance(left, right) <= tolerance
def _point_payload(point):
return {
"x": float(point.x),
"y": float(point.y),
"z": float(point.z),
}
def _append_unique(points, point):
vector = _vector(point)
if not points or not _vector_close(points[-1], vector):
points.append(vector)
def _axis_value(point, axis):
return float(getattr(point, axis, 0.0))
def _with_axis(point, axis, value):
return App.Vector(
float(value) if axis == "x" else float(point.x),
float(value) if axis == "y" else float(point.y),
float(value) if axis == "z" else float(point.z),
)
def _orthogonal_points(start_point, end_point, preferred_axis=None):
if _vector_close(start_point, end_point):
return [start_point]
# 每一段只沿一个坐标轴移动,这样生成的线天然是机柜布线常见的折线。
axis_order = sorted(
("x", "y", "z"),
key=lambda axis: abs(_axis_value(end_point, axis) - _axis_value(start_point, axis)),
reverse=True,
)
if preferred_axis in {"x", "y", "z"}:
axis_order = [axis for axis in axis_order if axis != preferred_axis]
axis_order.append(preferred_axis)
points = [start_point]
current = start_point
for axis in axis_order:
target = _axis_value(end_point, axis)
if abs(_axis_value(current, axis) - target) <= 0.000001:
continue
current = _with_axis(current, axis, target)
_append_unique(points, current)
_append_unique(points, end_point)
return points
def _append_orthogonal(points, target_point, preferred_axis=None):
if not points:
_append_unique(points, target_point)
return
segment = _orthogonal_points(points[-1], _vector(target_point), preferred_axis)
for point in segment[1:]:
_append_unique(points, point)
def _offset(point, direction, distance):
return App.Vector(
float(point.x) + float(direction.x) * float(distance),
float(point.y) + float(direction.y) * float(distance),
float(point.z) + float(direction.z) * float(distance),
)
def _terminal_origin(terminal):
return _vector(TerminalObjects.terminal_origin(terminal))
def _terminal_direction(terminal):
try:
return _vector(TerminalObjects.terminal_direction(terminal))
except Exception:
return App.Vector(0, 0, 1)
def _project_uuid(doc, start_terminal=None, end_terminal=None):
for obj in (start_terminal, end_terminal):
value = (getattr(obj, "QetProjectUuid", "") or "").strip()
if value:
return value
try:
return (getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "") or "").strip()
except Exception:
return ""
def index_terminals(doc):
"""Return {terminal_uuid: terminal_object} for routable engineering terminals."""
if doc is None:
return {}
terminals = []
root = None
try:
root = doc.getObject(TerminalObjects.ROOT_GROUP_NAME)
except Exception:
root = None
if root is not None:
terminals.extend(TerminalObjects.collect_terminal_objects(root))
terminals.extend(
obj
for obj in list(getattr(doc, "Objects", []) or [])
if TerminalObjects.is_terminal_object(obj)
)
indexed = {}
for terminal in terminals:
terminal_uuid = (getattr(terminal, "QetTerminalUuid", "") or "").strip()
if terminal_uuid and terminal_uuid not in indexed:
indexed[terminal_uuid] = terminal
return indexed
def _wire_object_name(start_terminal, end_terminal, wire_uuid=""):
if wire_uuid:
return "QETAutoWire_{0}".format(TerminalObjects.safe_token(wire_uuid))
return "QETAutoWire_{0}_{1}".format(
TerminalObjects.safe_token(getattr(start_terminal, "QetTerminalUuid", "")),
TerminalObjects.safe_token(getattr(end_terminal, "QetTerminalUuid", "")),
)
def _unique_name(doc, base_name):
name = TerminalObjects.safe_token(base_name)
if doc.getObject(name) is None:
return name
suffix = 1
while doc.getObject("{0}_{1}".format(name, suffix)) is not None:
suffix += 1
return "{0}_{1}".format(name, suffix)
def _create_wire_geometry(doc, name, points):
if getattr(App, "ActiveDocument", None) is doc:
try:
import Draft
obj = Draft.make_wire(
points,
closed=False,
placement=None,
face=None,
support=None,
bs2wire=False,
)
if obj is not None:
_set_points(obj, points)
return obj
except Exception:
pass
import Part
obj = doc.addObject("Part::Feature", name)
obj.Shape = Part.makePolygon(points)
_set_points(obj, points)
return obj
def _set_points(obj, points):
try:
if "Points" not in getattr(obj, "PropertiesList", []):
obj.addProperty("App::PropertyVectorList", "Points", "QET Wiring", "Auto route points")
obj.Points = list(points)
except Exception:
pass
def _set_string(obj, name, value, description="Auto-routing property"):
TerminalObjects.ensure_string_property(obj, name, "QET Routing", description, value)
def _route_payload(route_data, collisions):
return {
"algorithm": route_data.get("algorithm", ""),
"points": [_point_payload(point) for point in route_data.get("points", [])],
"collisions": collisions,
"network": route_data.get("network", {}),
}
def _set_auto_metadata(wire, route_data, collisions):
_set_string(
wire,
"QetAutoRouteAlgorithm",
route_data.get("algorithm", ""),
"Auto-routing algorithm used for this wire",
)
_set_string(
wire,
"QetAutoRouteDiagnosticsJson",
json.dumps(_route_payload(route_data, collisions), ensure_ascii=False),
"Auto-routing diagnostics",
)
if route_data.get("network"):
_set_string(
wire,
"QetAutoRouteNetworkJson",
json.dumps(route_data.get("network", {}), ensure_ascii=False),
"Route network metadata used by this wire",
)
def build_orthogonal_route(start_terminal, end_terminal, route_index=0, options=None):
opts = _merged_options(options)
start_origin = _terminal_origin(start_terminal)
end_origin = _terminal_origin(end_terminal)
exit_length = max(float(opts.get("terminal_exit_length", 0.0) or 0.0), 0.0)
start_exit = _offset(start_origin, _terminal_direction(start_terminal), exit_length)
end_exit = _offset(end_origin, _terminal_direction(end_terminal), exit_length)
clearance_axis = (opts.get("clearance_axis") or "z").lower()
if clearance_axis not in {"x", "y", "z"}:
clearance_axis = "z"
lane_axis = (opts.get("lane_axis") or "y").lower()
if lane_axis not in {"x", "y", "z"}:
lane_axis = "y"
clearance_value = max(
_axis_value(start_exit, clearance_axis),
_axis_value(end_exit, clearance_axis),
) + float(opts.get("clearance", 0.0) or 0.0)
lane_offset = float(route_index or 0) * float(opts.get("lane_spacing", 0.0) or 0.0)
lane_point = _with_axis(start_exit, clearance_axis, clearance_value)
lane_point = _with_axis(lane_point, lane_axis, _axis_value(lane_point, lane_axis) + lane_offset)
end_lane = _with_axis(end_exit, clearance_axis, clearance_value)
end_lane = _with_axis(end_lane, lane_axis, _axis_value(end_lane, lane_axis) + lane_offset)
points = []
_append_unique(points, start_origin)
_append_unique(points, start_exit)
_append_orthogonal(points, lane_point, preferred_axis=clearance_axis)
_append_orthogonal(points, end_lane)
_append_orthogonal(points, end_exit, preferred_axis=clearance_axis)
_append_unique(points, end_origin)
return {
"algorithm": "orthogonal-v1",
"points": points,
"network": {},
}
def build_network_route(start_terminal, end_terminal, route_index=0, options=None, doc=None):
opts = _merged_options(options)
if not opts.get("use_routing_network", True):
return None
if doc is None:
doc = getattr(start_terminal, "Document", None) or getattr(App, "ActiveDocument", None)
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
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)
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),
},
}
def _is_group(obj):
try:
return bool(obj.isDerivedFrom("App::DocumentObjectGroup"))
except Exception:
return False
def _is_origin_helper(obj):
type_id = (getattr(obj, "TypeId", "") or "").lower()
name = (getattr(obj, "Name", "") or "").lower()
label = (getattr(obj, "Label", "") or "").lower()
compact_name = "".join(ch for ch in name if not ch.isdigit()).replace("-", "_")
compact_label = "".join(ch for ch in label if not ch.isdigit()).replace("-", "_")
helper_names = {
"origin",
"xy_plane",
"xz_plane",
"yz_plane",
"x_axis",
"y_axis",
"z_axis",
}
if "origin" in type_id or compact_name in helper_names:
return True
return compact_label in helper_names or compact_label.replace(" ", "_") in helper_names
def _bbox_payload(obj, clearance=0.0):
shape = getattr(obj, "Shape", None)
bbox = getattr(shape, "BoundBox", None)
if bbox is None:
return None
return {
"xmin": float(bbox.XMin) - clearance,
"xmax": float(bbox.XMax) + clearance,
"ymin": float(bbox.YMin) - clearance,
"ymax": float(bbox.YMax) + clearance,
"zmin": float(bbox.ZMin) - clearance,
"zmax": float(bbox.ZMax) + clearance,
}
def collect_obstacles(doc, exclude=None, options=None):
opts = _merged_options(options)
excluded = set(id(obj) for obj in (exclude or []) if obj is not None)
clearance = float(opts.get("obstacle_clearance", 0.0) or 0.0)
obstacles = []
for obj in list(getattr(doc, "Objects", []) or []):
if id(obj) in excluded:
continue
obstacle_mode = (getattr(obj, "QetRoutingObstacleMode", "") or "").strip()
if obstacle_mode in {"PassThrough", "WireDuctPassThrough"}:
continue
if _is_group(obj) or _is_origin_helper(obj):
continue
if TerminalObjects.is_lcs_like(obj) or TerminalObjects.is_terminal_object(obj):
continue
if RoutingNetwork.is_route_carrier(obj) or WiringObjects.is_routed_wire_object(obj):
continue
bbox = _bbox_payload(obj, clearance=clearance)
if bbox is None:
continue
obstacles.append(
{
"name": getattr(obj, "Name", ""),
"label": getattr(obj, "Label", ""),
"type_id": getattr(obj, "TypeId", ""),
"bbox": bbox,
}
)
return obstacles
def _segment_intersects_bbox(start, end, bbox):
# Slab intersection: 把线段参数化为 start + t*(end-start)t 在 [0, 1] 内相交即命中。
t_min = 0.0
t_max = 1.0
for axis, min_key, max_key in (
("x", "xmin", "xmax"),
("y", "ymin", "ymax"),
("z", "zmin", "zmax"),
):
start_value = _axis_value(start, axis)
end_value = _axis_value(end, axis)
delta = end_value - start_value
low = float(bbox[min_key])
high = float(bbox[max_key])
if abs(delta) <= 0.000001:
if start_value < low or start_value > high:
return False
continue
inv = 1.0 / delta
near = (low - start_value) * inv
far = (high - start_value) * inv
if near > far:
near, far = far, near
t_min = max(t_min, near)
t_max = min(t_max, far)
if t_min > t_max:
return False
return True
def detect_collisions(points, obstacles):
collisions = []
for index in range(max(len(points) - 1, 0)):
start = points[index]
end = points[index + 1]
for obstacle in obstacles:
if _segment_intersects_bbox(start, end, obstacle["bbox"]):
collisions.append(
{
"segment_index": index,
"obstacle_name": obstacle.get("name", ""),
"obstacle_label": obstacle.get("label", ""),
}
)
return collisions
def _remove_existing_auto_routes(doc, start_uuid, end_uuid, wire_uuid=""):
removed = 0
for obj in list(WiringObjects.iter_routed_wire_objects(doc)):
if (getattr(obj, "RouteType", "") or "").strip() != "AutoSuggested":
continue
if wire_uuid and (getattr(obj, "QetWireUuid", "") or "").strip() != wire_uuid:
continue
same_direction = (
(getattr(obj, "QetStartTerminalUuid", "") or "").strip() == start_uuid
and (getattr(obj, "QetEndTerminalUuid", "") or "").strip() == end_uuid
)
reverse_direction = (
(getattr(obj, "QetStartTerminalUuid", "") or "").strip() == end_uuid
and (getattr(obj, "QetEndTerminalUuid", "") or "").strip() == start_uuid
)
if not same_direction and not reverse_direction:
continue
try:
doc.removeObject(obj.Name)
removed += 1
except Exception:
pass
return removed
def _find_task_by_wire_uuid(doc, wire_uuid):
if not wire_uuid:
return None
try:
task_group = doc.getObject("QETWiring_01_Tasks")
except Exception:
task_group = None
if task_group is None:
return None
for task in list(getattr(task_group, "Group", []) or []):
if (getattr(task, "QetWireUuid", "") or "").strip() == wire_uuid:
return task
return None
def _set_task_status(task, status):
if task is None:
return
TerminalObjects.ensure_string_property(
task,
"RouteStatus",
"QET Wiring",
"Wire task route status",
status,
)
def _style_wire(wire, collision_count=0):
try:
wire.ViewObject.Visibility = True
wire.ViewObject.LineWidth = 3.0
if collision_count:
wire.ViewObject.LineColor = (1.0, 0.1, 0.0)
else:
wire.ViewObject.LineColor = (0.0, 0.35, 1.0)
except Exception:
pass
def route_between_terminals(
doc,
start_terminal,
end_terminal,
route_index=0,
options=None,
wire_uuid="",
wire_label="",
net_uuid="",
group_uuid="",
wire_mark="",
wire_mark_is_manual=False,
):
if doc is None:
raise AutoRoutingError("No FreeCAD document is available.")
if not TerminalObjects.is_terminal_object(start_terminal):
raise AutoRoutingError("Start object is not a routable terminal.")
if not TerminalObjects.is_terminal_object(end_terminal):
raise AutoRoutingError("End object is not a routable terminal.")
if start_terminal == end_terminal:
raise AutoRoutingError("Start and end terminal must be different.")
opts = _merged_options(options)
start_uuid = (getattr(start_terminal, "QetTerminalUuid", "") or "").strip()
end_uuid = (getattr(end_terminal, "QetTerminalUuid", "") or "").strip()
project_uuid = _project_uuid(doc, start_terminal, end_terminal)
if not project_uuid:
raise AutoRoutingError("Project UUID is required for auto-routing.")
if opts.get("replace_existing", True):
_remove_existing_auto_routes(doc, start_uuid, end_uuid, wire_uuid=wire_uuid)
route_data = build_network_route(
start_terminal,
end_terminal,
route_index=route_index,
options=opts,
doc=doc,
)
if route_data is None:
if not opts.get("allow_floating_fallback", False):
raise AutoRoutingError(
"没有可用的线槽/路由路径网络;请先选择线槽中心线、草图路径或必要的辅助路由区域。"
)
route_data = build_orthogonal_route(
start_terminal,
end_terminal,
route_index=route_index,
options=opts,
)
points = route_data.get("points", [])
if len(points) < 2:
raise AutoRoutingError("Auto-routing produced fewer than two points.")
obstacles = collect_obstacles(doc, exclude=[start_terminal, end_terminal], options=opts)
collisions = detect_collisions(points, obstacles)
status = "CollisionWarning" if collisions else "Routed"
wire_name = _unique_name(doc, _wire_object_name(start_terminal, end_terminal, wire_uuid))
wire = _create_wire_geometry(doc, wire_name, points)
wire.Label = wire_label or wire_mark or wire_uuid or "QET Auto Wire"
WiringObjects.set_routed_wire_semantics(
wire,
project_uuid,
wire_uuid,
wire_label or wire_mark or wire_uuid,
start_uuid,
end_uuid,
(getattr(start_terminal, "QetInstanceId", "") or "").strip(),
(getattr(end_terminal, "QetInstanceId", "") or "").strip(),
route_type="AutoSuggested",
route_status=status,
route_mode="Auto",
net_uuid=net_uuid,
group_uuid=group_uuid,
wire_mark=wire_mark,
wire_mark_is_manual=wire_mark_is_manual,
)
_set_auto_metadata(wire, route_data, collisions)
routed_group = WiringObjects.ensure_routed_group(doc, project_uuid)
if wire not in getattr(routed_group, "Group", []):
routed_group.addObject(wire)
try:
routed_group.ViewObject.Visibility = True
except Exception:
pass
_style_wire(wire, collision_count=len(collisions))
task = _find_task_by_wire_uuid(doc, wire_uuid)
_set_task_status(task, status)
try:
doc.recompute()
except Exception:
pass
return {
"wire": wire,
"route_status": status,
"algorithm": route_data.get("algorithm", ""),
"points": points,
"collision_count": len(collisions),
"collisions": collisions,
}
def _wire_item_value(item, *names):
if not isinstance(item, dict):
return ""
for name in names:
value = item.get(name, "")
if value is not None and str(value).strip():
return str(value).strip()
return ""
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.")
terminals = index_terminals(doc)
wires = payload.get("wires", []) or []
report = {
"total_wires": len(wires),
"routed": 0,
"collision_warnings": 0,
"skipped_missing_terminal": 0,
"skipped_invalid": 0,
"errors": [],
"routes": [],
}
for index, item in enumerate(wires):
if not isinstance(item, dict):
report["skipped_invalid"] += 1
continue
start_uuid = _wire_item_value(item, "start_terminal_uuid")
end_uuid = _wire_item_value(item, "end_terminal_uuid")
start_terminal = terminals.get(start_uuid)
end_terminal = terminals.get(end_uuid)
if start_terminal is None or end_terminal is None:
report["skipped_missing_terminal"] += 1
continue
try:
result = route_between_terminals(
doc,
start_terminal,
end_terminal,
route_index=index,
options=options,
wire_uuid=_wire_item_value(item, "wire_id", "wire_uuid", "id"),
wire_label=_wire_item_value(item, "wire_label", "wire_mark"),
net_uuid=_wire_item_value(item, "net_uuid"),
group_uuid=_wire_item_value(item, "group_uuid"),
wire_mark=_wire_item_value(item, "wire_mark"),
wire_mark_is_manual=bool(item.get("wire_mark_is_manual", False)),
)
except Exception as exc:
report["errors"].append(str(exc))
continue
if result["route_status"] == "CollisionWarning":
report["collision_warnings"] += 1
report["routed"] += 1
report["routes"].append(
{
"wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"),
"algorithm": result["algorithm"],
"route_status": result["route_status"],
"collision_count": result["collision_count"],
}
)
return report
def _iter_wire_tasks(doc):
try:
task_group = doc.getObject("QETWiring_01_Tasks")
except Exception:
task_group = None
if task_group is None:
return []
return [
task
for task in list(getattr(task_group, "Group", []) or [])
if (getattr(task, "RouteType", "") or "").strip() == "Task"
]
def route_all_tasks(doc, options=None):
payload = {"wires": []}
for task in _iter_wire_tasks(doc):
payload["wires"].append(
{
"wire_id": (getattr(task, "QetWireUuid", "") or "").strip(),
"wire_label": (getattr(task, "QetWireLabel", "") or "").strip(),
"wire_mark": (getattr(task, "QetWireMark", "") or "").strip(),
"wire_mark_is_manual": bool(getattr(task, "QetWireMarkIsManual", False)),
"net_uuid": (getattr(task, "QetNetUuid", "") or "").strip(),
"group_uuid": (getattr(task, "QetGroupUuid", "") or "").strip(),
"start_terminal_uuid": (getattr(task, "QetStartTerminalUuid", "") or "").strip(),
"end_terminal_uuid": (getattr(task, "QetEndTerminalUuid", "") or "").strip(),
}
)
return route_all_from_payload(doc, payload, options=options)
def wire_task_count(doc):
return len(_iter_wire_tasks(doc))
def clear_auto_routes(doc):
removed = 0
for obj in list(WiringObjects.iter_routed_wire_objects(doc)):
if (getattr(obj, "RouteType", "") or "").strip() != "AutoSuggested":
continue
try:
doc.removeObject(obj.Name)
removed += 1
except Exception:
pass
try:
doc.recompute()
except Exception:
pass
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))
except Exception:
pass
def _console_error(message):
try:
App.Console.PrintError("[FreeCADExchange] {0}\n".format(message))
except Exception:
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 自动布线路径",
}
def IsActive(self):
return getattr(App, "ActiveDocument", None) is not None
def Activated(self):
doc = getattr(App, "ActiveDocument", None)
try:
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)
if report.get("total_wires", 0) <= 0:
_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),
)
)
except Exception as exc:
_console_error("批量自动布线失败:{0}".format(exc))
_COMMANDS_REGISTERED = False
def register_commands():
global _COMMANDS_REGISTERED
if _COMMANDS_REGISTERED:
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
register_commands()

@ -0,0 +1,366 @@
# FreeCADExchange GUI panel for 3D automatic wiring.
#
# 面板只调用 Python 算法:扫描端子/走线网络、从选择创建 carrier、
# 对选中端子或全部导线任务执行自动布线。
import FreeCAD as App
try:
import FreeCADGui as Gui
except ImportError:
Gui = None
try:
from PySide6 import QtWidgets
except ImportError:
try:
from PySide2 import QtWidgets
except ImportError:
try:
from PySide import QtGui as QtWidgets
except ImportError:
QtWidgets = None
import AutoRouting
import RoutingNetwork
import TerminalObjects
try:
import ExchangeWriteBack
except Exception:
ExchangeWriteBack = None
COMMAND_NAME = "QET_Exchange_OpenAutoRoutingPanel"
class AutoRoutingPanelError(RuntimeError):
pass
def _active_document():
doc = getattr(App, "ActiveDocument", None)
if doc is None:
raise AutoRoutingPanelError("请先打开 QET 3D 工程文档。")
return doc
def _console_message(message):
try:
App.Console.PrintMessage("[FreeCADExchange] {0}\n".format(message))
except Exception:
pass
def _console_error(message):
try:
App.Console.PrintError("[FreeCADExchange] {0}\n".format(message))
except Exception:
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 []
try:
return list(Gui.Selection.getSelectionEx() or [])
except Exception:
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
def summary(self):
doc = _active_document()
terminal_count = len(AutoRouting.index_terminals(doc))
task_count = AutoRouting.wire_task_count(doc)
payload = getattr(App, "_qet_exchange_payload", None)
payload_wire_count = 0
if isinstance(payload, dict) and isinstance(payload.get("wires"), list):
payload_wire_count = len(payload.get("wires") or [])
network = RoutingNetwork.network_summary(doc)
kinds = network.get("kinds", {}) if isinstance(network.get("kinds", {}), dict) else {}
kind_text = ""
if kinds:
kind_text = ";类型:" + "".join(
"{0}={1}".format(key, value)
for key, value in sorted(kinds.items())
)
return "端子:{0};导线任务:{1}QET导线{2};路由网络:{3} 条 carrier / {4} 段 / {5} 节点{6}".format(
terminal_count,
task_count,
payload_wire_count,
network.get("carriers", 0),
network.get("segments", 0),
network.get("nodes", 0),
kind_text,
)
def create_carriers_from_selection(self):
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
def create_wire_duct_carriers_from_selection(self):
doc = _active_document()
project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip()
created = RoutingNetwork.create_wire_duct_carriers_from_selection(
doc,
_selection_ex(),
project_uuid=project_uuid,
)
self.last_report = {"created_wire_duct_carriers": len(created)}
return created
def create_surface_carriers_from_selection(self):
doc = _active_document()
project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip()
created = RoutingNetwork.create_surface_carriers_from_selection(
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)
if report.get("total_wires", 0) <= 0:
raise AutoRoutingPanelError(
"没有导线任务,不能判断哪些端子应该互相连接。请先从 QET 导入 wires[],或确认 QETWiring_01_Tasks 中存在导线任务。"
)
self.last_report = report
return report
def clear_auto_routes(self):
doc = _active_document()
removed = AutoRouting.clear_auto_routes(doc)
self.last_report = {"removed": removed}
return removed
def clear_route_carriers(self):
doc = _active_document()
removed = RoutingNetwork.clear_route_carriers(doc)
self.last_report = {"removed_carriers": removed}
return removed
def save(self):
doc = _active_document()
file_name = getattr(doc, "FileName", "")
if file_name and hasattr(doc, "save"):
doc.save()
if ExchangeWriteBack is not None:
return ExchangeWriteBack.write_back_document(doc)
return {"saved": bool(file_name)}
class AutoRoutingTaskPanel:
def __init__(self, controller=None):
if QtWidgets is None:
raise AutoRoutingPanelError("Qt widgets are not available.")
self.controller = controller or AutoRoutingController()
self.form = QtWidgets.QWidget()
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.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.route_all_button,
self.clear_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)
self.route_all_button.clicked.connect(self.route_all)
self.clear_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)
def _set_error(self, message):
self.status_label.setText(message)
_console_error(message)
def scan(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 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 create_surface_carriers(self):
try:
created = self.controller.create_surface_carriers_from_selection()
self._set_status("已创建辅助路由区域:{0} 条。{1}".format(len(created), self.controller.summary()))
except Exception as exc:
self._set_error(str(exc))
def route_selected(self):
try:
result = self.controller.route_selected()
self._set_status(
"自动布线完成algorithm={0}, status={1}, collisions={2}".format(
result.get("algorithm", ""),
result.get("route_status", ""),
result.get("collision_count", 0),
)
)
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),
)
)
except Exception as exc:
self._set_error(str(exc))
def clear_auto_routes(self):
try:
removed = self.controller.clear_auto_routes()
self._set_status("已清除自动布线:{0} 条。".format(removed))
except Exception as exc:
self._set_error(str(exc))
def clear_route_carriers(self):
try:
removed = self.controller.clear_route_carriers()
self._set_status("已清除走线路径:{0} 条。{1}".format(removed, self.controller.summary()))
except Exception as exc:
self._set_error(str(exc))
def save(self):
try:
report = self.controller.save()
output_path = report.get("output_path", "") if isinstance(report, dict) else ""
self._set_status("已保存。{0}".format(output_path))
except Exception as exc:
self._set_error(str(exc))
def accept(self):
return True
def reject(self):
return True
class CommandOpenAutoRoutingPanel:
def GetResources(self):
return {
"MenuText": "3D自动布线",
"ToolTip": "打开 3D 自动布线面板",
}
def IsActive(self):
return getattr(App, "ActiveDocument", None) is not None and Gui is not None
def Activated(self):
if Gui is None or not hasattr(Gui, "Control"):
return
if hasattr(Gui.Control, "activeDialog") and Gui.Control.activeDialog():
Gui.Control.closeDialog()
Gui.Control.showDialog(AutoRoutingTaskPanel())
_COMMANDS_REGISTERED = False
def register_commands():
global _COMMANDS_REGISTERED
if _COMMANDS_REGISTERED:
return
if Gui is None or not hasattr(Gui, "addCommand"):
return
Gui.addCommand(COMMAND_NAME, CommandOpenAutoRoutingPanel())
_COMMANDS_REGISTERED = True
register_commands()

@ -13,6 +13,9 @@ set(FreeCADExchange_Scripts
TerminalImport.py TerminalImport.py
WiringObjects.py WiringObjects.py
WiringImport.py WiringImport.py
RoutingNetwork.py
AutoRouting.py
AutoRoutingPanel.py
ExchangeWriteBack.py ExchangeWriteBack.py
ManualWiring.py ManualWiring.py
ManualWiringPanel.py ManualWiringPanel.py

@ -16,6 +16,9 @@ COMMANDS = [
"QET_Template_CreateEngineeringTerminals", "QET_Template_CreateEngineeringTerminals",
"QET_Exchange_CreateManualWire", "QET_Exchange_CreateManualWire",
"QET_Exchange_OpenManualWiringPanel", "QET_Exchange_OpenManualWiringPanel",
"QET_Exchange_AutoRouteSelected",
"QET_Exchange_AutoRouteAll",
"QET_Exchange_OpenAutoRoutingPanel",
] ]
@ -63,6 +66,8 @@ def _register_exchange_commands(
traceback_module=traceback, traceback_module=traceback,
): ):
exchange_write_back = safe_import("ExchangeWriteBack") exchange_write_back = safe_import("ExchangeWriteBack")
auto_routing = safe_import("AutoRouting")
auto_routing_panel = safe_import("AutoRoutingPanel")
manual_wiring = safe_import("ManualWiring") manual_wiring = safe_import("ManualWiring")
manual_wiring_panel = safe_import("ManualWiringPanel") manual_wiring_panel = safe_import("ManualWiringPanel")
template_authoring = safe_import("TemplateAuthoring") template_authoring = safe_import("TemplateAuthoring")
@ -89,6 +94,26 @@ def _register_exchange_commands(
) )
) )
try:
if auto_routing is not None:
auto_routing.register_commands()
except Exception:
append_init_log(
"InitGui failed to register auto-routing commands:\n{0}".format(
traceback_module.format_exc()
)
)
try:
if auto_routing_panel is not None:
auto_routing_panel.register_commands()
except Exception:
append_init_log(
"InitGui failed to register auto-routing panel command:\n{0}".format(
traceback_module.format_exc()
)
)
try: try:
if manual_wiring is not None: if manual_wiring is not None:
manual_wiring.register_commands() manual_wiring.register_commands()

@ -0,0 +1,875 @@
# FreeCADExchange route carrier network helpers.
#
# 这个模块只管理 FreeCAD 文档里的走线网络,不写数据库。
# 第一版的思路是:用户或模板把线槽/导轨中心线标成 carrier
# 自动布线算法再沿这些 carrier 做最短路搜索。
import heapq
import FreeCAD as App
import TerminalObjects
import WiringObjects
ROUTING_ROLE = "RoutingCarrier"
ROUTE_CARRIER_KIND = "RoutingPath"
ROUTE_CARRIER_KIND_WIRE_DUCT = "WireDuct"
ROUTE_CARRIER_KIND_AUXILIARY_PATH = "AuxiliaryPath"
ROUTE_CARRIER_KIND_ROUTING_RANGE = "RoutingRange"
PROPERTY_GROUP = "QET Routing"
DEFAULT_NODE_TOLERANCE = 0.001
DEFAULT_SURFACE_LANE_SPACING = 100.0
DEFAULT_SURFACE_OFFSET = 5.0
DEFAULT_SURFACE_MARGIN = 20.0
DEFAULT_WIRE_DUCT_MARGIN = 20.0
WIRE_DUCT_OBSTACLE_MODE = "PassThrough"
DEFAULT_KIND_COST_FACTORS = {
ROUTE_CARRIER_KIND_WIRE_DUCT: 1.0,
ROUTE_CARRIER_KIND: 1.0,
ROUTE_CARRIER_KIND_AUXILIARY_PATH: 2.0,
ROUTE_CARRIER_KIND_ROUTING_RANGE: 8.0,
# 旧文档兼容:之前的贴面网格使用 SurfaceGrid。
"SurfaceGrid": 8.0,
"UserPath": 1.0,
}
class RoutingNetworkError(RuntimeError):
pass
class _SimpleBoundBox:
def __init__(self, xmin, xmax, ymin, ymax, zmin, zmax):
self.XMin = float(xmin)
self.XMax = float(xmax)
self.YMin = float(ymin)
self.YMax = float(ymax)
self.ZMin = float(zmin)
self.ZMax = float(zmax)
def _vector(point):
if isinstance(point, App.Vector):
return App.Vector(point.x, point.y, point.z)
if isinstance(point, (list, tuple)) and len(point) >= 3:
return App.Vector(float(point[0]), float(point[1]), float(point[2]))
if isinstance(point, dict):
return App.Vector(
float(point.get("x", 0.0)),
float(point.get("y", 0.0)),
float(point.get("z", 0.0)),
)
if all(hasattr(point, name) for name in ("x", "y", "z")):
return App.Vector(float(point.x), float(point.y), float(point.z))
raise RoutingNetworkError("Route carrier point must be a 3D point.")
def _distance(left, right):
dx = float(left.x) - float(right.x)
dy = float(left.y) - float(right.y)
dz = float(left.z) - float(right.z)
return (dx * dx + dy * dy + dz * dz) ** 0.5
def _direction_key(left, right, tolerance=DEFAULT_NODE_TOLERANCE):
dx = float(right.x) - float(left.x)
dy = float(right.y) - float(left.y)
dz = float(right.z) - float(left.z)
length = (dx * dx + dy * dy + dz * dz) ** 0.5
if length <= tolerance:
return (0, 0, 0)
return (
int(round(dx / length * 1000.0)),
int(round(dy / length * 1000.0)),
int(round(dz / length * 1000.0)),
)
def _dominant_axis(vector):
components = {
"x": abs(float(getattr(vector, "x", 0.0))),
"y": abs(float(getattr(vector, "y", 0.0))),
"z": abs(float(getattr(vector, "z", 0.0))),
}
axis = max(components, key=components.get)
if components[axis] <= 0.000001:
return None
return axis
def _axis_value(point, axis):
return float(getattr(point, axis, 0.0))
def _set_axis(point, axis, value):
return App.Vector(
float(value) if axis == "x" else float(point.x),
float(value) if axis == "y" else float(point.y),
float(value) if axis == "z" else float(point.z),
)
def _bound_box_from_object(obj):
if obj is None:
return None
shape = getattr(obj, "Shape", None)
bbox = getattr(shape, "BoundBox", None)
if bbox is not None:
return bbox
bbox = getattr(obj, "BoundBox", None)
if bbox is not None:
return bbox
merged = None
for child in list(getattr(obj, "Group", []) or []):
child_bbox = _bound_box_from_object(child)
if child_bbox is None:
continue
if merged is None:
merged = _SimpleBoundBox(
child_bbox.XMin,
child_bbox.XMax,
child_bbox.YMin,
child_bbox.YMax,
child_bbox.ZMin,
child_bbox.ZMax,
)
continue
merged = _SimpleBoundBox(
min(merged.XMin, child_bbox.XMin),
max(merged.XMax, child_bbox.XMax),
min(merged.YMin, child_bbox.YMin),
max(merged.YMax, child_bbox.YMax),
min(merged.ZMin, child_bbox.ZMin),
max(merged.ZMax, child_bbox.ZMax),
)
return merged
def _bbox_center(bbox):
return App.Vector(
(float(bbox.XMin) + float(bbox.XMax)) * 0.5,
(float(bbox.YMin) + float(bbox.YMax)) * 0.5,
(float(bbox.ZMin) + float(bbox.ZMax)) * 0.5,
)
def _bbox_extent(bbox, axis):
low, high = _bbox_axis_range(bbox, axis)
return abs(high - low)
def _point_key(point, tolerance=DEFAULT_NODE_TOLERANCE):
scale = 1.0 / float(tolerance or DEFAULT_NODE_TOLERANCE)
return (
int(round(float(point.x) * scale)),
int(round(float(point.y) * scale)),
int(round(float(point.z) * scale)),
)
def _point_payload(point):
return {
"x": float(point.x),
"y": float(point.y),
"z": float(point.z),
}
def _unique_name(doc, base_name):
name = TerminalObjects.safe_token(base_name)
if doc.getObject(name) is None:
return name
suffix = 1
while doc.getObject("{0}_{1}".format(name, suffix)) is not None:
suffix += 1
return "{0}_{1}".format(name, suffix)
def _ensure_vector_list_property(obj, prop_name, description):
if prop_name not in getattr(obj, "PropertiesList", []):
obj.addProperty(
"App::PropertyVectorList",
prop_name,
PROPERTY_GROUP,
description,
)
def _set_route_carrier_semantics(obj, project_uuid="", kind=ROUTE_CARRIER_KIND):
TerminalObjects.ensure_string_property(
obj,
"QetRoutingRole",
PROPERTY_GROUP,
"Routing role marker",
ROUTING_ROLE,
)
TerminalObjects.ensure_string_property(
obj,
"QetRouteCarrierKind",
PROPERTY_GROUP,
"Route carrier kind",
kind,
)
TerminalObjects.ensure_string_property(
obj,
"QetProjectUuid",
PROPERTY_GROUP,
"Project UUID for this route carrier",
project_uuid,
)
TerminalObjects.ensure_bool_property(
obj,
"CanRouteWire",
PROPERTY_GROUP,
"Whether auto-routing can use this path",
True,
)
return obj
def _create_carrier_geometry(doc, name, points):
# Draft 生成的线在 GUI 里更容易看见;命令行/测试环境没有 Draft 时退回 Part。
if getattr(App, "ActiveDocument", None) is doc:
try:
import Draft
obj = Draft.make_wire(
points,
closed=False,
placement=None,
face=None,
support=None,
bs2wire=False,
)
if obj is not None:
return obj
except Exception:
pass
try:
import Part
obj = doc.addObject("Part::Feature", name)
obj.Shape = Part.makePolygon(points)
return obj
except Exception:
obj = doc.addObject("App::FeaturePython", name)
return obj
def create_route_carrier(doc, points, label="", project_uuid="", kind=ROUTE_CARRIER_KIND):
"""Create a routable carrier from ordered 3D points."""
if doc is None:
raise RoutingNetworkError("No FreeCAD document is available.")
normalized = []
for point in points or []:
vector = _vector(point)
if not normalized or _distance(normalized[-1], vector) > DEFAULT_NODE_TOLERANCE:
normalized.append(vector)
if len(normalized) < 2:
raise RoutingNetworkError("A route carrier requires at least two distinct points.")
name = _unique_name(doc, "QETRouteCarrier")
carrier = _create_carrier_geometry(doc, name, normalized)
carrier.Label = label or "QET Route Carrier"
_ensure_vector_list_property(
carrier,
"Points",
"Ordered centerline points used by the 3D router",
)
carrier.Points = list(normalized)
_set_route_carrier_semantics(carrier, project_uuid=project_uuid, kind=kind)
group = WiringObjects.ensure_carrier_group(doc, project_uuid)
if carrier not in getattr(group, "Group", []):
group.addObject(carrier)
try:
carrier.ViewObject.Visibility = True
carrier.ViewObject.LineWidth = 3.0
carrier.ViewObject.LineColor = (1.0, 0.55, 0.0)
except Exception:
pass
try:
doc.recompute()
except Exception:
pass
return carrier
def is_route_carrier(obj):
if obj is None:
return False
role = (getattr(obj, "QetRoutingRole", "") or "").strip()
return role == ROUTING_ROLE and bool(getattr(obj, "CanRouteWire", False))
def _carrier_points(obj):
points = list(getattr(obj, "Points", []) or [])
if points:
return [_vector(point) for point in points]
shape = getattr(obj, "Shape", None)
ordered = getattr(shape, "OrderedVertexes", None)
if ordered:
return [_vector(vertex.Point) for vertex in ordered if getattr(vertex, "Point", None) is not None]
vertexes = getattr(shape, "Vertexes", None)
if vertexes:
return [_vector(vertex.Point) for vertex in vertexes if getattr(vertex, "Point", None) is not None]
return []
def collect_route_carriers(doc):
if doc is None:
return []
group = None
try:
group = doc.getObject("QETWiring_02_Carriers")
except Exception:
group = None
candidates = []
if group is not None:
candidates.extend(list(getattr(group, "Group", []) or []))
candidates.extend(list(getattr(doc, "Objects", []) or []))
result = []
seen = set()
for obj in candidates:
if obj is None or id(obj) in seen:
continue
seen.add(id(obj))
if is_route_carrier(obj):
result.append(obj)
return result
def _detach_from_groups(doc, obj):
for parent in list(getattr(obj, "InList", []) or []):
group = list(getattr(parent, "Group", []) or [])
if obj not in group:
continue
try:
if hasattr(parent, "removeObject"):
parent.removeObject(obj)
else:
parent.Group = [child for child in group if child is not obj]
except Exception:
try:
parent.Group = [child for child in group if child is not obj]
except Exception:
pass
for parent in list(getattr(doc, "Objects", []) or []):
group = list(getattr(parent, "Group", []) or [])
if obj not in group:
continue
try:
if hasattr(parent, "removeObject"):
parent.removeObject(obj)
else:
parent.Group = [child for child in group if child is not obj]
except Exception:
try:
parent.Group = [child for child in group if child is not obj]
except Exception:
pass
def clear_route_carriers(doc):
"""Delete generated route carriers while keeping terminals and routed wires."""
removed = 0
for carrier in list(collect_route_carriers(doc)):
_detach_from_groups(doc, carrier)
try:
if doc.getObject(getattr(carrier, "Name", "")) is not None:
doc.removeObject(carrier.Name)
removed += 1
except Exception:
pass
try:
doc.recompute()
except Exception:
pass
return removed
def _shape_center(shape):
bbox = getattr(shape, "BoundBox", None)
if bbox is None:
return None
return App.Vector(
(float(bbox.XMin) + float(bbox.XMax)) * 0.5,
(float(bbox.YMin) + float(bbox.YMax)) * 0.5,
(float(bbox.ZMin) + float(bbox.ZMax)) * 0.5,
)
def _edge_points(edge):
first = None
last = None
vertexes = list(getattr(edge, "Vertexes", []) or [])
if len(vertexes) >= 2:
first = getattr(vertexes[0], "Point", None)
last = getattr(vertexes[-1], "Point", None)
if first is not None and last is not None:
return [_vector(first), _vector(last)]
try:
first = edge.valueAt(edge.FirstParameter)
last = edge.valueAt(edge.LastParameter)
return [_vector(first), _vector(last)]
except Exception:
return []
def _is_route_path_source_object(obj):
if obj is None:
return False
type_id = (getattr(obj, "TypeId", "") or "").lower()
if "sketch" in type_id:
return True
if list(getattr(obj, "Points", []) or []):
return True
shape = getattr(obj, "Shape", None)
if shape is None:
return False
# SOLIDWORKS/EPLAN 的 routing path 是草图/线槽路径,不是把实体零件的全部边都当路径。
# 所以只有纯线状对象才允许整对象转换;带 Face/Solid 的实体必须显式选中边。
faces = list(getattr(shape, "Faces", []) or [])
solids = list(getattr(shape, "Solids", []) or [])
shells = list(getattr(shape, "Shells", []) or [])
if faces or solids or shells:
return False
return bool(list(getattr(shape, "Edges", []) or []))
def _points_from_selection_item(selection_item):
points = []
for point in list(getattr(selection_item, "PickedPoints", []) or []):
points.append(_vector(point))
for sub_object in list(getattr(selection_item, "SubObjects", []) or []):
shape_type = (getattr(sub_object, "ShapeType", "") or "").lower()
if shape_type == "edge":
points.extend(_edge_points(sub_object))
continue
if shape_type == "vertex":
point = getattr(sub_object, "Point", None)
if point is not None:
points.append(_vector(point))
continue
center = _shape_center(sub_object)
if center is not None:
points.append(center)
obj = getattr(selection_item, "Object", None)
shape = getattr(obj, "Shape", None)
if shape is not None and _is_route_path_source_object(obj):
for edge in list(getattr(shape, "Edges", []) or []):
points.extend(_edge_points(edge))
if not points:
center = _shape_center(shape)
if center is not None:
points.append(center)
normalized = []
for point in points:
if not normalized or _distance(normalized[-1], point) > DEFAULT_NODE_TOLERANCE:
normalized.append(point)
return normalized
def _face_normal(face):
try:
return _vector(face.normalAt(0.5, 0.5))
except Exception:
pass
try:
return _vector(face.normalAt(0.0, 0.0))
except Exception:
pass
return None
def _bbox_axis_range(bbox, axis):
if axis == "x":
return float(bbox.XMin), float(bbox.XMax)
if axis == "y":
return float(bbox.YMin), float(bbox.YMax)
return float(bbox.ZMin), float(bbox.ZMax)
def _surface_grid_values(min_value, max_value, spacing, margin):
low = float(min_value) + float(margin)
high = float(max_value) - float(margin)
if high < low:
low = float(min_value)
high = float(max_value)
if abs(high - low) <= DEFAULT_NODE_TOLERANCE:
return [low]
spacing = max(float(spacing or DEFAULT_SURFACE_LANE_SPACING), 1.0)
values = [low]
current = low + spacing
while current < high - DEFAULT_NODE_TOLERANCE:
values.append(current)
current += spacing
if abs(values[-1] - high) > DEFAULT_NODE_TOLERANCE:
values.append(high)
return values
def _surface_face_grid_points(face, spacing, offset, margin):
normal = _face_normal(face)
bbox = getattr(face, "BoundBox", None)
if normal is None or bbox is None:
return []
normal_axis = _dominant_axis(normal)
if normal_axis is None:
return []
tangent_axes = [axis for axis in ("x", "y", "z") if axis != normal_axis]
first_axis, second_axis = tangent_axes[0], tangent_axes[1]
first_min, first_max = _bbox_axis_range(bbox, first_axis)
second_min, second_max = _bbox_axis_range(bbox, second_axis)
normal_min, normal_max = _bbox_axis_range(bbox, normal_axis)
normal_sign = 1.0 if _axis_value(normal, normal_axis) >= 0.0 else -1.0
plane_value = ((normal_min + normal_max) * 0.5) + normal_sign * float(offset or 0.0)
first_values = _surface_grid_values(first_min, first_max, spacing, margin)
second_values = _surface_grid_values(second_min, second_max, spacing, margin)
if len(first_values) < 2 or len(second_values) < 2:
return []
rows = []
for second_value in second_values:
row = []
for first_value in first_values:
point = App.Vector(0, 0, 0)
point = _set_axis(point, normal_axis, plane_value)
point = _set_axis(point, first_axis, first_value)
point = _set_axis(point, second_axis, second_value)
row.append(point)
rows.append(row)
columns = []
for first_index in range(len(first_values)):
column = []
for row in rows:
column.append(row[first_index])
columns.append(column)
# 行和列都要生成 carrierDijkstra 才能在网格交点处横竖换向。
return rows + columns
def create_carriers_from_selection(doc, selection_ex, project_uuid="", kind=ROUTE_CARRIER_KIND):
created = []
for index, item in enumerate(selection_ex or [], start=1):
points = _points_from_selection_item(item)
if len(points) < 2:
continue
created.append(
create_route_carrier(
doc,
points,
label="QET Route Carrier {0}".format(index),
project_uuid=project_uuid,
kind=kind,
)
)
return created
def _wire_duct_centerline_from_bbox(bbox, margin=DEFAULT_WIRE_DUCT_MARGIN, min_aspect=1.5):
extents = {
axis: _bbox_extent(bbox, axis)
for axis in ("x", "y", "z")
}
main_axis = max(extents, key=extents.get)
sorted_extents = sorted(extents.values(), reverse=True)
if sorted_extents[0] <= DEFAULT_NODE_TOLERANCE:
return []
if len(sorted_extents) > 1 and sorted_extents[1] > DEFAULT_NODE_TOLERANCE:
if sorted_extents[0] / sorted_extents[1] < float(min_aspect or 1.0):
return []
low, high = _bbox_axis_range(bbox, main_axis)
center = _bbox_center(bbox)
usable_margin = max(float(margin or 0.0), 0.0)
if abs(high - low) <= usable_margin * 2.0:
usable_margin = 0.0
start = _set_axis(center, main_axis, low + usable_margin)
end = _set_axis(center, main_axis, high - usable_margin)
if _distance(start, end) <= DEFAULT_NODE_TOLERANCE:
return []
return [start, end]
def _wire_duct_sources_from_selection(selection_ex):
sources = []
seen = set()
for item in selection_ex or []:
obj = getattr(item, "Object", None)
if obj is not None and id(obj) not in seen:
seen.add(id(obj))
sources.append(obj)
continue
for sub_object in list(getattr(item, "SubObjects", []) or []):
if sub_object is None or id(sub_object) in seen:
continue
seen.add(id(sub_object))
sources.append(sub_object)
return sources
def _mark_wire_duct_source(source, carrier):
if source is None:
return
try:
TerminalObjects.ensure_string_property(
source,
"QetRoutingSourceKind",
PROPERTY_GROUP,
"Routing source kind",
ROUTE_CARRIER_KIND_WIRE_DUCT,
)
TerminalObjects.ensure_string_property(
source,
"QetRoutingObstacleMode",
PROPERTY_GROUP,
"How auto-routing collision checks should treat this object",
WIRE_DUCT_OBSTACLE_MODE,
)
TerminalObjects.ensure_string_property(
source,
"QetRouteCarrierName",
PROPERTY_GROUP,
"Generated route carrier for this source",
getattr(carrier, "Name", ""),
)
except Exception:
pass
def create_wire_duct_carriers_from_selection(
doc,
selection_ex,
project_uuid="",
margin=DEFAULT_WIRE_DUCT_MARGIN,
min_aspect=1.5,
):
"""Create WireDuct centerline carriers from selected duct-like solids."""
created = []
for index, source in enumerate(_wire_duct_sources_from_selection(selection_ex), start=1):
bbox = _bound_box_from_object(source)
if bbox is None:
continue
points = _wire_duct_centerline_from_bbox(
bbox,
margin=margin,
min_aspect=min_aspect,
)
if len(points) < 2:
continue
label = getattr(source, "Label", "") or getattr(source, "Name", "") or "Wire Duct"
carrier = create_route_carrier(
doc,
points,
label="QET Wire Duct Centerline {0}".format(label),
project_uuid=project_uuid,
kind=ROUTE_CARRIER_KIND_WIRE_DUCT,
)
_mark_wire_duct_source(source, carrier)
created.append(carrier)
return created
def create_surface_carriers_from_selection(
doc,
selection_ex,
project_uuid="",
spacing=DEFAULT_SURFACE_LANE_SPACING,
offset=DEFAULT_SURFACE_OFFSET,
margin=DEFAULT_SURFACE_MARGIN,
):
"""Create a supported route grid on selected planar cabinet/panel faces."""
created = []
for item in selection_ex or []:
for sub_object in list(getattr(item, "SubObjects", []) or []):
shape_type = (getattr(sub_object, "ShapeType", "") or "").lower()
if shape_type != "face":
continue
grids = _surface_face_grid_points(
sub_object,
spacing=spacing,
offset=offset,
margin=margin,
)
for index, points in enumerate(grids, start=1):
if len(points) < 2:
continue
carrier = create_route_carrier(
doc,
points,
label="QET Surface Route {0}".format(index),
project_uuid=project_uuid,
kind=ROUTE_CARRIER_KIND_ROUTING_RANGE,
)
created.append(carrier)
return created
def _carrier_cost_factor(carrier, kind_cost_factors=None):
kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip()
factors = dict(DEFAULT_KIND_COST_FACTORS)
if isinstance(kind_cost_factors, dict):
factors.update(kind_cost_factors)
try:
return max(float(factors.get(kind, 1.0)), 0.01)
except Exception:
return 1.0
def build_route_graph(doc, tolerance=DEFAULT_NODE_TOLERANCE):
"""Build an undirected graph from every enabled route carrier."""
nodes = {}
edges = {}
carriers = collect_route_carriers(doc)
segment_count = 0
def ensure_node(point):
key = _point_key(point, tolerance=tolerance)
if key not in nodes:
nodes[key] = point
edges[key] = []
return key
for carrier in carriers:
points = _carrier_points(carrier)
if len(points) < 2:
continue
previous_key = ensure_node(points[0])
previous_point = nodes[previous_key]
for point in points[1:]:
current_key = ensure_node(point)
current_point = nodes[current_key]
weight = _distance(previous_point, current_point)
if weight > tolerance:
edges[previous_key].append((current_key, weight, carrier))
edges[current_key].append((previous_key, weight, carrier))
segment_count += 1
previous_key = current_key
previous_point = current_point
return {
"nodes": nodes,
"edges": edges,
"carriers": carriers,
"carrier_count": len(carriers),
"segment_count": segment_count,
"tolerance": tolerance,
}
def nearest_node(network, point):
nodes = network.get("nodes", {}) if isinstance(network, dict) else {}
if not nodes:
return None, None
target = _vector(point)
best_key = None
best_distance = None
for key, node_point in nodes.items():
distance = _distance(target, node_point)
if best_distance is None or distance < best_distance:
best_key = key
best_distance = distance
return best_key, best_distance
def shortest_path(network, start_key, end_key, bend_penalty=0.0, kind_cost_factors=None):
"""Dijkstra search with a small extra cost when route direction changes."""
if start_key is None or end_key is None:
return None
if start_key == end_key:
return [start_key]
nodes = network.get("nodes", {})
edges = network.get("edges", {})
queue = []
counter = 0
start_state = (start_key, None)
distances = {start_state: 0.0}
previous = {}
heapq.heappush(queue, (0.0, counter, start_key, None))
while queue:
cost, _counter, key, previous_direction = heapq.heappop(queue)
state = (key, previous_direction)
if cost > distances.get(state, float("inf")):
continue
if key == end_key:
path = [key]
current_state = state
while current_state in previous:
current_state = previous[current_state]
path.append(current_state[0])
path.reverse()
return path
for next_key, weight, carrier in edges.get(key, []):
direction = _direction_key(nodes[key], nodes[next_key])
bend_cost = 0.0
if previous_direction is not None and direction != previous_direction:
bend_cost = float(bend_penalty or 0.0)
next_state = (next_key, direction)
next_cost = cost + float(weight) * _carrier_cost_factor(carrier, kind_cost_factors) + bend_cost
if next_cost < distances.get(next_state, float("inf")):
distances[next_state] = next_cost
previous[next_state] = state
counter += 1
heapq.heappush(queue, (next_cost, counter, next_key, direction))
return None
def path_points(network, path_keys):
nodes = network.get("nodes", {}) if isinstance(network, dict) else {}
return [nodes[key] for key in path_keys or [] if key in nodes]
def network_summary(doc):
network = build_route_graph(doc)
kinds = {}
for carrier in network.get("carriers", []) or []:
kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND
kinds[kind] = kinds.get(kind, 0) + 1
return {
"carriers": int(network.get("carrier_count", 0)),
"segments": int(network.get("segment_count", 0)),
"nodes": len(network.get("nodes", {})),
"kinds": kinds,
}
def carrier_payload(carrier):
return {
"name": getattr(carrier, "Name", ""),
"label": getattr(carrier, "Label", ""),
"kind": getattr(carrier, "QetRouteCarrierKind", ""),
"points": [_point_payload(point) for point in _carrier_points(carrier)],
}

@ -0,0 +1,96 @@
import json
import os
import sys
import FreeCAD as App
REPO_ROOT = r"D:\LightWork3D"
MODULE_DIR = os.path.join(REPO_ROOT, "src", "Mod", "FreeCADExchange")
OUT_DIR = os.path.join(REPO_ROOT, "tests", "out")
OUT_FCSTD = os.path.join(OUT_DIR, "auto_routing_smoke.FCStd")
OUT_JSON = os.path.join(OUT_DIR, "auto_routing_smoke_result.json")
if MODULE_DIR not in sys.path:
sys.path.insert(0, MODULE_DIR)
import AutoRouting
import RoutingNetwork
import TerminalObjects
import WiringObjects
def _make_terminal(doc, name, terminal_uuid, point):
terminal = TerminalObjects.create_lcs_object(
doc,
name,
placement=App.Placement(point, App.Rotation()),
label=terminal_uuid,
)
TerminalObjects.set_terminal_semantics(
terminal,
"project-smoke",
"element-" + terminal_uuid,
terminal_uuid,
"instance-" + terminal_uuid,
label=terminal_uuid,
)
return terminal
def _point_payload(point):
return {
"x": float(point.x),
"y": float(point.y),
"z": float(point.z),
}
def main():
os.makedirs(OUT_DIR, exist_ok=True)
doc = App.newDocument("AutoRoutingSmoke")
App.setActiveDocument(doc.Name)
TerminalObjects.ensure_root_group(doc, "project-smoke")
WiringObjects.initialize_wiring_scene(doc, "project-smoke")
start = _make_terminal(doc, "SmokeTerminalStart", "terminal-start", App.Vector(0, 0, 0))
end = _make_terminal(doc, "SmokeTerminalEnd", "terminal-end", App.Vector(160, 0, 0))
RoutingNetwork.create_route_carrier(
doc,
[
App.Vector(0, 0, 20),
App.Vector(0, 60, 20),
App.Vector(160, 60, 20),
App.Vector(160, 0, 20),
],
label="Smoke Route Carrier",
project_uuid="project-smoke",
)
obstacle = doc.addObject("Part::Box", "SmokeObstacle")
obstacle.Label = "Smoke Obstacle"
obstacle.Length = 40
obstacle.Width = 40
obstacle.Height = 60
obstacle.Placement = App.Placement(App.Vector(60, -20, -10), App.Rotation())
doc.recompute()
result = AutoRouting.route_between_terminals(doc, start, end)
payload = {
"algorithm": result["algorithm"],
"route_status": result["route_status"],
"collision_count": result["collision_count"],
"points": [_point_payload(point) for point in result["points"]],
"network": RoutingNetwork.network_summary(doc),
"scene": OUT_FCSTD,
}
doc.saveAs(OUT_FCSTD)
with open(OUT_JSON, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
print(json.dumps(payload, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

@ -0,0 +1,460 @@
import importlib
import sys
import types
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
MODULE_DIR = REPO_ROOT / "src" / "Mod" / "FreeCADExchange"
if str(MODULE_DIR) not in sys.path:
sys.path.insert(0, str(MODULE_DIR))
def _install_fake_freecad():
class Vector:
def __init__(self, x=0.0, y=0.0, z=0.0):
self.x = float(x)
self.y = float(y)
self.z = float(z)
class Rotation:
def multVec(self, vector):
return vector
class Placement:
def __init__(self, base=None, rotation=None):
self.Base = base or Vector()
self.Rotation = rotation or Rotation()
def multVec(self, vector):
return Vector(
self.Base.x + vector.x,
self.Base.y + vector.y,
self.Base.z + vector.z,
)
fake_freecad = types.ModuleType("FreeCAD")
fake_freecad.Vector = Vector
fake_freecad.Rotation = Rotation
fake_freecad.Placement = Placement
fake_freecad.ActiveDocument = None
fake_freecad.Console = types.SimpleNamespace(
PrintMessage=lambda *args, **kwargs: None,
PrintWarning=lambda *args, **kwargs: None,
PrintError=lambda *args, **kwargs: None,
)
sys.modules["FreeCAD"] = fake_freecad
fake_gui = types.ModuleType("FreeCADGui")
fake_gui.addCommand = lambda *args, **kwargs: None
fake_gui.SendMsgToActiveView = lambda *args, **kwargs: None
fake_gui.Selection = types.SimpleNamespace(
getSelection=lambda: [],
getSelectionEx=lambda: [],
)
sys.modules["FreeCADGui"] = fake_gui
fake_part = types.ModuleType("Part")
fake_part.makePolygon = lambda points: tuple(points)
sys.modules["Part"] = fake_part
class FakeViewObject:
def __init__(self):
self.Visibility = True
self.LineWidth = None
self.LineColor = None
class FakeObject:
def __init__(self, name, type_id, doc=None):
self.Name = name
self.Label = name
self.TypeId = type_id
self.Document = doc
self.PropertiesList = []
self.Group = []
self.ViewObject = FakeViewObject()
self.Shape = None
self.Points = []
self.Placement = sys.modules["FreeCAD"].Placement()
self.InList = []
def isDerivedFrom(self, type_name):
if self.TypeId == type_name:
return True
if type_name == "App::DocumentObjectGroup":
return self.TypeId == "App::DocumentObjectGroup"
if type_name == "App::LocalCoordinateSystem":
return self.TypeId in {"Part::LocalCoordinateSystem", "PartDesign::CoordinateSystem"}
return False
def addProperty(self, prop_type, prop_name, group_name, description):
if prop_name not in self.PropertiesList:
self.PropertiesList.append(prop_name)
def addObject(self, child):
if child not in self.Group:
self.Group.append(child)
if self not in child.InList:
child.InList.append(self)
class FakeDocument:
def __init__(self):
self.Objects = []
self.Name = "FakeDoc"
def addObject(self, type_name, name):
obj = FakeObject(name, type_name, doc=self)
self.Objects.append(obj)
return obj
def getObject(self, name):
for obj in self.Objects:
if obj.Name == name:
return obj
return None
def removeObject(self, name):
self.Objects = [obj for obj in self.Objects if obj.Name != name]
def recompute(self):
return None
class FakeBoundBox:
def __init__(self, xmin, xmax, ymin, ymax, zmin, zmax):
self.XMin = xmin
self.XMax = xmax
self.YMin = ymin
self.YMax = ymax
self.ZMin = zmin
self.ZMax = zmax
class FakeShape:
def __init__(self, bbox, edges=None, faces=None):
self.BoundBox = bbox
self.Edges = edges or []
self.Faces = faces or []
self.Solids = []
self.Shells = []
class FakeVertex:
def __init__(self, point):
self.Point = point
class FakeEdge:
ShapeType = "Edge"
def __init__(self, start, end):
self.Vertexes = [FakeVertex(start), FakeVertex(end)]
class FakeFace:
ShapeType = "Face"
def __init__(self, bbox, normal):
self.BoundBox = bbox
self._normal = normal
def normalAt(self, _u, _v):
return self._normal
class FakeSelectionItem:
def __init__(self, sub_objects=None, obj=None):
self.SubObjects = sub_objects or []
self.PickedPoints = []
self.Object = obj
def _reload_modules():
for name in [
"TerminalObjects",
"WiringObjects",
"RoutingNetwork",
"AutoRouting",
"AutoRoutingPanel",
]:
sys.modules.pop(name, None)
terminal_objects = importlib.import_module("TerminalObjects")
wiring_objects = importlib.import_module("WiringObjects")
routing_network = importlib.import_module("RoutingNetwork")
auto_routing = importlib.import_module("AutoRouting")
return terminal_objects, wiring_objects, routing_network, auto_routing
def _terminal(doc, terminal_objects, name, terminal_uuid, point):
app = sys.modules["FreeCAD"]
terminal = doc.addObject("Part::LocalCoordinateSystem", name)
terminal.Placement = app.Placement(point, app.Rotation())
terminal_objects.set_terminal_semantics(
terminal,
"project-1",
"element-{0}".format(terminal_uuid),
terminal_uuid,
"instance-{0}".format(terminal_uuid),
label=terminal_uuid,
)
return terminal
class AutoRoutingTest(unittest.TestCase):
def test_auto_route_selected_terminals_requires_supported_route_by_default(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, 20, 0))
with self.assertRaises(auto_routing.AutoRoutingError):
auto_routing.route_between_terminals(doc, start, end)
def test_auto_route_can_still_use_explicit_floating_fallback_for_debug(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, 20, 0))
result = auto_routing.route_between_terminals(
doc,
start,
end,
options={"allow_floating_fallback": True},
)
wire = result["wire"]
self.assertEqual("orthogonal-v1", result["algorithm"])
self.assertEqual("AutoSuggested", wire.RouteType)
self.assertEqual("Auto", wire.RouteMode)
self.assertEqual("Routed", wire.RouteStatus)
self.assertGreaterEqual(len(wire.Points), 4)
self.assertIn(wire, doc.getObject("QETWiring_04_Routed").Group)
def test_auto_route_prefers_user_route_carrier_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")
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, 20),
app.Vector(0, 30, 20),
app.Vector(120, 30, 20),
app.Vector(120, 0, 20),
],
project_uuid="project-1",
)
result = auto_routing.route_between_terminals(doc, start, end)
self.assertEqual("network-dijkstra-v1", result["algorithm"])
self.assertEqual("Routed", result["route_status"])
self.assertTrue(any(point.y == 30.0 for point in result["points"]))
def test_auto_route_prefers_wire_duct_over_auxiliary_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")
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, 20), app.Vector(120, 0, 20)],
project_uuid="project-1",
kind="RoutingRange",
)
routing_network.create_route_carrier(
doc,
[
app.Vector(0, 0, 20),
app.Vector(0, 40, 20),
app.Vector(120, 40, 20),
app.Vector(120, 0, 20),
],
project_uuid="project-1",
kind="WireDuct",
)
result = auto_routing.route_between_terminals(doc, start, end)
self.assertEqual("network-dijkstra-v1", result["algorithm"])
self.assertTrue(any(point.y == 40.0 for point in result["points"]))
def test_surface_carrier_grid_supports_backplate_routing(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))
face = FakeFace(
FakeBoundBox(0, 120, -20, 120, -1, -1),
app.Vector(0, 0, 1),
)
created = routing_network.create_surface_carriers_from_selection(
doc,
[FakeSelectionItem([face])],
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("RoutingRange", getattr(created[0], "QetRouteCarrierKind", ""))
self.assertEqual("network-dijkstra-v1", result["algorithm"])
self.assertTrue(any(point.z == 4.0 for point in result["points"]))
def test_route_path_creation_ignores_whole_solid_object_edges(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")
solid = doc.addObject("Part::Feature", "CabinetSolid")
solid.Shape = FakeShape(
FakeBoundBox(0, 100, 0, 100, 0, 10),
edges=[FakeEdge(app.Vector(0, 0, 0), app.Vector(100, 0, 0))],
faces=[object()],
)
created = routing_network.create_carriers_from_selection(
doc,
[FakeSelectionItem(obj=solid)],
project_uuid="project-1",
)
self.assertEqual([], created)
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()
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
duct = doc.addObject("Part::Feature", "WireDuct")
duct.Shape = FakeShape(FakeBoundBox(0, 120, -10, 10, 5, 25))
created = routing_network.create_wire_duct_carriers_from_selection(
doc,
[FakeSelectionItem(obj=duct)],
project_uuid="project-1",
margin=20.0,
)
self.assertEqual(1, len(created))
carrier = created[0]
self.assertEqual("WireDuct", carrier.QetRouteCarrierKind)
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_wire_duct_source_is_not_reported_as_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(120, 0, 0))
duct = doc.addObject("Part::Feature", "WireDuct")
duct.Shape = FakeShape(FakeBoundBox(-10, 130, -10, 10, 15, 25))
routing_network.create_wire_duct_carriers_from_selection(
doc,
[FakeSelectionItem(obj=duct)],
project_uuid="project-1",
margin=0.0,
)
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"])
def test_auto_route_marks_collision_warning_against_obstacle_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(0, 0, 100), app.Vector(100, 0, 100)],
project_uuid="project-1",
)
obstacle = doc.addObject("Part::Feature", "Obstacle")
obstacle.Shape = FakeShape(FakeBoundBox(40, 60, -10, 10, 90, 110))
result = auto_routing.route_between_terminals(doc, start, end)
self.assertEqual("CollisionWarning", result["route_status"])
self.assertEqual("CollisionWarning", result["wire"].RouteStatus)
self.assertEqual(1, result["collision_count"])
def test_route_all_from_payload_skips_missing_terminal(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))
payload = {
"wires": [
{
"wire_id": "wire-1",
"start_terminal_uuid": "terminal-start",
"end_terminal_uuid": "terminal-missing",
}
]
}
report = auto_routing.route_all_from_payload(doc, payload)
self.assertEqual(0, report["routed"])
self.assertEqual(1, report["skipped_missing_terminal"])
def test_clear_route_carriers_keeps_routed_wires(self):
_install_fake_freecad()
terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0))
end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0))
routing_network.create_route_carrier(
doc,
[app.Vector(0, 0, 20), app.Vector(100, 0, 20)],
project_uuid="project-1",
)
wire = auto_routing.route_between_terminals(doc, start, end)["wire"]
removed = routing_network.clear_route_carriers(doc)
self.assertEqual(1, removed)
self.assertEqual([], routing_network.collect_route_carriers(doc))
self.assertIn(wire, wiring_objects.ensure_routed_group(doc, "project-1").Group)
if __name__ == "__main__":
unittest.main()
Loading…
Cancel
Save