diff --git a/docs/FreeCAD 3D自动布线设计方案.md b/docs/FreeCAD 3D自动布线设计方案.md index 0ca4427..982d210 100644 --- a/docs/FreeCAD 3D自动布线设计方案.md +++ b/docs/FreeCAD 3D自动布线设计方案.md @@ -69,6 +69,175 @@ terminal_uuid 4. 3D 位姿、装配关系、布线几何以 FreeCAD 文档为准。 +### 1.4 金晖隆布线需求提炼 + +根据金晖隆提供的 3D 布线功能需求文档和操作演示视频,当前自动布线需要吸收以下信息。 + +#### 1.4.1 甲方演示中的完整工作流 + +演示流程不是单纯“两个端子之间生成一根线”,而是完整的设备模板、装配、路径和电气关系闭环: + +```text +三维柜体/安装板 + -> 从设备库选择已做好的设备 + -> 设备快速配合到导轨/安装板 + -> 缺失设备按说明书新建三维模块 + -> 为设备定义电气脚点 + -> 为设备定义局部走线路径 + -> 绘制柜内主走线路径 + -> 将设备脚点、局部路径、主路径和 QET 导线关系关联 + -> 生成 3D 布线 +``` + +设备库中的设备不只是几何模型,还应包含: + +1. 外形尺寸。 +2. 电气属性。 +3. 接线脚点。 +4. 设备自身走线路径。 +5. 配合面或安装基准。 + +#### 1.4.2 对当前 FreeCAD 自动布线最有价值的信息 + +1. 柜内布线不应强制依赖线槽。线槽是最高优先级路径,但用户自定义三维草图路径也必须能参与布线。 +2. 设备模板需要保存“局部出线路径”。自动布线不应直接从端子跳到全局主路径,而应支持: + +```text +设备脚点 + -> 设备局部走线路径 + -> 柜内主路径/线槽/用户草图路径 + -> 目标设备局部走线路径 + -> 目标设备脚点 +``` + +3. 端子排、小型断路器等重复设备需要批量插入、自动沿导轨排布、自动配合。 +4. 导轨、线槽、安装板、设备之间的装配关系会直接影响布线路径网络。 +5. 布线完成后需要支持线束高亮和源/目标设备提示。 + +#### 1.4.3 当前阶段暂缓的内容 + +取线机和完整取线表可以放到后面阶段。当前阶段只保留布线结果中必要的基础数据: + +1. 起点端子和终点端子。 +2. 线号/导线标识。 +3. 导线长度。 +4. 导线样式 ID 或 QET 传入的导线规格引用。 +5. 路由路径诊断。 + +导线规格、颜色、线耳等导线数据由 QET 提供,FreeCAD 第一版只消费和保留,不在 3D 侧重新发明导线主数据。 + +### 1.5 设备脚号与 3D 脚点绑定方案 + +设备脚号与 3D 脚点绑定分成两层:模板槽位绑定和工程端子绑定。 + +#### 1.5.1 模板槽位 + +设备模板 `.FCStd` 内,每个可接线脚点使用 LCS 或等价定位对象表示,并保存稳定槽位名: + +```text +QetTemplateSlotName = "A1" | "A2" | "13" | "14" | "P1" | "P2" | ... +QetTerminalLabel = <显示名> +Role = "Terminal" +CanWire = true +``` + +这个槽位名来自设备说明书或设备实物端子标识。它是跨项目复用的模板语义,不保存 `terminal_uuid`。 + +#### 1.5.2 工程端子 + +工程中实例化设备后,FreeCAD 从模板槽位生成工程端子,并补上当前项目和实例信息: + +```text +QetProjectUuid +QetElementUuid +QetInstanceId +QetTerminalUuid +QetTemplateSlotName +``` + +其中 `QetTerminalUuid` 是 2D 端子主键,也是第一版 3D 端子绑定唯一依据。 + +#### 1.5.3 匹配优先级 + +当 QET 导入导线任务或端子数据后,FreeCAD 按下面顺序把 2D 脚号绑定到 3D 脚点: + +1. 如果工程端子已经有相同 `terminal_uuid`,直接使用该工程端子。 +2. 在相同 `instance_id` 的设备下,用 QET 当前已有的端子显示名/脚号,例如 `terminal_display`、`start_terminal_display`、`end_terminal_display`,归一化后匹配 `QetTemplateSlotName` 或 `QetTerminalLabel`。 +3. 如果后续 QET 额外提供 `slot_name_hint`,则可优先用它精确匹配模板槽位;但它不是第一版必需字段。 +4. 如果同一设备下 2D 端子数量和 3D 模板槽位数量一致,允许按顺序兜底匹配,但必须写诊断提示。 +5. 仍无法匹配时,保留为 `local:*` 本地端子,不参与可靠自动布线。 + +#### 1.5.4 QET 需要配合提供的数据 + +第一版数据库仍只使用 `project_2d3d_symbol_binding` 和 `project_2d3d_terminal_binding`。交换 JSON 中当前应优先使用已有的端子显示字段作为匹配提示: + +```json +{ + "terminal_uuid": "2d-terminal-uuid", + "element_uuid": "2d-device-element-uuid", + "instance_id": "3d-device-instance-id", + "terminal_display": "A1" +} +``` + +这些字段只作为 FreeCAD 匹配模板槽位的提示,不写入第一版绑定表,也不能替代 `terminal_uuid`。 + +`slot_name_hint` 只是 FreeCAD 侧预留的可选扩展字段。当前 QET 如果没有该字段,不需要为了第一版专门增加;只要 `terminal_display` / `start_terminal_display` / `end_terminal_display` 能稳定表示设备脚号,就可以完成槽位匹配。 + +QET 侧还需要保证导线任务中继续提供: + +```json +{ + "start_terminal_uuid": "...", + "end_terminal_uuid": "...", + "start_instance_id": "...", + "end_instance_id": "...", + "wire_label": "...", + "wire_style_id": "..." +} +``` + +其中导线规格、颜色、线耳等导线主数据以后由 `wire_style_id` 或等价字段回查 QET。 + +### 1.6 区域与批量排布方案 + +#### 1.6.1 设备区域/柜内区域 + +取线机阶段之前,区域先不写数据库。FreeCAD 文档中可用空间对象或属性对象定义区域: + +```text +QetRegionId +QetRegionName +QetRegionKind = "Cabinet" | "Door" | "MountingPlate" | "LeftBay" | "RightBay" | ... +``` + +设备区域通过 3D 包围盒或设备基准点落在哪个区域对象内推导。后续生成取线表时,再把区域名称写入导出数据。 + +#### 1.6.2 端子排和小型断路器批量排布 + +端子排、小型断路器排布规则参照 SW 的装配思路:导轨是宿主,设备沿导轨轴向按顺序排布。 + +推荐 FreeCAD 文档内保存: + +```text +QetMountHostName = +QetRailAxis = "x" | "y" | "z" +QetRailStartOffsetMm +QetRailPitchMm +QetRailOrderIndex +QetRailDirection = 1 | -1 +``` + +批量插入时: + +1. 用户选择导轨。 +2. 选择设备模板和数量,或从 QET 设备列表生成。 +3. FreeCAD 根据模板宽度、间距、起始偏移和方向计算每个实例的位置。 +4. 每个设备记录宿主导轨和排序号。 +5. 后续导轨移动时,可通过宿主关系刷新设备位置。 + +QET 侧如果能提供端子排/断路器的顺序、数量和显示编号,则 FreeCAD 可以按 2D 顺序生成;如果 QET 暂不提供,第一版由 FreeCAD 用户在 3D 侧手动指定顺序。 + ## 2. 总体方案 布线连接不直接在任意 3D 空间里找线,也不会把所有端子任意两两连接。它参考 EPLAN 的思路,先建立 routing path network,再按 QET 导线任务中的起点端子和终点端子逐条求路。 @@ -246,7 +415,7 @@ edge_cost = segment_length * carrier_kind_factor + bend_penalty WireDuct = 1.0 RoutingPath = 1.0 AuxiliaryPath = 2.0 -RoutingRange = 8.0 +RoutingRange = 25.0 ``` 这使算法优先走线槽和明确路由路径,尽量少走辅助面域。 @@ -492,11 +661,11 @@ tests/manual/freecad_auto_routing_smoke.py ```text 准备布线布局空间:识别并标记 layout space 里的线槽、支撑面、工程端子和障碍处理方式 -生成布线路径网络:按 EPLAN routing path network 逻辑生成 WireDuct、RoutingRange 和 TerminalAccess carrier +生成布线路径网络:按 EPLAN routing path network 逻辑生成 WireDuct、UserPath、RoutingRange 和 TerminalAccess carrier 生成布线连接:先更新布线路径网络,再检查/绑定工程端子,按 QET 导线任务批量求路并生成 AutoSuggested 导线 ``` -如果模型名称/标签足够规范,可以不手动选择,直接执行三步;也可以只点击“生成布线连接”,系统会准备当前可识别的布线路径网络。若线槽无法自动识别,则先选中线槽实体执行“生成布线路径网络”作为补充。 +如果模型名称/标签足够规范,可以不手动选择,直接执行三步;也可以只点击“生成布线连接”,系统会准备当前可识别的布线路径网络。若线槽无法自动识别,则先选中线槽实体执行“生成布线路径网络”作为补充。若甲方现场没有线槽,或需要绕开线槽自由定义柜内主路径,可以选中草图、Draft 线、线段或纯线状对象,再执行“生成布线路径网络”,系统会生成 `UserPath`。 ### 6.2 批量生成布线连接前提 @@ -537,6 +706,75 @@ end_terminal_display 安装板和导轨的机械配合关系会影响对象最终位置,但当前路由器不从装配约束求解位置;它只读取 FreeCAD 文档中已经确定的几何位姿。后续如果增加装配语义,应保存在 FreeCAD 文档中,不扩展第一版数据库绑定表。 +### 6.4 自由路径与设备局部路径 + +为满足甲方“柜内布线不强制依赖线槽,可自由定义任意空间路径”的要求,当前 FreeCAD 侧已支持 `UserPath`: + +1. 选中草图、Draft 线、线段或纯线状对象。 +2. 点击 3D 布线连接面板中的“选中路径作为用户路径”,或直接点击“生成布线路径网络”。 +3. 系统把选中路径转换为 `UserPath` carrier,并参与后续自动布线最短路搜索。“选中路径作为用户路径”只创建用户路径;“生成布线路径网络”会同时更新线槽、布线面、端子接入等完整网络。 +4. 再次选择同一个路径对象生成网络时,系统会刷新原 carrier,不会重复生成。 +5. 如果删除了原草图/线段源对象,再点击“选中路径作为用户路径”或重新生成网络,系统会清理对应的失效 `UserPath` carrier。 + +`UserPath` 与线槽的关系: + +```text +WireDuct:真实线槽,仍是推荐主路径 +UserPath:用户自定义柜内主路径/过渡路径,可在无线槽或特殊路径场景中使用 +RoutingRange:安装板/背板上的低优先级布线面,只作为兜底过渡 +TerminalAccess:端子到主路径的接入段 +``` + +为满足“设备内部/局部走线路径需要连接柜内大路径/主路径”的要求,当前 FreeCAD 侧先提供端子级局部路径字段: + +```text +QetTerminalLocalRoutePointsJson +``` + +字段内容是端子本地坐标系下的点数组,例如: + +```json +[[0, 0, 0], [10, 0, 0], [10, 30, 0]] +``` + +自动生成 `TerminalAccess` 时,系统会先把这些局部点按端子和父设备的 `Placement` 转成全局点,再从局部路径末端连接到最近的柜内主路径、线槽、用户路径或布线面。没有该字段时,仍使用原来的端子 LCS `+Z` 方向短出线。 + +当前模板链路已经支持把局部路径从设备模板带到工程端子。模板 sidecar 或 `QetTemplateSlotsJson` 可以在对应端子槽位上提供: + +```json +{ + "terminal_slots": [ + { + "name": "A1", + "label": "A1", + "base": [10, 20, 30], + "local_route_points": [[0, 0, 0], [10, 0, 0], [10, 30, 0]] + } + ] +} +``` + +导入/更新工程端子时,FreeCAD 会把 `local_route_points` 写入该端子的 `QetTerminalLocalRoutePointsJson`。后续自动生成 `TerminalAccess` 和最终导线几何时都会使用这段局部路径。 + +如果直接在 FCStd 模板端子 LCS 上维护,也可以给模板端子写入同名属性 `QetTerminalLocalRoutePointsJson`。当前模板作者工具提供了内部函数: + +```python +TemplateAuthoring.set_template_terminal_local_route_points(terminal, points) +``` + +其中 `points` 仍是端子本地坐标系下的点数组。导入 FCStd 模板时,FreeCAD 会把 live 模板端子的局部路径保存进设备组的 `QetTemplateSlotsJson`,随后删除 live 模板 LCS,只保留工程端子需要的槽位元数据。 + +模板作者也可以用选择方式维护局部路径: + +```text +1. 在 FCStd 模板文档中先选中一个模板端子 LCS。 +2. 再选中表示局部出线路径的草图、Draft 线或边。 +3. 执行“设置端子局部路径”命令。 +4. 系统把所选路径的文档坐标转换为该端子的本地坐标,并写入 QetTerminalLocalRoutePointsJson。 +``` + +第一版不要求 QET 提供这个字段。它属于 FreeCAD 设备模板/工程端子的 3D 几何元数据,由 FreeCAD 模板作者维护;QET 仍只提供导线任务、设备实例、端子实例和 2D/3D 绑定所需 UUID。 + ## 7. 当前限制 当前版本可完成布线连接原型,但仍有以下限制: @@ -548,7 +786,7 @@ end_terminal_display 5. 未做强弱电分槽、线缆类型隔离。 6. 障碍检测基于 AABB,存在误报和漏报。 7. 辅助路由区域是网格近似,不等于专业软件的完整布线区域建模。 -8. 端子出线方向依赖端子 LCS 方向;如果模板端子方向不准,布线连接会受影响。 +8. 端子出线方向默认依赖端子 LCS 方向;若工程端子提供 `QetTerminalLocalRoutePointsJson`,会优先使用局部路径。模板端子方向或局部路径不准时,布线连接会受影响。 9. 导线几何当前保存在 FreeCAD 文档,不作为第一版数据库字段回写。 10. 当前不自动求解导轨、安装板和设备之间的 Assembly 配合关系;装配位置以 `scene.FCStd` 中对象的最终 `Placement` 为准。 @@ -586,7 +824,7 @@ WireDuct: 4. 端子出线规则 ```text -根据端子 LCS 方向、设备安装面、接线孔方向生成更合理的短出线段 +根据端子 LCS 方向、设备安装面、接线孔方向和模板局部路径生成更合理的短出线段 ``` ### 8.2 中期能力 diff --git a/src/Mod/FreeCADExchange/AutoRouting.py b/src/Mod/FreeCADExchange/AutoRouting.py index f908e84..e697d2c 100644 --- a/src/Mod/FreeCADExchange/AutoRouting.py +++ b/src/Mod/FreeCADExchange/AutoRouting.py @@ -40,7 +40,7 @@ DEFAULT_OPTIONS = { "UserPath": 1.0, "AuxiliaryPath": 2.0, "TerminalAccess": 2.0, - "RoutingRange": 8.0, + "RoutingRange": 25.0, }, # 主干必须走 carrier/贴面网络;没有布线路径网络时直接失败。 # 障碍包围盒会按这个距离膨胀,用于提前发现贴碰风险。 @@ -54,6 +54,7 @@ DEFAULT_OPTIONS = { # 替代路径,再退回原图并用 CollisionWarning 告诉用户当前网络不足。 "avoid_obstacles": True, "replace_existing": True, + "hide_route_carriers_after_route": True, } @@ -563,6 +564,7 @@ def _bind_wire_task_terminals(doc, payload): label=terminal_display or getattr(terminal_obj, "Label", "") or terminal_uuid, slot_name=slot_name, ) + TerminalObjects.hide_engineering_terminal(terminal_obj) used_objects.add(terminal_obj) slot_token = _normalized_match_token(slot_name) if slot_token: @@ -766,10 +768,12 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non return None exit_length = max(float(opts.get("terminal_exit_length", 0.0) or 0.0), 0.0) - start_origin = _terminal_origin(start_terminal) - end_origin = _terminal_origin(end_terminal) - start_exit = _offset(start_origin, _terminal_direction(start_terminal), exit_length) - end_exit = _offset(end_origin, _terminal_direction(end_terminal), exit_length) + start_access_points = RoutingNetwork.terminal_access_path_points(start_terminal, exit_length) + end_access_points = RoutingNetwork.terminal_access_path_points(end_terminal, exit_length) + start_origin = start_access_points[0] if start_access_points else _terminal_origin(start_terminal) + end_origin = end_access_points[0] if end_access_points else _terminal_origin(end_terminal) + start_exit = start_access_points[-1] if start_access_points else _offset(start_origin, _terminal_direction(start_terminal), exit_length) + end_exit = end_access_points[-1] if end_access_points else _offset(end_origin, _terminal_direction(end_terminal), exit_length) def route_on_network(network, obstacle_aware=False): if network.get("segment_count", 0) <= 0: @@ -807,13 +811,14 @@ def build_network_route(start_terminal, end_terminal, route_index=0, options=Non carrier_points = _apply_lane_offset(carrier_points, lane) points = [] - _append_unique(points, start_origin) - _append_unique(points, start_exit) + for point in start_access_points or [start_origin, start_exit]: + _append_unique(points, point) _append_orthogonal(points, carrier_points[0]) for point in carrier_points[1:]: _append_unique(points, point) _append_orthogonal(points, end_exit) - _append_unique(points, end_origin) + for point in reversed(end_access_points or [end_origin, end_exit]): + _append_unique(points, point) points = _simplify_collinear_points( points, preserved_point_keys=_important_route_node_keys(network, path_keys, path_result), @@ -1185,6 +1190,7 @@ def route_eplan_connection_between_terminals( wire_mark_is_manual=False, wire_style_id="", endpoint_metadata=None, + defer_recompute=False, ): if doc is None: raise AutoRoutingError("No FreeCAD document is available.") @@ -1237,6 +1243,7 @@ def route_eplan_connection_between_terminals( wire_name = _unique_name(doc, _wire_object_name(start_terminal, end_terminal, wire_uuid)) wire = None + removed_existing = 0 try: wire = _create_wire_geometry(doc, wire_name, points) wire.Label = wire_label or wire_mark or wire_uuid or "QET Routed Connection" @@ -1288,10 +1295,11 @@ def route_eplan_connection_between_terminals( _remove_routing_connection_objects(doc, [wire]) raise AutoRoutingError("Failed to replace existing routed connection.") - try: - doc.recompute() - except Exception: - pass + if not defer_recompute: + try: + doc.recompute() + except Exception: + pass return { "wire": wire, @@ -1304,6 +1312,7 @@ def route_eplan_connection_between_terminals( "length_mm": _route_length(points), "collision_count": len(collisions), "collisions": collisions, + "replaced_routed_connections": removed_existing, } @@ -1436,6 +1445,7 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la if not isinstance(payload, dict): raise AutoRoutingError("Exchange payload must be an object.") + opts = _merged_options(options) terminal_binding_report = bind_wire_task_terminals_from_payload(doc, payload) terminals = index_terminals(doc) local_terminal_count = sum( @@ -1453,11 +1463,14 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la "auto_terminal_binding_warnings": terminal_binding_report["warnings"], "routed": 0, "collision_warnings": 0, + "replaced_routed_connections": 0, "total_length_mm": 0.0, "skipped_missing_terminal": 0, + "skipped_missing_route_network": 0, "skipped_invalid": 0, "missing_endpoint_uuids": [], "missing_endpoint_samples": [], + "missing_route_network_samples": [], "collision_samples": [], "errors": [], "error_samples": [], @@ -1466,6 +1479,18 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la } if isinstance(prepared_layout, dict): report["prepared_layout"] = prepared_layout + try: + route_network = RoutingNetwork.build_route_graph( + doc, + adjoining_duct_tolerance=float(opts.get("adjoining_duct_tolerance", 0.0) or 0.0), + ) + except Exception as exc: + route_network = {} + report["route_network_error"] = str(exc) + report["route_network_carriers"] = int(route_network.get("carrier_count", 0) or 0) + report["route_network_segments"] = int(route_network.get("segment_count", 0) or 0) + report["route_network_nodes"] = len(route_network.get("nodes", {}) or {}) + has_route_network = report["route_network_segments"] > 0 missing_endpoint_uuids = set() lane_indexes_by_pair = {} lane_indexes_by_segment = {} @@ -1478,6 +1503,12 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la key = str(status or "").strip() or "Unknown" report["route_status_counts"][key] = report["route_status_counts"].get(key, 0) + 1 + def set_item_task_status(item, status): + wire_uuid = _wire_item_value(item, "wire_id", "wire_uuid", "id") + if not wire_uuid: + return + _set_task_status(_find_task_by_wire_uuid(doc, wire_uuid), status) + def create_route(route_lane_index, item, start_terminal, end_terminal, endpoint_metadata): route_options = dict(options or {}) if isinstance(item, dict) and "__segment_usage_costs" in item: @@ -1496,6 +1527,7 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la wire_mark_is_manual=bool(item.get("wire_mark_is_manual", False)), wire_style_id=_wire_item_value(item, "wire_style_id"), endpoint_metadata=endpoint_metadata, + defer_recompute=True, ) for item in wires: @@ -1510,6 +1542,7 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la if start_terminal is None or end_terminal is None: report["skipped_missing_terminal"] += 1 add_status("MissingTerminal") + set_item_task_status(item, "MissingTerminal") for terminal_uuid in (start_uuid, end_uuid): if terminal_uuid and terminal_uuid not in terminals: missing_endpoint_uuids.add(terminal_uuid) @@ -1530,6 +1563,27 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la } ) continue + if not has_route_network: + report["skipped_missing_route_network"] += 1 + add_status("MissingRouteNetwork") + set_item_task_status(item, "MissingRouteNetwork") + if len(report["missing_route_network_samples"]) < 8: + report["missing_route_network_samples"].append( + { + "wire_uuid": _wire_item_value(item, "wire_id", "wire_uuid", "id"), + "wire_label": _wire_item_value(item, "wire_label", "wire_mark"), + "start_terminal_uuid": start_uuid, + "start_element_uuid": _wire_item_value(item, "start_element_uuid"), + "start_terminal_display": _wire_item_value(item, "start_terminal_display"), + "start_device_label": _wire_item_value(item, "start_device_label"), + "end_terminal_uuid": end_uuid, + "end_element_uuid": _wire_item_value(item, "end_element_uuid"), + "end_terminal_display": _wire_item_value(item, "end_terminal_display"), + "end_device_label": _wire_item_value(item, "end_device_label"), + "endpoint_label": _wire_item_value(item, "endpoint_label"), + } + ) + continue lane_key = _route_lane_key(start_uuid, end_uuid) route_lane_index = lane_indexes_by_pair.get(lane_key, 0) try: @@ -1573,6 +1627,7 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la error_text = str(exc) report["errors"].append(error_text) add_status("Error") + set_item_task_status(item, "Error") if len(report["error_samples"]) < 8: report["error_samples"].append( { @@ -1603,6 +1658,9 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la segment_usage_costs[segment_key] = segment_usage_costs.get(segment_key, 0) + 1 if result["route_status"] == "CollisionWarning": report["collision_warnings"] += 1 + report["replaced_routed_connections"] += int( + result.get("replaced_routed_connections", 0) or 0 + ) add_status(result["route_status"]) route_collision_samples = [] for collision in list(result.get("collisions", []) or [])[:3]: @@ -1645,6 +1703,11 @@ def route_eplan_connections_from_payload(doc, payload, options=None, prepared_la "collision_samples": route_collision_samples, } ) + if report["routed"] > 0: + try: + doc.recompute() + except Exception: + pass report["missing_endpoint_uuids"] = sorted(missing_endpoint_uuids) _write_routing_connection_batch_diagnostic(doc, report) return report @@ -1819,6 +1882,61 @@ def _route_capacity_pressure_summary(report): return pressure +_ROUTE_QUALITY_WARNING_KIND_LABELS = { + "RoutingRange": "布线面", + "AuxiliaryPath": "辅助路径", +} + + +def _route_track_carrier_kinds(route_track): + if not isinstance(route_track, dict): + return {} + carrier_kinds = route_track.get("carrier_kinds", {}) + if isinstance(carrier_kinds, dict) and carrier_kinds: + return { + str(key): value + for key, value in carrier_kinds.items() + if str(key).strip() + } + counts = {} + for segment in route_track.get("segments", []) or []: + carrier = segment.get("carrier", {}) if isinstance(segment, dict) else {} + if not isinstance(carrier, dict): + continue + kind = str(carrier.get("kind", "") or "").strip() + if kind: + counts[kind] = counts.get(kind, 0) + 1 + return counts + + +def _route_quality_warning_summary(report): + warning_count = 0 + sample = None + for route in report.get("routes", []) or []: + if not isinstance(route, dict): + continue + carrier_kinds = _route_track_carrier_kinds(route.get("route_track", {})) + warning_labels = [ + label + for kind, label in _ROUTE_QUALITY_WARNING_KIND_LABELS.items() + if carrier_kinds.get(kind, 0) + ] + if not warning_labels: + continue + warning_count += 1 + if sample is None: + sample = { + "wire": _wire_sample_text(route), + "labels": warning_labels, + } + if warning_count <= 0: + return {} + return { + "count": warning_count, + "sample": sample or {}, + } + + def format_eplan_connection_route_report(report): message = "批量生成布线连接完成:routed={0}, collision_warnings={1}, missing_terminals={2}".format( report.get("routed", 0), @@ -1832,6 +1950,7 @@ def format_eplan_connection_route_report(report): "CollisionWarning": "碰撞告警", "Error": "错误", "MissingTerminal": "缺失端子", + "MissingRouteNetwork": "缺少布线路径网络", "Invalid": "无效任务", } def status_count_value(value): @@ -1840,7 +1959,14 @@ def format_eplan_connection_route_report(report): except Exception: return 0 status_parts = [] - for key in ("Routed", "CollisionWarning", "Error", "MissingTerminal", "Invalid"): + for key in ( + "Routed", + "CollisionWarning", + "Error", + "MissingTerminal", + "MissingRouteNetwork", + "Invalid", + ): value = status_count_value(status_counts.get(key, 0)) if value > 0: status_parts.append("{0} {1} 条".format(status_labels[key], value)) @@ -1858,9 +1984,19 @@ def format_eplan_connection_route_report(report): prepared_layout.get("surface_carriers", 0), prepared_layout.get("terminal_access_carriers", 0), ) + if report.get("skipped_missing_route_network", 0) > 0: + message += "\n缺少布线路径网络:{0} 条导线已跳过。请先生成线槽、布线面或布线路径网络。".format( + report.get("skipped_missing_route_network", 0) + ) total_length_mm = float(report.get("total_length_mm", 0.0) or 0.0) if total_length_mm > 0.0: message += "\n布线连接总长度:{0:.1f} mm。".format(total_length_mm) + replaced_routed_connections = int(report.get("replaced_routed_connections", 0) or 0) + if replaced_routed_connections > 0: + message += "\n已替换旧布线连接:{0} 条。".format(replaced_routed_connections) + hidden_route_carriers = int(report.get("hidden_route_carriers", 0) or 0) + if hidden_route_carriers > 0: + message += "\n已隐藏走线路径辅助对象:{0} 条。".format(hidden_route_carriers) bridged_segments = _route_network_metric_max(report, "bridged_segments") blocked_segments = _route_network_metric_max(report, "blocked_segments") network_parts = [] @@ -1885,6 +2021,17 @@ def format_eplan_connection_route_report(report): route_source_sample = _route_source_sample_text(report) if route_source_sample: message += "\n{0}".format(route_source_sample) + quality_warning = _route_quality_warning_summary(report) + if quality_warning: + message += "\n路径质量提示:{0} 条导线使用布线面/辅助路径,可能没有完全优先进入线槽。".format( + quality_warning.get("count", 0) + ) + sample = quality_warning.get("sample", {}) + if isinstance(sample, dict) and sample.get("wire") and sample.get("labels"): + message += " 示例 {0} 使用{1}。".format( + sample.get("wire"), + "、".join(sample.get("labels", []) or []), + ) errors = report.get("errors", []) or [] if errors: message += "\n首个错误:{0}".format(str(errors[0])) @@ -1970,6 +2117,95 @@ def _clear_routing_connection_batch_diagnostics(doc): return removed +def _compact_route_sample(route): + route_track = route.get("route_track", {}) if isinstance(route, dict) else {} + if not isinstance(route_track, dict): + route_track = {} + sample = { + "wire_uuid": route.get("wire_uuid", ""), + "wire_label": route.get("wire_label", ""), + "start_terminal_uuid": route.get("start_terminal_uuid", ""), + "start_element_uuid": route.get("start_element_uuid", ""), + "start_terminal_display": route.get("start_terminal_display", ""), + "end_terminal_uuid": route.get("end_terminal_uuid", ""), + "end_element_uuid": route.get("end_element_uuid", ""), + "end_terminal_display": route.get("end_terminal_display", ""), + "endpoint_label": route.get("endpoint_label", ""), + "route_status": route.get("route_status", ""), + "length_mm": route.get("length_mm", 0.0), + "lane": route.get("lane", {}), + "algorithm": route.get("algorithm", ""), + "collision_count": route.get("collision_count", 0), + "carrier_kinds": route_track.get("carrier_kinds", {}), + "carrier_names": list(route_track.get("carrier_names", []) or [])[:8], + "route_source_labels": _route_source_labels(route_track, limit=8), + } + network = route.get("network", {}) + if isinstance(network, dict): + sample["network"] = { + "carriers": network.get("carriers", 0), + "segments": network.get("segments", 0), + "bridged_segments": network.get("bridged_segments", 0), + "blocked_segments": network.get("blocked_segments", 0), + "entry_distance": network.get("entry_distance", 0.0), + "exit_distance": network.get("exit_distance", 0.0), + } + return sample + + +def _compact_routing_connection_batch_report(report, sample_limit=8): + if not isinstance(report, dict): + return {} + limit = max(int(sample_limit or 0), 0) + payload = {} + scalar_keys = ( + "total_wires", + "available_terminals", + "local_terminals", + "auto_bound_terminals", + "auto_created_terminals", + "routed", + "collision_warnings", + "replaced_routed_connections", + "total_length_mm", + "skipped_missing_terminal", + "skipped_missing_route_network", + "skipped_invalid", + "route_network_carriers", + "route_network_segments", + "route_network_nodes", + "route_network_error", + "hidden_route_carriers", + ) + for key in scalar_keys: + if key in report: + payload[key] = report.get(key) + if isinstance(report.get("prepared_layout"), dict): + payload["prepared_layout"] = report.get("prepared_layout") + if isinstance(report.get("route_status_counts"), dict): + payload["route_status_counts"] = dict(report.get("route_status_counts") or {}) + missing_endpoint_uuids = list(report.get("missing_endpoint_uuids", []) or []) + payload["missing_endpoint_uuid_count"] = len(missing_endpoint_uuids) + payload["missing_endpoint_uuids"] = missing_endpoint_uuids[:50] + for key in ( + "auto_terminal_binding_warnings", + "missing_endpoint_samples", + "missing_route_network_samples", + "collision_samples", + "error_samples", + "errors", + ): + values = list(report.get(key, []) or []) + payload[key] = values[:limit] + payload["{0}_count".format(key)] = len(values) + routes = [route for route in list(report.get("routes", []) or []) if isinstance(route, dict)] + payload["route_count"] = len(routes) + payload["route_samples"] = [_compact_route_sample(route) for route in routes[:limit]] + payload["route_sample_count"] = len(payload["route_samples"]) + payload["diagnostic_payload"] = "compact-routing-connection-batch-v1" + return payload + + def _write_routing_connection_batch_diagnostic(doc, report): if doc is None or not isinstance(report, dict): return None @@ -1990,7 +2226,7 @@ def _write_routing_connection_batch_diagnostic(doc, report): _set_string( diagnostic, "QetDiagnosticJson", - json.dumps(report, ensure_ascii=False), + json.dumps(_compact_routing_connection_batch_report(report), ensure_ascii=False), "QET routing connection batch diagnostic payload", ) group.addObject(diagnostic) @@ -2229,13 +2465,14 @@ def route_eplan_connections( """Route QET wire tasks through the EPLAN-style routing path network.""" if doc is None: raise AutoRoutingError("No FreeCAD document is available.") + opts = _merged_options(options) prepared_network = None if update_network: prepared_network = update_eplan_routing_path_network( doc, project_uuid=project_uuid, - options=options, + options=opts, selection_ex=selection_ex, ) @@ -2247,13 +2484,13 @@ def route_eplan_connections( report = route_eplan_connections_from_payload( doc, target_payload, - options=options, + options=opts, prepared_layout=prepared_network, ) else: report = route_eplan_connection_tasks( doc, - options=options, + options=opts, prepared_layout=prepared_network, ) @@ -2261,6 +2498,10 @@ def route_eplan_connections( report["routing_path_network_updated"] = bool(update_network) if isinstance(prepared_network, dict): report["routing_path_network"] = prepared_network + if opts.get("hide_route_carriers_after_route", True): + report["hidden_route_carriers"] = RoutingNetwork.set_route_carriers_visibility(doc, False) + else: + report["hidden_route_carriers"] = 0 return report def wire_task_count(doc): @@ -2269,15 +2510,22 @@ def wire_task_count(doc): def clear_routing_connections(doc): removed = 0 + removed_wire_uuids = set() for obj in list(WiringObjects.iter_routed_wire_objects(doc)): if (getattr(obj, "RouteType", "") or "").strip() != "RoutedConnection": continue + wire_uuid = (getattr(obj, "QetWireUuid", "") or "").strip() try: _detach_object_from_groups(doc, obj) doc.removeObject(obj.Name) removed += 1 + if wire_uuid: + removed_wire_uuids.add(wire_uuid) except Exception: pass + for wire_uuid in sorted(removed_wire_uuids): + _set_task_status(_find_task_by_wire_uuid(doc, wire_uuid), "Task") + _clear_routing_connection_batch_diagnostics(doc) try: doc.recompute() except Exception: diff --git a/src/Mod/FreeCADExchange/AutoRoutingPanel.py b/src/Mod/FreeCADExchange/AutoRoutingPanel.py index 97e444f..f3c766c 100644 --- a/src/Mod/FreeCADExchange/AutoRoutingPanel.py +++ b/src/Mod/FreeCADExchange/AutoRoutingPanel.py @@ -70,6 +70,32 @@ def _selection_ex(): return [] +def _style_command_button(button, text, tooltip=""): + """Keep task-panel command button text visible across FreeCAD themes.""" + if button is None: + return button + try: + button.setText(text) + except Exception: + pass + try: + button.setToolTip(tooltip) + except Exception: + pass + try: + button.setMinimumHeight(28) + except Exception: + pass + try: + button.setStyleSheet( + "QPushButton { color: #202020; text-align: left; padding-left: 8px; }" + "QPushButton:disabled { color: #606060; }" + ) + except Exception: + pass + return button + + class AutoRoutingController: def __init__(self, options=None): self.last_report = None @@ -187,6 +213,31 @@ class AutoRoutingController: self.last_report["source_mode"] = "document" return self.last_report + def create_user_paths_from_selection(self): + doc = _active_document() + project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() + removed_stale_carriers = RoutingNetwork.cleanup_invalid_source_carriers(doc) + created = RoutingNetwork.create_user_path_carriers_from_selection( + doc, + _selection_ex(), + project_uuid=project_uuid, + ) + self.last_report = { + "user_path_carriers": len(created), + "removed_stale_carriers": removed_stale_carriers, + "network": RoutingNetwork.network_summary( + doc, + adjoining_duct_tolerance=float( + self.routing_options().get( + "adjoining_duct_tolerance", + RoutingNetwork.DEFAULT_ADJOINING_DUCT_TOLERANCE, + ) + or 0.0 + ), + ), + } + return self.last_report + def route_eplan_connections(self): doc = _active_document() project_uuid = getattr(TerminalObjects.ensure_root_group(doc), "QetProjectUuid", "").strip() @@ -308,35 +359,46 @@ class AutoRoutingTaskPanel: self.lane_axis_combo.setCurrentIndex(axis_index if axis_index >= 0 else 0) options_layout.addWidget(self.lane_axis_combo) - self.generate_layout_button = QtWidgets.QPushButton("准备布线布局空间") - self.generate_layout_button.setToolTip( - "按 EPLAN 布局空间语义识别线槽、安装面、工程端子和障碍处理方式,不生成导线。" + self.generate_layout_button = _style_command_button( + QtWidgets.QPushButton(), + "准备布线布局空间", + "按 EPLAN 布局空间语义识别线槽、安装面、工程端子和障碍处理方式,不生成导线。", + ) + + self.generate_paths_button = _style_command_button( + QtWidgets.QPushButton(), + "生成布线路径网络", + "按 EPLAN 逻辑从布局空间生成完整 routing path network:线槽、布线区域和端子接入。", ) - self.generate_paths_button = QtWidgets.QPushButton("生成布线路径网络") - self.generate_paths_button.setToolTip( - "按 EPLAN 逻辑从布局空间生成完整 routing path network:线槽、布线区域和端子接入。" + self.create_user_paths_button = _style_command_button( + QtWidgets.QPushButton(), + "选中路径作为用户路径", + "把选中的草图、线段或纯线状对象转换为可参与自动布线的 UserPath。", ) - self.check_paths_button = QtWidgets.QPushButton("检查布线路径网络") - self.check_paths_button.setToolTip( - "检查 routing path network 的断点、孤立网络和未接入端子,并写入诊断对象。" + self.check_paths_button = _style_command_button( + QtWidgets.QPushButton(), + "检查布线路径网络", + "检查 routing path network 的断点、孤立网络和未接入端子,并写入诊断对象。", ) - self.route_connections_button = QtWidgets.QPushButton("生成布线连接") - self.route_connections_button.setToolTip( - "自动更新布线路径网络,并按全部 QET 导线任务生成 3D 布线连接。" + self.route_connections_button = _style_command_button( + QtWidgets.QPushButton(), + "生成布线连接", + "自动更新布线路径网络,并按全部 QET 导线任务生成 3D 布线连接。", ) - self.clear_routes_button = QtWidgets.QPushButton("清除布线连接") - self.clear_carriers_button = QtWidgets.QPushButton("清除走线路径") - self.save_button = QtWidgets.QPushButton("保存") + self.clear_routes_button = _style_command_button(QtWidgets.QPushButton(), "清除布线连接") + self.clear_carriers_button = _style_command_button(QtWidgets.QPushButton(), "清除走线路径") + self.save_button = _style_command_button(QtWidgets.QPushButton(), "保存") self.status_label = QtWidgets.QLabel("") self.status_label.setWordWrap(True) for widget in ( self.generate_layout_button, + self.create_user_paths_button, self.generate_paths_button, self.check_paths_button, self.route_connections_button, @@ -350,6 +412,7 @@ class AutoRoutingTaskPanel: layout.addWidget(self.status_label) self.generate_paths_button.clicked.connect(self.generate_routing_paths) + self.create_user_paths_button.clicked.connect(self.create_user_paths_from_selection) self.check_paths_button.clicked.connect(self.check_routing_path_network) self.generate_layout_button.clicked.connect(self.generate_layout_space) self.route_connections_button.clicked.connect(self.route_eplan_connections) @@ -385,18 +448,20 @@ class AutoRoutingTaskPanel: self._sync_options_from_widgets() result = self.controller.generate_routing_paths() wire_ducts = result.get("wire_duct_carriers", 0) + user_paths = result.get("user_path_carriers", 0) surfaces = result.get("surface_carriers", 0) terminal_access = result.get("terminal_access_carriers", 0) network = result.get("network", {}) if isinstance(result.get("network", {}), dict) else {} if network.get("segments", 0) == 0: self._set_status( - "未生成可用布线路径网络。可选中线槽实体,或确认安装板/背板能作为布线区域。" + "未生成可用布线路径网络。可选中线槽实体、草图/线段作为用户路径,或确认安装板/背板能作为布线区域。" + self.controller.summary() ) return self._set_status( - "已生成布线路径网络:线槽路径 {0} 条,布线区域 {1} 条,端子接入 {2} 条,网络段 {3} 条。{4}".format( + "已生成布线路径网络:线槽路径 {0} 条,用户路径 {1} 条,布线区域 {2} 条,端子接入 {3} 条,网络段 {4} 条。{5}".format( wire_ducts, + user_paths, surfaces, terminal_access, network.get("segments", 0), @@ -406,6 +471,35 @@ class AutoRoutingTaskPanel: except Exception as exc: self._set_error(str(exc)) + def create_user_paths_from_selection(self): + try: + self._sync_options_from_widgets() + result = self.controller.create_user_paths_from_selection() + created = result.get("user_path_carriers", 0) + removed = result.get("removed_stale_carriers", 0) + if created <= 0: + stale_text = "" + if removed > 0: + stale_text = "已清理失效用户路径:{0} 条。".format(removed) + self._set_status( + stale_text + + "未创建用户路径。请先选择草图、Draft 线、线段或纯线状对象。" + + self.controller.summary() + ) + return + stale_text = "" + if removed > 0: + stale_text = "已清理失效用户路径:{0} 条。".format(removed) + self._set_status( + "{0}已创建用户路径:{1} 条。{2}".format( + stale_text, + created, + self.controller.summary(), + ) + ) + except Exception as exc: + self._set_error(str(exc)) + def check_routing_path_network(self): try: self._sync_options_from_widgets() diff --git a/src/Mod/FreeCADExchange/RoutingNetwork.py b/src/Mod/FreeCADExchange/RoutingNetwork.py index db73c54..fefcf2e 100644 --- a/src/Mod/FreeCADExchange/RoutingNetwork.py +++ b/src/Mod/FreeCADExchange/RoutingNetwork.py @@ -19,6 +19,7 @@ ROUTE_CARRIER_KIND = "RoutingPath" ROUTE_CARRIER_KIND_WIRE_DUCT = "WireDuct" ROUTE_CARRIER_KIND_WIRE_DUCT_OPEN_END = "WireDuctOpenEnd" ROUTE_CARRIER_KIND_WIRING_CUT_OUT = "WiringCutOut" +ROUTE_CARRIER_KIND_USER_PATH = "UserPath" ROUTE_CARRIER_KIND_AUXILIARY_PATH = "AuxiliaryPath" ROUTE_CARRIER_KIND_ROUTING_RANGE = "RoutingRange" ROUTE_CARRIER_KIND_TERMINAL_ACCESS = "TerminalAccess" @@ -26,6 +27,7 @@ MANAGED_ROUTE_SOURCE_KINDS = { ROUTE_CARRIER_KIND_WIRE_DUCT, ROUTE_CARRIER_KIND_WIRING_CUT_OUT, ROUTE_CARRIER_KIND_ROUTING_RANGE, + ROUTE_CARRIER_KIND_USER_PATH, } PROPERTY_GROUP = "QET Routing" DEFAULT_NODE_TOLERANCE = 0.001 @@ -114,8 +116,8 @@ DEFAULT_KIND_COST_FACTORS = { ROUTE_CARRIER_KIND: 1.0, ROUTE_CARRIER_KIND_AUXILIARY_PATH: 2.0, ROUTE_CARRIER_KIND_TERMINAL_ACCESS: 2.0, - ROUTE_CARRIER_KIND_ROUTING_RANGE: 8.0, - "UserPath": 1.0, + ROUTE_CARRIER_KIND_ROUTING_RANGE: 25.0, + ROUTE_CARRIER_KIND_USER_PATH: 1.0, } ROUTE_CARRIER_VIEW_STYLES = { ROUTE_CARRIER_KIND_WIRE_DUCT: { @@ -142,6 +144,10 @@ ROUTE_CARRIER_VIEW_STYLES = { "color": (0.45, 0.45, 0.45), "width": 2.0, }, + ROUTE_CARRIER_KIND_USER_PATH: { + "color": (0.95, 0.15, 0.15), + "width": 3.0, + }, ROUTE_CARRIER_KIND: { "color": (0.0, 0.45, 0.85), "width": 2.0, @@ -587,6 +593,18 @@ def _set_wiring_cut_out_source_semantics(source, bridge_extension=DEFAULT_WIRING ) +def _set_user_path_source_semantics(source): + if source is None: + return + TerminalObjects.ensure_string_property( + source, + "QetRoutingSourceKind", + PROPERTY_GROUP, + "Routing source kind", + ROUTE_CARRIER_KIND_USER_PATH, + ) + + def _style_route_carrier(carrier, kind): style = ROUTE_CARRIER_VIEW_STYLES.get(kind) or ROUTE_CARRIER_VIEW_STYLES[ROUTE_CARRIER_KIND] try: @@ -707,8 +725,11 @@ def create_route_carrier(doc, points, label="", project_uuid="", kind=ROUTE_CARR def is_route_carrier(obj): if obj is None: return False - role = (getattr(obj, "QetRoutingRole", "") or "").strip() - return role == ROUTING_ROLE and bool(getattr(obj, "CanRouteWire", False)) + try: + role = (getattr(obj, "QetRoutingRole", "") or "").strip() + return role == ROUTING_ROLE and bool(getattr(obj, "CanRouteWire", False)) + except Exception: + return False def _carrier_points(obj): @@ -892,6 +913,22 @@ def collect_route_carriers(doc): return result +def set_route_carriers_visibility(doc, visible): + """Show or hide generated route carrier helper objects.""" + updated = 0 + for carrier in collect_route_carriers(doc): + try: + carrier.ViewObject.Visibility = bool(visible) + updated += 1 + except Exception: + pass + try: + doc.recompute() + except Exception: + pass + return updated + + def _detach_from_groups(doc, obj): for parent in list(getattr(obj, "InList", []) or []): group = list(getattr(parent, "Group", []) or []) @@ -1418,6 +1455,61 @@ def create_carriers_from_selection(doc, selection_ex, project_uuid="", kind=ROUT return created +def create_user_path_carriers_from_selection(doc, selection_ex, project_uuid=""): + """Create or refresh user-defined spatial route paths from selected sketches/edges.""" + cleanup_invalid_source_carriers(doc) + created = [] + support_face = _support_face_from_selection(selection_ex) + seen_sources = set() + for index, item in enumerate(selection_ex or [], start=1): + if support_face is not None and _selection_item_is_only_support_face(item): + continue + source = getattr(item, "Object", None) + if source is not None: + if id(source) in seen_sources: + continue + seen_sources.add(id(source)) + if ( + _is_wire_duct_candidate(source) + or _is_support_surface_candidate(source) + or _is_wiring_cut_out_candidate(source) + ): + continue + points = _points_from_selection_item(item) + if len(points) < 2: + continue + if support_face is not None: + points = _project_points_to_face(points, support_face) + + label = "QET User Route Path {0}".format(index) + if source is not None: + label = "QET User Route Path {0}".format( + getattr(source, "Label", "") or getattr(source, "Name", "") or index + ) + live_carrier = _live_source_carrier(doc, source) + if live_carrier is not None: + if _update_route_carrier( + live_carrier, + points, + project_uuid=project_uuid, + kind=ROUTE_CARRIER_KIND_USER_PATH, + ): + _mark_user_path_source(source, live_carrier) + continue + + carrier = create_route_carrier( + doc, + points, + label=label, + project_uuid=project_uuid, + kind=ROUTE_CARRIER_KIND_USER_PATH, + ) + if source is not None: + _mark_user_path_source(source, carrier) + created.append(carrier) + return created + + def _wire_duct_centerline_spec_from_bbox(bbox, margin=DEFAULT_WIRE_DUCT_MARGIN, min_aspect=1.5): extents = { axis: _bbox_extent(bbox, axis) @@ -1702,6 +1794,23 @@ def _mark_wiring_cut_out_source(source, carrier, bridge_extension=DEFAULT_WIRING pass +def _mark_user_path_source(source, carrier): + if source is None or carrier is None: + return + try: + _set_user_path_source_semantics(source) + TerminalObjects.ensure_string_property( + source, + "QetRouteCarrierName", + PROPERTY_GROUP, + "Generated route carrier for this source", + getattr(carrier, "Name", ""), + ) + _remember_source_carriers(source, [carrier]) + except Exception: + pass + + def _mark_terminal_access_source(source, carrier): if source is None or carrier is None: return @@ -1737,6 +1846,8 @@ def _source_is_valid_for_kind(source, source_kind): return _is_support_surface_candidate(source) if source_kind == ROUTE_CARRIER_KIND_WIRING_CUT_OUT: return _is_wiring_cut_out_candidate(source) + if source_kind == ROUTE_CARRIER_KIND_USER_PATH: + return _is_route_path_source_object(source) return True @@ -2103,6 +2214,101 @@ def _terminal_exit_point(terminal, exit_length): return _add(origin, _scale(direction, max(float(exit_length or 0.0), 0.0))) +def _json_route_point(item): + try: + if isinstance(item, dict): + return App.Vector( + float(item.get("x", 0.0) or 0.0), + float(item.get("y", 0.0) or 0.0), + float(item.get("z", 0.0) or 0.0), + ) + if isinstance(item, (list, tuple)) and len(item) >= 3: + return App.Vector(float(item[0] or 0.0), float(item[1] or 0.0), float(item[2] or 0.0)) + except Exception: + return None + return None + + +def _terminal_local_route_points(terminal): + for property_name in ("QetTerminalLocalRoutePointsJson", "QetLocalRoutePointsJson"): + raw = (getattr(terminal, property_name, "") or "").strip() + if not raw: + continue + try: + parsed = json.loads(raw) + except Exception: + continue + points = [_json_route_point(item) for item in parsed if item is not None] + points = [point for point in points if point is not None] + if points: + return points + return [] + + +def _terminal_parent_chain(terminal): + chain = [] + current = terminal + visited = set() + while current is not None: + parents = list(getattr(current, "InList", []) or []) + parent = None + for candidate in parents: + if id(candidate) in visited: + continue + if getattr(candidate, "Placement", None) is not None: + parent = candidate + break + if parent is None: + break + visited.add(id(parent)) + chain.append(parent) + current = parent + return chain + + +def _placement_mult_vec(placement, point): + if placement is None: + return point + try: + transformed = placement.multVec(point) + if transformed is not None: + return _vector(transformed) + except Exception: + pass + base = getattr(placement, "Base", None) + if base is not None: + return App.Vector(point.x + base.x, point.y + base.y, point.z + base.z) + return point + + +def _terminal_local_point_to_global(terminal, local_point): + try: + if hasattr(terminal, "getGlobalPlacement"): + placement = terminal.getGlobalPlacement() + return _placement_mult_vec(placement, _vector(local_point)) + except Exception: + pass + + point = _placement_mult_vec(getattr(terminal, "Placement", None), _vector(local_point)) + for parent in _terminal_parent_chain(terminal): + point = _placement_mult_vec(getattr(parent, "Placement", None), point) + return point + + +def terminal_access_path_points(terminal, exit_length=20.0): + """Return terminal-to-network access points, honoring optional local route metadata.""" + origin = _vector(TerminalObjects.terminal_origin(terminal)) + local_points = _terminal_local_route_points(terminal) + if local_points: + points = [_terminal_local_point_to_global(terminal, point) for point in local_points] + if not points or _distance(points[0], origin) > DEFAULT_NODE_TOLERANCE: + points.insert(0, origin) + normalized = _normalized_route_points(points) + if len(normalized) >= 2: + return normalized + return _normalized_route_points([origin, _terminal_exit_point(terminal, exit_length)]) + + def _orthogonal_access_points(start, end): """Create a Manhattan path so access carriers can join the routing graph.""" start = _vector(start) @@ -2162,7 +2368,11 @@ def create_terminal_access_carriers_from_document( for terminal in _collect_routable_terminals(doc): if _live_source_carrier(doc, terminal) is not None: continue - exit_point = _terminal_exit_point(terminal, terminal_exit_length) + has_local_route_points = bool(_terminal_local_route_points(terminal)) + terminal_access_points = terminal_access_path_points(terminal, terminal_exit_length) + if len(terminal_access_points) < 2: + continue + exit_point = terminal_access_points[-1] nearest_point, distance = nearest_point_on_network(network, exit_point) if nearest_point is None: continue @@ -2171,7 +2381,13 @@ def create_terminal_access_carriers_from_document( if float(distance or 0.0) <= DEFAULT_NODE_TOLERANCE: continue - points = _orthogonal_access_points(exit_point, nearest_point) + if has_local_route_points: + points = list(terminal_access_points) + for point in _orthogonal_access_points(exit_point, nearest_point)[1:]: + if _distance(points[-1], point) > DEFAULT_NODE_TOLERANCE: + points.append(point) + else: + points = _orthogonal_access_points(exit_point, nearest_point) if len(points) < 2: continue label = getattr(terminal, "Label", "") or getattr(terminal, "Name", "") or "Terminal" @@ -2206,12 +2422,18 @@ def create_routing_path_network_from_document( project_uuid=project_uuid, ) selected_wire_ducts = [] + selected_user_paths = [] if selection_ex: selected_wire_ducts = create_wire_duct_carriers_from_selection( doc, selection_ex, project_uuid=project_uuid, ) + selected_user_paths = create_user_path_carriers_from_selection( + doc, + selection_ex, + project_uuid=project_uuid, + ) wire_ducts = create_wire_duct_carriers_from_document( doc, project_uuid=project_uuid, @@ -2250,6 +2472,7 @@ def create_routing_path_network_from_document( return { "wire_duct_carriers": wire_duct_main_count, "selected_wire_duct_carriers": selected_wire_duct_main_count, + "user_path_carriers": len(selected_user_paths), "wire_duct_open_end_carriers": open_end_count, "wiring_cut_out_carriers": len(cut_outs), "surface_carriers": len(surfaces), @@ -2273,6 +2496,8 @@ def create_wire_duct_carriers_from_selection( cleanup_invalid_source_carriers(doc) created = [] for index, source in enumerate(_wire_duct_sources_from_selection(selection_ex), start=1): + if _source_kind_value(source) == ROUTE_CARRIER_KIND_USER_PATH or _is_route_path_source_object(source): + continue bbox = _bound_box_from_object(source) if bbox is None: continue diff --git a/src/Mod/FreeCADExchange/TemplateAuthoring.py b/src/Mod/FreeCADExchange/TemplateAuthoring.py index 1b8fa9e..39ad758 100644 --- a/src/Mod/FreeCADExchange/TemplateAuthoring.py +++ b/src/Mod/FreeCADExchange/TemplateAuthoring.py @@ -1,5 +1,7 @@ # FreeCADExchange FCStd template authoring helpers. +import json + import FreeCAD as App try: @@ -110,6 +112,151 @@ def is_template_terminal(obj): return TerminalObjects.is_terminal_hint_object(obj) +def _route_point_payload(point): + if isinstance(point, App.Vector): + return [float(point.x), float(point.y), float(point.z)] + if isinstance(point, dict): + try: + return [ + float(point.get("x", 0.0) or 0.0), + float(point.get("y", 0.0) or 0.0), + float(point.get("z", 0.0) or 0.0), + ] + except Exception: + return None + if isinstance(point, (list, tuple)) and len(point) >= 3: + try: + return [float(point[0] or 0.0), float(point[1] or 0.0), float(point[2] or 0.0)] + except Exception: + return None + return None + + +def _distance(left, right): + try: + dx = float(left.x) - float(right.x) + dy = float(left.y) - float(right.y) + dz = float(left.z) - float(right.z) + return (dx * dx + dy * dy + dz * dz) ** 0.5 + except Exception: + return 0.0 + + +def _edge_points(edge): + vertexes = list(getattr(edge, "Vertexes", []) or []) + if len(vertexes) >= 2: + start = getattr(vertexes[0], "Point", None) + end = getattr(vertexes[-1], "Point", None) + if start is not None and end is not None: + return [start, end] + try: + return [edge.valueAt(edge.FirstParameter), edge.valueAt(edge.LastParameter)] + except Exception: + return [] + + +def _selection_item_points(selection_item): + points = [] + points.extend(list(getattr(selection_item, "PickedPoints", []) or [])) + for sub_object in list(getattr(selection_item, "SubObjects", []) or []): + shape_type = (getattr(sub_object, "ShapeType", "") or "").lower() + if shape_type == "edge": + points.extend(_edge_points(sub_object)) + continue + if shape_type == "vertex": + point = getattr(sub_object, "Point", None) + if point is not None: + points.append(point) + obj = getattr(selection_item, "Object", None) + shape = getattr(obj, "Shape", None) + if shape is not None and not is_template_terminal(obj): + for edge in list(getattr(shape, "Edges", []) or []): + points.extend(_edge_points(edge)) + + normalized = [] + for point in points: + if point is None: + continue + if not normalized or _distance(normalized[-1], point) > 1e-6: + normalized.append(point) + return normalized + + +def _document_point_to_terminal_local(terminal, point): + placement = getattr(terminal, "Placement", None) + if placement is None: + return point + try: + inverse = placement.inverse() + transformed = inverse.multVec(point) + if transformed is not None: + return transformed + except Exception: + pass + base = getattr(placement, "Base", None) + if base is None: + return point + return App.Vector( + float(point.x) - float(base.x), + float(point.y) - float(base.y), + float(point.z) - float(base.z), + ) + + +def set_template_terminal_local_route_points(obj, points): + if not is_template_terminal(obj): + raise TemplateAuthoringError("A valid template terminal is required.") + payload = [] + for point in points or []: + item = _route_point_payload(point) + if item is not None: + payload.append(item) + if len(payload) < 2: + raise TemplateAuthoringError("At least two local route points are required.") + TerminalObjects.ensure_string_property( + obj, + "QetTerminalLocalRoutePointsJson", + TEMPLATE_PROPERTY_GROUP, + "Terminal-local route points used before joining the cabinet routing network", + json.dumps(payload, ensure_ascii=False), + ) + try: + obj.Document.recompute() + except Exception: + pass + return obj + + +def set_selected_template_terminal_local_route_points(selection_ex=None): + if selection_ex is None: + if Gui is None: + raise TemplateAuthoringError("FreeCAD GUI selection is not available.") + try: + selection_ex = Gui.Selection.getSelectionEx() + except Exception: + selection_ex = [] + + terminal = None + route_points = [] + for item in list(selection_ex or []): + obj = getattr(item, "Object", None) + if terminal is None and is_template_terminal(obj): + terminal = obj + continue + route_points.extend(_selection_item_points(item)) + + if terminal is None: + raise TemplateAuthoringError("Select one template terminal before selecting its local route path.") + if len(route_points) < 2: + raise TemplateAuthoringError("Select a sketch, edge, or route path with at least two points.") + + local_points = [ + _document_point_to_terminal_local(terminal, point) + for point in route_points + ] + return set_template_terminal_local_route_points(terminal, local_points) + + def _has_property(obj, prop_name): return prop_name in getattr(obj, "PropertiesList", []) @@ -276,6 +423,30 @@ class CommandValidateTemplateTerminals: App.Console.PrintWarning("[FreeCADExchange] {0}\n".format(warning)) +class CommandSetTemplateTerminalLocalRoute: + def GetResources(self): + return { + "MenuText": "设置端子局部路径", + "ToolTip": "把选中的草图或线段保存为模板端子的局部出线路径", + } + + def IsActive(self): + return App.ActiveDocument is not None and Gui is not None + + def Activated(self): + try: + terminal = set_selected_template_terminal_local_route_points() + App.Console.PrintMessage( + "[FreeCADExchange] Updated local route points for template terminal {0}.\n".format( + getattr(terminal, "QetTemplateSlotName", "") or getattr(terminal, "Name", "") + ) + ) + except Exception as exc: + App.Console.PrintError( + "[FreeCADExchange] setting template terminal local route failed: {0}\n".format(exc) + ) + + class CommandSaveTemplateAsFCStd: def GetResources(self): return { @@ -325,6 +496,7 @@ def register_commands(): return Gui.addCommand("QET_Template_AddTerminal", CommandAddTemplateTerminal()) Gui.addCommand("QET_Template_ValidateTerminals", CommandValidateTemplateTerminals()) + Gui.addCommand("QET_Template_SetTerminalLocalRoute", CommandSetTemplateTerminalLocalRoute()) Gui.addCommand("QET_Template_SaveAsFCStd", CommandSaveTemplateAsFCStd()) _COMMANDS_REGISTERED = True diff --git a/src/Mod/FreeCADExchange/TemplateSemantics.py b/src/Mod/FreeCADExchange/TemplateSemantics.py index b532df0..ff17ad4 100644 --- a/src/Mod/FreeCADExchange/TemplateSemantics.py +++ b/src/Mod/FreeCADExchange/TemplateSemantics.py @@ -123,6 +123,34 @@ def _vector_to_payload(value): return [float(vector.x), float(vector.y), float(vector.z)] +def _route_points_to_payload(value): + if not isinstance(value, (list, tuple)): + return None + points = [] + for item in value: + point = _vector_to_payload(item) + if point is not None: + points.append(point) + return points if points else None + + +def _route_points_from_object(source_object): + if source_object is None: + return None + for property_name in ("QetTerminalLocalRoutePointsJson", "QetLocalRoutePointsJson"): + raw_text = getattr(source_object, property_name, "") + if not isinstance(raw_text, str) or not raw_text.strip(): + continue + try: + parsed = json.loads(raw_text) + except (TypeError, ValueError): + continue + points = _route_points_to_payload(parsed) + if points: + return points + return None + + def _rotation_to_payload(value): if not isinstance(value, dict): return None @@ -161,6 +189,9 @@ def _slot_to_payload(slot): rotation = _rotation_to_payload(slot.get("rotation")) if rotation is not None: payload["rotation"] = rotation + local_route_points = _route_points_to_payload(slot.get("local_route_points")) + if local_route_points is not None: + payload["local_route_points"] = local_route_points return payload @@ -263,6 +294,18 @@ def _slot_from_payload(item, source, index, source_object=None): rotation = placement_rotation if rotation is None: rotation = _rotation_from_object(source_object) + local_route_points = None + for route_points_key in ( + "local_route_points", + "terminal_local_route_points", + "exit_path", + "route_points", + ): + local_route_points = _route_points_to_payload(item.get(route_points_key)) + if local_route_points is not None: + break + if local_route_points is None: + local_route_points = _route_points_from_object(source_object) slot = { "name": name, @@ -273,6 +316,8 @@ def _slot_from_payload(item, source, index, source_object=None): } if rotation is not None: slot["rotation"] = rotation + if local_route_points is not None: + slot["local_route_points"] = local_route_points return slot diff --git a/src/Mod/FreeCADExchange/TerminalImport.py b/src/Mod/FreeCADExchange/TerminalImport.py index e4b6435..88bed6e 100644 --- a/src/Mod/FreeCADExchange/TerminalImport.py +++ b/src/Mod/FreeCADExchange/TerminalImport.py @@ -1,6 +1,7 @@ # FreeCADExchange terminal import helpers. from collections import OrderedDict +import json import FreeCAD as App @@ -144,12 +145,8 @@ def _terminal_belongs_to_payload_devices(entry, device_lookup): return False -def _ensure_visible(obj): - try: - if getattr(obj, "ViewObject", None) is not None: - obj.ViewObject.Visibility = True - except Exception: - pass +def _hide_engineering_terminal(obj): + TerminalObjects.hide_engineering_terminal(obj) def _set_terminal_geometry_source(obj, source): @@ -167,6 +164,21 @@ def _set_terminal_geometry_source(obj, source): ) +def _set_terminal_local_route_points(obj, slot): + points = slot.get("local_route_points") if isinstance(slot, dict) else None + raw_text = "" + if isinstance(points, list) and points: + raw_text = json.dumps(points, ensure_ascii=False) + if raw_text or "QetTerminalLocalRoutePointsJson" in getattr(obj, "PropertiesList", []): + TerminalObjects.ensure_string_property( + obj, + "QetTerminalLocalRoutePointsJson", + "QET Routing", + "Terminal-local route points used before joining the cabinet routing network", + raw_text, + ) + + def _hide_object(obj): try: if getattr(obj, "ViewObject", None) is not None: @@ -339,7 +351,8 @@ def _create_terminal_object(doc, terminal_uuid, entry, slot, terminal_group, pro slot_name=slot.get("name", ""), ) _set_terminal_geometry_source(terminal_obj, slot.get("source", "template")) - _ensure_visible(terminal_obj) + _set_terminal_local_route_points(terminal_obj, slot) + _hide_engineering_terminal(terminal_obj) return terminal_obj @@ -510,11 +523,12 @@ def import_terminals_from_payload(payload, scene_path=""): slot_name=slot.get("name", ""), ) _set_terminal_geometry_source(terminal_obj, slot.get("source", "template")) + _set_terminal_local_route_points(terminal_obj, slot) try: terminal_obj.Placement = _slot_placement(slot) except Exception: pass - _ensure_visible(terminal_obj) + _hide_engineering_terminal(terminal_obj) report["updated_terminals"] += 1 else: terminal_obj = _create_terminal_object( @@ -539,11 +553,12 @@ def import_terminals_from_payload(payload, scene_path=""): slot_name=slot.get("name", ""), ) _set_terminal_geometry_source(terminal_obj, slot.get("source", "template")) + _set_terminal_local_route_points(terminal_obj, slot) try: terminal_obj.Placement = _slot_placement(slot) except Exception: pass - _ensure_visible(terminal_obj) + _hide_engineering_terminal(terminal_obj) report["updated_terminals"] += 1 if terminal_obj not in getattr(terminal_group, "Group", []): diff --git a/tests/python/freecad_exchange_auto_routing_test.py b/tests/python/freecad_exchange_auto_routing_test.py index 923a858..5ab3b89 100644 --- a/tests/python/freecad_exchange_auto_routing_test.py +++ b/tests/python/freecad_exchange_auto_routing_test.py @@ -662,6 +662,57 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual((0.65, 0.2, 1.0), terminal_access.ViewObject.LineColor) self.assertEqual("Solid", terminal_access.ViewObject.DrawStyle) + def test_set_route_carriers_visibility_toggles_only_route_helpers(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + carrier = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], + project_uuid="project-1", + kind="WireDuct", + ) + device = doc.addObject("Part::Feature", "DeviceA") + device.ViewObject.Visibility = True + + hidden = routing_network.set_route_carriers_visibility(doc, False) + self.assertFalse(carrier.ViewObject.Visibility) + shown = routing_network.set_route_carriers_visibility(doc, True) + + self.assertEqual(1, hidden) + self.assertEqual(1, shown) + self.assertTrue(carrier.ViewObject.Visibility) + self.assertTrue(device.ViewObject.Visibility) + + def test_collect_route_carriers_ignores_deleted_object_references(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + carrier = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 0), app.Vector(100, 0, 0)], + project_uuid="project-1", + kind="WireDuct", + ) + + class DeletedObjectReference: + Name = "DeletedCarrier" + + def __getattr__(self, name): + if name == "QetRoutingRole": + raise RuntimeError("Cannot access attribute 'QetRoutingRole' of deleted object") + raise AttributeError(name) + + doc.Objects.append(DeletedObjectReference()) + + carriers = routing_network.collect_route_carriers(doc) + + self.assertEqual([carrier], carriers) + def test_route_carrier_exposes_capacity_property_for_auto_routing(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() @@ -1141,6 +1192,182 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(1, result["wire_duct_carriers"]) self.assertEqual("selection", result["source_mode"]) + def test_generate_routing_paths_uses_selected_route_path_as_user_path(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + gui = sys.modules["FreeCADGui"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + route_path = doc.addObject("Part::Feature", "UserRouteSketch") + route_path.Label = "用户主路径A" + route_path.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 80, 20, 20), + edges=[ + FakeEdge(app.Vector(0, 0, 20), app.Vector(0, 80, 20)), + FakeEdge(app.Vector(0, 80, 20), app.Vector(100, 80, 20)), + ], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], + ) + + result = auto_routing_panel.AutoRoutingController().generate_routing_paths() + carriers = routing_network.collect_route_carriers(doc) + user_paths = [item for item in carriers if item.QetRouteCarrierKind == "UserPath"] + + self.assertEqual(1, result["user_path_carriers"]) + self.assertEqual(1, len(user_paths)) + self.assertEqual("UserRouteSketch", user_paths[0].QetRouteSourceName) + self.assertEqual("用户主路径A", user_paths[0].QetRouteSourceLabel) + self.assertEqual("selection", result["source_mode"]) + + def test_controller_creates_selected_user_paths_without_full_network_generation(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + gui = sys.modules["FreeCADGui"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + route_path = doc.addObject("Part::Feature", "UserRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 80, 20, 20), + edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 20))], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], + ) + + result = auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() + carriers = routing_network.collect_route_carriers(doc) + + self.assertEqual(1, result["user_path_carriers"]) + self.assertEqual(1, result["network"]["kinds"]["UserPath"]) + self.assertEqual(1, len(carriers)) + self.assertEqual("UserPath", carriers[0].QetRouteCarrierKind) + + def test_controller_create_user_paths_reports_removed_stale_source_carriers(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + gui = sys.modules["FreeCADGui"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + route_path = doc.addObject("Part::Feature", "UserRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 80, 20, 20), + edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 20))], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], + ) + auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() + doc.removeObject("UserRouteSketch") + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [], + ) + + result = auto_routing_panel.AutoRoutingController().create_user_paths_from_selection() + + self.assertEqual(1, result["removed_stale_carriers"]) + self.assertEqual(0, result["network"]["carriers"]) + self.assertEqual([], routing_network.collect_route_carriers(doc)) + + def test_terminal_access_uses_terminal_local_route_points_before_main_network(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + terminal = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + terminal.addProperty("App::PropertyString", "QetTerminalLocalRoutePointsJson", "QET Routing", "") + terminal.QetTerminalLocalRoutePointsJson = json.dumps([[0, 0, 0], [10, 0, 0], [10, 30, 0]]) + routing_network.create_route_carrier( + doc, + [app.Vector(10, 80, 0), app.Vector(110, 80, 0)], + project_uuid="project-1", + kind="UserPath", + ) + + created = routing_network.create_terminal_access_carriers_from_document( + doc, + project_uuid="project-1", + terminal_exit_length=20.0, + max_distance=100.0, + ) + + self.assertEqual(1, len(created)) + self.assertEqual( + [(0.0, 0.0, 0.0), (10.0, 0.0, 0.0), (10.0, 30.0, 0.0)], + [(p.x, p.y, p.z) for p in created[0].Points[:3]], + ) + + def test_generate_routing_paths_refreshes_selected_user_path_without_duplicate(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + app = sys.modules["FreeCAD"] + gui = sys.modules["FreeCADGui"] + doc = FakeDocument() + app.ActiveDocument = doc + terminal_objects.ensure_root_group(doc, "project-1") + route_path = doc.addObject("Part::Feature", "UserRouteSketch") + route_path.Shape = FakeShape( + FakeBoundBox(0, 100, 0, 80, 20, 20), + edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(100, 0, 20))], + ) + gui.Selection = types.SimpleNamespace( + getSelection=lambda: [], + getSelectionEx=lambda: [FakeSelectionItem(obj=route_path)], + ) + + first = auto_routing_panel.AutoRoutingController().generate_routing_paths() + route_path.Shape = FakeShape( + FakeBoundBox(0, 200, 0, 80, 20, 20), + edges=[FakeEdge(app.Vector(0, 0, 20), app.Vector(200, 0, 20))], + ) + second = auto_routing_panel.AutoRoutingController().generate_routing_paths() + user_paths = [ + item + for item in routing_network.collect_route_carriers(doc) + if item.QetRouteCarrierKind == "UserPath" + ] + + self.assertEqual(1, first["user_path_carriers"]) + self.assertEqual(0, second["user_path_carriers"]) + self.assertEqual(1, len(user_paths)) + self.assertEqual([(0.0, 0.0, 20.0), (200.0, 0.0, 20.0)], [(p.x, p.y, p.z) for p in user_paths[0].Points]) + + def test_eplan_connection_route_can_use_generated_user_path(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(200, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(200, 0, 20)], + project_uuid="project-1", + kind="UserPath", + ) + + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + + self.assertEqual("Routed", result["route_status"]) + self.assertIn("UserPath", result["route_track"]["carrier_kinds"]) + def test_generate_routing_paths_does_not_duplicate_selected_wire_duct_carriers(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, _auto_routing = _reload_modules() @@ -1814,6 +2041,39 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual("z", report["routes"][1]["lane"]["axis"]) self.assertEqual(8.0, report["routes"][1]["lane"]["offset_mm"]) + def test_auto_routing_panel_command_button_style_keeps_text_visible(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, _auto_routing = _reload_modules() + auto_routing_panel = importlib.import_module("AutoRoutingPanel") + + class FakeButton: + def __init__(self): + self.text = "" + self.tooltip = "" + self.minimum_height = 0 + self.stylesheet = "" + + def setText(self, text): + self.text = text + + def setToolTip(self, tooltip): + self.tooltip = tooltip + + def setMinimumHeight(self, height): + self.minimum_height = height + + def setStyleSheet(self, stylesheet): + self.stylesheet = stylesheet + + button = FakeButton() + + auto_routing_panel._style_command_button(button, "生成布线连接", "按导线任务布线") + + self.assertEqual("生成布线连接", button.text) + self.assertEqual("按导线任务布线", button.tooltip) + self.assertGreaterEqual(button.minimum_height, 28) + self.assertIn("color", button.stylesheet) + def test_eplan_connection_route_rejects_far_network_entry_to_avoid_huge_render_bbox(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() @@ -2208,6 +2468,151 @@ class AutoRoutingTest(unittest.TestCase): self.assertTrue(report["missing_endpoint_samples"][0]["start_found"]) self.assertFalse(report["missing_endpoint_samples"][0]["end_found"]) + def test_route_eplan_connections_from_payload_skips_resolved_tasks_without_route_network(self): + _install_fake_freecad() + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-{0}".format(index), + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + for index in range(3) + ], + } + original_route = auto_routing.route_eplan_connection_between_terminals + + def fail_if_called(*_args, **_kwargs): + raise AssertionError("batch route must not call per-wire routing without route carriers") + + auto_routing.route_eplan_connection_between_terminals = fail_if_called + try: + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + finally: + auto_routing.route_eplan_connection_between_terminals = original_route + + self.assertEqual(0, report["routed"]) + self.assertEqual(3, report["skipped_missing_route_network"]) + self.assertEqual(3, report["route_status_counts"]["MissingRouteNetwork"]) + self.assertEqual([], report["errors"]) + self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc)) + + def test_route_eplan_connection_tasks_marks_task_missing_route_network_when_skipped(self): + _install_fake_freecad() + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + task = wiring_objects.create_wire_task( + doc, + "project-1", + "wire-missing-network", + "N1", + "terminal-start", + "terminal-end", + "instance-a", + "instance-b", + ) + task.RouteStatus = "Routed" + + report = auto_routing.route_eplan_connection_tasks(doc) + + self.assertEqual(0, report["routed"]) + self.assertEqual(1, report["skipped_missing_route_network"]) + self.assertEqual("MissingRouteNetwork", task.RouteStatus) + + def test_eplan_connection_route_prefers_wire_duct_over_shorter_routing_range(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + start = _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + end = _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(300, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(300, 0, 20)], + project_uuid="project-1", + kind="RoutingRange", + ) + routing_network.create_route_carrier( + doc, + [ + app.Vector(0, 0, 20), + app.Vector(0, 1200, 20), + app.Vector(300, 1200, 20), + app.Vector(300, 0, 20), + ], + project_uuid="project-1", + kind="WireDuct", + ) + + result = auto_routing.route_eplan_connection_between_terminals(doc, start, end) + + self.assertIn("WireDuct", result["route_track"]["carrier_kinds"]) + self.assertNotIn("RoutingRange", result["route_track"]["carrier_kinds"]) + + def test_route_eplan_connections_from_payload_skips_tasks_when_carriers_have_no_segments(self): + _install_fake_freecad() + terminal_objects, wiring_objects, _routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + broken_carrier = doc.addObject("Part::Feature", "BrokenCarrier") + terminal_objects.ensure_string_property( + broken_carrier, + "QetRoutingRole", + "QET Routing", + "Routing role marker", + "RoutingCarrier", + ) + terminal_objects.ensure_string_property( + broken_carrier, + "QetRouteCarrierKind", + "QET Routing", + "Route carrier kind", + "WireDuct", + ) + terminal_objects.ensure_bool_property( + broken_carrier, + "CanRouteWire", + "QET Routing", + "Whether routing connections can use this path", + True, + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + + self.assertEqual(1, report["route_network_carriers"]) + self.assertEqual(0, report["route_network_segments"]) + self.assertEqual(0, report["route_network_nodes"]) + self.assertEqual(0, report["routed"]) + self.assertEqual(1, report["skipped_missing_route_network"]) + self.assertEqual(1, report["route_status_counts"]["MissingRouteNetwork"]) + self.assertEqual([], report["errors"]) + self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc)) + def test_route_eplan_connections_writes_diagnostic_object_for_missing_terminal(self): _install_fake_freecad() terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() @@ -2236,6 +2641,57 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual("RoutingConnectionBatch", diagnostic.QetDiagnosticKind) self.assertIn("terminal-missing", diagnostic.QetDiagnosticJson) + def test_route_eplan_connections_writes_compact_batch_diagnostic(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 20, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 20, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 20, 20), app.Vector(100, 20, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "wire_label": "N1", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + { + "wire_id": "wire-b", + "wire_label": "N2", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + diagnostic_payload = json.loads(diagnostic_group.Group[0].QetDiagnosticJson) + + self.assertEqual(2, len(report["routes"])) + self.assertNotIn("routes", diagnostic_payload) + self.assertEqual(2, diagnostic_payload["route_sample_count"]) + self.assertEqual(2, len(diagnostic_payload["route_samples"])) + self.assertEqual("wire-a", diagnostic_payload["route_samples"][0]["wire_uuid"]) + self.assertEqual("Routed", diagnostic_payload["route_samples"][0]["route_status"]) + def test_route_eplan_connections_reports_total_connection_route_length(self): _install_fake_freecad() terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() @@ -2268,6 +2724,164 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(report["total_length_mm"], report["routes"][0]["length_mm"]) self.assertIn("总长度", message) + def test_route_eplan_connections_hides_route_carriers_after_routing_by_default(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + carrier = routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-1", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + } + ], + } + + report = auto_routing.route_eplan_connections( + doc, + payload=payload, + update_network=False, + ) + + self.assertEqual(1, report["routed"]) + self.assertEqual(1, report["hidden_route_carriers"]) + self.assertFalse(carrier.ViewObject.Visibility) + + def test_route_eplan_connections_batch_recomputes_once_after_created_wires(self): + _install_fake_freecad() + terminal_objects, _wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStartA", "terminal-start-a", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEndA", "terminal-end-a", app.Vector(100, 0, 0)) + _terminal(doc, terminal_objects, "TerminalStartB", "terminal-start-b", app.Vector(0, 10, 0)) + _terminal(doc, terminal_objects, "TerminalEndB", "terminal-end-b", app.Vector(100, 10, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 10, 20), app.Vector(100, 10, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + recompute_count = {"value": 0} + + def count_recompute(): + recompute_count["value"] += 1 + + doc.recompute = count_recompute + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-a", + "start_terminal_uuid": "terminal-start-a", + "end_terminal_uuid": "terminal-end-a", + }, + { + "wire_id": "wire-b", + "start_terminal_uuid": "terminal-start-b", + "end_terminal_uuid": "terminal-end-b", + }, + ], + } + + report = auto_routing.route_eplan_connections_from_payload(doc, payload) + + self.assertEqual(2, report["routed"]) + self.assertEqual(1, recompute_count["value"]) + + def test_route_eplan_connections_replaces_existing_routed_wires_for_same_batch(self): + _install_fake_freecad() + terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + payload = { + "project_uuid": "project-1", + "wires": [ + { + "wire_id": "wire-repeat", + "start_terminal_uuid": "terminal-start", + "end_terminal_uuid": "terminal-end", + }, + ], + } + + first = auto_routing.route_eplan_connections_from_payload(doc, payload) + second = auto_routing.route_eplan_connections_from_payload(doc, payload) + routed_wires = list(wiring_objects.iter_routed_wire_objects(doc)) + + self.assertEqual(1, first["routed"]) + self.assertEqual(1, second["routed"]) + self.assertEqual(1, second["replaced_routed_connections"]) + self.assertEqual(1, len(routed_wires)) + self.assertEqual("wire-repeat", routed_wires[0].QetWireUuid) + + def test_clear_routing_connections_resets_task_status_and_batch_diagnostics(self): + _install_fake_freecad() + terminal_objects, wiring_objects, routing_network, auto_routing = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal_objects.ensure_root_group(doc, "project-1") + _terminal(doc, terminal_objects, "TerminalStart", "terminal-start", app.Vector(0, 0, 0)) + _terminal(doc, terminal_objects, "TerminalEnd", "terminal-end", app.Vector(100, 0, 0)) + routing_network.create_route_carrier( + doc, + [app.Vector(0, 0, 20), app.Vector(100, 0, 20)], + project_uuid="project-1", + kind="WireDuct", + ) + task = wiring_objects.create_wire_task( + doc, + "project-1", + "wire-clear", + "N1", + "terminal-start", + "terminal-end", + "instance-a", + "instance-b", + ) + + report = auto_routing.route_eplan_connection_tasks(doc) + diagnostic_group = doc.getObject("QETWiring_05_Diagnostics") + + self.assertEqual(1, report["routed"]) + self.assertEqual("Routed", task.RouteStatus) + self.assertEqual(1, len(list(getattr(diagnostic_group, "Group", []) or []))) + + removed = auto_routing.clear_routing_connections(doc) + + self.assertEqual(1, removed) + self.assertEqual("Task", task.RouteStatus) + self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc)) + self.assertEqual([], list(getattr(diagnostic_group, "Group", []) or [])) + def test_route_report_includes_route_source_sample_when_available(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() @@ -2332,6 +2946,69 @@ class AutoRoutingTest(unittest.TestCase): self.assertIn("并行错位:最大 lane 2,间距 10.0 mm。", message) + def test_route_report_includes_replaced_routed_connection_count(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "replaced_routed_connections": 2, + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("已替换旧布线连接:2 条。", message) + + def test_route_report_includes_hidden_route_carrier_count(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 1, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "hidden_route_carriers": 3, + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("已隐藏走线路径辅助对象:3 条。", message) + + def test_route_report_warns_when_routes_use_surface_or_auxiliary_paths(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "routed": 2, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "routes": [ + { + "wire_label": "N1", + "route_track": { + "carrier_kinds": { + "TerminalAccess": 2, + "WireDuct": 1, + "RoutingRange": 2, + }, + }, + }, + { + "wire_label": "N2", + "route_track": { + "carrier_kinds": { + "TerminalAccess": 2, + "WireDuct": 3, + }, + }, + }, + ], + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("路径质量提示:1 条导线使用布线面/辅助路径", message) + self.assertIn("示例 N1 使用布线面。", message) + def test_route_report_warns_when_parallel_lanes_exceed_track_capacity(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() @@ -2737,6 +3414,7 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(0, report["routes"][0]["lane"]["index"]) self.assertEqual(1, report["routes"][1]["lane"]["index"]) routed_group = doc.getObject("QETWiring_04_Routed") + self.assertEqual(2, len(list(getattr(routed_group, "Group", []) or []))) second_wire = [ wire for wire in list(getattr(routed_group, "Group", []) or []) @@ -3101,6 +3779,25 @@ class AutoRoutingTest(unittest.TestCase): self.assertIn("首个错误:没有可用的线槽/路由路径网络", message) self.assertIn("缺失示例:terminal-a -> terminal-b", message) + def test_route_eplan_connections_report_calls_out_missing_route_network(self): + _install_fake_freecad() + _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() + report = { + "total_wires": 3, + "routed": 0, + "collision_warnings": 0, + "skipped_missing_terminal": 0, + "skipped_missing_route_network": 3, + "route_status_counts": { + "MissingRouteNetwork": 3, + }, + } + + message = auto_routing.format_eplan_connection_route_report(report) + + self.assertIn("缺少布线路径网络 3 条", message) + self.assertIn("请先生成线槽、布线面或布线路径网络", message) + def test_route_eplan_connections_report_includes_readable_missing_endpoint_labels(self): _install_fake_freecad() _terminal_objects, _wiring_objects, _routing_network, auto_routing = _reload_modules() @@ -3249,6 +3946,8 @@ class AutoRoutingTest(unittest.TestCase): self.assertEqual(0, report["local_terminals"]) self.assertEqual([], wiring_objects.iter_routed_wire_objects(doc)) self.assertEqual("qet", indexed["qet-terminal-p1"].QetTerminalBindingMode) + self.assertFalse(indexed["qet-terminal-p1"].ViewObject.Visibility) + self.assertFalse(indexed["qet-terminal-p2"].ViewObject.Visibility) def test_route_eplan_connections_rebinds_local_template_terminals_from_wire_endpoints(self): _install_fake_freecad() @@ -3324,6 +4023,8 @@ class AutoRoutingTest(unittest.TestCase): self.assertIn("qet-terminal-p1", indexed) self.assertIn("qet-terminal-p2", indexed) self.assertEqual("qet", indexed["qet-terminal-p1"].QetTerminalBindingMode) + self.assertFalse(indexed["qet-terminal-p1"].ViewObject.Visibility) + self.assertFalse(indexed["qet-terminal-p2"].ViewObject.Visibility) def test_clear_route_carriers_keeps_routed_wires(self): _install_fake_freecad() diff --git a/tests/python/freecad_exchange_device_import_fcstd_test.py b/tests/python/freecad_exchange_device_import_fcstd_test.py index 0326859..bbfcc2a 100644 --- a/tests/python/freecad_exchange_device_import_fcstd_test.py +++ b/tests/python/freecad_exchange_device_import_fcstd_test.py @@ -476,6 +476,8 @@ class FcstdDeviceImportTest(unittest.TestCase): terminal.Role = "Terminal" terminal.addProperty("App::PropertyString", "QetTemplateSlotName", "QET Template", "") terminal.QetTemplateSlotName = "D1" + terminal.addProperty("App::PropertyString", "QetTerminalLocalRoutePointsJson", "QET Template", "") + terminal.QetTerminalLocalRoutePointsJson = "[[0,0,0],[6,0,0],[6,18,0]]" x_axis = source.addObject("App::Line", "Terminal_D1_XAxis") terminal.OriginFeatures = [x_axis] @@ -504,6 +506,10 @@ class FcstdDeviceImportTest(unittest.TestCase): self.assertEqual("D1", slots[0]["name"]) self.assertEqual(11.0, slots[0]["base"].x) self.assertEqual(90.0, slots[0]["rotation"]["angle"]) + self.assertEqual( + [[0.0, 0.0, 0.0], [6.0, 0.0, 0.0], [6.0, 18.0, 0.0]], + slots[0]["local_route_points"], + ) self.assertIsNone(slots[0]["source_object"]) def test_fcstd_import_keeps_link_dependencies_out_of_device_group(self): diff --git a/tests/python/freecad_exchange_template_authoring_test.py b/tests/python/freecad_exchange_template_authoring_test.py index 2c4a839..6556cc0 100644 --- a/tests/python/freecad_exchange_template_authoring_test.py +++ b/tests/python/freecad_exchange_template_authoring_test.py @@ -1,4 +1,5 @@ import importlib +import json import sys import types import unittest @@ -88,6 +89,30 @@ class FakeObject: self.Group.append(child) +class FakeVertex: + def __init__(self, point): + self.Point = point + + +class FakeEdge: + ShapeType = "Edge" + + def __init__(self, start, end): + self.Vertexes = [FakeVertex(start), FakeVertex(end)] + + +class FakeShape: + def __init__(self, edges=None): + self.Edges = edges or [] + + +class FakeSelectionItem: + def __init__(self, obj=None, sub_objects=None, picked_points=None): + self.Object = obj + self.SubObjects = sub_objects or [] + self.PickedPoints = picked_points or [] + + class FakeDocument: def __init__(self): self.Name = "TemplateDoc" @@ -132,6 +157,10 @@ class TemplateAuthoringTest(unittest.TestCase): "校验模板端子", template_authoring.CommandValidateTemplateTerminals().GetResources()["MenuText"], ) + self.assertEqual( + "设置端子局部路径", + template_authoring.CommandSetTemplateTerminalLocalRoute().GetResources()["MenuText"], + ) self.assertEqual( "保存模板为 FCStd", template_authoring.CommandSaveTemplateAsFCStd().GetResources()["MenuText"], @@ -169,6 +198,54 @@ class TemplateAuthoringTest(unittest.TestCase): self.assertEqual(30.0, terminal.Placement.Base.z) self.assertTrue(doc.recomputed) + def test_set_template_terminal_local_route_points_writes_json_property(self): + _install_fake_freecad() + template_authoring = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal = template_authoring.create_template_terminal(doc, "P1", app.Vector(1, 2, 3)) + + template_authoring.set_template_terminal_local_route_points( + terminal, + [ + app.Vector(0, 0, 0), + [8, 0, 0], + {"x": 8, "y": 20, "z": 0}, + ], + ) + + self.assertIn("QetTerminalLocalRoutePointsJson", terminal.PropertiesList) + self.assertEqual( + [[0.0, 0.0, 0.0], [8.0, 0.0, 0.0], [8.0, 20.0, 0.0]], + json.loads(terminal.QetTerminalLocalRoutePointsJson), + ) + + def test_set_selected_template_terminal_local_route_points_uses_terminal_local_coordinates(self): + _install_fake_freecad() + template_authoring = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + terminal = template_authoring.create_template_terminal(doc, "P1", app.Vector(10, 0, 0)) + path = doc.addObject("Part::Feature", "LocalExitPath") + path.Shape = FakeShape( + edges=[ + FakeEdge(app.Vector(10, 0, 0), app.Vector(20, 0, 0)), + FakeEdge(app.Vector(20, 0, 0), app.Vector(20, 15, 0)), + ] + ) + + template_authoring.set_selected_template_terminal_local_route_points( + [ + FakeSelectionItem(obj=terminal), + FakeSelectionItem(obj=path), + ] + ) + + self.assertEqual( + [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 15.0, 0.0]], + json.loads(terminal.QetTerminalLocalRoutePointsJson), + ) + def test_validate_template_terminals_reports_missing_slot_name(self): _install_fake_freecad() template_authoring = _reload_modules() diff --git a/tests/python/freecad_exchange_template_semantics_test.py b/tests/python/freecad_exchange_template_semantics_test.py index 444cf25..6292f63 100644 --- a/tests/python/freecad_exchange_template_semantics_test.py +++ b/tests/python/freecad_exchange_template_semantics_test.py @@ -153,6 +153,40 @@ class TemplateSemanticsRotationTest(unittest.TestCase): self.assertEqual(0.0, slots[0]["rotation"]["axis"].y) self.assertEqual(1.0, slots[0]["rotation"]["axis"].z) + def test_sidecar_keeps_terminal_local_route_points(self): + _install_fake_freecad() + template_semantics, _ = _reload_exchange_modules() + + with tempfile.TemporaryDirectory() as temp_dir: + model_path = Path(temp_dir) / "Relay.step" + model_path.write_text("", encoding="utf-8") + sidecar_path = Path(temp_dir) / "Relay.qet_template.json" + sidecar_path.write_text( + json.dumps( + { + "terminal_slots": [ + { + "name": "A1", + "position": [10, 20, 30], + "local_route_points": [ + [0, 0, 0], + {"x": 12, "y": 0, "z": 0}, + [12, 25, 0], + ], + } + ] + } + ), + encoding="utf-8", + ) + + slots = template_semantics.load_sidecar_terminal_slots(str(model_path)) + + self.assertEqual( + [[0.0, 0.0, 0.0], [12.0, 0.0, 0.0], [12.0, 25.0, 0.0]], + slots[0]["local_route_points"], + ) + class TerminalSlotResolutionPolicyTest(unittest.TestCase): def test_resolve_terminal_slots_returns_bbox_fallback_when_model_has_no_template_slots(self): diff --git a/tests/python/freecad_exchange_terminal_import_template_slots_test.py b/tests/python/freecad_exchange_terminal_import_template_slots_test.py index 7636713..d6e09b1 100644 --- a/tests/python/freecad_exchange_terminal_import_template_slots_test.py +++ b/tests/python/freecad_exchange_terminal_import_template_slots_test.py @@ -1,4 +1,5 @@ import importlib +import json import sys import types import unittest @@ -191,6 +192,7 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase): self.assertEqual(1, len(terminals)) self.assertEqual("terminal-a", terminals[0].QetTerminalUuid) self.assertEqual("generated_bbox_fallback", terminals[0].QetTerminalGeometrySource) + self.assertFalse(terminals[0].ViewObject.Visibility) def test_import_preserves_local_terminals_when_payload_has_no_entry_for_device(self): _install_fake_freecad() @@ -524,6 +526,91 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase): self.assertEqual(2, len(terminals)) self.assertEqual(20.0, terminals["terminal-p2"].Placement.Base.x) self.assertEqual(10.0, terminals["terminal-p1"].Placement.Base.x) + self.assertFalse(terminals["terminal-p2"].ViewObject.Visibility) + self.assertFalse(terminals["terminal-p1"].ViewObject.Visibility) + + def test_import_copies_template_slot_local_route_points_to_terminal(self): + _install_fake_freecad() + terminal_import, terminal_objects, device_import = _reload_modules() + + doc = FakeDocument() + device_import._ensure_document = lambda scene_path: doc + root = device_import._ensure_root_group(doc, project_uuid="project-1") + device = doc.addObject("App::Part", "QETDevice_device_a") + root.addObject(device) + terminal_objects.ensure_string_property( + device, + "QetProjectUuid", + "QET Exchange", + "Project UUID", + "project-1", + ) + terminal_objects.ensure_string_property( + device, + "QetElementUuid", + "QET Exchange", + "Element UUID", + "device-a", + ) + terminal_objects.ensure_string_property( + device, + "QetInstanceId", + "QET Exchange", + "Instance ID", + "instance-a", + ) + terminal_objects.ensure_string_property( + device, + "QetTemplateSlotsJson", + "QET Exchange", + "Stored template slots", + json.dumps( + { + "terminal_slots": [ + { + "name": "P1", + "label": "P1", + "base": [10, 0, 0], + "local_route_points": [[0, 0, 0], [8, 0, 0], [8, 20, 0]], + } + ] + } + ), + ) + + report = terminal_import.import_terminals_from_payload( + { + "project_uuid": "project-1", + "devices": [ + { + "element_uuid": "device-a", + "instance_id": "instance-a", + } + ], + "terminals": [ + { + "terminal_uuid": "terminal-p1", + "element_uuid": "device-a", + "instance_id": "instance-a", + "terminal_display": "P1", + }, + ], + } + ) + + terminal_group = terminal_objects.find_child_group_by_kind( + device, + terminal_objects.TERMINAL_GROUP_KIND, + ) + terminals = terminal_objects.collect_terminal_objects(terminal_group) + + self.assertEqual(1, report["imported_terminals"]) + self.assertEqual(1, len(terminals)) + self.assertIn("QetTerminalLocalRoutePointsJson", terminals[0].PropertiesList) + self.assertEqual( + [[0.0, 0.0, 0.0], [8.0, 0.0, 0.0], [8.0, 20.0, 0.0]], + json.loads(terminals[0].QetTerminalLocalRoutePointsJson), + ) def test_import_rebinds_existing_local_terminal_on_matching_template_slot(self): _install_fake_freecad() @@ -619,6 +706,7 @@ class TerminalImportTemplateSlotPolicyTest(unittest.TestCase): self.assertEqual("terminal-real-p1", local_terminal.QetTerminalUuid) self.assertEqual("instance-a", local_terminal.QetInstanceId) self.assertEqual(15.0, local_terminal.Placement.Base.x) + self.assertFalse(local_terminal.ViewObject.Visibility) if __name__ == "__main__":