diff --git a/docs/FreeCAD 3D自动布线设计方案.md b/docs/FreeCAD 3D自动布线设计方案.md index 64fd820..d7240e9 100644 --- a/docs/FreeCAD 3D自动布线设计方案.md +++ b/docs/FreeCAD 3D自动布线设计方案.md @@ -6,6 +6,7 @@ ```text 工程端子 +-> 检查/绑定 QET terminal_uuid -> 路由路径网络 -> 自动求路 -> 生成 3D 折线导线 @@ -19,11 +20,12 @@ 当前版本只要求完成“能够自动布线”: 1. 能识别 FreeCAD 文档中的工程端子。 -2. 能从用户指定的线槽、草图、面域创建路由网络。 -3. 能在两个端子之间自动生成 3D 折线导线。 -4. 能批量处理 QET 导入的导线任务。 -5. 能绕开或至少发现明显障碍碰撞。 -6. 默认禁止长距离悬空线。 +2. 能在一键布线前检查并绑定工程端子,把可匹配的 `local:*` 模板端子提升为真实 QET `terminal_uuid`。 +3. 能从用户指定或识别到的线槽实体、机柜/安装板面域创建路由网络。 +4. 能在两个端子之间自动生成 3D 折线导线。 +5. 能批量处理 QET 导入的导线任务。 +6. 能绕开或至少发现明显障碍碰撞。 +7. 默认禁止长距离悬空线。 ### 1.2 当前版本不做 @@ -81,14 +83,17 @@ terminal_uuid 路由网络由 `carrier` 组成。一个 carrier 表示一段可走线中心线或辅助路由区域中的一条网格线。 +构图时不要求所有 carrier 都提前手工打断。系统会识别轴向线段之间的几何相交和同线重叠,把交点/重叠端点自动切成图节点。这样多条线槽中心路径只要在空间中相交,就可以在交点处换向,Dijkstra 才能得到符合工程布线习惯的折线路径。 + ### 2.1 路由优先级 当前版本按下面优先级处理: 1. `WireDuct`:线槽中心路径,最高优先级。 -2. `RoutingPath`:用户画的草图线、Draft Wire、明确选中的路由边。 -3. `AuxiliaryPath`:辅助路径,后续扩展使用。 -4. `RoutingRange`:选中面生成的辅助路由区域,成本较高,只用于过渡或没有线槽时兜底。 +2. `RoutingPath`:历史兼容和内部调试用的明确路由线,不作为当前正式入口。 +3. `TerminalAccess`:端子到路由网络的自动接入路径,只用于把工程端子接入线槽/布线面。 +4. `AuxiliaryPath`:辅助路径,后续扩展使用。 +5. `RoutingRange`:柜面/安装板等支撑面生成的辅助路由区域,成本较高,只用于过渡或没有线槽时兜底。 普通机柜、设备外壳、实体边默认不是路由路径。 @@ -108,6 +113,22 @@ QetProjectUuid = 端子空间位置来自 FreeCAD 文档。自动布线使用端子的全局坐标和方向。 +模板实例生成的端子可能先处于本地状态: + +```text +QetTerminalUuid = local:: +QetTerminalBindingMode = local +``` + +这类端子只有空间槽位,没有 2D 电气语义,不能直接用于批量导线任务。正式布线前需要执行“检查/绑定工程端子”。系统会根据导线任务中的 `start/end_terminal_uuid`、`start/end_instance_id`、`start/end_element_uuid` 和端子显示号,在对应 3D 设备下查找 local 端子或模板槽位;匹配成功后写入: + +```text +QetTerminalUuid = +QetTerminalBindingMode = qet +``` + +如果导线任务缺少实例定位信息,或 QET 端子显示号与模板槽位名称不一致,绑定会跳过并给出诊断。 + ### 3.2 路由路径 Carrier 路由路径对象使用以下语义属性: @@ -238,25 +259,28 @@ network-dijkstra-v1 allow_floating_fallback = false ``` -也就是说,如果没有可用路由网络,系统不会生成长距离悬空线,而是提示用户先创建线槽路径、草图路径或辅助路由区域。 +也就是说,如果没有可用路由网络,系统不会生成长距离悬空线,而是提示用户先创建线槽中心路径或辅助路由区域。 调试时可以显式启用 fallback,但正式使用不建议开启。 ### 4.5 障碍物处理 -当前版本使用 AABB 包围盒做碰撞诊断: +当前版本使用 AABB 包围盒做主动避障和碰撞诊断: 1. 收集 FreeCAD 文档中的几何对象。 2. 排除端子、carrier、已布线导线、原点辅助对象。 3. 排除标记为 `QetRoutingObstacleMode = PassThrough` 的线槽实体。 -4. 检查导线线段是否与障碍包围盒相交。 -5. 如果有碰撞,导线状态设为: +4. 在构建路由图时,先移除穿过障碍包围盒的 carrier 边。 +5. 如果还有可连通路径,Dijkstra 会选择绕开障碍的路径。 +6. 如果所有可用路径都被障碍挡住,系统退回未过滤路由图并保留碰撞诊断。 +7. 检查最终导线线段是否与障碍包围盒相交。 +8. 如果仍有碰撞,导线状态设为: ```text RouteStatus = "CollisionWarning" ``` -当前版本是“碰撞诊断”,不是完整自由空间避障。 +当前版本是“路由网络上的主动避障”,不是完整自由空间避障。也就是说,它会在已有线槽/路由路径网络中绕开机柜和障碍物;如果模型中没有可绕行的 carrier,它不会凭空生成新的自由空间通道。 ## 5. 当前已完成 @@ -281,24 +305,36 @@ src/Mod/FreeCADExchange/InitGui.py 已完成: -1. 从选中草图线、Draft Wire、明确选中的边创建 `RoutingPath`。 -2. 从选中线槽实体生成 `WireDuct` 中心路径。 -3. 从选中面生成 `RoutingRange` 辅助路由区域。 -4. 清除已生成路由路径。 -5. 扫描并统计路由网络。 +1. 自动识别文档中的线槽模型并生成 `WireDuct` 中心路径。 +2. 从选中线槽实体生成 `WireDuct` 中心路径,作为自动识别失败时的补充手段。 +3. 自动把相交或重叠的 carrier 切成可连接节点,支持线槽交叉处自动拐弯。 +4. 自动识别安装板、背板、门板、panel、mounting plate 等薄板对象并生成低优先级 `RoutingRange`。 +5. 自动生成 `TerminalAccess`,把工程端子接入最近的路由网络节点。 +6. 清除已生成路由路径。 +7. 扫描并统计路由网络。 +8. 保留 `RoutingPath` 底层兼容能力,但当前面板不再暴露草图/线段补路入口。 + +自动识别线槽时,系统会同时检查对象语义/名称和几何比例。对象名或标签需要包含 `线槽`、`走线槽`、`wire duct`、`cable duct`、`trunking` 等线槽语义,并且包围盒需要呈明显细长比例。`机柜`、`柜体`、`door`、`panel`、`安装板` 等对象不会被当作线槽生成路径,仍按普通模型参与障碍检测。 + +自动识别柜面/安装板时,系统只接受有支撑面语义且包围盒呈薄板形态的对象。识别出的对象标记为 `QetRoutingObstacleMode = "SupportSurface"`,用于避免沿支撑面走线时误报碰撞;普通机柜整体和设备外壳仍默认作为障碍物。 + +生成布线布局空间时,系统按整份 FreeCAD 文档处理,不再要求用户选中某个面。它会先准备线槽/支撑面 carrier,再按端子 LCS 的全局坐标和出线方向生成短的 `TerminalAccess` 接入 carrier。这样正式自动布线更接近 EPLAN / SOLIDWORKS Electrical 的“一键准备布局空间并布线”逻辑。 ### 5.3 自动布线功能 已完成: -1. 两个选中端子之间自动布线。 -2. 根据导线任务批量自动布线。 -3. 使用 Dijkstra 求路。 -4. 支持转弯惩罚。 -5. 支持 carrier 类型成本。 -6. 默认禁止长距离悬空线。 -7. 碰撞检测和 `CollisionWarning` 状态。 -8. 自动导线可见显示并保存到 FreeCAD 文档。 +1. 检查/绑定工程端子,不生成导线。 +2. 两个选中端子之间自动布线。 +3. 根据导线任务批量自动布线。 +4. 使用 Dijkstra 求路。 +5. 支持在 carrier 交点、重叠段端点处自动换向。 +6. 支持转弯惩罚。 +7. 支持 carrier 类型成本。 +8. 默认禁止长距离悬空线。 +9. 支持基于障碍 AABB 的路由图边级主动避障。 +10. 无安全替代路径时保留碰撞检测和 `CollisionWarning` 状态。 +11. 自动导线可见显示并保存到 FreeCAD 文档。 ### 5.4 FreeCAD 面板 @@ -308,15 +344,12 @@ src/Mod/FreeCADExchange/InitGui.py QET模板 -> 3D自动布线 ``` -当前按钮: +当前面板按 EPLAN / SOLIDWORKS Electrical 的使用习惯收敛为三步正式流程。测试性入口不再显示在面板上;`生成布线布局空间` 和 `自动布线` 都按整份 3D 装配执行,不再沿用“选中面/草图辅助路径”的测试流程。 ```text -扫描端子/网络 -从线槽实体生成中心路径 -从线槽/草图创建路由路径 -从选中面创建辅助路由区域 -测试布线选中两个端子 -按导线任务自动布线全部 +生成布线网络路径 +生成布线布局空间 +自动布线 清除自动布线 清除走线路径 保存 @@ -336,13 +369,22 @@ tests/python/freecad_exchange_auto_routing_test.py 2. 显式调试 fallback 时生成正交路径。 3. 优先使用用户路由网络。 4. 优先使用 `WireDuct`,降低 `RoutingRange` 优先级。 -5. 从选中面生成辅助路由区域。 +5. 自动生成端子接入路由网络的 `TerminalAccess`。 6. 选中整个实体不会把所有外壳边转成路径。 7. 从线槽实体生成中心路径。 8. 线槽实体不作为自身路径碰撞障碍。 9. 碰撞状态标记。 10. 批量导线任务缺端子时跳过。 11. 清除路由路径不删除已布线导线。 +12. 只执行工程端子绑定时不会生成导线。 +13. 一键布线前自动把匹配到的 `local:*` 端子提升为 QET 工程端子。 +14. 自动识别线槽模型生成中心路径,并避免把机柜模型误判为线槽。 +15. 自动识别安装板/柜面生成 `RoutingRange`,并把支撑面标记为 `SupportSurface`。 +16. 无线槽或线槽不完整时,可使用自动识别的支撑面辅助路径完成贴面布线。 +17. 面板流程已简化为“生成布线网络路径 -> 生成布线布局空间 -> 自动布线”。 +18. “生成布线网络路径”在有选择时从选中的线槽实体生成中心路径;没有选择时自动识别整份文档。 +19. “生成布线布局空间”始终按整份文档准备布局空间:自动识别线槽/支撑面,并生成端子接入 carrier。 +20. “自动布线”会先执行同一套布局空间准备逻辑,再按全部 QET 导线任务批量求路。 已完成 FreeCAD smoke: @@ -352,40 +394,47 @@ tests/manual/freecad_auto_routing_smoke.py ## 6. 当前使用流程 -### 6.1 单条导线自动布线 +### 6.1 简化正式流程 + +当前版本不再生成内置演示场景,也不把单端子测试作为正式入口。正式流程面向真实 FreeCAD 文档: ```text 1. 打开 FreeCAD 工程 scene.FCStd 2. 进入 QET模板 -> 3D自动布线 -3. 清除走线路径 -4. 选中线槽实体 -5. 点击“从线槽实体生成中心路径” -6. 必要时选草图线或 Draft Wire,点击“从线槽/草图创建路由路径” -7. 必要时选背板/门板面,点击“从选中面创建辅助路由区域” -8. 选中两个工程端子 -9. 点击“测试布线选中两个端子” +3. 清除自动布线 +4. 清除走线路径 +5. 可选:全选或选中线槽实体后点击“生成布线网络路径”;如果不选择,则使用整份文档自动识别 +6. 点击“生成布线网络路径” +7. 点击“生成布线布局空间” +8. 点击“自动布线” ``` -### 6.2 批量导线自动布线 +三个按钮的职责: + +```text +生成布线网络路径:生成 WireDuct 中心线 carrier +生成布线布局空间:按整份装配准备线槽、支撑面和端子接入网络 +自动布线:先准备布局空间,再自动检查/绑定工程端子,按 QET 导线任务批量求路并生成 AutoSuggested 导线 +``` -前提: +如果模型名称/标签足够规范,可以不手动选择,直接执行三步;也可以只点击“自动布线”,系统会自动准备当前可识别的布线网络和布局空间。若线槽无法自动识别,则先选中线槽实体执行“生成布线网络路径”作为补充。 + +### 6.2 批量导线自动布线前提 1. QET 导出的 `2d_to_3d.json` 中包含 `wires[]`。 2. 每条导线包含: ```text +start_instance_id 或 start_element_uuid start_terminal_uuid +start_terminal_display +end_instance_id 或 end_element_uuid end_terminal_uuid +end_terminal_display ``` -3. FreeCAD 文档中存在对应 `QetTerminalUuid` 的工程端子。 - -操作: - -```text -1. 创建线槽/路由网络 -2. 点击“按导线任务自动布线全部” -``` +3. FreeCAD 文档中存在对应 `QetTerminalUuid` 的工程端子,或存在可按设备和端子显示号匹配的 `local:*` 模板端子。 +4. 自动布线只按导线任务布线,不会把场景里所有端子任意两两相连。 注意:批量自动布线的依据是导线任务,不是“所有端子自动互连”。如果文档中只有端子而没有 `wires[]` 或 `QETWiring_01_Tasks`,系统不能判断哪些端子应该连接。 @@ -466,9 +515,9 @@ PE 线优先路径 禁布区域 ``` -5. 更智能的线槽识别 +5. 更完整的线槽/机柜模型识别 -根据对象名称、尺寸比例、设备库语义自动识别线槽,而不完全依赖用户选择。 +当前已能按名称和薄板比例识别线槽、安装板、背板、门板和 panel。后续需要接入设备库语义,进一步区分完整机柜、可布线柜面、禁布区域、可开门区域和普通设备外壳。 ### 8.3 长期能力 @@ -484,15 +533,19 @@ PE 线优先路径 当前版本验收只看“能否自动布线”: -1. 文档中有至少两个工程端子。 -2. 文档中有至少一条 `WireDuct` 或 `RoutingPath` carrier。 -3. 选择两个端子后执行单线测试布线,能生成 `AutoSuggested` 导线。 -4. 存在导线任务时执行“按导线任务自动布线全部”,能批量生成 `AutoSuggested` 导线。 -5. 生成导线在 `QETWiring_04_Routed` 下可见。 -6. 没有路由网络时不生成长距离悬空线。 -7. 没有导线任务时,批量布线明确提示缺少连接关系。 -8. 明显碰撞时状态为 `CollisionWarning`。 -9. 保存 FreeCAD 文档后,自动导线和路由网络仍保留。 +1. 文档中有至少两个真实工程端子。 +2. 文档中有至少一条 `WireDuct` carrier,或有可作为低优先级路径的 `RoutingRange` 支撑面 carrier。 +3. 执行“生成布线网络路径”后,能生成 `WireDuct` carrier。 +4. 执行“生成布线布局空间”后,能生成或复用 `WireDuct` / `RoutingRange` carrier,并为工程端子生成 `TerminalAccess` 接入 carrier。 +5. 存在导线任务时执行“自动布线”,会先准备布局空间,再批量生成 `AutoSuggested` 导线。 +6. 生成导线在 `QETWiring_04_Routed` 下可见。 +7. 没有路由网络时正式布线不生成长距离悬空线。 +8. 没有导线任务时,批量布线明确提示缺少连接关系。 +9. 有备选 carrier 时,明显障碍会被绕开,生成导线状态为 `Routed`。 +10. 没有备选 carrier 时,明显碰撞状态为 `CollisionWarning`。 +11. 两条相交或重叠的线槽中心路径能在交点/重叠端点处连通并自动拐弯。 +12. 自动识别出的安装板/柜面能生成低优先级 `RoutingRange`,并可被自动布线使用。 +13. 保存 FreeCAD 文档后,自动导线和路由网络仍保留。 ## 10. 开发验证命令 diff --git a/src/Mod/FreeCADExchange/AutoRouting.py b/src/Mod/FreeCADExchange/AutoRouting.py index bc26dff..db80ad7 100644 --- a/src/Mod/FreeCADExchange/AutoRouting.py +++ b/src/Mod/FreeCADExchange/AutoRouting.py @@ -5,6 +5,7 @@ # 然后在 QETWiring_04_Routed 下生成一条可见的折线导线。 import json +import math import FreeCAD as App @@ -15,6 +16,7 @@ except ImportError: import RoutingNetwork import TerminalObjects +import TemplateSemantics import WiringObjects @@ -28,7 +30,7 @@ DEFAULT_OPTIONS = { "lane_spacing": 10.0, # 线槽网络相关参数。 "use_routing_network": True, - "network_entry_max_distance": 0.0, + "network_entry_max_distance": 1000.0, "bend_penalty": 25.0, # EPLAN/SOLIDWORKS 风格:线槽/路由路径最优先,辅助面域只作为过渡/兜底区域。 "carrier_kind_cost_factors": { @@ -36,6 +38,7 @@ DEFAULT_OPTIONS = { "RoutingPath": 1.0, "UserPath": 1.0, "AuxiliaryPath": 2.0, + "TerminalAccess": 2.0, "RoutingRange": 8.0, "SurfaceGrid": 8.0, }, @@ -43,6 +46,12 @@ DEFAULT_OPTIONS = { "allow_floating_fallback": False, # 障碍包围盒会按这个距离膨胀,用于提前发现贴碰风险。 "obstacle_clearance": 5.0, + # 防止坐标异常或端子离路由网络过远时生成超长接入线,把 FreeCAD + # 视图包围盒拉得过大,导致旋转时模型被裁剪到看不见。 + "terminal_access_max_distance": 1000.0, + # 先把穿过障碍包围盒的路由网络边从 Dijkstra 图中移除;如果没有安全 + # 替代路径,再退回原图并用 CollisionWarning 告诉用户当前网络不足。 + "avoid_obstacles": True, "replace_existing": True, } @@ -93,8 +102,20 @@ def _point_payload(point): } +def _is_finite_point(point): + try: + return all( + math.isfinite(float(getattr(point, axis, 0.0))) + for axis in ("x", "y", "z") + ) + except Exception: + return False + + def _append_unique(points, point): vector = _vector(point) + if not _is_finite_point(vector): + return if not points or not _vector_close(points[-1], vector): points.append(vector) @@ -204,6 +225,222 @@ def index_terminals(doc): return indexed +def _normalized_match_token(value): + return (value or "").strip().lower().replace(" ", "") + + +def _device_group_for_wire_endpoint(doc, instance_id, element_uuid): + device_group = TerminalObjects.find_device_group_by_instance_id(doc, instance_id) + if device_group is None: + device_group = TerminalObjects.find_device_group(doc, element_uuid) + return device_group + + +def _terminal_match_tokens(obj): + tokens = [] + for value in ( + getattr(obj, "QetTemplateSlotName", ""), + getattr(obj, "QetTerminalLabel", ""), + getattr(obj, "Label", ""), + getattr(obj, "Name", ""), + ): + token = _normalized_match_token(value) + if token and token not in tokens: + tokens.append(token) + return tokens + + +def _slot_match_tokens(slot): + tokens = [] + for value in ( + slot.get("name", ""), + slot.get("label", ""), + ): + token = _normalized_match_token(value) + if token and token not in tokens: + tokens.append(token) + return tokens + + +def _matching_local_terminal(terminal_group, terminal_display, used_objects): + local_terminals = [] + display_token = _normalized_match_token(terminal_display) + for terminal in TerminalObjects.collect_terminal_objects(terminal_group): + if terminal in used_objects: + continue + terminal_uuid = (getattr(terminal, "QetTerminalUuid", "") or "").strip() + if not TerminalObjects.is_local_terminal_uuid(terminal_uuid): + continue + local_terminals.append(terminal) + if display_token and display_token in _terminal_match_tokens(terminal): + return terminal + if not display_token and len(local_terminals) == 1: + return local_terminals[0] + return None + + +def _matching_template_slot(device_group, terminal_display, used_slot_tokens): + display_token = _normalized_match_token(terminal_display) + if not display_token: + return None + for slot in TemplateSemantics.collect_terminal_hints(device_group): + slot_tokens = _slot_match_tokens(slot) + if display_token not in slot_tokens: + continue + slot_token = slot_tokens[0] if slot_tokens else "" + if slot_token and slot_token in used_slot_tokens: + continue + return slot + return None + + +def _slot_placement(slot): + base = slot.get("base") + if not isinstance(base, App.Vector): + base = App.Vector(0, 0, 0) + + rotation = App.Rotation() + rotation_value = slot.get("rotation") + if isinstance(rotation_value, dict): + axis = rotation_value.get("axis") + angle = rotation_value.get("angle") + if isinstance(axis, App.Vector) and angle is not None: + try: + rotation = App.Rotation(axis, float(angle)) + except Exception: + rotation = App.Rotation() + return App.Placement(base, rotation) + + +def _wire_endpoint_entries(payload): + entries = [] + seen = set() + for item in payload.get("wires", []) or []: + if not isinstance(item, dict): + continue + for prefix in ("start", "end"): + terminal_uuid = _wire_item_value(item, "{0}_terminal_uuid".format(prefix)) + if not terminal_uuid or TerminalObjects.is_local_terminal_uuid(terminal_uuid): + continue + if terminal_uuid in seen: + continue + seen.add(terminal_uuid) + entries.append( + { + "terminal_uuid": terminal_uuid, + "element_uuid": _wire_item_value(item, "{0}_element_uuid".format(prefix)), + "instance_id": _wire_item_value(item, "{0}_instance_id".format(prefix)), + "terminal_display": _wire_item_value( + item, + "{0}_terminal_display".format(prefix), + "{0}_terminal_label".format(prefix), + ), + } + ) + return entries + + +def _bind_wire_task_terminals(doc, payload): + """Promote matching local template terminals to QET terminal UUIDs before routing.""" + report = { + "bound": 0, + "created": 0, + "skipped": 0, + "warnings": [], + } + if doc is None or not isinstance(payload, dict): + return report + + project_uuid = (payload.get("project_uuid") or "").strip() + if not project_uuid: + try: + project_uuid = (getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "") or "").strip() + except Exception: + project_uuid = "" + + indexed = index_terminals(doc) + used_objects = set() + used_slot_tokens = set() + for entry in _wire_endpoint_entries(payload): + terminal_uuid = entry["terminal_uuid"] + if terminal_uuid in indexed: + continue + + device_group = _device_group_for_wire_endpoint( + doc, + entry.get("instance_id", ""), + entry.get("element_uuid", ""), + ) + if device_group is None: + report["skipped"] += 1 + report["warnings"].append( + "端子 {0} 找不到所属 3D 设备实例。".format(terminal_uuid) + ) + continue + + instance_id = (getattr(device_group, "QetInstanceId", "") or "").strip() + element_uuid = (getattr(device_group, "QetElementUuid", "") or "").strip() + terminal_group = TerminalObjects.ensure_terminal_group( + doc, + device_group, + project_uuid=project_uuid, + instance_id=instance_id, + ) + terminal_display = entry.get("terminal_display", "") + terminal_obj = _matching_local_terminal(terminal_group, terminal_display, used_objects) + + if terminal_obj is None: + slot = _matching_template_slot(device_group, terminal_display, used_slot_tokens) + if slot is None: + report["skipped"] += 1 + report["warnings"].append( + "端子 {0} 没有匹配到模板槽位 {1}。".format( + terminal_uuid, + terminal_display or "", + ) + ) + continue + slot_name = (slot.get("name") or terminal_display or terminal_uuid).strip() + terminal_obj = TerminalObjects.create_lcs_object( + doc, + "QETTerminal_{0}".format(TerminalObjects.safe_token(terminal_uuid)), + placement=_slot_placement(slot), + label=terminal_display or terminal_uuid, + ) + terminal_group.addObject(terminal_obj) + source_obj = slot.get("source_object") + if source_obj is not None: + try: + source_obj.ViewObject.Visibility = False + except Exception: + pass + report["created"] += 1 + else: + slot_name = (getattr(terminal_obj, "QetTemplateSlotName", "") or terminal_display).strip() + report["bound"] += 1 + + TerminalObjects.set_terminal_semantics( + terminal_obj, + project_uuid, + element_uuid, + terminal_uuid, + instance_id, + label=terminal_display or getattr(terminal_obj, "Label", "") or terminal_uuid, + slot_name=slot_name, + ) + used_objects.add(terminal_obj) + slot_token = _normalized_match_token(slot_name) + if slot_token: + used_slot_tokens.add(slot_token) + + if report["bound"] or report["created"]: + try: + doc.recompute() + except Exception: + pass + return report + + def _wire_object_name(start_terminal, end_terminal, wire_uuid=""): if wire_uuid: return "QETAutoWire_{0}".format(TerminalObjects.safe_token(wire_uuid)) @@ -224,6 +461,19 @@ def _unique_name(doc, base_name): def _create_wire_geometry(doc, name, points): + # Use a plain Part edge for generated auto wires. Draft wires are convenient for + # editing, but in large imported assemblies they can trigger view-provider redraw + # glitches while rotating the 3D scene. + try: + import Part + + obj = doc.addObject("Part::Feature", name) + obj.Shape = Part.makePolygon(points) + _set_points(obj, points) + return obj + except Exception: + pass + if getattr(App, "ActiveDocument", None) is doc: try: import Draft @@ -232,20 +482,21 @@ def _create_wire_geometry(doc, name, points): points, closed=False, placement=None, - face=None, + face=False, support=None, bs2wire=False, ) if obj is not None: + try: + obj.MakeFace = False + except Exception: + pass _set_points(obj, points) return obj except Exception: pass - import Part - - obj = doc.addObject("Part::Feature", name) - obj.Shape = Part.makePolygon(points) + obj = doc.addObject("App::FeaturePython", name) _set_points(obj, points) return obj @@ -344,62 +595,79 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non if doc is None: return None - network = RoutingNetwork.build_route_graph(doc) - if network.get("segment_count", 0) <= 0: - return None - exit_length = max(float(opts.get("terminal_exit_length", 0.0) or 0.0), 0.0) start_origin = _terminal_origin(start_terminal) end_origin = _terminal_origin(end_terminal) start_exit = _offset(start_origin, _terminal_direction(start_terminal), exit_length) end_exit = _offset(end_origin, _terminal_direction(end_terminal), exit_length) - start_key, start_distance = RoutingNetwork.nearest_node(network, start_exit) - end_key, end_distance = RoutingNetwork.nearest_node(network, end_exit) - if start_key is None or end_key is None: - return None - - max_distance = float(opts.get("network_entry_max_distance", 0.0) or 0.0) - if max_distance > 0.0 and ( - float(start_distance or 0.0) > max_distance - or float(end_distance or 0.0) > max_distance - ): - return None + def route_on_network(network, obstacle_aware=False): + if network.get("segment_count", 0) <= 0: + return None + + start_key, start_distance = RoutingNetwork.nearest_node(network, start_exit) + end_key, end_distance = RoutingNetwork.nearest_node(network, end_exit) + if start_key is None or end_key is None: + return None + + max_distance = float(opts.get("network_entry_max_distance", 0.0) or 0.0) + if max_distance > 0.0 and ( + float(start_distance or 0.0) > max_distance + or float(end_distance or 0.0) > max_distance + ): + return None + + path_keys = RoutingNetwork.shortest_path( + network, + start_key, + end_key, + bend_penalty=float(opts.get("bend_penalty", 0.0) or 0.0), + kind_cost_factors=opts.get("carrier_kind_cost_factors", {}), + ) + if not path_keys: + return None + + carrier_points = RoutingNetwork.path_points(network, path_keys) + if not carrier_points: + return None + + points = [] + _append_unique(points, start_origin) + _append_unique(points, start_exit) + _append_orthogonal(points, carrier_points[0]) + for point in carrier_points[1:]: + _append_unique(points, point) + _append_orthogonal(points, end_exit) + _append_unique(points, end_origin) - path_keys = RoutingNetwork.shortest_path( - network, - start_key, - end_key, - bend_penalty=float(opts.get("bend_penalty", 0.0) or 0.0), - kind_cost_factors=opts.get("carrier_kind_cost_factors", {}), - ) - if not path_keys: - return None + return { + "algorithm": "network-dijkstra-v1", + "points": points, + "network": { + "carriers": int(network.get("carrier_count", 0)), + "segments": int(network.get("segment_count", 0)), + "blocked_segments": int(network.get("blocked_segment_count", 0)), + "nodes": len(network.get("nodes", {})), + "entry_distance": float(start_distance or 0.0), + "exit_distance": float(end_distance or 0.0), + "obstacle_aware": bool(obstacle_aware), + }, + } - carrier_points = RoutingNetwork.path_points(network, path_keys) - if not carrier_points: - return None + use_obstacle_avoidance = bool(opts.get("avoid_obstacles", True)) + obstacles = [] + if use_obstacle_avoidance: + obstacles = collect_obstacles(doc, exclude=[start_terminal, end_terminal], options=opts) + blocked_bboxes = [obstacle["bbox"] for obstacle in obstacles if obstacle.get("bbox")] - points = [] - _append_unique(points, start_origin) - _append_unique(points, start_exit) - _append_orthogonal(points, carrier_points[0]) - for point in carrier_points[1:]: - _append_unique(points, point) - _append_orthogonal(points, end_exit) - _append_unique(points, end_origin) + if blocked_bboxes: + obstacle_aware_network = RoutingNetwork.build_route_graph(doc, blocked_bboxes=blocked_bboxes) + route_data = route_on_network(obstacle_aware_network, obstacle_aware=True) + if route_data is not None: + return route_data - return { - "algorithm": "network-dijkstra-v1", - "points": points, - "network": { - "carriers": int(network.get("carrier_count", 0)), - "segments": int(network.get("segment_count", 0)), - "nodes": len(network.get("nodes", {})), - "entry_distance": float(start_distance or 0.0), - "exit_distance": float(end_distance or 0.0), - }, - } + network = RoutingNetwork.build_route_graph(doc) + return route_on_network(network, obstacle_aware=False) def _is_group(obj): @@ -453,7 +721,7 @@ def collect_obstacles(doc, exclude=None, options=None): if id(obj) in excluded: continue obstacle_mode = (getattr(obj, "QetRoutingObstacleMode", "") or "").strip() - if obstacle_mode in {"PassThrough", "WireDuctPassThrough"}: + if obstacle_mode in {"PassThrough", "WireDuctPassThrough", "SupportSurface"}: continue if _is_group(obj) or _is_origin_helper(obj): continue @@ -577,7 +845,11 @@ def _set_task_status(task, status): def _style_wire(wire, collision_count=0): try: wire.ViewObject.Visibility = True - wire.ViewObject.LineWidth = 3.0 + wire.ViewObject.LineWidth = 5.0 + if hasattr(wire.ViewObject, "DrawStyle"): + wire.ViewObject.DrawStyle = "Solid" + if hasattr(wire.ViewObject, "DisplayMode"): + wire.ViewObject.DisplayMode = "Wireframe" if collision_count: wire.ViewObject.LineColor = (1.0, 0.1, 0.0) else: @@ -628,7 +900,7 @@ def route_between_terminals( if route_data is None: if not opts.get("allow_floating_fallback", False): raise AutoRoutingError( - "没有可用的线槽/路由路径网络;请先选择线槽中心线、草图路径或必要的辅助路由区域。" + "没有可用的线槽/路由路径网络;请先自动识别线槽生成路径,或选择线槽实体生成中心路径。" ) route_data = build_orthogonal_route( start_terminal, @@ -688,6 +960,7 @@ def route_between_terminals( "wire": wire, "route_status": status, "algorithm": route_data.get("algorithm", ""), + "network": route_data.get("network", {}), "points": points, "collision_count": len(collisions), "collisions": collisions, @@ -704,23 +977,79 @@ def _wire_item_value(item, *names): return "" +def bind_wire_task_terminals_from_payload(doc, payload): + """Bind local template terminals to QET terminal UUIDs without creating wires.""" + if doc is None: + raise AutoRoutingError("No FreeCAD document is available.") + if not isinstance(payload, dict): + raise AutoRoutingError("Exchange payload must be an object.") + + binding_report = _bind_wire_task_terminals(doc, payload) + terminals = index_terminals(doc) + wires = payload.get("wires", []) or [] + endpoints = _wire_endpoint_entries(payload) + binding_report.update( + { + "total_wires": len(wires), + "endpoint_terminals": len(endpoints), + "available_terminals": len(terminals), + "local_terminals": sum( + 1 + for terminal_uuid in terminals + if TerminalObjects.is_local_terminal_uuid(terminal_uuid) + ), + } + ) + return binding_report + + +def format_terminal_binding_report(report): + message = "工程端子检查/绑定完成:更新 {0} 个,新建 {1} 个,跳过 {2} 个;当前端子 {3} 个,本地端子 {4} 个。".format( + report.get("bound", 0), + report.get("created", 0), + report.get("skipped", 0), + report.get("available_terminals", 0), + report.get("local_terminals", 0), + ) + warnings = report.get("warnings", []) or [] + if warnings: + message += "\n首个问题:{0}".format(warnings[0]) + if report.get("total_wires", 0) <= 0: + message += "\n没有导线任务,无法按 QET terminal_uuid 绑定工程端子。" + return message + + def route_all_from_payload(doc, payload, options=None): if doc is None: raise AutoRoutingError("No FreeCAD document is available.") if not isinstance(payload, dict): raise AutoRoutingError("Exchange payload must be an object.") + terminal_binding_report = bind_wire_task_terminals_from_payload(doc, payload) terminals = index_terminals(doc) + local_terminal_count = sum( + 1 + for terminal_uuid in terminals + if TerminalObjects.is_local_terminal_uuid(terminal_uuid) + ) wires = payload.get("wires", []) or [] report = { "total_wires": len(wires), + "available_terminals": len(terminals), + "local_terminals": local_terminal_count, + "auto_bound_terminals": terminal_binding_report["bound"], + "auto_created_terminals": terminal_binding_report["created"], + "auto_terminal_binding_warnings": terminal_binding_report["warnings"], "routed": 0, "collision_warnings": 0, "skipped_missing_terminal": 0, "skipped_invalid": 0, + "missing_endpoint_uuids": [], + "missing_endpoint_samples": [], "errors": [], "routes": [], } + missing_endpoint_uuids = set() for index, item in enumerate(wires): if not isinstance(item, dict): @@ -732,6 +1061,25 @@ def route_all_from_payload(doc, payload, options=None): end_terminal = terminals.get(end_uuid) if start_terminal is None or end_terminal is None: report["skipped_missing_terminal"] += 1 + for terminal_uuid in (start_uuid, end_uuid): + if terminal_uuid and terminal_uuid not in terminals: + missing_endpoint_uuids.add(terminal_uuid) + # 这里只保留少量样例,避免面板状态被大量导线任务刷屏。 + if len(report["missing_endpoint_samples"]) < 8: + report["missing_endpoint_samples"].append( + { + "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), + "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), + "start_terminal_uuid": start_uuid, + "start_found": start_terminal is not None, + "start_element_uuid": _wire_item_value(item, "start_element_uuid"), + "start_terminal_display": _wire_item_value(item, "start_terminal_display"), + "end_terminal_uuid": end_uuid, + "end_found": end_terminal is not None, + "end_element_uuid": _wire_item_value(item, "end_element_uuid"), + "end_terminal_display": _wire_item_value(item, "end_terminal_display"), + } + ) continue try: result = route_between_terminals( @@ -761,9 +1109,49 @@ def route_all_from_payload(doc, payload, options=None): "collision_count": result["collision_count"], } ) + report["missing_endpoint_uuids"] = sorted(missing_endpoint_uuids) return report +def format_route_all_report(report): + message = "批量自动布线完成:routed={0}, collision_warnings={1}, missing_terminals={2}".format( + report.get("routed", 0), + report.get("collision_warnings", 0), + report.get("skipped_missing_terminal", 0), + ) + prepared_layout = report.get("prepared_layout") + if isinstance(prepared_layout, dict): + message += "\n布线布局空间:线槽路径 {0} 条,布线面 {1} 条,端子接入 {2} 条。".format( + prepared_layout.get("wire_duct_carriers", 0), + prepared_layout.get("surface_carriers", 0), + prepared_layout.get("terminal_access_carriers", 0), + ) + auto_bound = report.get("auto_bound_terminals", 0) + auto_created = report.get("auto_created_terminals", 0) + if auto_bound or auto_created: + message += "\n已按导线任务绑定 3D 工程端子:更新 {0} 个,新建 {1} 个。".format( + auto_bound, + auto_created, + ) + if report.get("routed", 0) == 0 and report.get("skipped_missing_terminal", 0) > 0: + message += ( + "\n端子匹配失败:当前 3D 可布线端子 {0} 个,其中本地模板端子 {1} 个;" + "导线任务引用的 QET terminal_uuid 没有绑定到这些 3D 工程端子。" + ).format( + report.get("available_terminals", 0), + report.get("local_terminals", 0), + ) + if report.get("local_terminals", 0) > 0: + message += " 请先从 QET 重新导入/更新工程端子,使端子 UUID 不再是 local:...。" + sample = (report.get("missing_endpoint_samples") or [None])[0] + if sample: + message += "\n缺失示例:{0} -> {1}".format( + sample.get("start_terminal_uuid", ""), + sample.get("end_terminal_uuid", ""), + ) + return message + + def _iter_wire_tasks(doc): try: task_group = doc.getObject("QETWiring_01_Tasks") @@ -778,8 +1166,8 @@ def _iter_wire_tasks(doc): ] -def route_all_tasks(doc, options=None): - payload = {"wires": []} +def _wire_tasks_payload(doc): + payload = {"project_uuid": _project_uuid(doc), "wires": []} for task in _iter_wire_tasks(doc): payload["wires"].append( { @@ -789,13 +1177,52 @@ def route_all_tasks(doc, options=None): "wire_mark_is_manual": bool(getattr(task, "QetWireMarkIsManual", False)), "net_uuid": (getattr(task, "QetNetUuid", "") or "").strip(), "group_uuid": (getattr(task, "QetGroupUuid", "") or "").strip(), + "start_element_uuid": (getattr(task, "QetStartElementUuid", "") or "").strip(), + "start_instance_id": (getattr(task, "QetStartInstanceId", "") or "").strip(), "start_terminal_uuid": (getattr(task, "QetStartTerminalUuid", "") or "").strip(), + "start_terminal_display": (getattr(task, "QetStartTerminalDisplay", "") or "").strip(), + "end_element_uuid": (getattr(task, "QetEndElementUuid", "") or "").strip(), + "end_instance_id": (getattr(task, "QetEndInstanceId", "") or "").strip(), "end_terminal_uuid": (getattr(task, "QetEndTerminalUuid", "") or "").strip(), + "end_terminal_display": (getattr(task, "QetEndTerminalDisplay", "") or "").strip(), } ) + return payload + + +def bind_wire_task_terminals_from_tasks(doc): + return bind_wire_task_terminals_from_payload(doc, _wire_tasks_payload(doc)) + + +def route_all_tasks(doc, options=None): + payload = _wire_tasks_payload(doc) return route_all_from_payload(doc, payload, options=options) +def prepare_eplan_style_layout(doc, project_uuid="", options=None): + """Prepare the whole document for production auto-routing. + + EPLAN/SW 的操作语义是“对布线布局空间执行布线”,不是要求用户先点面、 + 画草图或手工补每个端子的接入线。这里统一生成:线槽中心路径、柜内 + 可布线面,以及端子到路由网络的自动接入 carrier。 + """ + if doc is None: + raise AutoRoutingError("No FreeCAD document is available.") + opts = _merged_options(options) + target_project_uuid = (project_uuid or "").strip() or _project_uuid(doc) + if not target_project_uuid: + try: + target_project_uuid = (getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "") or "").strip() + except Exception: + target_project_uuid = "" + return RoutingNetwork.create_layout_space_from_document( + doc, + project_uuid=target_project_uuid, + terminal_exit_length=float(opts.get("terminal_exit_length", 20.0) or 0.0), + terminal_access_max_distance=float(opts.get("terminal_access_max_distance", 1000.0) or 0.0), + ) + + def wire_task_count(doc): return len(_iter_wire_tasks(doc)) @@ -817,15 +1244,6 @@ def clear_auto_routes(doc): return removed -def _selected_terminals(): - if Gui is None: - return [] - try: - return [obj for obj in Gui.Selection.getSelection() if TerminalObjects.is_terminal_object(obj)] - except Exception: - return [] - - def _console_message(message): try: App.Console.PrintMessage("[FreeCADExchange] {0}\n".format(message)) @@ -840,44 +1258,11 @@ def _console_error(message): pass -class CommandAutoRouteSelected: - def GetResources(self): - return { - "MenuText": "测试布线选中两个端子", - "ToolTip": "单条连接测试:只在两个选中的 QET 3D 工程端子之间生成自动折线导线", - } - - def IsActive(self): - return getattr(App, "ActiveDocument", None) is not None and Gui is not None - - def Activated(self): - doc = getattr(App, "ActiveDocument", None) - terminals = _selected_terminals() - if len(terminals) != 2: - _console_error("请先选中两个可布线工程端子。") - return - try: - result = route_between_terminals(doc, terminals[0], terminals[1]) - _console_message( - "自动布线完成:algorithm={0}, status={1}, collisions={2}".format( - result["algorithm"], - result["route_status"], - result["collision_count"], - ) - ) - try: - Gui.SendMsgToActiveView("ViewFit") - except Exception: - pass - except Exception as exc: - _console_error("自动布线失败:{0}".format(exc)) - - class CommandAutoRouteAll: def GetResources(self): return { - "MenuText": "按导线任务自动布线全部", - "ToolTip": "根据 QET 导线任务批量生成 3D 自动布线路径", + "MenuText": "一键自动布线(全部导线)", + "ToolTip": "自动识别线槽/安装板并生成全部 3D 布线路径", } def IsActive(self): @@ -886,21 +1271,18 @@ class CommandAutoRouteAll: def Activated(self): doc = getattr(App, "ActiveDocument", None) try: + prepared_layout = prepare_eplan_style_layout(doc) + payload = getattr(App, "_qet_exchange_payload", None) if isinstance(payload, dict) and payload.get("wires"): report = route_all_from_payload(doc, payload) else: report = route_all_tasks(doc) + report["prepared_layout"] = prepared_layout if report.get("total_wires", 0) <= 0: - _console_error("没有导线任务。自动布线全部导线需要 QET wires[] 或 QETWiring_01_Tasks。") + _console_error("没有导线任务。一键自动布线需要 QET wires[] 或 QETWiring_01_Tasks。") return - _console_message( - "批量自动布线完成:routed={0}, collision_warnings={1}, missing_terminals={2}".format( - report.get("routed", 0), - report.get("collision_warnings", 0), - report.get("skipped_missing_terminal", 0), - ) - ) + _console_message(format_route_all_report(report)) except Exception as exc: _console_error("批量自动布线失败:{0}".format(exc)) @@ -914,7 +1296,6 @@ def register_commands(): return if Gui is None or not hasattr(Gui, "addCommand"): return - Gui.addCommand("QET_Exchange_AutoRouteSelected", CommandAutoRouteSelected()) Gui.addCommand("QET_Exchange_AutoRouteAll", CommandAutoRouteAll()) _COMMANDS_REGISTERED = True diff --git a/src/Mod/FreeCADExchange/AutoRoutingPanel.py b/src/Mod/FreeCADExchange/AutoRoutingPanel.py index b411849..72536fc 100644 --- a/src/Mod/FreeCADExchange/AutoRoutingPanel.py +++ b/src/Mod/FreeCADExchange/AutoRoutingPanel.py @@ -1,7 +1,9 @@ # FreeCADExchange GUI panel for 3D automatic wiring. # -# 面板只调用 Python 算法:扫描端子/走线网络、从选择创建 carrier、 -# 对选中端子或全部导线任务执行自动布线。 +# EPLAN-style simplified workflow: +# 1. "生成布线网络路径" - generate wire-duct centerline carriers +# 2. "生成布线布局空间" - prepare surfaces and terminal access routes for the whole assembly +# 3. "自动布线" - prepare the layout again and route all QET wire tasks import FreeCAD as App @@ -59,15 +61,6 @@ def _console_error(message): pass -def _selection(): - if Gui is None: - return [] - try: - return list(Gui.Selection.getSelection() or []) - except Exception: - return [] - - def _selection_ex(): if Gui is None: return [] @@ -77,10 +70,6 @@ def _selection_ex(): return [] -def _selected_terminals(): - return [obj for obj in _selection() if TerminalObjects.is_terminal_object(obj)] - - class AutoRoutingController: def __init__(self): self.last_report = None @@ -97,7 +86,7 @@ class AutoRoutingController: kinds = network.get("kinds", {}) if isinstance(network.get("kinds", {}), dict) else {} kind_text = "" if kinds: - kind_text = ";类型:" + ",".join( + kind_text = "; types: " + ", ".join( "{0}={1}".format(key, value) for key, value in sorted(kinds.items()) ) @@ -111,60 +100,59 @@ class AutoRoutingController: kind_text, ) - def create_carriers_from_selection(self): + def generate_routing_paths(self): + """Generate wire-duct routing paths from the current selection or whole document.""" doc = _active_document() project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() - created = RoutingNetwork.create_carriers_from_selection( - doc, - _selection_ex(), - project_uuid=project_uuid, - ) - self.last_report = {"created_carriers": len(created)} - return created + selection_ex = _selection_ex() + source_mode = "selection" if selection_ex else "document" + if selection_ex: + wire_ducts = RoutingNetwork.create_wire_duct_carriers_from_selection( + doc, + selection_ex, + project_uuid=project_uuid, + ) + else: + wire_ducts = RoutingNetwork.create_wire_duct_carriers_from_document( + doc, + project_uuid=project_uuid, + ) + self.last_report = { + "wire_duct_carriers": len(wire_ducts), + "source_mode": source_mode, + } + return self.last_report - def create_wire_duct_carriers_from_selection(self): + def generate_layout_space(self): + """Prepare the whole document as an EPLAN-style routing layout space.""" doc = _active_document() project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() - created = RoutingNetwork.create_wire_duct_carriers_from_selection( + self.last_report = AutoRouting.prepare_eplan_style_layout( doc, - _selection_ex(), project_uuid=project_uuid, ) - self.last_report = {"created_wire_duct_carriers": len(created)} - return created + self.last_report["source_mode"] = "document" + return self.last_report - def create_surface_carriers_from_selection(self): + def route_all(self): doc = _active_document() project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() - created = RoutingNetwork.create_surface_carriers_from_selection( + # EPLAN-style one-click routing: prepare the whole layout space first, then + # solve every QET wire task. The user should not need to select faces or draw + # Draft/Sketch helper paths for normal production routing. + prepared_layout = AutoRouting.prepare_eplan_style_layout( doc, - _selection_ex(), project_uuid=project_uuid, ) - self.last_report = {"created_surface_carriers": len(created)} - return created - - def route_selected(self): - doc = _active_document() - terminals = _selected_terminals() - if len(terminals) != 2: - raise AutoRoutingPanelError( - "这个按钮只用于单条连接测试,请正好选中两个可布线工程端子。批量布线请使用“按导线任务自动布线全部”。" - ) - result = AutoRouting.route_between_terminals(doc, terminals[0], terminals[1]) - self.last_report = result - return result - - def route_all(self): - doc = _active_document() payload = getattr(App, "_qet_exchange_payload", None) if isinstance(payload, dict) and payload.get("wires"): report = AutoRouting.route_all_from_payload(doc, payload) else: report = AutoRouting.route_all_tasks(doc) + report["prepared_layout"] = prepared_layout if report.get("total_wires", 0) <= 0: raise AutoRoutingPanelError( - "没有导线任务,不能判断哪些端子应该互相连接。请先从 QET 导入 wires[],或确认 QETWiring_01_Tasks 中存在导线任务。" + "没有导线任务。请先从 QET 导入 wires[],或确认 QETWiring_01_Tasks 中存在导线任务。" ) self.last_report = report return report @@ -200,106 +188,110 @@ class AutoRoutingTaskPanel: self.form.setWindowTitle("3D 自动布线") layout = QtWidgets.QVBoxLayout(self.form) - self.scan_button = QtWidgets.QPushButton("扫描端子/网络") - self.create_wire_duct_button = QtWidgets.QPushButton("从线槽实体生成中心路径") - self.create_carrier_button = QtWidgets.QPushButton("从线槽/草图创建路由路径") - self.create_surface_button = QtWidgets.QPushButton("从选中面创建辅助路由区域") - self.route_selected_button = QtWidgets.QPushButton("测试布线选中两个端子") - self.route_all_button = QtWidgets.QPushButton("按导线任务自动布线全部") - self.clear_button = QtWidgets.QPushButton("清除自动布线") + + self.generate_paths_button = QtWidgets.QPushButton("生成布线网络路径") + self.generate_paths_button.setToolTip( + "优先从当前选择生成线槽中心路径;未选择时自动识别整份文档里的线槽。" + ) + + self.generate_layout_button = QtWidgets.QPushButton("生成布线布局空间") + self.generate_layout_button.setToolTip( + "按整份 3D 装配生成布线布局空间:识别线槽/安装面,并把工程端子自动接入路由网络。" + ) + + self.route_all_button = QtWidgets.QPushButton("自动布线") + self.route_all_button.setToolTip( + "一键准备布线网络和布局空间,并按全部 QET 导线任务生成 3D 自动布线。" + ) + + self.clear_routes_button = QtWidgets.QPushButton("清除自动布线") self.clear_carriers_button = QtWidgets.QPushButton("清除走线路径") self.save_button = QtWidgets.QPushButton("保存") + self.status_label = QtWidgets.QLabel("") self.status_label.setWordWrap(True) for widget in ( - self.scan_button, - self.create_wire_duct_button, - self.create_carrier_button, - self.create_surface_button, - self.route_selected_button, + self.generate_paths_button, + self.generate_layout_button, self.route_all_button, - self.clear_button, + self.clear_routes_button, self.clear_carriers_button, self.save_button, - self.status_label, ): layout.addWidget(widget) - self.scan_button.clicked.connect(self.scan) - self.create_wire_duct_button.clicked.connect(self.create_wire_duct_carriers) - self.create_carrier_button.clicked.connect(self.create_carriers) - self.create_surface_button.clicked.connect(self.create_surface_carriers) - self.route_selected_button.clicked.connect(self.route_selected) + layout.addWidget(self.status_label) + + self.generate_paths_button.clicked.connect(self.generate_routing_paths) + self.generate_layout_button.clicked.connect(self.generate_layout_space) self.route_all_button.clicked.connect(self.route_all) - self.clear_button.clicked.connect(self.clear_auto_routes) + self.clear_routes_button.clicked.connect(self.clear_auto_routes) self.clear_carriers_button.clicked.connect(self.clear_route_carriers) self.save_button.clicked.connect(self.save) - self.scan() - def _set_status(self, message): - self.status_label.setText(message) - _console_message(message) + self._refresh_status() - def _set_error(self, message): - self.status_label.setText(message) - _console_error(message) - - def scan(self): + def _refresh_status(self): try: self._set_status(self.controller.summary()) except Exception as exc: self._set_error(str(exc)) - def create_carriers(self): - try: - created = self.controller.create_carriers_from_selection() - self._set_status("已创建路由路径:{0} 条。{1}".format(len(created), self.controller.summary())) - except Exception as exc: - self._set_error(str(exc)) + def _set_status(self, message): + self.status_label.setText(message) + _console_message(message) - def create_wire_duct_carriers(self): - try: - created = self.controller.create_wire_duct_carriers_from_selection() - self._set_status("已创建线槽中心路径:{0} 条。{1}".format(len(created), self.controller.summary())) - except Exception as exc: - self._set_error(str(exc)) + def _set_error(self, message): + self.status_label.setText(message) + _console_error(message) - def create_surface_carriers(self): + def generate_routing_paths(self): try: - created = self.controller.create_surface_carriers_from_selection() - self._set_status("已创建辅助路由区域:{0} 条。{1}".format(len(created), self.controller.summary())) + result = self.controller.generate_routing_paths() + wire_ducts = result.get("wire_duct_carriers", 0) + if wire_ducts == 0: + self._set_status( + "未生成布线网络路径。可先全选或选中线槽实体,再执行本按钮。" + + self.controller.summary() + ) + return + self._set_status( + "已生成布线网络路径:线槽中心路径 {0} 条。{1}".format( + wire_ducts, self.controller.summary() + ) + ) except Exception as exc: self._set_error(str(exc)) - def route_selected(self): + def generate_layout_space(self): try: - result = self.controller.route_selected() + result = self.controller.generate_layout_space() + wire_ducts = result.get("wire_duct_carriers", 0) + surfaces = result.get("surface_carriers", 0) + terminal_access = result.get("terminal_access_carriers", 0) + network = result.get("network", {}) if isinstance(result.get("network", {}), dict) else {} + if network.get("segments", 0) == 0: + self._set_status( + "未生成可用布线布局空间。请确认 3D 装配里有可识别的线槽、安装板、背板或已标记路由路径。" + + self.controller.summary() + ) + return self._set_status( - "自动布线完成:algorithm={0}, status={1}, collisions={2}".format( - result.get("algorithm", ""), - result.get("route_status", ""), - result.get("collision_count", 0), + "已生成布线布局空间:线槽路径 {0} 条,布线面 {1} 条,端子接入 {2} 条。{3}".format( + wire_ducts, + surfaces, + terminal_access, + self.controller.summary(), ) ) - try: - if Gui is not None: - Gui.SendMsgToActiveView("ViewFit") - except Exception: - pass except Exception as exc: self._set_error(str(exc)) def route_all(self): try: report = self.controller.route_all() - self._set_status( - "批量自动布线完成:routed={0}, collision_warnings={1}, missing_terminals={2}".format( - report.get("routed", 0), - report.get("collision_warnings", 0), - report.get("skipped_missing_terminal", 0), - ) - ) + self._set_status(AutoRouting.format_route_all_report(report)) except Exception as exc: self._set_error(str(exc)) diff --git a/src/Mod/FreeCADExchange/ExchangeBootstrap.py b/src/Mod/FreeCADExchange/ExchangeBootstrap.py index edb4b70..4dd6302 100644 --- a/src/Mod/FreeCADExchange/ExchangeBootstrap.py +++ b/src/Mod/FreeCADExchange/ExchangeBootstrap.py @@ -453,8 +453,10 @@ def _normalize_wires(payload): "wire_mark": _optional_string(item, "wire_mark", entry_label), "wire_mark_is_manual": wire_mark_is_manual, "start_element_uuid": _optional_string(item, "start_element_uuid", entry_label), + "start_instance_id": _optional_string(item, "start_instance_id", entry_label), "start_terminal_uuid": _optional_string(item, "start_terminal_uuid", entry_label), "end_element_uuid": _optional_string(item, "end_element_uuid", entry_label), + "end_instance_id": _optional_string(item, "end_instance_id", entry_label), "end_terminal_uuid": _optional_string(item, "end_terminal_uuid", entry_label), "start_terminal_display": _optional_string(item, "start_terminal_display", entry_label), "end_terminal_display": _optional_string(item, "end_terminal_display", entry_label), diff --git a/src/Mod/FreeCADExchange/RoutingNetwork.py b/src/Mod/FreeCADExchange/RoutingNetwork.py index 32c29e5..010be69 100644 --- a/src/Mod/FreeCADExchange/RoutingNetwork.py +++ b/src/Mod/FreeCADExchange/RoutingNetwork.py @@ -5,6 +5,7 @@ # 自动布线算法再沿这些 carrier 做最短路搜索。 import heapq +import math import FreeCAD as App @@ -17,22 +18,101 @@ ROUTE_CARRIER_KIND = "RoutingPath" ROUTE_CARRIER_KIND_WIRE_DUCT = "WireDuct" ROUTE_CARRIER_KIND_AUXILIARY_PATH = "AuxiliaryPath" ROUTE_CARRIER_KIND_ROUTING_RANGE = "RoutingRange" +ROUTE_CARRIER_KIND_TERMINAL_ACCESS = "TerminalAccess" 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 +DEFAULT_ROUTE_PATH_FACE_OFFSET = 2.0 +DEFAULT_AUTO_WIRE_DUCT_MIN_ASPECT = 2.5 +DEFAULT_TERMINAL_ACCESS_MAX_DISTANCE = 1000.0 WIRE_DUCT_OBSTACLE_MODE = "PassThrough" +SUPPORT_SURFACE_OBSTACLE_MODE = "SupportSurface" +WIRE_DUCT_NAME_KEYWORDS = ( + "wire duct", + "wiring duct", + "cable duct", + "cable tray", + "trunking", + "wireway", + "线槽", + "走线槽", + "走线", + "电缆槽", + "配线槽", +) +WIRE_DUCT_EXCLUDE_KEYWORDS = ( + "cabinet", + "door", + "panel", + "backplate", + "base plate", + "mounting plate", + "机柜", + "柜体", + "门板", + "安装板", + "背板", + "底板", +) +SUPPORT_SURFACE_NAME_KEYWORDS = ( + "mounting plate", + "base plate", + "back plate", + "backplate", + "panel", + "door panel", + "rear door", + "front door", + "cabinet face", + "cabinet panel", + "\u5b89\u88c5\u677f", + "\u80cc\u677f", + "\u5e95\u677f", + "\u95e8\u677f", + "\u67dc\u9762", +) +SUPPORT_SURFACE_CARRIER_KINDS = { + "cabinet", + "panel", + "cabinet_face", + "mounting_plate", + "routing_range", +} 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_TERMINAL_ACCESS: 2.0, ROUTE_CARRIER_KIND_ROUTING_RANGE: 8.0, # 旧文档兼容:之前的贴面网格使用 SurfaceGrid。 "SurfaceGrid": 8.0, "UserPath": 1.0, } +ROUTE_CARRIER_VIEW_STYLES = { + ROUTE_CARRIER_KIND_WIRE_DUCT: { + "color": (1.0, 0.55, 0.0), + "width": 4.0, + }, + ROUTE_CARRIER_KIND_ROUTING_RANGE: { + "color": (0.0, 0.65, 0.35), + "width": 1.0, + }, + ROUTE_CARRIER_KIND_TERMINAL_ACCESS: { + "color": (0.65, 0.2, 1.0), + "width": 2.0, + }, + ROUTE_CARRIER_KIND_AUXILIARY_PATH: { + "color": (0.45, 0.45, 0.45), + "width": 2.0, + }, + ROUTE_CARRIER_KIND: { + "color": (0.0, 0.45, 0.85), + "width": 2.0, + }, +} class RoutingNetworkError(RuntimeError): @@ -49,6 +129,28 @@ class _SimpleBoundBox: self.ZMax = float(zmax) +class _PointVertex: + def __init__(self, point): + self.Point = point + + +class _BBoxFace: + ShapeType = "Face" + + def __init__(self, points, normal): + self.Vertexes = [_PointVertex(point) for point in points] + self._normal = normal + self.QetSurfaceUAxis = _subtract(points[1], points[0]) if len(points) > 1 else None + self.CenterOfMass = _average_points(points) + xs = [point.x for point in points] + ys = [point.y for point in points] + zs = [point.z for point in points] + self.BoundBox = _SimpleBoundBox(min(xs), max(xs), min(ys), max(ys), min(zs), max(zs)) + + def normalAt(self, _u, _v): + return self._normal + + def _vector(point): if isinstance(point, App.Vector): return App.Vector(point.x, point.y, point.z) @@ -72,6 +174,53 @@ def _distance(left, right): return (dx * dx + dy * dy + dz * dz) ** 0.5 +def _add(left, right): + return App.Vector( + float(left.x) + float(right.x), + float(left.y) + float(right.y), + float(left.z) + float(right.z), + ) + + +def _subtract(left, right): + return App.Vector( + float(left.x) - float(right.x), + float(left.y) - float(right.y), + float(left.z) - float(right.z), + ) + + +def _scale(vector, factor): + return App.Vector( + float(vector.x) * float(factor), + float(vector.y) * float(factor), + float(vector.z) * float(factor), + ) + + +def _dot(left, right): + return ( + float(left.x) * float(right.x) + + float(left.y) * float(right.y) + + float(left.z) * float(right.z) + ) + + +def _cross(left, right): + return App.Vector( + float(left.y) * float(right.z) - float(left.z) * float(right.y), + float(left.z) * float(right.x) - float(left.x) * float(right.z), + float(left.x) * float(right.y) - float(left.y) * float(right.x), + ) + + +def _normalize(vector): + length = _distance(vector, App.Vector(0, 0, 0)) + if length <= DEFAULT_NODE_TOLERANCE: + return None + return _scale(vector, 1.0 / length) + + def _direction_key(left, right, tolerance=DEFAULT_NODE_TOLERANCE): dx = float(right.x) - float(left.x) dy = float(right.y) - float(left.y) @@ -157,6 +306,16 @@ def _bbox_center(bbox): ) +def _average_points(points): + points = list(points or []) + if not points: + return App.Vector(0, 0, 0) + total = App.Vector(0, 0, 0) + for point in points: + total = _add(total, point) + return _scale(total, 1.0 / len(points)) + + def _bbox_extent(bbox, axis): low, high = _bbox_axis_range(bbox, axis) return abs(high - low) @@ -179,6 +338,16 @@ def _point_payload(point): } +def _is_finite_point(point): + try: + return all( + math.isfinite(float(getattr(point, axis, 0.0))) + for axis in ("x", "y", "z") + ) + except Exception: + return False + + def _unique_name(doc, base_name): name = TerminalObjects.safe_token(base_name) if doc.getObject(name) is None: @@ -231,8 +400,34 @@ def _set_route_carrier_semantics(obj, project_uuid="", kind=ROUTE_CARRIER_KIND): return obj +def _style_route_carrier(carrier, kind): + style = ROUTE_CARRIER_VIEW_STYLES.get(kind) or ROUTE_CARRIER_VIEW_STYLES[ROUTE_CARRIER_KIND] + try: + carrier.ViewObject.Visibility = True + carrier.ViewObject.LineWidth = float(style.get("width", 2.0)) + carrier.ViewObject.LineColor = style.get("color", (0.0, 0.45, 0.85)) + # Keep routing helper geometry on stable solid-line rendering. Dashed/dotted + # Coin3D line rendering can make large FreeCAD scenes disappear while rotating. + if hasattr(carrier.ViewObject, "DrawStyle"): + carrier.ViewObject.DrawStyle = "Solid" + if hasattr(carrier.ViewObject, "DisplayMode"): + carrier.ViewObject.DisplayMode = "Wireframe" + except Exception: + pass + + def _create_carrier_geometry(doc, name, points): - # Draft 生成的线在 GUI 里更容易看见;命令行/测试环境没有 Draft 时退回 Part。 + # Use a simple Part edge shape by default. It is less feature-rich than Draft + # Wire, but much more stable for large 3D scenes while rotating the view. + try: + import Part + + obj = doc.addObject("Part::Feature", name) + obj.Shape = Part.makePolygon(points) + return obj + except Exception: + pass + if getattr(App, "ActiveDocument", None) is doc: try: import Draft @@ -241,24 +436,21 @@ def _create_carrier_geometry(doc, name, points): points, closed=False, placement=None, - face=None, + face=False, support=None, bs2wire=False, ) if obj is not None: + try: + obj.MakeFace = False + except Exception: + pass 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 + obj = doc.addObject("App::FeaturePython", name) + return obj def create_route_carrier(doc, points, label="", project_uuid="", kind=ROUTE_CARRIER_KIND): @@ -269,6 +461,8 @@ def create_route_carrier(doc, points, label="", project_uuid="", kind=ROUTE_CARR normalized = [] for point in points or []: vector = _vector(point) + if not _is_finite_point(vector): + continue if not normalized or _distance(normalized[-1], vector) > DEFAULT_NODE_TOLERANCE: normalized.append(vector) @@ -290,12 +484,7 @@ def create_route_carrier(doc, points, label="", project_uuid="", kind=ROUTE_CARR 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 + _style_route_carrier(carrier, kind) try: doc.recompute() @@ -328,6 +517,144 @@ def _carrier_points(obj): return [] +def _segment_axis(start, end, tolerance=DEFAULT_NODE_TOLERANCE): + varying = [ + axis + for axis in ("x", "y", "z") + if abs(_axis_value(start, axis) - _axis_value(end, axis)) > tolerance + ] + if len(varying) == 1: + return varying[0] + return None + + +def _between(value, first, second, tolerance=DEFAULT_NODE_TOLERANCE): + low = min(float(first), float(second)) - float(tolerance) + high = max(float(first), float(second)) + float(tolerance) + return low <= float(value) <= high + + +def _dedupe_points(points, tolerance=DEFAULT_NODE_TOLERANCE): + deduped = [] + seen = set() + for point in points: + key = _point_key(point, tolerance=tolerance) + if key in seen: + continue + seen.add(key) + deduped.append(point) + return deduped + + +def _orthogonal_segment_intersections( + first_start, + first_end, + second_start, + second_end, + tolerance=DEFAULT_NODE_TOLERANCE, +): + first_axis = _segment_axis(first_start, first_end, tolerance=tolerance) + second_axis = _segment_axis(second_start, second_end, tolerance=tolerance) + if first_axis is None or second_axis is None: + return [] + + if first_axis == second_axis: + for axis in ("x", "y", "z"): + if axis == first_axis: + continue + if abs(_axis_value(first_start, axis) - _axis_value(second_start, axis)) > tolerance: + return [] + + first_low = min(_axis_value(first_start, first_axis), _axis_value(first_end, first_axis)) + first_high = max(_axis_value(first_start, first_axis), _axis_value(first_end, first_axis)) + second_low = min(_axis_value(second_start, second_axis), _axis_value(second_end, second_axis)) + second_high = max(_axis_value(second_start, second_axis), _axis_value(second_end, second_axis)) + overlap_low = max(first_low, second_low) + overlap_high = min(first_high, second_high) + if overlap_high < overlap_low - tolerance: + return [] + + if abs(overlap_high - overlap_low) <= tolerance: + return [_set_axis(first_start, first_axis, overlap_low)] + return [ + _set_axis(first_start, first_axis, overlap_low), + _set_axis(first_start, first_axis, overlap_high), + ] + + remaining_axes = [axis for axis in ("x", "y", "z") if axis not in {first_axis, second_axis}] + if len(remaining_axes) != 1: + return [] + shared_axis = remaining_axes[0] + + if abs(_axis_value(first_start, shared_axis) - _axis_value(second_start, shared_axis)) > tolerance: + return [] + + first_axis_value = _axis_value(second_start, first_axis) + second_axis_value = _axis_value(first_start, second_axis) + if not _between(first_axis_value, _axis_value(first_start, first_axis), _axis_value(first_end, first_axis), tolerance): + return [] + if not _between(second_axis_value, _axis_value(second_start, second_axis), _axis_value(second_end, second_axis), tolerance): + return [] + + coordinates = { + first_axis: first_axis_value, + second_axis: second_axis_value, + shared_axis: (_axis_value(first_start, shared_axis) + _axis_value(second_start, shared_axis)) * 0.5, + } + return [App.Vector(coordinates["x"], coordinates["y"], coordinates["z"])] + + +def _sorted_segment_points(start, end, points, tolerance=DEFAULT_NODE_TOLERANCE): + points = _dedupe_points(points, tolerance=tolerance) + axis = _segment_axis(start, end, tolerance=tolerance) + if axis is not None: + reverse = _axis_value(start, axis) > _axis_value(end, axis) + return sorted(points, key=lambda point: _axis_value(point, axis), reverse=reverse) + return sorted(points, key=lambda point: _distance(start, point)) + + +def _segment_intersects_bbox_payload(start, end, bbox): + if not isinstance(bbox, dict): + return False + + try: + 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) <= DEFAULT_NODE_TOLERANCE: + 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 + except Exception: + return False + return True + + +def _segment_hits_blocked_bbox(start, end, blocked_bboxes): + for bbox in blocked_bboxes or []: + if _segment_intersects_bbox_payload(start, end, bbox): + return True + return False + + def collect_route_carriers(doc): if doc is None: return [] @@ -454,6 +781,139 @@ def _is_route_path_source_object(obj): return bool(list(getattr(shape, "Edges", []) or [])) +def _routing_source_text(obj): + return " ".join( + str(value or "") + for value in ( + getattr(obj, "Name", ""), + getattr(obj, "Label", ""), + getattr(obj, "QetCarrierKind", ""), + getattr(obj, "QetCarrierRoleLabel", ""), + getattr(obj, "QetRoutingSourceKind", ""), + ) + ).lower() + + +def _bbox_aspect_ratio(bbox): + extents = sorted( + (_bbox_extent(bbox, axis) for axis in ("x", "y", "z")), + reverse=True, + ) + if not extents or extents[0] <= DEFAULT_NODE_TOLERANCE: + return 0.0 + if len(extents) < 2 or extents[1] <= DEFAULT_NODE_TOLERANCE: + return float("inf") + return extents[0] / extents[1] + + +def _is_wire_duct_candidate(obj, min_aspect=DEFAULT_AUTO_WIRE_DUCT_MIN_ASPECT): + if obj is None: + return False + if is_route_carrier(obj) or TerminalObjects.is_terminal_object(obj): + return False + if (getattr(obj, "RouteType", "") or "").strip(): + return False + text = _routing_source_text(obj) + if any(keyword in text for keyword in WIRE_DUCT_EXCLUDE_KEYWORDS): + return False + has_semantic_hint = ( + (getattr(obj, "QetRoutingSourceKind", "") or "").strip() == ROUTE_CARRIER_KIND_WIRE_DUCT + or (getattr(obj, "QetCarrierKind", "") or "").strip().lower() == "wire_duct" + ) + has_name_hint = any(keyword in text for keyword in WIRE_DUCT_NAME_KEYWORDS) + if not has_semantic_hint and not has_name_hint: + return False + bbox = _bound_box_from_object(obj) + if bbox is None: + return False + # 自动识别只接受明显细长的对象,避免把柜体、门板、安装板误判成线槽。 + return _bbox_aspect_ratio(bbox) >= float(min_aspect or 1.0) + + +def _bbox_extents(bbox): + return { + axis: _bbox_extent(bbox, axis) + for axis in ("x", "y", "z") + } + + +def _is_thin_surface_bbox(bbox, min_surface_extent=50.0, max_thickness=40.0, thickness_ratio=0.2): + extents = _bbox_extents(bbox) + ordered = sorted(extents.values()) + if len(ordered) < 3 or ordered[-1] <= DEFAULT_NODE_TOLERANCE: + return False + thickness = ordered[0] + second_extent = ordered[1] + longest = ordered[2] + if second_extent < float(min_surface_extent or 0.0): + return False + allowed_thickness = min(float(max_thickness or 0.0), longest * float(thickness_ratio or 0.0)) + return thickness <= max(allowed_thickness, DEFAULT_NODE_TOLERANCE) + + +def _is_support_surface_candidate(obj): + if obj is None: + return False + if is_route_carrier(obj) or TerminalObjects.is_terminal_object(obj): + return False + if (getattr(obj, "RouteType", "") or "").strip(): + return False + text = _routing_source_text(obj) + if any(keyword in text for keyword in WIRE_DUCT_NAME_KEYWORDS): + return False + + carrier_kind = (getattr(obj, "QetCarrierKind", "") or "").strip().lower() + source_kind = (getattr(obj, "QetRoutingSourceKind", "") or "").strip() + has_semantic_hint = ( + source_kind == ROUTE_CARRIER_KIND_ROUTING_RANGE + or carrier_kind in SUPPORT_SURFACE_CARRIER_KINDS + ) + has_name_hint = any(keyword in text for keyword in SUPPORT_SURFACE_NAME_KEYWORDS) + if not has_semantic_hint and not has_name_hint: + return False + + bbox = _bound_box_from_object(obj) + if bbox is None: + return False + return _is_thin_surface_bbox(bbox) + + +def _support_face_from_bbox(bbox): + extents = _bbox_extents(bbox) + normal_axis = min(extents, key=extents.get) + surface_axes = sorted( + [axis for axis in ("x", "y", "z") if axis != normal_axis], + key=lambda axis: _bbox_extent(bbox, axis), + reverse=True, + ) + normal_value = _bbox_axis_range(bbox, normal_axis)[1] + normal = App.Vector( + 1.0 if normal_axis == "x" else 0.0, + 1.0 if normal_axis == "y" else 0.0, + 1.0 if normal_axis == "z" else 0.0, + ) + + first_axis = surface_axes[0] + second_axis = surface_axes[1] + first_low, first_high = _bbox_axis_range(bbox, first_axis) + second_low, second_high = _bbox_axis_range(bbox, second_axis) + + points = [] + for first_value, second_value in ( + (first_low, second_low), + (first_high, second_low), + (first_high, second_high), + (first_low, second_high), + ): + coordinates = { + normal_axis: normal_value, + first_axis: first_value, + second_axis: second_value, + } + points.append(App.Vector(coordinates["x"], coordinates["y"], coordinates["z"])) + return _BBoxFace(points, normal) + + def _points_from_selection_item(selection_item): points = [] @@ -491,6 +951,24 @@ def _points_from_selection_item(selection_item): return normalized +def _support_face_from_selection(selection_ex): + for item in selection_ex or []: + for sub_object in list(getattr(item, "SubObjects", []) or []): + if (getattr(sub_object, "ShapeType", "") or "").lower() == "face": + return sub_object + return None + + +def _selection_item_is_only_support_face(selection_item): + sub_objects = list(getattr(selection_item, "SubObjects", []) or []) + if not sub_objects: + return False + return all( + (getattr(sub_object, "ShapeType", "") or "").lower() == "face" + for sub_object in sub_objects + ) + + def _face_normal(face): try: return _vector(face.normalAt(0.5, 0.5)) @@ -531,37 +1009,114 @@ def _surface_grid_values(min_value, max_value, spacing, margin): return values -def _surface_face_grid_points(face, spacing, offset, margin): - normal = _face_normal(face) +def _face_points(face): + points = [] + for vertex in list(getattr(face, "Vertexes", []) or []): + point = getattr(vertex, "Point", None) + if point is not None: + points.append(_vector(point)) + if points: + return points + + bbox = getattr(face, "BoundBox", None) + if bbox is None: + return [] + return [ + App.Vector(bbox.XMin, bbox.YMin, bbox.ZMin), + App.Vector(bbox.XMin, bbox.YMin, bbox.ZMax), + App.Vector(bbox.XMin, bbox.YMax, bbox.ZMin), + App.Vector(bbox.XMin, bbox.YMax, bbox.ZMax), + App.Vector(bbox.XMax, bbox.YMin, bbox.ZMin), + App.Vector(bbox.XMax, bbox.YMin, bbox.ZMax), + App.Vector(bbox.XMax, bbox.YMax, bbox.ZMin), + App.Vector(bbox.XMax, bbox.YMax, bbox.ZMax), + ] + + +def _face_origin(face, fallback_points): + center = getattr(face, "CenterOfMass", None) + if center is not None: + return _vector(center) + + if fallback_points: + total = App.Vector(0, 0, 0) + for point in fallback_points: + total = _add(total, point) + return _scale(total, 1.0 / len(fallback_points)) + bbox = getattr(face, "BoundBox", None) - if normal is None or bbox is None: + if bbox is not None: + return _bbox_center(bbox) + return App.Vector(0, 0, 0) + + +def _face_u_axis(face, normal, points, origin): + explicit_axis = getattr(face, "QetSurfaceUAxis", None) + if explicit_axis is not None: + explicit_axis = _vector(explicit_axis) + candidate = _subtract(explicit_axis, _scale(normal, _dot(explicit_axis, normal))) + normalized = _normalize(candidate) + if normalized is not None: + return normalized + + best = None + best_length = 0.0 + for left in points: + for right in points: + candidate = _subtract(right, left) + candidate = _subtract(candidate, _scale(normal, _dot(candidate, normal))) + length = _distance(candidate, App.Vector(0, 0, 0)) + if length > best_length: + best = candidate + best_length = length + if best is not None and best_length > DEFAULT_NODE_TOLERANCE: + return _normalize(best) + + seed = App.Vector(1, 0, 0) + if abs(_dot(normal, seed)) > 0.9: + seed = App.Vector(0, 1, 0) + candidate = _subtract(seed, _scale(normal, _dot(seed, normal))) + return _normalize(candidate) + + +def _surface_face_grid_points(face, spacing, offset, margin): + normal = _normalize(_face_normal(face)) + if normal is None: + return [] + + face_points = _face_points(face) + origin = _face_origin(face, face_points) + if not face_points: return [] - normal_axis = _dominant_axis(normal) - if normal_axis is None: + u_axis = _face_u_axis(face, normal, face_points, origin) + if u_axis is None: + return [] + v_axis = _normalize(_cross(normal, u_axis)) + if v_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) + projected_u = [] + projected_v = [] + for point in face_points: + relative = _subtract(point, origin) + projected_u.append(_dot(relative, u_axis)) + projected_v.append(_dot(relative, v_axis)) - first_values = _surface_grid_values(first_min, first_max, spacing, margin) - second_values = _surface_grid_values(second_min, second_max, spacing, margin) + first_values = _surface_grid_values(min(projected_u), max(projected_u), spacing, margin) + second_values = _surface_grid_values(min(projected_v), max(projected_v), spacing, margin) if len(first_values) < 2 or len(second_values) < 2: return [] + plane_origin = _add(origin, _scale(normal, float(offset or 0.0))) 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) + point = _add( + _add(plane_origin, _scale(u_axis, first_value)), + _scale(v_axis, second_value), + ) row.append(point) rows.append(row) @@ -576,12 +1131,42 @@ def _surface_face_grid_points(face, spacing, offset, margin): return rows + columns +def _project_points_to_face(points, face, offset=DEFAULT_ROUTE_PATH_FACE_OFFSET): + normal = _normalize(_face_normal(face)) + if normal is None: + return list(points or []) + + face_points = _face_points(face) + origin = _face_origin(face, face_points) + distances = [_dot(_subtract(point, origin), normal) for point in points or []] + if not distances: + return [] + + # 保留线段原本所在的面侧,避免投影到板子的背面。 + average_distance = sum(distances) / float(len(distances)) + signed_offset = abs(float(offset or 0.0)) + if average_distance < 0.0: + signed_offset = -signed_offset + + projected = [] + for point, distance in zip(points, distances): + projected.append(_subtract(point, _scale(normal, distance - signed_offset))) + return projected + + def create_carriers_from_selection(doc, selection_ex, project_uuid="", kind=ROUTE_CARRIER_KIND): created = [] + support_face = _support_face_from_selection(selection_ex) 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 points = _points_from_selection_item(item) if len(points) < 2: continue + if support_face is not None: + # 如果同时选中了支撑面和草图/线段,先把草图点投影到支撑面的平面上。 + # Draft 自身只记录工作平面坐标,不会自动吸附到柜板面。 + points = _project_points_to_face(points, support_face) created.append( create_route_carrier( doc, @@ -666,6 +1251,320 @@ def _mark_wire_duct_source(source, carrier): pass +def _mark_support_surface_source(source, carriers): + if source is None or not carriers: + return + try: + TerminalObjects.ensure_string_property( + source, + "QetRoutingSourceKind", + PROPERTY_GROUP, + "Routing source kind", + ROUTE_CARRIER_KIND_ROUTING_RANGE, + ) + TerminalObjects.ensure_string_property( + source, + "QetRoutingObstacleMode", + PROPERTY_GROUP, + "How auto-routing collision checks should treat this object", + SUPPORT_SURFACE_OBSTACLE_MODE, + ) + TerminalObjects.ensure_string_property( + source, + "QetRouteCarrierName", + PROPERTY_GROUP, + "Generated route carrier for this source", + getattr(carriers[0], "Name", ""), + ) + except Exception: + pass + + +def _mark_terminal_access_source(source, carrier): + if source is None or carrier is None: + return + try: + TerminalObjects.ensure_string_property( + source, + "QetRoutingSourceKind", + PROPERTY_GROUP, + "Routing source kind", + ROUTE_CARRIER_KIND_TERMINAL_ACCESS, + ) + TerminalObjects.ensure_string_property( + source, + "QetRouteCarrierName", + PROPERTY_GROUP, + "Generated route carrier for this source", + getattr(carrier, "Name", ""), + ) + except Exception: + pass + + +def _live_source_carrier(doc, source): + carrier_name = (getattr(source, "QetRouteCarrierName", "") or "").strip() + if not carrier_name or doc is None: + return None + carrier = doc.getObject(carrier_name) + if carrier is not None and is_route_carrier(carrier): + return carrier + return None + + +def detect_wire_duct_sources(doc, min_aspect=DEFAULT_AUTO_WIRE_DUCT_MIN_ASPECT): + """Return document objects that look like wire ducts based on semantics/name and shape.""" + sources = [] + seen = set() + for obj in list(getattr(doc, "Objects", []) or []): + if id(obj) in seen: + continue + seen.add(id(obj)) + if _is_wire_duct_candidate(obj, min_aspect=min_aspect): + sources.append(obj) + return sources + + +def detect_support_surface_sources(doc): + """Return thin cabinet/panel objects that can provide low-priority support routes.""" + sources = [] + seen = set() + for obj in list(getattr(doc, "Objects", []) or []): + if id(obj) in seen: + continue + seen.add(id(obj)) + if _is_support_surface_candidate(obj): + sources.append(obj) + return sources + + +def create_wire_duct_carriers_from_document( + doc, + project_uuid="", + margin=DEFAULT_WIRE_DUCT_MARGIN, + min_aspect=DEFAULT_AUTO_WIRE_DUCT_MIN_ASPECT, +): + """Auto-detect wire duct objects in the document and create WireDuct centerlines.""" + created = [] + for index, source in enumerate(detect_wire_duct_sources(doc, min_aspect=min_aspect), start=1): + if _live_source_carrier(doc, source) is not None: + continue + 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 Auto 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_document( + doc, + project_uuid="", + spacing=DEFAULT_SURFACE_LANE_SPACING, + offset=DEFAULT_SURFACE_OFFSET, + margin=DEFAULT_SURFACE_MARGIN, +): + """Auto-detect thin support panels and create low-priority RoutingRange grids.""" + created = [] + for source in detect_support_surface_sources(doc): + if _live_source_carrier(doc, source) is not None: + continue + bbox = _bound_box_from_object(source) + if bbox is None: + continue + support_face = _support_face_from_bbox(bbox) + grids = _surface_face_grid_points( + support_face, + spacing=spacing, + offset=offset, + margin=margin, + ) + source_created = [] + label = getattr(source, "Label", "") or getattr(source, "Name", "") or "Support Surface" + for index, points in enumerate(grids, start=1): + if len(points) < 2: + continue + carrier = create_route_carrier( + doc, + points, + label="QET Auto Support Surface Route {0} {1}".format(label, index), + project_uuid=project_uuid, + kind=ROUTE_CARRIER_KIND_ROUTING_RANGE, + ) + source_created.append(carrier) + created.append(carrier) + _mark_support_surface_source(source, source_created) + return created + + +def _collect_routable_terminals(doc): + terminals = [] + seen = set() + + 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) + ) + + result = [] + for terminal in terminals: + if terminal is None or id(terminal) in seen: + continue + seen.add(id(terminal)) + result.append(terminal) + return result + + +def _terminal_exit_point(terminal, exit_length): + origin = _vector(TerminalObjects.terminal_origin(terminal)) + direction = _normalize(_vector(TerminalObjects.terminal_direction(terminal))) + if direction is None: + direction = App.Vector(0, 0, 1) + return _add(origin, _scale(direction, max(float(exit_length or 0.0), 0.0))) + + +def _orthogonal_access_points(start, end): + """Create a Manhattan path so access carriers can join the routing graph.""" + start = _vector(start) + end = _vector(end) + points = [start] + current = start + axes = sorted( + ("x", "y", "z"), + key=lambda axis: abs(_axis_value(end, axis) - _axis_value(start, axis)), + reverse=True, + ) + for axis in axes: + if abs(_axis_value(end, axis) - _axis_value(current, axis)) <= DEFAULT_NODE_TOLERANCE: + continue + current = _set_axis(current, axis, _axis_value(end, axis)) + if _distance(points[-1], current) > DEFAULT_NODE_TOLERANCE: + points.append(current) + if _distance(points[-1], end) > DEFAULT_NODE_TOLERANCE: + points.append(end) + return points + + +def create_terminal_access_carriers_from_document( + doc, + project_uuid="", + terminal_exit_length=20.0, + max_distance=DEFAULT_TERMINAL_ACCESS_MAX_DISTANCE, +): + """Connect every engineering terminal to the generated route network. + + EPLAN/SW 的一键布线不是让用户给每个端子手工画辅助线,而是先把端子 + 自动接入路由网络。这里生成短的 TerminalAccess carrier,后续 Dijkstra + 才能从端子入口进入线槽/布线面。 + """ + # TerminalAccess depends directly on current terminal placement, so regenerate it + # every time the layout space is prepared. This keeps one-click routing predictable + # after devices or terminals are moved in FreeCAD. + for carrier in list(collect_route_carriers(doc)): + if (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() != ROUTE_CARRIER_KIND_TERMINAL_ACCESS: + continue + _detach_from_groups(doc, carrier) + try: + if doc.getObject(getattr(carrier, "Name", "")) is not None: + doc.removeObject(carrier.Name) + except Exception: + pass + try: + doc.recompute() + except Exception: + pass + + network = build_route_graph(doc) + if network.get("segment_count", 0) <= 0: + return [] + + nodes = network.get("nodes", {}) or {} + created = [] + 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) + nearest_key, distance = nearest_node(network, exit_point) + if nearest_key is None: + continue + if max_distance and float(distance or 0.0) > float(max_distance): + continue + if float(distance or 0.0) <= DEFAULT_NODE_TOLERANCE: + continue + + points = _orthogonal_access_points(exit_point, nodes[nearest_key]) + if len(points) < 2: + continue + label = getattr(terminal, "Label", "") or getattr(terminal, "Name", "") or "Terminal" + carrier = create_route_carrier( + doc, + points, + label="QET Auto Terminal Access {0}".format(label), + project_uuid=project_uuid, + kind=ROUTE_CARRIER_KIND_TERMINAL_ACCESS, + ) + _mark_terminal_access_source(terminal, carrier) + created.append(carrier) + return created + + +def create_layout_space_from_document( + doc, + project_uuid="", + terminal_exit_length=20.0, + terminal_access_max_distance=DEFAULT_TERMINAL_ACCESS_MAX_DISTANCE, +): + """Prepare routing layout space from the full 3D assembly. + + This is the production/EPLAN-style path: use the FreeCAD document as the + layout-space source, not selected faces or Draft sketches. + """ + wire_ducts = create_wire_duct_carriers_from_document( + doc, + project_uuid=project_uuid, + ) + surfaces = create_surface_carriers_from_document( + doc, + project_uuid=project_uuid, + ) + terminal_access = create_terminal_access_carriers_from_document( + doc, + project_uuid=project_uuid, + terminal_exit_length=terminal_exit_length, + max_distance=terminal_access_max_distance, + ) + return { + "wire_duct_carriers": len(wire_ducts), + "surface_carriers": len(surfaces), + "terminal_access_carriers": len(terminal_access), + "network": network_summary(doc), + } + + def create_wire_duct_carriers_from_selection( doc, selection_ex, @@ -710,6 +1609,7 @@ def create_surface_carriers_from_selection( """Create a supported route grid on selected planar cabinet/panel faces.""" created = [] for item in selection_ex or []: + item_created = [] for sub_object in list(getattr(item, "SubObjects", []) or []): shape_type = (getattr(sub_object, "ShapeType", "") or "").lower() if shape_type != "face": @@ -730,7 +1630,41 @@ def create_surface_carriers_from_selection( project_uuid=project_uuid, kind=ROUTE_CARRIER_KIND_ROUTING_RANGE, ) + item_created.append(carrier) created.append(carrier) + if item_created: + continue + + obj = getattr(item, "Object", None) + if not _is_support_surface_candidate(obj): + continue + if _live_source_carrier(doc, obj) is not None: + continue + bbox = _bound_box_from_object(obj) + if bbox is None: + continue + support_face = _support_face_from_bbox(bbox) + grids = _surface_face_grid_points( + support_face, + spacing=spacing, + offset=offset, + margin=margin, + ) + source_created = [] + label = getattr(obj, "Label", "") or getattr(obj, "Name", "") or "Support Surface" + for index, points in enumerate(grids, start=1): + if len(points) < 2: + continue + carrier = create_route_carrier( + doc, + points, + label="QET Surface Route {0} {1}".format(label, index), + project_uuid=project_uuid, + kind=ROUTE_CARRIER_KIND_ROUTING_RANGE, + ) + source_created.append(carrier) + created.append(carrier) + _mark_support_surface_source(obj, source_created) return created @@ -745,12 +1679,15 @@ def _carrier_cost_factor(carrier, kind_cost_factors=None): return 1.0 -def build_route_graph(doc, tolerance=DEFAULT_NODE_TOLERANCE): +def build_route_graph(doc, tolerance=DEFAULT_NODE_TOLERANCE, blocked_bboxes=None): """Build an undirected graph from every enabled route carrier.""" nodes = {} edges = {} carriers = collect_route_carriers(doc) segment_count = 0 + blocked_segment_count = 0 + blocked_bboxes = list(blocked_bboxes or []) + segments = [] def ensure_node(point): key = _point_key(point, tolerance=tolerance) @@ -763,13 +1700,60 @@ def build_route_graph(doc, tolerance=DEFAULT_NODE_TOLERANCE): points = _carrier_points(carrier) if len(points) < 2: continue - previous_key = ensure_node(points[0]) + for index in range(len(points) - 1): + start = points[index] + end = points[index + 1] + if _distance(start, end) <= tolerance: + continue + segments.append( + { + "carrier": carrier, + "start": start, + "end": end, + "points": [start, end], + } + ) + + # Several wire ducts often touch or cross geometrically without sharing endpoint + # coordinates. Split those carrier segments at the intersection points so Dijkstra + # can change direction there, which matches CAD routing path behavior. + for left_index in range(len(segments)): + left = segments[left_index] + for right in segments[left_index + 1:]: + intersections = _orthogonal_segment_intersections( + left["start"], + left["end"], + right["start"], + right["end"], + tolerance=tolerance, + ) + if not intersections: + continue + left["points"].extend(intersections) + right["points"].extend(intersections) + + for segment in segments: + ordered = _sorted_segment_points( + segment["start"], + segment["end"], + segment["points"], + tolerance=tolerance, + ) + if len(ordered) < 2: + continue + previous_key = ensure_node(ordered[0]) previous_point = nodes[previous_key] - for point in points[1:]: + for point in ordered[1:]: current_key = ensure_node(point) current_point = nodes[current_key] weight = _distance(previous_point, current_point) if weight > tolerance: + if _segment_hits_blocked_bbox(previous_point, current_point, blocked_bboxes): + blocked_segment_count += 1 + previous_key = current_key + previous_point = current_point + continue + carrier = segment["carrier"] edges[previous_key].append((current_key, weight, carrier)) edges[current_key].append((previous_key, weight, carrier)) segment_count += 1 @@ -782,6 +1766,7 @@ def build_route_graph(doc, tolerance=DEFAULT_NODE_TOLERANCE): "carriers": carriers, "carrier_count": len(carriers), "segment_count": segment_count, + "blocked_segment_count": blocked_segment_count, "tolerance": tolerance, } @@ -861,6 +1846,7 @@ def network_summary(doc): return { "carriers": int(network.get("carrier_count", 0)), "segments": int(network.get("segment_count", 0)), + "blocked_segments": int(network.get("blocked_segment_count", 0)), "nodes": len(network.get("nodes", {})), "kinds": kinds, } diff --git a/src/Mod/FreeCADExchange/TemplateSemantics.py b/src/Mod/FreeCADExchange/TemplateSemantics.py index 9eb7c03..b532df0 100644 --- a/src/Mod/FreeCADExchange/TemplateSemantics.py +++ b/src/Mod/FreeCADExchange/TemplateSemantics.py @@ -485,4 +485,10 @@ def resolve_terminal_slots(device_group, model_path, desired_count): break slots.append(slot) + if len(slots) < desired_count: + needed = desired_count - len(slots) + fallback = build_fallback_terminal_slots(device_group, needed) + if fallback: + slots.extend(fallback) + return slots[:desired_count] diff --git a/tests/python/freecad_exchange_auto_routing_test.py b/tests/python/freecad_exchange_auto_routing_test.py index a46edee..30e9613 100644 --- a/tests/python/freecad_exchange_auto_routing_test.py +++ b/tests/python/freecad_exchange_auto_routing_test.py @@ -65,6 +65,7 @@ class FakeViewObject: self.Visibility = True self.LineWidth = None self.LineColor = None + self.DrawStyle = None class FakeObject: @@ -158,9 +159,11 @@ class FakeEdge: class FakeFace: ShapeType = "Face" - def __init__(self, bbox, normal): + def __init__(self, bbox, normal, vertices=None, center=None): self.BoundBox = bbox self._normal = normal + self.Vertexes = [FakeVertex(point) for point in (vertices or [])] + self.CenterOfMass = center def normalAt(self, _u, _v): return self._normal @@ -176,6 +179,7 @@ class FakeSelectionItem: def _reload_modules(): for name in [ "TerminalObjects", + "TemplateSemantics", "WiringObjects", "RoutingNetwork", "AutoRouting", @@ -266,6 +270,96 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual("Routed", result["route_status"]) self.assertTrue(any(point.y == 30.0 for point in result["points"])) + def test_route_carrier_styles_make_generated_objects_distinguishable(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + + wire_duct = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_range = routing_network.create_route_carrier( + doc, + [app.Vector(0, 10, 0), app.Vector(100, 10, 0)], + project_uuid="project-1", + kind="RoutingRange", + ) + terminal_access = routing_network.create_route_carrier( + doc, + [app.Vector(0, 20, 0), app.Vector(100, 20, 0)], + project_uuid="project-1", + kind="TerminalAccess", + ) + + self.assertEqual((1.0, 0.55, 0.0), wire_duct.ViewObject.LineColor) + self.assertEqual(4.0, wire_duct.ViewObject.LineWidth) + self.assertEqual((0.0, 0.65, 0.35), routing_range.ViewObject.LineColor) + self.assertEqual("Solid", routing_range.ViewObject.DrawStyle) + self.assertEqual((0.65, 0.2, 1.0), terminal_access.ViewObject.LineColor) + self.assertEqual("Solid", terminal_access.ViewObject.DrawStyle) + + def test_route_graph_connects_crossing_carriers_at_intersection(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(50, 50, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(50, -50, 0), app.Vector(50, 50, 0)], + project_uuid="project-1", + kind="WireDuct", + ) + + network = routing_network.build_route_graph(doc) + result = auto_routing.route_between_terminals(doc, start, end) + + self.assertEqual(5, len(network["nodes"])) + self.assertEqual("network-dijkstra-v1", result["algorithm"]) + self.assertIn((50.0, 0.0, 0.0), [(point.x, point.y, point.z) for point in result["points"]]) + + def test_route_graph_connects_overlapping_collinear_carriers(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(80, 0, 0)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(40, 0, 0), app.Vector(120, 0, 0)], + project_uuid="project-1", + kind="WireDuct", + ) + + network = routing_network.build_route_graph(doc) + result = auto_routing.route_between_terminals(doc, start, end) + + self.assertEqual("network-dijkstra-v1", result["algorithm"]) + self.assertIn((40.0, 0.0, 0.0), [(point.x, point.y, point.z) for point in result["points"]]) + self.assertIn((80.0, 0.0, 0.0), [(point.x, point.y, point.z) for point in result["points"]]) + self.assertGreaterEqual(network["segment_count"], 3) + def test_auto_route_prefers_wire_duct_over_auxiliary_range(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() @@ -325,6 +419,277 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual("network-dijkstra-v1", result["algorithm"]) self.assertTrue(any(point.z == 4.0 for point in result["points"])) + def test_auto_detect_support_surface_creates_routing_range(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + panel = doc.addObject("Part::Feature", "MountingPlateA") + panel.Label = "安装板A" + panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) + cabinet = doc.addObject("Part::Feature", "Cabinet") + cabinet.Label = "3D机柜" + cabinet.Shape = FakeShape(FakeBoundBox(0, 300, 0, 80, 0, 400)) + duct = doc.addObject("Part::Feature", "WireDuctA") + duct.Label = "Wire Duct A" + duct.Shape = FakeShape(FakeBoundBox(0, 120, -10, 10, 5, 25)) + + created = routing_network.create_surface_carriers_from_document( + doc, + project_uuid="project-1", + spacing=60.0, + offset=5.0, + margin=0.0, + ) + created_again = routing_network.create_surface_carriers_from_document( + doc, + project_uuid="project-1", + spacing=60.0, + offset=5.0, + margin=0.0, + ) + + self.assertEqual(6, len(created)) + self.assertEqual(0, len(created_again)) + self.assertTrue(all(carrier.QetRouteCarrierKind == "RoutingRange" for carrier in created)) + self.assertEqual("RoutingRange", panel.QetRoutingSourceKind) + self.assertEqual("SupportSurface", panel.QetRoutingObstacleMode) + self.assertFalse(hasattr(cabinet, "QetRoutingSourceKind")) + self.assertFalse(hasattr(duct, "QetRoutingSourceKind")) + + def test_auto_route_can_use_auto_detected_support_surface(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 10, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 10, 0)) + panel = doc.addObject("Part::Feature", "MountingPlateA") + panel.Label = "Mounting Plate A" + panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) + + created = routing_network.create_surface_carriers_from_document( + doc, + project_uuid="project-1", + spacing=60.0, + offset=5.0, + margin=0.0, + ) + result = auto_routing.route_between_terminals(doc, start, end) + + self.assertGreater(len(created), 0) + self.assertEqual("network-dijkstra-v1", result["algorithm"]) + self.assertEqual("Routed", result["route_status"]) + self.assertEqual(0, result["collision_count"]) + self.assertTrue(any(point.y == 10.0 for point in result["points"])) + + def test_generate_layout_space_auto_detects_support_surface(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + panel = doc.addObject("Part::Feature", "MountingPlateA") + panel.Label = "Mounting Plate A" + panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) + + result = auto_routing_panel.AutoRoutingController().generate_layout_space() + + self.assertGreater(result["surface_carriers"], 0) + self.assertEqual("document", result["source_mode"]) + + def test_generate_routing_paths_uses_selected_wire_duct_entity(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + gui = sys.modules["FreeCADGui"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + duct = doc.addObject("Part::Feature", "UnlabeledLongDuct") + duct.Shape = FakeShape(FakeBoundBox(0, 160, -10, 10, 0, 20)) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=duct)], + ) + + result = auto_routing_panel.AutoRoutingController().generate_routing_paths() + + self.assertEqual(1, result["wire_duct_carriers"]) + self.assertEqual("selection", result["source_mode"]) + + def test_generate_layout_space_uses_whole_document_not_selected_face_workflow(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + gui = sys.modules["FreeCADGui"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + panel = doc.addObject("Part::Feature", "MountingPlateA") + panel.Label = "Mounting Plate A" + panel.Shape = FakeShape(FakeBoundBox(0, 120, 0, 5, 0, 100)) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=panel)], + ) + + result = auto_routing_panel.AutoRoutingController().generate_layout_space() + + self.assertGreater(result["surface_carriers"], 0) + self.assertEqual("document", result["source_mode"]) + + def test_generate_layout_space_adds_terminal_access_to_route_network(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + duct = doc.addObject("Part::Feature", "WireDuctA") + duct.Label = "Wire Duct A" + duct.Shape = FakeShape(FakeBoundBox(0, 100, -5, 5, 15, 25)) + + result = auto_routing_panel.AutoRoutingController().generate_layout_space() + result_again = auto_routing_panel.AutoRoutingController().generate_layout_space() + access_carriers = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if getattr(carrier, "QetRouteCarrierKind", "") == "TerminalAccess" + ] + + self.assertEqual(1, result["wire_duct_carriers"]) + self.assertEqual(2, result["terminal_access_carriers"]) + self.assertEqual(0, result_again["wire_duct_carriers"]) + self.assertEqual(2, result_again["terminal_access_carriers"]) + self.assertEqual(2, len(access_carriers)) + self.assertGreater(result["network"]["segments"], 0) + + def test_generate_layout_space_skips_far_terminal_access_to_protect_view_bbox(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + duct = doc.addObject("Part::Feature", "WireDuctFar") + duct.Label = "Wire Duct Far" + duct.Shape = FakeShape(FakeBoundBox(5000, 5100, -5, 5, 15, 25)) + + result = auto_routing_panel.AutoRoutingController().generate_layout_space() + + self.assertEqual(1, result["wire_duct_carriers"]) + self.assertEqual(0, result["terminal_access_carriers"]) + + def test_route_all_prepares_layout_space_like_one_click_routing(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + duct = doc.addObject("Part::Feature", "WireDuctA") + duct.Label = "Wire Duct A" + duct.Shape = FakeShape(FakeBoundBox(0, 100, -5, 5, 15, 25)) + app._qet_exchange_payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing_panel.AutoRoutingController().route_all() + + self.assertEqual(1, report["routed"]) + self.assertEqual(1, report["prepared_layout"]["wire_duct_carriers"]) + self.assertEqual(2, report["prepared_layout"]["terminal_access_carriers"]) + + def test_auto_route_rejects_far_network_entry_to_avoid_huge_render_bbox(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(5000, 0, 20), app.Vector(5100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + + with self.assertRaises(auto_routing.AutoRoutingError): + auto_routing.route_between_terminals(doc, start, end) + + def test_route_between_terminals_fails_without_network(self): + _install_fake_freecad() + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 30, 0)) + + with self.assertRaises(auto_routing.AutoRoutingError): + auto_routing.route_between_terminals(doc, start, end) + self.assertEqual(0, len(wiring_objects.iter_routed_wire_objects(doc))) + + def test_surface_carrier_grid_uses_actual_rotated_face_plane(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + normal = app.Vector(0, 1, 1) + vertices = [ + app.Vector(0, 0, 0), + app.Vector(100, 0, 0), + app.Vector(0, 50, -50), + app.Vector(100, 50, -50), + ] + face = FakeFace( + FakeBoundBox(0, 100, 0, 50, -50, 0), + normal, + vertices=vertices, + center=app.Vector(50, 25, -25), + ) + + created = routing_network.create_surface_carriers_from_selection( + doc, + [FakeSelectionItem([face])], + project_uuid="project-1", + spacing=50.0, + offset=10.0, + margin=0.0, + ) + + self.assertGreater(len(created), 0) + first_point = created[0].Points[0] + for carrier in created: + for point in carrier.Points: + # The rotated face is y + z = 0; after a 10 mm normal offset, + # all generated points must stay on one parallel plane. + self.assertAlmostEqual(first_point.y + first_point.z, point.y + point.z, places=6) + def test_route_path_creation_ignores_whole_solid_object_edges(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() @@ -346,6 +711,34 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual([], created) + def test_route_path_creation_projects_line_to_selected_face(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + face = FakeFace( + FakeBoundBox(0, 100, 0, 100, 0, 0), + app.Vector(0, 0, 1), + ) + draft_line = doc.addObject("Part::Feature", "DraftLine") + draft_line.Shape = FakeShape( + FakeBoundBox(10, 90, 10, 90, 25, 35), + edges=[FakeEdge(app.Vector(10, 10, 25), app.Vector(90, 90, 35))], + ) + + created = routing_network.create_carriers_from_selection( + doc, + [ + FakeSelectionItem([face]), + FakeSelectionItem(obj=draft_line), + ], + project_uuid="project-1", + ) + + self.assertEqual(1, len(created)) + self.assertEqual([2.0, 2.0], [point.z for point in created[0].Points]) + def test_wire_duct_entity_generates_centerline_and_marks_source_pass_through(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() @@ -367,6 +760,33 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual("PassThrough", duct.QetRoutingObstacleMode) self.assertEqual([(20.0, 0.0, 15.0), (100.0, 0.0, 15.0)], [(p.x, p.y, p.z) for p in carrier.Points]) + def test_auto_detect_wire_ducts_ignores_cabinet_models(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + duct = doc.addObject("Part::Feature", "WireDuctA") + duct.Label = "线槽A" + duct.Shape = FakeShape(FakeBoundBox(0, 120, -10, 10, 5, 25)) + cabinet = doc.addObject("Part::Feature", "Cabinet") + cabinet.Label = "3D机柜" + cabinet.Shape = FakeShape(FakeBoundBox(0, 300, 0, 80, 0, 400)) + + created = routing_network.create_wire_duct_carriers_from_document( + doc, + project_uuid="project-1", + ) + created_again = routing_network.create_wire_duct_carriers_from_document( + doc, + project_uuid="project-1", + ) + + self.assertEqual(1, len(created)) + self.assertEqual(0, len(created_again)) + self.assertEqual("WireDuct", created[0].QetRouteCarrierKind) + self.assertEqual("PassThrough", duct.QetRoutingObstacleMode) + self.assertFalse(hasattr(cabinet, "QetRoutingObstacleMode")) + def test_wire_duct_source_is_not_reported_as_collision(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() @@ -390,6 +810,43 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual("Routed", result["route_status"]) self.assertEqual(0, result["collision_count"]) + def test_auto_route_uses_alternate_carrier_to_avoid_obstacle(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [ + app.Vector(0, 0, 20), + app.Vector(0, 50, 20), + app.Vector(100, 50, 20), + app.Vector(100, 0, 20), + ], + project_uuid="project-1", + kind="WireDuct", + ) + obstacle = doc.addObject("Part::Feature", "CabinetObstacle") + obstacle.Shape = FakeShape(FakeBoundBox(40, 60, -10, 10, 15, 25)) + + result = auto_routing.route_between_terminals(doc, start, end) + + self.assertEqual("network-dijkstra-v1", result["algorithm"]) + self.assertEqual("Routed", result["route_status"]) + self.assertEqual(0, result["collision_count"]) + self.assertTrue(result["network"]["obstacle_aware"]) + self.assertGreaterEqual(result["network"]["blocked_segments"], 1) + self.assertIn(50.0, [point.y for point in result["points"]]) + def test_auto_route_marks_collision_warning_against_obstacle_bbox(self): _install_fake_freecad() terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() @@ -433,6 +890,179 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(0, report["routed"]) self.assertEqual(1, report["skipped_missing_terminal"]) + self.assertEqual(1, report["available_terminals"]) + self.assertEqual(0, report["local_terminals"]) + self.assertEqual(["terminal-missing"], report["missing_endpoint_uuids"]) + self.assertEqual("terminal-start", report["missing_endpoint_samples"][0]["start_terminal_uuid"]) + self.assertTrue(report["missing_endpoint_samples"][0]["start_found"]) + self.assertFalse(report["missing_endpoint_samples"][0]["end_found"]) + + def test_route_all_report_calls_out_local_unbound_terminals(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal( + doc, + terminal_objects, + "LocalTerminal", + "local:instance-1:p1", + app.Vector(0, 0, 0), + ) + payload = { + "wires": [ + { + "wire_id": "wire-1", + "start_terminal_uuid": "qet-terminal-start", + "end_terminal_uuid": "qet-terminal-end", + } + ] + } + + report = auto_routing.route_all_from_payload(doc, payload) + message = auto_routing.format_route_all_report(report) + + self.assertEqual(0, report["routed"]) + self.assertEqual(1, report["available_terminals"]) + self.assertEqual(1, report["local_terminals"]) + self.assertIn("端子匹配失败", message) + self.assertIn("local:", message) + + def test_bind_wire_task_terminals_from_payload_does_not_create_wires(self): + _install_fake_freecad() + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + root = terminal_objects.ensure_root_group(doc, "project-1") + device = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_a") + root.addObject(device) + terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "device-a") + terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "instance-a") + terminal_objects.ensure_string_property(device, "QetProjectUuid", "QET Exchange", "", "project-1") + terminal_group = terminal_objects.ensure_terminal_group( + doc, + device, + project_uuid="project-1", + instance_id="instance-a", + ) + + for slot_name, point in ( + ("P1", app.Vector(0, 0, 0)), + ("P2", app.Vector(100, 0, 0)), + ): + terminal = terminal_objects.create_lcs_object( + doc, + "QETTerminal_instance_a_{0}".format(slot_name), + placement=app.Placement(point, app.Rotation()), + label=slot_name, + ) + terminal_group.addObject(terminal) + terminal_objects.set_terminal_semantics( + terminal, + "project-1", + "device-a", + "local:instance-a:{0}".format(slot_name), + "instance-a", + label=slot_name, + slot_name=slot_name, + ) + + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "start_element_uuid": "device-a", + "start_instance_id": "instance-a", + "start_terminal_uuid": "qet-terminal-p1", + "start_terminal_display": "P1", + "end_element_uuid": "device-a", + "end_instance_id": "instance-a", + "end_terminal_uuid": "qet-terminal-p2", + "end_terminal_display": "P2", + } + ], + } + + report = auto_routing.bind_wire_task_terminals_from_payload(doc, payload) + indexed = auto_routing.index_terminals(doc) + + self.assertEqual(2, report["bound"]) + self.assertEqual(0, report["created"]) + self.assertEqual(0, report["local_terminals"]) + self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc)) + self.assertEqual("qet", indexed["qet-terminal-p1"].QetTerminalBindingMode) + + def test_route_all_rebinds_local_template_terminals_from_wire_endpoints(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + root = terminal_objects.ensure_root_group(doc, "project-1") + device = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_a") + root.addObject(device) + terminal_objects.ensure_string_property(device, "QetElementUuid", "QET Exchange", "", "device-a") + terminal_objects.ensure_string_property(device, "QetInstanceId", "QET Exchange", "", "instance-a") + terminal_objects.ensure_string_property(device, "QetProjectUuid", "QET Exchange", "", "project-1") + terminal_group = terminal_objects.ensure_terminal_group( + doc, + device, + project_uuid="project-1", + instance_id="instance-a", + ) + + for slot_name, point in ( + ("P1", app.Vector(0, 0, 0)), + ("P2", app.Vector(100, 0, 0)), + ): + terminal = terminal_objects.create_lcs_object( + doc, + "QETTerminal_instance_a_{0}".format(slot_name), + placement=app.Placement(point, app.Rotation()), + label=slot_name, + ) + terminal_group.addObject(terminal) + terminal_objects.set_terminal_semantics( + terminal, + "project-1", + "device-a", + "local:instance-a:{0}".format(slot_name), + "instance-a", + label=slot_name, + slot_name=slot_name, + ) + + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "start_element_uuid": "device-a", + "start_instance_id": "instance-a", + "start_terminal_uuid": "qet-terminal-p1", + "start_terminal_display": "P1", + "end_element_uuid": "device-a", + "end_instance_id": "instance-a", + "end_terminal_uuid": "qet-terminal-p2", + "end_terminal_display": "P2", + } + ], + } + + report = auto_routing.route_all_from_payload( + doc, + payload, + options={"allow_floating_fallback": True}, + ) + indexed = auto_routing.index_terminals(doc) + + self.assertEqual(1, report["routed"]) + self.assertEqual(2, report["auto_bound_terminals"]) + self.assertEqual(0, report["local_terminals"]) + self.assertIn("qet-terminal-p1", indexed) + self.assertIn("qet-terminal-p2", indexed) + self.assertEqual("qet", indexed["qet-terminal-p1"].QetTerminalBindingMode) def test_clear_route_carriers_keeps_routed_wires(self): _install_fake_freecad()