From d1c7169cad1e09b28bac4538534d011bca4e4b98 Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Fri, 12 Jun 2026 09:09:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(freecad):=20=E6=94=AF=E6=8C=81SW=E7=B1=BB?= =?UTF-8?q?=E5=B8=83=E7=BA=BF=E8=B7=AF=E5=BE=84=E8=8D=89=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FreeCAD 3D自动布线设计方案.md | 34 +- docs/FreeCAD 机柜装配操作文档.md | 88 ++- src/Mod/FreeCADExchange/AutoRoutingPanel.py | 212 +++++++ src/Mod/FreeCADExchange/RoutingNetwork.py | 535 +++++++++++++++++- .../freecad_exchange_auto_routing_test.py | 345 ++++++++++- 5 files changed, 1177 insertions(+), 37 deletions(-) diff --git a/docs/FreeCAD 3D自动布线设计方案.md b/docs/FreeCAD 3D自动布线设计方案.md index 2882b4e..e754d98 100644 --- a/docs/FreeCAD 3D自动布线设计方案.md +++ b/docs/FreeCAD 3D自动布线设计方案.md @@ -1040,6 +1040,35 @@ SW 教程中路径可以由“创建草图”或“转换草图”得到,并 如果临时画的 Draft 线或草图线明显悬空,可同时选中安装板/柜板上的支撑面 Face 和该路径对象,再执行“选中路径作为用户路径”或“生成布线路径网络”。系统会把路径点投影到支撑面附近,而不是直接使用 Draft 当前工作平面坐标。 +#### 6.4.1 SW 类布线路径草图 MVP + +为贴近 SW Electrical 视频中的“选面/离面画路径,再生成 3D 布线”的操作,当前 `3D 布线连接` 面板提供了轻量的布线路径草图入口。它不是完整复刻 SW Sketcher,而是面向电气布线的 `UserPath` 绘制子集: + +1. `草图离面距离 mm`:创建草图时沿选中 Face 法向偏移指定距离。设为 0 时贴近选中面;设为 30、50 等数值时,可在离安装板或柜门一定距离的位置画路径。 +2. `创建布线路径草图`:先选中机柜面板、安装板、门板或线槽上的一个 Face,再点击该按钮。系统会创建黄色 `布线路径草图`,写入 `AttachmentSupport`、`MapMode=FlatFace`、`AttachmentOffset` 和 `QetRouteSketchMode=ManualUserPathSketch`,并尽量进入 Sketcher 编辑。 +3. 用户在 Sketcher 中用普通线段、折线、水平/垂直/重合等约束画路径。Sketcher 的水平/垂直是草图本地平面方向;如果要画全局 Z 向路径,应选竖直 Face 建草图,或使用正交 3D 路径按钮。 +4. 退出草图后,直接点击 `生成布线路径网络` 即可。系统会自动扫描明确标记为布线路径的草图并生成/刷新 `UserPath` carrier,不要求再手动点一次 `选中路径作为用户路径`。 +5. 为避免误把设备或柜体里的普通建模草图当成布线路径,文档级自动扫描只自动纳入明确标记/命名的路径源,例如 `QetRoutingSourceKind=UserPath`、`ManualUserPathSketch`、名称或标签包含 `UserPath`、`Route Path`、`布线路径`、`走线路径`、`用户路径` 等。普通 `Sketch001` 不会自动转换;如果确实要把普通草图作为路径,仍可选中它后点击 `选中路径作为用户路径`。 +6. 明确标记为 `UserPath` 或 `ManualUserPathSketch` 的草图优先按布线路径处理,即使名称里同时包含“安装板”“门板”等支撑面关键词,也不会被误跳过。这样现场可以使用“安装板布线路径草图”这类更自然的命名。 +7. 草图修改后重新生成布线路径网络会刷新同源 `UserPath`。如果把草图里的线段全部删除,系统会同步删除之前由该草图生成的旧 carrier,并清空源草图上的 carrier 名称记录,避免旧路径继续参与自动布线。 + +对于跨平面或竖向过渡,面板还提供两种 3D 点选路径: + +1. `选中点生成3D路径`:按选择顺序把多个 3D 点、顶点、边端点或带 `Points` 的对象串成一条自由空间 `UserPath`。适合现场补一段明确的空间连接,但可能生成斜线。 +2. `选中点生成正交3D路径`:按选择顺序生成只沿 X/Y/Z 单轴变化的折线路径,并标记 `QetRoutePathMode=Orthogonal3D`。它更接近 SW 3D 草图里锁定 X/Y/Z 方向画路径的效果,也更适合减少斜穿设备或柜体。 + +推荐手测顺序: + +```text +1. 选中安装板/柜门/线槽 Face。 +2. 设置“草图离面距离 mm”。 +3. 点击“创建布线路径草图”。 +4. 在 Sketcher 里画黄色主路径,退出草图。 +5. 如有跨面路径,按顺序选择端点/顶点,点击“选中点生成正交3D路径”。 +6. 点击“生成布线路径网络”。 +7. 点击“检查布线路径网络”和“生成布线连接”。 +``` + `UserPath` 与线槽的关系: ```text @@ -1129,7 +1158,9 @@ TemplateAuthoring.set_template_terminal_local_route_points(terminal, points) ## 7. 当前限制 -当前版本可完成布线连接原型,但仍有以下限制: +当前版本已进入第一阶段收尾:可从 QET 导线任务生成 FreeCAD 侧布线路径网络和 `AutoSuggested` 导线;可识别/生成 `WireDuct`、`UserPath`、`WiringCutOut`、`RoutingRange`、`TerminalAccess`;可通过“创建布线路径草图”“选中点生成正交 3D 路径”等入口完成接近 SW 的电气布线路径草图/UserPath 绘制 MVP。这个 MVP 已覆盖选 Face 创建离面草图、Sketcher 画线、生成网络自动转 `UserPath`、同源刷新、清空草图后删除旧 carrier、普通机械草图不误转换、真实 Sketcher 坐标不重复应用 Placement 等关键手测场景。 + +但第一阶段仍不是 SW/EPLAN 完整布线系统,主要限制如下: 1. 线槽实体中心线生成基于包围盒长轴,不理解真实线槽开口、盖板和内部空间。 2. 多根线共路时已做基础错位显示,但不是线束级排布,也不计算每根线在线槽内的真实截面位置。 @@ -1141,6 +1172,7 @@ TemplateAuthoring.set_template_terminal_local_route_points(terminal, points) 8. 端子出线方向默认依赖端子 LCS 方向;若工程端子提供 `QetTerminalLocalRoutePointsJson`,会优先使用局部路径。模板端子方向或局部路径不准时,布线连接会受影响。 9. 导线几何当前保存在 FreeCAD 文档,不作为第一版数据库字段回写。 10. 当前不自动求解导轨、安装板和设备之间的 Assembly 配合关系;装配位置以 `scene.FCStd` 中对象的最终 `Placement` 为准。 +11. `创建布线路径草图` 只提供面向电气路径的轻量草图入口,不复刻 SW 3D Sketch 的全部约束、捕捉、智能标注和工程规则;跨平面路径当前优先用“选中点生成正交3D路径”补充。 ## 8. 后续需要完成 diff --git a/docs/FreeCAD 机柜装配操作文档.md b/docs/FreeCAD 机柜装配操作文档.md index 9ea9138..cf71a47 100644 --- a/docs/FreeCAD 机柜装配操作文档.md +++ b/docs/FreeCAD 机柜装配操作文档.md @@ -976,15 +976,63 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。 1. 面板状态摘要显示 `版本:2026-06-08-runtime-routing-v4` 或更新版本。 2. `检查布线准备度` 能识别到 QET 导线来源、工程端子、路径网络和柜内边界;若有问题,`RoutingPreflight.QetDiagnosticIssueCodes` 能说明原因。 3. `检查布线路径网络` 不应出现空网络、路径对象几何无效、端子越出柜内边界或路径越出柜内边界;如果出现,应先修装配或路径源。 -4. `生成布线连接` 后,`RoutingConnectionBatch.QetDiagnosticJson.runtime_version` 与面板版本一致。 -5. 有导线任务时,`RoutingConnectionBatch.routed` 应大于 0;如果为 0,应优先看 `missing_endpoint_samples`、`missing_route_network_samples` 或 `error_samples`。 -6. 正常导线的单线 `QetRouteIssueCodes` 应为空;若存在问题码,应能归类到端子接入、碰撞、柜内越界、容量压力、路径质量或路径约束。 -7. 已标记 `CabinetInterior` 时,优先要求 `QetRouteBoundaryStatus=InsideBoundary`;出现 `BoundaryWarning` 时,应补柜内主路径或修正边界。 -8. 碰撞状态优先看 `QetRouteCollisionStatus`:`NoCollision` 为理想结果,`ClearanceWarning` 可作为间隙问题记录,`HardIntersectionWarning` 视为穿模问题。 -9. 多根线共路时,检查 `QetRouteLaneIndex`、`QetRouteLaneOffsetMm` 和 `QetRouteCapacityStatus`,确认新增导线不会无诊断地贴到旧线上。 -10. 最后点击 `汇总布线诊断`,把 `RoutingDiagnosticSummary.QetDiagnosticMessage` 和主要 `issue_codes` 作为本次手测结论。 +4. 如果使用 `创建布线路径草图`,退出 Sketcher 后点击 `生成布线路径网络`,应能在树目录看到对应 `UserPath` carrier;源草图应保留 `QetRouteSketchMode=ManualUserPathSketch`,生成的 carrier 应保留 `QetRouteSourceLabel` / `QetRouteSourceName`。 +5. 如果在竖直 Face 上创建草图,生成的 `UserPath` 点应沿该 Face 的离面方向偏移,不应出现离面距离翻倍或路径跑到柜外;若出现,优先记录源草图 Label、离面距离和生成 carrier 的 `Points`。 +6. 如果把布线路径草图里的线段全部删掉,再点击 `生成布线路径网络`,之前由该草图生成的旧 `UserPath` 应被清理,不应继续参与布线。 +7. `生成布线连接` 后,`RoutingConnectionBatch.QetDiagnosticJson.runtime_version` 与面板版本一致。 +8. 有导线任务时,`RoutingConnectionBatch.routed` 应大于 0;如果为 0,应优先看 `missing_endpoint_samples`、`missing_route_network_samples` 或 `error_samples`。 +9. 正常导线的单线 `QetRouteIssueCodes` 应为空;若存在问题码,应能归类到端子接入、碰撞、柜内越界、容量压力、路径质量或路径约束。 +10. 已标记 `CabinetInterior` 时,优先要求 `QetRouteBoundaryStatus=InsideBoundary`;出现 `BoundaryWarning` 时,应补柜内主路径或修正边界。 +11. 碰撞状态优先看 `QetRouteCollisionStatus`:`NoCollision` 为理想结果,`ClearanceWarning` 可作为间隙问题记录,`HardIntersectionWarning` 视为穿模问题。 +12. 多根线共路时,检查 `QetRouteLaneIndex`、`QetRouteLaneOffsetMm` 和 `QetRouteCapacityStatus`,确认新增导线不会无诊断地贴到旧线上。 +13. 最后点击 `汇总布线诊断`,把 `RoutingDiagnosticSummary.QetDiagnosticMessage` 和主要 `issue_codes` 作为本次手测结论。 -如果上面 1、4、5 成立,且问题导线都能通过诊断字段定位原因,说明当前版本已经具备第一版可测闭环。若仍存在导线穿模、柜外线或未布通,应优先把对应导线的 `wire_object_label`、`QetRouteIssueCodes`、`QetRouteDiagnosticsJson` 和录屏时间点一起反馈。 +如果上面 1、4、7、8 成立,且问题导线都能通过诊断字段定位原因,说明当前版本已经具备第一版可测闭环。若仍存在导线穿模、柜外线或未布通,应优先把对应导线的 `wire_object_label`、`QetRouteIssueCodes`、`QetRouteDiagnosticsJson` 和录屏时间点一起反馈。 + +### 14.3 手测反馈记录模板 + +建议每次 GUI 手测后按下面格式记录,便于开发侧快速判断问题来自装配、路径网络、端子接入还是导线求路: + +```text +测试工程: +FreeCAD 版本/运行目录: +面板 runtime_version: +是否从 QET 3D 按钮打开: +是否重新生成工程端子: +是否标记 CabinetInterior: + +路径输入: +- 线槽数量/是否标记为线槽: +- UserPath 来源:创建布线路径草图 / 选中路径作为用户路径 / 选中点生成正交3D路径 / 诊断桥接 +- 是否使用竖直 Face 创建草图: +- 草图离面距离 mm: +- 生成布线路径网络后 UserPath carrier 数量: + +批量布线结果: +- total_wires: +- routed: +- missing_terminals: +- missing_route_network: +- collision_warnings: +- boundary warnings: +- main_path_not_used / fallback_routes: + +汇总诊断: +- RoutingDiagnosticSummary.QetDiagnosticMessage: +- 主要 issue_codes: +- 下一步建议动作: + +异常样例: +- 录屏时间点: +- wire_object_label: +- QetRouteIssueCodes: +- QetRouteSourceLabels / QetRouteCarrierNames: +- QetRouteAccessStatus: +- QetRouteCollisionStatus: +- QetRouteBoundaryStatus: +- QetRouteQualityStatus: +- 简要现象:未布通 / 穿模 / 跑出柜外 / 接入过长 / 未走主路径 / 线样式不对 +``` --- @@ -1040,13 +1088,23 @@ FreeCADExchange 会生成 3D -> 2D 的回写结果。 1. 先把导轨、线槽和设备摆到机柜内真实位置。 2. 选择线槽,点击 `标记为线槽`。 -3. 如果没有线槽,先画草图或 Draft 线,再点击 `选中路径作为用户路径`。 -4. 打开 `3D布线连接`。 -5. 点击 `检查布线准备度`,确认有布线源。 -6. 点击 `准备布线布局空间`。 -7. 点击 `生成布线路径网络`。 -8. 点击 `检查布线路径网络`。 -9. 再尝试生成布线连接。 +3. 如果没有线槽,或需要补柜内主路径,优先使用 `3D布线连接` 面板的 SW 类路径流程: + - 选中安装板、柜门、线槽或柜板上的一个 Face。 + - 设置 `草图离面距离 mm`,例如 0、20、50。 + - 点击 `创建布线路径草图`。 + - 在 Sketcher 中画横竖路径线,退出草图。 + - 点击 `生成布线路径网络`,系统会自动把明确标记的布线路径草图转换为 `UserPath`。 + - 如果要画全局 Z 向或竖向过渡路径,优先选柜侧板、门板内侧、线槽侧面等竖直 Face 创建草图;Sketcher 里的水平/垂直约束始终相对当前草图平面。 +4. 如果需要跨平面或 Z 向过渡路径,按顺序选择多个 3D 点、顶点或边端点,再点击 `选中点生成正交3D路径`。该路径会按 X/Y/Z 折线生成,适合补线槽之间、门板到柜内、设备区域到主路径之间的连接。 +5. 如果已经有普通草图或 Draft 线,也可以选中它后点击 `选中路径作为用户路径`。普通 `Sketch001` 这类未标记机械草图不会被 `生成布线路径网络` 自动当成布线路径,避免误转换。 + - 通过 `创建布线路径草图` 生成的草图已经写入布线路径标记,可以命名为“安装板布线路径草图”“门板布线路径草图”等;即使名称里带安装板、门板这类支撑面词,也会按 `UserPath` 处理。 + - 如果回到草图里把路径线全部删掉,再点击 `生成布线路径网络` 会清理之前生成的旧 `UserPath`,不用手动删除树目录里的旧 carrier。 +6. 打开 `3D布线连接`。 +7. 点击 `检查布线准备度`,确认有布线源。 +8. 点击 `准备布线布局空间`。 +9. 点击 `生成布线路径网络`。 +10. 点击 `检查布线路径网络`。 +11. 再尝试生成布线连接。 ### 15.4 为什么保存后 QET 看不到 3D 位姿? diff --git a/src/Mod/FreeCADExchange/AutoRoutingPanel.py b/src/Mod/FreeCADExchange/AutoRoutingPanel.py index 566f062..8453951 100644 --- a/src/Mod/FreeCADExchange/AutoRoutingPanel.py +++ b/src/Mod/FreeCADExchange/AutoRoutingPanel.py @@ -101,6 +101,7 @@ class AutoRoutingController: def __init__(self, options=None): self.last_report = None self.options = dict(options or {}) + self.options.setdefault("user_path_sketch_offset", RoutingNetwork.DEFAULT_ROUTE_PATH_FACE_OFFSET) def routing_options(self): return dict(self.options) @@ -174,6 +175,13 @@ class AutoRoutingController: capacity = 1 self.options["selected_route_capacity"] = max(capacity, 1) + def set_user_path_sketch_offset(self, value): + try: + offset = float(value) + except Exception: + offset = RoutingNetwork.DEFAULT_ROUTE_PATH_FACE_OFFSET + self.options["user_path_sketch_offset"] = max(offset, 0.0) + def set_preflight_routeability_sample_limit(self, value): try: sample_limit = int(value) @@ -1846,6 +1854,84 @@ class AutoRoutingController: } return self.last_report + def create_user_path_sketch_from_selection(self): + doc = _active_document() + project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() + result = RoutingNetwork.create_user_path_sketch_from_selection( + doc, + _selection_ex(), + project_uuid=project_uuid, + offset=float( + self.routing_options().get( + "user_path_sketch_offset", + RoutingNetwork.DEFAULT_ROUTE_PATH_FACE_OFFSET, + ) + or 0.0 + ), + ) + sketch = result.get("sketch") + self.last_report = { + "user_path_sketches": 1 if sketch is not None else 0, + "user_path_sketch_names": [getattr(sketch, "Name", "")] if sketch is not None else [], + "support_name": getattr(result.get("support"), "Name", "") if result.get("support") is not None else "", + "support_sub_element": result.get("sub_element_name", ""), + "sketch": sketch, + } + return self.last_report + + def create_user_path_from_selected_points(self): + doc = _active_document() + project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() + result = RoutingNetwork.create_user_path_carrier_from_selected_points( + doc, + _selection_ex(), + project_uuid=project_uuid, + ) + carrier = result.get("carrier") + self.last_report = { + "user_path_carriers": 1 if carrier is not None else 0, + "user_path_carrier_names": [getattr(carrier, "Name", "")] if carrier is not None else [], + "user_path_point_count": len(result.get("points", []) or []), + "network": RoutingNetwork.network_summary( + doc, + adjoining_duct_tolerance=float( + self.routing_options().get( + "adjoining_duct_tolerance", + RoutingNetwork.DEFAULT_ADJOINING_DUCT_TOLERANCE, + ) + or 0.0 + ), + ), + } + return self.last_report + + def create_orthogonal_user_path_from_selected_points(self): + doc = _active_document() + project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() + result = RoutingNetwork.create_orthogonal_user_path_carrier_from_selected_points( + doc, + _selection_ex(), + project_uuid=project_uuid, + ) + carrier = result.get("carrier") + self.last_report = { + "user_path_carriers": 1 if carrier is not None else 0, + "user_path_carrier_names": [getattr(carrier, "Name", "")] if carrier is not None else [], + "user_path_point_count": len(result.get("points", []) or []), + "user_path_mode": "Orthogonal3D", + "network": RoutingNetwork.network_summary( + doc, + adjoining_duct_tolerance=float( + self.routing_options().get( + "adjoining_duct_tolerance", + RoutingNetwork.DEFAULT_ADJOINING_DUCT_TOLERANCE, + ) + or 0.0 + ), + ), + } + return self.last_report + def set_selected_terminal_local_route_points(self): result = RoutingNetwork.set_terminal_local_route_points_from_selection(_selection_ex()) terminal = result.get("terminal") @@ -2326,6 +2412,21 @@ class AutoRoutingTaskPanel: int(self.controller.routing_options().get("selected_route_capacity", 4) or 4) ) options_layout.addWidget(self.selected_route_capacity_spin) + options_layout.addWidget(QtWidgets.QLabel("草图离面距离 mm")) + self.user_path_sketch_offset_spin = QtWidgets.QDoubleSpinBox() + self.user_path_sketch_offset_spin.setRange(0.0, 10000.0) + self.user_path_sketch_offset_spin.setDecimals(1) + self.user_path_sketch_offset_spin.setSingleStep(5.0) + self.user_path_sketch_offset_spin.setValue( + float( + self.controller.routing_options().get( + "user_path_sketch_offset", + RoutingNetwork.DEFAULT_ROUTE_PATH_FACE_OFFSET, + ) + or 0.0 + ) + ) + options_layout.addWidget(self.user_path_sketch_offset_spin) self.generate_layout_button = _style_command_button( QtWidgets.QPushButton(), @@ -2339,6 +2440,24 @@ class AutoRoutingTaskPanel: "按 EPLAN 逻辑从布局空间生成完整 routing path network:线槽、布线区域和端子接入。", ) + self.create_user_path_sketch_button = _style_command_button( + QtWidgets.QPushButton(), + "创建布线路径草图", + "先选中机柜面板、安装板、门板或线槽的一个 Face,再创建一次性贴面草图;生成布线路径网络时会自动转换为 UserPath。", + ) + + self.create_3d_user_path_button = _style_command_button( + QtWidgets.QPushButton(), + "选中点生成3D路径", + "按选择顺序把多个 3D 点、顶点、边端点或带 Points 的对象串成一条 UserPath,适合补竖向或跨平面路径。", + ) + + self.create_orthogonal_3d_user_path_button = _style_command_button( + QtWidgets.QPushButton(), + "选中点生成正交3D路径", + "按选择顺序把多个 3D 点串成只沿 X/Y/Z 单轴变化的折线路径,减少斜穿设备和柜体。", + ) + self.create_user_paths_button = _style_command_button( QtWidgets.QPushButton(), "选中路径作为用户路径", @@ -2558,6 +2677,9 @@ class AutoRoutingTaskPanel: for widget in ( self.generate_layout_button, + self.create_user_path_sketch_button, + self.create_3d_user_path_button, + self.create_orthogonal_3d_user_path_button, self.create_user_paths_button, self.set_terminal_local_route_button, self.create_user_path_bridge_button, @@ -2604,6 +2726,9 @@ class AutoRoutingTaskPanel: layout.addWidget(self.status_label) self.generate_paths_button.clicked.connect(self.generate_routing_paths) + self.create_user_path_sketch_button.clicked.connect(self.create_user_path_sketch_from_selection) + self.create_3d_user_path_button.clicked.connect(self.create_user_path_from_selected_points) + self.create_orthogonal_3d_user_path_button.clicked.connect(self.create_orthogonal_user_path_from_selected_points) self.create_user_paths_button.clicked.connect(self.create_user_paths_from_selection) self.set_terminal_local_route_button.clicked.connect(self.set_selected_terminal_local_route_points) self.create_user_path_bridge_button.clicked.connect(self.create_user_path_bridge_from_selection) @@ -2686,6 +2811,7 @@ class AutoRoutingTaskPanel: self.controller.set_lane_max_offset(self.lane_max_offset_spin.value()) self.controller.set_lane_axis(self.lane_axis_combo.currentText()) self.controller.set_selected_route_capacity(self.selected_route_capacity_spin.value()) + self.controller.set_user_path_sketch_offset(self.user_path_sketch_offset_spin.value()) def generate_routing_paths(self): try: @@ -2715,6 +2841,92 @@ class AutoRoutingTaskPanel: except Exception as exc: self._set_error(str(exc)) + def _select_and_edit_sketch(self, sketch): + if Gui is None or sketch is None: + return False + try: + Gui.Selection.clearSelection() + except Exception: + pass + try: + Gui.Selection.addSelection(sketch) + except Exception: + try: + doc = getattr(sketch, "Document", None) + Gui.Selection.addSelection(getattr(doc, "Name", ""), getattr(sketch, "Name", "")) + except Exception: + pass + try: + active_gui_doc = getattr(Gui, "ActiveDocument", None) + if active_gui_doc is not None: + active_gui_doc.setEdit(getattr(sketch, "Name", "")) + return True + except Exception: + pass + return False + + def create_user_path_sketch_from_selection(self): + try: + self._sync_options_from_widgets() + result = self.controller.create_user_path_sketch_from_selection() + sketch = result.get("sketch") + edited = self._select_and_edit_sketch(sketch) + sketch_name = getattr(sketch, "Label", "") or getattr(sketch, "Name", "") or "布线路径草图" + edit_text = "已进入草图编辑;" if edited else "已选中新草图,可双击进入编辑;" + self._set_status( + "已创建{0}:{1}请在草图里画布线路径线,退出草图后直接点击“生成布线路径网络”即可自动生成/刷新 UserPath;如果要立即转换当前草图,也可以选中它点击“选中路径作为用户路径”。{2}".format( + sketch_name, + edit_text, + self.controller.summary(), + ) + ) + except Exception as exc: + self._set_error(str(exc)) + + def create_user_path_from_selected_points(self): + try: + self._sync_options_from_widgets() + result = self.controller.create_user_path_from_selected_points() + count = result.get("user_path_carriers", 0) + point_count = result.get("user_path_point_count", 0) + if count <= 0: + self._set_status( + "未生成 3D 用户路径。请按顺序选择至少两个 3D 点、顶点或边端点。" + + self.controller.summary() + ) + return + self._set_status( + "已生成 3D 用户路径:{0} 条,路径点 {1} 个。重新生成布线路径网络后会参与自动布线。{2}".format( + count, + point_count, + self.controller.summary(), + ) + ) + except Exception as exc: + self._set_error(str(exc)) + + def create_orthogonal_user_path_from_selected_points(self): + try: + self._sync_options_from_widgets() + result = self.controller.create_orthogonal_user_path_from_selected_points() + count = result.get("user_path_carriers", 0) + point_count = result.get("user_path_point_count", 0) + if count <= 0: + self._set_status( + "未生成正交 3D 用户路径。请按顺序选择至少两个 3D 点、顶点或边端点。" + + self.controller.summary() + ) + return + self._set_status( + "已生成正交 3D 用户路径:{0} 条,路径点 {1} 个。路径只沿 X/Y/Z 单轴折线前进,重新生成布线路径网络后会参与自动布线。{2}".format( + count, + point_count, + self.controller.summary(), + ) + ) + except Exception as exc: + self._set_error(str(exc)) + def create_user_paths_from_selection(self): try: self._sync_options_from_widgets() diff --git a/src/Mod/FreeCADExchange/RoutingNetwork.py b/src/Mod/FreeCADExchange/RoutingNetwork.py index e087838..126ab24 100644 --- a/src/Mod/FreeCADExchange/RoutingNetwork.py +++ b/src/Mod/FreeCADExchange/RoutingNetwork.py @@ -57,6 +57,7 @@ DEFAULT_TERMINAL_ACCESS_COMPONENT_SEGMENT_PENALTY = 25.0 DEFAULT_TERMINAL_ACCESS_FALLBACK_ONLY_COMPONENT_PENALTY = 1000.0 DEFAULT_TERMINAL_ACCESS_FALLBACK_CARRIER_PENALTY = 5000.0 DEFAULT_TERMINAL_ACCESS_ENTRY_CANDIDATE_PENALTY = 2000.0 +DEFAULT_TERMINAL_DEVICE_EXIT_CLEARANCE = 10.0 DEFAULT_ADJOINING_DUCT_TOLERANCE = 5.0 DEFAULT_WIRING_CUT_OUT_BRIDGE_EXTENSION = 20.0 WIRE_DUCT_OBSTACLE_MODE = "PassThrough" @@ -700,6 +701,70 @@ def _set_user_path_source_semantics(source): ) +def _set_user_path_sketch_semantics(sketch, project_uuid="", support=None, sub_element_name="", offset=DEFAULT_ROUTE_PATH_FACE_OFFSET): + _set_user_path_source_semantics(sketch) + TerminalObjects.ensure_string_property( + sketch, + "QetProjectUuid", + PROPERTY_GROUP, + "Project UUID for this route sketch", + project_uuid, + ) + TerminalObjects.ensure_string_property( + sketch, + "QetRouteSketchMode", + PROPERTY_GROUP, + "Manual route sketch mode", + "ManualUserPathSketch", + ) + TerminalObjects.ensure_string_property( + sketch, + "QetRouteSketchSupportName", + PROPERTY_GROUP, + "One-shot support object used when creating this manual route sketch", + getattr(support, "Name", "") if support is not None else "", + ) + TerminalObjects.ensure_string_property( + sketch, + "QetRouteSketchSupportLabel", + PROPERTY_GROUP, + "One-shot support label used when creating this manual route sketch", + getattr(support, "Label", "") if support is not None else "", + ) + TerminalObjects.ensure_string_property( + sketch, + "QetRouteSketchSupportSubElement", + PROPERTY_GROUP, + "One-shot support sub-element used when creating this manual route sketch", + sub_element_name or "", + ) + _ensure_float_property( + sketch, + "QetRouteSketchFaceOffsetMm", + "Offset from selected support face when creating this manual route sketch", + offset, + ) + + +def _style_user_path_sketch(sketch): + view = getattr(sketch, "ViewObject", None) + if view is None: + return + for attr_name, value in ( + ("LineColor", (1.0, 0.85, 0.0)), + ("ShapeColor", (1.0, 0.85, 0.0)), + ("PointColor", (1.0, 0.85, 0.0)), + ): + try: + setattr(view, attr_name, value) + except Exception: + pass + try: + view.LineWidth = 3.0 + except Exception: + pass + + def _object_has_bbox(obj): shape = getattr(obj, "Shape", None) return getattr(shape, "BoundBox", None) is not None @@ -1222,6 +1287,14 @@ def _object_global_placement(obj): return getattr(obj, "Placement", None) +def _route_source_geometry_placement(obj): + type_id = (getattr(obj, "TypeId", "") or "").lower() + if "sketch" in type_id: + # 真实 Sketcher::SketchObject 的 Shape 点已经带有 Attachment/Placement,不能再平移一次。 + return None + return _object_global_placement(obj) + + def _points_with_placement(points, placement): return [_placement_mult_vec(placement, _vector(point)) for point in points] @@ -1283,6 +1356,13 @@ def _routing_source_text(obj): ).lower() +def _is_explicit_user_path_source(obj): + return ( + (getattr(obj, "QetRoutingSourceKind", "") or "").strip() == ROUTE_CARRIER_KIND_USER_PATH + or (getattr(obj, "QetRouteSketchMode", "") or "").strip() == "ManualUserPathSketch" + ) + + def _bbox_aspect_ratio(bbox): extents = sorted( (_bbox_extent(bbox, axis) for axis in ("x", "y", "z")), @@ -1300,6 +1380,8 @@ def _is_wire_duct_candidate(obj, min_aspect=DEFAULT_AUTO_WIRE_DUCT_MIN_ASPECT): return False if is_route_carrier(obj) or is_routing_boundary(obj) or TerminalObjects.is_terminal_object(obj): return False + if _is_explicit_user_path_source(obj): + return False if (getattr(obj, "RouteType", "") or "").strip(): return False text = _routing_source_text(obj) @@ -1345,6 +1427,8 @@ def _is_support_surface_candidate(obj): return False if is_route_carrier(obj) or is_routing_boundary(obj) or TerminalObjects.is_terminal_object(obj): return False + if _is_explicit_user_path_source(obj): + return False if (getattr(obj, "RouteType", "") or "").strip(): return False text = _routing_source_text(obj) @@ -1372,6 +1456,8 @@ def _is_wiring_cut_out_candidate(obj): return False if is_route_carrier(obj) or is_routing_boundary(obj) or TerminalObjects.is_terminal_object(obj): return False + if _is_explicit_user_path_source(obj): + return False if (getattr(obj, "RouteType", "") or "").strip(): return False @@ -1437,6 +1523,7 @@ def _point_runs_from_selection_item(selection_item): points = [] obj = getattr(selection_item, "Object", None) placement = _object_global_placement(obj) + geometry_placement = _route_source_geometry_placement(obj) for point in list(getattr(selection_item, "PickedPoints", []) or []): points.append(_vector(point)) @@ -1447,19 +1534,19 @@ def _point_runs_from_selection_item(selection_item): if points: runs.append(points) points = [] - runs.append(_points_with_placement(_wire_points(sub_object), placement)) + runs.append(_points_with_placement(_wire_points(sub_object), geometry_placement)) continue if shape_type == "edge": - points.extend(_points_with_placement(_edge_points(sub_object), placement)) + points.extend(_points_with_placement(_edge_points(sub_object), geometry_placement)) continue if shape_type == "vertex": point = getattr(sub_object, "Point", None) if point is not None: - points.append(_placement_mult_vec(placement, _vector(point))) + points.append(_placement_mult_vec(geometry_placement, _vector(point))) continue center = _shape_center(sub_object) if center is not None: - points.append(_placement_mult_vec(placement, center)) + points.append(_placement_mult_vec(geometry_placement, center)) if obj is not None and _is_route_path_source_object(obj): for point in list(getattr(obj, "Points", []) or []): @@ -1473,14 +1560,14 @@ def _point_runs_from_selection_item(selection_item): runs.append(points) points = [] for wire in wires: - runs.append(_points_with_placement(_wire_points(wire), placement)) + runs.append(_points_with_placement(_wire_points(wire), geometry_placement)) else: for edge in list(getattr(shape, "Edges", []) or []): - points.extend(_points_with_placement(_edge_points(edge), placement)) + points.extend(_points_with_placement(_edge_points(edge), geometry_placement)) if not runs and not points: center = _shape_center(shape) if center is not None: - points.append(_placement_mult_vec(placement, center)) + points.append(_placement_mult_vec(geometry_placement, center)) if points: runs.append(points) @@ -1508,6 +1595,21 @@ def _support_face_from_selection(selection_ex): return None +def _support_face_selection_from_selection(selection_ex): + for item in selection_ex or []: + source = getattr(item, "Object", None) + sub_names = list(getattr(item, "SubElementNames", []) or []) + for index, sub_object in enumerate(list(getattr(item, "SubObjects", []) or [])): + if (getattr(sub_object, "ShapeType", "") or "").lower() != "face": + continue + return { + "face": sub_object, + "source": source, + "sub_element_name": sub_names[index] if index < len(sub_names) else "", + } + return None + + def _selection_item_is_only_support_face(selection_item): sub_objects = list(getattr(selection_item, "SubObjects", []) or []) if not sub_objects: @@ -1703,6 +1805,216 @@ def _project_points_to_face(points, face, offset=DEFAULT_ROUTE_PATH_FACE_OFFSET) return projected +def _rotation_from_face_normal(normal): + try: + rotation = App.Rotation(App.Vector(0, 0, 1), normal) + if rotation is not None: + return rotation + except Exception: + pass + try: + return App.Rotation() + except Exception: + return None + + +def _attach_sketch_to_support_face(sketch, support, sub_element_name, offset, fallback_base, normal): + if sketch is None: + return + attached = False + if support is not None and sub_element_name: + try: + sketch.AttachmentSupport = [(support, sub_element_name)] + sketch.MapMode = "FlatFace" + sketch.AttachmentOffset = App.Placement( + App.Vector(0, 0, abs(float(offset or 0.0))), + App.Rotation(), + ) + attached = True + except Exception: + attached = False + try: + sketch.Placement = App.Placement(fallback_base, _rotation_from_face_normal(normal)) + except Exception: + pass + if attached: + # Attachment 是真实 FreeCAD 里的编辑语义;Placement 仍保留为测试和旧对象兜底。 + return + + +def create_user_path_sketch_from_selection( + doc, + selection_ex, + project_uuid="", + offset=DEFAULT_ROUTE_PATH_FACE_OFFSET, +): + """Create a one-shot Sketcher sketch on the selected cabinet/support face for manual UserPath drawing.""" + if doc is None: + raise RoutingNetworkError("没有可用的 FreeCAD 文档。") + + support = _support_face_selection_from_selection(selection_ex) + if support is None: + raise RoutingNetworkError("请先选中机柜面板、安装板、门板或线槽上的一个面 Face,再创建布线路径草图。") + + face = support["face"] + normal = _normalize(_face_normal(face)) + if normal is None: + raise RoutingNetworkError("选中的 Face 无法确定法向,不能创建布线路径草图。") + + face_points = _face_points(face) + origin = _face_origin(face, face_points) + base = _add(origin, _scale(normal, abs(float(offset or 0.0)))) + name = _unique_name(doc, "QETUserPathSketch") + sketch = doc.addObject("Sketcher::SketchObject", name) + sketch.Label = "布线路径草图" + _attach_sketch_to_support_face( + sketch, + support.get("source"), + support.get("sub_element_name", ""), + abs(float(offset or 0.0)), + base, + normal, + ) + _set_user_path_sketch_semantics( + sketch, + project_uuid=project_uuid, + support=support.get("source"), + sub_element_name=support.get("sub_element_name", ""), + offset=abs(float(offset or 0.0)), + ) + _style_user_path_sketch(sketch) + try: + doc.recompute() + except Exception: + pass + return { + "sketch": sketch, + "support": support.get("source"), + "sub_element_name": support.get("sub_element_name", ""), + "offset": abs(float(offset or 0.0)), + } + + +def _selected_points_from_selection(selection_ex): + points = [] + for item in selection_ex or []: + obj = getattr(item, "Object", None) + placement = _object_global_placement(obj) + geometry_placement = _route_source_geometry_placement(obj) + for point in list(getattr(item, "PickedPoints", []) or []): + points.append(_vector(point)) + for sub_object in list(getattr(item, "SubObjects", []) or []): + shape_type = (getattr(sub_object, "ShapeType", "") or "").lower() + if shape_type == "wire": + points.extend(_points_with_placement(_wire_points(sub_object), geometry_placement)) + continue + if shape_type == "edge": + points.extend(_points_with_placement(_edge_points(sub_object), geometry_placement)) + continue + if shape_type == "vertex": + point = getattr(sub_object, "Point", None) + if point is not None: + points.append(_placement_mult_vec(geometry_placement, _vector(point))) + continue + center = _shape_center(sub_object) + if center is not None: + points.append(_placement_mult_vec(geometry_placement, center)) + if obj is not None and _is_route_path_source_object(obj): + for point in list(getattr(obj, "Points", []) or []): + points.append(_placement_mult_vec(placement, _vector(point))) + return _normalize_point_run(points) + + +def create_user_path_carrier_from_selected_points(doc, selection_ex, project_uuid="", label="QET 3D User Route Path"): + """Create one 3D UserPath from selected vertices, picked points, or point-like sub-objects in selection order.""" + if doc is None: + raise RoutingNetworkError("没有可用的 FreeCAD 文档。") + points = _selected_points_from_selection(selection_ex) + if len(points) < 2: + raise RoutingNetworkError("请至少按顺序选择两个 3D 点、顶点、边端点或带 Points 的路径对象。") + carrier = create_route_carrier( + doc, + points, + label=label, + project_uuid=project_uuid, + kind=ROUTE_CARRIER_KIND_USER_PATH, + ) + try: + doc.recompute() + except Exception: + pass + return { + "carrier": carrier, + "points": points, + } + + +def _orthogonal_points_between(start, end, axis_order=("x", "y", "z")): + current = _vector(start) + end = _vector(end) + points = [current] + for axis in axis_order or ("x", "y", "z"): + if abs(_axis_value(current, axis) - _axis_value(end, axis)) <= DEFAULT_NODE_TOLERANCE: + continue + current = _set_axis(current, axis, _axis_value(end, axis)) + if _distance(points[-1], current) > DEFAULT_NODE_TOLERANCE: + points.append(current) + if _distance(points[-1], end) > DEFAULT_NODE_TOLERANCE: + points.append(end) + return points + + +def _orthogonalize_points(points, axis_order=("x", "y", "z")): + source_points = _normalize_point_run([_vector(point) for point in points or []]) + if len(source_points) < 2: + return source_points + result = [source_points[0]] + for end in source_points[1:]: + segment_points = _orthogonal_points_between(result[-1], end, axis_order=axis_order) + for point in segment_points[1:]: + if _distance(result[-1], point) > DEFAULT_NODE_TOLERANCE: + result.append(point) + return _normalize_point_run(result) + + +def create_orthogonal_user_path_carrier_from_selected_points( + doc, + selection_ex, + project_uuid="", + label="QET Orthogonal 3D User Route Path", + axis_order=("x", "y", "z"), +): + """Create one X/Y/Z orthogonal 3D UserPath from selected points in selection order.""" + if doc is None: + raise RoutingNetworkError("没有可用的 FreeCAD 文档。") + points = _selected_points_from_selection(selection_ex) + if len(points) < 2: + raise RoutingNetworkError("请至少按顺序选择两个 3D 点、顶点、边端点或带 Points 的路径对象。") + orthogonal_points = _orthogonalize_points(points, axis_order=axis_order) + carrier = create_route_carrier( + doc, + orthogonal_points, + label=label, + project_uuid=project_uuid, + kind=ROUTE_CARRIER_KIND_USER_PATH, + ) + TerminalObjects.ensure_string_property( + carrier, + "QetRoutePathMode", + PROPERTY_GROUP, + "Manual 3D route path mode", + "Orthogonal3D", + ) + try: + doc.recompute() + except Exception: + pass + return { + "carrier": carrier, + "points": orthogonal_points, + } + + def create_carriers_from_selection(doc, selection_ex, project_uuid="", kind=ROUTE_CARRIER_KIND): created = [] support_face = _support_face_from_selection(selection_ex) @@ -1762,6 +2074,11 @@ def create_user_path_carriers_from_selection(doc, selection_ex, project_uuid="") point_runs = [_normalize_point_run(points) for points in point_runs] point_runs = [points for points in point_runs if len(points) >= 2] if not point_runs: + if source is not None: + live_carriers = _live_source_carriers(doc, source) + if live_carriers: + _remove_route_carriers(doc, live_carriers) + _mark_user_path_source_carriers(source, []) continue label = "QET User Route Path {0}".format(index) @@ -1821,6 +2138,70 @@ def create_user_path_carriers_from_selection(doc, selection_ex, project_uuid="") return created +def _create_or_refresh_user_path_source(doc, source, project_uuid="", label_prefix="QET User Route Path"): + if source is None or is_route_carrier(source) or is_routing_boundary(source): + return [] + point_runs = _point_runs_from_selection_item(type("_Selection", (), {"Object": source, "SubObjects": []})()) + point_runs = [_normalize_point_run(points) for points in point_runs] + point_runs = [points for points in point_runs if len(points) >= 2] + if not point_runs: + live_carriers = _live_source_carriers(doc, source) + if live_carriers: + _remove_route_carriers(doc, live_carriers) + _mark_user_path_source_carriers(source, []) + return [] + + label = "{0} {1}".format( + label_prefix, + getattr(source, "Label", "") or getattr(source, "Name", "") or "", + ).strip() + capacity = _route_carrier_capacity_value(source, default=1) + live_carriers = _live_source_carriers(doc, source) + refreshed = [] + for run_index, points in enumerate(point_runs, start=1): + run_label = label if len(point_runs) == 1 else "{0} {1}".format(label, run_index) + if run_index <= len(live_carriers): + carrier = live_carriers[run_index - 1] + if _update_route_carrier( + carrier, + points, + project_uuid=project_uuid, + kind=ROUTE_CARRIER_KIND_USER_PATH, + capacity=capacity, + ): + refreshed.append(carrier) + continue + refreshed.append( + create_route_carrier( + doc, + points, + label=run_label, + project_uuid=project_uuid, + kind=ROUTE_CARRIER_KIND_USER_PATH, + capacity=capacity, + ) + ) + if len(live_carriers) > len(point_runs): + _remove_route_carriers(doc, live_carriers[len(point_runs) :]) + _mark_user_path_source_carriers(source, refreshed) + return refreshed + + +def create_user_path_carriers_from_document(doc, project_uuid=""): + """Create or refresh UserPath carriers from all sketch/Draft-like path sources in the document.""" + cleanup_invalid_source_carriers(doc) + created = [] + for source in detect_document_user_path_sources(doc): + if ( + _is_wire_duct_candidate(source) + or _is_support_surface_candidate(source) + or _is_wiring_cut_out_candidate(source) + ): + continue + created.extend(_create_or_refresh_user_path_source(doc, source, project_uuid=project_uuid)) + return created + + def _nearest_points_between_route_point_runs(left_points, right_points): left_points = _normalized_route_points(left_points) right_points = _normalized_route_points(right_points) @@ -2357,6 +2738,8 @@ def _set_route_carrier_source_metadata(carrier, source, source_kind="", source_p def _remember_source_carriers(source, carriers): + if source is None: + return live_names = [ getattr(carrier, "Name", "") for carrier in (carriers or []) @@ -2377,13 +2760,13 @@ def _remember_source_carriers(source, carriers): source_kind=source_kind, source_path_index=source_path_index, ) - TerminalObjects.ensure_string_property( - source, - "QetRouteCarrierNamesJson", - PROPERTY_GROUP, - "Generated route carriers for this source", - json.dumps(live_names, ensure_ascii=False), - ) + TerminalObjects.ensure_string_property( + source, + "QetRouteCarrierNamesJson", + PROPERTY_GROUP, + "Generated route carriers for this source", + json.dumps(live_names, ensure_ascii=False), + ) def _mark_wire_duct_source(source, carrier, carriers=None, end_margin=DEFAULT_WIRE_DUCT_MARGIN): @@ -2457,7 +2840,7 @@ def _mark_user_path_source(source, carrier): def _mark_user_path_source_carriers(source, carriers): carriers = [carrier for carrier in (carriers or []) if carrier is not None] - if source is None or not carriers: + if source is None: return try: _set_user_path_source_semantics(source) @@ -2466,7 +2849,7 @@ def _mark_user_path_source_carriers(source, carriers): "QetRouteCarrierName", PROPERTY_GROUP, "Generated route carrier for this source", - getattr(carriers[0], "Name", ""), + getattr(carriers[0], "Name", "") if carriers else "", ) _remember_source_carriers(source, carriers) except Exception: @@ -2626,6 +3009,47 @@ def detect_user_path_sources(doc): return sources +def _is_document_user_path_source(obj): + if not _is_route_path_source_object(obj): + return False + if _is_explicit_user_path_source(obj): + return True + text = " ".join( + str(value or "") + for value in ( + getattr(obj, "Name", ""), + getattr(obj, "Label", ""), + ) + ).lower() + return any( + keyword in text + for keyword in ( + "userpath", + "user path", + "route path", + "routing path", + "wire path", + "wiring path", + "布线路径", + "走线路径", + "用户路径", + ) + ) + + +def detect_document_user_path_sources(doc): + """Return path sources that are safe to auto-convert during full network generation.""" + sources = [] + seen = set() + for obj in list(getattr(doc, "Objects", []) or []): + if id(obj) in seen: + continue + seen.add(id(obj)) + if _is_document_user_path_source(obj): + sources.append(obj) + return sources + + def _source_sample(obj): return { "name": getattr(obj, "Name", ""), @@ -3060,6 +3484,68 @@ def _terminal_parent_chain(terminal): return chain +def _bbox_volume(bbox): + try: + return ( + max(float(bbox.XMax) - float(bbox.XMin), 0.0) + * max(float(bbox.YMax) - float(bbox.YMin), 0.0) + * max(float(bbox.ZMax) - float(bbox.ZMin), 0.0) + ) + except Exception: + return float("inf") + + +def _terminal_parent_device_bbox(terminal, origin): + candidates = [] + seen = set() + pending = list(getattr(terminal, "InList", []) or []) + while pending: + parent = pending.pop(0) + if parent is None or id(parent) in seen: + continue + seen.add(id(parent)) + bbox = _bound_box_from_object(parent) + if bbox is not None and _point_inside_bbox(origin, bbox, tolerance=DEFAULT_NODE_TOLERANCE): + candidates.append(bbox) + pending.extend(list(getattr(parent, "InList", []) or [])) + if not candidates: + return None + return min(candidates, key=_bbox_volume) + + +def _ray_exit_distance_from_bbox(origin, direction, bbox): + if not _point_inside_bbox(origin, bbox, tolerance=DEFAULT_NODE_TOLERANCE): + return None + distances = [] + for axis in ("x", "y", "z"): + component = _axis_value(direction, axis) + if abs(component) <= DEFAULT_NODE_TOLERANCE: + continue + low, high = _bbox_axis_range(bbox, axis) + boundary = high if component > 0 else low + distance = (boundary - _axis_value(origin, axis)) / component + if distance >= -DEFAULT_NODE_TOLERANCE: + distances.append(max(float(distance), 0.0)) + if not distances: + return None + return min(distances) + + +def _terminal_device_aware_exit_point(terminal, exit_length): + origin = _vector(TerminalObjects.terminal_origin(terminal)) + direction = _normalize(_vector(TerminalObjects.terminal_direction(terminal))) + if direction is None: + direction = App.Vector(0, 0, 1) + + length = max(float(exit_length or 0.0), 0.0) + bbox = _terminal_parent_device_bbox(terminal, origin) + exit_distance = _ray_exit_distance_from_bbox(origin, direction, bbox) + if exit_distance is not None: + # 没有人工局部路径时,默认出线至少先离开所属设备外轮廓,避免导线贴在模型内部。 + length = max(length, exit_distance + DEFAULT_TERMINAL_DEVICE_EXIT_CLEARANCE) + return _add(origin, _scale(direction, length)) + + def _terminal_local_point_to_global(terminal, local_point): try: if hasattr(terminal, "getGlobalPlacement"): @@ -3176,7 +3662,7 @@ def terminal_access_path_points(terminal, exit_length=20.0): normalized = _normalized_route_points(points) if len(normalized) >= 2: return normalized - return _normalized_route_points([origin, _terminal_exit_point(terminal, exit_length)]) + return _normalized_route_points([origin, _terminal_device_aware_exit_point(terminal, exit_length)]) def _orthogonal_access_points(start, end): @@ -3491,6 +3977,10 @@ def create_routing_path_network_from_document( selection_ex, project_uuid=project_uuid, ) + document_user_paths = create_user_path_carriers_from_document( + doc, + project_uuid=project_uuid, + ) wire_ducts = create_wire_duct_carriers_from_document( doc, project_uuid=project_uuid, @@ -3520,6 +4010,13 @@ def create_routing_path_network_from_document( for carrier in selected_wire_ducts if (getattr(carrier, "QetRouteCarrierKind", "") or "").strip() == ROUTE_CARRIER_KIND_WIRE_DUCT ) + all_user_paths = [] + seen_user_paths = set() + for carrier in list(selected_user_paths) + list(document_user_paths): + if carrier is None or id(carrier) in seen_user_paths: + continue + seen_user_paths.add(id(carrier)) + all_user_paths.append(carrier) open_end_count = sum( 1 for carrier in all_wire_duct_created @@ -3529,7 +4026,9 @@ def create_routing_path_network_from_document( return { "wire_duct_carriers": wire_duct_main_count, "selected_wire_duct_carriers": selected_wire_duct_main_count, - "user_path_carriers": len(selected_user_paths), + "user_path_carriers": len(all_user_paths), + "selected_user_path_carriers": len(selected_user_paths), + "document_user_path_carriers": len(document_user_paths), "wire_duct_open_end_carriers": open_end_count, "wiring_cut_out_carriers": len(cut_outs), "surface_carriers": len(surfaces), diff --git a/tests/python/freecad_exchange_auto_routing_test.py b/tests/python/freecad_exchange_auto_routing_test.py index 327d7fe..6e321e8 100644 --- a/tests/python/freecad_exchange_auto_routing_test.py +++ b/tests/python/freecad_exchange_auto_routing_test.py @@ -150,6 +150,8 @@ class FakeShape: class FakeVertex: + ShapeType = "Vertex" + def __init__(self, point): self.Point = point @@ -197,8 +199,9 @@ class FakeFace: class FakeSelectionItem: - def __init__(self, sub_objects=None, obj=None): + def __init__(self, sub_objects=None, obj=None, sub_element_names=None): self.SubObjects = sub_objects or [] + self.SubElementNames = sub_element_names or [] self.PickedPoints = [] self.Object = obj @@ -2824,7 +2827,7 @@ class AutoRoutingTest(unittest.TestCase): [(point.x, point.y, point.z) for point in carriers[0].Points], ) - def test_selected_user_path_shape_points_honor_object_placement(self): + def test_selected_user_path_shape_points_honor_object_placement_for_non_sketch_path(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() auto_routing_panel = importlib.import_module("AutoRoutingPanel") @@ -2833,7 +2836,7 @@ class AutoRoutingTest(unittest.TestCase): doc = FakeDocument() app.ActiveDocument = doc terminal_objects.ensure_root_group(doc, "project-1") - route_path = doc.addObject("Sketcher::SketchObject", "MovedRouteSketch") + route_path = doc.addObject("Part::Feature", "MovedRoutePath") route_path.Placement = app.Placement(app.Vector(100, 10, 5), app.Rotation()) route_path.Shape = FakeShape( FakeBoundBox(0, 50, 0, 50, 20, 20), @@ -2861,6 +2864,296 @@ class AutoRoutingTest(unittest.TestCase): [(point.x, point.y, point.z) for point in carriers[0].Points], ) + def test_selected_sketch_user_path_shape_points_are_not_placement_shifted_twice(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + gui = sys.modules["FreeCADGui"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + route_path = doc.addObject("Sketcher::SketchObject", "PlacedRouteSketch") + route_path.Placement = app.Placement(app.Vector(100, 10, 35), app.Rotation()) + route_path.Shape = FakeShape( + FakeBoundBox(100, 150, 10, 60, 35, 35), + wires=[ + FakeWire( + [ + app.Vector(100, 10, 35), + app.Vector(150, 10, 35), + app.Vector(150, 60, 35), + ] + ) + ], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], + ) + + result = auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() + carriers = routing_network.collect_route_carriers(doc) + + self.assertEqual(1, result["user_path_carriers"]) + self.assertEqual( + [(100.0, 10.0, 35.0), (150.0, 10.0, 35.0), (150.0, 60.0, 35.0)], + [(point.x, point.y, point.z) for point in carriers[0].Points], + ) + + def test_controller_creates_face_based_user_path_sketch_for_manual_drawing(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + gui = sys.modules["FreeCADGui"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + panel = doc.addObject("Part::Feature", "MountingPlateA") + face = FakeFace( + FakeBoundBox(0, 100, 0, 60, 10, 10), + app.Vector(0, 0, 1), + vertices=[ + app.Vector(0, 0, 10), + app.Vector(100, 0, 10), + app.Vector(100, 60, 10), + app.Vector(0, 60, 10), + ], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=panel, sub_objects=[face], sub_element_names=["Face1"])], + ) + + result = auto_routing_panel.AutoRoutingController().create_user_path_sketch_from_selection() + sketch = result["sketch"] + + self.assertEqual(1, result["user_path_sketches"]) + self.assertEqual("Sketcher::SketchObject", sketch.TypeId) + self.assertEqual("UserPath", sketch.QetRoutingSourceKind) + self.assertEqual("project-1", sketch.QetProjectUuid) + self.assertEqual("MountingPlateA", sketch.QetRouteSketchSupportName) + self.assertEqual("Face1", sketch.QetRouteSketchSupportSubElement) + self.assertEqual([(panel, "Face1")], sketch.AttachmentSupport) + self.assertEqual("FlatFace", sketch.MapMode) + self.assertEqual(12.0, sketch.Placement.Base.z) + + def test_controller_creates_face_based_user_path_sketch_with_configured_offset(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + gui = sys.modules["FreeCADGui"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + panel = doc.addObject("Part::Feature", "MountingPlateA") + face = FakeFace( + FakeBoundBox(0, 100, 0, 60, 10, 10), + app.Vector(0, 0, 1), + vertices=[ + app.Vector(0, 0, 10), + app.Vector(100, 0, 10), + app.Vector(100, 60, 10), + app.Vector(0, 60, 10), + ], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=panel, sub_objects=[face], sub_element_names=["Face1"])], + ) + controller = auto_routing_panel.AutoRoutingController() + controller.set_user_path_sketch_offset(50.0) + + result = controller.create_user_path_sketch_from_selection() + sketch = result["sketch"] + + self.assertEqual(60.0, sketch.Placement.Base.z) + self.assertEqual(50.0, sketch.QetRouteSketchFaceOffsetMm) + self.assertEqual(50.0, sketch.AttachmentOffset.Base.z) + + def test_controller_creates_3d_user_path_from_selected_vertices_in_selection_order(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + gui = sys.modules["FreeCADGui"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + source_a = doc.addObject("Part::Feature", "PointA") + source_b = doc.addObject("Part::Feature", "PointB") + source_c = doc.addObject("Part::Feature", "PointC") + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [ + FakeSelectionItem(obj=source_a, sub_objects=[FakeVertex(app.Vector(0, 0, 10))]), + FakeSelectionItem(obj=source_b, sub_objects=[FakeVertex(app.Vector(0, 50, 10))]), + FakeSelectionItem(obj=source_c, sub_objects=[FakeVertex(app.Vector(80, 50, 40))]), + ], + ) + + result = auto_routing_panel.AutoRoutingController().create_user_path_from_selected_points() + carriers = routing_network.collect_route_carriers(doc) + + self.assertEqual(1, result["user_path_carriers"]) + self.assertEqual( + [(0.0, 0.0, 10.0), (0.0, 50.0, 10.0), (80.0, 50.0, 40.0)], + [(point.x, point.y, point.z) for point in carriers[0].Points], + ) + + def test_controller_creates_orthogonal_3d_user_path_from_selected_vertices(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + gui = sys.modules["FreeCADGui"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + source_a = doc.addObject("Part::Feature", "PointA") + source_b = doc.addObject("Part::Feature", "PointB") + source_c = doc.addObject("Part::Feature", "PointC") + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [ + FakeSelectionItem(obj=source_a, sub_objects=[FakeVertex(app.Vector(0, 0, 10))]), + FakeSelectionItem(obj=source_b, sub_objects=[FakeVertex(app.Vector(0, 50, 10))]), + FakeSelectionItem(obj=source_c, sub_objects=[FakeVertex(app.Vector(80, 50, 40))]), + ], + ) + + result = auto_routing_panel.AutoRoutingController().create_orthogonal_user_path_from_selected_points() + carriers = routing_network.collect_route_carriers(doc) + + self.assertEqual(1, result["user_path_carriers"]) + self.assertEqual(4, result["user_path_point_count"]) + self.assertEqual("Orthogonal3D", carriers[0].QetRoutePathMode) + self.assertEqual( + [ + (0.0, 0.0, 10.0), + (0.0, 50.0, 10.0), + (80.0, 50.0, 10.0), + (80.0, 50.0, 40.0), + ], + [(point.x, point.y, point.z) for point in carriers[0].Points], + ) + + def test_generate_routing_paths_discovers_drawn_user_path_sketch_without_selecting_it(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + gui = sys.modules["FreeCADGui"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + route_path = doc.addObject("Sketcher::SketchObject", "DrawnUserPathSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 120, 0, 80, 20, 20), + wires=[ + FakeWire( + [ + app.Vector(0, 0, 20), + app.Vector(0, 60, 20), + app.Vector(120, 60, 20), + ] + ) + ], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [], + ) + + result = auto_routing_panel.AutoRoutingController().generate_routing_paths() + carriers = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if carrier.QetRouteCarrierKind == "UserPath" + ] + + self.assertEqual(1, result["user_path_carriers"]) + self.assertEqual(1, len(carriers)) + self.assertEqual( + [(0.0, 0.0, 20.0), (0.0, 60.0, 20.0), (120.0, 60.0, 20.0)], + [(point.x, point.y, point.z) for point in carriers[0].Points], + ) + + def test_generate_routing_paths_does_not_auto_convert_unmarked_mechanical_sketch(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + gui = sys.modules["FreeCADGui"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + mechanical_sketch = doc.addObject("Sketcher::SketchObject", "Sketch001") + mechanical_sketch.Shape = FakeShape( + FakeBoundBox(0, 120, 0, 80, 20, 20), + wires=[FakeWire([app.Vector(0, 0, 20), app.Vector(120, 0, 20)])], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [], + ) + + result = auto_routing_panel.AutoRoutingController().generate_routing_paths() + carriers = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if carrier.QetRouteCarrierKind == "UserPath" + ] + + self.assertEqual(0, result["user_path_carriers"]) + self.assertEqual([], carriers) + + def test_marked_user_path_sketch_is_not_skipped_when_name_mentions_support_surface(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + gui = sys.modules["FreeCADGui"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + route_path = doc.addObject("Sketcher::SketchObject", "MountingPlateRouteSketch") + route_path.Label = "安装板布线路径草图" + route_path.QetRoutingSourceKind = "UserPath" + route_path.QetRouteSketchMode = "ManualUserPathSketch" + route_path.Shape = FakeShape( + FakeBoundBox(0, 120, 0, 80, 20, 20), + wires=[ + FakeWire( + [ + app.Vector(0, 0, 20), + app.Vector(0, 80, 20), + app.Vector(120, 80, 20), + ] + ) + ], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [], + ) + + result = auto_routing_panel.AutoRoutingController().generate_routing_paths() + carriers = [ + carrier + for carrier in routing_network.collect_route_carriers(doc) + if carrier.QetRouteCarrierKind == "UserPath" + ] + + self.assertEqual(1, result["user_path_carriers"]) + self.assertEqual(1, len(carriers)) + self.assertEqual( + [(0.0, 0.0, 20.0), (0.0, 80.0, 20.0), (120.0, 80.0, 20.0)], + [(point.x, point.y, point.z) for point in carriers[0].Points], + ) + def test_disconnected_shape_wires_create_separate_user_paths(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() @@ -2937,6 +3230,36 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual("", carriers[0].QetRouteSourcePathIndex) self.assertEqual(1, len(json.loads(route_path.QetRouteCarrierNamesJson))) + def test_refreshing_marked_user_path_with_no_drawn_lines_removes_stale_carriers(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + route_path = doc.addObject("Sketcher::SketchObject", "EditableUserPathSketch") + route_path.QetRoutingSourceKind = "UserPath" + route_path.QetRouteSketchMode = "ManualUserPathSketch" + route_path.Shape = FakeShape( + FakeBoundBox(0, 80, 0, 20, 20, 20), + wires=[FakeWire([app.Vector(0, 0, 20), app.Vector(80, 20, 20)])], + ) + first = routing_network.create_user_path_carriers_from_document( + doc, + project_uuid="project-1", + ) + route_path.Shape = FakeShape(FakeBoundBox(0, 80, 0, 20, 20, 20), wires=[]) + + second = routing_network.create_user_path_carriers_from_document( + doc, + project_uuid="project-1", + ) + carriers = routing_network.collect_route_carriers(doc) + + self.assertEqual(1, len(first)) + self.assertEqual([], second) + self.assertEqual([], carriers) + self.assertEqual([], json.loads(route_path.QetRouteCarrierNamesJson)) + def test_refreshing_single_wire_user_path_adds_new_carriers_for_added_wires(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() @@ -5115,6 +5438,22 @@ class AutoRoutingTest(unittest.TestCase): [(point.x, point.y, point.z) for point in access_points], ) + def test_terminal_access_extends_past_parent_device_bbox_when_no_local_route_exists(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") + device = doc.addObject("App::DocumentObjectGroup", "ProtectionDevice") + device.Shape = FakeShape(FakeBoundBox(-20, 20, -20, 20, -5, 60)) + terminal = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + device.addObject(terminal) + + access_points = routing_network.terminal_access_path_points(terminal, exit_length=20.0) + + self.assertEqual((0.0, 0.0, 0.0), (access_points[0].x, access_points[0].y, access_points[0].z)) + self.assertGreater(access_points[-1].z, 60.0) + def test_generate_routing_paths_refreshes_selected_user_path_without_duplicate(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules()