feat: 支持自由路径和端子局部出线自动布线

dev
Zhaowenlong 3 weeks ago
parent 2302eea33f
commit 5dbd39747b

@ -69,6 +69,175 @@ terminal_uuid
4. 3D 位姿、装配关系、布线几何以 FreeCAD 文档为准。
### 1.4 金晖隆布线需求提炼
根据金晖隆提供的 3D 布线功能需求文档和操作演示视频,当前自动布线需要吸收以下信息。
#### 1.4.1 甲方演示中的完整工作流
演示流程不是单纯“两个端子之间生成一根线”,而是完整的设备模板、装配、路径和电气关系闭环:
```text
三维柜体/安装板
-> 从设备库选择已做好的设备
-> 设备快速配合到导轨/安装板
-> 缺失设备按说明书新建三维模块
-> 为设备定义电气脚点
-> 为设备定义局部走线路径
-> 绘制柜内主走线路径
-> 将设备脚点、局部路径、主路径和 QET 导线关系关联
-> 生成 3D 布线
```
设备库中的设备不只是几何模型,还应包含:
1. 外形尺寸。
2. 电气属性。
3. 接线脚点。
4. 设备自身走线路径。
5. 配合面或安装基准。
#### 1.4.2 对当前 FreeCAD 自动布线最有价值的信息
1. 柜内布线不应强制依赖线槽。线槽是最高优先级路径,但用户自定义三维草图路径也必须能参与布线。
2. 设备模板需要保存“局部出线路径”。自动布线不应直接从端子跳到全局主路径,而应支持:
```text
设备脚点
-> 设备局部走线路径
-> 柜内主路径/线槽/用户草图路径
-> 目标设备局部走线路径
-> 目标设备脚点
```
3. 端子排、小型断路器等重复设备需要批量插入、自动沿导轨排布、自动配合。
4. 导轨、线槽、安装板、设备之间的装配关系会直接影响布线路径网络。
5. 布线完成后需要支持线束高亮和源/目标设备提示。
#### 1.4.3 当前阶段暂缓的内容
取线机和完整取线表可以放到后面阶段。当前阶段只保留布线结果中必要的基础数据:
1. 起点端子和终点端子。
2. 线号/导线标识。
3. 导线长度。
4. 导线样式 ID 或 QET 传入的导线规格引用。
5. 路由路径诊断。
导线规格、颜色、线耳等导线数据由 QET 提供FreeCAD 第一版只消费和保留,不在 3D 侧重新发明导线主数据。
### 1.5 设备脚号与 3D 脚点绑定方案
设备脚号与 3D 脚点绑定分成两层:模板槽位绑定和工程端子绑定。
#### 1.5.1 模板槽位
设备模板 `.FCStd` 内,每个可接线脚点使用 LCS 或等价定位对象表示,并保存稳定槽位名:
```text
QetTemplateSlotName = "A1" | "A2" | "13" | "14" | "P1" | "P2" | ...
QetTerminalLabel = <显示名>
Role = "Terminal"
CanWire = true
```
这个槽位名来自设备说明书或设备实物端子标识。它是跨项目复用的模板语义,不保存 `terminal_uuid`
#### 1.5.2 工程端子
工程中实例化设备后FreeCAD 从模板槽位生成工程端子,并补上当前项目和实例信息:
```text
QetProjectUuid
QetElementUuid
QetInstanceId
QetTerminalUuid
QetTemplateSlotName
```
其中 `QetTerminalUuid` 是 2D 端子主键,也是第一版 3D 端子绑定唯一依据。
#### 1.5.3 匹配优先级
当 QET 导入导线任务或端子数据后FreeCAD 按下面顺序把 2D 脚号绑定到 3D 脚点:
1. 如果工程端子已经有相同 `terminal_uuid`,直接使用该工程端子。
2. 在相同 `instance_id` 的设备下,用 QET 当前已有的端子显示名/脚号,例如 `terminal_display`、`start_terminal_display`、`end_terminal_display`,归一化后匹配 `QetTemplateSlotName``QetTerminalLabel`
3. 如果后续 QET 额外提供 `slot_name_hint`,则可优先用它精确匹配模板槽位;但它不是第一版必需字段。
4. 如果同一设备下 2D 端子数量和 3D 模板槽位数量一致,允许按顺序兜底匹配,但必须写诊断提示。
5. 仍无法匹配时,保留为 `local:*` 本地端子,不参与可靠自动布线。
#### 1.5.4 QET 需要配合提供的数据
第一版数据库仍只使用 `project_2d3d_symbol_binding``project_2d3d_terminal_binding`。交换 JSON 中当前应优先使用已有的端子显示字段作为匹配提示:
```json
{
"terminal_uuid": "2d-terminal-uuid",
"element_uuid": "2d-device-element-uuid",
"instance_id": "3d-device-instance-id",
"terminal_display": "A1"
}
```
这些字段只作为 FreeCAD 匹配模板槽位的提示,不写入第一版绑定表,也不能替代 `terminal_uuid`
`slot_name_hint` 只是 FreeCAD 侧预留的可选扩展字段。当前 QET 如果没有该字段,不需要为了第一版专门增加;只要 `terminal_display` / `start_terminal_display` / `end_terminal_display` 能稳定表示设备脚号,就可以完成槽位匹配。
QET 侧还需要保证导线任务中继续提供:
```json
{
"start_terminal_uuid": "...",
"end_terminal_uuid": "...",
"start_instance_id": "...",
"end_instance_id": "...",
"wire_label": "...",
"wire_style_id": "..."
}
```
其中导线规格、颜色、线耳等导线主数据以后由 `wire_style_id` 或等价字段回查 QET。
### 1.6 区域与批量排布方案
#### 1.6.1 设备区域/柜内区域
取线机阶段之前区域先不写数据库。FreeCAD 文档中可用空间对象或属性对象定义区域:
```text
QetRegionId
QetRegionName
QetRegionKind = "Cabinet" | "Door" | "MountingPlate" | "LeftBay" | "RightBay" | ...
```
设备区域通过 3D 包围盒或设备基准点落在哪个区域对象内推导。后续生成取线表时,再把区域名称写入导出数据。
#### 1.6.2 端子排和小型断路器批量排布
端子排、小型断路器排布规则参照 SW 的装配思路:导轨是宿主,设备沿导轨轴向按顺序排布。
推荐 FreeCAD 文档内保存:
```text
QetMountHostName = <rail object name>
QetRailAxis = "x" | "y" | "z"
QetRailStartOffsetMm
QetRailPitchMm
QetRailOrderIndex
QetRailDirection = 1 | -1
```
批量插入时:
1. 用户选择导轨。
2. 选择设备模板和数量,或从 QET 设备列表生成。
3. FreeCAD 根据模板宽度、间距、起始偏移和方向计算每个实例的位置。
4. 每个设备记录宿主导轨和排序号。
5. 后续导轨移动时,可通过宿主关系刷新设备位置。
QET 侧如果能提供端子排/断路器的顺序、数量和显示编号,则 FreeCAD 可以按 2D 顺序生成;如果 QET 暂不提供,第一版由 FreeCAD 用户在 3D 侧手动指定顺序。
## 2. 总体方案
布线连接不直接在任意 3D 空间里找线,也不会把所有端子任意两两连接。它参考 EPLAN 的思路,先建立 routing path network再按 QET 导线任务中的起点端子和终点端子逐条求路。
@ -246,7 +415,7 @@ edge_cost = segment_length * carrier_kind_factor + bend_penalty
WireDuct = 1.0
RoutingPath = 1.0
AuxiliaryPath = 2.0
RoutingRange = 8.0
RoutingRange = 25.0
```
这使算法优先走线槽和明确路由路径,尽量少走辅助面域。
@ -492,11 +661,11 @@ tests/manual/freecad_auto_routing_smoke.py
```text
准备布线布局空间:识别并标记 layout space 里的线槽、支撑面、工程端子和障碍处理方式
生成布线路径网络:按 EPLAN routing path network 逻辑生成 WireDuct、RoutingRange 和 TerminalAccess carrier
生成布线路径网络:按 EPLAN routing path network 逻辑生成 WireDuct、UserPath、RoutingRange 和 TerminalAccess carrier
生成布线连接:先更新布线路径网络,再检查/绑定工程端子,按 QET 导线任务批量求路并生成 AutoSuggested 导线
```
如果模型名称/标签足够规范,可以不手动选择,直接执行三步;也可以只点击“生成布线连接”,系统会准备当前可识别的布线路径网络。若线槽无法自动识别,则先选中线槽实体执行“生成布线路径网络”作为补充。
如果模型名称/标签足够规范,可以不手动选择,直接执行三步;也可以只点击“生成布线连接”,系统会准备当前可识别的布线路径网络。若线槽无法自动识别,则先选中线槽实体执行“生成布线路径网络”作为补充。若甲方现场没有线槽或需要绕开线槽自由定义柜内主路径可以选中草图、Draft 线、线段或纯线状对象,再执行“生成布线路径网络”,系统会生成 `UserPath`
### 6.2 批量生成布线连接前提
@ -537,6 +706,75 @@ end_terminal_display
安装板和导轨的机械配合关系会影响对象最终位置,但当前路由器不从装配约束求解位置;它只读取 FreeCAD 文档中已经确定的几何位姿。后续如果增加装配语义,应保存在 FreeCAD 文档中,不扩展第一版数据库绑定表。
### 6.4 自由路径与设备局部路径
为满足甲方“柜内布线不强制依赖线槽,可自由定义任意空间路径”的要求,当前 FreeCAD 侧已支持 `UserPath`
1. 选中草图、Draft 线、线段或纯线状对象。
2. 点击 3D 布线连接面板中的“选中路径作为用户路径”,或直接点击“生成布线路径网络”。
3. 系统把选中路径转换为 `UserPath` carrier并参与后续自动布线最短路搜索。“选中路径作为用户路径”只创建用户路径“生成布线路径网络”会同时更新线槽、布线面、端子接入等完整网络。
4. 再次选择同一个路径对象生成网络时,系统会刷新原 carrier不会重复生成。
5. 如果删除了原草图/线段源对象,再点击“选中路径作为用户路径”或重新生成网络,系统会清理对应的失效 `UserPath` carrier。
`UserPath` 与线槽的关系:
```text
WireDuct真实线槽仍是推荐主路径
UserPath用户自定义柜内主路径/过渡路径,可在无线槽或特殊路径场景中使用
RoutingRange安装板/背板上的低优先级布线面,只作为兜底过渡
TerminalAccess端子到主路径的接入段
```
为满足“设备内部/局部走线路径需要连接柜内大路径/主路径”的要求,当前 FreeCAD 侧先提供端子级局部路径字段:
```text
QetTerminalLocalRoutePointsJson
```
字段内容是端子本地坐标系下的点数组,例如:
```json
[[0, 0, 0], [10, 0, 0], [10, 30, 0]]
```
自动生成 `TerminalAccess` 时,系统会先把这些局部点按端子和父设备的 `Placement` 转成全局点,再从局部路径末端连接到最近的柜内主路径、线槽、用户路径或布线面。没有该字段时,仍使用原来的端子 LCS `+Z` 方向短出线。
当前模板链路已经支持把局部路径从设备模板带到工程端子。模板 sidecar 或 `QetTemplateSlotsJson` 可以在对应端子槽位上提供:
```json
{
"terminal_slots": [
{
"name": "A1",
"label": "A1",
"base": [10, 20, 30],
"local_route_points": [[0, 0, 0], [10, 0, 0], [10, 30, 0]]
}
]
}
```
导入/更新工程端子时FreeCAD 会把 `local_route_points` 写入该端子的 `QetTerminalLocalRoutePointsJson`。后续自动生成 `TerminalAccess` 和最终导线几何时都会使用这段局部路径。
如果直接在 FCStd 模板端子 LCS 上维护,也可以给模板端子写入同名属性 `QetTerminalLocalRoutePointsJson`。当前模板作者工具提供了内部函数:
```python
TemplateAuthoring.set_template_terminal_local_route_points(terminal, points)
```
其中 `points` 仍是端子本地坐标系下的点数组。导入 FCStd 模板时FreeCAD 会把 live 模板端子的局部路径保存进设备组的 `QetTemplateSlotsJson`,随后删除 live 模板 LCS只保留工程端子需要的槽位元数据。
模板作者也可以用选择方式维护局部路径:
```text
1. 在 FCStd 模板文档中先选中一个模板端子 LCS。
2. 再选中表示局部出线路径的草图、Draft 线或边。
3. 执行“设置端子局部路径”命令。
4. 系统把所选路径的文档坐标转换为该端子的本地坐标,并写入 QetTerminalLocalRoutePointsJson。
```
第一版不要求 QET 提供这个字段。它属于 FreeCAD 设备模板/工程端子的 3D 几何元数据,由 FreeCAD 模板作者维护QET 仍只提供导线任务、设备实例、端子实例和 2D/3D 绑定所需 UUID。
## 7. 当前限制
当前版本可完成布线连接原型,但仍有以下限制:
@ -548,7 +786,7 @@ end_terminal_display
5. 未做强弱电分槽、线缆类型隔离。
6. 障碍检测基于 AABB存在误报和漏报。
7. 辅助路由区域是网格近似,不等于专业软件的完整布线区域建模。
8. 端子出线方向依赖端子 LCS 方向;如果模板端子方向不准,布线连接会受影响。
8. 端子出线方向默认依赖端子 LCS 方向;若工程端子提供 `QetTerminalLocalRoutePointsJson`,会优先使用局部路径。模板端子方向或局部路径不准时,布线连接会受影响。
9. 导线几何当前保存在 FreeCAD 文档,不作为第一版数据库字段回写。
10. 当前不自动求解导轨、安装板和设备之间的 Assembly 配合关系;装配位置以 `scene.FCStd` 中对象的最终 `Placement` 为准。
@ -586,7 +824,7 @@ WireDuct:
4. 端子出线规则
```text
根据端子 LCS 方向、设备安装面、接线孔方向生成更合理的短出线段
根据端子 LCS 方向、设备安装面、接线孔方向和模板局部路径生成更合理的短出线段
```
### 8.2 中期能力

@ -40,7 +40,7 @@ DEFAULT_OPTIONS = {
"UserPath": 1.0,
"AuxiliaryPath": 2.0,
"TerminalAccess": 2.0,
"RoutingRange": 8.0,
"RoutingRange": 25.0,
},
# 主干必须走 carrier/贴面网络;没有布线路径网络时直接失败。
# 障碍包围盒会按这个距离膨胀,用于提前发现贴碰风险。
@ -54,6 +54,7 @@ DEFAULT_OPTIONS = {
# 替代路径,再退回原图并用 CollisionWarning 告诉用户当前网络不足。
"avoid_obstacles": True,
"replace_existing": True,
"hide_route_carriers_after_route": True,
}
@ -563,6 +564,7 @@ def _bind_wire_task_terminals(doc, payload):
label=terminal_display or getattr(terminal_obj, "Label", "") or terminal_uuid,
slot_name=slot_name,
)
TerminalObjects.hide_engineering_terminal(terminal_obj)
used_objects.add(terminal_obj)
slot_token = _normalized_match_token(slot_name)
if slot_token:
@ -766,10 +768,12 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non
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_access_points = RoutingNetwork.terminal_access_path_points(start_terminal, exit_length)
end_access_points = RoutingNetwork.terminal_access_path_points(end_terminal, exit_length)
start_origin = start_access_points[0] if start_access_points else _terminal_origin(start_terminal)
end_origin = end_access_points[0] if end_access_points else _terminal_origin(end_terminal)
start_exit = start_access_points[-1] if start_access_points else _offset(start_origin, _terminal_direction(start_terminal), exit_length)
end_exit = end_access_points[-1] if end_access_points else _offset(end_origin, _terminal_direction(end_terminal), exit_length)
def route_on_network(network, obstacle_aware=False):
if network.get("segment_count", 0) <= 0:
@ -807,13 +811,14 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non
carrier_points = _apply_lane_offset(carrier_points, lane)
points = []
_append_unique(points, start_origin)
_append_unique(points, start_exit)
for point in start_access_points or [start_origin, start_exit]:
_append_unique(points, point)
_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)
for point in reversed(end_access_points or [end_origin, end_exit]):
_append_unique(points, point)
points = _simplify_collinear_points(
points,
preserved_point_keys=_important_route_node_keys(network, path_keys, path_result),
@ -1185,6 +1190,7 @@ def route_eplan_connection_between_terminals(
wire_mark_is_manual=False,
wire_style_id="",
endpoint_metadata=None,
defer_recompute=False,
):
if doc is None:
raise AutoRoutingError("No FreeCAD document is available.")
@ -1237,6 +1243,7 @@ def route_eplan_connection_between_terminals(
wire_name = _unique_name(doc, _wire_object_name(start_terminal, end_terminal, wire_uuid))
wire = None
removed_existing = 0
try:
wire = _create_wire_geometry(doc, wire_name, points)
wire.Label = wire_label or wire_mark or wire_uuid or "QET Routed Connection"
@ -1288,10 +1295,11 @@ def route_eplan_connection_between_terminals(
_remove_routing_connection_objects(doc, [wire])
raise AutoRoutingError("Failed to replace existing routed connection.")
try:
doc.recompute()
except Exception:
pass
if not defer_recompute:
try:
doc.recompute()
except Exception:
pass
return {
"wire": wire,
@ -1304,6 +1312,7 @@ def route_eplan_connection_between_terminals(
"length_mm": _route_length(points),
"collision_count": len(collisions),
"collisions": collisions,
"replaced_routed_connections": removed_existing,
}
@ -1436,6 +1445,7 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la
if not isinstance(payload, dict):
raise AutoRoutingError("Exchange payload must be an object.")
opts = _merged_options(options)
terminal_binding_report = bind_wire_task_terminals_from_payload(doc, payload)
terminals = index_terminals(doc)
local_terminal_count = sum(
@ -1453,11 +1463,14 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la
"auto_terminal_binding_warnings": terminal_binding_report["warnings"],
"routed": 0,
"collision_warnings": 0,
"replaced_routed_connections": 0,
"total_length_mm": 0.0,
"skipped_missing_terminal": 0,
"skipped_missing_route_network": 0,
"skipped_invalid": 0,
"missing_endpoint_uuids": [],
"missing_endpoint_samples": [],
"missing_route_network_samples": [],
"collision_samples": [],
"errors": [],
"error_samples": [],
@ -1466,6 +1479,18 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la
}
if isinstance(prepared_layout, dict):
report["prepared_layout"] = prepared_layout
try:
route_network = RoutingNetwork.build_route_graph(
doc,
adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0),
)
except Exception as exc:
route_network = {}
report["route_network_error"] = str(exc)
report["route_network_carriers"] = int(route_network.get("carrier_count", 0) or 0)
report["route_network_segments"] = int(route_network.get("segment_count", 0) or 0)
report["route_network_nodes"] = len(route_network.get("nodes", {}) or {})
has_route_network = report["route_network_segments"] > 0
missing_endpoint_uuids = set()
lane_indexes_by_pair = {}
lane_indexes_by_segment = {}
@ -1478,6 +1503,12 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la
key = str(status or "").strip() or "Unknown"
report["route_status_counts"][key] = report["route_status_counts"].get(key, 0) + 1
def set_item_task_status(item, status):
wire_uuid = _wire_item_value(item, "wire_id", "wire_uuid", "id")
if not wire_uuid:
return
_set_task_status(_find_task_by_wire_uuid(doc, wire_uuid), status)
def create_route(route_lane_index, item, start_terminal, end_terminal, endpoint_metadata):
route_options = dict(options or {})
if isinstance(item, dict) and "__segment_usage_costs" in item:
@ -1496,6 +1527,7 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la
wire_mark_is_manual=bool(item.get("wire_mark_is_manual", False)),
wire_style_id=_wire_item_value(item, "wire_style_id"),
endpoint_metadata=endpoint_metadata,
defer_recompute=True,
)
for item in wires:
@ -1510,6 +1542,7 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la
if start_terminal is None or end_terminal is None:
report["skipped_missing_terminal"] += 1
add_status("MissingTerminal")
set_item_task_status(item, "MissingTerminal")
for terminal_uuid in (start_uuid, end_uuid):
if terminal_uuid and terminal_uuid not in terminals:
missing_endpoint_uuids.add(terminal_uuid)
@ -1530,6 +1563,27 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la
}
)
continue
if not has_route_network:
report["skipped_missing_route_network"] += 1
add_status("MissingRouteNetwork")
set_item_task_status(item, "MissingRouteNetwork")
if len(report["missing_route_network_samples"]) < 8:
report["missing_route_network_samples"].append(
{
"wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"),
"wire_label": _wire_item_value(item, "wire_label", "wire_mark"),
"start_terminal_uuid": start_uuid,
"start_element_uuid": _wire_item_value(item, "start_element_uuid"),
"start_terminal_display": _wire_item_value(item, "start_terminal_display"),
"start_device_label": _wire_item_value(item, "start_device_label"),
"end_terminal_uuid": end_uuid,
"end_element_uuid": _wire_item_value(item, "end_element_uuid"),
"end_terminal_display": _wire_item_value(item, "end_terminal_display"),
"end_device_label": _wire_item_value(item, "end_device_label"),
"endpoint_label": _wire_item_value(item, "endpoint_label"),
}
)
continue
lane_key = _route_lane_key(start_uuid, end_uuid)
route_lane_index = lane_indexes_by_pair.get(lane_key, 0)
try:
@ -1573,6 +1627,7 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la
error_text = str(exc)
report["errors"].append(error_text)
add_status("Error")
set_item_task_status(item, "Error")
if len(report["error_samples"]) < 8:
report["error_samples"].append(
{
@ -1603,6 +1658,9 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la
segment_usage_costs[segment_key] = segment_usage_costs.get(segment_key, 0) + 1
if result["route_status"] == "CollisionWarning":
report["collision_warnings"] += 1
report["replaced_routed_connections"] += int(
result.get("replaced_routed_connections", 0) or 0
)
add_status(result["route_status"])
route_collision_samples = []
for collision in list(result.get("collisions", []) or [])[:3]:
@ -1645,6 +1703,11 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la
"collision_samples": route_collision_samples,
}
)
if report["routed"] > 0:
try:
doc.recompute()
except Exception:
pass
report["missing_endpoint_uuids"] = sorted(missing_endpoint_uuids)
_write_routing_connection_batch_diagnostic(doc, report)
return report
@ -1819,6 +1882,61 @@ def _route_capacity_pressure_summary(report):
return pressure
_ROUTE_QUALITY_WARNING_KIND_LABELS = {
"RoutingRange": "布线面",
"AuxiliaryPath": "辅助路径",
}
def _route_track_carrier_kinds(route_track):
if not isinstance(route_track, dict):
return {}
carrier_kinds = route_track.get("carrier_kinds", {})
if isinstance(carrier_kinds, dict) and carrier_kinds:
return {
str(key): value
for key, value in carrier_kinds.items()
if str(key).strip()
}
counts = {}
for segment in route_track.get("segments", []) or []:
carrier = segment.get("carrier", {}) if isinstance(segment, dict) else {}
if not isinstance(carrier, dict):
continue
kind = str(carrier.get("kind", "") or "").strip()
if kind:
counts[kind] = counts.get(kind, 0) + 1
return counts
def _route_quality_warning_summary(report):
warning_count = 0
sample = None
for route in report.get("routes", []) or []:
if not isinstance(route, dict):
continue
carrier_kinds = _route_track_carrier_kinds(route.get("route_track", {}))
warning_labels = [
label
for kind, label in _ROUTE_QUALITY_WARNING_KIND_LABELS.items()
if carrier_kinds.get(kind, 0)
]
if not warning_labels:
continue
warning_count += 1
if sample is None:
sample = {
"wire": _wire_sample_text(route),
"labels": warning_labels,
}
if warning_count <= 0:
return {}
return {
"count": warning_count,
"sample": sample or {},
}
def format_eplan_connection_route_report(report):
message = "批量生成布线连接完成routed={0}, collision_warnings={1}, missing_terminals={2}".format(
report.get("routed", 0),
@ -1832,6 +1950,7 @@ def format_eplan_connection_route_report(report):
"CollisionWarning": "碰撞告警",
"Error": "错误",
"MissingTerminal": "缺失端子",
"MissingRouteNetwork": "缺少布线路径网络",
"Invalid": "无效任务",
}
def status_count_value(value):
@ -1840,7 +1959,14 @@ def format_eplan_connection_route_report(report):
except Exception:
return 0
status_parts = []
for key in ("Routed", "CollisionWarning", "Error", "MissingTerminal", "Invalid"):
for key in (
"Routed",
"CollisionWarning",
"Error",
"MissingTerminal",
"MissingRouteNetwork",
"Invalid",
):
value = status_count_value(status_counts.get(key, 0))
if value > 0:
status_parts.append("{0} {1}".format(status_labels[key], value))
@ -1858,9 +1984,19 @@ def format_eplan_connection_route_report(report):
prepared_layout.get("surface_carriers", 0),
prepared_layout.get("terminal_access_carriers", 0),
)
if report.get("skipped_missing_route_network", 0) > 0:
message += "\n缺少布线路径网络:{0} 条导线已跳过。请先生成线槽、布线面或布线路径网络。".format(
report.get("skipped_missing_route_network", 0)
)
total_length_mm = float(report.get("total_length_mm", 0.0) or 0.0)
if total_length_mm > 0.0:
message += "\n布线连接总长度:{0:.1f} mm。".format(total_length_mm)
replaced_routed_connections = int(report.get("replaced_routed_connections", 0) or 0)
if replaced_routed_connections > 0:
message += "\n已替换旧布线连接:{0} 条。".format(replaced_routed_connections)
hidden_route_carriers = int(report.get("hidden_route_carriers", 0) or 0)
if hidden_route_carriers > 0:
message += "\n已隐藏走线路径辅助对象:{0} 条。".format(hidden_route_carriers)
bridged_segments = _route_network_metric_max(report, "bridged_segments")
blocked_segments = _route_network_metric_max(report, "blocked_segments")
network_parts = []
@ -1885,6 +2021,17 @@ def format_eplan_connection_route_report(report):
route_source_sample = _route_source_sample_text(report)
if route_source_sample:
message += "\n{0}".format(route_source_sample)
quality_warning = _route_quality_warning_summary(report)
if quality_warning:
message += "\n路径质量提示:{0} 条导线使用布线面/辅助路径,可能没有完全优先进入线槽。".format(
quality_warning.get("count", 0)
)
sample = quality_warning.get("sample", {})
if isinstance(sample, dict) and sample.get("wire") and sample.get("labels"):
message += " 示例 {0} 使用{1}".format(
sample.get("wire"),
"".join(sample.get("labels", []) or []),
)
errors = report.get("errors", []) or []
if errors:
message += "\n首个错误:{0}".format(str(errors[0]))
@ -1970,6 +2117,95 @@ def _clear_routing_connection_batch_diagnostics(doc):
return removed
def _compact_route_sample(route):
route_track = route.get("route_track", {}) if isinstance(route, dict) else {}
if not isinstance(route_track, dict):
route_track = {}
sample = {
"wire_uuid": route.get("wire_uuid", ""),
"wire_label": route.get("wire_label", ""),
"start_terminal_uuid": route.get("start_terminal_uuid", ""),
"start_element_uuid": route.get("start_element_uuid", ""),
"start_terminal_display": route.get("start_terminal_display", ""),
"end_terminal_uuid": route.get("end_terminal_uuid", ""),
"end_element_uuid": route.get("end_element_uuid", ""),
"end_terminal_display": route.get("end_terminal_display", ""),
"endpoint_label": route.get("endpoint_label", ""),
"route_status": route.get("route_status", ""),
"length_mm": route.get("length_mm", 0.0),
"lane": route.get("lane", {}),
"algorithm": route.get("algorithm", ""),
"collision_count": route.get("collision_count", 0),
"carrier_kinds": route_track.get("carrier_kinds", {}),
"carrier_names": list(route_track.get("carrier_names", []) or [])[:8],
"route_source_labels": _route_source_labels(route_track, limit=8),
}
network = route.get("network", {})
if isinstance(network, dict):
sample["network"] = {
"carriers": network.get("carriers", 0),
"segments": network.get("segments", 0),
"bridged_segments": network.get("bridged_segments", 0),
"blocked_segments": network.get("blocked_segments", 0),
"entry_distance": network.get("entry_distance", 0.0),
"exit_distance": network.get("exit_distance", 0.0),
}
return sample
def _compact_routing_connection_batch_report(report, sample_limit=8):
if not isinstance(report, dict):
return {}
limit = max(int(sample_limit or 0), 0)
payload = {}
scalar_keys = (
"total_wires",
"available_terminals",
"local_terminals",
"auto_bound_terminals",
"auto_created_terminals",
"routed",
"collision_warnings",
"replaced_routed_connections",
"total_length_mm",
"skipped_missing_terminal",
"skipped_missing_route_network",
"skipped_invalid",
"route_network_carriers",
"route_network_segments",
"route_network_nodes",
"route_network_error",
"hidden_route_carriers",
)
for key in scalar_keys:
if key in report:
payload[key] = report.get(key)
if isinstance(report.get("prepared_layout"), dict):
payload["prepared_layout"] = report.get("prepared_layout")
if isinstance(report.get("route_status_counts"), dict):
payload["route_status_counts"] = dict(report.get("route_status_counts") or {})
missing_endpoint_uuids = list(report.get("missing_endpoint_uuids", []) or [])
payload["missing_endpoint_uuid_count"] = len(missing_endpoint_uuids)
payload["missing_endpoint_uuids"] = missing_endpoint_uuids[:50]
for key in (
"auto_terminal_binding_warnings",
"missing_endpoint_samples",
"missing_route_network_samples",
"collision_samples",
"error_samples",
"errors",
):
values = list(report.get(key, []) or [])
payload[key] = values[:limit]
payload["{0}_count".format(key)] = len(values)
routes = [route for route in list(report.get("routes", []) or []) if isinstance(route, dict)]
payload["route_count"] = len(routes)
payload["route_samples"] = [_compact_route_sample(route) for route in routes[:limit]]
payload["route_sample_count"] = len(payload["route_samples"])
payload["diagnostic_payload"] = "compact-routing-connection-batch-v1"
return payload
def _write_routing_connection_batch_diagnostic(doc, report):
if doc is None or not isinstance(report, dict):
return None
@ -1990,7 +2226,7 @@ def _write_routing_connection_batch_diagnostic(doc, report):
_set_string(
diagnostic,
"QetDiagnosticJson",
json.dumps(report, ensure_ascii=False),
json.dumps(_compact_routing_connection_batch_report(report), ensure_ascii=False),
"QET routing connection batch diagnostic payload",
)
group.addObject(diagnostic)
@ -2229,13 +2465,14 @@ def route_eplan_connections(
"""Route QET wire tasks through the EPLAN-style routing path network."""
if doc is None:
raise AutoRoutingError("No FreeCAD document is available.")
opts = _merged_options(options)
prepared_network = None
if update_network:
prepared_network = update_eplan_routing_path_network(
doc,
project_uuid=project_uuid,
options=options,
options=opts,
selection_ex=selection_ex,
)
@ -2247,13 +2484,13 @@ def route_eplan_connections(
report = route_eplan_connections_from_payload(
doc,
target_payload,
options=options,
options=opts,
prepared_layout=prepared_network,
)
else:
report = route_eplan_connection_tasks(
doc,
options=options,
options=opts,
prepared_layout=prepared_network,
)
@ -2261,6 +2498,10 @@ def route_eplan_connections(
report["routing_path_network_updated"] = bool(update_network)
if isinstance(prepared_network, dict):
report["routing_path_network"] = prepared_network
if opts.get("hide_route_carriers_after_route", True):
report["hidden_route_carriers"] = RoutingNetwork.set_route_carriers_visibility(doc, False)
else:
report["hidden_route_carriers"] = 0
return report
def wire_task_count(doc):
@ -2269,15 +2510,22 @@ def wire_task_count(doc):
def clear_routing_connections(doc):
removed = 0
removed_wire_uuids = set()
for obj in list(WiringObjects.iter_routed_wire_objects(doc)):
if (getattr(obj, "RouteType", "") or "").strip() != "RoutedConnection":
continue
wire_uuid = (getattr(obj, "QetWireUuid", "") or "").strip()
try:
_detach_object_from_groups(doc, obj)
doc.removeObject(obj.Name)
removed += 1
if wire_uuid:
removed_wire_uuids.add(wire_uuid)
except Exception:
pass
for wire_uuid in sorted(removed_wire_uuids):
_set_task_status(_find_task_by_wire_uuid(doc, wire_uuid), "Task")
_clear_routing_connection_batch_diagnostics(doc)
try:
doc.recompute()
except Exception:

@ -70,6 +70,32 @@ def _selection_ex():
return []
def _style_command_button(button, text, tooltip=""):
"""Keep task-panel command button text visible across FreeCAD themes."""
if button is None:
return button
try:
button.setText(text)
except Exception:
pass
try:
button.setToolTip(tooltip)
except Exception:
pass
try:
button.setMinimumHeight(28)
except Exception:
pass
try:
button.setStyleSheet(
"QPushButton { color: #202020; text-align: left; padding-left: 8px; }"
"QPushButton:disabled { color: #606060; }"
)
except Exception:
pass
return button
class AutoRoutingController:
def __init__(self, options=None):
self.last_report = None
@ -187,6 +213,31 @@ class AutoRoutingController:
self.last_report["source_mode"] = "document"
return self.last_report
def create_user_paths_from_selection(self):
doc = _active_document()
project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip()
removed_stale_carriers = RoutingNetwork.cleanup_invalid_source_carriers(doc)
created = RoutingNetwork.create_user_path_carriers_from_selection(
doc,
_selection_ex(),
project_uuid=project_uuid,
)
self.last_report = {
"user_path_carriers": len(created),
"removed_stale_carriers": removed_stale_carriers,
"network": RoutingNetwork.network_summary(
doc,
adjoining_duct_tolerance=float(
self.routing_options().get(
"adjoining_duct_tolerance",
RoutingNetwork.DEFAULT_ADJOINING_DUCT_TOLERANCE,
)
or 0.0
),
),
}
return self.last_report
def route_eplan_connections(self):
doc = _active_document()
project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip()
@ -308,35 +359,46 @@ class AutoRoutingTaskPanel:
self.lane_axis_combo.setCurrentIndex(axis_index if axis_index >= 0 else 0)
options_layout.addWidget(self.lane_axis_combo)
self.generate_layout_button = QtWidgets.QPushButton("准备布线布局空间")
self.generate_layout_button.setToolTip(
"按 EPLAN 布局空间语义识别线槽、安装面、工程端子和障碍处理方式,不生成导线。"
self.generate_layout_button = _style_command_button(
QtWidgets.QPushButton(),
"准备布线布局空间",
"按 EPLAN 布局空间语义识别线槽、安装面、工程端子和障碍处理方式,不生成导线。",
)
self.generate_paths_button = _style_command_button(
QtWidgets.QPushButton(),
"生成布线路径网络",
"按 EPLAN 逻辑从布局空间生成完整 routing path network线槽、布线区域和端子接入。",
)
self.generate_paths_button = QtWidgets.QPushButton("生成布线路径网络")
self.generate_paths_button.setToolTip(
"按 EPLAN 逻辑从布局空间生成完整 routing path network线槽、布线区域和端子接入。"
self.create_user_paths_button = _style_command_button(
QtWidgets.QPushButton(),
"选中路径作为用户路径",
"把选中的草图、线段或纯线状对象转换为可参与自动布线的 UserPath。",
)
self.check_paths_button = QtWidgets.QPushButton("检查布线路径网络")
self.check_paths_button.setToolTip(
"检查 routing path network 的断点、孤立网络和未接入端子,并写入诊断对象。"
self.check_paths_button = _style_command_button(
QtWidgets.QPushButton(),
"检查布线路径网络",
"检查 routing path network 的断点、孤立网络和未接入端子,并写入诊断对象。",
)
self.route_connections_button = QtWidgets.QPushButton("生成布线连接")
self.route_connections_button.setToolTip(
"自动更新布线路径网络,并按全部 QET 导线任务生成 3D 布线连接。"
self.route_connections_button = _style_command_button(
QtWidgets.QPushButton(),
"生成布线连接",
"自动更新布线路径网络,并按全部 QET 导线任务生成 3D 布线连接。",
)
self.clear_routes_button = QtWidgets.QPushButton("清除布线连接")
self.clear_carriers_button = QtWidgets.QPushButton("清除走线路径")
self.save_button = QtWidgets.QPushButton("保存")
self.clear_routes_button = _style_command_button(QtWidgets.QPushButton(), "清除布线连接")
self.clear_carriers_button = _style_command_button(QtWidgets.QPushButton(), "清除走线路径")
self.save_button = _style_command_button(QtWidgets.QPushButton(), "保存")
self.status_label = QtWidgets.QLabel("")
self.status_label.setWordWrap(True)
for widget in (
self.generate_layout_button,
self.create_user_paths_button,
self.generate_paths_button,
self.check_paths_button,
self.route_connections_button,
@ -350,6 +412,7 @@ class AutoRoutingTaskPanel:
layout.addWidget(self.status_label)
self.generate_paths_button.clicked.connect(self.generate_routing_paths)
self.create_user_paths_button.clicked.connect(self.create_user_paths_from_selection)
self.check_paths_button.clicked.connect(self.check_routing_path_network)
self.generate_layout_button.clicked.connect(self.generate_layout_space)
self.route_connections_button.clicked.connect(self.route_eplan_connections)
@ -385,18 +448,20 @@ class AutoRoutingTaskPanel:
self._sync_options_from_widgets()
result = self.controller.generate_routing_paths()
wire_ducts = result.get("wire_duct_carriers", 0)
user_paths = result.get("user_path_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(
"未生成可用布线路径网络。可选中线槽实体,或确认安装板/背板能作为布线区域。"
"未生成可用布线路径网络。可选中线槽实体、草图/线段作为用户路径,或确认安装板/背板能作为布线区域。"
+ self.controller.summary()
)
return
self._set_status(
"已生成布线路径网络:线槽路径 {0} 条,布线区域 {1} 条,端子接入 {2} 条,网络段 {3} 条。{4}".format(
"已生成布线路径网络:线槽路径 {0} 条,用户路径 {1} 条,布线区域 {2} 条,端子接入 {3} 条,网络段 {4} 条。{5}".format(
wire_ducts,
user_paths,
surfaces,
terminal_access,
network.get("segments", 0),
@ -406,6 +471,35 @@ class AutoRoutingTaskPanel:
except Exception as exc:
self._set_error(str(exc))
def create_user_paths_from_selection(self):
try:
self._sync_options_from_widgets()
result = self.controller.create_user_paths_from_selection()
created = result.get("user_path_carriers", 0)
removed = result.get("removed_stale_carriers", 0)
if created <= 0:
stale_text = ""
if removed > 0:
stale_text = "已清理失效用户路径:{0} 条。".format(removed)
self._set_status(
stale_text
+ "未创建用户路径。请先选择草图、Draft 线、线段或纯线状对象。"
+ self.controller.summary()
)
return
stale_text = ""
if removed > 0:
stale_text = "已清理失效用户路径:{0} 条。".format(removed)
self._set_status(
"{0}已创建用户路径:{1} 条。{2}".format(
stale_text,
created,
self.controller.summary(),
)
)
except Exception as exc:
self._set_error(str(exc))
def check_routing_path_network(self):
try:
self._sync_options_from_widgets()

@ -19,6 +19,7 @@ ROUTE_CARRIER_KIND = "RoutingPath"
ROUTE_CARRIER_KIND_WIRE_DUCT = "WireDuct"
ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END = "WireDuctOpenEnd"
ROUTE_CARRIER_KIND_WIRING_CUT_OUT = "WiringCutOut"
ROUTE_CARRIER_KIND_USER_PATH = "UserPath"
ROUTE_CARRIER_KIND_AUXILIARY_PATH = "AuxiliaryPath"
ROUTE_CARRIER_KIND_ROUTING_RANGE = "RoutingRange"
ROUTE_CARRIER_KIND_TERMINAL_ACCESS = "TerminalAccess"
@ -26,6 +27,7 @@ MANAGED_ROUTE_SOURCE_KINDS = {
ROUTE_CARRIER_KIND_WIRE_DUCT,
ROUTE_CARRIER_KIND_WIRING_CUT_OUT,
ROUTE_CARRIER_KIND_ROUTING_RANGE,
ROUTE_CARRIER_KIND_USER_PATH,
}
PROPERTY_GROUP = "QET Routing"
DEFAULT_NODE_TOLERANCE = 0.001
@ -114,8 +116,8 @@ DEFAULT_KIND_COST_FACTORS = {
ROUTE_CARRIER_KIND: 1.0,
ROUTE_CARRIER_KIND_AUXILIARY_PATH: 2.0,
ROUTE_CARRIER_KIND_TERMINAL_ACCESS: 2.0,
ROUTE_CARRIER_KIND_ROUTING_RANGE: 8.0,
"UserPath": 1.0,
ROUTE_CARRIER_KIND_ROUTING_RANGE: 25.0,
ROUTE_CARRIER_KIND_USER_PATH: 1.0,
}
ROUTE_CARRIER_VIEW_STYLES = {
ROUTE_CARRIER_KIND_WIRE_DUCT: {
@ -142,6 +144,10 @@ ROUTE_CARRIER_VIEW_STYLES = {
"color": (0.45, 0.45, 0.45),
"width": 2.0,
},
ROUTE_CARRIER_KIND_USER_PATH: {
"color": (0.95, 0.15, 0.15),
"width": 3.0,
},
ROUTE_CARRIER_KIND: {
"color": (0.0, 0.45, 0.85),
"width": 2.0,
@ -587,6 +593,18 @@ def _set_wiring_cut_out_source_semantics(source, bridge_extension=DEFAULT_WIRING
)
def _set_user_path_source_semantics(source):
if source is None:
return
TerminalObjects.ensure_string_property(
source,
"QetRoutingSourceKind",
PROPERTY_GROUP,
"Routing source kind",
ROUTE_CARRIER_KIND_USER_PATH,
)
def _style_route_carrier(carrier, kind):
style = ROUTE_CARRIER_VIEW_STYLES.get(kind) or ROUTE_CARRIER_VIEW_STYLES[ROUTE_CARRIER_KIND]
try:
@ -707,8 +725,11 @@ def create_route_carrier(doc, points, label="", project_uuid="", kind=ROUTE_CARR
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))
try:
role = (getattr(obj, "QetRoutingRole", "") or "").strip()
return role == ROUTING_ROLE and bool(getattr(obj, "CanRouteWire", False))
except Exception:
return False
def _carrier_points(obj):
@ -892,6 +913,22 @@ def collect_route_carriers(doc):
return result
def set_route_carriers_visibility(doc, visible):
"""Show or hide generated route carrier helper objects."""
updated = 0
for carrier in collect_route_carriers(doc):
try:
carrier.ViewObject.Visibility = bool(visible)
updated += 1
except Exception:
pass
try:
doc.recompute()
except Exception:
pass
return updated
def _detach_from_groups(doc, obj):
for parent in list(getattr(obj, "InList", []) or []):
group = list(getattr(parent, "Group", []) or [])
@ -1418,6 +1455,61 @@ def create_carriers_from_selection(doc, selection_ex, project_uuid="", kind=ROUT
return created
def create_user_path_carriers_from_selection(doc, selection_ex, project_uuid=""):
"""Create or refresh user-defined spatial route paths from selected sketches/edges."""
cleanup_invalid_source_carriers(doc)
created = []
support_face = _support_face_from_selection(selection_ex)
seen_sources = set()
for index, item in enumerate(selection_ex or [], start=1):
if support_face is not None and _selection_item_is_only_support_face(item):
continue
source = getattr(item, "Object", None)
if source is not None:
if id(source) in seen_sources:
continue
seen_sources.add(id(source))
if (
_is_wire_duct_candidate(source)
or _is_support_surface_candidate(source)
or _is_wiring_cut_out_candidate(source)
):
continue
points = _points_from_selection_item(item)
if len(points) < 2:
continue
if support_face is not None:
points = _project_points_to_face(points, support_face)
label = "QET User Route Path {0}".format(index)
if source is not None:
label = "QET User Route Path {0}".format(
getattr(source, "Label", "") or getattr(source, "Name", "") or index
)
live_carrier = _live_source_carrier(doc, source)
if live_carrier is not None:
if _update_route_carrier(
live_carrier,
points,
project_uuid=project_uuid,
kind=ROUTE_CARRIER_KIND_USER_PATH,
):
_mark_user_path_source(source, live_carrier)
continue
carrier = create_route_carrier(
doc,
points,
label=label,
project_uuid=project_uuid,
kind=ROUTE_CARRIER_KIND_USER_PATH,
)
if source is not None:
_mark_user_path_source(source, carrier)
created.append(carrier)
return created
def _wire_duct_centerline_spec_from_bbox(bbox, margin=DEFAULT_WIRE_DUCT_MARGIN, min_aspect=1.5):
extents = {
axis: _bbox_extent(bbox, axis)
@ -1702,6 +1794,23 @@ def _mark_wiring_cut_out_source(source, carrier, bridge_extension=DEFAULT_WIRING
pass
def _mark_user_path_source(source, carrier):
if source is None or carrier is None:
return
try:
_set_user_path_source_semantics(source)
TerminalObjects.ensure_string_property(
source,
"QetRouteCarrierName",
PROPERTY_GROUP,
"Generated route carrier for this source",
getattr(carrier, "Name", ""),
)
_remember_source_carriers(source, [carrier])
except Exception:
pass
def _mark_terminal_access_source(source, carrier):
if source is None or carrier is None:
return
@ -1737,6 +1846,8 @@ def _source_is_valid_for_kind(source, source_kind):
return _is_support_surface_candidate(source)
if source_kind == ROUTE_CARRIER_KIND_WIRING_CUT_OUT:
return _is_wiring_cut_out_candidate(source)
if source_kind == ROUTE_CARRIER_KIND_USER_PATH:
return _is_route_path_source_object(source)
return True
@ -2103,6 +2214,101 @@ def _terminal_exit_point(terminal, exit_length):
return _add(origin, _scale(direction, max(float(exit_length or 0.0), 0.0)))
def _json_route_point(item):
try:
if isinstance(item, dict):
return App.Vector(
float(item.get("x", 0.0) or 0.0),
float(item.get("y", 0.0) or 0.0),
float(item.get("z", 0.0) or 0.0),
)
if isinstance(item, (list, tuple)) and len(item) >= 3:
return App.Vector(float(item[0] or 0.0), float(item[1] or 0.0), float(item[2] or 0.0))
except Exception:
return None
return None
def _terminal_local_route_points(terminal):
for property_name in ("QetTerminalLocalRoutePointsJson", "QetLocalRoutePointsJson"):
raw = (getattr(terminal, property_name, "") or "").strip()
if not raw:
continue
try:
parsed = json.loads(raw)
except Exception:
continue
points = [_json_route_point(item) for item in parsed if item is not None]
points = [point for point in points if point is not None]
if points:
return points
return []
def _terminal_parent_chain(terminal):
chain = []
current = terminal
visited = set()
while current is not None:
parents = list(getattr(current, "InList", []) or [])
parent = None
for candidate in parents:
if id(candidate) in visited:
continue
if getattr(candidate, "Placement", None) is not None:
parent = candidate
break
if parent is None:
break
visited.add(id(parent))
chain.append(parent)
current = parent
return chain
def _placement_mult_vec(placement, point):
if placement is None:
return point
try:
transformed = placement.multVec(point)
if transformed is not None:
return _vector(transformed)
except Exception:
pass
base = getattr(placement, "Base", None)
if base is not None:
return App.Vector(point.x + base.x, point.y + base.y, point.z + base.z)
return point
def _terminal_local_point_to_global(terminal, local_point):
try:
if hasattr(terminal, "getGlobalPlacement"):
placement = terminal.getGlobalPlacement()
return _placement_mult_vec(placement, _vector(local_point))
except Exception:
pass
point = _placement_mult_vec(getattr(terminal, "Placement", None), _vector(local_point))
for parent in _terminal_parent_chain(terminal):
point = _placement_mult_vec(getattr(parent, "Placement", None), point)
return point
def terminal_access_path_points(terminal, exit_length=20.0):
"""Return terminal-to-network access points, honoring optional local route metadata."""
origin = _vector(TerminalObjects.terminal_origin(terminal))
local_points = _terminal_local_route_points(terminal)
if local_points:
points = [_terminal_local_point_to_global(terminal, point) for point in local_points]
if not points or _distance(points[0], origin) > DEFAULT_NODE_TOLERANCE:
points.insert(0, origin)
normalized = _normalized_route_points(points)
if len(normalized) >= 2:
return normalized
return _normalized_route_points([origin, _terminal_exit_point(terminal, exit_length)])
def _orthogonal_access_points(start, end):
"""Create a Manhattan path so access carriers can join the routing graph."""
start = _vector(start)
@ -2162,7 +2368,11 @@ def create_terminal_access_carriers_from_document(
for terminal in _collect_routable_terminals(doc):
if _live_source_carrier(doc, terminal) is not None:
continue
exit_point = _terminal_exit_point(terminal, terminal_exit_length)
has_local_route_points = bool(_terminal_local_route_points(terminal))
terminal_access_points = terminal_access_path_points(terminal, terminal_exit_length)
if len(terminal_access_points) < 2:
continue
exit_point = terminal_access_points[-1]
nearest_point, distance = nearest_point_on_network(network, exit_point)
if nearest_point is None:
continue
@ -2171,7 +2381,13 @@ def create_terminal_access_carriers_from_document(
if float(distance or 0.0) <= DEFAULT_NODE_TOLERANCE:
continue
points = _orthogonal_access_points(exit_point, nearest_point)
if has_local_route_points:
points = list(terminal_access_points)
for point in _orthogonal_access_points(exit_point, nearest_point)[1:]:
if _distance(points[-1], point) > DEFAULT_NODE_TOLERANCE:
points.append(point)
else:
points = _orthogonal_access_points(exit_point, nearest_point)
if len(points) < 2:
continue
label = getattr(terminal, "Label", "") or getattr(terminal, "Name", "") or "Terminal"
@ -2206,12 +2422,18 @@ def create_routing_path_network_from_document(
project_uuid=project_uuid,
)
selected_wire_ducts = []
selected_user_paths = []
if selection_ex:
selected_wire_ducts = create_wire_duct_carriers_from_selection(
doc,
selection_ex,
project_uuid=project_uuid,
)
selected_user_paths = create_user_path_carriers_from_selection(
doc,
selection_ex,
project_uuid=project_uuid,
)
wire_ducts = create_wire_duct_carriers_from_document(
doc,
project_uuid=project_uuid,
@ -2250,6 +2472,7 @@ def create_routing_path_network_from_document(
return {
"wire_duct_carriers": wire_duct_main_count,
"selected_wire_duct_carriers": selected_wire_duct_main_count,
"user_path_carriers": len(selected_user_paths),
"wire_duct_open_end_carriers": open_end_count,
"wiring_cut_out_carriers": len(cut_outs),
"surface_carriers": len(surfaces),
@ -2273,6 +2496,8 @@ def create_wire_duct_carriers_from_selection(
cleanup_invalid_source_carriers(doc)
created = []
for index, source in enumerate(_wire_duct_sources_from_selection(selection_ex), start=1):
if _source_kind_value(source) == ROUTE_CARRIER_KIND_USER_PATH or _is_route_path_source_object(source):
continue
bbox = _bound_box_from_object(source)
if bbox is None:
continue

@ -1,5 +1,7 @@
# FreeCADExchange FCStd template authoring helpers.
import json
import FreeCAD as App
try:
@ -110,6 +112,151 @@ def is_template_terminal(obj):
return TerminalObjects.is_terminal_hint_object(obj)
def _route_point_payload(point):
if isinstance(point, App.Vector):
return [float(point.x), float(point.y), float(point.z)]
if isinstance(point, dict):
try:
return [
float(point.get("x", 0.0) or 0.0),
float(point.get("y", 0.0) or 0.0),
float(point.get("z", 0.0) or 0.0),
]
except Exception:
return None
if isinstance(point, (list, tuple)) and len(point) >= 3:
try:
return [float(point[0] or 0.0), float(point[1] or 0.0), float(point[2] or 0.0)]
except Exception:
return None
return None
def _distance(left, right):
try:
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
except Exception:
return 0.0
def _edge_points(edge):
vertexes = list(getattr(edge, "Vertexes", []) or [])
if len(vertexes) >= 2:
start = getattr(vertexes[0], "Point", None)
end = getattr(vertexes[-1], "Point", None)
if start is not None and end is not None:
return [start, end]
try:
return [edge.valueAt(edge.FirstParameter), edge.valueAt(edge.LastParameter)]
except Exception:
return []
def _selection_item_points(selection_item):
points = []
points.extend(list(getattr(selection_item, "PickedPoints", []) or []))
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(point)
obj = getattr(selection_item, "Object", None)
shape = getattr(obj, "Shape", None)
if shape is not None and not is_template_terminal(obj):
for edge in list(getattr(shape, "Edges", []) or []):
points.extend(_edge_points(edge))
normalized = []
for point in points:
if point is None:
continue
if not normalized or _distance(normalized[-1], point) > 1e-6:
normalized.append(point)
return normalized
def _document_point_to_terminal_local(terminal, point):
placement = getattr(terminal, "Placement", None)
if placement is None:
return point
try:
inverse = placement.inverse()
transformed = inverse.multVec(point)
if transformed is not None:
return transformed
except Exception:
pass
base = getattr(placement, "Base", None)
if base is None:
return point
return App.Vector(
float(point.x) - float(base.x),
float(point.y) - float(base.y),
float(point.z) - float(base.z),
)
def set_template_terminal_local_route_points(obj, points):
if not is_template_terminal(obj):
raise TemplateAuthoringError("A valid template terminal is required.")
payload = []
for point in points or []:
item = _route_point_payload(point)
if item is not None:
payload.append(item)
if len(payload) < 2:
raise TemplateAuthoringError("At least two local route points are required.")
TerminalObjects.ensure_string_property(
obj,
"QetTerminalLocalRoutePointsJson",
TEMPLATE_PROPERTY_GROUP,
"Terminal-local route points used before joining the cabinet routing network",
json.dumps(payload, ensure_ascii=False),
)
try:
obj.Document.recompute()
except Exception:
pass
return obj
def set_selected_template_terminal_local_route_points(selection_ex=None):
if selection_ex is None:
if Gui is None:
raise TemplateAuthoringError("FreeCAD GUI selection is not available.")
try:
selection_ex = Gui.Selection.getSelectionEx()
except Exception:
selection_ex = []
terminal = None
route_points = []
for item in list(selection_ex or []):
obj = getattr(item, "Object", None)
if terminal is None and is_template_terminal(obj):
terminal = obj
continue
route_points.extend(_selection_item_points(item))
if terminal is None:
raise TemplateAuthoringError("Select one template terminal before selecting its local route path.")
if len(route_points) < 2:
raise TemplateAuthoringError("Select a sketch, edge, or route path with at least two points.")
local_points = [
_document_point_to_terminal_local(terminal, point)
for point in route_points
]
return set_template_terminal_local_route_points(terminal, local_points)
def _has_property(obj, prop_name):
return prop_name in getattr(obj, "PropertiesList", [])
@ -276,6 +423,30 @@ class CommandValidateTemplateTerminals:
App.Console.PrintWarning("[FreeCADExchange] {0}\n".format(warning))
class CommandSetTemplateTerminalLocalRoute:
def GetResources(self):
return {
"MenuText": "设置端子局部路径",
"ToolTip": "把选中的草图或线段保存为模板端子的局部出线路径",
}
def IsActive(self):
return App.ActiveDocument is not None and Gui is not None
def Activated(self):
try:
terminal = set_selected_template_terminal_local_route_points()
App.Console.PrintMessage(
"[FreeCADExchange] Updated local route points for template terminal {0}.\n".format(
getattr(terminal, "QetTemplateSlotName", "") or getattr(terminal, "Name", "")
)
)
except Exception as exc:
App.Console.PrintError(
"[FreeCADExchange] setting template terminal local route failed: {0}\n".format(exc)
)
class CommandSaveTemplateAsFCStd:
def GetResources(self):
return {
@ -325,6 +496,7 @@ def register_commands():
return
Gui.addCommand("QET_Template_AddTerminal", CommandAddTemplateTerminal())
Gui.addCommand("QET_Template_ValidateTerminals", CommandValidateTemplateTerminals())
Gui.addCommand("QET_Template_SetTerminalLocalRoute", CommandSetTemplateTerminalLocalRoute())
Gui.addCommand("QET_Template_SaveAsFCStd", CommandSaveTemplateAsFCStd())
_COMMANDS_REGISTERED = True

@ -123,6 +123,34 @@ def _vector_to_payload(value):
return [float(vector.x), float(vector.y), float(vector.z)]
def _route_points_to_payload(value):
if not isinstance(value, (list, tuple)):
return None
points = []
for item in value:
point = _vector_to_payload(item)
if point is not None:
points.append(point)
return points if points else None
def _route_points_from_object(source_object):
if source_object is None:
return None
for property_name in ("QetTerminalLocalRoutePointsJson", "QetLocalRoutePointsJson"):
raw_text = getattr(source_object, property_name, "")
if not isinstance(raw_text, str) or not raw_text.strip():
continue
try:
parsed = json.loads(raw_text)
except (TypeError, ValueError):
continue
points = _route_points_to_payload(parsed)
if points:
return points
return None
def _rotation_to_payload(value):
if not isinstance(value, dict):
return None
@ -161,6 +189,9 @@ def _slot_to_payload(slot):
rotation = _rotation_to_payload(slot.get("rotation"))
if rotation is not None:
payload["rotation"] = rotation
local_route_points = _route_points_to_payload(slot.get("local_route_points"))
if local_route_points is not None:
payload["local_route_points"] = local_route_points
return payload
@ -263,6 +294,18 @@ def _slot_from_payload(item, source, index, source_object=None):
rotation = placement_rotation
if rotation is None:
rotation = _rotation_from_object(source_object)
local_route_points = None
for route_points_key in (
"local_route_points",
"terminal_local_route_points",
"exit_path",
"route_points",
):
local_route_points = _route_points_to_payload(item.get(route_points_key))
if local_route_points is not None:
break
if local_route_points is None:
local_route_points = _route_points_from_object(source_object)
slot = {
"name": name,
@ -273,6 +316,8 @@ def _slot_from_payload(item, source, index, source_object=None):
}
if rotation is not None:
slot["rotation"] = rotation
if local_route_points is not None:
slot["local_route_points"] = local_route_points
return slot

@ -1,6 +1,7 @@
# FreeCADExchange terminal import helpers.
from collections import OrderedDict
import json
import FreeCAD as App
@ -144,12 +145,8 @@ def _terminal_belongs_to_payload_devices(entry, device_lookup):
return False
def _ensure_visible(obj):
try:
if getattr(obj, "ViewObject", None) is not None:
obj.ViewObject.Visibility = True
except Exception:
pass
def _hide_engineering_terminal(obj):
TerminalObjects.hide_engineering_terminal(obj)
def _set_terminal_geometry_source(obj, source):
@ -167,6 +164,21 @@ def _set_terminal_geometry_source(obj, source):
)
def _set_terminal_local_route_points(obj, slot):
points = slot.get("local_route_points") if isinstance(slot, dict) else None
raw_text = ""
if isinstance(points, list) and points:
raw_text = json.dumps(points, ensure_ascii=False)
if raw_text or "QetTerminalLocalRoutePointsJson" in getattr(obj, "PropertiesList", []):
TerminalObjects.ensure_string_property(
obj,
"QetTerminalLocalRoutePointsJson",
"QET Routing",
"Terminal-local route points used before joining the cabinet routing network",
raw_text,
)
def _hide_object(obj):
try:
if getattr(obj, "ViewObject", None) is not None:
@ -339,7 +351,8 @@ def _create_terminal_object(doc, terminal_uuid, entry, slot, terminal_group, pro
slot_name=slot.get("name", ""),
)
_set_terminal_geometry_source(terminal_obj, slot.get("source", "template"))
_ensure_visible(terminal_obj)
_set_terminal_local_route_points(terminal_obj, slot)
_hide_engineering_terminal(terminal_obj)
return terminal_obj
@ -510,11 +523,12 @@ def import_terminals_from_payload(payload, scene_path=""):
slot_name=slot.get("name", ""),
)
_set_terminal_geometry_source(terminal_obj, slot.get("source", "template"))
_set_terminal_local_route_points(terminal_obj, slot)
try:
terminal_obj.Placement = _slot_placement(slot)
except Exception:
pass
_ensure_visible(terminal_obj)
_hide_engineering_terminal(terminal_obj)
report["updated_terminals"] += 1
else:
terminal_obj = _create_terminal_object(
@ -539,11 +553,12 @@ def import_terminals_from_payload(payload, scene_path=""):
slot_name=slot.get("name", ""),
)
_set_terminal_geometry_source(terminal_obj, slot.get("source", "template"))
_set_terminal_local_route_points(terminal_obj, slot)
try:
terminal_obj.Placement = _slot_placement(slot)
except Exception:
pass
_ensure_visible(terminal_obj)
_hide_engineering_terminal(terminal_obj)
report["updated_terminals"] += 1
if terminal_obj not in getattr(terminal_group, "Group", []):

@ -662,6 +662,57 @@ class AutoRoutingTest(unittest.TestCase):
self.assertEqual((0.65, 0.2, 1.0), terminal_access.ViewObject.LineColor)
self.assertEqual("Solid", terminal_access.ViewObject.DrawStyle)
def test_set_route_carriers_visibility_toggles_only_route_helpers(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")
carrier = routing_network.create_route_carrier(
doc,
[app.Vector(0, 0, 0), app.Vector(100, 0, 0)],
project_uuid="project-1",
kind="WireDuct",
)
device = doc.addObject("Part::Feature", "DeviceA")
device.ViewObject.Visibility = True
hidden = routing_network.set_route_carriers_visibility(doc, False)
self.assertFalse(carrier.ViewObject.Visibility)
shown = routing_network.set_route_carriers_visibility(doc, True)
self.assertEqual(1, hidden)
self.assertEqual(1, shown)
self.assertTrue(carrier.ViewObject.Visibility)
self.assertTrue(device.ViewObject.Visibility)
def test_collect_route_carriers_ignores_deleted_object_references(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")
carrier = routing_network.create_route_carrier(
doc,
[app.Vector(0, 0, 0), app.Vector(100, 0, 0)],
project_uuid="project-1",
kind="WireDuct",
)
class DeletedObjectReference:
Name = "DeletedCarrier"
def __getattr__(self, name):
if name == "QetRoutingRole":
raise RuntimeError("Cannot access attribute 'QetRoutingRole' of deleted object")
raise AttributeError(name)
doc.Objects.append(DeletedObjectReference())
carriers = routing_network.collect_route_carriers(doc)
self.assertEqual([carrier], carriers)
def test_route_carrier_exposes_capacity_property_for_auto_routing(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules()
@ -1141,6 +1192,182 @@ class AutoRoutingTest(unittest.TestCase):
self.assertEqual(1, result["wire_duct_carriers"])
self.assertEqual("selection", result["source_mode"])
def test_generate_routing_paths_uses_selected_route_path_as_user_path(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules()
auto_routing_panel = importlib.import_module("AutoRoutingPanel")
app = sys.modules["FreeCAD"]
gui = sys.modules["FreeCADGui"]
doc = FakeDocument()
app.ActiveDocument = doc
terminal_objects.ensure_root_group(doc, "project-1")
route_path = doc.addObject("Part::Feature", "UserRouteSketch")
route_path.Label = "用户主路径A"
route_path.Shape = FakeShape(
FakeBoundBox(0, 100, 0, 80, 20, 20),
edges=[
FakeEdge(app.Vector(0, 0, 20), app.Vector(0, 80, 20)),
FakeEdge(app.Vector(0, 80, 20), app.Vector(100, 80, 20)),
],
)
gui.Selection = types.SimpleNamespace(
getSelection=lambda: [],
getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)],
)
result = auto_routing_panel.AutoRoutingController().generate_routing_paths()
carriers = routing_network.collect_route_carriers(doc)
user_paths = [item for item in carriers if item.QetRouteCarrierKind == "UserPath"]
self.assertEqual(1, result["user_path_carriers"])
self.assertEqual(1, len(user_paths))
self.assertEqual("UserRouteSketch", user_paths[0].QetRouteSourceName)
self.assertEqual("用户主路径A", user_paths[0].QetRouteSourceLabel)
self.assertEqual("selection", result["source_mode"])
def test_controller_creates_selected_user_paths_without_full_network_generation(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")
route_path = doc.addObject("Part::Feature", "UserRouteSketch")
route_path.Shape = FakeShape(
FakeBoundBox(0, 100, 0, 80, 20, 20),
edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 20))],
)
gui.Selection = types.SimpleNamespace(
getSelection=lambda: [],
getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)],
)
result = auto_routing_panel.AutoRoutingController().create_user_paths_from_selection()
carriers = routing_network.collect_route_carriers(doc)
self.assertEqual(1, result["user_path_carriers"])
self.assertEqual(1, result["network"]["kinds"]["UserPath"])
self.assertEqual(1, len(carriers))
self.assertEqual("UserPath", carriers[0].QetRouteCarrierKind)
def test_controller_create_user_paths_reports_removed_stale_source_carriers(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")
route_path = doc.addObject("Part::Feature", "UserRouteSketch")
route_path.Shape = FakeShape(
FakeBoundBox(0, 100, 0, 80, 20, 20),
edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 20))],
)
gui.Selection = types.SimpleNamespace(
getSelection=lambda: [],
getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)],
)
auto_routing_panel.AutoRoutingController().create_user_paths_from_selection()
doc.removeObject("UserRouteSketch")
gui.Selection = types.SimpleNamespace(
getSelection=lambda: [],
getSelectionEx=lambda: [],
)
result = auto_routing_panel.AutoRoutingController().create_user_paths_from_selection()
self.assertEqual(1, result["removed_stale_carriers"])
self.assertEqual(0, result["network"]["carriers"])
self.assertEqual([], routing_network.collect_route_carriers(doc))
def test_terminal_access_uses_terminal_local_route_points_before_main_network(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
terminal = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0))
terminal.addProperty("App::PropertyString", "QetTerminalLocalRoutePointsJson", "QET Routing", "")
terminal.QetTerminalLocalRoutePointsJson = json.dumps([[0, 0, 0], [10, 0, 0], [10, 30, 0]])
routing_network.create_route_carrier(
doc,
[app.Vector(10, 80, 0), app.Vector(110, 80, 0)],
project_uuid="project-1",
kind="UserPath",
)
created = routing_network.create_terminal_access_carriers_from_document(
doc,
project_uuid="project-1",
terminal_exit_length=20.0,
max_distance=100.0,
)
self.assertEqual(1, len(created))
self.assertEqual(
[(0.0, 0.0, 0.0), (10.0, 0.0, 0.0), (10.0, 30.0, 0.0)],
[(p.x, p.y, p.z) for p in created[0].Points[:3]],
)
def test_generate_routing_paths_refreshes_selected_user_path_without_duplicate(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")
route_path = doc.addObject("Part::Feature", "UserRouteSketch")
route_path.Shape = FakeShape(
FakeBoundBox(0, 100, 0, 80, 20, 20),
edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 20))],
)
gui.Selection = types.SimpleNamespace(
getSelection=lambda: [],
getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)],
)
first = auto_routing_panel.AutoRoutingController().generate_routing_paths()
route_path.Shape = FakeShape(
FakeBoundBox(0, 200, 0, 80, 20, 20),
edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(200, 0, 20))],
)
second = auto_routing_panel.AutoRoutingController().generate_routing_paths()
user_paths = [
item
for item in routing_network.collect_route_carriers(doc)
if item.QetRouteCarrierKind == "UserPath"
]
self.assertEqual(1, first["user_path_carriers"])
self.assertEqual(0, second["user_path_carriers"])
self.assertEqual(1, len(user_paths))
self.assertEqual([(0.0, 0.0, 20.0), (200.0, 0.0, 20.0)], [(p.x, p.y, p.z) for p in user_paths[0].Points])
def test_eplan_connection_route_can_use_generated_user_path(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0))
end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(200, 0, 0))
routing_network.create_route_carrier(
doc,
[app.Vector(0, 0, 20), app.Vector(200, 0, 20)],
project_uuid="project-1",
kind="UserPath",
)
result = auto_routing.route_eplan_connection_between_terminals(doc, start, end)
self.assertEqual("Routed", result["route_status"])
self.assertIn("UserPath", result["route_track"]["carrier_kinds"])
def test_generate_routing_paths_does_not_duplicate_selected_wire_duct_carriers(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules()
@ -1814,6 +2041,39 @@ class AutoRoutingTest(unittest.TestCase):
self.assertEqual("z", report["routes"][1]["lane"]["axis"])
self.assertEqual(8.0, report["routes"][1]["lane"]["offset_mm"])
def test_auto_routing_panel_command_button_style_keeps_text_visible(self):
_install_fake_freecad()
_terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules()
auto_routing_panel = importlib.import_module("AutoRoutingPanel")
class FakeButton:
def __init__(self):
self.text = ""
self.tooltip = ""
self.minimum_height = 0
self.stylesheet = ""
def setText(self, text):
self.text = text
def setToolTip(self, tooltip):
self.tooltip = tooltip
def setMinimumHeight(self, height):
self.minimum_height = height
def setStyleSheet(self, stylesheet):
self.stylesheet = stylesheet
button = FakeButton()
auto_routing_panel._style_command_button(button, "生成布线连接", "按导线任务布线")
self.assertEqual("生成布线连接", button.text)
self.assertEqual("按导线任务布线", button.tooltip)
self.assertGreaterEqual(button.minimum_height, 28)
self.assertIn("color", button.stylesheet)
def test_eplan_connection_route_rejects_far_network_entry_to_avoid_huge_render_bbox(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules()
@ -2208,6 +2468,151 @@ class AutoRoutingTest(unittest.TestCase):
self.assertTrue(report["missing_endpoint_samples"][0]["start_found"])
self.assertFalse(report["missing_endpoint_samples"][0]["end_found"])
def test_route_eplan_connections_from_payload_skips_resolved_tasks_without_route_network(self):
_install_fake_freecad()
terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
_terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0))
_terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0))
payload = {
"project_uuid": "project-1",
"wires": [
{
"wire_id": "wire-{0}".format(index),
"start_terminal_uuid": "terminal-start",
"end_terminal_uuid": "terminal-end",
}
for index in range(3)
],
}
original_route = auto_routing.route_eplan_connection_between_terminals
def fail_if_called(*_args, **_kwargs):
raise AssertionError("batch route must not call per-wire routing without route carriers")
auto_routing.route_eplan_connection_between_terminals = fail_if_called
try:
report = auto_routing.route_eplan_connections_from_payload(doc, payload)
finally:
auto_routing.route_eplan_connection_between_terminals = original_route
self.assertEqual(0, report["routed"])
self.assertEqual(3, report["skipped_missing_route_network"])
self.assertEqual(3, report["route_status_counts"]["MissingRouteNetwork"])
self.assertEqual([], report["errors"])
self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc))
def test_route_eplan_connection_tasks_marks_task_missing_route_network_when_skipped(self):
_install_fake_freecad()
terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
_terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0))
_terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0))
task = wiring_objects.create_wire_task(
doc,
"project-1",
"wire-missing-network",
"N1",
"terminal-start",
"terminal-end",
"instance-a",
"instance-b",
)
task.RouteStatus = "Routed"
report = auto_routing.route_eplan_connection_tasks(doc)
self.assertEqual(0, report["routed"])
self.assertEqual(1, report["skipped_missing_route_network"])
self.assertEqual("MissingRouteNetwork", task.RouteStatus)
def test_eplan_connection_route_prefers_wire_duct_over_shorter_routing_range(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0))
end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(300, 0, 0))
routing_network.create_route_carrier(
doc,
[app.Vector(0, 0, 20), app.Vector(300, 0, 20)],
project_uuid="project-1",
kind="RoutingRange",
)
routing_network.create_route_carrier(
doc,
[
app.Vector(0, 0, 20),
app.Vector(0, 1200, 20),
app.Vector(300, 1200, 20),
app.Vector(300, 0, 20),
],
project_uuid="project-1",
kind="WireDuct",
)
result = auto_routing.route_eplan_connection_between_terminals(doc, start, end)
self.assertIn("WireDuct", result["route_track"]["carrier_kinds"])
self.assertNotIn("RoutingRange", result["route_track"]["carrier_kinds"])
def test_route_eplan_connections_from_payload_skips_tasks_when_carriers_have_no_segments(self):
_install_fake_freecad()
terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
_terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0))
_terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0))
broken_carrier = doc.addObject("Part::Feature", "BrokenCarrier")
terminal_objects.ensure_string_property(
broken_carrier,
"QetRoutingRole",
"QET Routing",
"Routing role marker",
"RoutingCarrier",
)
terminal_objects.ensure_string_property(
broken_carrier,
"QetRouteCarrierKind",
"QET Routing",
"Route carrier kind",
"WireDuct",
)
terminal_objects.ensure_bool_property(
broken_carrier,
"CanRouteWire",
"QET Routing",
"Whether routing connections can use this path",
True,
)
payload = {
"project_uuid": "project-1",
"wires": [
{
"wire_id": "wire-a",
"start_terminal_uuid": "terminal-start",
"end_terminal_uuid": "terminal-end",
},
],
}
report = auto_routing.route_eplan_connections_from_payload(doc, payload)
self.assertEqual(1, report["route_network_carriers"])
self.assertEqual(0, report["route_network_segments"])
self.assertEqual(0, report["route_network_nodes"])
self.assertEqual(0, report["routed"])
self.assertEqual(1, report["skipped_missing_route_network"])
self.assertEqual(1, report["route_status_counts"]["MissingRouteNetwork"])
self.assertEqual([], report["errors"])
self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc))
def test_route_eplan_connections_writes_diagnostic_object_for_missing_terminal(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules()
@ -2236,6 +2641,57 @@ class AutoRoutingTest(unittest.TestCase):
self.assertEqual("RoutingConnectionBatch", diagnostic.QetDiagnosticKind)
self.assertIn("terminal-missing", diagnostic.QetDiagnosticJson)
def test_route_eplan_connections_writes_compact_batch_diagnostic(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
_terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0))
_terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0))
_terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 20, 0))
_terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 20, 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, 20, 20), app.Vector(100, 20, 20)],
project_uuid="project-1",
kind="WireDuct",
)
payload = {
"project_uuid": "project-1",
"wires": [
{
"wire_id": "wire-a",
"wire_label": "N1",
"start_terminal_uuid": "terminal-start-a",
"end_terminal_uuid": "terminal-end-a",
},
{
"wire_id": "wire-b",
"wire_label": "N2",
"start_terminal_uuid": "terminal-start-b",
"end_terminal_uuid": "terminal-end-b",
},
],
}
report = auto_routing.route_eplan_connections_from_payload(doc, payload)
diagnostic_group = doc.getObject("QETWiring_05_Diagnostics")
diagnostic_payload = json.loads(diagnostic_group.Group[0].QetDiagnosticJson)
self.assertEqual(2, len(report["routes"]))
self.assertNotIn("routes", diagnostic_payload)
self.assertEqual(2, diagnostic_payload["route_sample_count"])
self.assertEqual(2, len(diagnostic_payload["route_samples"]))
self.assertEqual("wire-a", diagnostic_payload["route_samples"][0]["wire_uuid"])
self.assertEqual("Routed", diagnostic_payload["route_samples"][0]["route_status"])
def test_route_eplan_connections_reports_total_connection_route_length(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules()
@ -2268,6 +2724,164 @@ class AutoRoutingTest(unittest.TestCase):
self.assertEqual(report["total_length_mm"], report["routes"][0]["length_mm"])
self.assertIn("总长度", message)
def test_route_eplan_connections_hides_route_carriers_after_routing_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")
_terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0))
_terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0))
carrier = routing_network.create_route_carrier(
doc,
[app.Vector(0, 0, 20), app.Vector(100, 0, 20)],
project_uuid="project-1",
kind="WireDuct",
)
payload = {
"project_uuid": "project-1",
"wires": [
{
"wire_id": "wire-1",
"start_terminal_uuid": "terminal-start",
"end_terminal_uuid": "terminal-end",
}
],
}
report = auto_routing.route_eplan_connections(
doc,
payload=payload,
update_network=False,
)
self.assertEqual(1, report["routed"])
self.assertEqual(1, report["hidden_route_carriers"])
self.assertFalse(carrier.ViewObject.Visibility)
def test_route_eplan_connections_batch_recomputes_once_after_created_wires(self):
_install_fake_freecad()
terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
_terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0))
_terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0))
_terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 10, 0))
_terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 10, 0))
routing_network.create_route_carrier(
doc,
[app.Vector(0, 0, 20), app.Vector(100, 0, 20)],
project_uuid="project-1",
kind="WireDuct",
)
routing_network.create_route_carrier(
doc,
[app.Vector(0, 10, 20), app.Vector(100, 10, 20)],
project_uuid="project-1",
kind="WireDuct",
)
recompute_count = {"value": 0}
def count_recompute():
recompute_count["value"] += 1
doc.recompute = count_recompute
payload = {
"project_uuid": "project-1",
"wires": [
{
"wire_id": "wire-a",
"start_terminal_uuid": "terminal-start-a",
"end_terminal_uuid": "terminal-end-a",
},
{
"wire_id": "wire-b",
"start_terminal_uuid": "terminal-start-b",
"end_terminal_uuid": "terminal-end-b",
},
],
}
report = auto_routing.route_eplan_connections_from_payload(doc, payload)
self.assertEqual(2, report["routed"])
self.assertEqual(1, recompute_count["value"])
def test_route_eplan_connections_replaces_existing_routed_wires_for_same_batch(self):
_install_fake_freecad()
terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
_terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0))
_terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0))
routing_network.create_route_carrier(
doc,
[app.Vector(0, 0, 20), app.Vector(100, 0, 20)],
project_uuid="project-1",
kind="WireDuct",
)
payload = {
"project_uuid": "project-1",
"wires": [
{
"wire_id": "wire-repeat",
"start_terminal_uuid": "terminal-start",
"end_terminal_uuid": "terminal-end",
},
],
}
first = auto_routing.route_eplan_connections_from_payload(doc, payload)
second = auto_routing.route_eplan_connections_from_payload(doc, payload)
routed_wires = list(wiring_objects.iter_routed_wire_objects(doc))
self.assertEqual(1, first["routed"])
self.assertEqual(1, second["routed"])
self.assertEqual(1, second["replaced_routed_connections"])
self.assertEqual(1, len(routed_wires))
self.assertEqual("wire-repeat", routed_wires[0].QetWireUuid)
def test_clear_routing_connections_resets_task_status_and_batch_diagnostics(self):
_install_fake_freecad()
terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal_objects.ensure_root_group(doc, "project-1")
_terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0))
_terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0))
routing_network.create_route_carrier(
doc,
[app.Vector(0, 0, 20), app.Vector(100, 0, 20)],
project_uuid="project-1",
kind="WireDuct",
)
task = wiring_objects.create_wire_task(
doc,
"project-1",
"wire-clear",
"N1",
"terminal-start",
"terminal-end",
"instance-a",
"instance-b",
)
report = auto_routing.route_eplan_connection_tasks(doc)
diagnostic_group = doc.getObject("QETWiring_05_Diagnostics")
self.assertEqual(1, report["routed"])
self.assertEqual("Routed", task.RouteStatus)
self.assertEqual(1, len(list(getattr(diagnostic_group, "Group", []) or [])))
removed = auto_routing.clear_routing_connections(doc)
self.assertEqual(1, removed)
self.assertEqual("Task", task.RouteStatus)
self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc))
self.assertEqual([], list(getattr(diagnostic_group, "Group", []) or []))
def test_route_report_includes_route_source_sample_when_available(self):
_install_fake_freecad()
_terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules()
@ -2332,6 +2946,69 @@ class AutoRoutingTest(unittest.TestCase):
self.assertIn("并行错位:最大 lane 2间距 10.0 mm。", message)
def test_route_report_includes_replaced_routed_connection_count(self):
_install_fake_freecad()
_terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules()
report = {
"routed": 1,
"collision_warnings": 0,
"skipped_missing_terminal": 0,
"replaced_routed_connections": 2,
}
message = auto_routing.format_eplan_connection_route_report(report)
self.assertIn("已替换旧布线连接2 条。", message)
def test_route_report_includes_hidden_route_carrier_count(self):
_install_fake_freecad()
_terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules()
report = {
"routed": 1,
"collision_warnings": 0,
"skipped_missing_terminal": 0,
"hidden_route_carriers": 3,
}
message = auto_routing.format_eplan_connection_route_report(report)
self.assertIn("已隐藏走线路径辅助对象3 条。", message)
def test_route_report_warns_when_routes_use_surface_or_auxiliary_paths(self):
_install_fake_freecad()
_terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules()
report = {
"routed": 2,
"collision_warnings": 0,
"skipped_missing_terminal": 0,
"routes": [
{
"wire_label": "N1",
"route_track": {
"carrier_kinds": {
"TerminalAccess": 2,
"WireDuct": 1,
"RoutingRange": 2,
},
},
},
{
"wire_label": "N2",
"route_track": {
"carrier_kinds": {
"TerminalAccess": 2,
"WireDuct": 3,
},
},
},
],
}
message = auto_routing.format_eplan_connection_route_report(report)
self.assertIn("路径质量提示1 条导线使用布线面/辅助路径", message)
self.assertIn("示例 N1 使用布线面。", message)
def test_route_report_warns_when_parallel_lanes_exceed_track_capacity(self):
_install_fake_freecad()
_terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules()
@ -2737,6 +3414,7 @@ class AutoRoutingTest(unittest.TestCase):
self.assertEqual(0, report["routes"][0]["lane"]["index"])
self.assertEqual(1, report["routes"][1]["lane"]["index"])
routed_group = doc.getObject("QETWiring_04_Routed")
self.assertEqual(2, len(list(getattr(routed_group, "Group", []) or [])))
second_wire = [
wire
for wire in list(getattr(routed_group, "Group", []) or [])
@ -3101,6 +3779,25 @@ class AutoRoutingTest(unittest.TestCase):
self.assertIn("首个错误:没有可用的线槽/路由路径网络", message)
self.assertIn("缺失示例terminal-a -> terminal-b", message)
def test_route_eplan_connections_report_calls_out_missing_route_network(self):
_install_fake_freecad()
_terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules()
report = {
"total_wires": 3,
"routed": 0,
"collision_warnings": 0,
"skipped_missing_terminal": 0,
"skipped_missing_route_network": 3,
"route_status_counts": {
"MissingRouteNetwork": 3,
},
}
message = auto_routing.format_eplan_connection_route_report(report)
self.assertIn("缺少布线路径网络 3 条", message)
self.assertIn("请先生成线槽、布线面或布线路径网络", message)
def test_route_eplan_connections_report_includes_readable_missing_endpoint_labels(self):
_install_fake_freecad()
_terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules()
@ -3249,6 +3946,8 @@ class AutoRoutingTest(unittest.TestCase):
self.assertEqual(0, report["local_terminals"])
self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc))
self.assertEqual("qet", indexed["qet-terminal-p1"].QetTerminalBindingMode)
self.assertFalse(indexed["qet-terminal-p1"].ViewObject.Visibility)
self.assertFalse(indexed["qet-terminal-p2"].ViewObject.Visibility)
def test_route_eplan_connections_rebinds_local_template_terminals_from_wire_endpoints(self):
_install_fake_freecad()
@ -3324,6 +4023,8 @@ class AutoRoutingTest(unittest.TestCase):
self.assertIn("qet-terminal-p1", indexed)
self.assertIn("qet-terminal-p2", indexed)
self.assertEqual("qet", indexed["qet-terminal-p1"].QetTerminalBindingMode)
self.assertFalse(indexed["qet-terminal-p1"].ViewObject.Visibility)
self.assertFalse(indexed["qet-terminal-p2"].ViewObject.Visibility)
def test_clear_route_carriers_keeps_routed_wires(self):
_install_fake_freecad()

@ -476,6 +476,8 @@ class FcstdDeviceImportTest(unittest.TestCase):
terminal.Role = "Terminal"
terminal.addProperty("App::PropertyString", "QetTemplateSlotName", "QET Template", "")
terminal.QetTemplateSlotName = "D1"
terminal.addProperty("App::PropertyString", "QetTerminalLocalRoutePointsJson", "QET Template", "")
terminal.QetTerminalLocalRoutePointsJson = "[[0,0,0],[6,0,0],[6,18,0]]"
x_axis = source.addObject("App::Line", "Terminal_D1_XAxis")
terminal.OriginFeatures = [x_axis]
@ -504,6 +506,10 @@ class FcstdDeviceImportTest(unittest.TestCase):
self.assertEqual("D1", slots[0]["name"])
self.assertEqual(11.0, slots[0]["base"].x)
self.assertEqual(90.0, slots[0]["rotation"]["angle"])
self.assertEqual(
[[0.0, 0.0, 0.0], [6.0, 0.0, 0.0], [6.0, 18.0, 0.0]],
slots[0]["local_route_points"],
)
self.assertIsNone(slots[0]["source_object"])
def test_fcstd_import_keeps_link_dependencies_out_of_device_group(self):

@ -1,4 +1,5 @@
import importlib
import json
import sys
import types
import unittest
@ -88,6 +89,30 @@ class FakeObject:
self.Group.append(child)
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 FakeShape:
def __init__(self, edges=None):
self.Edges = edges or []
class FakeSelectionItem:
def __init__(self, obj=None, sub_objects=None, picked_points=None):
self.Object = obj
self.SubObjects = sub_objects or []
self.PickedPoints = picked_points or []
class FakeDocument:
def __init__(self):
self.Name = "TemplateDoc"
@ -132,6 +157,10 @@ class TemplateAuthoringTest(unittest.TestCase):
"校验模板端子",
template_authoring.CommandValidateTemplateTerminals().GetResources()["MenuText"],
)
self.assertEqual(
"设置端子局部路径",
template_authoring.CommandSetTemplateTerminalLocalRoute().GetResources()["MenuText"],
)
self.assertEqual(
"保存模板为 FCStd",
template_authoring.CommandSaveTemplateAsFCStd().GetResources()["MenuText"],
@ -169,6 +198,54 @@ class TemplateAuthoringTest(unittest.TestCase):
self.assertEqual(30.0, terminal.Placement.Base.z)
self.assertTrue(doc.recomputed)
def test_set_template_terminal_local_route_points_writes_json_property(self):
_install_fake_freecad()
template_authoring = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal = template_authoring.create_template_terminal(doc, "P1", app.Vector(1, 2, 3))
template_authoring.set_template_terminal_local_route_points(
terminal,
[
app.Vector(0, 0, 0),
[8, 0, 0],
{"x": 8, "y": 20, "z": 0},
],
)
self.assertIn("QetTerminalLocalRoutePointsJson", terminal.PropertiesList)
self.assertEqual(
[[0.0, 0.0, 0.0], [8.0, 0.0, 0.0], [8.0, 20.0, 0.0]],
json.loads(terminal.QetTerminalLocalRoutePointsJson),
)
def test_set_selected_template_terminal_local_route_points_uses_terminal_local_coordinates(self):
_install_fake_freecad()
template_authoring = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
terminal = template_authoring.create_template_terminal(doc, "P1", app.Vector(10, 0, 0))
path = doc.addObject("Part::Feature", "LocalExitPath")
path.Shape = FakeShape(
edges=[
FakeEdge(app.Vector(10, 0, 0), app.Vector(20, 0, 0)),
FakeEdge(app.Vector(20, 0, 0), app.Vector(20, 15, 0)),
]
)
template_authoring.set_selected_template_terminal_local_route_points(
[
FakeSelectionItem(obj=terminal),
FakeSelectionItem(obj=path),
]
)
self.assertEqual(
[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 15.0, 0.0]],
json.loads(terminal.QetTerminalLocalRoutePointsJson),
)
def test_validate_template_terminals_reports_missing_slot_name(self):
_install_fake_freecad()
template_authoring = _reload_modules()

@ -153,6 +153,40 @@ class TemplateSemanticsRotationTest(unittest.TestCase):
self.assertEqual(0.0, slots[0]["rotation"]["axis"].y)
self.assertEqual(1.0, slots[0]["rotation"]["axis"].z)
def test_sidecar_keeps_terminal_local_route_points(self):
_install_fake_freecad()
template_semantics, _ = _reload_exchange_modules()
with tempfile.TemporaryDirectory() as temp_dir:
model_path = Path(temp_dir) / "Relay.step"
model_path.write_text("", encoding="utf-8")
sidecar_path = Path(temp_dir) / "Relay.qet_template.json"
sidecar_path.write_text(
json.dumps(
{
"terminal_slots": [
{
"name": "A1",
"position": [10, 20, 30],
"local_route_points": [
[0, 0, 0],
{"x": 12, "y": 0, "z": 0},
[12, 25, 0],
],
}
]
}
),
encoding="utf-8",
)
slots = template_semantics.load_sidecar_terminal_slots(str(model_path))
self.assertEqual(
[[0.0, 0.0, 0.0], [12.0, 0.0, 0.0], [12.0, 25.0, 0.0]],
slots[0]["local_route_points"],
)
class TerminalSlotResolutionPolicyTest(unittest.TestCase):
def test_resolve_terminal_slots_returns_bbox_fallback_when_model_has_no_template_slots(self):

@ -1,4 +1,5 @@
import importlib
import json
import sys
import types
import unittest
@ -191,6 +192,7 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
self.assertEqual(1, len(terminals))
self.assertEqual("terminal-a", terminals[0].QetTerminalUuid)
self.assertEqual("generated_bbox_fallback", terminals[0].QetTerminalGeometrySource)
self.assertFalse(terminals[0].ViewObject.Visibility)
def test_import_preserves_local_terminals_when_payload_has_no_entry_for_device(self):
_install_fake_freecad()
@ -524,6 +526,91 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
self.assertEqual(2, len(terminals))
self.assertEqual(20.0, terminals["terminal-p2"].Placement.Base.x)
self.assertEqual(10.0, terminals["terminal-p1"].Placement.Base.x)
self.assertFalse(terminals["terminal-p2"].ViewObject.Visibility)
self.assertFalse(terminals["terminal-p1"].ViewObject.Visibility)
def test_import_copies_template_slot_local_route_points_to_terminal(self):
_install_fake_freecad()
terminal_import, terminal_objects, device_import = _reload_modules()
doc = FakeDocument()
device_import._ensure_document = lambda scene_path: doc
root = device_import._ensure_root_group(doc, project_uuid="project-1")
device = doc.addObject("App::Part", "QETDevice_device_a")
root.addObject(device)
terminal_objects.ensure_string_property(
device,
"QetProjectUuid",
"QET Exchange",
"Project UUID",
"project-1",
)
terminal_objects.ensure_string_property(
device,
"QetElementUuid",
"QET Exchange",
"Element UUID",
"device-a",
)
terminal_objects.ensure_string_property(
device,
"QetInstanceId",
"QET Exchange",
"Instance ID",
"instance-a",
)
terminal_objects.ensure_string_property(
device,
"QetTemplateSlotsJson",
"QET Exchange",
"Stored template slots",
json.dumps(
{
"terminal_slots": [
{
"name": "P1",
"label": "P1",
"base": [10, 0, 0],
"local_route_points": [[0, 0, 0], [8, 0, 0], [8, 20, 0]],
}
]
}
),
)
report = terminal_import.import_terminals_from_payload(
{
"project_uuid": "project-1",
"devices": [
{
"element_uuid": "device-a",
"instance_id": "instance-a",
}
],
"terminals": [
{
"terminal_uuid": "terminal-p1",
"element_uuid": "device-a",
"instance_id": "instance-a",
"terminal_display": "P1",
},
],
}
)
terminal_group = terminal_objects.find_child_group_by_kind(
device,
terminal_objects.TERMINAL_GROUP_KIND,
)
terminals = terminal_objects.collect_terminal_objects(terminal_group)
self.assertEqual(1, report["imported_terminals"])
self.assertEqual(1, len(terminals))
self.assertIn("QetTerminalLocalRoutePointsJson", terminals[0].PropertiesList)
self.assertEqual(
[[0.0, 0.0, 0.0], [8.0, 0.0, 0.0], [8.0, 20.0, 0.0]],
json.loads(terminals[0].QetTerminalLocalRoutePointsJson),
)
def test_import_rebinds_existing_local_terminal_on_matching_template_slot(self):
_install_fake_freecad()
@ -619,6 +706,7 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase):
self.assertEqual("terminal-real-p1", local_terminal.QetTerminalUuid)
self.assertEqual("instance-a", local_terminal.QetInstanceId)
self.assertEqual(15.0, local_terminal.Placement.Base.x)
self.assertFalse(local_terminal.ViewObject.Visibility)
if __name__ == "__main__":

Loading…
Cancel
Save