diff --git a/docs/2D-3D交换协议.md b/docs/2D-3D交换协议.md index ff20fbd..dddacae 100644 --- a/docs/2D-3D交换协议.md +++ b/docs/2D-3D交换协议.md @@ -293,13 +293,13 @@ "group_uuid": "string", "wire_mark": "string", "wire_mark_is_manual": false, + "wire_style_id": 0, "start_element_uuid": "string", "start_terminal_uuid": "string", "end_element_uuid": "string", "end_terminal_uuid": "string", "start_terminal_display": "string", - "end_terminal_display": "string", - "conductor_uuids": [] + "end_terminal_display": "string" } ``` @@ -307,18 +307,18 @@ | 字段 | 中文 | 必需 | 说明 | | --- | --- | --- | --- | -| `wire_id` | 导线交换ID | 是 | JSON 交换层稳定标识;优先使用单根导线 UUID,退回 `net_uuid + index` | +| `wire_id` | 导线交换ID | 是 | JSON 交换层稳定标识;按 `DirectionInfo` 生成,格式为 `direction:${net_uuid}:${index}` | | `net_uuid` | 网络UUID | 否 | 当前逻辑导线所属网络 | | `group_uuid` | 网络分组UUID | 否 | 当前逻辑导线所属网络分组 | | `wire_mark` | 导线标注 | 否 | 导线当前标注;为空时导出为 `无标注导线` | | `wire_mark_is_manual` | 导线标注是否手工 | 否 | 是否手工修改过导线标注 | +| `wire_style_id` | 导线样式ID | 否 | 取 `start_terminal` 所连接导线的样式 ID | | `start_element_uuid` | 起点设备UUID | 是 | 起点端子所属 2D 设备实例 | | `start_terminal_uuid` | 起点端子UUID | 是 | 起点 2D 端子实例 | | `end_element_uuid` | 终点设备UUID | 是 | 终点端子所属 2D 设备实例 | | `end_terminal_uuid` | 终点端子UUID | 是 | 终点 2D 端子实例 | | `start_terminal_display` | 起点端子显示号 | 否 | 起点端子在 QET 中的显示编号 | | `end_terminal_display` | 终点端子显示号 | 否 | 终点端子在 QET 中的显示编号 | -| `conductor_uuids` | 几何导线UUID列表 | 否 | 当前逻辑导线对应的 2D 几何导线 UUID 列表 | ### 8.4 说明 @@ -326,6 +326,12 @@ - 它是**导线标注** - 不是设备实例标注,也不是符号设备标注 +- `wire_id` 代表一条 `DirectionInfo` +- 不再混入几何 `Conductor` UUID 作为导线主标识 + +- `wire_style_id` 只取 `start_terminal` 所连接导线的样式 +- 不按整条几何路径聚合多个样式 + - `wires` 是交换 JSON 的扩展层 - 不意味着第一版数据库绑定表要新增导线绑定表 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/docs/FreeCAD Windows 编译运行指南.md b/docs/FreeCAD Windows 编译运行指南.md index 30a6a63..344654b 100644 --- a/docs/FreeCAD Windows 编译运行指南.md +++ b/docs/FreeCAD Windows 编译运行指南.md @@ -166,7 +166,7 @@ cmake --build $build --config RelWithDebInfo --parallel 20 编译完成后执行: ```powershell -cmake --install $build --config RelWithDebInfo --prefix $run +cmake --install $build --config RelWithDebInfo --prefix $run --parallel 20 ``` 执行完以后,运行: diff --git a/src/Gui/Icons/freecadsplash.png b/src/Gui/Icons/freecadsplash.png index 8e12288..d2882b3 100644 Binary files a/src/Gui/Icons/freecadsplash.png and b/src/Gui/Icons/freecadsplash.png differ diff --git a/src/Gui/Icons/freecadsplash_2x.png b/src/Gui/Icons/freecadsplash_2x.png index ea4bf27..2176c52 100644 Binary files a/src/Gui/Icons/freecadsplash_2x.png and b/src/Gui/Icons/freecadsplash_2x.png differ 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/DeviceImport.py b/src/Mod/FreeCADExchange/DeviceImport.py index b4ea5f9..053e19a 100644 --- a/src/Mod/FreeCADExchange/DeviceImport.py +++ b/src/Mod/FreeCADExchange/DeviceImport.py @@ -166,6 +166,30 @@ def _ensure_child_group(doc, parent_group, element_uuid, instance_id, name_prefi def _ensure_document(scene_path): preferred_name = _safe_token(Path(scene_path).stem if scene_path else "QETScene")[:48] or "QETScene" + normalized_scene_path = _native_path(scene_path) + if normalized_scene_path and os.path.isfile(normalized_scene_path): + normalized_target = os.path.normcase(os.path.normpath(normalized_scene_path)) + for candidate in App.listDocuments().values(): + candidate_path = getattr(candidate, "FileName", "") or "" + if candidate_path and os.path.normcase(os.path.normpath(candidate_path)) == normalized_target: + _activate_document(candidate) + return candidate + + try: + doc = App.openDocument(normalized_scene_path) + except Exception as exc: + raise DeviceImportError( + "Cannot open existing FreeCAD scene file: {0}".format(normalized_scene_path) + ) from exc + + if doc is None: + raise DeviceImportError( + "Cannot open existing FreeCAD scene file: {0}".format(normalized_scene_path) + ) + + _activate_document(doc) + return doc + existing_doc = DevicePreview.find_main_exchange_document(preferred_name) if existing_doc is not None: _activate_document(existing_doc) diff --git a/src/Mod/FreeCADExchange/ExchangeBootstrap.py b/src/Mod/FreeCADExchange/ExchangeBootstrap.py index 3a6582a..b41ba02 100644 --- a/src/Mod/FreeCADExchange/ExchangeBootstrap.py +++ b/src/Mod/FreeCADExchange/ExchangeBootstrap.py @@ -371,6 +371,7 @@ def _normalize_terminals(payload): "terminal_uuid": terminal_uuid, "instance_id": _normalize_instance_id(item), "element_uuid": element_uuid.strip() if isinstance(element_uuid, str) else "", + "terminal_display": _optional_string(item, "terminal_display", "terminal entry #{0}".format(index)), } ) return normalized 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/ManualWiring.py b/src/Mod/FreeCADExchange/ManualWiring.py index 38a3b20..4f17bd5 100644 --- a/src/Mod/FreeCADExchange/ManualWiring.py +++ b/src/Mod/FreeCADExchange/ManualWiring.py @@ -100,6 +100,14 @@ def _dominant_axis(vector): return axis +def _point_payload(point): + return { + "x": float(getattr(point, "x", 0.0)), + "y": float(getattr(point, "y", 0.0)), + "z": float(getattr(point, "z", 0.0)), + } + + def _coerce_waypoint(point_like): if isinstance(point_like, dict): point = _vector_from_point( @@ -129,7 +137,10 @@ def _coerce_waypoint(point_like): "point": point, "support_axis": support_axis, "anchor_kind": (point_like.get("anchor_kind", "") or "").strip(), + "carrier_kind": (point_like.get("carrier_kind", "") or "").strip(), + "carrier_axis": (point_like.get("carrier_axis", "") or "").strip().lower(), "source_label": (point_like.get("source_label", "") or "").strip(), + "source_object_name": (point_like.get("source_object_name", "") or "").strip(), "subelement_name": (point_like.get("subelement_name", "") or "").strip(), } @@ -140,7 +151,10 @@ def _coerce_waypoint(point_like): "point": point, "support_axis": None, "anchor_kind": "", + "carrier_kind": "", + "carrier_axis": "", "source_label": "", + "source_object_name": "", "subelement_name": "", } @@ -209,31 +223,329 @@ def _manual_waypoints_payload(waypoints): }, "support_axis": waypoint.get("support_axis", ""), "anchor_kind": waypoint.get("anchor_kind", ""), + "carrier_kind": waypoint.get("carrier_kind", ""), + "carrier_axis": waypoint.get("carrier_axis", ""), "source_label": waypoint.get("source_label", ""), + "source_object_name": waypoint.get("source_object_name", ""), "subelement_name": waypoint.get("subelement_name", ""), } ) return payload +def _route_node_payload( + role, + point, + terminal=None, + waypoint=None, + waypoint_index=None, +): + payload = { + "role": role, + "point": _point_payload(point), + } + if terminal is not None: + payload["terminal_uuid"] = getattr(terminal, "QetTerminalUuid", "").strip() + payload["instance_id"] = getattr(terminal, "QetInstanceId", "").strip() + if waypoint is not None: + payload["waypoint_index"] = int(waypoint_index or 0) + payload["support_axis"] = waypoint.get("support_axis", "") or "" + payload["anchor_kind"] = waypoint.get("anchor_kind", "") or "" + payload["carrier_kind"] = waypoint.get("carrier_kind", "") or "" + payload["carrier_axis"] = waypoint.get("carrier_axis", "") or "" + payload["source_label"] = waypoint.get("source_label", "") or "" + payload["source_object_name"] = waypoint.get("source_object_name", "") or "" + payload["subelement_name"] = waypoint.get("subelement_name", "") or "" + return payload + + +def _manual_route_nodes( + start_terminal, + end_terminal, + normalized_waypoints=None, + terminal_exit_length=0.0, +): + start_origin = TerminalObjects.terminal_origin(start_terminal) + end_origin = TerminalObjects.terminal_origin(end_terminal) + exit_length = max(float(terminal_exit_length or 0.0), 0.0) + + nodes = [ + _route_node_payload("start_terminal", start_origin, terminal=start_terminal) + ] + if exit_length > 0: + nodes.append( + _route_node_payload( + "start_exit", + _offset_point( + start_origin, + _terminal_exit_direction(start_terminal), + exit_length, + ), + terminal=start_terminal, + ) + ) + + for index, waypoint in enumerate(normalized_waypoints or [], start=1): + nodes.append( + _route_node_payload( + "waypoint", + waypoint["point"], + waypoint=waypoint, + waypoint_index=index, + ) + ) + + if exit_length > 0: + nodes.append( + _route_node_payload( + "end_exit", + _offset_point( + end_origin, + _terminal_exit_direction(end_terminal), + exit_length, + ), + terminal=end_terminal, + ) + ) + nodes.append(_route_node_payload("end_terminal", end_origin, terminal=end_terminal)) + return nodes + + +def _json_array_value(text): + if not text: + return [] + try: + value = json.loads(text) + except Exception: + return [] + if isinstance(value, list): + return value + return [] + + +def _diagnostic(severity, code, message): + return { + "severity": severity, + "code": code, + "message": message, + } + + +def diagnose_manual_wire(wire_obj): + diagnostics = [] + points = WiringObjects.wire_shape_points(wire_obj) + if len(points) < 2: + diagnostics.append( + _diagnostic("error", "wire_points_missing", "导线至少需要两个几何点。") + ) + + route_nodes = _json_array_value(getattr(wire_obj, "QetRouteNodesJson", "")) + if not route_nodes: + diagnostics.append( + _diagnostic("warning", "route_nodes_missing", "导线缺少语义路线节点。") + ) + return diagnostics + + roles = [str(node.get("role", "")) for node in route_nodes if isinstance(node, dict)] + if not roles or roles[0] != "start_terminal": + diagnostics.append( + _diagnostic("warning", "start_route_node_missing", "导线缺少起点端子路线节点。") + ) + if not roles or roles[-1] != "end_terminal": + diagnostics.append( + _diagnostic("warning", "end_route_node_missing", "导线缺少终点端子路线节点。") + ) + + for node in route_nodes: + if not isinstance(node, dict): + continue + if node.get("role") != "waypoint": + continue + if node.get("carrier_kind") != "wire_duct": + continue + if not (node.get("source_object_name") or "").strip(): + diagnostics.append( + _diagnostic( + "warning", + "wire_duct_source_missing", + "线槽折点缺少载体对象,不能可靠判断是否同一线槽。", + ) + ) + if not (node.get("carrier_axis") or "").strip(): + diagnostics.append( + _diagnostic( + "warning", + "wire_duct_axis_missing", + "线槽折点缺少轴向信息,不能可靠沿线槽方向走线。", + ) + ) + return diagnostics + + +def _remove_from_group(group, obj): + if group is None or obj is None: + return + try: + if hasattr(group, "removeObject"): + group.removeObject(obj) + else: + group.Group = [candidate for candidate in group.Group if candidate is not obj] + except Exception: + pass + + +def _clear_manual_diagnostics(doc, diagnostic_group): + for obj in list(getattr(diagnostic_group, "Group", []) or []): + if (getattr(obj, "QetDiagnosticSource", "") or "").strip() != "ManualWiring": + continue + _remove_from_group(diagnostic_group, obj) + try: + if doc.getObject(getattr(obj, "Name", "")) is not None: + doc.removeObject(obj.Name) + except Exception: + pass + + +def _set_diagnostic_property(obj, prop_name, value, description): + TerminalObjects.ensure_string_property( + obj, + prop_name, + "QET Wiring Diagnostics", + description, + value, + ) + + +def _create_diagnostic_object(doc, diagnostic_group, wire_obj, diagnostic, index): + name = "QETWireDiagnostic_{0}".format(index) + suffix = 1 + base_name = name + while doc.getObject(name) is not None: + name = "{0}_{1}".format(base_name, suffix) + suffix += 1 + + obj = doc.addObject("App::FeaturePython", name) + message = diagnostic.get("message", "") + obj.Label = "{0}: {1}".format(diagnostic.get("severity", "warning"), message) + _set_diagnostic_property(obj, "QetDiagnosticSource", "ManualWiring", "Diagnostic source") + _set_diagnostic_property(obj, "QetDiagnosticSeverity", diagnostic.get("severity", ""), "Diagnostic severity") + _set_diagnostic_property(obj, "QetDiagnosticCode", diagnostic.get("code", ""), "Diagnostic code") + _set_diagnostic_property(obj, "QetDiagnosticMessage", message, "Diagnostic message") + _set_diagnostic_property(obj, "QetWireObjectName", getattr(wire_obj, "Name", ""), "Wire object name") + _set_diagnostic_property(obj, "QetWireLabel", getattr(wire_obj, "Label", ""), "Wire label") + diagnostic_group.addObject(obj) + return obj + + +def write_document_wire_diagnostics(doc, wires=None, project_uuid=""): + if doc is None: + raise ManualWiringError("No active FreeCAD document is available.") + + if wires is None: + wires = WiringObjects.iter_routed_wire_objects(doc) + + project_uuid = ( + (project_uuid or "").strip() + or getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() + ) + diagnostic_group = WiringObjects.ensure_diagnostic_group(doc, project_uuid) + _clear_manual_diagnostics(doc, diagnostic_group) + + created = [] + issue_count = 0 + for wire_obj in list(wires or []): + for diagnostic in diagnose_manual_wire(wire_obj): + issue_count += 1 + created.append( + _create_diagnostic_object( + doc, + diagnostic_group, + wire_obj, + diagnostic, + issue_count, + ) + ) + + try: + doc.recompute() + except Exception: + pass + return { + "wire_count": len(list(wires or [])), + "issue_count": issue_count, + "diagnostic_objects": created, + } + + def _append_unique_point(points, point): if not points or not _vector_close(points[-1], point): points.append(point) -def _append_orthogonal_segment(points, target_point, preferred_axis=None): +def _append_orthogonal_segment(points, target_point, preferred_axis=None, leading_axis=None): if not points: points.append(target_point) return - segment = _orthogonal_segment_points( - points[-1], - target_point, - preferred_axis=preferred_axis, - ) + if leading_axis in {"x", "y", "z"}: + axis_order = [leading_axis] + [ + axis for axis in ("x", "y", "z") if axis != leading_axis + ] + segment = _orthogonal_segment_points_for_axis_order( + points[-1], + target_point, + axis_order, + ) + else: + segment = _orthogonal_segment_points( + points[-1], + target_point, + preferred_axis=preferred_axis, + ) for point in segment[1:]: _append_unique_point(points, point) +def _orthogonal_segment_points_for_axis_order(start_point, end_point, axis_order): + if _vector_close(start_point, end_point): + return [start_point] + + points = [start_point] + current = start_point + for axis in axis_order: + if axis not in {"x", "y", "z"}: + continue + target = _axis_value(end_point, axis) + if abs(_axis_value(current, axis) - target) <= 0.000001: + continue + current = _vector_with_axis(current, axis, target) + if not _vector_close(current, points[-1]): + points.append(current) + + if not _vector_close(points[-1], end_point): + points.append(end_point) + return points + + +def _same_carrier_run(left, right): + if not left or not right: + return None + if (left.get("carrier_kind") or "") != "wire_duct": + return None + if (right.get("carrier_kind") or "") != "wire_duct": + return None + left_source = (left.get("source_object_name") or "").strip() + right_source = (right.get("source_object_name") or "").strip() + if not left_source or not right_source: + return None + if left_source != right_source: + return None + left_axis = (left.get("carrier_axis") or "").strip().lower() + right_axis = (right.get("carrier_axis") or "").strip().lower() + if left_axis and left_axis == right_axis and left_axis in {"x", "y", "z"}: + return left_axis + return None + + def _terminal_points(start_terminal, end_terminal, waypoints=None, terminal_exit_length=0.0): start_origin = TerminalObjects.terminal_origin(start_terminal) end_origin = TerminalObjects.terminal_origin(end_terminal) @@ -251,16 +563,20 @@ def _terminal_points(start_terminal, end_terminal, waypoints=None, terminal_exit ) ) + previous_waypoint = None for point_like in waypoints or []: waypoint = _coerce_waypoint(point_like) if waypoint is None: continue normalized_waypoints.append(waypoint) + leading_axis = _same_carrier_run(previous_waypoint, waypoint) _append_orthogonal_segment( points, waypoint["point"], preferred_axis=waypoint.get("support_axis"), + leading_axis=leading_axis, ) + previous_waypoint = waypoint if exit_length > 0: end_exit = _offset_point( @@ -293,6 +609,8 @@ def _set_wire_properties( wire_mark="", wire_mark_is_manual=False, manual_waypoints=None, + route_nodes=None, + terminal_exit_length=0.0, ): WiringObjects.set_routed_wire_semantics( obj, @@ -325,6 +643,28 @@ def _set_wire_properties( ) except Exception: pass + try: + if "QetRouteNodesJson" not in getattr(obj, "PropertiesList", []): + obj.addProperty( + "App::PropertyString", + "QetRouteNodesJson", + "QET Wiring", + "Manual route semantic nodes", + ) + obj.QetRouteNodesJson = json.dumps(route_nodes or [], ensure_ascii=False) + except Exception: + pass + try: + if "QetTerminalExitLength" not in getattr(obj, "PropertiesList", []): + obj.addProperty( + "App::PropertyFloat", + "QetTerminalExitLength", + "QET Wiring", + "Terminal exit length in millimeters", + ) + obj.QetTerminalExitLength = max(float(terminal_exit_length or 0.0), 0.0) + except Exception: + pass def _set_wire_points(obj, points): @@ -447,6 +787,12 @@ def create_manual_wire( waypoints=waypoints, terminal_exit_length=terminal_exit_length, ) + route_nodes = _manual_route_nodes( + start_terminal, + end_terminal, + normalized_waypoints=normalized_waypoints, + terminal_exit_length=terminal_exit_length, + ) if len(points) < 2: raise ManualWiringError("A wire requires at least two points.") @@ -464,6 +810,8 @@ def create_manual_wire( wire_mark=wire_mark, wire_mark_is_manual=wire_mark_is_manual, manual_waypoints=normalized_waypoints, + route_nodes=route_nodes, + terminal_exit_length=terminal_exit_length, ) if parent_group is None: diff --git a/src/Mod/FreeCADExchange/ManualWiringPanel.py b/src/Mod/FreeCADExchange/ManualWiringPanel.py index a1eaf5e..6b99025 100644 --- a/src/Mod/FreeCADExchange/ManualWiringPanel.py +++ b/src/Mod/FreeCADExchange/ManualWiringPanel.py @@ -30,6 +30,11 @@ except Exception: COMMAND_NAME = "QET_Exchange_OpenManualWiringPanel" DEFAULT_TERMINAL_EXIT_LENGTH = 20.0 +CARRIER_ROLE_LABELS = { + "wire_duct": "线槽", + "cabinet": "柜面", + "rail": "导轨", +} class ManualWiringPanelError(RuntimeError): @@ -184,7 +189,10 @@ def _point_label(point_like, index): label = (point_like.get("source_label", "") or "").strip() subelement = (point_like.get("subelement_name", "") or "").strip() anchor_kind = (point_like.get("anchor_kind", "") or "").strip() + carrier_label = _carrier_role_label(point_like.get("carrier_kind", "")) parts = [] + if carrier_label: + parts.append(carrier_label) if label: parts.append(label) if subelement: @@ -216,6 +224,60 @@ def _dominant_axis(vector): return axis +def _carrier_kind_from_object(obj): + candidates = [] + current = obj + if current is not None: + candidates.append(current) + candidates.extend(list(getattr(current, "InList", []) or [])) + + for candidate in candidates: + carrier_kind = (getattr(candidate, "QetCarrierKind", "") or "").strip() + if carrier_kind: + return carrier_kind + + text_parts = [] + for candidate in candidates: + text_parts.append(getattr(candidate, "Name", "") or "") + text_parts.append(getattr(candidate, "Label", "") or "") + text = " ".join(text_parts).lower() + if "线槽" in text or "duct" in text or "trunking" in text: + return "wire_duct" + if "导轨" in text or "rail" in text: + return "rail" + if "机柜" in text or "柜体" in text or "cabinet" in text or "panel" in text: + return "cabinet" + return "" + + +def _edge_carrier_axis(edge): + vertexes = list(getattr(edge, "Vertexes", []) or []) + if len(vertexes) >= 2: + start = getattr(vertexes[0], "Point", None) + end = getattr(vertexes[-1], "Point", None) + if start is not None and end is not None: + return _dominant_axis( + App.Vector( + float(getattr(end, "x", 0.0)) - float(getattr(start, "x", 0.0)), + float(getattr(end, "y", 0.0)) - float(getattr(start, "y", 0.0)), + float(getattr(end, "z", 0.0)) - float(getattr(start, "z", 0.0)), + ) + ) + return None + + +def _carrier_role_label(carrier_kind): + return CARRIER_ROLE_LABELS.get((carrier_kind or "").strip(), "") + + +def _selected_carrier_objects(): + return [ + obj + for obj in _selection() + if obj is not None and not TerminalObjects.is_terminal_object(obj) + ] + + def _selected_waypoint(): for picked in _selection_ex(): picked_points = list(getattr(picked, "PickedPoints", []) or []) @@ -226,6 +288,7 @@ def _selected_waypoint(): obj = getattr(picked, "Object", None) support_axis = None anchor_kind = "" + carrier_axis = None if point is None and sub_objects: point = _shape_center(sub_objects[0]) @@ -248,6 +311,9 @@ def _selected_waypoint(): support_axis = _dominant_axis(sub_object.normalAt(0.5)) except Exception: support_axis = None + carrier_axis = _edge_carrier_axis(sub_object) + elif anchor_kind == "edge": + carrier_axis = _edge_carrier_axis(sub_object) elif anchor_kind == "vertex": support_axis = None @@ -255,7 +321,10 @@ def _selected_waypoint(): "point": point, "support_axis": support_axis, "anchor_kind": anchor_kind, + "carrier_kind": _carrier_kind_from_object(obj), + "carrier_axis": carrier_axis, "source_label": getattr(obj, "Label", "") if obj is not None else "", + "source_object_name": getattr(obj, "Name", "") if obj is not None else "", "subelement_name": subelement_names[0] if subelement_names else "", } return None @@ -385,6 +454,40 @@ class ManualWiringController: self.terminal_exit_length = max(float(value or 0.0), 0.0) return self.terminal_exit_length + def mark_selected_carriers(self, carrier_kind): + carrier_kind = (carrier_kind or "").strip() + if carrier_kind not in CARRIER_ROLE_LABELS: + raise ManualWiringPanelError("未知的布线载体类型:{0}".format(carrier_kind)) + + doc = _active_document() + selected = _selected_carrier_objects() + if not selected: + raise ManualWiringPanelError("请先选择线槽、柜面或导轨对象。") + + project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() + carrier_group = WiringObjects.ensure_carrier_group(doc, project_uuid) + role_label = _carrier_role_label(carrier_kind) + marked = [] + for obj in selected: + TerminalObjects.ensure_string_property( + obj, + "QetCarrierKind", + "QET Wiring", + "3D wiring carrier kind", + carrier_kind, + ) + TerminalObjects.ensure_string_property( + obj, + "QetCarrierRoleLabel", + "QET Wiring", + "3D wiring carrier role label", + role_label, + ) + if obj not in getattr(carrier_group, "Group", []): + carrier_group.addObject(obj) + marked.append(obj) + return marked + def _clear_preview_objects(self): doc = getattr(App, "ActiveDocument", None) if doc is None: @@ -493,6 +596,14 @@ class ManualWiringController: _set_task_route_status(self.current_task, "Routed") return wire + def diagnose_last_wire(self): + if self.last_wire is None: + raise ManualWiringPanelError("当前还没有生成导线。") + return ManualWiring.diagnose_manual_wire(self.last_wire) + + def diagnose_all_wires(self): + return ManualWiring.write_document_wire_diagnostics(_active_document()) + def clear(self): self._reset_route_state() @@ -558,9 +669,14 @@ class ManualWiringTaskPanel: self.exit_length_input.setSuffix(" mm") self.exit_length_input.setValue(self.controller.terminal_exit_length) self.start_button = QtWidgets.QPushButton("设为起点") + self.mark_duct_button = QtWidgets.QPushButton("标记为线槽") + self.mark_cabinet_button = QtWidgets.QPushButton("标记为柜面") + self.mark_rail_button = QtWidgets.QPushButton("标记为导轨") self.waypoint_button = QtWidgets.QPushButton("添加折点") self.delete_waypoint_button = QtWidgets.QPushButton("删除最后折点") self.end_button = QtWidgets.QPushButton("设为终点并生成") + self.diagnose_button = QtWidgets.QPushButton("检查最近导线") + self.diagnose_all_button = QtWidgets.QPushButton("检查全部导线") self.clear_button = QtWidgets.QPushButton("清除草稿") self.save_button = QtWidgets.QPushButton("保存并回写") @@ -571,10 +687,17 @@ class ManualWiringTaskPanel: exit_layout.addWidget(QtWidgets.QLabel("端子出线长度")) exit_layout.addWidget(self.exit_length_input) layout.addLayout(exit_layout) + carrier_layout = QtWidgets.QHBoxLayout() + carrier_layout.addWidget(self.mark_duct_button) + carrier_layout.addWidget(self.mark_cabinet_button) + carrier_layout.addWidget(self.mark_rail_button) + layout.addLayout(carrier_layout) layout.addWidget(self.start_button) layout.addWidget(self.waypoint_button) layout.addWidget(self.delete_waypoint_button) layout.addWidget(self.end_button) + layout.addWidget(self.diagnose_button) + layout.addWidget(self.diagnose_all_button) layout.addWidget(self.clear_button) layout.addWidget(self.save_button) @@ -589,10 +712,15 @@ class ManualWiringTaskPanel: self.use_task_button.clicked.connect(self.use_selected_task) self.reload_tasks_button.clicked.connect(self._refresh_task_list) self.exit_length_input.valueChanged.connect(self.set_exit_length) + self.mark_duct_button.clicked.connect(self.mark_wire_duct) + self.mark_cabinet_button.clicked.connect(self.mark_cabinet) + self.mark_rail_button.clicked.connect(self.mark_rail) self.start_button.clicked.connect(self.set_start) self.waypoint_button.clicked.connect(self.add_waypoint) self.delete_waypoint_button.clicked.connect(self.delete_last_waypoint) self.end_button.clicked.connect(self.set_end_and_generate) + self.diagnose_button.clicked.connect(self.diagnose_last_wire) + self.diagnose_all_button.clicked.connect(self.diagnose_all_wires) self.clear_button.clicked.connect(self.clear) self.save_button.clicked.connect(self.save_and_write_back) @@ -659,6 +787,29 @@ class ManualWiringTaskPanel: except Exception as exc: self._set_error(str(exc)) + def mark_carrier(self, carrier_kind): + marked = self.controller.mark_selected_carriers(carrier_kind) + role_label = _carrier_role_label(carrier_kind) or carrier_kind + self._set_status("已将 {0} 个对象标记为{1}。".format(len(marked), role_label)) + + def mark_wire_duct(self): + try: + self.mark_carrier("wire_duct") + except Exception as exc: + self._set_error(str(exc)) + + def mark_cabinet(self): + try: + self.mark_carrier("cabinet") + except Exception as exc: + self._set_error(str(exc)) + + def mark_rail(self): + try: + self.mark_carrier("rail") + except Exception as exc: + self._set_error(str(exc)) + def set_start(self): try: terminal = self.controller.set_start_from_selection() @@ -703,6 +854,39 @@ class ManualWiringTaskPanel: except Exception as exc: self._set_error(str(exc)) + def diagnose_last_wire(self): + try: + diagnostics = self.controller.diagnose_last_wire() + if not diagnostics: + self._set_status("最近导线检查通过。") + return + text = ";".join( + "{0}:{1}".format(item.get("severity", ""), item.get("message", "")) + for item in diagnostics[:3] + ) + if len(diagnostics) > 3: + text += ";..." + self._set_status("最近导线检查发现 {0} 个问题:{1}".format(len(diagnostics), text)) + except Exception as exc: + self._set_error(str(exc)) + + def diagnose_all_wires(self): + try: + report = self.controller.diagnose_all_wires() + issue_count = int(report.get("issue_count", 0)) + wire_count = int(report.get("wire_count", 0)) + if issue_count <= 0: + self._set_status("全部导线检查通过:共 {0} 条导线。".format(wire_count)) + return + self._set_status( + "全部导线检查完成:{0} 条导线,发现 {1} 个问题,已写入 Diagnostics。".format( + wire_count, + issue_count, + ) + ) + except Exception as exc: + self._set_error(str(exc)) + def clear(self): self.controller.clear() self._refresh_waypoint_list() 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/src/Mod/FreeCADExchange/TerminalImport.py b/src/Mod/FreeCADExchange/TerminalImport.py index 48a5e09..3419203 100644 --- a/src/Mod/FreeCADExchange/TerminalImport.py +++ b/src/Mod/FreeCADExchange/TerminalImport.py @@ -35,8 +35,10 @@ def _normalize_terminal_entry(item, index): instance_id = (item.get("instance_id") or "").strip() element_uuid = (item.get("element_uuid") or "").strip() + terminal_display = (item.get("terminal_display") or "").strip() slot_name_hint = ( item.get("slot_name_hint") + or item.get("terminal_display") or item.get("terminal_label") or item.get("slot_name") or item.get("display_tag") @@ -47,6 +49,7 @@ def _normalize_terminal_entry(item, index): "terminal_uuid": terminal_uuid, "instance_id": instance_id, "element_uuid": element_uuid, + "terminal_display": terminal_display, "slot_name_hint": slot_name_hint, } @@ -168,6 +171,13 @@ def _terminal_slot_label(slot, terminal_uuid): return terminal_uuid +def _terminal_entry_label(entry, slot, terminal_uuid): + entry_label = (entry.get("terminal_display") or "").strip() + if entry_label: + return entry_label + return _terminal_slot_label(slot, terminal_uuid) + + def _normalize_slot_name(value): return (value or "").strip().lower() @@ -237,13 +247,14 @@ def _slot_placement(slot): return App.Placement(base, rotation) -def _create_terminal_object(doc, terminal_uuid, slot, terminal_group, project_uuid, element_uuid, instance_id): +def _create_terminal_object(doc, terminal_uuid, entry, slot, terminal_group, project_uuid, element_uuid, instance_id): + terminal_label = _terminal_entry_label(entry, slot, terminal_uuid) name_hint = "QETTerminal_{0}".format(TerminalObjects.safe_token(terminal_uuid)) terminal_obj = TerminalObjects.create_lcs_object( doc, name_hint, placement=_slot_placement(slot), - label=_terminal_slot_label(slot, terminal_uuid), + label=terminal_label, ) terminal_group.addObject(terminal_obj) TerminalObjects.set_terminal_semantics( @@ -252,7 +263,7 @@ def _create_terminal_object(doc, terminal_uuid, slot, terminal_group, project_uu element_uuid, terminal_uuid, instance_id, - label=_terminal_slot_label(slot, terminal_uuid), + label=terminal_label, slot_name=slot.get("name", ""), ) _ensure_visible(terminal_obj) @@ -401,7 +412,7 @@ def import_terminals_from_payload(payload, scene_path=""): device_element_uuid, terminal_uuid, device_instance_id, - label=_terminal_slot_label(slot, terminal_uuid), + label=_terminal_entry_label(entry, slot, terminal_uuid), slot_name=slot.get("name", ""), ) try: @@ -414,6 +425,7 @@ def import_terminals_from_payload(payload, scene_path=""): terminal_obj = _create_terminal_object( doc, terminal_uuid, + entry, slot, terminal_group, project_uuid, @@ -428,7 +440,7 @@ def import_terminals_from_payload(payload, scene_path=""): device_element_uuid, terminal_uuid, device_instance_id, - label=_terminal_slot_label(slot, terminal_uuid), + label=_terminal_entry_label(entry, slot, terminal_uuid), slot_name=slot.get("name", ""), ) try: diff --git a/src/Mod/FreeCADExchange/WiringObjects.py b/src/Mod/FreeCADExchange/WiringObjects.py index 102c489..17cb256 100644 --- a/src/Mod/FreeCADExchange/WiringObjects.py +++ b/src/Mod/FreeCADExchange/WiringObjects.py @@ -291,6 +291,19 @@ def _point_from_vector(vector): } +def _json_array_property(obj, prop_name): + text = getattr(obj, prop_name, "") + if not text: + return [] + try: + value = json.loads(text) + except Exception: + return [] + if isinstance(value, list): + return value + return [] + + def wire_shape_points(wire_obj): if wire_obj is None: return [] @@ -347,6 +360,9 @@ def wire_payload_from_object(wire_obj): "wire_mark": getattr(wire_obj, "QetWireMark", "").strip(), "wire_mark_is_manual": bool(getattr(wire_obj, "QetWireMarkIsManual", False)), "points": [], + "manual_waypoints": [], + "route_nodes": [], + "terminal_exit_length": float(getattr(wire_obj, "QetTerminalExitLength", 0.0) or 0.0), } points = [_point_from_vector(point) for point in wire_shape_points(wire_obj)] return { @@ -364,6 +380,9 @@ def wire_payload_from_object(wire_obj): "wire_mark": getattr(wire_obj, "QetWireMark", "").strip(), "wire_mark_is_manual": bool(getattr(wire_obj, "QetWireMarkIsManual", False)), "points": points, + "manual_waypoints": _json_array_property(wire_obj, "QetManualWaypointsJson"), + "route_nodes": _json_array_property(wire_obj, "QetRouteNodesJson"), + "terminal_exit_length": float(getattr(wire_obj, "QetTerminalExitLength", 0.0) or 0.0), } 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() diff --git a/tests/python/freecad_exchange_device_import_fcstd_test.py b/tests/python/freecad_exchange_device_import_fcstd_test.py index fd238a0..6f20916 100644 --- a/tests/python/freecad_exchange_device_import_fcstd_test.py +++ b/tests/python/freecad_exchange_device_import_fcstd_test.py @@ -1,5 +1,6 @@ import importlib import sys +import tempfile import types import unittest from pathlib import Path @@ -39,6 +40,9 @@ def _install_fake_freecad(source_doc): ) fake_freecad.ActiveDocument = None fake_freecad.set_active_document_calls = [] + fake_freecad.open_document_calls = [] + fake_freecad.new_document_calls = [] + fake_freecad.documents = {} def set_active_document(name): fake_freecad.set_active_document_calls.append(name) @@ -46,9 +50,24 @@ def _install_fake_freecad(source_doc): def close_document(*args, **kwargs): fake_freecad.ActiveDocument = None + def open_document(*args, **kwargs): + fake_freecad.open_document_calls.append((args, kwargs)) + if source_doc is not None: + fake_freecad.documents[source_doc.Name] = source_doc + fake_freecad.ActiveDocument = source_doc + return source_doc + + def new_document(name): + fake_freecad.new_document_calls.append(name) + doc = FakeDocument(name) + fake_freecad.documents[doc.Name] = doc + fake_freecad.ActiveDocument = doc + return doc + fake_freecad.setActiveDocument = set_active_document - fake_freecad.listDocuments = lambda: {} - fake_freecad.openDocument = lambda *args, **kwargs: source_doc + fake_freecad.listDocuments = lambda: dict(fake_freecad.documents) + fake_freecad.openDocument = open_document + fake_freecad.newDocument = new_document fake_freecad.closeDocument = close_document sys.modules["FreeCAD"] = fake_freecad @@ -230,6 +249,25 @@ def _reload_modules(): class FcstdDeviceImportTest(unittest.TestCase): + def test_ensure_document_opens_existing_scene_file_instead_of_creating_new_document(self): + with tempfile.TemporaryDirectory() as temp_dir: + scene_path = Path(temp_dir) / "QETScene.FCStd" + scene_path.write_text("fake fcstd placeholder", encoding="utf-8") + scene_doc = FakeDocument("QETScene", str(scene_path)) + _install_fake_freecad(scene_doc) + app = sys.modules["FreeCAD"] + + device_import, _ = _reload_modules() + + doc = device_import._ensure_document(str(scene_path)) + + self.assertIs(doc, scene_doc) + self.assertEqual(1, len(app.open_document_calls)) + self.assertEqual(str(scene_path), app.open_document_calls[0][0][0]) + self.assertEqual([], app.new_document_calls) + self.assertIs(app.ActiveDocument, scene_doc) + self.assertIn("QETScene", app.set_active_document_calls) + def test_fcstd_import_preserves_template_slots_without_live_template_lcs(self): source = FakeDocument("Source", r"D:\models\breaker.FCStd") _install_fake_freecad(source) diff --git a/tests/python/freecad_exchange_manual_wiring_panel_test.py b/tests/python/freecad_exchange_manual_wiring_panel_test.py index 0b84880..10c1009 100644 --- a/tests/python/freecad_exchange_manual_wiring_panel_test.py +++ b/tests/python/freecad_exchange_manual_wiring_panel_test.py @@ -247,6 +247,106 @@ class ManualWiringPanelTest(unittest.TestCase): ), ) + def test_controller_records_selected_wire_duct_waypoint_as_carrier_anchor(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + + carrier = doc.addObject("Part::Feature", "WireDuct_A") + carrier.Label = "线槽A" + terminal_objects.ensure_string_property( + carrier, + "QetCarrierKind", + "QET Wiring", + "Carrier kind", + "wire_duct", + ) + + selection_state["selection_ex"] = [ + types.SimpleNamespace( + PickedPoints=[app.Vector(100, 20, 30)], + SubObjects=[ + types.SimpleNamespace( + ShapeType="Edge", + normalAt=lambda u: app.Vector(0, 1, 0), + ) + ], + SubElementNames=["Edge1"], + Object=carrier, + ) + ] + + waypoint = panel.ManualWiringController().add_waypoint_from_selection() + + self.assertEqual("edge", waypoint["anchor_kind"]) + self.assertEqual("wire_duct", waypoint["carrier_kind"]) + self.assertEqual("线槽A", waypoint["source_label"]) + self.assertEqual("WireDuct_A", waypoint["source_object_name"]) + + def test_controller_records_wire_duct_edge_axis_for_waypoint(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + + carrier = doc.addObject("Part::Feature", "WireDuct_A") + carrier.Label = "线槽A" + terminal_objects.ensure_string_property( + carrier, + "QetCarrierKind", + "QET Wiring", + "Carrier kind", + "wire_duct", + ) + edge = types.SimpleNamespace( + ShapeType="Edge", + Vertexes=[ + types.SimpleNamespace(Point=app.Vector(0, 10, 20)), + types.SimpleNamespace(Point=app.Vector(100, 10, 20)), + ], + ) + + selection_state["selection_ex"] = [ + types.SimpleNamespace( + PickedPoints=[app.Vector(40, 10, 20)], + SubObjects=[edge], + SubElementNames=["Edge1"], + Object=carrier, + ) + ] + + waypoint = panel.ManualWiringController().add_waypoint_from_selection() + + self.assertEqual("wire_duct", waypoint["carrier_kind"]) + self.assertEqual("x", waypoint["carrier_axis"]) + + def test_controller_marks_selected_object_as_wire_duct_carrier(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + carrier = doc.addObject("Part::Feature", "WireDuct_A") + carrier.Label = "线槽A" + selection_state["selection"] = [carrier] + + marked = panel.ManualWiringController().mark_selected_carriers("wire_duct") + + carrier_group = doc.getObject("QETWiring_02_Carriers") + self.assertEqual([carrier], marked) + self.assertEqual("wire_duct", getattr(carrier, "QetCarrierKind", "")) + self.assertEqual("线槽", getattr(carrier, "QetCarrierRoleLabel", "")) + self.assertIn(carrier, carrier_group.Group) + def test_controller_deletes_last_waypoint_and_preview_point(self): selection_state = _install_fake_freecad() terminal_objects, panel = _reload_modules() @@ -366,6 +466,104 @@ class ManualWiringPanelTest(unittest.TestCase): [(point.x, point.y, point.z) for point in getattr(wire, "Points", [])], ) + def test_controller_diagnoses_last_generated_wire(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + terminal_objects.set_terminal_semantics( + start_terminal, + "project-1", + "device-start", + "terminal-start", + "instance-start", + label="Start", + ) + end_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalEnd") + end_terminal.Placement = app.Placement(app.Vector(20, 0, 0), app.Rotation()) + terminal_objects.set_terminal_semantics( + end_terminal, + "project-1", + "device-end", + "terminal-end", + "instance-end", + label="End", + ) + + controller = panel.ManualWiringController() + selection_state["selection"] = [start_terminal] + controller.set_start_from_selection() + selection_state["selection_ex"] = [ + types.SimpleNamespace( + PickedPoints=[app.Vector(10, 10, 10)], + SubObjects=[], + SubElementNames=[], + Object=types.SimpleNamespace(Name="线槽无对象名", Label="线槽"), + ) + ] + controller.add_waypoint_from_selection() + controller.waypoints[0]["source_object_name"] = "" + selection_state["selection"] = [end_terminal] + controller.set_end_from_selection_and_generate() + + diagnostics = controller.diagnose_last_wire() + + self.assertTrue( + any(item["code"] == "wire_duct_source_missing" for item in diagnostics) + ) + + def test_controller_writes_all_wire_diagnostics(self): + selection_state = _install_fake_freecad() + terminal_objects, panel = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + terminal_objects.set_terminal_semantics( + start_terminal, + "project-1", + "device-start", + "terminal-start", + "instance-start", + label="Start", + ) + end_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalEnd") + end_terminal.Placement = app.Placement(app.Vector(20, 0, 0), app.Rotation()) + terminal_objects.set_terminal_semantics( + end_terminal, + "project-1", + "device-end", + "terminal-end", + "instance-end", + label="End", + ) + + controller = panel.ManualWiringController() + selection_state["selection"] = [start_terminal] + controller.set_start_from_selection() + controller.waypoints = [ + { + "point": app.Vector(10, 10, 10), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + } + ] + selection_state["selection"] = [end_terminal] + controller.set_end_from_selection_and_generate() + + report = controller.diagnose_all_wires() + + self.assertEqual(1, report["issue_count"]) + self.assertEqual(1, len(doc.getObject("QETWiring_05_Diagnostics").Group)) + def test_controller_generates_wire_from_selected_task(self): selection_state = _install_fake_freecad() terminal_objects, panel = _reload_modules() diff --git a/tests/python/freecad_exchange_manual_wiring_test.py b/tests/python/freecad_exchange_manual_wiring_test.py index 521af3c..09eaf42 100644 --- a/tests/python/freecad_exchange_manual_wiring_test.py +++ b/tests/python/freecad_exchange_manual_wiring_test.py @@ -1,6 +1,7 @@ import sys import types import unittest +import json from pathlib import Path @@ -306,6 +307,375 @@ class ManualWiringGroupTest(unittest.TestCase): points[:5], ) + def test_manual_wire_records_semantic_route_nodes_for_later_carrier_routing(self): + _install_fake_freecad() + _device_import, manual_wiring, terminal_objects = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + start_terminal.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation()) + terminal_objects.set_terminal_semantics( + start_terminal, + "project-1", + "device-start", + "terminal-start", + "instance-start", + label="Start", + ) + + end_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalEnd") + end_terminal.Placement = app.Placement(app.Vector(100, 20, 0), app.Rotation()) + terminal_objects.set_terminal_semantics( + end_terminal, + "project-1", + "device-end", + "terminal-end", + "instance-end", + label="End", + ) + + wire = manual_wiring.create_manual_wire( + doc, + start_terminal, + end_terminal, + waypoints=[ + { + "point": app.Vector(50, 10, 30), + "support_axis": "z", + "anchor_kind": "edge", + "carrier_kind": "wire_duct", + "source_label": "线槽A", + "subelement_name": "Edge1", + } + ], + terminal_exit_length=15.0, + ) + + self.assertEqual(15.0, getattr(wire, "QetTerminalExitLength", None)) + route_nodes = json.loads(getattr(wire, "QetRouteNodesJson", "[]")) + self.assertEqual( + [ + "start_terminal", + "start_exit", + "waypoint", + "end_exit", + "end_terminal", + ], + [node["role"] for node in route_nodes], + ) + self.assertEqual("wire_duct", route_nodes[2]["carrier_kind"]) + self.assertEqual("edge", route_nodes[2]["anchor_kind"]) + self.assertEqual("terminal-start", route_nodes[0]["terminal_uuid"]) + self.assertEqual("terminal-end", route_nodes[-1]["terminal_uuid"]) + + def test_manual_wire_routes_along_same_wire_duct_axis_between_waypoints(self): + _install_fake_freecad() + _device_import, manual_wiring, terminal_objects = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + start_terminal.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation()) + terminal_objects.set_terminal_semantics( + start_terminal, + "project-1", + "device-start", + "terminal-start", + "instance-start", + label="Start", + ) + + end_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalEnd") + end_terminal.Placement = app.Placement(app.Vector(30, 120, 0), app.Rotation()) + terminal_objects.set_terminal_semantics( + end_terminal, + "project-1", + "device-end", + "terminal-end", + "instance-end", + label="End", + ) + + wire = manual_wiring.create_manual_wire( + doc, + start_terminal, + end_terminal, + waypoints=[ + { + "point": app.Vector(10, 0, 20), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + "source_object_name": "WireDuct_A", + }, + { + "point": app.Vector(20, 100, 20), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + "source_object_name": "WireDuct_A", + }, + ], + terminal_exit_length=0.0, + ) + + points = [(point.x, point.y, point.z) for point in wire.Shape] + self.assertEqual( + [ + (10.0, 0.0, 20.0), + (20.0, 0.0, 20.0), + (20.0, 100.0, 20.0), + ], + points[2:5], + ) + route_nodes = json.loads(getattr(wire, "QetRouteNodesJson", "[]")) + self.assertEqual("x", route_nodes[1]["carrier_axis"]) + self.assertEqual("x", route_nodes[2]["carrier_axis"]) + + def test_manual_wire_does_not_treat_unknown_wire_duct_sources_as_same_carrier(self): + _install_fake_freecad() + _device_import, manual_wiring, terminal_objects = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + start_terminal.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation()) + terminal_objects.set_terminal_semantics( + start_terminal, + "project-1", + "device-start", + "terminal-start", + "instance-start", + label="Start", + ) + + end_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalEnd") + end_terminal.Placement = app.Placement(app.Vector(30, 120, 0), app.Rotation()) + terminal_objects.set_terminal_semantics( + end_terminal, + "project-1", + "device-end", + "terminal-end", + "instance-end", + label="End", + ) + + wire = manual_wiring.create_manual_wire( + doc, + start_terminal, + end_terminal, + waypoints=[ + { + "point": app.Vector(10, 0, 20), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + }, + { + "point": app.Vector(20, 100, 20), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + }, + ], + terminal_exit_length=0.0, + ) + + points = [(point.x, point.y, point.z) for point in wire.Shape] + self.assertEqual((10.0, 100.0, 20.0), points[3]) + + def test_manual_wire_diagnostics_warn_for_wire_duct_waypoint_without_source(self): + _install_fake_freecad() + _device_import, manual_wiring, terminal_objects = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + terminal_objects.set_terminal_semantics( + start_terminal, + "project-1", + "device-start", + "terminal-start", + "instance-start", + label="Start", + ) + end_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalEnd") + end_terminal.Placement = app.Placement(app.Vector(20, 0, 0), app.Rotation()) + terminal_objects.set_terminal_semantics( + end_terminal, + "project-1", + "device-end", + "terminal-end", + "instance-end", + label="End", + ) + + wire = manual_wiring.create_manual_wire( + doc, + start_terminal, + end_terminal, + waypoints=[ + { + "point": app.Vector(10, 10, 10), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + } + ], + ) + + diagnostics = manual_wiring.diagnose_manual_wire(wire) + + self.assertTrue( + any(item["code"] == "wire_duct_source_missing" for item in diagnostics) + ) + + def test_manual_wire_diagnostics_pass_for_complete_manual_route(self): + _install_fake_freecad() + _device_import, manual_wiring, terminal_objects = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + terminal_objects.set_terminal_semantics( + start_terminal, + "project-1", + "device-start", + "terminal-start", + "instance-start", + label="Start", + ) + end_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalEnd") + end_terminal.Placement = app.Placement(app.Vector(20, 0, 0), app.Rotation()) + terminal_objects.set_terminal_semantics( + end_terminal, + "project-1", + "device-end", + "terminal-end", + "instance-end", + label="End", + ) + + wire = manual_wiring.create_manual_wire( + doc, + start_terminal, + end_terminal, + waypoints=[ + { + "point": app.Vector(10, 10, 10), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + "source_object_name": "WireDuct_A", + } + ], + terminal_exit_length=20.0, + ) + + self.assertEqual([], manual_wiring.diagnose_manual_wire(wire)) + + def test_write_document_wire_diagnostics_creates_diagnostic_objects(self): + _install_fake_freecad() + _device_import, manual_wiring, terminal_objects = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + terminal_objects.set_terminal_semantics( + start_terminal, + "project-1", + "device-start", + "terminal-start", + "instance-start", + label="Start", + ) + end_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalEnd") + end_terminal.Placement = app.Placement(app.Vector(20, 0, 0), app.Rotation()) + terminal_objects.set_terminal_semantics( + end_terminal, + "project-1", + "device-end", + "terminal-end", + "instance-end", + label="End", + ) + + wire = manual_wiring.create_manual_wire( + doc, + start_terminal, + end_terminal, + waypoints=[ + { + "point": app.Vector(10, 10, 10), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + } + ], + ) + + report = manual_wiring.write_document_wire_diagnostics(doc) + + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + self.assertEqual(1, report["issue_count"]) + self.assertEqual(1, len(diagnostic_group.Group)) + diagnostic = diagnostic_group.Group[0] + self.assertEqual("wire_duct_source_missing", diagnostic.QetDiagnosticCode) + self.assertEqual(wire.Name, diagnostic.QetWireObjectName) + self.assertIn("线槽折点", diagnostic.QetDiagnosticMessage) + + def test_write_document_wire_diagnostics_replaces_previous_manual_results(self): + _install_fake_freecad() + _device_import, manual_wiring, terminal_objects = _reload_modules() + app = sys.modules["FreeCAD"] + + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + + start_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalStart") + terminal_objects.set_terminal_semantics( + start_terminal, + "project-1", + "device-start", + "terminal-start", + "instance-start", + label="Start", + ) + end_terminal = doc.addObject("Part::LocalCoordinateSystem", "TerminalEnd") + end_terminal.Placement = app.Placement(app.Vector(20, 0, 0), app.Rotation()) + terminal_objects.set_terminal_semantics( + end_terminal, + "project-1", + "device-end", + "terminal-end", + "instance-end", + label="End", + ) + manual_wiring.create_manual_wire( + doc, + start_terminal, + end_terminal, + waypoints=[ + { + "point": app.Vector(10, 10, 10), + "carrier_kind": "wire_duct", + "carrier_axis": "x", + } + ], + ) + + manual_wiring.write_document_wire_diagnostics(doc) + manual_wiring.write_document_wire_diagnostics(doc) + + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + self.assertEqual(1, len(diagnostic_group.Group)) + def test_manual_wire_is_visible_in_routed_group_not_hidden_legacy_group(self): _install_fake_freecad() _device_import, manual_wiring, terminal_objects = _reload_modules() diff --git a/tests/python/freecad_exchange_wiring_test.py b/tests/python/freecad_exchange_wiring_test.py index 85a73ba..b9ffc2e 100644 --- a/tests/python/freecad_exchange_wiring_test.py +++ b/tests/python/freecad_exchange_wiring_test.py @@ -330,6 +330,14 @@ class WiringTest(unittest.TestCase): self.assertTrue(any(point.x == 4.0 and point.y == 5.0 and point.z == 6.0 for point in wire.Points)) self.assertIn("QetManualWaypointsJson", getattr(wire, "PropertiesList", [])) self.assertIn('"support_axis": "x"', getattr(wire, "QetManualWaypointsJson", "")) + payload = wiring_objects.wire_payload_from_object(wire) + self.assertEqual(20.0, payload["terminal_exit_length"]) + self.assertEqual("Manual", payload["route_mode"]) + self.assertEqual( + ["start_terminal", "start_exit", "waypoint", "end_exit", "end_terminal"], + [node["role"] for node in payload["route_nodes"]], + ) + self.assertEqual("face", payload["route_nodes"][2]["anchor_kind"]) def test_wire_writeback_omits_scene_routed_wire_payload(self): _install_fake_freecad()