diff --git a/docs/FreeCAD 3D自动布线设计方案.md b/docs/FreeCAD 3D自动布线设计方案.md new file mode 100644 index 0000000..64fd820 --- /dev/null +++ b/docs/FreeCAD 3D自动布线设计方案.md @@ -0,0 +1,515 @@ +# FreeCAD 3D 自动布线设计方案 + +本文档描述 QET / LightWork3D 与 FreeCAD 协同中的 3D 自动布线设计。 + +当前版本目标不是完整复刻 EPLAN Pro Panel 或 SOLIDWORKS Electrical,而是先完成一个可用的最小闭环: + +```text +工程端子 +-> 路由路径网络 +-> 自动求路 +-> 生成 3D 折线导线 +-> 可保存到 FreeCAD 文档 +``` + +## 1. 设计边界 + +### 1.1 当前版本目标 + +当前版本只要求完成“能够自动布线”: + +1. 能识别 FreeCAD 文档中的工程端子。 +2. 能从用户指定的线槽、草图、面域创建路由网络。 +3. 能在两个端子之间自动生成 3D 折线导线。 +4. 能批量处理 QET 导入的导线任务。 +5. 能绕开或至少发现明显障碍碰撞。 +6. 默认禁止长距离悬空线。 + +### 1.2 当前版本不做 + +当前版本不做以下工程级能力: + +1. 不修改 FreeCAD C++ 源码。 +2. 不写入或依赖旧 3D 场景数据库表。 +3. 不把 3D 位姿、3D 路径点写入数据库。 +4. 不做完整线槽容量计算。 +5. 不做线径、弯曲半径、线束层叠排列。 +6. 不做强弱电隔离、EMC、屏蔽线等电气规则。 +7. 不保证达到 EPLAN / SOLIDWORKS Electrical 的完整工程级自动布线效果。 + +### 1.3 数据库约束 + +第一版严格遵守 `docs/数据库设计.md`: + +1. 设备绑定只依赖: + +```text +project_2d3d_symbol_binding: + project_uuid + element_uuid + instance_id +``` + +2. 端子绑定只依赖: + +```text +project_2d3d_terminal_binding: + project_uuid + terminal_uuid + instance_id +``` + +3. 3D 端子绑定唯一依据是: + +```text +terminal_uuid +``` + +4. 3D 位姿、装配关系、布线几何以 FreeCAD 文档为准。 + +## 2. 总体方案 + +自动布线不直接在任意 3D 空间里找线,也不会把所有端子任意两两连接。它参考 EPLAN / SOLIDWORKS 的思路,先建立“可走路径网络”,再按 QET 导线任务中的起点端子和终点端子逐条求路。 + +```text +端子出线段 + -> 进入最近路由节点 + -> 在线槽/路由路径网络中求最短路 + -> 从路由网络出来 + -> 进入目标端子 +``` + +路由网络由 `carrier` 组成。一个 carrier 表示一段可走线中心线或辅助路由区域中的一条网格线。 + +### 2.1 路由优先级 + +当前版本按下面优先级处理: + +1. `WireDuct`:线槽中心路径,最高优先级。 +2. `RoutingPath`:用户画的草图线、Draft Wire、明确选中的路由边。 +3. `AuxiliaryPath`:辅助路径,后续扩展使用。 +4. `RoutingRange`:选中面生成的辅助路由区域,成本较高,只用于过渡或没有线槽时兜底。 + +普通机柜、设备外壳、实体边默认不是路由路径。 + +## 3. FreeCAD 对象语义 + +### 3.1 工程端子 + +工程端子是可布线起点和终点,必须满足: + +```text +Role = "Terminal" +CanWire = true +QetTerminalUuid = <2D terminal_uuid> +QetInstanceId = <3D instance_id> +QetProjectUuid = +``` + +端子空间位置来自 FreeCAD 文档。自动布线使用端子的全局坐标和方向。 + +### 3.2 路由路径 Carrier + +路由路径对象使用以下语义属性: + +```text +QetRoutingRole = "RoutingCarrier" +QetRouteCarrierKind = "WireDuct" | "RoutingPath" | "AuxiliaryPath" | "RoutingRange" +CanRouteWire = true +QetProjectUuid = +Points = [Vector, Vector, ...] +``` + +carrier 统一放在: + +```text +QETWiring + 02_Carriers +``` + +### 3.3 自动导线 + +自动生成的导线放在: + +```text +QETWiring + 04_Routed +``` + +自动导线语义: + +```text +RouteType = "AutoSuggested" +RouteMode = "Auto" +RouteStatus = "Routed" | "CollisionWarning" +QetStartTerminalUuid +QetEndTerminalUuid +QetAutoRouteAlgorithm +QetAutoRouteDiagnosticsJson +``` + +### 3.4 线槽实体 + +当用户选择线槽实体并执行“从线槽实体生成中心路径”后: + +1. 系统按包围盒长轴生成 `WireDuct` 中心路径。 +2. 原线槽实体标记为可穿线路由源。 +3. 碰撞检测跳过该线槽实体本身,避免“线在槽内却撞到线槽外壳”的误报。 + +线槽实体属性: + +```text +QetRoutingSourceKind = "WireDuct" +QetRoutingObstacleMode = "PassThrough" +QetRouteCarrierName = +``` + +## 4. 算法设计 + +### 4.1 路由网络构建 + +模块: + +```text +src/Mod/FreeCADExchange/RoutingNetwork.py +``` + +构建步骤: + +1. 收集所有 `QetRoutingRole = RoutingCarrier` 的对象。 +2. 读取 carrier 的 `Points`。 +3. 将相同或接近坐标的点合并为图节点。 +4. 将相邻点之间的线段建立为图边。 +5. 每条边记录所属 carrier,用于计算路径成本。 + +### 4.2 最短路算法 + +当前使用: + +```text +Dijkstra + bend_penalty +``` + +路径成本: + +```text +edge_cost = segment_length * carrier_kind_factor + bend_penalty +``` + +其中: + +```text +WireDuct = 1.0 +RoutingPath = 1.0 +AuxiliaryPath = 2.0 +RoutingRange = 8.0 +``` + +这使算法优先走线槽和明确路由路径,尽量少走辅助面域。 + +### 4.3 端子接入 + +模块: + +```text +src/Mod/FreeCADExchange/AutoRouting.py +``` + +流程: + +1. 读取起点端子和终点端子的全局坐标。 +2. 按端子方向生成短出线段。 +3. 找到距离起点出线点最近的路由节点。 +4. 找到距离终点出线点最近的路由节点。 +5. 在路由网络中执行 Dijkstra。 +6. 将端子出线段、网络路径、终点入线段拼成最终折线。 + +当前算法名: + +```text +network-dijkstra-v1 +``` + +### 4.4 悬空线策略 + +当前版本默认: + +```text +allow_floating_fallback = false +``` + +也就是说,如果没有可用路由网络,系统不会生成长距离悬空线,而是提示用户先创建线槽路径、草图路径或辅助路由区域。 + +调试时可以显式启用 fallback,但正式使用不建议开启。 + +### 4.5 障碍物处理 + +当前版本使用 AABB 包围盒做碰撞诊断: + +1. 收集 FreeCAD 文档中的几何对象。 +2. 排除端子、carrier、已布线导线、原点辅助对象。 +3. 排除标记为 `QetRoutingObstacleMode = PassThrough` 的线槽实体。 +4. 检查导线线段是否与障碍包围盒相交。 +5. 如果有碰撞,导线状态设为: + +```text +RouteStatus = "CollisionWarning" +``` + +当前版本是“碰撞诊断”,不是完整自由空间避障。 + +## 5. 当前已完成 + +### 5.1 Python 模块 + +已实现: + +```text +src/Mod/FreeCADExchange/RoutingNetwork.py +src/Mod/FreeCADExchange/AutoRouting.py +src/Mod/FreeCADExchange/AutoRoutingPanel.py +``` + +并已加入: + +```text +src/Mod/FreeCADExchange/CMakeLists.txt +src/Mod/FreeCADExchange/InitGui.py +``` + +### 5.2 路由网络功能 + +已完成: + +1. 从选中草图线、Draft Wire、明确选中的边创建 `RoutingPath`。 +2. 从选中线槽实体生成 `WireDuct` 中心路径。 +3. 从选中面生成 `RoutingRange` 辅助路由区域。 +4. 清除已生成路由路径。 +5. 扫描并统计路由网络。 + +### 5.3 自动布线功能 + +已完成: + +1. 两个选中端子之间自动布线。 +2. 根据导线任务批量自动布线。 +3. 使用 Dijkstra 求路。 +4. 支持转弯惩罚。 +5. 支持 carrier 类型成本。 +6. 默认禁止长距离悬空线。 +7. 碰撞检测和 `CollisionWarning` 状态。 +8. 自动导线可见显示并保存到 FreeCAD 文档。 + +### 5.4 FreeCAD 面板 + +面板入口: + +```text +QET模板 -> 3D自动布线 +``` + +当前按钮: + +```text +扫描端子/网络 +从线槽实体生成中心路径 +从线槽/草图创建路由路径 +从选中面创建辅助路由区域 +测试布线选中两个端子 +按导线任务自动布线全部 +清除自动布线 +清除走线路径 +保存 +``` + +### 5.5 测试 + +已完成单元测试: + +```text +tests/python/freecad_exchange_auto_routing_test.py +``` + +覆盖: + +1. 无路由网络时默认拒绝悬空线。 +2. 显式调试 fallback 时生成正交路径。 +3. 优先使用用户路由网络。 +4. 优先使用 `WireDuct`,降低 `RoutingRange` 优先级。 +5. 从选中面生成辅助路由区域。 +6. 选中整个实体不会把所有外壳边转成路径。 +7. 从线槽实体生成中心路径。 +8. 线槽实体不作为自身路径碰撞障碍。 +9. 碰撞状态标记。 +10. 批量导线任务缺端子时跳过。 +11. 清除路由路径不删除已布线导线。 + +已完成 FreeCAD smoke: + +```text +tests/manual/freecad_auto_routing_smoke.py +``` + +## 6. 当前使用流程 + +### 6.1 单条导线自动布线 + +```text +1. 打开 FreeCAD 工程 scene.FCStd +2. 进入 QET模板 -> 3D自动布线 +3. 清除走线路径 +4. 选中线槽实体 +5. 点击“从线槽实体生成中心路径” +6. 必要时选草图线或 Draft Wire,点击“从线槽/草图创建路由路径” +7. 必要时选背板/门板面,点击“从选中面创建辅助路由区域” +8. 选中两个工程端子 +9. 点击“测试布线选中两个端子” +``` + +### 6.2 批量导线自动布线 + +前提: + +1. QET 导出的 `2d_to_3d.json` 中包含 `wires[]`。 +2. 每条导线包含: + +```text +start_terminal_uuid +end_terminal_uuid +``` + +3. FreeCAD 文档中存在对应 `QetTerminalUuid` 的工程端子。 + +操作: + +```text +1. 创建线槽/路由网络 +2. 点击“按导线任务自动布线全部” +``` + +注意:批量自动布线的依据是导线任务,不是“所有端子自动互连”。如果文档中只有端子而没有 `wires[]` 或 `QETWiring_01_Tasks`,系统不能判断哪些端子应该连接。 + +## 7. 当前限制 + +当前版本可完成自动布线原型,但仍有以下限制: + +1. 线槽实体中心线生成基于包围盒长轴,不理解真实线槽开口、盖板和内部空间。 +2. 多根线会沿同一路径生成,暂未做并行错位排列。 +3. 未计算线槽填充率和容量。 +4. 未考虑线径、最小弯曲半径。 +5. 未做强弱电分槽、线缆类型隔离。 +6. 障碍检测基于 AABB,存在误报和漏报。 +7. 辅助路由区域是网格近似,不等于专业软件的完整布线区域建模。 +8. 端子出线方向依赖端子 LCS 方向;如果模板端子方向不准,自动布线会受影响。 +9. 导线几何当前保存在 FreeCAD 文档,不作为第一版数据库字段回写。 + +## 8. 后续需要完成 + +### 8.1 近期优先级 + +1. 线槽语义库 + +```text +WireDuct: + centerline + width + height + inlet/outlet + usable_area + cover_state +``` + +2. 多根线错位排列 + +```text +同一路径上的多根导线按 lane_offset 排列 +避免完全重叠 +``` + +3. 线槽容量和填充率 + +```text +按线径估算截面积 +计算 duct fill ratio +超过阈值给出警告 +``` + +4. 端子出线规则 + +```text +根据端子 LCS 方向、设备安装面、接线孔方向生成更合理的短出线段 +``` + +### 8.2 中期能力 + +1. 真实几何碰撞检测 + +从 AABB 升级到更精确的 Shape / BRep 碰撞检测,降低误报。 + +2. 弯曲半径约束 + +根据线缆类型和线径控制转弯半径。 + +3. 线束对象 + +把多根同路导线抽象成 bundle,并支持展开显示。 + +4. 规则约束 + +支持: + +```text +强电 / 弱电隔离 +PE 线优先路径 +屏蔽线规则 +不同电压等级分槽 +禁布区域 +``` + +5. 更智能的线槽识别 + +根据对象名称、尺寸比例、设备库语义自动识别线槽,而不完全依赖用户选择。 + +### 8.3 长期能力 + +1. 接近 EPLAN / SOLIDWORKS 的工程级路由器。 +2. 完整导线长度统计。 +3. 自动生成线束报表。 +4. 2D / 3D 导线一致性校验。 +5. 自动推荐线槽路径和线槽容量。 +6. 多柜体、多门板、多安装板之间的跨区域路由。 +7. 可视化布线诊断面板。 + +## 9. 验收标准 + +当前版本验收只看“能否自动布线”: + +1. 文档中有至少两个工程端子。 +2. 文档中有至少一条 `WireDuct` 或 `RoutingPath` carrier。 +3. 选择两个端子后执行单线测试布线,能生成 `AutoSuggested` 导线。 +4. 存在导线任务时执行“按导线任务自动布线全部”,能批量生成 `AutoSuggested` 导线。 +5. 生成导线在 `QETWiring_04_Routed` 下可见。 +6. 没有路由网络时不生成长距离悬空线。 +7. 没有导线任务时,批量布线明确提示缺少连接关系。 +8. 明显碰撞时状态为 `CollisionWarning`。 +9. 保存 FreeCAD 文档后,自动导线和路由网络仍保留。 + +## 10. 开发验证命令 + +单元测试: + +```powershell +D:\FreeCAD-1.1.1\bin\python.exe -m unittest discover -s tests\python -p "freecad_exchange*_test.py" +``` + +真实 FreeCAD smoke: + +```powershell +D:\FreeCAD-1.1.1\bin\python.exe tests\manual\freecad_auto_routing_smoke.py +``` + +语法检查: + +```powershell +D:\FreeCAD-1.1.1\bin\python.exe -m py_compile src\Mod\FreeCADExchange\AutoRouting.py src\Mod\FreeCADExchange\RoutingNetwork.py src\Mod\FreeCADExchange\AutoRoutingPanel.py +``` diff --git a/src/Mod/FreeCADExchange/AutoRouting.py b/src/Mod/FreeCADExchange/AutoRouting.py new file mode 100644 index 0000000..bc26dff --- /dev/null +++ b/src/Mod/FreeCADExchange/AutoRouting.py @@ -0,0 +1,922 @@ +# FreeCADExchange 3D automatic wiring. +# +# 第一版不碰 C++,也不把 3D 走线结果写进数据库。 +# 它只读取 FreeCAD 文档里的端子、走线网络和几何障碍, +# 然后在 QETWiring_04_Routed 下生成一条可见的折线导线。 + +import json + +import FreeCAD as App + +try: + import FreeCADGui as Gui +except ImportError: + Gui = None + +import RoutingNetwork +import TerminalObjects +import WiringObjects + + +DEFAULT_OPTIONS = { + # 端子出来先走一小段,避免导线贴着设备外壳起步。 + "terminal_exit_length": 20.0, + # 没有线槽网络时,退回到这个方向抬高/偏移后做正交折线。 + "clearance_axis": "z", + "clearance": 80.0, + "lane_axis": "y", + "lane_spacing": 10.0, + # 线槽网络相关参数。 + "use_routing_network": True, + "network_entry_max_distance": 0.0, + "bend_penalty": 25.0, + # EPLAN/SOLIDWORKS 风格:线槽/路由路径最优先,辅助面域只作为过渡/兜底区域。 + "carrier_kind_cost_factors": { + "WireDuct": 1.0, + "RoutingPath": 1.0, + "UserPath": 1.0, + "AuxiliaryPath": 2.0, + "RoutingRange": 8.0, + "SurfaceGrid": 8.0, + }, + # 默认不再生成长距离悬空 fallback;主干必须走 carrier/贴面网络。 + "allow_floating_fallback": False, + # 障碍包围盒会按这个距离膨胀,用于提前发现贴碰风险。 + "obstacle_clearance": 5.0, + "replace_existing": True, +} + + +class AutoRoutingError(RuntimeError): + pass + + +def _merged_options(options): + merged = dict(DEFAULT_OPTIONS) + if isinstance(options, dict): + merged.update(options) + return merged + + +def _vector(point): + if isinstance(point, App.Vector): + return App.Vector(point.x, point.y, point.z) + if isinstance(point, (list, tuple)) and len(point) >= 3: + return App.Vector(float(point[0]), float(point[1]), float(point[2])) + if isinstance(point, dict): + return App.Vector( + float(point.get("x", 0.0)), + float(point.get("y", 0.0)), + float(point.get("z", 0.0)), + ) + if all(hasattr(point, name) for name in ("x", "y", "z")): + return App.Vector(float(point.x), float(point.y), float(point.z)) + return App.Vector(0, 0, 0) + + +def _distance(left, right): + dx = float(left.x) - float(right.x) + dy = float(left.y) - float(right.y) + dz = float(left.z) - float(right.z) + return (dx * dx + dy * dy + dz * dz) ** 0.5 + + +def _vector_close(left, right, tolerance=0.000001): + return _distance(left, right) <= tolerance + + +def _point_payload(point): + return { + "x": float(point.x), + "y": float(point.y), + "z": float(point.z), + } + + +def _append_unique(points, point): + vector = _vector(point) + if not points or not _vector_close(points[-1], vector): + points.append(vector) + + +def _axis_value(point, axis): + return float(getattr(point, axis, 0.0)) + + +def _with_axis(point, axis, value): + return App.Vector( + float(value) if axis == "x" else float(point.x), + float(value) if axis == "y" else float(point.y), + float(value) if axis == "z" else float(point.z), + ) + + +def _orthogonal_points(start_point, end_point, preferred_axis=None): + if _vector_close(start_point, end_point): + return [start_point] + + # 每一段只沿一个坐标轴移动,这样生成的线天然是机柜布线常见的折线。 + axis_order = sorted( + ("x", "y", "z"), + key=lambda axis: abs(_axis_value(end_point, axis) - _axis_value(start_point, axis)), + reverse=True, + ) + if preferred_axis in {"x", "y", "z"}: + axis_order = [axis for axis in axis_order if axis != preferred_axis] + axis_order.append(preferred_axis) + + points = [start_point] + current = start_point + for axis in axis_order: + target = _axis_value(end_point, axis) + if abs(_axis_value(current, axis) - target) <= 0.000001: + continue + current = _with_axis(current, axis, target) + _append_unique(points, current) + _append_unique(points, end_point) + return points + + +def _append_orthogonal(points, target_point, preferred_axis=None): + if not points: + _append_unique(points, target_point) + return + segment = _orthogonal_points(points[-1], _vector(target_point), preferred_axis) + for point in segment[1:]: + _append_unique(points, point) + + +def _offset(point, direction, distance): + return App.Vector( + float(point.x) + float(direction.x) * float(distance), + float(point.y) + float(direction.y) * float(distance), + float(point.z) + float(direction.z) * float(distance), + ) + + +def _terminal_origin(terminal): + return _vector(TerminalObjects.terminal_origin(terminal)) + + +def _terminal_direction(terminal): + try: + return _vector(TerminalObjects.terminal_direction(terminal)) + except Exception: + return App.Vector(0, 0, 1) + + +def _project_uuid(doc, start_terminal=None, end_terminal=None): + for obj in (start_terminal, end_terminal): + value = (getattr(obj, "QetProjectUuid", "") or "").strip() + if value: + return value + try: + return (getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "") or "").strip() + except Exception: + return "" + + +def index_terminals(doc): + """Return {terminal_uuid: terminal_object} for routable engineering terminals.""" + if doc is None: + return {} + + terminals = [] + root = None + try: + root = doc.getObject(TerminalObjects.ROOT_GROUP_NAME) + except Exception: + root = None + if root is not None: + terminals.extend(TerminalObjects.collect_terminal_objects(root)) + + terminals.extend( + obj + for obj in list(getattr(doc, "Objects", []) or []) + if TerminalObjects.is_terminal_object(obj) + ) + + indexed = {} + for terminal in terminals: + terminal_uuid = (getattr(terminal, "QetTerminalUuid", "") or "").strip() + if terminal_uuid and terminal_uuid not in indexed: + indexed[terminal_uuid] = terminal + return indexed + + +def _wire_object_name(start_terminal, end_terminal, wire_uuid=""): + if wire_uuid: + return "QETAutoWire_{0}".format(TerminalObjects.safe_token(wire_uuid)) + return "QETAutoWire_{0}_{1}".format( + TerminalObjects.safe_token(getattr(start_terminal, "QetTerminalUuid", "")), + TerminalObjects.safe_token(getattr(end_terminal, "QetTerminalUuid", "")), + ) + + +def _unique_name(doc, base_name): + name = TerminalObjects.safe_token(base_name) + if doc.getObject(name) is None: + return name + suffix = 1 + while doc.getObject("{0}_{1}".format(name, suffix)) is not None: + suffix += 1 + return "{0}_{1}".format(name, suffix) + + +def _create_wire_geometry(doc, name, points): + if getattr(App, "ActiveDocument", None) is doc: + try: + import Draft + + obj = Draft.make_wire( + points, + closed=False, + placement=None, + face=None, + support=None, + bs2wire=False, + ) + if obj is not None: + _set_points(obj, points) + return obj + except Exception: + pass + + import Part + + obj = doc.addObject("Part::Feature", name) + obj.Shape = Part.makePolygon(points) + _set_points(obj, points) + return obj + + +def _set_points(obj, points): + try: + if "Points" not in getattr(obj, "PropertiesList", []): + obj.addProperty("App::PropertyVectorList", "Points", "QET Wiring", "Auto route points") + obj.Points = list(points) + except Exception: + pass + + +def _set_string(obj, name, value, description="Auto-routing property"): + TerminalObjects.ensure_string_property(obj, name, "QET Routing", description, value) + + +def _route_payload(route_data, collisions): + return { + "algorithm": route_data.get("algorithm", ""), + "points": [_point_payload(point) for point in route_data.get("points", [])], + "collisions": collisions, + "network": route_data.get("network", {}), + } + + +def _set_auto_metadata(wire, route_data, collisions): + _set_string( + wire, + "QetAutoRouteAlgorithm", + route_data.get("algorithm", ""), + "Auto-routing algorithm used for this wire", + ) + _set_string( + wire, + "QetAutoRouteDiagnosticsJson", + json.dumps(_route_payload(route_data, collisions), ensure_ascii=False), + "Auto-routing diagnostics", + ) + if route_data.get("network"): + _set_string( + wire, + "QetAutoRouteNetworkJson", + json.dumps(route_data.get("network", {}), ensure_ascii=False), + "Route network metadata used by this wire", + ) + + +def build_orthogonal_route(start_terminal, end_terminal, route_index=0, options=None): + opts = _merged_options(options) + start_origin = _terminal_origin(start_terminal) + end_origin = _terminal_origin(end_terminal) + exit_length = max(float(opts.get("terminal_exit_length", 0.0) or 0.0), 0.0) + start_exit = _offset(start_origin, _terminal_direction(start_terminal), exit_length) + end_exit = _offset(end_origin, _terminal_direction(end_terminal), exit_length) + + clearance_axis = (opts.get("clearance_axis") or "z").lower() + if clearance_axis not in {"x", "y", "z"}: + clearance_axis = "z" + lane_axis = (opts.get("lane_axis") or "y").lower() + if lane_axis not in {"x", "y", "z"}: + lane_axis = "y" + + clearance_value = max( + _axis_value(start_exit, clearance_axis), + _axis_value(end_exit, clearance_axis), + ) + float(opts.get("clearance", 0.0) or 0.0) + + lane_offset = float(route_index or 0) * float(opts.get("lane_spacing", 0.0) or 0.0) + lane_point = _with_axis(start_exit, clearance_axis, clearance_value) + lane_point = _with_axis(lane_point, lane_axis, _axis_value(lane_point, lane_axis) + lane_offset) + end_lane = _with_axis(end_exit, clearance_axis, clearance_value) + end_lane = _with_axis(end_lane, lane_axis, _axis_value(end_lane, lane_axis) + lane_offset) + + points = [] + _append_unique(points, start_origin) + _append_unique(points, start_exit) + _append_orthogonal(points, lane_point, preferred_axis=clearance_axis) + _append_orthogonal(points, end_lane) + _append_orthogonal(points, end_exit, preferred_axis=clearance_axis) + _append_unique(points, end_origin) + + return { + "algorithm": "orthogonal-v1", + "points": points, + "network": {}, + } + + +def build_network_route(start_terminal, end_terminal, route_index=0, options=None, doc=None): + opts = _merged_options(options) + if not opts.get("use_routing_network", True): + return None + if doc is None: + doc = getattr(start_terminal, "Document", None) or getattr(App, "ActiveDocument", None) + if doc is None: + return None + + network = RoutingNetwork.build_route_graph(doc) + if network.get("segment_count", 0) <= 0: + return None + + exit_length = max(float(opts.get("terminal_exit_length", 0.0) or 0.0), 0.0) + start_origin = _terminal_origin(start_terminal) + end_origin = _terminal_origin(end_terminal) + start_exit = _offset(start_origin, _terminal_direction(start_terminal), exit_length) + end_exit = _offset(end_origin, _terminal_direction(end_terminal), exit_length) + + start_key, start_distance = RoutingNetwork.nearest_node(network, start_exit) + end_key, end_distance = RoutingNetwork.nearest_node(network, end_exit) + if start_key is None or end_key is None: + return None + + max_distance = float(opts.get("network_entry_max_distance", 0.0) or 0.0) + if max_distance > 0.0 and ( + float(start_distance or 0.0) > max_distance + or float(end_distance or 0.0) > max_distance + ): + return None + + path_keys = RoutingNetwork.shortest_path( + network, + start_key, + end_key, + bend_penalty=float(opts.get("bend_penalty", 0.0) or 0.0), + kind_cost_factors=opts.get("carrier_kind_cost_factors", {}), + ) + if not path_keys: + return None + + carrier_points = RoutingNetwork.path_points(network, path_keys) + if not carrier_points: + return None + + points = [] + _append_unique(points, start_origin) + _append_unique(points, start_exit) + _append_orthogonal(points, carrier_points[0]) + for point in carrier_points[1:]: + _append_unique(points, point) + _append_orthogonal(points, end_exit) + _append_unique(points, end_origin) + + return { + "algorithm": "network-dijkstra-v1", + "points": points, + "network": { + "carriers": int(network.get("carrier_count", 0)), + "segments": int(network.get("segment_count", 0)), + "nodes": len(network.get("nodes", {})), + "entry_distance": float(start_distance or 0.0), + "exit_distance": float(end_distance or 0.0), + }, + } + + +def _is_group(obj): + try: + return bool(obj.isDerivedFrom("App::DocumentObjectGroup")) + except Exception: + return False + + +def _is_origin_helper(obj): + type_id = (getattr(obj, "TypeId", "") or "").lower() + name = (getattr(obj, "Name", "") or "").lower() + label = (getattr(obj, "Label", "") or "").lower() + compact_name = "".join(ch for ch in name if not ch.isdigit()).replace("-", "_") + compact_label = "".join(ch for ch in label if not ch.isdigit()).replace("-", "_") + helper_names = { + "origin", + "xy_plane", + "xz_plane", + "yz_plane", + "x_axis", + "y_axis", + "z_axis", + } + if "origin" in type_id or compact_name in helper_names: + return True + return compact_label in helper_names or compact_label.replace(" ", "_") in helper_names + + +def _bbox_payload(obj, clearance=0.0): + shape = getattr(obj, "Shape", None) + bbox = getattr(shape, "BoundBox", None) + if bbox is None: + return None + return { + "xmin": float(bbox.XMin) - clearance, + "xmax": float(bbox.XMax) + clearance, + "ymin": float(bbox.YMin) - clearance, + "ymax": float(bbox.YMax) + clearance, + "zmin": float(bbox.ZMin) - clearance, + "zmax": float(bbox.ZMax) + clearance, + } + + +def collect_obstacles(doc, exclude=None, options=None): + opts = _merged_options(options) + excluded = set(id(obj) for obj in (exclude or []) if obj is not None) + clearance = float(opts.get("obstacle_clearance", 0.0) or 0.0) + obstacles = [] + for obj in list(getattr(doc, "Objects", []) or []): + if id(obj) in excluded: + continue + obstacle_mode = (getattr(obj, "QetRoutingObstacleMode", "") or "").strip() + if obstacle_mode in {"PassThrough", "WireDuctPassThrough"}: + continue + if _is_group(obj) or _is_origin_helper(obj): + continue + if TerminalObjects.is_lcs_like(obj) or TerminalObjects.is_terminal_object(obj): + continue + if RoutingNetwork.is_route_carrier(obj) or WiringObjects.is_routed_wire_object(obj): + continue + bbox = _bbox_payload(obj, clearance=clearance) + if bbox is None: + continue + obstacles.append( + { + "name": getattr(obj, "Name", ""), + "label": getattr(obj, "Label", ""), + "type_id": getattr(obj, "TypeId", ""), + "bbox": bbox, + } + ) + return obstacles + + +def _segment_intersects_bbox(start, end, bbox): + # Slab intersection: 把线段参数化为 start + t*(end-start),t 在 [0, 1] 内相交即命中。 + t_min = 0.0 + t_max = 1.0 + for axis, min_key, max_key in ( + ("x", "xmin", "xmax"), + ("y", "ymin", "ymax"), + ("z", "zmin", "zmax"), + ): + start_value = _axis_value(start, axis) + end_value = _axis_value(end, axis) + delta = end_value - start_value + low = float(bbox[min_key]) + high = float(bbox[max_key]) + if abs(delta) <= 0.000001: + if start_value < low or start_value > high: + return False + continue + inv = 1.0 / delta + near = (low - start_value) * inv + far = (high - start_value) * inv + if near > far: + near, far = far, near + t_min = max(t_min, near) + t_max = min(t_max, far) + if t_min > t_max: + return False + return True + + +def detect_collisions(points, obstacles): + collisions = [] + for index in range(max(len(points) - 1, 0)): + start = points[index] + end = points[index + 1] + for obstacle in obstacles: + if _segment_intersects_bbox(start, end, obstacle["bbox"]): + collisions.append( + { + "segment_index": index, + "obstacle_name": obstacle.get("name", ""), + "obstacle_label": obstacle.get("label", ""), + } + ) + return collisions + + +def _remove_existing_auto_routes(doc, start_uuid, end_uuid, wire_uuid=""): + removed = 0 + for obj in list(WiringObjects.iter_routed_wire_objects(doc)): + if (getattr(obj, "RouteType", "") or "").strip() != "AutoSuggested": + continue + if wire_uuid and (getattr(obj, "QetWireUuid", "") or "").strip() != wire_uuid: + continue + same_direction = ( + (getattr(obj, "QetStartTerminalUuid", "") or "").strip() == start_uuid + and (getattr(obj, "QetEndTerminalUuid", "") or "").strip() == end_uuid + ) + reverse_direction = ( + (getattr(obj, "QetStartTerminalUuid", "") or "").strip() == end_uuid + and (getattr(obj, "QetEndTerminalUuid", "") or "").strip() == start_uuid + ) + if not same_direction and not reverse_direction: + continue + try: + doc.removeObject(obj.Name) + removed += 1 + except Exception: + pass + return removed + + +def _find_task_by_wire_uuid(doc, wire_uuid): + if not wire_uuid: + return None + try: + task_group = doc.getObject("QETWiring_01_Tasks") + except Exception: + task_group = None + if task_group is None: + return None + for task in list(getattr(task_group, "Group", []) or []): + if (getattr(task, "QetWireUuid", "") or "").strip() == wire_uuid: + return task + return None + + +def _set_task_status(task, status): + if task is None: + return + TerminalObjects.ensure_string_property( + task, + "RouteStatus", + "QET Wiring", + "Wire task route status", + status, + ) + + +def _style_wire(wire, collision_count=0): + try: + wire.ViewObject.Visibility = True + wire.ViewObject.LineWidth = 3.0 + if collision_count: + wire.ViewObject.LineColor = (1.0, 0.1, 0.0) + else: + wire.ViewObject.LineColor = (0.0, 0.35, 1.0) + except Exception: + pass + + +def route_between_terminals( + doc, + start_terminal, + end_terminal, + route_index=0, + options=None, + wire_uuid="", + wire_label="", + net_uuid="", + group_uuid="", + wire_mark="", + wire_mark_is_manual=False, +): + if doc is None: + raise AutoRoutingError("No FreeCAD document is available.") + if not TerminalObjects.is_terminal_object(start_terminal): + raise AutoRoutingError("Start object is not a routable terminal.") + if not TerminalObjects.is_terminal_object(end_terminal): + raise AutoRoutingError("End object is not a routable terminal.") + if start_terminal == end_terminal: + raise AutoRoutingError("Start and end terminal must be different.") + + opts = _merged_options(options) + start_uuid = (getattr(start_terminal, "QetTerminalUuid", "") or "").strip() + end_uuid = (getattr(end_terminal, "QetTerminalUuid", "") or "").strip() + project_uuid = _project_uuid(doc, start_terminal, end_terminal) + if not project_uuid: + raise AutoRoutingError("Project UUID is required for auto-routing.") + + if opts.get("replace_existing", True): + _remove_existing_auto_routes(doc, start_uuid, end_uuid, wire_uuid=wire_uuid) + + route_data = build_network_route( + start_terminal, + end_terminal, + route_index=route_index, + options=opts, + doc=doc, + ) + if route_data is None: + if not opts.get("allow_floating_fallback", False): + raise AutoRoutingError( + "没有可用的线槽/路由路径网络;请先选择线槽中心线、草图路径或必要的辅助路由区域。" + ) + route_data = build_orthogonal_route( + start_terminal, + end_terminal, + route_index=route_index, + options=opts, + ) + + points = route_data.get("points", []) + if len(points) < 2: + raise AutoRoutingError("Auto-routing produced fewer than two points.") + + obstacles = collect_obstacles(doc, exclude=[start_terminal, end_terminal], options=opts) + collisions = detect_collisions(points, obstacles) + status = "CollisionWarning" if collisions else "Routed" + + wire_name = _unique_name(doc, _wire_object_name(start_terminal, end_terminal, wire_uuid)) + wire = _create_wire_geometry(doc, wire_name, points) + wire.Label = wire_label or wire_mark or wire_uuid or "QET Auto Wire" + WiringObjects.set_routed_wire_semantics( + wire, + project_uuid, + wire_uuid, + wire_label or wire_mark or wire_uuid, + start_uuid, + end_uuid, + (getattr(start_terminal, "QetInstanceId", "") or "").strip(), + (getattr(end_terminal, "QetInstanceId", "") or "").strip(), + route_type="AutoSuggested", + route_status=status, + route_mode="Auto", + net_uuid=net_uuid, + group_uuid=group_uuid, + wire_mark=wire_mark, + wire_mark_is_manual=wire_mark_is_manual, + ) + _set_auto_metadata(wire, route_data, collisions) + + routed_group = WiringObjects.ensure_routed_group(doc, project_uuid) + if wire not in getattr(routed_group, "Group", []): + routed_group.addObject(wire) + try: + routed_group.ViewObject.Visibility = True + except Exception: + pass + _style_wire(wire, collision_count=len(collisions)) + + task = _find_task_by_wire_uuid(doc, wire_uuid) + _set_task_status(task, status) + + try: + doc.recompute() + except Exception: + pass + + return { + "wire": wire, + "route_status": status, + "algorithm": route_data.get("algorithm", ""), + "points": points, + "collision_count": len(collisions), + "collisions": collisions, + } + + +def _wire_item_value(item, *names): + if not isinstance(item, dict): + return "" + for name in names: + value = item.get(name, "") + if value is not None and str(value).strip(): + return str(value).strip() + return "" + + +def route_all_from_payload(doc, payload, options=None): + if doc is None: + raise AutoRoutingError("No FreeCAD document is available.") + if not isinstance(payload, dict): + raise AutoRoutingError("Exchange payload must be an object.") + + terminals = index_terminals(doc) + wires = payload.get("wires", []) or [] + report = { + "total_wires": len(wires), + "routed": 0, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "skipped_invalid": 0, + "errors": [], + "routes": [], + } + + for index, item in enumerate(wires): + if not isinstance(item, dict): + report["skipped_invalid"] += 1 + continue + start_uuid = _wire_item_value(item, "start_terminal_uuid") + end_uuid = _wire_item_value(item, "end_terminal_uuid") + start_terminal = terminals.get(start_uuid) + end_terminal = terminals.get(end_uuid) + if start_terminal is None or end_terminal is None: + report["skipped_missing_terminal"] += 1 + continue + try: + result = route_between_terminals( + doc, + start_terminal, + end_terminal, + route_index=index, + options=options, + wire_uuid=_wire_item_value(item, "wire_id", "wire_uuid", "id"), + wire_label=_wire_item_value(item, "wire_label", "wire_mark"), + net_uuid=_wire_item_value(item, "net_uuid"), + group_uuid=_wire_item_value(item, "group_uuid"), + wire_mark=_wire_item_value(item, "wire_mark"), + wire_mark_is_manual=bool(item.get("wire_mark_is_manual", False)), + ) + except Exception as exc: + report["errors"].append(str(exc)) + continue + if result["route_status"] == "CollisionWarning": + report["collision_warnings"] += 1 + report["routed"] += 1 + report["routes"].append( + { + "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), + "algorithm": result["algorithm"], + "route_status": result["route_status"], + "collision_count": result["collision_count"], + } + ) + return report + + +def _iter_wire_tasks(doc): + try: + task_group = doc.getObject("QETWiring_01_Tasks") + except Exception: + task_group = None + if task_group is None: + return [] + return [ + task + for task in list(getattr(task_group, "Group", []) or []) + if (getattr(task, "RouteType", "") or "").strip() == "Task" + ] + + +def route_all_tasks(doc, options=None): + payload = {"wires": []} + for task in _iter_wire_tasks(doc): + payload["wires"].append( + { + "wire_id": (getattr(task, "QetWireUuid", "") or "").strip(), + "wire_label": (getattr(task, "QetWireLabel", "") or "").strip(), + "wire_mark": (getattr(task, "QetWireMark", "") or "").strip(), + "wire_mark_is_manual": bool(getattr(task, "QetWireMarkIsManual", False)), + "net_uuid": (getattr(task, "QetNetUuid", "") or "").strip(), + "group_uuid": (getattr(task, "QetGroupUuid", "") or "").strip(), + "start_terminal_uuid": (getattr(task, "QetStartTerminalUuid", "") or "").strip(), + "end_terminal_uuid": (getattr(task, "QetEndTerminalUuid", "") or "").strip(), + } + ) + return route_all_from_payload(doc, payload, options=options) + + +def wire_task_count(doc): + return len(_iter_wire_tasks(doc)) + + +def clear_auto_routes(doc): + removed = 0 + for obj in list(WiringObjects.iter_routed_wire_objects(doc)): + if (getattr(obj, "RouteType", "") or "").strip() != "AutoSuggested": + continue + try: + doc.removeObject(obj.Name) + removed += 1 + except Exception: + pass + try: + doc.recompute() + except Exception: + pass + return removed + + +def _selected_terminals(): + if Gui is None: + return [] + try: + return [obj for obj in Gui.Selection.getSelection() if TerminalObjects.is_terminal_object(obj)] + except Exception: + return [] + + +def _console_message(message): + try: + App.Console.PrintMessage("[FreeCADExchange] {0}\n".format(message)) + except Exception: + pass + + +def _console_error(message): + try: + App.Console.PrintError("[FreeCADExchange] {0}\n".format(message)) + except Exception: + pass + + +class CommandAutoRouteSelected: + def GetResources(self): + return { + "MenuText": "测试布线选中两个端子", + "ToolTip": "单条连接测试:只在两个选中的 QET 3D 工程端子之间生成自动折线导线", + } + + def IsActive(self): + return getattr(App, "ActiveDocument", None) is not None and Gui is not None + + def Activated(self): + doc = getattr(App, "ActiveDocument", None) + terminals = _selected_terminals() + if len(terminals) != 2: + _console_error("请先选中两个可布线工程端子。") + return + try: + result = route_between_terminals(doc, terminals[0], terminals[1]) + _console_message( + "自动布线完成:algorithm={0}, status={1}, collisions={2}".format( + result["algorithm"], + result["route_status"], + result["collision_count"], + ) + ) + try: + Gui.SendMsgToActiveView("ViewFit") + except Exception: + pass + except Exception as exc: + _console_error("自动布线失败:{0}".format(exc)) + + +class CommandAutoRouteAll: + def GetResources(self): + return { + "MenuText": "按导线任务自动布线全部", + "ToolTip": "根据 QET 导线任务批量生成 3D 自动布线路径", + } + + def IsActive(self): + return getattr(App, "ActiveDocument", None) is not None + + def Activated(self): + doc = getattr(App, "ActiveDocument", None) + try: + payload = getattr(App, "_qet_exchange_payload", None) + if isinstance(payload, dict) and payload.get("wires"): + report = route_all_from_payload(doc, payload) + else: + report = route_all_tasks(doc) + if report.get("total_wires", 0) <= 0: + _console_error("没有导线任务。自动布线全部导线需要 QET wires[] 或 QETWiring_01_Tasks。") + return + _console_message( + "批量自动布线完成:routed={0}, collision_warnings={1}, missing_terminals={2}".format( + report.get("routed", 0), + report.get("collision_warnings", 0), + report.get("skipped_missing_terminal", 0), + ) + ) + except Exception as exc: + _console_error("批量自动布线失败:{0}".format(exc)) + + +_COMMANDS_REGISTERED = False + + +def register_commands(): + global _COMMANDS_REGISTERED + if _COMMANDS_REGISTERED: + return + if Gui is None or not hasattr(Gui, "addCommand"): + return + Gui.addCommand("QET_Exchange_AutoRouteSelected", CommandAutoRouteSelected()) + Gui.addCommand("QET_Exchange_AutoRouteAll", CommandAutoRouteAll()) + _COMMANDS_REGISTERED = True + + +register_commands() diff --git a/src/Mod/FreeCADExchange/AutoRoutingPanel.py b/src/Mod/FreeCADExchange/AutoRoutingPanel.py new file mode 100644 index 0000000..b411849 --- /dev/null +++ b/src/Mod/FreeCADExchange/AutoRoutingPanel.py @@ -0,0 +1,366 @@ +# FreeCADExchange GUI panel for 3D automatic wiring. +# +# 面板只调用 Python 算法:扫描端子/走线网络、从选择创建 carrier、 +# 对选中端子或全部导线任务执行自动布线。 + +import FreeCAD as App + +try: + import FreeCADGui as Gui +except ImportError: + Gui = None + +try: + from PySide6 import QtWidgets +except ImportError: + try: + from PySide2 import QtWidgets + except ImportError: + try: + from PySide import QtGui as QtWidgets + except ImportError: + QtWidgets = None + +import AutoRouting +import RoutingNetwork +import TerminalObjects + +try: + import ExchangeWriteBack +except Exception: + ExchangeWriteBack = None + + +COMMAND_NAME = "QET_Exchange_OpenAutoRoutingPanel" + + +class AutoRoutingPanelError(RuntimeError): + pass + + +def _active_document(): + doc = getattr(App, "ActiveDocument", None) + if doc is None: + raise AutoRoutingPanelError("请先打开 QET 3D 工程文档。") + return doc + + +def _console_message(message): + try: + App.Console.PrintMessage("[FreeCADExchange] {0}\n".format(message)) + except Exception: + pass + + +def _console_error(message): + try: + App.Console.PrintError("[FreeCADExchange] {0}\n".format(message)) + except Exception: + pass + + +def _selection(): + if Gui is None: + return [] + try: + return list(Gui.Selection.getSelection() or []) + except Exception: + return [] + + +def _selection_ex(): + if Gui is None: + return [] + try: + return list(Gui.Selection.getSelectionEx() or []) + except Exception: + return [] + + +def _selected_terminals(): + return [obj for obj in _selection() if TerminalObjects.is_terminal_object(obj)] + + +class AutoRoutingController: + def __init__(self): + self.last_report = None + + def summary(self): + doc = _active_document() + terminal_count = len(AutoRouting.index_terminals(doc)) + task_count = AutoRouting.wire_task_count(doc) + payload = getattr(App, "_qet_exchange_payload", None) + payload_wire_count = 0 + if isinstance(payload, dict) and isinstance(payload.get("wires"), list): + payload_wire_count = len(payload.get("wires") or []) + network = RoutingNetwork.network_summary(doc) + kinds = network.get("kinds", {}) if isinstance(network.get("kinds", {}), dict) else {} + kind_text = "" + if kinds: + kind_text = ";类型:" + ",".join( + "{0}={1}".format(key, value) + for key, value in sorted(kinds.items()) + ) + return "端子:{0};导线任务:{1};QET导线:{2};路由网络:{3} 条 carrier / {4} 段 / {5} 节点{6}".format( + terminal_count, + task_count, + payload_wire_count, + network.get("carriers", 0), + network.get("segments", 0), + network.get("nodes", 0), + kind_text, + ) + + def create_carriers_from_selection(self): + doc = _active_document() + project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() + created = RoutingNetwork.create_carriers_from_selection( + doc, + _selection_ex(), + project_uuid=project_uuid, + ) + self.last_report = {"created_carriers": len(created)} + return created + + def create_wire_duct_carriers_from_selection(self): + doc = _active_document() + project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() + created = RoutingNetwork.create_wire_duct_carriers_from_selection( + doc, + _selection_ex(), + project_uuid=project_uuid, + ) + self.last_report = {"created_wire_duct_carriers": len(created)} + return created + + def create_surface_carriers_from_selection(self): + doc = _active_document() + project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() + created = RoutingNetwork.create_surface_carriers_from_selection( + doc, + _selection_ex(), + project_uuid=project_uuid, + ) + self.last_report = {"created_surface_carriers": len(created)} + return created + + def route_selected(self): + doc = _active_document() + terminals = _selected_terminals() + if len(terminals) != 2: + raise AutoRoutingPanelError( + "这个按钮只用于单条连接测试,请正好选中两个可布线工程端子。批量布线请使用“按导线任务自动布线全部”。" + ) + result = AutoRouting.route_between_terminals(doc, terminals[0], terminals[1]) + self.last_report = result + return result + + def route_all(self): + doc = _active_document() + payload = getattr(App, "_qet_exchange_payload", None) + if isinstance(payload, dict) and payload.get("wires"): + report = AutoRouting.route_all_from_payload(doc, payload) + else: + report = AutoRouting.route_all_tasks(doc) + if report.get("total_wires", 0) <= 0: + raise AutoRoutingPanelError( + "没有导线任务,不能判断哪些端子应该互相连接。请先从 QET 导入 wires[],或确认 QETWiring_01_Tasks 中存在导线任务。" + ) + self.last_report = report + return report + + def clear_auto_routes(self): + doc = _active_document() + removed = AutoRouting.clear_auto_routes(doc) + self.last_report = {"removed": removed} + return removed + + def clear_route_carriers(self): + doc = _active_document() + removed = RoutingNetwork.clear_route_carriers(doc) + self.last_report = {"removed_carriers": removed} + return removed + + def save(self): + doc = _active_document() + file_name = getattr(doc, "FileName", "") + if file_name and hasattr(doc, "save"): + doc.save() + if ExchangeWriteBack is not None: + return ExchangeWriteBack.write_back_document(doc) + return {"saved": bool(file_name)} + + +class AutoRoutingTaskPanel: + def __init__(self, controller=None): + if QtWidgets is None: + raise AutoRoutingPanelError("Qt widgets are not available.") + self.controller = controller or AutoRoutingController() + self.form = QtWidgets.QWidget() + self.form.setWindowTitle("3D 自动布线") + + layout = QtWidgets.QVBoxLayout(self.form) + self.scan_button = QtWidgets.QPushButton("扫描端子/网络") + self.create_wire_duct_button = QtWidgets.QPushButton("从线槽实体生成中心路径") + self.create_carrier_button = QtWidgets.QPushButton("从线槽/草图创建路由路径") + self.create_surface_button = QtWidgets.QPushButton("从选中面创建辅助路由区域") + self.route_selected_button = QtWidgets.QPushButton("测试布线选中两个端子") + self.route_all_button = QtWidgets.QPushButton("按导线任务自动布线全部") + self.clear_button = QtWidgets.QPushButton("清除自动布线") + self.clear_carriers_button = QtWidgets.QPushButton("清除走线路径") + self.save_button = QtWidgets.QPushButton("保存") + self.status_label = QtWidgets.QLabel("") + self.status_label.setWordWrap(True) + + for widget in ( + self.scan_button, + self.create_wire_duct_button, + self.create_carrier_button, + self.create_surface_button, + self.route_selected_button, + self.route_all_button, + self.clear_button, + self.clear_carriers_button, + self.save_button, + self.status_label, + ): + layout.addWidget(widget) + + self.scan_button.clicked.connect(self.scan) + self.create_wire_duct_button.clicked.connect(self.create_wire_duct_carriers) + self.create_carrier_button.clicked.connect(self.create_carriers) + self.create_surface_button.clicked.connect(self.create_surface_carriers) + self.route_selected_button.clicked.connect(self.route_selected) + self.route_all_button.clicked.connect(self.route_all) + self.clear_button.clicked.connect(self.clear_auto_routes) + self.clear_carriers_button.clicked.connect(self.clear_route_carriers) + self.save_button.clicked.connect(self.save) + self.scan() + + def _set_status(self, message): + self.status_label.setText(message) + _console_message(message) + + def _set_error(self, message): + self.status_label.setText(message) + _console_error(message) + + def scan(self): + try: + self._set_status(self.controller.summary()) + except Exception as exc: + self._set_error(str(exc)) + + def create_carriers(self): + try: + created = self.controller.create_carriers_from_selection() + self._set_status("已创建路由路径:{0} 条。{1}".format(len(created), self.controller.summary())) + except Exception as exc: + self._set_error(str(exc)) + + def create_wire_duct_carriers(self): + try: + created = self.controller.create_wire_duct_carriers_from_selection() + self._set_status("已创建线槽中心路径:{0} 条。{1}".format(len(created), self.controller.summary())) + except Exception as exc: + self._set_error(str(exc)) + + def create_surface_carriers(self): + try: + created = self.controller.create_surface_carriers_from_selection() + self._set_status("已创建辅助路由区域:{0} 条。{1}".format(len(created), self.controller.summary())) + except Exception as exc: + self._set_error(str(exc)) + + def route_selected(self): + try: + result = self.controller.route_selected() + self._set_status( + "自动布线完成:algorithm={0}, status={1}, collisions={2}".format( + result.get("algorithm", ""), + result.get("route_status", ""), + result.get("collision_count", 0), + ) + ) + try: + if Gui is not None: + Gui.SendMsgToActiveView("ViewFit") + except Exception: + pass + except Exception as exc: + self._set_error(str(exc)) + + def route_all(self): + try: + report = self.controller.route_all() + self._set_status( + "批量自动布线完成:routed={0}, collision_warnings={1}, missing_terminals={2}".format( + report.get("routed", 0), + report.get("collision_warnings", 0), + report.get("skipped_missing_terminal", 0), + ) + ) + except Exception as exc: + self._set_error(str(exc)) + + def clear_auto_routes(self): + try: + removed = self.controller.clear_auto_routes() + self._set_status("已清除自动布线:{0} 条。".format(removed)) + except Exception as exc: + self._set_error(str(exc)) + + def clear_route_carriers(self): + try: + removed = self.controller.clear_route_carriers() + self._set_status("已清除走线路径:{0} 条。{1}".format(removed, self.controller.summary())) + except Exception as exc: + self._set_error(str(exc)) + + def save(self): + try: + report = self.controller.save() + output_path = report.get("output_path", "") if isinstance(report, dict) else "" + self._set_status("已保存。{0}".format(output_path)) + except Exception as exc: + self._set_error(str(exc)) + + def accept(self): + return True + + def reject(self): + return True + + +class CommandOpenAutoRoutingPanel: + def GetResources(self): + return { + "MenuText": "3D自动布线", + "ToolTip": "打开 3D 自动布线面板", + } + + def IsActive(self): + return getattr(App, "ActiveDocument", None) is not None and Gui is not None + + def Activated(self): + if Gui is None or not hasattr(Gui, "Control"): + return + if hasattr(Gui.Control, "activeDialog") and Gui.Control.activeDialog(): + Gui.Control.closeDialog() + Gui.Control.showDialog(AutoRoutingTaskPanel()) + + +_COMMANDS_REGISTERED = False + + +def register_commands(): + global _COMMANDS_REGISTERED + if _COMMANDS_REGISTERED: + return + if Gui is None or not hasattr(Gui, "addCommand"): + return + Gui.addCommand(COMMAND_NAME, CommandOpenAutoRoutingPanel()) + _COMMANDS_REGISTERED = True + + +register_commands() diff --git a/src/Mod/FreeCADExchange/CMakeLists.txt b/src/Mod/FreeCADExchange/CMakeLists.txt index 34436ae..05e6f87 100644 --- a/src/Mod/FreeCADExchange/CMakeLists.txt +++ b/src/Mod/FreeCADExchange/CMakeLists.txt @@ -13,6 +13,9 @@ set(FreeCADExchange_Scripts TerminalImport.py WiringObjects.py WiringImport.py + RoutingNetwork.py + AutoRouting.py + AutoRoutingPanel.py ExchangeWriteBack.py ManualWiring.py ManualWiringPanel.py diff --git a/src/Mod/FreeCADExchange/InitGui.py b/src/Mod/FreeCADExchange/InitGui.py index 76975bc..e233a56 100644 --- a/src/Mod/FreeCADExchange/InitGui.py +++ b/src/Mod/FreeCADExchange/InitGui.py @@ -16,6 +16,9 @@ COMMANDS = [ "QET_Template_CreateEngineeringTerminals", "QET_Exchange_CreateManualWire", "QET_Exchange_OpenManualWiringPanel", + "QET_Exchange_AutoRouteSelected", + "QET_Exchange_AutoRouteAll", + "QET_Exchange_OpenAutoRoutingPanel", ] @@ -63,6 +66,8 @@ def _register_exchange_commands( traceback_module=traceback, ): exchange_write_back = safe_import("ExchangeWriteBack") + auto_routing = safe_import("AutoRouting") + auto_routing_panel = safe_import("AutoRoutingPanel") manual_wiring = safe_import("ManualWiring") manual_wiring_panel = safe_import("ManualWiringPanel") template_authoring = safe_import("TemplateAuthoring") @@ -89,6 +94,26 @@ def _register_exchange_commands( ) ) + try: + if auto_routing is not None: + auto_routing.register_commands() + except Exception: + append_init_log( + "InitGui failed to register auto-routing commands:\n{0}".format( + traceback_module.format_exc() + ) + ) + + try: + if auto_routing_panel is not None: + auto_routing_panel.register_commands() + except Exception: + append_init_log( + "InitGui failed to register auto-routing panel command:\n{0}".format( + traceback_module.format_exc() + ) + ) + try: if manual_wiring is not None: manual_wiring.register_commands() diff --git a/src/Mod/FreeCADExchange/RoutingNetwork.py b/src/Mod/FreeCADExchange/RoutingNetwork.py new file mode 100644 index 0000000..32c29e5 --- /dev/null +++ b/src/Mod/FreeCADExchange/RoutingNetwork.py @@ -0,0 +1,875 @@ +# FreeCADExchange route carrier network helpers. +# +# 这个模块只管理 FreeCAD 文档里的走线网络,不写数据库。 +# 第一版的思路是:用户或模板把线槽/导轨中心线标成 carrier, +# 自动布线算法再沿这些 carrier 做最短路搜索。 + +import heapq + +import FreeCAD as App + +import TerminalObjects +import WiringObjects + + +ROUTING_ROLE = "RoutingCarrier" +ROUTE_CARRIER_KIND = "RoutingPath" +ROUTE_CARRIER_KIND_WIRE_DUCT = "WireDuct" +ROUTE_CARRIER_KIND_AUXILIARY_PATH = "AuxiliaryPath" +ROUTE_CARRIER_KIND_ROUTING_RANGE = "RoutingRange" +PROPERTY_GROUP = "QET Routing" +DEFAULT_NODE_TOLERANCE = 0.001 +DEFAULT_SURFACE_LANE_SPACING = 100.0 +DEFAULT_SURFACE_OFFSET = 5.0 +DEFAULT_SURFACE_MARGIN = 20.0 +DEFAULT_WIRE_DUCT_MARGIN = 20.0 +WIRE_DUCT_OBSTACLE_MODE = "PassThrough" +DEFAULT_KIND_COST_FACTORS = { + ROUTE_CARRIER_KIND_WIRE_DUCT: 1.0, + ROUTE_CARRIER_KIND: 1.0, + ROUTE_CARRIER_KIND_AUXILIARY_PATH: 2.0, + ROUTE_CARRIER_KIND_ROUTING_RANGE: 8.0, + # 旧文档兼容:之前的贴面网格使用 SurfaceGrid。 + "SurfaceGrid": 8.0, + "UserPath": 1.0, +} + + +class RoutingNetworkError(RuntimeError): + pass + + +class _SimpleBoundBox: + def __init__(self, xmin, xmax, ymin, ymax, zmin, zmax): + self.XMin = float(xmin) + self.XMax = float(xmax) + self.YMin = float(ymin) + self.YMax = float(ymax) + self.ZMin = float(zmin) + self.ZMax = float(zmax) + + +def _vector(point): + if isinstance(point, App.Vector): + return App.Vector(point.x, point.y, point.z) + if isinstance(point, (list, tuple)) and len(point) >= 3: + return App.Vector(float(point[0]), float(point[1]), float(point[2])) + if isinstance(point, dict): + return App.Vector( + float(point.get("x", 0.0)), + float(point.get("y", 0.0)), + float(point.get("z", 0.0)), + ) + if all(hasattr(point, name) for name in ("x", "y", "z")): + return App.Vector(float(point.x), float(point.y), float(point.z)) + raise RoutingNetworkError("Route carrier point must be a 3D point.") + + +def _distance(left, right): + dx = float(left.x) - float(right.x) + dy = float(left.y) - float(right.y) + dz = float(left.z) - float(right.z) + return (dx * dx + dy * dy + dz * dz) ** 0.5 + + +def _direction_key(left, right, tolerance=DEFAULT_NODE_TOLERANCE): + dx = float(right.x) - float(left.x) + dy = float(right.y) - float(left.y) + dz = float(right.z) - float(left.z) + length = (dx * dx + dy * dy + dz * dz) ** 0.5 + if length <= tolerance: + return (0, 0, 0) + return ( + int(round(dx / length * 1000.0)), + int(round(dy / length * 1000.0)), + int(round(dz / length * 1000.0)), + ) + + +def _dominant_axis(vector): + components = { + "x": abs(float(getattr(vector, "x", 0.0))), + "y": abs(float(getattr(vector, "y", 0.0))), + "z": abs(float(getattr(vector, "z", 0.0))), + } + axis = max(components, key=components.get) + if components[axis] <= 0.000001: + return None + return axis + + +def _axis_value(point, axis): + return float(getattr(point, axis, 0.0)) + + +def _set_axis(point, axis, value): + return App.Vector( + float(value) if axis == "x" else float(point.x), + float(value) if axis == "y" else float(point.y), + float(value) if axis == "z" else float(point.z), + ) + + +def _bound_box_from_object(obj): + if obj is None: + return None + + shape = getattr(obj, "Shape", None) + bbox = getattr(shape, "BoundBox", None) + if bbox is not None: + return bbox + + bbox = getattr(obj, "BoundBox", None) + if bbox is not None: + return bbox + + merged = None + for child in list(getattr(obj, "Group", []) or []): + child_bbox = _bound_box_from_object(child) + if child_bbox is None: + continue + if merged is None: + merged = _SimpleBoundBox( + child_bbox.XMin, + child_bbox.XMax, + child_bbox.YMin, + child_bbox.YMax, + child_bbox.ZMin, + child_bbox.ZMax, + ) + continue + merged = _SimpleBoundBox( + min(merged.XMin, child_bbox.XMin), + max(merged.XMax, child_bbox.XMax), + min(merged.YMin, child_bbox.YMin), + max(merged.YMax, child_bbox.YMax), + min(merged.ZMin, child_bbox.ZMin), + max(merged.ZMax, child_bbox.ZMax), + ) + return merged + + +def _bbox_center(bbox): + return App.Vector( + (float(bbox.XMin) + float(bbox.XMax)) * 0.5, + (float(bbox.YMin) + float(bbox.YMax)) * 0.5, + (float(bbox.ZMin) + float(bbox.ZMax)) * 0.5, + ) + + +def _bbox_extent(bbox, axis): + low, high = _bbox_axis_range(bbox, axis) + return abs(high - low) + + +def _point_key(point, tolerance=DEFAULT_NODE_TOLERANCE): + scale = 1.0 / float(tolerance or DEFAULT_NODE_TOLERANCE) + return ( + int(round(float(point.x) * scale)), + int(round(float(point.y) * scale)), + int(round(float(point.z) * scale)), + ) + + +def _point_payload(point): + return { + "x": float(point.x), + "y": float(point.y), + "z": float(point.z), + } + + +def _unique_name(doc, base_name): + name = TerminalObjects.safe_token(base_name) + if doc.getObject(name) is None: + return name + suffix = 1 + while doc.getObject("{0}_{1}".format(name, suffix)) is not None: + suffix += 1 + return "{0}_{1}".format(name, suffix) + + +def _ensure_vector_list_property(obj, prop_name, description): + if prop_name not in getattr(obj, "PropertiesList", []): + obj.addProperty( + "App::PropertyVectorList", + prop_name, + PROPERTY_GROUP, + description, + ) + + +def _set_route_carrier_semantics(obj, project_uuid="", kind=ROUTE_CARRIER_KIND): + TerminalObjects.ensure_string_property( + obj, + "QetRoutingRole", + PROPERTY_GROUP, + "Routing role marker", + ROUTING_ROLE, + ) + TerminalObjects.ensure_string_property( + obj, + "QetRouteCarrierKind", + PROPERTY_GROUP, + "Route carrier kind", + kind, + ) + TerminalObjects.ensure_string_property( + obj, + "QetProjectUuid", + PROPERTY_GROUP, + "Project UUID for this route carrier", + project_uuid, + ) + TerminalObjects.ensure_bool_property( + obj, + "CanRouteWire", + PROPERTY_GROUP, + "Whether auto-routing can use this path", + True, + ) + return obj + + +def _create_carrier_geometry(doc, name, points): + # Draft 生成的线在 GUI 里更容易看见;命令行/测试环境没有 Draft 时退回 Part。 + if getattr(App, "ActiveDocument", None) is doc: + try: + import Draft + + obj = Draft.make_wire( + points, + closed=False, + placement=None, + face=None, + support=None, + bs2wire=False, + ) + if obj is not None: + return obj + except Exception: + pass + + try: + import Part + + obj = doc.addObject("Part::Feature", name) + obj.Shape = Part.makePolygon(points) + return obj + except Exception: + obj = doc.addObject("App::FeaturePython", name) + return obj + + +def create_route_carrier(doc, points, label="", project_uuid="", kind=ROUTE_CARRIER_KIND): + """Create a routable carrier from ordered 3D points.""" + if doc is None: + raise RoutingNetworkError("No FreeCAD document is available.") + + normalized = [] + for point in points or []: + vector = _vector(point) + if not normalized or _distance(normalized[-1], vector) > DEFAULT_NODE_TOLERANCE: + normalized.append(vector) + + if len(normalized) < 2: + raise RoutingNetworkError("A route carrier requires at least two distinct points.") + + name = _unique_name(doc, "QETRouteCarrier") + carrier = _create_carrier_geometry(doc, name, normalized) + carrier.Label = label or "QET Route Carrier" + _ensure_vector_list_property( + carrier, + "Points", + "Ordered centerline points used by the 3D router", + ) + carrier.Points = list(normalized) + _set_route_carrier_semantics(carrier, project_uuid=project_uuid, kind=kind) + + group = WiringObjects.ensure_carrier_group(doc, project_uuid) + if carrier not in getattr(group, "Group", []): + group.addObject(carrier) + + try: + carrier.ViewObject.Visibility = True + carrier.ViewObject.LineWidth = 3.0 + carrier.ViewObject.LineColor = (1.0, 0.55, 0.0) + except Exception: + pass + + try: + doc.recompute() + except Exception: + pass + return carrier + + +def is_route_carrier(obj): + if obj is None: + return False + role = (getattr(obj, "QetRoutingRole", "") or "").strip() + return role == ROUTING_ROLE and bool(getattr(obj, "CanRouteWire", False)) + + +def _carrier_points(obj): + points = list(getattr(obj, "Points", []) or []) + if points: + return [_vector(point) for point in points] + + shape = getattr(obj, "Shape", None) + ordered = getattr(shape, "OrderedVertexes", None) + if ordered: + return [_vector(vertex.Point) for vertex in ordered if getattr(vertex, "Point", None) is not None] + + vertexes = getattr(shape, "Vertexes", None) + if vertexes: + return [_vector(vertex.Point) for vertex in vertexes if getattr(vertex, "Point", None) is not None] + + return [] + + +def collect_route_carriers(doc): + if doc is None: + return [] + + group = None + try: + group = doc.getObject("QETWiring_02_Carriers") + except Exception: + group = None + + candidates = [] + if group is not None: + candidates.extend(list(getattr(group, "Group", []) or [])) + candidates.extend(list(getattr(doc, "Objects", []) or [])) + + result = [] + seen = set() + for obj in candidates: + if obj is None or id(obj) in seen: + continue + seen.add(id(obj)) + if is_route_carrier(obj): + result.append(obj) + return result + + +def _detach_from_groups(doc, obj): + for parent in list(getattr(obj, "InList", []) or []): + group = list(getattr(parent, "Group", []) or []) + if obj not in group: + continue + try: + if hasattr(parent, "removeObject"): + parent.removeObject(obj) + else: + parent.Group = [child for child in group if child is not obj] + except Exception: + try: + parent.Group = [child for child in group if child is not obj] + except Exception: + pass + + for parent in list(getattr(doc, "Objects", []) or []): + group = list(getattr(parent, "Group", []) or []) + if obj not in group: + continue + try: + if hasattr(parent, "removeObject"): + parent.removeObject(obj) + else: + parent.Group = [child for child in group if child is not obj] + except Exception: + try: + parent.Group = [child for child in group if child is not obj] + except Exception: + pass + + +def clear_route_carriers(doc): + """Delete generated route carriers while keeping terminals and routed wires.""" + removed = 0 + for carrier in list(collect_route_carriers(doc)): + _detach_from_groups(doc, carrier) + try: + if doc.getObject(getattr(carrier, "Name", "")) is not None: + doc.removeObject(carrier.Name) + removed += 1 + except Exception: + pass + try: + doc.recompute() + except Exception: + pass + return removed + + +def _shape_center(shape): + bbox = getattr(shape, "BoundBox", None) + if bbox is None: + return None + return App.Vector( + (float(bbox.XMin) + float(bbox.XMax)) * 0.5, + (float(bbox.YMin) + float(bbox.YMax)) * 0.5, + (float(bbox.ZMin) + float(bbox.ZMax)) * 0.5, + ) + + +def _edge_points(edge): + first = None + last = None + vertexes = list(getattr(edge, "Vertexes", []) or []) + if len(vertexes) >= 2: + first = getattr(vertexes[0], "Point", None) + last = getattr(vertexes[-1], "Point", None) + if first is not None and last is not None: + return [_vector(first), _vector(last)] + + try: + first = edge.valueAt(edge.FirstParameter) + last = edge.valueAt(edge.LastParameter) + return [_vector(first), _vector(last)] + except Exception: + return [] + + +def _is_route_path_source_object(obj): + if obj is None: + return False + type_id = (getattr(obj, "TypeId", "") or "").lower() + if "sketch" in type_id: + return True + if list(getattr(obj, "Points", []) or []): + return True + shape = getattr(obj, "Shape", None) + if shape is None: + return False + # SOLIDWORKS/EPLAN 的 routing path 是草图/线槽路径,不是把实体零件的全部边都当路径。 + # 所以只有纯线状对象才允许整对象转换;带 Face/Solid 的实体必须显式选中边。 + faces = list(getattr(shape, "Faces", []) or []) + solids = list(getattr(shape, "Solids", []) or []) + shells = list(getattr(shape, "Shells", []) or []) + if faces or solids or shells: + return False + return bool(list(getattr(shape, "Edges", []) or [])) + + +def _points_from_selection_item(selection_item): + points = [] + + for point in list(getattr(selection_item, "PickedPoints", []) or []): + points.append(_vector(point)) + + for sub_object in list(getattr(selection_item, "SubObjects", []) or []): + shape_type = (getattr(sub_object, "ShapeType", "") or "").lower() + if shape_type == "edge": + points.extend(_edge_points(sub_object)) + continue + if shape_type == "vertex": + point = getattr(sub_object, "Point", None) + if point is not None: + points.append(_vector(point)) + continue + center = _shape_center(sub_object) + if center is not None: + points.append(center) + + obj = getattr(selection_item, "Object", None) + shape = getattr(obj, "Shape", None) + if shape is not None and _is_route_path_source_object(obj): + for edge in list(getattr(shape, "Edges", []) or []): + points.extend(_edge_points(edge)) + if not points: + center = _shape_center(shape) + if center is not None: + points.append(center) + + normalized = [] + for point in points: + if not normalized or _distance(normalized[-1], point) > DEFAULT_NODE_TOLERANCE: + normalized.append(point) + return normalized + + +def _face_normal(face): + try: + return _vector(face.normalAt(0.5, 0.5)) + except Exception: + pass + try: + return _vector(face.normalAt(0.0, 0.0)) + except Exception: + pass + return None + + +def _bbox_axis_range(bbox, axis): + if axis == "x": + return float(bbox.XMin), float(bbox.XMax) + if axis == "y": + return float(bbox.YMin), float(bbox.YMax) + return float(bbox.ZMin), float(bbox.ZMax) + + +def _surface_grid_values(min_value, max_value, spacing, margin): + low = float(min_value) + float(margin) + high = float(max_value) - float(margin) + if high < low: + low = float(min_value) + high = float(max_value) + if abs(high - low) <= DEFAULT_NODE_TOLERANCE: + return [low] + + spacing = max(float(spacing or DEFAULT_SURFACE_LANE_SPACING), 1.0) + values = [low] + current = low + spacing + while current < high - DEFAULT_NODE_TOLERANCE: + values.append(current) + current += spacing + if abs(values[-1] - high) > DEFAULT_NODE_TOLERANCE: + values.append(high) + return values + + +def _surface_face_grid_points(face, spacing, offset, margin): + normal = _face_normal(face) + bbox = getattr(face, "BoundBox", None) + if normal is None or bbox is None: + return [] + + normal_axis = _dominant_axis(normal) + if normal_axis is None: + return [] + + tangent_axes = [axis for axis in ("x", "y", "z") if axis != normal_axis] + first_axis, second_axis = tangent_axes[0], tangent_axes[1] + first_min, first_max = _bbox_axis_range(bbox, first_axis) + second_min, second_max = _bbox_axis_range(bbox, second_axis) + normal_min, normal_max = _bbox_axis_range(bbox, normal_axis) + normal_sign = 1.0 if _axis_value(normal, normal_axis) >= 0.0 else -1.0 + plane_value = ((normal_min + normal_max) * 0.5) + normal_sign * float(offset or 0.0) + + first_values = _surface_grid_values(first_min, first_max, spacing, margin) + second_values = _surface_grid_values(second_min, second_max, spacing, margin) + if len(first_values) < 2 or len(second_values) < 2: + return [] + + rows = [] + for second_value in second_values: + row = [] + for first_value in first_values: + point = App.Vector(0, 0, 0) + point = _set_axis(point, normal_axis, plane_value) + point = _set_axis(point, first_axis, first_value) + point = _set_axis(point, second_axis, second_value) + row.append(point) + rows.append(row) + + columns = [] + for first_index in range(len(first_values)): + column = [] + for row in rows: + column.append(row[first_index]) + columns.append(column) + + # 行和列都要生成 carrier,Dijkstra 才能在网格交点处横竖换向。 + return rows + columns + + +def create_carriers_from_selection(doc, selection_ex, project_uuid="", kind=ROUTE_CARRIER_KIND): + created = [] + for index, item in enumerate(selection_ex or [], start=1): + points = _points_from_selection_item(item) + if len(points) < 2: + continue + created.append( + create_route_carrier( + doc, + points, + label="QET Route Carrier {0}".format(index), + project_uuid=project_uuid, + kind=kind, + ) + ) + return created + + +def _wire_duct_centerline_from_bbox(bbox, margin=DEFAULT_WIRE_DUCT_MARGIN, min_aspect=1.5): + extents = { + axis: _bbox_extent(bbox, axis) + for axis in ("x", "y", "z") + } + main_axis = max(extents, key=extents.get) + sorted_extents = sorted(extents.values(), reverse=True) + if sorted_extents[0] <= DEFAULT_NODE_TOLERANCE: + return [] + if len(sorted_extents) > 1 and sorted_extents[1] > DEFAULT_NODE_TOLERANCE: + if sorted_extents[0] / sorted_extents[1] < float(min_aspect or 1.0): + return [] + + low, high = _bbox_axis_range(bbox, main_axis) + center = _bbox_center(bbox) + usable_margin = max(float(margin or 0.0), 0.0) + if abs(high - low) <= usable_margin * 2.0: + usable_margin = 0.0 + + start = _set_axis(center, main_axis, low + usable_margin) + end = _set_axis(center, main_axis, high - usable_margin) + if _distance(start, end) <= DEFAULT_NODE_TOLERANCE: + return [] + return [start, end] + + +def _wire_duct_sources_from_selection(selection_ex): + sources = [] + seen = set() + for item in selection_ex or []: + obj = getattr(item, "Object", None) + if obj is not None and id(obj) not in seen: + seen.add(id(obj)) + sources.append(obj) + continue + for sub_object in list(getattr(item, "SubObjects", []) or []): + if sub_object is None or id(sub_object) in seen: + continue + seen.add(id(sub_object)) + sources.append(sub_object) + return sources + + +def _mark_wire_duct_source(source, carrier): + if source is None: + return + try: + TerminalObjects.ensure_string_property( + source, + "QetRoutingSourceKind", + PROPERTY_GROUP, + "Routing source kind", + ROUTE_CARRIER_KIND_WIRE_DUCT, + ) + TerminalObjects.ensure_string_property( + source, + "QetRoutingObstacleMode", + PROPERTY_GROUP, + "How auto-routing collision checks should treat this object", + WIRE_DUCT_OBSTACLE_MODE, + ) + TerminalObjects.ensure_string_property( + source, + "QetRouteCarrierName", + PROPERTY_GROUP, + "Generated route carrier for this source", + getattr(carrier, "Name", ""), + ) + except Exception: + pass + + +def create_wire_duct_carriers_from_selection( + doc, + selection_ex, + project_uuid="", + margin=DEFAULT_WIRE_DUCT_MARGIN, + min_aspect=1.5, +): + """Create WireDuct centerline carriers from selected duct-like solids.""" + created = [] + for index, source in enumerate(_wire_duct_sources_from_selection(selection_ex), start=1): + bbox = _bound_box_from_object(source) + if bbox is None: + continue + points = _wire_duct_centerline_from_bbox( + bbox, + margin=margin, + min_aspect=min_aspect, + ) + if len(points) < 2: + continue + label = getattr(source, "Label", "") or getattr(source, "Name", "") or "Wire Duct" + carrier = create_route_carrier( + doc, + points, + label="QET Wire Duct Centerline {0}".format(label), + project_uuid=project_uuid, + kind=ROUTE_CARRIER_KIND_WIRE_DUCT, + ) + _mark_wire_duct_source(source, carrier) + created.append(carrier) + return created + + +def create_surface_carriers_from_selection( + doc, + selection_ex, + project_uuid="", + spacing=DEFAULT_SURFACE_LANE_SPACING, + offset=DEFAULT_SURFACE_OFFSET, + margin=DEFAULT_SURFACE_MARGIN, +): + """Create a supported route grid on selected planar cabinet/panel faces.""" + created = [] + for item in selection_ex or []: + for sub_object in list(getattr(item, "SubObjects", []) or []): + shape_type = (getattr(sub_object, "ShapeType", "") or "").lower() + if shape_type != "face": + continue + grids = _surface_face_grid_points( + sub_object, + spacing=spacing, + offset=offset, + margin=margin, + ) + for index, points in enumerate(grids, start=1): + if len(points) < 2: + continue + carrier = create_route_carrier( + doc, + points, + label="QET Surface Route {0}".format(index), + project_uuid=project_uuid, + kind=ROUTE_CARRIER_KIND_ROUTING_RANGE, + ) + created.append(carrier) + return created + + +def _carrier_cost_factor(carrier, kind_cost_factors=None): + kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() + factors = dict(DEFAULT_KIND_COST_FACTORS) + if isinstance(kind_cost_factors, dict): + factors.update(kind_cost_factors) + try: + return max(float(factors.get(kind, 1.0)), 0.01) + except Exception: + return 1.0 + + +def build_route_graph(doc, tolerance=DEFAULT_NODE_TOLERANCE): + """Build an undirected graph from every enabled route carrier.""" + nodes = {} + edges = {} + carriers = collect_route_carriers(doc) + segment_count = 0 + + def ensure_node(point): + key = _point_key(point, tolerance=tolerance) + if key not in nodes: + nodes[key] = point + edges[key] = [] + return key + + for carrier in carriers: + points = _carrier_points(carrier) + if len(points) < 2: + continue + previous_key = ensure_node(points[0]) + previous_point = nodes[previous_key] + for point in points[1:]: + current_key = ensure_node(point) + current_point = nodes[current_key] + weight = _distance(previous_point, current_point) + if weight > tolerance: + edges[previous_key].append((current_key, weight, carrier)) + edges[current_key].append((previous_key, weight, carrier)) + segment_count += 1 + previous_key = current_key + previous_point = current_point + + return { + "nodes": nodes, + "edges": edges, + "carriers": carriers, + "carrier_count": len(carriers), + "segment_count": segment_count, + "tolerance": tolerance, + } + + +def nearest_node(network, point): + nodes = network.get("nodes", {}) if isinstance(network, dict) else {} + if not nodes: + return None, None + target = _vector(point) + best_key = None + best_distance = None + for key, node_point in nodes.items(): + distance = _distance(target, node_point) + if best_distance is None or distance < best_distance: + best_key = key + best_distance = distance + return best_key, best_distance + + +def shortest_path(network, start_key, end_key, bend_penalty=0.0, kind_cost_factors=None): + """Dijkstra search with a small extra cost when route direction changes.""" + if start_key is None or end_key is None: + return None + if start_key == end_key: + return [start_key] + + nodes = network.get("nodes", {}) + edges = network.get("edges", {}) + queue = [] + counter = 0 + start_state = (start_key, None) + distances = {start_state: 0.0} + previous = {} + heapq.heappush(queue, (0.0, counter, start_key, None)) + + while queue: + cost, _counter, key, previous_direction = heapq.heappop(queue) + state = (key, previous_direction) + if cost > distances.get(state, float("inf")): + continue + if key == end_key: + path = [key] + current_state = state + while current_state in previous: + current_state = previous[current_state] + path.append(current_state[0]) + path.reverse() + return path + + for next_key, weight, carrier in edges.get(key, []): + direction = _direction_key(nodes[key], nodes[next_key]) + bend_cost = 0.0 + if previous_direction is not None and direction != previous_direction: + bend_cost = float(bend_penalty or 0.0) + next_state = (next_key, direction) + next_cost = cost + float(weight) * _carrier_cost_factor(carrier, kind_cost_factors) + bend_cost + if next_cost < distances.get(next_state, float("inf")): + distances[next_state] = next_cost + previous[next_state] = state + counter += 1 + heapq.heappush(queue, (next_cost, counter, next_key, direction)) + + return None + + +def path_points(network, path_keys): + nodes = network.get("nodes", {}) if isinstance(network, dict) else {} + return [nodes[key] for key in path_keys or [] if key in nodes] + + +def network_summary(doc): + network = build_route_graph(doc) + kinds = {} + for carrier in network.get("carriers", []) or []: + kind = (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() or ROUTE_CARRIER_KIND + kinds[kind] = kinds.get(kind, 0) + 1 + return { + "carriers": int(network.get("carrier_count", 0)), + "segments": int(network.get("segment_count", 0)), + "nodes": len(network.get("nodes", {})), + "kinds": kinds, + } + + +def carrier_payload(carrier): + return { + "name": getattr(carrier, "Name", ""), + "label": getattr(carrier, "Label", ""), + "kind": getattr(carrier, "QetRouteCarrierKind", ""), + "points": [_point_payload(point) for point in _carrier_points(carrier)], + } diff --git a/tests/manual/freecad_auto_routing_smoke.py b/tests/manual/freecad_auto_routing_smoke.py new file mode 100644 index 0000000..d0eebeb --- /dev/null +++ b/tests/manual/freecad_auto_routing_smoke.py @@ -0,0 +1,96 @@ +import json +import os +import sys + +import FreeCAD as App + + +REPO_ROOT = r"D:\LightWork3D" +MODULE_DIR = os.path.join(REPO_ROOT, "src", "Mod", "FreeCADExchange") +OUT_DIR = os.path.join(REPO_ROOT, "tests", "out") +OUT_FCSTD = os.path.join(OUT_DIR, "auto_routing_smoke.FCStd") +OUT_JSON = os.path.join(OUT_DIR, "auto_routing_smoke_result.json") + +if MODULE_DIR not in sys.path: + sys.path.insert(0, MODULE_DIR) + +import AutoRouting +import RoutingNetwork +import TerminalObjects +import WiringObjects + + +def _make_terminal(doc, name, terminal_uuid, point): + terminal = TerminalObjects.create_lcs_object( + doc, + name, + placement=App.Placement(point, App.Rotation()), + label=terminal_uuid, + ) + TerminalObjects.set_terminal_semantics( + terminal, + "project-smoke", + "element-" + terminal_uuid, + terminal_uuid, + "instance-" + terminal_uuid, + label=terminal_uuid, + ) + return terminal + + +def _point_payload(point): + return { + "x": float(point.x), + "y": float(point.y), + "z": float(point.z), + } + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + doc = App.newDocument("AutoRoutingSmoke") + App.setActiveDocument(doc.Name) + TerminalObjects.ensure_root_group(doc, "project-smoke") + WiringObjects.initialize_wiring_scene(doc, "project-smoke") + + start = _make_terminal(doc, "SmokeTerminalStart", "terminal-start", App.Vector(0, 0, 0)) + end = _make_terminal(doc, "SmokeTerminalEnd", "terminal-end", App.Vector(160, 0, 0)) + + RoutingNetwork.create_route_carrier( + doc, + [ + App.Vector(0, 0, 20), + App.Vector(0, 60, 20), + App.Vector(160, 60, 20), + App.Vector(160, 0, 20), + ], + label="Smoke Route Carrier", + project_uuid="project-smoke", + ) + + obstacle = doc.addObject("Part::Box", "SmokeObstacle") + obstacle.Label = "Smoke Obstacle" + obstacle.Length = 40 + obstacle.Width = 40 + obstacle.Height = 60 + obstacle.Placement = App.Placement(App.Vector(60, -20, -10), App.Rotation()) + doc.recompute() + + result = AutoRouting.route_between_terminals(doc, start, end) + payload = { + "algorithm": result["algorithm"], + "route_status": result["route_status"], + "collision_count": result["collision_count"], + "points": [_point_payload(point) for point in result["points"]], + "network": RoutingNetwork.network_summary(doc), + "scene": OUT_FCSTD, + } + + doc.saveAs(OUT_FCSTD) + with open(OUT_JSON, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tests/python/freecad_exchange_auto_routing_test.py b/tests/python/freecad_exchange_auto_routing_test.py new file mode 100644 index 0000000..a46edee --- /dev/null +++ b/tests/python/freecad_exchange_auto_routing_test.py @@ -0,0 +1,460 @@ +import importlib +import sys +import types +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +MODULE_DIR = REPO_ROOT / "src" / "Mod" / "FreeCADExchange" +if str(MODULE_DIR) not in sys.path: + sys.path.insert(0, str(MODULE_DIR)) + + +def _install_fake_freecad(): + class Vector: + def __init__(self, x=0.0, y=0.0, z=0.0): + self.x = float(x) + self.y = float(y) + self.z = float(z) + + class Rotation: + def multVec(self, vector): + return vector + + class Placement: + def __init__(self, base=None, rotation=None): + self.Base = base or Vector() + self.Rotation = rotation or Rotation() + + def multVec(self, vector): + return Vector( + self.Base.x + vector.x, + self.Base.y + vector.y, + self.Base.z + vector.z, + ) + + fake_freecad = types.ModuleType("FreeCAD") + fake_freecad.Vector = Vector + fake_freecad.Rotation = Rotation + fake_freecad.Placement = Placement + fake_freecad.ActiveDocument = None + fake_freecad.Console = types.SimpleNamespace( + PrintMessage=lambda *args, **kwargs: None, + PrintWarning=lambda *args, **kwargs: None, + PrintError=lambda *args, **kwargs: None, + ) + sys.modules["FreeCAD"] = fake_freecad + + fake_gui = types.ModuleType("FreeCADGui") + fake_gui.addCommand = lambda *args, **kwargs: None + fake_gui.SendMsgToActiveView = lambda *args, **kwargs: None + fake_gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [], + ) + sys.modules["FreeCADGui"] = fake_gui + + fake_part = types.ModuleType("Part") + fake_part.makePolygon = lambda points: tuple(points) + sys.modules["Part"] = fake_part + + +class FakeViewObject: + def __init__(self): + self.Visibility = True + self.LineWidth = None + self.LineColor = None + + +class FakeObject: + def __init__(self, name, type_id, doc=None): + self.Name = name + self.Label = name + self.TypeId = type_id + self.Document = doc + self.PropertiesList = [] + self.Group = [] + self.ViewObject = FakeViewObject() + self.Shape = None + self.Points = [] + self.Placement = sys.modules["FreeCAD"].Placement() + self.InList = [] + + def isDerivedFrom(self, type_name): + if self.TypeId == type_name: + return True + if type_name == "App::DocumentObjectGroup": + return self.TypeId == "App::DocumentObjectGroup" + if type_name == "App::LocalCoordinateSystem": + return self.TypeId in {"Part::LocalCoordinateSystem", "PartDesign::CoordinateSystem"} + return False + + def addProperty(self, prop_type, prop_name, group_name, description): + if prop_name not in self.PropertiesList: + self.PropertiesList.append(prop_name) + + def addObject(self, child): + if child not in self.Group: + self.Group.append(child) + if self not in child.InList: + child.InList.append(self) + + +class FakeDocument: + def __init__(self): + self.Objects = [] + self.Name = "FakeDoc" + + def addObject(self, type_name, name): + obj = FakeObject(name, type_name, doc=self) + self.Objects.append(obj) + return obj + + def getObject(self, name): + for obj in self.Objects: + if obj.Name == name: + return obj + return None + + def removeObject(self, name): + self.Objects = [obj for obj in self.Objects if obj.Name != name] + + def recompute(self): + return None + + +class FakeBoundBox: + def __init__(self, xmin, xmax, ymin, ymax, zmin, zmax): + self.XMin = xmin + self.XMax = xmax + self.YMin = ymin + self.YMax = ymax + self.ZMin = zmin + self.ZMax = zmax + + +class FakeShape: + def __init__(self, bbox, edges=None, faces=None): + self.BoundBox = bbox + self.Edges = edges or [] + self.Faces = faces or [] + self.Solids = [] + self.Shells = [] + + +class FakeVertex: + def __init__(self, point): + self.Point = point + + +class FakeEdge: + ShapeType = "Edge" + + def __init__(self, start, end): + self.Vertexes = [FakeVertex(start), FakeVertex(end)] + + +class FakeFace: + ShapeType = "Face" + + def __init__(self, bbox, normal): + self.BoundBox = bbox + self._normal = normal + + def normalAt(self, _u, _v): + return self._normal + + +class FakeSelectionItem: + def __init__(self, sub_objects=None, obj=None): + self.SubObjects = sub_objects or [] + self.PickedPoints = [] + self.Object = obj + + +def _reload_modules(): + for name in [ + "TerminalObjects", + "WiringObjects", + "RoutingNetwork", + "AutoRouting", + "AutoRoutingPanel", + ]: + sys.modules.pop(name, None) + terminal_objects = importlib.import_module("TerminalObjects") + wiring_objects = importlib.import_module("WiringObjects") + routing_network = importlib.import_module("RoutingNetwork") + auto_routing = importlib.import_module("AutoRouting") + return terminal_objects, wiring_objects, routing_network, auto_routing + + +def _terminal(doc, terminal_objects, name, terminal_uuid, point): + app = sys.modules["FreeCAD"] + terminal = doc.addObject("Part::LocalCoordinateSystem", name) + terminal.Placement = app.Placement(point, app.Rotation()) + terminal_objects.set_terminal_semantics( + terminal, + "project-1", + "element-{0}".format(terminal_uuid), + terminal_uuid, + "instance-{0}".format(terminal_uuid), + label=terminal_uuid, + ) + return terminal + + +class AutoRoutingTest(unittest.TestCase): + def test_auto_route_selected_terminals_requires_supported_route_by_default(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 20, 0)) + + with self.assertRaises(auto_routing.AutoRoutingError): + auto_routing.route_between_terminals(doc, start, end) + + def test_auto_route_can_still_use_explicit_floating_fallback_for_debug(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 20, 0)) + + result = auto_routing.route_between_terminals( + doc, + start, + end, + options={"allow_floating_fallback": True}, + ) + + wire = result["wire"] + self.assertEqual("orthogonal-v1", result["algorithm"]) + self.assertEqual("AutoSuggested", wire.RouteType) + self.assertEqual("Auto", wire.RouteMode) + self.assertEqual("Routed", wire.RouteStatus) + self.assertGreaterEqual(len(wire.Points), 4) + self.assertIn(wire, doc.getObject("QETWiring_04_Routed").Group) + + def test_auto_route_prefers_user_route_carrier_network(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 0, 0)) + routing_network.create_route_carrier( + doc, + [ + app.Vector(0, 0, 20), + app.Vector(0, 30, 20), + app.Vector(120, 30, 20), + app.Vector(120, 0, 20), + ], + project_uuid="project-1", + ) + + result = auto_routing.route_between_terminals(doc, start, end) + + self.assertEqual("network-dijkstra-v1", result["algorithm"]) + self.assertEqual("Routed", result["route_status"]) + self.assertTrue(any(point.y == 30.0 for point in result["points"])) + + def test_auto_route_prefers_wire_duct_over_auxiliary_range(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(120, 0, 20)], + project_uuid="project-1", + kind="RoutingRange", + ) + routing_network.create_route_carrier( + doc, + [ + app.Vector(0, 0, 20), + app.Vector(0, 40, 20), + app.Vector(120, 40, 20), + app.Vector(120, 0, 20), + ], + project_uuid="project-1", + kind="WireDuct", + ) + + result = auto_routing.route_between_terminals(doc, start, end) + + self.assertEqual("network-dijkstra-v1", result["algorithm"]) + self.assertTrue(any(point.y == 40.0 for point in result["points"])) + + def test_surface_carrier_grid_supports_backplate_routing(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 0, 0)) + face = FakeFace( + FakeBoundBox(0, 120, -20, 120, -1, -1), + app.Vector(0, 0, 1), + ) + + created = routing_network.create_surface_carriers_from_selection( + doc, + [FakeSelectionItem([face])], + project_uuid="project-1", + spacing=60.0, + offset=5.0, + margin=0.0, + ) + result = auto_routing.route_between_terminals(doc, start, end) + + self.assertGreater(len(created), 0) + self.assertEqual("RoutingRange", getattr(created[0], "QetRouteCarrierKind", "")) + self.assertEqual("network-dijkstra-v1", result["algorithm"]) + self.assertTrue(any(point.z == 4.0 for point in result["points"])) + + def test_route_path_creation_ignores_whole_solid_object_edges(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + solid = doc.addObject("Part::Feature", "CabinetSolid") + solid.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 100, 0, 10), + edges=[FakeEdge(app.Vector(0, 0, 0), app.Vector(100, 0, 0))], + faces=[object()], + ) + + created = routing_network.create_carriers_from_selection( + doc, + [FakeSelectionItem(obj=solid)], + project_uuid="project-1", + ) + + self.assertEqual([], created) + + def test_wire_duct_entity_generates_centerline_and_marks_source_pass_through(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + duct = doc.addObject("Part::Feature", "WireDuct") + duct.Shape = FakeShape(FakeBoundBox(0, 120, -10, 10, 5, 25)) + + created = routing_network.create_wire_duct_carriers_from_selection( + doc, + [FakeSelectionItem(obj=duct)], + project_uuid="project-1", + margin=20.0, + ) + + self.assertEqual(1, len(created)) + carrier = created[0] + self.assertEqual("WireDuct", carrier.QetRouteCarrierKind) + self.assertEqual("PassThrough", duct.QetRoutingObstacleMode) + self.assertEqual([(20.0, 0.0, 15.0), (100.0, 0.0, 15.0)], [(p.x, p.y, p.z) for p in carrier.Points]) + + def test_wire_duct_source_is_not_reported_as_collision(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(120, 0, 0)) + duct = doc.addObject("Part::Feature", "WireDuct") + duct.Shape = FakeShape(FakeBoundBox(-10, 130, -10, 10, 15, 25)) + routing_network.create_wire_duct_carriers_from_selection( + doc, + [FakeSelectionItem(obj=duct)], + project_uuid="project-1", + margin=0.0, + ) + + result = auto_routing.route_between_terminals(doc, start, end) + + self.assertEqual("network-dijkstra-v1", result["algorithm"]) + self.assertEqual("Routed", result["route_status"]) + self.assertEqual(0, result["collision_count"]) + + def test_auto_route_marks_collision_warning_against_obstacle_bbox(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + _routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 100), app.Vector(100, 0, 100)], + project_uuid="project-1", + ) + obstacle = doc.addObject("Part::Feature", "Obstacle") + obstacle.Shape = FakeShape(FakeBoundBox(40, 60, -10, 10, 90, 110)) + + result = auto_routing.route_between_terminals(doc, start, end) + + self.assertEqual("CollisionWarning", result["route_status"]) + self.assertEqual("CollisionWarning", result["wire"].RouteStatus) + self.assertEqual(1, result["collision_count"]) + + def test_route_all_from_payload_skips_missing_terminal(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + payload = { + "wires": [ + { + "wire_id": "wire-1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-missing", + } + ] + } + + report = auto_routing.route_all_from_payload(doc, payload) + + self.assertEqual(0, report["routed"]) + self.assertEqual(1, report["skipped_missing_terminal"]) + + def test_clear_route_carriers_keeps_routed_wires(self): + _install_fake_freecad() + terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + ) + wire = auto_routing.route_between_terminals(doc, start, end)["wire"] + + removed = routing_network.clear_route_carriers(doc) + + self.assertEqual(1, removed) + self.assertEqual([], routing_network.collect_route_carriers(doc)) + self.assertIn(wire, wiring_objects.ensure_routed_group(doc, "project-1").Group) + + +if __name__ == "__main__": + unittest.main()